@saadnvd1/agent-os 0.3.672 → 0.3.678

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 (88) hide show
  1. package/README.md +2 -1
  2. package/app/api/bus/read/route.ts +34 -0
  3. package/app/api/files/upload-temp/route.test.ts +144 -0
  4. package/app/api/files/upload-temp/route.ts +52 -21
  5. package/app/api/search/conversations/context/route.ts +26 -0
  6. package/app/api/search/conversations/route.test.ts +139 -0
  7. package/app/api/search/conversations/route.ts +25 -0
  8. package/app/api/sessions/[id]/route.ts +2 -0
  9. package/app/api/sessions/route.ts +6 -2
  10. package/app/globals.css +25 -0
  11. package/components/Bookmarks/BookmarksDialog.tsx +2 -8
  12. package/components/Chat/ChatPanel.tsx +9 -2
  13. package/components/Chat/FileChip.tsx +67 -0
  14. package/components/Chat/Messages.tsx +8 -0
  15. package/components/Chat/Queue.tsx +6 -0
  16. package/components/Chat/composer/Attachments.tsx +16 -2
  17. package/components/Chat/composer/ComposerBody.tsx +35 -14
  18. package/components/Chat/composer/Toolbar.tsx +11 -8
  19. package/components/Chat/composer/editor.test.ts +1 -1
  20. package/components/Chat/composer/paste.ts +5 -4
  21. package/components/Chat/composer/useAttachFiles.ts +142 -0
  22. package/components/Chat/composer/useComposerEditor.ts +3 -3
  23. package/components/Chat/useDraft.ts +9 -2
  24. package/components/Chat/useGoToItem.ts +11 -0
  25. package/components/CommandPalette/CommandPalette.tsx +23 -156
  26. package/components/CommandPalette/CommandsBody.tsx +216 -0
  27. package/components/CommandPalette/conversations/ConversationSearch.tsx +246 -0
  28. package/components/CommandPalette/conversations/EmptyState.tsx +95 -0
  29. package/components/CommandPalette/conversations/Preview.tsx +129 -0
  30. package/components/CommandPalette/conversations/ResultList.tsx +91 -0
  31. package/components/CommandPalette/conversations/Snippet.tsx +24 -0
  32. package/components/CommandPalette/conversations/format.ts +30 -0
  33. package/components/CommandPalette/conversations/recent.test.ts +20 -0
  34. package/components/CommandPalette/conversations/recent.ts +35 -0
  35. package/components/CommandPalette/conversations/useOpenHit.ts +30 -0
  36. package/components/Draft/DraftPanel.tsx +7 -4
  37. package/components/Terminal/index.tsx +6 -5
  38. package/data/chat/useChat.ts +10 -3
  39. package/data/client-config/queries.ts +18 -0
  40. package/data/search/index.ts +65 -0
  41. package/data/search/keys.ts +7 -0
  42. package/data/sessions/queries.ts +2 -1
  43. package/hooks/useDebouncedValue.ts +11 -0
  44. package/lib/agents/brief.ts +1 -0
  45. package/lib/attachments/check.ts +50 -0
  46. package/lib/attachments/message.ts +49 -0
  47. package/lib/attachments/policy.ts +116 -0
  48. package/lib/attachments/store.ts +92 -0
  49. package/lib/attachments/types.ts +8 -0
  50. package/lib/bus/condense.ts +242 -0
  51. package/lib/bus/read-scope.ts +109 -0
  52. package/lib/bus/read.ts +181 -0
  53. package/lib/chat/client-send.ts +6 -2
  54. package/lib/chat/driver.ts +4 -1
  55. package/lib/chat/drivers/claude.ts +2 -0
  56. package/lib/chat/events.ts +13 -1
  57. package/lib/chat/pending.ts +2 -1
  58. package/lib/chat/queued.ts +24 -7
  59. package/lib/chat/runner.ts +11 -2
  60. package/lib/chat/worker/host.ts +9 -3
  61. package/lib/chat/worker/protocol.ts +2 -0
  62. package/lib/config/client-config-types.ts +14 -3
  63. package/lib/config/client-config.ts +1 -1
  64. package/lib/db/migrations/20261019T1200_chat_queue_files.ts +14 -0
  65. package/lib/db/migrations/20261019T1500_conversation_search.ts +12 -0
  66. package/lib/file-upload.ts +61 -27
  67. package/lib/intake/intake.ts +2 -17
  68. package/lib/intake/sniff.ts +1 -1
  69. package/lib/orchestrator/read.ts +18 -4
  70. package/lib/palette/registry.ts +4 -0
  71. package/lib/resurrect/index.ts +2 -3
  72. package/lib/search/conversations/backfill.ts +117 -0
  73. package/lib/search/conversations/buildMatchQuery.ts +31 -0
  74. package/lib/search/conversations/index.ts +9 -0
  75. package/lib/search/conversations/messageContext.ts +45 -0
  76. package/lib/search/conversations/schema.ts +71 -0
  77. package/lib/search/conversations/searchConversations.ts +151 -0
  78. package/lib/search/conversations/types.ts +45 -0
  79. package/lib/sessions/launch.ts +12 -6
  80. package/lib/sidebar/row-actions.ts +19 -8
  81. package/lib/sidebar/row-menu.ts +1 -1
  82. package/lib/tasks/restorable.ts +16 -0
  83. package/package.json +1 -1
  84. package/scripts/bench-conversation-search.ts +73 -0
  85. package/server.ts +3 -0
  86. package/stores/chatJump.ts +10 -0
  87. package/stores/palette.ts +22 -4
  88. package/components/Chat/composer/useImageDrop.ts +0 -52
