@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
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,144 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { afterEach, describe, expect, it } from "vitest";
4
+ import { ATTACHMENT_LIMITS } from "@/lib/attachments/policy";
5
+ import { attachmentsRoot } from "@/lib/attachments/store";
6
+ import { POST } from "./route";
7
+
8
+ const PDF = Buffer.from("%PDF-1.4\n1 0 obj\n<<>>\nendobj\n");
9
+ const ZIPPED = Buffer.concat([
10
+ Buffer.from([0x50, 0x4b, 0x03, 0x04]),
11
+ Buffer.alloc(32, 7),
12
+ ]);
13
+ const PNG = Buffer.concat([
14
+ Buffer.from([0x89, 0x50, 0x4e, 0x47]),
15
+ Buffer.alloc(32, 1),
16
+ ]);
17
+
18
+ const SESSION = "test-upload-session";
19
+
20
+ const upload = (
21
+ filename: string,
22
+ data: Buffer | string,
23
+ extra: Record<string, unknown> = {}
24
+ ) =>
25
+ POST(
26
+ new Request("http://localhost/api/files/upload-temp", {
27
+ method: "POST",
28
+ body: JSON.stringify({
29
+ filename,
30
+ base64: Buffer.from(data).toString("base64"),
31
+ ...extra,
32
+ }),
33
+ })
34
+ );
35
+
36
+ afterEach(() => {
37
+ fs.rmSync(path.join(attachmentsRoot(), SESSION), {
38
+ recursive: true,
39
+ force: true,
40
+ });
41
+ });
42
+
43
+ describe("POST /api/files/upload-temp", () => {
44
+ it.each([
45
+ ["report.pdf", PDF, "pdf"],
46
+ ["sheet.xlsx", ZIPPED, "sheet"],
47
+ ["letter.docx", ZIPPED, "doc"],
48
+ ["data.csv", "a,b\n1,2\n", "sheet"],
49
+ ["notes.md", "# hi\n", "text"],
50
+ ["app.ts", "export const a = 1;\n", "text"],
51
+ ["shot.png", PNG, "image"],
52
+ ])("takes %s", async (name, data, kind) => {
53
+ const res = await upload(name, data, { sessionId: SESSION });
54
+ expect(res.status).toBe(200);
55
+ const body = await res.json();
56
+ expect(body.kind).toBe(kind);
57
+ expect(body.path).toBe(path.join(attachmentsRoot(), SESSION, name));
58
+ expect(fs.readFileSync(body.path)).toEqual(Buffer.from(data));
59
+ });
60
+
61
+ it("keeps the old shape: no session means temp storage and a path", async () => {
62
+ const res = await upload("old.csv", "a,b\n");
63
+ const body = await res.json();
64
+ expect(res.status).toBe(200);
65
+ expect(body.path).toContain("agent-os-screenshots");
66
+ expect(body.path).not.toContain(attachmentsRoot());
67
+ fs.rmSync(body.path);
68
+ });
69
+
70
+ it("refuses types it doesn't take, saying which", async () => {
71
+ for (const name of ["a.zip", "run.sh", "tool.exe", "noext"]) {
72
+ const res = await upload(name, "x", { sessionId: SESSION });
73
+ expect(res.status).toBe(415);
74
+ expect((await res.json()).error).toContain(`${name}: this file type`);
75
+ }
76
+ expect(fs.existsSync(path.join(attachmentsRoot(), SESSION))).toBe(false);
77
+ });
78
+
79
+ it("refuses a file that isn't what its name says", async () => {
80
+ for (const [name, data] of [
81
+ ["fake.xlsx", "plain text"],
82
+ ["fake.pdf", "plain text"],
83
+ ["fake.xls", ZIPPED],
84
+ ["fake.csv", Buffer.from([0, 1, 2, 0])],
85
+ ["fake.png", PDF],
86
+ ] as const) {
87
+ const res = await upload(name, data, { sessionId: SESSION });
88
+ expect(res.status, name).toBe(400);
89
+ expect((await res.json()).error).toContain("doesn't look like a real");
90
+ }
91
+ });
92
+
93
+ it("refuses an empty file and a missing body", async () => {
94
+ expect((await upload("a.txt", "", { sessionId: SESSION })).status).toBe(
95
+ 400
96
+ );
97
+ const none = await POST(
98
+ new Request("http://localhost/x", {
99
+ method: "POST",
100
+ body: JSON.stringify({ filename: "a.txt" }),
101
+ })
102
+ );
103
+ expect(none.status).toBe(400);
104
+ });
105
+
106
+ it("refuses a file over the limit, and says what the limit is", async () => {
107
+ const big = Buffer.alloc(ATTACHMENT_LIMITS.maxBytes + 1, 97);
108
+ const res = await upload("big.txt", big, { sessionId: SESSION });
109
+ expect(res.status).toBe(413);
110
+ expect((await res.json()).error).toContain("the limit is 25.0 MB");
111
+ });
112
+
113
+ it("refuses an image over the image limit", async () => {
114
+ const big = Buffer.concat([
115
+ PNG,
116
+ Buffer.alloc(ATTACHMENT_LIMITS.maxImageBytes),
117
+ ]);
118
+ expect((await upload("big.png", big)).status).toBe(413);
119
+ });
120
+
121
+ it("refuses a body over the limit before reading it", async () => {
122
+ const res = await POST(
123
+ new Request("http://localhost/x", {
124
+ method: "POST",
125
+ headers: { "content-length": String(ATTACHMENT_LIMITS.maxBytes * 2) },
126
+ body: "{}",
127
+ })
128
+ );
129
+ expect(res.status).toBe(413);
130
+ });
131
+
132
+ it("keeps a hostile name inside the session's folder", async () => {
133
+ const res = await upload("../../escape.pdf", PDF, { sessionId: SESSION });
134
+ const { path: saved } = await res.json();
135
+ expect(path.dirname(saved)).toBe(path.join(attachmentsRoot(), SESSION));
136
+ });
137
+
138
+ it("never overwrites: a second file with the same name gets its own", async () => {
139
+ const a = await (await upload("x.csv", "1", { sessionId: SESSION })).json();
140
+ const b = await (await upload("x.csv", "2", { sessionId: SESSION })).json();
141
+ expect(a.path).not.toBe(b.path);
142
+ expect(fs.readFileSync(a.path, "utf8")).toBe("1");
143
+ });
144
+ });
@@ -2,38 +2,69 @@ import { NextResponse } from "next/server";
2
2
  import { mkdir, writeFile } from "fs/promises";
