@saadnvd1/agent-os 0.3.673 → 0.3.680

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +2 -1
  2. package/app/api/bus/read/route.ts +34 -0
  3. package/app/api/search/conversations/context/route.ts +26 -0
  4. package/app/api/search/conversations/route.test.ts +139 -0
  5. package/app/api/search/conversations/route.ts +25 -0
  6. package/app/globals.css +25 -0
  7. package/components/Bookmarks/BookmarksDialog.tsx +2 -8
  8. package/components/Chat/useGoToItem.ts +11 -0
  9. package/components/CommandPalette/CommandPalette.tsx +23 -156
  10. package/components/CommandPalette/CommandsBody.tsx +216 -0
  11. package/components/CommandPalette/conversations/ConversationSearch.tsx +246 -0
  12. package/components/CommandPalette/conversations/EmptyState.tsx +95 -0
  13. package/components/CommandPalette/conversations/Preview.tsx +129 -0
  14. package/components/CommandPalette/conversations/ResultList.tsx +91 -0
  15. package/components/CommandPalette/conversations/Snippet.tsx +24 -0
  16. package/components/CommandPalette/conversations/format.ts +30 -0
  17. package/components/CommandPalette/conversations/recent.test.ts +20 -0
  18. package/components/CommandPalette/conversations/recent.ts +35 -0
  19. package/components/CommandPalette/conversations/useOpenHit.ts +30 -0
  20. package/data/search/index.ts +65 -0
  21. package/data/search/keys.ts +7 -0
  22. package/hooks/useDebouncedValue.ts +11 -0
  23. package/lib/agents/brief.ts +8 -0
  24. package/lib/bus/condense.ts +242 -0
  25. package/lib/bus/read-scope.ts +109 -0
  26. package/lib/bus/read.ts +181 -0
  27. package/lib/chat/worker/host.ts +3 -1
  28. package/lib/db/migrations/20261019T1500_conversation_search.ts +12 -0
  29. package/lib/demo-reseed.ts +4 -0
  30. package/lib/health/facts.ts +13 -3
  31. package/lib/health/spin-tick.ts +293 -0
  32. package/lib/health/spin.ts +158 -0
  33. package/lib/health/tick.ts +10 -2
  34. package/lib/health/types.ts +2 -0
  35. package/lib/orchestrator/read.ts +18 -4
  36. package/lib/palette/registry.ts +4 -0
  37. package/lib/resurrect/index.ts +2 -3
  38. package/lib/search/conversations/backfill.ts +117 -0
  39. package/lib/search/conversations/buildMatchQuery.ts +31 -0
  40. package/lib/search/conversations/index.ts +9 -0
  41. package/lib/search/conversations/messageContext.ts +45 -0
  42. package/lib/search/conversations/schema.ts +71 -0
  43. package/lib/search/conversations/searchConversations.ts +151 -0
  44. package/lib/search/conversations/types.ts +45 -0
  45. package/lib/sidebar/row-actions.ts +19 -8
  46. package/lib/sidebar/row-menu.ts +1 -1
  47. package/lib/tasks/restorable.ts +16 -0
  48. package/package.json +1 -1
  49. package/scripts/bench-conversation-search.ts +73 -0
  50. package/server.ts +3 -0
  51. package/stores/chatJump.ts +10 -0
  52. package/stores/palette.ts +22 -4