@@ -1,182 +1,49 @@
1
1
  "use client";
2
2
 
3
- import { useEffect, useMemo, useRef, useState } from "react";
4
3
  import { useSnapshot } from "valtio";
5
- import { Search, type LucideIcon } from "lucide-react";
6
4
  import {
7
5
  Dialog,
8
6
  DialogContent,
9
7
  DialogDescription,
10
8
  DialogTitle,
11
9
  } from "@/components/ui/dialog";
12
- import { matchCommands, type PaletteCommand } from "@/lib/palette/registry";
13
10
  import { cn } from "@/lib/utils";
14
- import { usePaletteList } from "@/hooks/usePaletteCommands";
15
11
  import { paletteActions, paletteUi } from "@/stores/palette";
16
-
17
- // How many of a long group (sessions) show before a query narrows it.
18
- const GROUP_PREVIEW = 8;
12
+ import { CommandsBody } from "./CommandsBody";
13
+ import { ConversationSearch } from "./conversations/ConversationSearch";
19
14
 
20
15
  // ⌘K anywhere, or the header button on a phone: every action the open
21
- // features offer, found by typing.
16
+ // features offer, found by typing, and (Tab) what was said in any chat.
22
17
  export function CommandPalette() {
23
- const { open } = useSnapshot(paletteUi);
18
+ const { open, mode } = useSnapshot(paletteUi);
19
+ const wide = mode === "conversations";
24
20
  return (
25
21
  <Dialog open={open} onOpenChange={paletteActions.setOpen}>
26
22
  <DialogContent
27
23
  showCloseButton={false}
28
24
  // Sized to what the keyboard leaves visible (--app-height), not vh.
29
- className="top-[max(env(safe-area-inset-top),1rem)] flex max-h-[min(calc(var(--app-height,100dvh)-env(safe-area-inset-top)-2rem),560px)] translate-y-0 flex-col gap-0 overflow-hidden p-0 sm:top-[15vh] sm:max-h-[min(calc(var(--app-height,100dvh)-15vh-1rem),560px)] sm:max-w-lg"
25
+ className={cn(
26
+ "top-[max(env(safe-area-inset-top),1rem)] flex translate-y-0 flex-col gap-0 overflow-hidden p-0 sm:top-[15vh]",
27
+ wide
28
+ ? "h-[calc(var(--app-height,100dvh)-env(safe-area-inset-top)-2rem)] sm:h-[min(calc(var(--app-height,100dvh)-15vh-1rem),600px)] sm:max-w-4xl"
29
+ : "max-h-[min(calc(var(--app-height,100dvh)-env(safe-area-inset-top)-2rem),560px)] sm:max-h-[min(calc(var(--app-height,100dvh)-15vh-1rem),560px)] sm:max-w-lg"
30
+ )}
30
31
  >
31
- <DialogTitle className="sr-only">Command palette</DialogTitle>
32
+ <DialogTitle className="sr-only">
33
+ {wide ? "Search conversations" : "Command palette"}
34
+ </DialogTitle>
32
35
  <DialogDescription className="sr-only">
33
- Search for an action or a session
36
+ {wide
37
+ ? "Search what was said in every conversation"
38
+ : "Search for an action, a session or a conversation"}
34
39
  </DialogDescription>
35
- {/* Mounted per opening, so each starts with an empty query. */}
36
- <PaletteBody />
40
+ {/* Mounted per opening and per mode, so each starts from its own query. */}
41
+ {wide ? (
42
+ <ConversationSearch key="conversations" />
43
+ ) : (
44
+ <CommandsBody key="commands" />
45
+ )}
37
46
  </DialogContent>
38
47
  </Dialog>
39
48
  );
40
49
  }
