@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
package/README.md
CHANGED
|
@@ -22,6 +22,7 @@ and check on all of it from your phone.
|
|
|
22
22
|
- **Agents that talk to each other.** Sessions message each other with `aos`, and you see every conversation in Messages. [More](docs/guides/agent-network.md)
|
|
23
23
|
- **Chat or terminal, same conversation.** Streaming replies, steps and diffs, plan mode, slash commands, `@file`, undo. [More](docs/guides/chat.md)
|
|
24
24
|
- **Bookmarks.** Star any message, tool call or turn, then find it again from the chat or across every chat, with a search. [More](docs/guides/chat.md)
|
|
25
|
+
- **Search every conversation.** ⌘K (Ctrl+K off a Mac), then Tab: what you and your agents said in any chat, found as you type from a full-text index (a word's start matches, quotes match a phrase), with the message and its neighbours shown beside the results and a jump to it in the chat. Done, dropped and archived chats are included. [More](docs/reference/conversation-search.md)
|
|
25
26
|
- **Every artifact in one place.** A session's plan, PR, files, pages, images and job logs in one list, with a preview, kept after its worktree is removed. [More](docs/guides/sessions.md#artifacts)
|
|
26
27
|
- **Share a chat or a page.** A read-only snapshot link with secrets, home paths and machine names masked, a preview before anything leaves your machine, and a Markdown copy to hand to an agent. [More](docs/guides/sharing.md)
|
|
27
28
|
- **Project config.** `agentos.json` gives each session its own ports, environment, files and a private Postgres copy. [More](docs/reference/project-config.md)
|
|
@@ -33,7 +34,7 @@ and check on all of it from your phone.
|
|
|
33
34
|
- **LumifyHub (optional).** Boards, cards and docs for a workspace, if you want them. [More](docs/lumifyhub.md)
|
|
34
35
|
- **No approvals mode.** One switch in Settings that approves every ask, lets brakes and plan-only tasks through, and starts every agent with its permission prompts off; each approval it makes is logged. Off by default. [More](docs/reference/orchestrator.md#no-approvals-mode)
|
|
35
36
|
- **Experiments.** Settings → Experiments lists features still being tried, each off until you turn it on, with what it has cost and how often its suggestions were taken. The first is Learning cards: when a session ends it can offer to save the work as a recipe or keep a lesson where it belongs. [More](docs/decisions/2026-10-13-experiments.md)
|
|
36
|
-
- **Also:** a ⌘K palette, code search, a file picker, a git panel, dev servers, up to four panes, and dictation.
|
|
37
|
+
- **Also:** a ⌘K palette (actions, sessions, conversations), code search, a file picker, a git panel, dev servers, up to four panes, and dictation.
|
|
37
38
|
|
|
38
39
|
## Documentation
|
|
39
40
|
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { ReadError, readPeer } from "@/lib/bus/read";
|
|
3
|
+
|
|
4
|
+
// `aos read <session>`: a peer's conversation in the caller's workspace,
|
|
5
|
+
// condensed. Read-only: nothing about the target changes.
|
|
6
|
+
export async function GET(request: NextRequest) {
|
|
7
|
+
const q = request.nextUrl.searchParams;
|
|
8
|
+
const count = (k: string) => {
|
|
9
|
+
const raw = q.get(k);
|
|
10
|
+
if (raw === null) return undefined;
|
|
11
|
+
const n = Number(raw);
|
|
12
|
+
if (!Number.isInteger(n) || n < 1 || n > 5000)
|
|
13
|
+
throw new ReadError(`${k} must be a whole number from 1 to 5000`, 400);
|
|
14
|
+
return n;
|
|
15
|
+
};
|
|
16
|
+
try {
|
|
17
|
+
const from = q.get("from");
|
|
18
|
+
if (!from)
|
|
19
|
+
throw new ReadError("aos read runs from inside an AgentOS session", 400);
|
|
20
|
+
const result = await readPeer(from, q.get("ref") ?? "", {
|
|
21
|
+
turns: count("turns"),
|
|
22
|
+
lines: count("lines"),
|
|
23
|
+
all: q.get("all") === "1",
|
|
24
|
+
grep: q.get("grep") || undefined,
|
|
25
|
+
});
|
|
26
|
+
return NextResponse.json(result);
|
|
27
|
+
} catch (error) {
|
|
28
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
29
|
+
return NextResponse.json(
|
|
30
|
+
{ error: message },
|
|
31
|
+
{ status: error instanceof ReadError ? error.status : 500 }
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { getMessageContext } from "@/lib/search/conversations";
|
|
4
|
+
|
|
5
|
+
const Query = z.object({
|
|
6
|
+
session: z.string().min(1).max(100),
|
|
7
|
+
item: z.string().min(1).max(200),
|
|
8
|
+
around: z.coerce.number().int().min(0).max(6).default(3),
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
// GET /api/search/conversations/context?session=&item=&around= - a message and
|
|
12
|
+
// the ones around it, for the preview. Read-only.
|
|
13
|
+
export async function GET(request: Request) {
|
|
14
|
+
const parsed = Query.safeParse(
|
|
15
|
+
Object.fromEntries(new URL(request.url).searchParams)
|
|
16
|
+
);
|
|
17
|
+
if (!parsed.success)
|
|
18
|
+
return NextResponse.json({ error: "Bad request" }, { status: 400 });
|
|
19
|
+
const { session, item, around } = parsed.data;
|
|
20
|
+
const items = getMessageContext(session, item, around);
|
|
21
|
+
if (!items) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
|
22
|
+
return NextResponse.json(
|
|
23
|
+
{ items },
|
|
24
|
+
{ headers: { "Cache-Control": "no-store" } }
|
|
25
|
+
);
|
|
26
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { randomUUID } from "crypto";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { getDb } from "@/lib/db";
|
|
4
|
+
import type { ChatItem } from "@/lib/chat/events";
|
|
5
|
+
import { saveItem } from "@/lib/chat/store";
|
|
6
|
+
import * as route from "./route";
|
|
7
|
+
import * as contextRoute from "./context/route";
|
|
8
|
+
|
|
9
|
+
const get = (qs: Record<string, string>) =>
|
|
10
|
+
route.GET(
|
|
11
|
+
new Request(
|
|
12
|
+
`http://localhost/api/search/conversations?${new URLSearchParams(qs)}`
|
|
13
|
+
)
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
function setup() {
|
|
17
|
+
const db = getDb();
|
|
18
|
+
const tag = randomUUID().slice(0, 6);
|
|
19
|
+
const ws = (n: string) => {
|
|
20
|
+
const id = `ws-${tag}-${n}`;
|
|
21
|
+
db.prepare(`INSERT INTO workspaces (id, name) VALUES (?, ?)`).run(
|
|
22
|
+
id,
|
|
23
|
+
`Space ${n}`
|
|
24
|
+
);
|
|
25
|
+
return id;
|
|
26
|
+
};
|
|
27
|
+
const a = ws("a");
|
|
28
|
+
const b = ws("b");
|
|
29
|
+
const proj = (n: string, w: string) => {
|
|
30
|
+
const id = `p-${tag}-${n}`;
|
|
31
|
+
db.prepare(
|
|
32
|
+
`INSERT INTO projects (id, name, working_directory, workspace_id) VALUES (?, ?, '/tmp/x', ?)`
|
|
33
|
+
).run(id, `Proj ${n}`, w);
|
|
34
|
+
return id;
|
|
35
|
+
};
|
|
36
|
+
const pa = proj("a", a);
|
|
37
|
+
const pb = proj("b", b);
|
|
38
|
+
const chat = (
|
|
39
|
+
name: string,
|
|
40
|
+
project: string | null,
|
|
41
|
+
ws: string | null,
|
|
42
|
+
text: string,
|
|
43
|
+
archived = false
|
|
44
|
+
) => {
|
|
45
|
+
const sid = randomUUID();
|
|
46
|
+
db.prepare(
|
|
47
|
+
`INSERT INTO sessions (id, name, tmux_name, working_directory, project_id, workspace_id, archived_at)
|
|
48
|
+
VALUES (?, ?, ?, '/tmp', ?, ?, ?)`
|
|
49
|
+
).run(
|
|
50
|
+
sid,
|
|
51
|
+
name,
|
|
52
|
+
`claude-${sid}`,
|
|
53
|
+
project,
|
|
54
|
+
ws,
|
|
55
|
+
archived ? "2026-01-01" : null
|
|
56
|
+
);
|
|
57
|
+
for (let i = 0; i < 5; i++)
|
|
58
|
+
saveItem(sid, {
|
|
59
|
+
id: `${sid}-${i}`,
|
|
60
|
+
kind: i % 2 ? "assistant" : "user",
|
|
61
|
+
text: `${text} ${i}`,
|
|
62
|
+
createdAt: 1,
|
|
63
|
+
} as ChatItem);
|
|
64
|
+
return sid;
|
|
65
|
+
};
|
|
66
|
+
return { a, b, pa, pb, chat, word: `lynx${tag}` };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
describe("GET /api/search/conversations", () => {
|
|
70
|
+
it("scopes to a workspace, all of them, or a project", async () => {
|
|
71
|
+
const { a, b, pa, chat, word } = setup();
|
|
72
|
+
const inA = chat("in a", pa, null, word);
|
|
73
|
+
const direct = chat("direct", null, a, word);
|
|
74
|
+
chat("in b", `p-${word.slice(4)}-b`, null, word);
|
|
75
|
+
const only = async (qs: Record<string, string>) =>
|
|
76
|
+
(
|
|
77
|
+
(await (await get({ q: word, ...qs })).json()).groups as {
|
|
78
|
+
sessionId: string;
|
|
79
|
+
}[]
|
|
80
|
+
)
|
|
81
|
+
.map((g) => g.sessionId)
|
|
82
|
+
.sort();
|
|
83
|
+
expect(await only({ workspace: a })).toEqual([inA, direct].sort());
|
|
84
|
+
expect((await only({ workspace: "all" })).length).toBe(3);
|
|
85
|
+
expect(await only({ workspace: "all", project: pa })).toEqual([inA]);
|
|
86
|
+
expect(await only({ workspace: b })).toHaveLength(1);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("includes archived conversations and labels them, with the match marked", async () => {
|
|
90
|
+
const { chat, word } = setup();
|
|
91
|
+
chat("old", null, null, `${word} cleanup`, true);
|
|
92
|
+
const body = await (await get({ q: word.slice(0, -1) })).json();
|
|
93
|
+
expect(body.groups).toHaveLength(1);
|
|
94
|
+
const g = body.groups[0];
|
|
95
|
+
expect(g.status).toBe("archived");
|
|
96
|
+
expect(g.hits.length).toBeLessThanOrEqual(3);
|
|
97
|
+
expect(g.hits[0].snippet.some((s: { hit: boolean }) => s.hit)).toBe(true);
|
|
98
|
+
expect(body.terms).toEqual([word.slice(0, -1)]);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("rejects bad input and treats short queries as empty", async () => {
|
|
102
|
+
expect((await get({ q: "x", limit: "999" })).status).toBe(400);
|
|
103
|
+
expect((await get({ q: "x".repeat(300) })).status).toBe(400);
|
|
104
|
+
const r = await get({ q: "a" });
|
|
105
|
+
expect((await r.json()).groups).toEqual([]);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("answers hostile query text with results, not an error", async () => {
|
|
109
|
+
const r = await get({ q: '"AND" OR NEAR( * ^ col: "' });
|
|
110
|
+
expect(r.status).toBe(200);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("only reads", () => {
|
|
114
|
+
expect(Object.keys(route).sort()).toEqual(["GET"]);
|
|
115
|
+
expect(Object.keys(contextRoute).sort()).toEqual(["GET"]);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("serves a message with its neighbours, and 404s a missing one", async () => {
|
|
119
|
+
const { chat, word } = setup();
|
|
120
|
+
const sid = chat("ctx", null, null, word);
|
|
121
|
+
const ok = await contextRoute.GET(
|
|
122
|
+
new Request(
|
|
123
|
+
`http://localhost/x?${new URLSearchParams({ session: sid, item: `${sid}-2`, around: "1" })}`
|
|
124
|
+
)
|
|
125
|
+
);
|
|
126
|
+
const items = (await ok.json()).items as { id: string; hit: boolean }[];
|
|
127
|
+
expect(items.map((i) => i.id)).toEqual([
|
|
128
|
+
`${sid}-1`,
|
|
129
|
+
`${sid}-2`,
|
|
130
|
+
`${sid}-3`,
|
|
131
|
+
]);
|
|
132
|
+
const missing = await contextRoute.GET(
|
|
133
|
+
new Request(
|
|
134
|
+
`http://localhost/x?${new URLSearchParams({ session: sid, item: "nope" })}`
|
|
135
|
+
)
|
|
136
|
+
);
|
|
137
|
+
expect(missing.status).toBe(404);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { searchConversations } from "@/lib/search/conversations";
|
|
4
|
+
|
|
5
|
+
const Query = z.object({
|
|
6
|
+
q: z.string().max(200).default(""),
|
|
7
|
+
workspace: z.string().min(1).max(100).default("all"),
|
|
8
|
+
project: z.string().min(1).max(100).optional(),
|
|
9
|
+
offset: z.coerce.number().int().min(0).max(200).default(0),
|
|
10
|
+
limit: z.coerce.number().int().min(1).max(30).default(20),
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
// GET /api/search/conversations?q=&workspace=<id|all>&project=&offset=&limit=
|
|
14
|
+
// - what was said in chats, grouped by conversation. Read-only.
|
|
15
|
+
export async function GET(request: Request) {
|
|
16
|
+
const params = Object.fromEntries(new URL(request.url).searchParams);
|
|
17
|
+
const parsed = Query.safeParse(params);
|
|
18
|
+
if (!parsed.success)
|
|
19
|
+
return NextResponse.json({ error: "Bad search" }, { status: 400 });
|
|
20
|
+
const { q, workspace, project, offset, limit } = parsed.data;
|
|
21
|
+
return NextResponse.json(
|
|
22
|
+
searchConversations(q, { workspace, project }, { offset, limit }),
|
|
23
|
+
{ headers: { "Cache-Control": "no-store" } }
|
|
24
|
+
);
|
|
25
|
+
}
|
package/app/globals.css
CHANGED
|
@@ -309,3 +309,28 @@ body {
|
|
|
309
309
|
background-color: hsl(var(--find-current));
|
|
310
310
|
color: hsl(var(--find-current-foreground));
|
|
311
311
|
}
|
|
312
|
+
|
|
313
|
+
/* A message just jumped to (components/Chat/useGoToItem). */
|
|
314
|
+
@keyframes jump-flash {
|
|
315
|
+
from {
|
|
316
|
+
background-color: hsl(var(--primary) / 0.22);
|
|
317
|
+
}
|
|
318
|
+
to {
|
|
319
|
+
background-color: transparent;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
[data-block][data-flash] {
|
|
323
|
+
animation: jump-flash 1.8s ease-out;
|
|
324
|
+
border-radius: 0.75rem;
|
|
325
|
+
}
|
|
326
|
+
@media (prefers-reduced-motion: reduce) {
|
|
327
|
+
[data-block][data-flash] {
|
|
328
|
+
animation: none;
|
|
329
|
+
background-color: hsl(var(--primary) / 0.12);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/* Matches in the search preview (components/CommandPalette/conversations). */
|
|
334
|
+
::highlight(conv-search) {
|
|
335
|
+
background-color: hsl(var(--find-match));
|
|
336
|
+
}
|
|
@@ -15,7 +15,7 @@ import { useBookmarks, type Bookmark } from "@/data/bookmarks";
|
|
|
15
15
|
import { useWorkspacesQuery } from "@/data/workspaces";
|
|
16
16
|
import { useUnarchiveSession } from "@/data/done";
|
|
17
17
|
import { toast } from "sonner";
|
|
18
|
-
import {
|
|
18
|
+
import { openChatAt } from "@/stores/chatJump";
|
|
19
19
|
import { bookmarksUi, bookmarksUiActions } from "@/stores/bookmarksUi";
|
|
20
20
|
import { BookmarkRow } from "./BookmarkRow";
|
|
21
21
|
import { EmptyBookmarks } from "./EmptyBookmarks";
|
|
@@ -24,13 +24,7 @@ import { groupByDay } from "./helpers";
|
|
|
24
24
|
// Opens the chat by its address, then takes it to the message.
|
|
25
25
|
function openChat(b: Bookmark) {
|
|
26
26
|
bookmarksUiActions.setOpen(false);
|
|
27
|
-
|
|
28
|
-
null,
|
|
29
|
-
"",
|
|
30
|
-
hrefWithSession(location.href, b.sessionId)
|
|
31
|
-
);
|
|
32
|
-
window.dispatchEvent(new PopStateEvent("popstate"));
|
|
33
|
-
bookmarksUiActions.jump(b.sessionId, b.itemId);
|
|
27
|
+
openChatAt(b.sessionId, b.itemId);
|
|
34
28
|
}
|
|
35
29
|
|
|
36
30
|
// Every bookmark: one workspace's or all of them, searched on the server.
|
|
@@ -17,6 +17,16 @@ interface Deps {
|
|
|
17
17
|
foldEvents: boolean;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
// The message arrived at is marked for a moment (app/globals.css fades it).
|
|
21
|
+
function flash(block: string) {
|
|
22
|
+
const el = document.querySelector(`[data-block="${CSS.escape(block)}"]`);
|
|
23
|
+
if (!el) return;
|
|
24
|
+
el.removeAttribute("data-flash");
|
|
25
|
+
void (el as HTMLElement).offsetWidth;
|
|
26
|
+
el.setAttribute("data-flash", "");
|
|
27
|
+
setTimeout(() => el.removeAttribute("data-flash"), 2000);
|
|
28
|
+
}
|
|
29
|
+
|
|
20
30
|
// Takes the reader to a message by a rule over the loaded items (the strip's
|
|
21
31
|
// "the message where the question was asked"): older pages load until it is
|
|
22
32
|
// found, then its folded rows open and it scrolls into view.
|
|
@@ -54,6 +64,7 @@ export function useGoToItem(d: Deps) {
|
|
|
54
64
|
)
|
|
55
65
|
?.scrollIntoView({ block: "center" })
|
|
56
66
|
)
|
|
67
|
+
.then(() => flash(where.block))
|
|
57
68
|
.catch(() => {});
|
|
58
69
|
}, [
|
|
59
70
|
find,
|
|
@@ -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
|
-
}
|