feat(search): global conversation search in the topbar with deep-link nav

Topbar search box → debounced crm.search → dropdown of hits (chat/mail, highlighted
snippet). Clicking a hit switches to the right tab and focuses the exact thread:
mail → Inbox, chat → Messenger (via the SDK's new focusThreadId, 0.1.6). Snippet HTML
is escaped except the <em> highlight. Demo mode searches an in-memory set.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 14:07:26 +05:30
parent c874a726ef
commit 904d003d32
13 changed files with 1242 additions and 105 deletions
+50
View File
@@ -0,0 +1,50 @@
"use client";
// Global conversation search. Live path calls the be-crm data door (crm.search → IIOS Meilisearch,
// permission-scoped there); demo path filters a small in-memory set. Search is imperative (the query
// changes on every keystroke), so it uses sdk.query directly rather than the cached useQuery hook.
import { useCallback } from "react";
import { useAppShell } from "@abe-kap/appshell-sdk/react";
import { isShellConfigured } from "./appshell";
export interface SearchResult {
interactionId: string;
threadId: string;
surface: "messenger" | "inbox";
title: string;
/** Snippet with <em>…</em> around the matched terms. */
snippet: string;
at: number;
}
export type SearchFn = (query: string) => Promise<SearchResult[]>;
const MOCK: SearchResult[] = [
{ interactionId: "s1", threadId: "th_mock_1", surface: "messenger", title: "Sofia Ramirez", snippet: "Can you confirm the <em>Henderson</em> scope?", at: Date.now() },
{ interactionId: "s2", threadId: "th_mock_2", surface: "messenger", title: "Storm response — East side", snippet: "Crew is <em>rolling</em> out at 7", at: Date.now() },
{ interactionId: "s3", threadId: "mt_invoice", surface: "inbox", title: "Invoice #1042 — Acme Roofing", snippet: "Attached is <em>invoice</em> #1042 for the East-side job", at: Date.now() },
];
const SHELL = isShellConfigured();
function useLiveSearch(): SearchFn {
const { sdk } = useAppShell();
return useCallback(async (query: string) => {
const q = query.trim();
if (!q) return [];
return sdk.query<SearchResult[]>("crm.search", { query: q, limit: 20 });
}, [sdk]);
}
function useMockSearch(): SearchFn {
return useCallback(async (query: string) => {
const q = query.trim().toLowerCase();
if (!q) return [];
return MOCK.filter((m) => (m.title + " " + m.snippet).toLowerCase().includes(q));
}, []);
}
export function useGlobalSearch(): SearchFn {
return SHELL ? useLiveSearch() : useMockSearch();
}