@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.
- package/README.md +2 -1
- package/app/api/bus/read/route.ts +34 -0
- 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/globals.css +25 -0
- package/components/Bookmarks/BookmarksDialog.tsx +2 -8
- 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/data/search/index.ts +65 -0
- package/data/search/keys.ts +7 -0
- package/hooks/useDebouncedValue.ts +11 -0
- package/lib/agents/brief.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/worker/host.ts +3 -1
- package/lib/db/migrations/20261019T1500_conversation_search.ts +12 -0
- package/lib/demo-reseed.ts +4 -0
- package/lib/health/facts.ts +13 -3
- package/lib/health/spin-tick.ts +293 -0
- package/lib/health/spin.ts +158 -0
- package/lib/health/tick.ts +10 -2
- package/lib/health/types.ts +2 -0
- 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/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
|
@@ -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
|
+
}
|