@@ -0,0 +1,216 @@
1
+ "use client";
2
+
3
+ import { useEffect, useMemo, useRef, useState } from "react";
4
+ import { useSnapshot } from "valtio";
5
+ import { MessagesSquare, Search, type LucideIcon } from "lucide-react";
6
+ import { matchCommands, type PaletteCommand } from "@/lib/palette/registry";
7
+ import { cn } from "@/lib/utils";
8
+ import { MIN_QUERY, useConversationSearch } from "@/data/search";
9
+ import { useDebouncedValue } from "@/hooks/useDebouncedValue";
10
+ import { usePaletteList } from "@/hooks/usePaletteCommands";
11
+ import { useSelectedWorkspace } from "@/hooks/useSelectedWorkspace";
12
+ import { paletteActions, paletteUi } from "@/stores/palette";
13
+ import { plain } from "./conversations/format";
14
+ import { useOpenHit } from "./conversations/useOpenHit";
15
+
16
+ // How many of a long group (sessions) show before a query narrows it.
17
+ const GROUP_PREVIEW = 8;
18
+ // Conversation matches offered beside the commands.
19
+ const CONVERSATIONS = 3;
20
+
21
+ export function CommandsBody() {
22
+ const commands = usePaletteList();
23
+ // Subscribed for changes, read raw: its items are held by ref.
24
+ useSnapshot(paletteUi);
25
+ const picker = paletteUi.picker;
26
+ const all = picker?.items ?? commands;
27
+ const [query, setQuery] = useState(paletteUi.query);
28
+ const [active, setActive] = useState(0);
29
+ const listRef = useRef<HTMLDivElement>(null);
30
+
31
+ // Conversations that match, under the commands (never in a picker).
32
+ const { workspace } = useSelectedWorkspace();
33
+ const debounced = useDebouncedValue(query.trim(), 150);
34
+ const openHit = useOpenHit();
35
+ const { data: found } = useConversationSearch(
36
+ debounced,
37
+ workspace?.id ?? "all",
38
+ {
39
+ limit: CONVERSATIONS,
40
+ enabled: !picker,
41
+ }
42
+ );
43
+ const conversations = useMemo<PaletteCommand[]>(() => {
44
+ const q = query.trim();
45
+ if (picker || q.length < MIN_QUERY || q !== debounced) return [];
46
+ const hits = (found?.groups ?? []).flatMap((group) =>
47
+ group.hits.slice(0, 1).map((hit) => ({
48
+ id: `conversation:${group.sessionId}:${hit.itemId}`,
49
+ title: group.name,
50
+ detail: plain(hit.snippet),
51
+ group: "Conversations",
52
+ icon: MessagesSquare,
53
+ run: () => openHit(group, hit),
54
+ keepOpen: true,
55
+ }))
56
+ );
57
+ return [
58
+ ...hits,
59
+ {
60
+ id: "conversation:all",
61
+ title: `Search all conversations for “${q}”`,
62
+ group: "Conversations",
63
+ icon: Search,
64
+ hint: "tab",
65
+ keepOpen: true,
66
+ run: () => paletteActions.setMode("conversations", q),
67
+ },
68
+ ];
69
+ }, [found, query, debounced, picker, openHit]);
70
+
71
+ const results = useMemo(() => {
72
+ if (query.trim()) return [...matchCommands(all, query), ...conversations];
73
+ // A picker shows every choice; it's the whole list.
74
+ if (picker) return all;
75
+ // With no query, long groups are cut short so every group shows.
76
+ const seen = new Map<string, number>();
77
+ return all.filter((c) => {
78
+ const n = (seen.get(c.group) ?? 0) + 1;
79
+ seen.set(c.group, n);
80
+ return n <= GROUP_PREVIEW;
81
+ });
82
+ }, [all, query, picker, conversations]);
83
+ const highlighted = Math.min(active, Math.max(results.length - 1, 0));
84
+
85
+ useEffect(() => {
86
+ listRef.current
87
+ ?.querySelector(`[data-index="${highlighted}"]`)
88
+ ?.scrollIntoView({ block: "nearest" });
89
+ }, [highlighted]);
90
+
91
+ const run = (c: PaletteCommand | undefined) => {
92
+ if (!c) return;
93
+ if (c.keepOpen) return c.run();
94
+ paletteActions.setOpen(false);
95
+ // After the dialog lets go of focus, so what it opens can take it.
96
+ setTimeout(() => c.run(), 0);
97
+ };
98
+
99
+ // Under their headings with no query; one ranked list with one.
100
+ const groups: { name: string; items: { c: PaletteCommand; i: number }[] }[] =
101
+ [];
102
+ results.forEach((c, i) => {
103
+ const name = query.trim() && c.group !== "Conversations" ? "" : c.group;
104
+ let g = groups.find((x) => x.name === name);
105
+ if (!g) groups.push((g = { name, items: [] }));
106
+ g.items.push({ c, i });
107
+ });
108
+
109
+ return (
110
+ <>
111
+ <div className="border-foreground/[0.06] flex items-center gap-2 border-b px-3">
112
+ <Search className="text-muted-foreground h-4 w-4 shrink-0" />
113
+ <input
114
+ autoFocus
115
+ value={query}
116
+ onChange={(e) => {
117
+ setQuery(e.target.value);
118
+ setActive(0);
119
+ }}
120
+ onKeyDown={(e) => {
121
+ if (e.key === "ArrowDown" || e.key === "ArrowUp") {
122
+ e.preventDefault();
123
+ const n = results.length || 1;
124
+ setActive(
125
+ (highlighted + (e.key === "ArrowDown" ? 1 : -1) + n) % n
126
+ );
127
+ } else if (e.key === "Tab" && !picker) {
128
+ e.preventDefault();
129
+ paletteActions.setMode("conversations", query);
130
+ } else if (e.key === "Enter") {
131
+ e.preventDefault();
132
+ run(results[highlighted]);
133
+ } else if (
134
+ picker &&
135
+ (e.metaKey || e.ctrlKey) &&
136
+ /^[1-9]$/.test(e.key)
137
+ ) {
138
+ e.preventDefault();
139
+ run(picker.items[Number(e.key) - 1]);
140
+ }
141
+ }}
142
+ placeholder={
143
+ picker?.placeholder ?? "Search actions, sessions and conversations…"
144
+ }
145
+ aria-label={
146
+ picker?.placeholder ?? "Search actions, sessions and conversations"
147
+ }
148
+ className="placeholder:text-muted-foreground h-12 min-w-0 flex-1 bg-transparent text-base outline-none sm:text-sm"
149
+ />
150
+ <kbd className="text-muted-foreground hidden font-mono text-[10px] sm:inline">
151
+ esc
152
+ </kbd>
153
+ </div>
154
+ <div
155
+ ref={listRef}
156
+ role="listbox"
157
+ className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-1.5"
158
+ >
159
+ {results.length === 0 && (
160
+ <p className="text-muted-foreground py-8 text-center text-sm">
161
+ Nothing matches.
162
+ </p>
163
+ )}
164
+ {groups.map((g) => (
165
+ <div key={g.name || "results"} className="mb-1">
166
+ {g.name && (
167
+ <p className="text-muted-foreground px-2 pt-2 pb-1 text-[11px] font-medium">
168
+ {g.name}
169
+ </p>
170
+ )}
171
+ {g.items.map(({ c, i }) => {
172
+ const Icon = c.icon as LucideIcon | undefined;
173
+ return (
174
+ <button
175
+ key={c.id}
176
+ type="button"
177
+ role="option"
178
+ aria-selected={i === highlighted}
179
+ data-index={i}
180
+ onMouseMove={() => i !== highlighted && setActive(i)}
181
+ onClick={() => run(c)}
182
+ className={cn(
183
+ "flex min-h-11 w-full items-center gap-2.5 rounded-lg px-2 text-left text-sm md:min-h-9",
184
+ i === highlighted && "bg-primary/10"
185
+ )}
186
+ >
187
+ {Icon && (
188
+ <Icon className="text-muted-foreground h-4 w-4 shrink-0" />
189
+ )}
190
+ <span className="min-w-0 flex-1">
191
+ <span className="block truncate">{c.title}</span>
192
+ {c.detail && (
193
+ <span className="text-muted-foreground block truncate text-xs">
194
+ {c.detail}
195
+ </span>
196
+ )}
197
+ </span>
198
+ {query.trim() && c.group !== "Conversations" && (
199
+ <span className="text-muted-foreground shrink-0 text-xs">
200
+ {c.group}
201
+ </span>
202
+ )}
203
+ {c.hint && (
204
+ <kbd className="text-muted-foreground hidden shrink-0 font-mono text-[11px] sm:inline">
205
+ {c.hint}
206
+ </kbd>
207
+ )}
208
+ </button>
209
+ );
210
+ })}
211
+ </div>
212
+ ))}
213
+ </div>
214
+ </>
215
+ );
216
+ }
@@ -0,0 +1,246 @@
1
+ "use client";
2
+
3
+ import { useEffect, useMemo, useRef, useState } from "react";
4
+ import { Loader2, Search } from "lucide-react";
5
+ import { useSnapshot } from "valtio";
6
+ import { cn } from "@/lib/utils";
7
+ import { MIN_QUERY, useConversationSearch } from "@/data/search";
8
+ import { useDebouncedValue } from "@/hooks/useDebouncedValue";
9
+ import { useSelectedWorkspace } from "@/hooks/useSelectedWorkspace";
10
+ import { useViewport } from "@/hooks/useViewport";
11
+ import { paletteActions, paletteUi } from "@/stores/palette";
12
+ import { NoResults, Recent, Skeleton } from "./EmptyState";
13
+ import { Preview } from "./Preview";
14
+ import { ResultList, flatten } from "./ResultList";
15
+ import { loadRecent, saveRecent, withRecent } from "./recent";
16
+ import { useOpenHit } from "./useOpenHit";
17
+
18
+ // ⌘K's second mode: what was said in any chat, found as you type. Results on
19
+ // the left, the selected message in its conversation on the right; a phone
20
+ // shows the list, and a tap shows the message.
21
+ export function ConversationSearch() {
22
+ const { query: seed } = useSnapshot(paletteUi);
23
+ const { isMobile } = useViewport();
24
+ const { workspace } = useSelectedWorkspace();
25
+ const [text, setText] = useState(seed);
26
+ const [all, setAll] = useState(false);
27
+ const [recent, setRecent] = useState(loadRecent);
28
+ const [active, setActive] = useState(0);
29
+ const [previewing, setPreviewing] = useState(false);
30
+ const listRef = useRef<HTMLDivElement>(null);
31
+ const open = useOpenHit();
32
+
33
+ const q = useDebouncedValue(text.trim(), 150);
34
+ const scope = all || !workspace ? "all" : workspace.id;
35
+ const searching = q.length >= MIN_QUERY;
36
+ const { data, isPending, isFetching, isError } = useConversationSearch(
37
+ q,
38
+ scope,
39
+ { enabled: searching }
40
+ );
41
+ const groups = useMemo(
42
+ () => (searching ? (data?.groups ?? []) : []),
43
+ [searching, data]
44
+ );
45
+ const hits = useMemo(() => flatten(groups), [groups]);
46
+ const highlighted = Math.min(active, Math.max(hits.length - 1, 0));
47
+ const current = hits[highlighted];
48
+ // The text typed is ahead of the results shown while the debounce runs.
49
+ const stale = text.trim() !== q || (searching && isFetching);
50
+
51
+ // New results start at the top.
52
+ const [shown, setShown] = useState(data);
53
+ if (shown !== data) {
54
+ setShown(data);
55
+ setActive(0);
56
+ }
57
+ useEffect(() => {
58
+ listRef.current
59
+ ?.querySelector(`[data-index="${highlighted}"]`)
60
+ ?.scrollIntoView({ block: "nearest" });
61
+ }, [highlighted]);
62
+
63
+ const openHit = (i: number) => {
64
+ const h = hits[i];
65
+ if (!h) return;
66
+ saveRecent(withRecent(recent, text));
67
+ open(h.group, h.hit);
68
+ };
69
+ const pick = (i: number) => {
70
+ setActive(i);
71
+ if (isMobile) setPreviewing(true);
72
+ else openHit(i);
73
+ };
74
+
75
+ const showPreview = !!current && (!isMobile || previewing);
76
+ const showList = !isMobile || !previewing;
77
+
78
+ return (
79
+ <>
80
+ {showList && (
81
+ <div className="border-foreground/[0.06] flex flex-col border-b">
82
+ <div className="flex items-center gap-2 px-3">
83
+ <Search className="text-muted-foreground h-4 w-4 shrink-0" />
84
+ <input
85
+ autoFocus
86
+ value={text}
87
+ onChange={(e) => setText(e.target.value)}
88
+ onKeyDown={(e) => {
89
+ if (e.key === "ArrowDown" || e.key === "ArrowUp") {
90
+ e.preventDefault();
91
+ const n = hits.length || 1;
92
+ setActive(
93
+ (highlighted + (e.key === "ArrowDown" ? 1 : -1) + n) % n
94
+ );
95
+ } else if (e.key === "Enter") {
96
+ e.preventDefault();
97
+ openHit(highlighted);
98
+ } else if (e.key === "Tab") {
99
+ e.preventDefault();
100
+ paletteActions.setMode("commands", text);
101
+ }
102
+ }}
103
+ placeholder="Search every conversation…"
104
+ aria-label="Search every conversation"
105
+ className="placeholder:text-muted-foreground h-12 min-w-0 flex-1 bg-transparent text-base outline-none sm:text-sm"
106
+ />
107
+ {stale && searching && (
108
+ <Loader2 className="text-muted-foreground h-3.5 w-3.5 shrink-0 animate-spin" />
109
+ )}
110
+ <kbd className="text-muted-foreground hidden font-mono text-[10px] sm:inline">
111
+ esc
112
+ </kbd>
113
+ </div>
114
+ {workspace && (
115
+ <div className="flex items-center gap-2 px-3 pb-2">
116
+ <div className="flex overflow-hidden rounded-md border text-xs">
117
+ {(
118
+ [
119
+ [false, workspace.name],
120
+ [true, "All workspaces"],
121
+ ] as const
122
+ ).map(([value, label]) => (
123
+ <button
124
+ key={String(value)}
125
+ type="button"
126
+ aria-pressed={all === value}
127
+ onClick={() => setAll(value)}
128
+ className={cn(
129
+ "h-8 max-w-40 truncate px-3 md:h-7",
130
+ all === value
131
+ ? "bg-primary/10 text-foreground font-medium"
132
+ : "text-muted-foreground hover:text-foreground"
133
+ )}
134
+ >
135
+ {label}
136
+ </button>
137
+ ))}
138
+ </div>
139
+ </div>
140
+ )}
141
+ </div>
142
+ )}
143
+ <div className="flex min-h-0 flex-1">
144
+ {showList && (
145
+ <div
146
+ ref={listRef}
147
+ role="listbox"
148
+ aria-label="Matches"
149
+ className={cn(
150
+ "min-h-0 flex-1 overflow-y-auto overscroll-contain p-1.5",
151
+ current &&
152
+ "md:border-foreground/[0.06] md:flex-none md:basis-[44%] md:border-r"
153
+ )}
154
+ >
155
+ {data?.indexing != null && (
156
+ <p className="bg-muted/50 text-muted-foreground mx-1 mt-1 rounded-lg px-3 py-2 text-xs">
157
+ Still indexing older conversations (
158
+ {Math.round(data.indexing * 100)}%). Results fill in as it goes.
159
+ </p>
160
+ )}
161
+ {!searching && (
162
+ <Recent
163
+ items={recent}
164
+ onPick={setText}
165
+ onClear={() => {
166
+ saveRecent([]);
167
+ setRecent([]);
168
+ }}
169
+ />
170
+ )}
171
+ {searching && isPending && <Skeleton />}
172
+ {searching && isError && (
173
+ <p className="text-muted-foreground px-4 py-10 text-center text-sm">
174
+ Search isn’t answering. Try again in a moment.
175
+ </p>
176
+ )}
177
+ {searching &&
178
+ !isPending &&
179
+ !isError &&
180
+ hits.length === 0 &&
181
+ !stale && (
182
+ <NoResults
183
+ query={q}
184
+ scoped={scope !== "all"}
185
+ onWiden={() => setAll(true)}
186
+ />
187
+ )}
188
+ <div className={cn(stale && "opacity-60 transition-opacity")}>
189
+ <ResultList
190
+ groups={groups}
191
+ active={highlighted}
192
+ onActive={setActive}
193
+ onPick={pick}
194
+ />
195
+ </div>
196
+ {data?.more && (
197
+ <p className="text-muted-foreground px-3 py-3 text-center text-xs">
198
+ Showing the best matches. Add a word to narrow it down.
199
+ </p>
200
+ )}
201
+ </div>
202
+ )}
203
+ {showPreview && current && (
204
+ <div className="flex min-w-0 flex-1 flex-col">
205
+ <Preview
206
+ key={`${current.group.sessionId}:${current.hit.itemId}`}
207
+ group={current.group}
208
+ itemId={current.hit.itemId}
209
+ terms={data?.terms ?? []}
210
+ onOpen={() => openHit(highlighted)}
211
+ onBack={isMobile ? () => setPreviewing(false) : undefined}
212
+ />
213
+ </div>
214
+ )}
215
+ </div>
216
+ <div className="border-foreground/[0.06] text-muted-foreground flex items-center gap-4 border-t px-3 py-2 text-[11px]">
217
+ <span className="hidden gap-4 sm:flex">
218
+ <span>
219
+ <kbd className="font-mono">↑↓</kbd> move
220
+ </span>
221
+ <span>
222
+ <kbd className="font-mono">↵</kbd> open
223
+ </span>
224
+ <span>
225
+ <kbd className="font-mono">tab</kbd> commands
226
+ </span>
227
+ <span>
228
+ <kbd className="font-mono">esc</kbd> close
229
+ </span>
230
+ </span>
231
+ <button
232
+ type="button"
233
+ onClick={() => paletteActions.setMode("commands", text)}
234
+ className="h-8 sm:hidden"
235
+ >
236
+ Commands
237
+ </button>
238
+ <span className="ml-auto font-mono">
239
+ {searching && data
240
+ ? `${groups.length} ${groups.length === 1 ? "chat" : "chats"} · ${data.tookMs} ms`
241
+ : ""}
242
+ </span>
243
+ </div>
244
+ </>
245
+ );
246
+ }
@@ -0,0 +1,95 @@
1
+ "use client";
2
+
3
+ import { Clock, SearchX, X } from "lucide-react";
4
+ import { Button } from "@/components/ui/button";
5
+
6
+ export function Hint() {
7
+ return (
8
+ <p className="text-muted-foreground px-4 py-10 text-center text-sm">
9
+ Search what you and your agents have said, in every chat.
10
+ <span className="mt-1 block text-xs">
11
+ Words match as you type. Quote a phrase to match it exactly.
12
+ </span>
13
+ </p>
14
+ );
15
+ }
16
+
17
+ export function Recent({
18
+ items,
19
+ onPick,
20
+ onClear,
21
+ }: {
22
+ items: string[];
23
+ onPick: (q: string) => void;
24
+ onClear: () => void;
25
+ }) {
26
+ if (!items.length) return <Hint />;
27
+ return (
28
+ <div className="p-1.5">
29
+ <div className="flex items-center justify-between px-2 pt-2 pb-1">
30
+ <p className="text-muted-foreground text-[11px] font-medium">Recent</p>
31
+ <button
32
+ type="button"
33
+ onClick={onClear}
34
+ className="text-muted-foreground hover:text-foreground flex h-6 items-center gap-1 text-[11px]"
35
+ >
36
+ <X className="h-3 w-3" /> Clear
37
+ </button>
38
+ </div>
39
+ {items.map((q) => (
40
+ <button
41
+ key={q}
42
+ type="button"
43
+ onClick={() => onPick(q)}
44
+ className="hover:bg-foreground/[0.05] flex min-h-11 w-full items-center gap-2.5 rounded-lg px-2 text-left text-sm md:min-h-9"
45
+ >
46
+ <Clock className="text-muted-foreground h-4 w-4 shrink-0" />
47
+ <span className="min-w-0 flex-1 truncate">{q}</span>
48
+ </button>
49
+ ))}
50
+ </div>
51
+ );
52
+ }
53
+
54
+ export function Skeleton() {
55
+ return (
56
+ <div className="space-y-4 p-3" role="status" aria-label="Searching">
57
+ {[0, 1, 2].map((g) => (
58
+ <div key={g} className="space-y-2">
59
+ <div className="bg-foreground/[0.08] h-3 w-1/3 animate-pulse rounded" />
60
+ <div className="bg-foreground/[0.06] h-9 animate-pulse rounded-lg" />
61
+ <div className="bg-muted/40 h-9 w-5/6 animate-pulse rounded-lg" />
62
+ </div>
63
+ ))}
64
+ </div>
65
+ );
66
+ }
67
+
68
+ export function NoResults({
69
+ query,
70
+ scoped,
71
+ onWiden,
72
+ }: {
73
+ query: string;
74
+ scoped: boolean;
75
+ onWiden: () => void;
76
+ }) {
77
+ return (
78
+ <div className="flex flex-col items-center gap-3 px-4 py-10 text-center">
79
+ <SearchX className="text-muted-foreground h-6 w-6" />
80
+ <p className="text-muted-foreground text-sm">
81
+ Nothing said matches “{query}”.
82
+ </p>
83
+ {scoped && (
84
+ <Button
85
+ variant="outline"
86
+ size="sm"
87
+ onClick={onWiden}
88
+ className="h-11 md:h-8"
89
+ >
90
+ Search all workspaces
91
+ </Button>
92
+ )}
93
+ </div>
94
+ );
95
+ }
@@ -0,0 +1,129 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef } from "react";
4
+ import { ArrowLeft, CornerDownLeft } from "lucide-react";
5
+ import { Button } from "@/components/ui/button";
6
+ import { Markdown } from "@/components/Chat/Markdown";
7
+ import { findRanges } from "@/components/Chat/find-highlight";
8
+ import { useMessageContext, type ConversationGroup } from "@/data/search";
9
+ import { cn } from "@/lib/utils";
10
+ import { whenLabel } from "./format";
11
+
12
+ // Marks `terms` in the preview with the browser's highlight registry, so the
13
+ // markdown and code blocks inside are never rewritten. Without the API the
14
+ // hit still scrolls into view and the result row still marks the words.
15
+ function markTerms(root: HTMLElement, terms: string[]) {
16
+ if (typeof CSS === "undefined" || !("highlights" in CSS)) return () => {};
17
+ const ranges = terms.flatMap((t) => findRanges(root, t));
18
+ CSS.highlights.set("conv-search", new Highlight(...ranges));
19
+ return () => {
20
+ CSS.highlights.delete("conv-search");
21
+ };
22
+ }
23
+
24
+ // The message a result found with the ones around it, drawn as the chat draws
25
+ // them. On a phone it has the whole dialog and a way back to the list.
26
+ export function Preview({
27
+ group,
28
+ itemId,
29
+ terms,
30
+ onOpen,
31
+ onBack,
32
+ }: {
33
+ group: ConversationGroup;
34
+ itemId: string;
35
+ terms: string[];
36
+ onOpen: () => void;
37
+ onBack?: () => void;
38
+ }) {
39
+ const {
40
+ data: items,
41
+ isPending,
42
+ isError,
43
+ } = useMessageContext(group.sessionId, itemId);
44
+ const root = useRef<HTMLDivElement>(null);
45
+ const termKey = terms.join(" ");
46
+ useEffect(() => {
47
+ const el = root.current;
48
+ if (!el || !items) return;
49
+ el.querySelector("[data-hit]")?.scrollIntoView({ block: "center" });
50
+ return markTerms(el, termKey.split(" ").filter(Boolean));
51
+ }, [items, termKey]);
52
+
53
+ return (
54
+ <div className="flex min-h-0 flex-1 flex-col">
55
+ <div className="border-foreground/[0.06] flex items-center gap-2 border-b px-3 py-2">
56
+ {onBack && (
57
+ <Button
58
+ variant="ghost"
59
+ size="icon"
60
+ onClick={onBack}
61
+ aria-label="Back to results"
62
+ className="h-11 w-11 shrink-0"
63
+ >
64
+ <ArrowLeft className="h-4 w-4" />
65
+ </Button>
66
+ )}
67
+ <div className="min-w-0 flex-1">
68
+ <p className="truncate text-sm font-medium">{group.name}</p>
69
+ <p className="text-muted-foreground truncate text-xs">
70
+ {[group.projectName, group.workspaceName]
71
+ .filter(Boolean)
72
+ .join(" · ")}
73
+ </p>
74
+ </div>
75
+ <Button
76
+ size="sm"
77
+ onClick={onOpen}
78
+ className="h-11 shrink-0 gap-1.5 md:h-8"
79
+ >
80
+ Open
81
+ <CornerDownLeft className="hidden h-3.5 w-3.5 md:inline" />
82
+ </Button>
83
+ </div>
84
+ <div
85
+ ref={root}
86
+ className="min-h-0 flex-1 space-y-6 overflow-y-auto overscroll-contain p-5"
87
+ >
88
+ {isPending && (
89
+ <div className="space-y-3" aria-label="Loading preview">
90
+ {[0, 1, 2].map((i) => (
91
+ <div
92
+ key={i}
93
+ className="bg-foreground/[0.06] h-12 animate-pulse rounded-xl"
94
+ />
95
+ ))}
96
+ </div>
97
+ )}
98
+ {isError && (
99
+ <p className="text-muted-foreground py-6 text-center text-sm">
100
+ That message is gone.
101
+ </p>
102
+ )}
103
+ {items?.map((m) => (
104
+ <div
105
+ key={m.id}
106
+ {...(m.hit ? { "data-hit": "" } : {})}
107
+ className={cn(
108
+ "rounded-xl",
109
+ m.hit && "ring-primary/30 -m-2 p-2 ring-1",
110
+ m.role === "user" && "flex flex-col items-end"
111
+ )}
112
+ >
113
+ <span className="label-mono text-muted-foreground mb-1 flex items-center gap-1.5">
114
+ {m.role === "user" ? "You" : "Agent"}
115
+ {m.createdAt ? <span>· {whenLabel(m.createdAt)}</span> : null}
116
+ </span>
117
+ {m.role === "user" ? (
118
+ <div className="bg-foreground/[0.07] max-w-[85%] rounded-2xl rounded-br-md px-3.5 py-2 text-sm whitespace-pre-wrap">
119
+ {m.text}
120
+ </div>
121
+ ) : (
122
+ <Markdown text={m.text} />
123
+ )}
124
+ </div>
125
+ ))}
126
+ </div>
127
+ </div>
128
+ );
129
+ }