3
3
  import path from "path";
4
4
  import os from "os";
5
+ import { checkUpload } from "@/lib/attachments/check";
6
+ import { ATTACHMENT_LIMITS, tooBigMessage } from "@/lib/attachments/policy";
7
+ import { saveAttachment } from "@/lib/attachments/store";
8
+ import { peerChatLink } from "@/lib/chat/peer-relay";
5
9
 
10
+ // Base64 is a third bigger than the bytes, and the JSON around it is small.
11
+ const MAX_BODY = Math.ceil((ATTACHMENT_LIMITS.maxBytes * 4) / 3) + 64 * 1024;
12
+
13
+ const fail = (error: string, status: number) =>
14
+ NextResponse.json({ error }, { status });
15
+
16
+ // POST /api/files/upload-temp { filename, base64, mimeType?, sessionId? }
17
+ // Without a sessionId the file goes to the shared temp folder (the terminal's
18
+ // drop, the file picker). With one - a session's id, or "draft" for a chat
19
+ // not started yet - it is checked against lib/attachments/policy and kept in
20
+ // that session's attachments folder, outside any repo.
6
21
  export async function POST(request: Request) {
7
22
  try {
8
- const { filename, base64, mimeType } = await request.json();
23
+ const length = Number(request.headers.get("content-length"));
24
+ if (length > MAX_BODY)
25
+ return fail(
26
+ tooBigMessage("That file", length, ATTACHMENT_LIMITS.maxBytes),
27
+ 413
28
+ );
29
+ const { filename, base64, sessionId } = await request.json();
30
+
31
+ if (typeof base64 !== "string" || !base64) return fail("No file data", 400);
32
+ const name =
33
+ typeof filename === "string" && filename ? filename : "attachment";
34
+ const buffer = Buffer.from(base64, "base64");
35
+ const checked = checkUpload(name, buffer);
36
+ if (!checked.ok) return fail(checked.error, checked.status);
9
37
 
10
- if (!base64) {
11
- return NextResponse.json({ error: "No image data" }, { status: 400 });
38
+ if (typeof sessionId === "string" && sessionId) {
39
+ if (sessionId !== "draft" && peerChatLink(sessionId))
40
+ return fail(
41
+ "Files can't be attached to a session running on another machine yet. Images still work.",
42
+ 409
43
+ );
44
+ const file = saveAttachment(
45
+ sessionId,
46
+ name,
47
+ buffer,
48
+ checked.type.mediaType
49
+ );
50
+ return NextResponse.json({ ...file, kind: checked.type.kind });
12
51
  }
13
52
 
14
- // Create temp directory for screenshots if it doesn't exist
15
53
  const tempDir = path.join(os.tmpdir(), "agent-os-screenshots");
16
54
  await mkdir(tempDir, { recursive: true });
17
-
18
- // Generate unique filename
19
- const ext = mimeType?.split("/")[1] || "png";
20
- const safeName = filename?.replace(/[^a-zA-Z0-9.-]/g, "_") || "screenshot";
21
- const uniqueName = `${Date.now()}-${safeName}`;
22
- const finalName = uniqueName.endsWith(`.${ext}`)
23
- ? uniqueName
24
- : `${uniqueName}.${ext}`;
25
- const filePath = path.join(tempDir, finalName);
26
-
27
- // Decode base64 and write file
28
- const buffer = Buffer.from(base64, "base64");
55
+ const safeName = name.replace(/[^a-zA-Z0-9.-]/g, "_");
56
+ const filePath = path.join(tempDir, `${Date.now()}-${safeName}`);
29
57
  await writeFile(filePath, buffer);
30
58
 
31
- return NextResponse.json({ path: filePath });
59
+ return NextResponse.json({
60
+ path: filePath,
61
+ name: safeName,
62
+ size: buffer.length,
63
+ mediaType: checked.type.mediaType,
64
+ kind: checked.type.kind,
65
+ });
32
66
  } catch (error) {
33
67
  console.error("Upload error:", error);
34
- return NextResponse.json(
35
- { error: "Failed to save image" },
36
- { status: 500 }
37
- );
68
+ return fail("Failed to save the file", 500);
38
69
  }
39
70
  }
@@ -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
+ }
@@ -1,3 +1,4 @@
1
+ import { removeSessionAttachments } from "@/lib/attachments/store";
1
2
  import { NextRequest, NextResponse } from "next/server";