41
-
42
- function PaletteBody() {
43
- const commands = usePaletteList();
44
- // Subscribed for changes, read raw: its items are held by ref.
45
- useSnapshot(paletteUi);
46
- const picker = paletteUi.picker;
47
- const all = picker?.items ?? commands;
48
- const [query, setQuery] = useState("");
49
- const [active, setActive] = useState(0);
50
- const listRef = useRef<HTMLDivElement>(null);
51
-
52
- const results = useMemo(() => {
53
- if (query.trim()) return matchCommands(all, query);
54
- // A picker shows every choice; it's the whole list.
55
- if (picker) return all;
56
- // With no query, long groups are cut short so every group shows.
57
- const seen = new Map<string, number>();
58
- return all.filter((c) => {
59
- const n = (seen.get(c.group) ?? 0) + 1;
60
- seen.set(c.group, n);
61
- return n <= GROUP_PREVIEW;
62
- });
63
- }, [all, query, picker]);
64
- const highlighted = Math.min(active, Math.max(results.length - 1, 0));
65
-
66
- useEffect(() => {
67
- listRef.current
68
- ?.querySelector(`[data-index="${highlighted}"]`)
69
- ?.scrollIntoView({ block: "nearest" });
70
- }, [highlighted]);
71
-
72
- const run = (c: PaletteCommand | undefined) => {
73
- if (!c) return;
74
- paletteActions.setOpen(false);
75
- // After the dialog lets go of focus, so what it opens can take it.
76
- setTimeout(() => c.run(), 0);
77
- };
78
-
79
- // Under their headings with no query; one ranked list with one.
80
- const groups: { name: string; items: { c: PaletteCommand; i: number }[] }[] =
81
- [];
82
- results.forEach((c, i) => {
83
- const name = query.trim() ? "" : c.group;
84
- let g = groups.find((x) => x.name === name);
85
- if (!g) groups.push((g = { name, items: [] }));
86
- g.items.push({ c, i });
87
- });
88
-
89
- return (
90
- <>
91
- <div className="border-foreground/[0.06] flex items-center gap-2 border-b px-3">
92
- <Search className="text-muted-foreground h-4 w-4 shrink-0" />
93
- <input
94
- autoFocus
95
- value={query}
96
- onChange={(e) => {
97
- setQuery(e.target.value);
98
- setActive(0);
99
- }}
100
- onKeyDown={(e) => {
101
- if (e.key === "ArrowDown" || e.key === "ArrowUp") {
102
- e.preventDefault();
103
- const n = results.length || 1;
104
- setActive(
105
- (highlighted + (e.key === "ArrowDown" ? 1 : -1) + n) % n
106
- );
107
- } else if (e.key === "Enter") {
108
- e.preventDefault();
109
- run(results[highlighted]);
110
- } else if (
111
- picker &&
112
- (e.metaKey || e.ctrlKey) &&
113
- /^[1-9]$/.test(e.key)
114
- ) {
115
- e.preventDefault();
116
- run(picker.items[Number(e.key) - 1]);
117
- }
118
- }}
119
- placeholder={picker?.placeholder ?? "Search actions and sessions…"}
120
- aria-label={picker?.placeholder ?? "Search actions and sessions"}
121
- className="placeholder:text-muted-foreground h-12 min-w-0 flex-1 bg-transparent text-base outline-none sm:text-sm"
122
- />
123
- <kbd className="text-muted-foreground hidden font-mono text-[10px] sm:inline">
124
- esc
125
- </kbd>
126
- </div>
127
- <div
128
- ref={listRef}
129
- role="listbox"
130
- className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-1.5"
131
- >
132
- {results.length === 0 && (
133
- <p className="text-muted-foreground py-8 text-center text-sm">
134
- Nothing matches.
135
- </p>
136
- )}
137
- {groups.map((g) => (
138
- <div key={g.name || "results"} className="mb-1">
139
- {g.name && (
140
- <p className="text-muted-foreground px-2 pt-2 pb-1 text-[11px] font-medium">
141
- {g.name}
142
- </p>
143
- )}
144
- {g.items.map(({ c, i }) => {
145
- const Icon = c.icon as LucideIcon | undefined;
146
- return (
147
- <button
148
- key={c.id}
149
- type="button"
150
- role="option"
151
- aria-selected={i === highlighted}
152
- data-index={i}
153
- onMouseMove={() => i !== highlighted && setActive(i)}
154
- onClick={() => run(c)}
155
- className={cn(
156
- "flex min-h-11 w-full items-center gap-2.5 rounded-lg px-2 text-left text-sm md:min-h-9",
157
- i === highlighted && "bg-primary/10"
158
- )}
159
- >
160
- {Icon && (
161
- <Icon className="text-muted-foreground h-4 w-4 shrink-0" />
162
- )}
163
- <span className="min-w-0 flex-1 truncate">{c.title}</span>
164
- {query.trim() && (
165
- <span className="text-muted-foreground shrink-0 text-xs">
166
- {c.group}
167
- </span>
168
- )}
169
- {c.hint && (
170
- <kbd className="text-muted-foreground hidden shrink-0 font-mono text-[11px] sm:inline">
171
- {c.hint}
172
- </kbd>
173
- )}
174
- </button>
175
- );
176
- })}
177
- </div>
178
- ))}
179
- </div>
180
- </>
181
- );
182
- }
@@ -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
+ }