@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.
- package/README.md +2 -1
- package/app/api/bus/read/route.ts +34 -0
- package/app/api/files/upload-temp/route.test.ts +144 -0
- package/app/api/files/upload-temp/route.ts +52 -21
- package/app/api/search/conversations/context/route.ts +26 -0
- package/app/api/search/conversations/route.test.ts +139 -0
- package/app/api/search/conversations/route.ts +25 -0
- package/app/api/sessions/[id]/route.ts +2 -0
- package/app/api/sessions/route.ts +6 -2
- package/app/globals.css +25 -0
- package/components/Bookmarks/BookmarksDialog.tsx +2 -8
- package/components/Chat/ChatPanel.tsx +9 -2
- package/components/Chat/FileChip.tsx +67 -0
- package/components/Chat/Messages.tsx +8 -0
- package/components/Chat/Queue.tsx +6 -0
- package/components/Chat/composer/Attachments.tsx +16 -2
- package/components/Chat/composer/ComposerBody.tsx +35 -14
- package/components/Chat/composer/Toolbar.tsx +11 -8
- package/components/Chat/composer/editor.test.ts +1 -1
- package/components/Chat/composer/paste.ts +5 -4
- package/components/Chat/composer/useAttachFiles.ts +142 -0
- package/components/Chat/composer/useComposerEditor.ts +3 -3
- package/components/Chat/useDraft.ts +9 -2
- package/components/Chat/useGoToItem.ts +11 -0
- package/components/CommandPalette/CommandPalette.tsx +23 -156
- package/components/CommandPalette/CommandsBody.tsx +216 -0
- package/components/CommandPalette/conversations/ConversationSearch.tsx +246 -0
- package/components/CommandPalette/conversations/EmptyState.tsx +95 -0
- package/components/CommandPalette/conversations/Preview.tsx +129 -0
- package/components/CommandPalette/conversations/ResultList.tsx +91 -0
- package/components/CommandPalette/conversations/Snippet.tsx +24 -0
- package/components/CommandPalette/conversations/format.ts +30 -0
- package/components/CommandPalette/conversations/recent.test.ts +20 -0
- package/components/CommandPalette/conversations/recent.ts +35 -0
- package/components/CommandPalette/conversations/useOpenHit.ts +30 -0
- package/components/Draft/DraftPanel.tsx +7 -4
- package/components/Terminal/index.tsx +6 -5
- package/data/chat/useChat.ts +10 -3
- package/data/client-config/queries.ts +18 -0
- package/data/search/index.ts +65 -0
- package/data/search/keys.ts +7 -0
- package/data/sessions/queries.ts +2 -1
- package/hooks/useDebouncedValue.ts +11 -0
- package/lib/agents/brief.ts +1 -0
- package/lib/attachments/check.ts +50 -0
- package/lib/attachments/message.ts +49 -0
- package/lib/attachments/policy.ts +116 -0
- package/lib/attachments/store.ts +92 -0
- package/lib/attachments/types.ts +8 -0
- package/lib/bus/condense.ts +242 -0
- package/lib/bus/read-scope.ts +109 -0
- package/lib/bus/read.ts +181 -0
- package/lib/chat/client-send.ts +6 -2
- package/lib/chat/driver.ts +4 -1
- package/lib/chat/drivers/claude.ts +2 -0
- package/lib/chat/events.ts +13 -1
- package/lib/chat/pending.ts +2 -1
- package/lib/chat/queued.ts +24 -7
- package/lib/chat/runner.ts +11 -2
- package/lib/chat/worker/host.ts +9 -3
- package/lib/chat/worker/protocol.ts +2 -0
- package/lib/config/client-config-types.ts +14 -3
- package/lib/config/client-config.ts +1 -1
- package/lib/db/migrations/20261019T1200_chat_queue_files.ts +14 -0
- package/lib/db/migrations/20261019T1500_conversation_search.ts +12 -0
- package/lib/file-upload.ts +61 -27
- package/lib/intake/intake.ts +2 -17
- package/lib/intake/sniff.ts +1 -1
- package/lib/orchestrator/read.ts +18 -4
- package/lib/palette/registry.ts +4 -0
- package/lib/resurrect/index.ts +2 -3
- package/lib/search/conversations/backfill.ts +117 -0
- package/lib/search/conversations/buildMatchQuery.ts +31 -0
- package/lib/search/conversations/index.ts +9 -0
- package/lib/search/conversations/messageContext.ts +45 -0
- package/lib/search/conversations/schema.ts +71 -0
- package/lib/search/conversations/searchConversations.ts +151 -0
- package/lib/search/conversations/types.ts +45 -0
- package/lib/sessions/launch.ts +12 -6
- package/lib/sidebar/row-actions.ts +19 -8
- package/lib/sidebar/row-menu.ts +1 -1
- package/lib/tasks/restorable.ts +16 -0
- package/package.json +1 -1
- package/scripts/bench-conversation-search.ts +73 -0
- package/server.ts +3 -0
- package/stores/chatJump.ts +10 -0
- package/stores/palette.ts +22 -4
- 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
|
-
|
|
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=
|
|
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">
|
|
32
|
+
<DialogTitle className="sr-only">
|
|
33
|
+
{wide ? "Search conversations" : "Command palette"}
|
|
34
|
+
</DialogTitle>
|
|
32
35
|
<DialogDescription className="sr-only">
|
|
33
|
-
|
|
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
|
|
36
|
-
|
|
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
|
+
}
|