@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
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
|
|
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 (
|
|
11
|
-
|
|
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
|
-
|
|
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({
|
|
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
|
|
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({
|
|
54
|
-
|
|
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 {
|
|
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.
|
|
@@ -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(
|
|
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
|
+
}
|