2
3
  import { deletionRefusal } from "@/lib/orchestrator/home";
3
4
  import { getDb, queries, type Session } from "@/lib/db";
@@ -281,6 +282,7 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
281
282
  );
282
283
  deleteItems(id);
283
284
  clearQueue(id);
285
+ void removeSessionAttachments(id).catch(() => {});
284
286
  queries.deleteSession(db).run(id);
285
287
 
286
288
  // Clean up worktree in background (non-blocking)
@@ -50,8 +50,12 @@ export async function POST(request: NextRequest) {
50
50
  }
51
51
  const str = (v: unknown) => (typeof v === "string" ? v : null);
52
52
  const firstMessage = (b: Record<string, unknown>) => {
53
- const { text, images } = clientSend({ text: b.prompt, images: b.images });
54
- return { prompt: text, images };
53
+ const { text, images, files } = clientSend({
54
+ text: b.prompt,
55
+ images: b.images,
56
+ files: b.files,
57
+ });
58
+ return { prompt: text, images, files };
55
59
  };
56
60
  try {
57
61
  const { session, initialPrompt, repeat } = await launchSession({
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 { hrefWithSession } from "@/lib/session-url";
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
- window.history.pushState(
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.
@@ -694,6 +694,7 @@ export function ChatPanel({
694
694
  kind: "user",
695
695
  text: p.text,
696
696
  images: p.images,
697
+ files: p.files,
697
698
  createdAt: p.at,
698
699
  }}
699
700
  pending={{
@@ -820,6 +821,7 @@ export function ChatPanel({
820
821
  <LearningCardsHost sessionId={sessionId} />
821
822
  <Composer
822
823
  draftKey={sessionId}
824
+ attachScope={sessionId}
823
825
  suggestion={shownSuggestion}
824
826
  onDismissSuggestion={dismissSuggestion}
825
827
  history={history}
@@ -828,7 +830,7 @@ export function ChatPanel({
828
830
  running={running}
829
831
  disabled={!connected}
830
832
  placeholder={connected ? `Message ${sessionName}` : "Reconnecting…"}
831
- onSend={(text, images) => {
833
+ onSend={(text, images, files) => {
832
834
  if (freshStart && isFreshCommand(text)) {
833
835
  startFresh.mutate(sessionId);
834
836
  return;
@@ -840,7 +842,12 @@ export function ChatPanel({
840
842
  asIs,
841
843
  });
842
844
  setAsIs(false);
843
- send(text, images.length ? images : undefined, compact);
845
+ send(
846
+ text,
847
+ images.length ? images : undefined,
848
+ compact,
849
+ files.length ? files : undefined
850
+ );
844
851
  if (replyTo) {
845
852
  resolveQuestion.mutate(replyTo.id);
846
853
  setReplyTo(null);
@@ -0,0 +1,67 @@
1
+ import {
2
+ FileCode,
3
+ FileSpreadsheet,
4
+ FileText,
5
+ Loader2,
6
+ X,
7
+ type LucideIcon,
8
+ } from "lucide-react";
9
+ import {
10
+ formatBytes,
11
+ typeFor,
12
+ type AttachmentKind,
13
+ } from "@/lib/attachments/policy";
14
+ import { cn } from "@/lib/utils";
15
+
16
+ const ICONS: Record<AttachmentKind, LucideIcon> = {
17
+ image: FileText,
18
+ pdf: FileText,
19
+ sheet: FileSpreadsheet,
20
+ doc: FileText,
21
+ text: FileCode,
22
+ };
23
+
24
+ // A file attached to a message: in the composer (with a way to remove it,
25
+ // and a spinner while it uploads) and in the sent message.
26
+ export function FileChip({
27
+ file,
28
+ uploading,
29
+ onRemove,
30
+ }: {
31
+ file: { name: string; size: number };
32
+ uploading?: boolean;
33
+ onRemove?: () => void;
34
+ }) {
35
+ const Icon = ICONS[typeFor(file.name)?.kind ?? "text"];
36
+ return (
37
+ <div
38
+ className={cn(
39
+ "bg-foreground/[0.05] flex h-14 max-w-56 min-w-0 items-center gap-2 rounded-lg pl-3",
40
+ onRemove ? "pr-1" : "pr-3"
41
+ )}
42
+ title={file.name}
43
+ >
44
+ {uploading ? (
45
+ <Loader2 className="text-muted-foreground h-4 w-4 shrink-0 animate-spin" />
46
+ ) : (
47
+ <Icon className="text-muted-foreground h-4 w-4 shrink-0" />
48
+ )}
49
+ <span className="min-w-0">
50
+ <span className="block truncate text-sm">{file.name}</span>
51
+ <span className="text-muted-foreground block text-xs">
52
+ {uploading ? "Uploading…" : formatBytes(file.size)}
53
+ </span>
54
+ </span>
55
+ {onRemove && (
56
+ <button
57
+ type="button"
58
+ aria-label={`Remove ${file.name}`}
59
+ onClick={onRemove}
60
+ className="text-muted-foreground hover:text-foreground flex h-11 w-11 shrink-0 items-center justify-center"
61
+ >
62
+ <X className="h-4 w-4" />
63
+ </button>
64
+ )}
65
+ </div>
66
+ );
67
+ }