kanna-code 0.17.0 → 0.19.0

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 (36) hide show
  1. package/dist/client/assets/index-C952_xJD.css +32 -0
  2. package/dist/client/assets/index-lgfU0zw2.js +606 -0
  3. package/dist/client/chat-sounds/Blow.mp3 +0 -0
  4. package/dist/client/chat-sounds/Bottle.mp3 +0 -0
  5. package/dist/client/chat-sounds/Frog.mp3 +0 -0
  6. package/dist/client/chat-sounds/Funk.mp3 +0 -0
  7. package/dist/client/chat-sounds/Glass.mp3 +0 -0
  8. package/dist/client/chat-sounds/Ping.mp3 +0 -0
  9. package/dist/client/chat-sounds/Pop.mp3 +0 -0
  10. package/dist/client/chat-sounds/Purr.mp3 +0 -0
  11. package/dist/client/chat-sounds/Tink.mp3 +0 -0
  12. package/dist/client/index.html +2 -2
  13. package/package.json +2 -1
  14. package/src/server/agent.test.ts +153 -4
  15. package/src/server/agent.ts +76 -15
  16. package/src/server/event-store.test.ts +65 -0
  17. package/src/server/event-store.ts +27 -1
  18. package/src/server/events.ts +8 -0
  19. package/src/server/generate-title.ts +42 -2
  20. package/src/server/paths.ts +4 -0
  21. package/src/server/quick-response.test.ts +126 -3
  22. package/src/server/quick-response.ts +107 -14
  23. package/src/server/read-models.test.ts +4 -0
  24. package/src/server/read-models.ts +1 -0
  25. package/src/server/server.ts +178 -1
  26. package/src/server/title-generation.live.test.ts +44 -0
  27. package/src/server/uploads.test.ts +282 -0
  28. package/src/server/uploads.ts +127 -0
  29. package/src/server/ws-router.test.ts +181 -0
  30. package/src/server/ws-router.ts +18 -0
  31. package/src/shared/protocol.ts +3 -0
  32. package/src/shared/tools.test.ts +65 -0
  33. package/src/shared/tools.ts +76 -0
  34. package/src/shared/types.ts +34 -1
  35. package/dist/client/assets/index-CtKZIZdD.js +0 -533
  36. package/dist/client/assets/index-Cwex1U8S.css +0 -32
@@ -0,0 +1,282 @@
1
+ import { afterEach, describe, expect, test } from "bun:test"
2
+ import { mkdtemp, rm } from "node:fs/promises"
3
+ import { tmpdir } from "node:os"
4
+ import path from "node:path"
5
+ import { deleteProjectUpload, inferAttachmentContentType, persistProjectUpload } from "./uploads"
6
+ import { getProjectUploadDir } from "./paths"
7
+ import { persistUploadedFiles, startKannaServer } from "./server"
8
+
9
+ const PNG_BASE64 =
10
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+yF9sAAAAASUVORK5CYII="
11
+
12
+ const tempDirs: string[] = []
13
+
14
+ afterEach(async () => {
15
+ await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })))
16
+ })
17
+
18
+ describe("uploads", () => {
19
+ test("stores uploads in .kanna/uploads and keeps duplicate filenames", async () => {
20
+ const projectDir = await mkdtemp(path.join(tmpdir(), "kanna-upload-test-"))
21
+ tempDirs.push(projectDir)
22
+
23
+ const first = await persistProjectUpload({
24
+ projectId: "project-1",
25
+ localPath: projectDir,
26
+ fileName: "notes.txt",
27
+ bytes: new TextEncoder().encode("hello"),
28
+ fallbackMimeType: "text/plain",
29
+ })
30
+ const second = await persistProjectUpload({
31
+ projectId: "project-1",
32
+ localPath: projectDir,
33
+ fileName: "notes.txt",
34
+ bytes: new TextEncoder().encode("world"),
35
+ fallbackMimeType: "text/plain",
36
+ })
37
+
38
+ expect(first.absolutePath).toBe(path.join(projectDir, ".kanna/uploads/notes.txt"))
39
+ expect(first.relativePath).toBe("./.kanna/uploads/notes.txt")
40
+ expect(first.contentUrl).toBe("/api/projects/project-1/uploads/notes.txt/content")
41
+ expect(second.absolutePath).toBe(path.join(projectDir, ".kanna/uploads/notes-1.txt"))
42
+ expect(second.relativePath).toBe("./.kanna/uploads/notes-1.txt")
43
+ expect(second.contentUrl).toBe("/api/projects/project-1/uploads/notes-1.txt/content")
44
+ expect(await Bun.file(path.join(projectDir, ".kanna/uploads/notes.txt")).text()).toBe("hello")
45
+ expect(await Bun.file(path.join(projectDir, ".kanna/uploads/notes-1.txt")).text()).toBe("world")
46
+ })
47
+
48
+ test("stores concurrent same-name uploads without overwriting existing content", async () => {
49
+ const projectDir = await mkdtemp(path.join(tmpdir(), "kanna-upload-concurrent-"))
50
+ tempDirs.push(projectDir)
51
+
52
+ const attachments = await Promise.all([
53
+ persistProjectUpload({
54
+ projectId: "project-1",
55
+ localPath: projectDir,
56
+ fileName: "notes.txt",
57
+ bytes: new TextEncoder().encode("first"),
58
+ fallbackMimeType: "text/plain",
59
+ }),
60
+ persistProjectUpload({
61
+ projectId: "project-1",
62
+ localPath: projectDir,
63
+ fileName: "notes.txt",
64
+ bytes: new TextEncoder().encode("second"),
65
+ fallbackMimeType: "text/plain",
66
+ }),
67
+ persistProjectUpload({
68
+ projectId: "project-1",
69
+ localPath: projectDir,
70
+ fileName: "notes.txt",
71
+ bytes: new TextEncoder().encode("third"),
72
+ fallbackMimeType: "text/plain",
73
+ }),
74
+ ])
75
+
76
+ const storedNames = attachments.map((attachment) => path.basename(attachment.absolutePath)).sort()
77
+ expect(storedNames).toEqual(["notes-1.txt", "notes-2.txt", "notes.txt"])
78
+
79
+ const contents = await Promise.all(attachments.map((attachment) => Bun.file(attachment.absolutePath).text()))
80
+ expect(new Set(contents)).toEqual(new Set(["first", "second", "third"]))
81
+ })
82
+
83
+ test("detects image uploads and returns absolute plus project-relative paths", async () => {
84
+ const projectDir = await mkdtemp(path.join(tmpdir(), "kanna-upload-image-"))
85
+ tempDirs.push(projectDir)
86
+
87
+ const attachment = await persistProjectUpload({
88
+ projectId: "project-2",
89
+ localPath: projectDir,
90
+ fileName: "pixel.png",
91
+ bytes: Buffer.from(PNG_BASE64, "base64"),
92
+ })
93
+
94
+ expect(attachment.kind).toBe("image")
95
+ expect(attachment.mimeType).toBe("image/png")
96
+ expect(getProjectUploadDir(projectDir)).toBe(path.join(projectDir, ".kanna", "uploads"))
97
+ expect(attachment.absolutePath).toBe(path.join(projectDir, ".kanna/uploads/pixel.png"))
98
+ expect(attachment.relativePath).toBe("./.kanna/uploads/pixel.png")
99
+ expect(attachment.contentUrl).toBe("/api/projects/project-2/uploads/pixel.png/content")
100
+ })
101
+
102
+ test("serves uploaded attachment content through the project content URL", async () => {
103
+ const projectDir = await mkdtemp(path.join(tmpdir(), "kanna-project-"))
104
+ tempDirs.push(projectDir)
105
+
106
+ const server = await startKannaServer({ port: 4310, strictPort: true })
107
+
108
+ try {
109
+ const project = await server.store.openProject(projectDir, "Project")
110
+ const attachment = await persistProjectUpload({
111
+ projectId: project.id,
112
+ localPath: projectDir,
113
+ fileName: "hello.txt",
114
+ bytes: new TextEncoder().encode("hello from upload"),
115
+ fallbackMimeType: "text/plain",
116
+ })
117
+
118
+ const response = await fetch(`http://localhost:${server.port}${attachment.contentUrl}`)
119
+ expect(response.status).toBe(200)
120
+ expect(response.headers.get("content-type")).toBe("text/plain; charset=utf-8")
121
+ expect(await response.text()).toBe("hello from upload")
122
+ } finally {
123
+ await server.stop()
124
+ }
125
+ })
126
+
127
+ test("serves TypeScript uploads as text content", async () => {
128
+ const projectDir = await mkdtemp(path.join(tmpdir(), "kanna-project-typescript-"))
129
+ tempDirs.push(projectDir)
130
+
131
+ const server = await startKannaServer({ port: 4314, strictPort: true })
132
+
133
+ try {
134
+ const project = await server.store.openProject(projectDir, "Project")
135
+ const attachment = await persistProjectUpload({
136
+ projectId: project.id,
137
+ localPath: projectDir,
138
+ fileName: "main.ts",
139
+ bytes: new TextEncoder().encode("export const value = 1\n"),
140
+ fallbackMimeType: "video/mp2t",
141
+ })
142
+
143
+ const response = await fetch(`http://localhost:${server.port}${attachment.contentUrl}`)
144
+ expect(response.status).toBe(200)
145
+ expect(response.headers.get("content-type")).toBe("text/plain; charset=utf-8")
146
+ expect(await response.text()).toContain("export const value = 1")
147
+ } finally {
148
+ await server.stop()
149
+ }
150
+ })
151
+
152
+ test("rejects non-GET requests for attachment content", async () => {
153
+ const projectDir = await mkdtemp(path.join(tmpdir(), "kanna-project-content-method-"))
154
+ tempDirs.push(projectDir)
155
+
156
+ const server = await startKannaServer({ port: 4312, strictPort: true })
157
+
158
+ try {
159
+ const project = await server.store.openProject(projectDir, "Project")
160
+ const attachment = await persistProjectUpload({
161
+ projectId: project.id,
162
+ localPath: projectDir,
163
+ fileName: "hello.txt",
164
+ bytes: new TextEncoder().encode("hello from upload"),
165
+ fallbackMimeType: "text/plain",
166
+ })
167
+
168
+ const response = await fetch(`http://localhost:${server.port}${attachment.contentUrl}`, { method: "POST" })
169
+ expect(response.status).toBe(405)
170
+ expect(response.headers.get("allow")).toBe("GET")
171
+ } finally {
172
+ await server.stop()
173
+ }
174
+ })
175
+
176
+ test("rejects oversized uploads before reading them into memory", async () => {
177
+ const projectDir = await mkdtemp(path.join(tmpdir(), "kanna-project-oversize-"))
178
+ tempDirs.push(projectDir)
179
+
180
+ const server = await startKannaServer({ port: 4313, strictPort: true })
181
+
182
+ try {
183
+ const project = await server.store.openProject(projectDir, "Project")
184
+ const formData = new FormData()
185
+ formData.append("files", new File([new Uint8Array(25 * 1024 * 1024 + 1)], "big.bin", { type: "application/octet-stream" }))
186
+
187
+ const response = await fetch(`http://localhost:${server.port}/api/projects/${project.id}/uploads`, {
188
+ method: "POST",
189
+ body: formData,
190
+ })
191
+
192
+ expect(response.status).toBe(413)
193
+ expect(await response.json()).toEqual({
194
+ error: "File \"big.bin\" exceeds the 25 MB limit.",
195
+ })
196
+ } finally {
197
+ await server.stop()
198
+ }
199
+ })
200
+
201
+ test("cleans up already-persisted files when a later file in the batch fails", async () => {
202
+ const projectDir = await mkdtemp(path.join(tmpdir(), "kanna-project-cleanup-"))
203
+ tempDirs.push(projectDir)
204
+
205
+ const files = [
206
+ new File(["first"], "first.txt", { type: "text/plain" }),
207
+ new File(["second"], "second.txt", { type: "text/plain" }),
208
+ ]
209
+
210
+ await expect(
211
+ persistUploadedFiles({
212
+ projectId: "project-4",
213
+ localPath: projectDir,
214
+ files,
215
+ persistUpload: async (args) => {
216
+ if (args.fileName === "second.txt") {
217
+ throw new Error("disk full")
218
+ }
219
+
220
+ return persistProjectUpload(args)
221
+ },
222
+ })
223
+ ).rejects.toThrow("disk full")
224
+
225
+ expect(await Bun.file(path.join(projectDir, ".kanna/uploads/first.txt")).exists()).toBe(false)
226
+ expect(await Bun.file(path.join(projectDir, ".kanna/uploads/second.txt")).exists()).toBe(false)
227
+ })
228
+
229
+ test("deletes uploaded attachments from the project uploads directory", async () => {
230
+ const projectDir = await mkdtemp(path.join(tmpdir(), "kanna-upload-delete-"))
231
+ tempDirs.push(projectDir)
232
+
233
+ const attachment = await persistProjectUpload({
234
+ projectId: "project-3",
235
+ localPath: projectDir,
236
+ fileName: "delete-me.txt",
237
+ bytes: new TextEncoder().encode("bye"),
238
+ fallbackMimeType: "text/plain",
239
+ })
240
+
241
+ const deleted = await deleteProjectUpload({
242
+ localPath: projectDir,
243
+ storedName: "delete-me.txt",
244
+ })
245
+
246
+ expect(deleted).toBe(true)
247
+ expect(await Bun.file(attachment.absolutePath).exists()).toBe(false)
248
+ })
249
+
250
+ test("deletes uploaded attachment content through the project delete URL", async () => {
251
+ const projectDir = await mkdtemp(path.join(tmpdir(), "kanna-project-delete-"))
252
+ tempDirs.push(projectDir)
253
+
254
+ const server = await startKannaServer({ port: 4311, strictPort: true })
255
+
256
+ try {
257
+ const project = await server.store.openProject(projectDir, "Project")
258
+ const attachment = await persistProjectUpload({
259
+ projectId: project.id,
260
+ localPath: projectDir,
261
+ fileName: "bye.txt",
262
+ bytes: new TextEncoder().encode("delete over http"),
263
+ fallbackMimeType: "text/plain",
264
+ })
265
+
266
+ const deleteUrl = `http://localhost:${server.port}${attachment.contentUrl.replace(/\/content$/, "")}`
267
+ const response = await fetch(deleteUrl, { method: "DELETE" })
268
+ expect(response.status).toBe(200)
269
+ expect(await response.json()).toEqual({ ok: true })
270
+ expect(await Bun.file(attachment.absolutePath).exists()).toBe(false)
271
+ } finally {
272
+ await server.stop()
273
+ }
274
+ })
275
+
276
+ test("infers text-friendly content types for previewable source files", () => {
277
+ expect(inferAttachmentContentType("notes.txt")).toBe("text/plain; charset=utf-8")
278
+ expect(inferAttachmentContentType("README.md")).toBe("text/markdown; charset=utf-8")
279
+ expect(inferAttachmentContentType("main.ts", "video/mp2t")).toBe("text/plain; charset=utf-8")
280
+ expect(inferAttachmentContentType("archive.zip", "application/zip")).toBe("application/zip")
281
+ })
282
+ })
@@ -0,0 +1,127 @@
1
+ import { randomUUID } from "node:crypto"
2
+ import { mkdir, open, rm } from "node:fs/promises"
3
+ import path from "node:path"
4
+ import { fileTypeFromBuffer } from "file-type"
5
+ import type { ChatAttachment } from "../shared/types"
6
+ import { getProjectUploadDir } from "./paths"
7
+
8
+ const DEFAULT_BINARY_MIME_TYPE = "application/octet-stream"
9
+ const IMAGE_MIME_PREFIX = "image/"
10
+ const TEXT_PLAIN_CONTENT_TYPE = "text/plain; charset=utf-8"
11
+
12
+ const TEXT_CONTENT_TYPE_BY_EXTENSION = new Map<string, string>([
13
+ [".csv", "text/csv; charset=utf-8"],
14
+ [".json", "application/json; charset=utf-8"],
15
+ [".jsonc", TEXT_PLAIN_CONTENT_TYPE],
16
+ [".md", "text/markdown; charset=utf-8"],
17
+ [".tsv", "text/tab-separated-values; charset=utf-8"],
18
+ ])
19
+
20
+ const TEXT_LIKE_EXTENSIONS = new Set([
21
+ ".c", ".cc", ".cfg", ".conf", ".cpp", ".cs", ".css", ".env", ".go", ".graphql", ".h", ".hpp", ".html",
22
+ ".ini", ".java", ".js", ".jsx", ".kt", ".lua", ".mjs", ".php", ".pl", ".properties", ".py", ".rb", ".rs",
23
+ ".scss", ".sh", ".sql", ".swift", ".toml", ".ts", ".tsx", ".txt", ".vue", ".xml", ".yaml", ".yml", ".zsh",
24
+ ])
25
+
26
+ function sanitizeFileName(fileName: string) {
27
+ const baseName = path.basename(fileName).trim()
28
+ const cleaned = baseName.replace(/[^\w.-]+/g, "-").replace(/^-+|-+$/g, "")
29
+ return cleaned || "upload"
30
+ }
31
+
32
+ function getUploadCandidateNames(originalName: string) {
33
+ const sanitizedName = sanitizeFileName(originalName)
34
+ const parsed = path.parse(sanitizedName)
35
+ const extension = parsed.ext
36
+ const name = parsed.name || "upload"
37
+
38
+ return {
39
+ first: sanitizedName,
40
+ withCounter(counter: number) {
41
+ return `${name}-${counter}${extension}`
42
+ },
43
+ }
44
+ }
45
+
46
+ export async function persistProjectUpload(args: {
47
+ projectId: string
48
+ localPath: string
49
+ fileName: string
50
+ bytes: Uint8Array
51
+ fallbackMimeType?: string
52
+ }): Promise<ChatAttachment> {
53
+ const uploadDir = getProjectUploadDir(args.localPath)
54
+ await mkdir(uploadDir, { recursive: true })
55
+
56
+ const detectedType = await fileTypeFromBuffer(args.bytes)
57
+ const mimeType = detectedType?.mime ?? args.fallbackMimeType ?? DEFAULT_BINARY_MIME_TYPE
58
+ const candidates = getUploadCandidateNames(args.fileName)
59
+
60
+ let storedName = candidates.first
61
+ let absolutePath = path.join(uploadDir, storedName)
62
+ let counter = 1
63
+
64
+ while (true) {
65
+ try {
66
+ const handle = await open(absolutePath, "wx")
67
+ try {
68
+ await handle.writeFile(args.bytes)
69
+ } finally {
70
+ await handle.close()
71
+ }
72
+ break
73
+ } catch (error) {
74
+ const code = error instanceof Error && "code" in error ? (error as NodeJS.ErrnoException).code : undefined
75
+ if (code !== "EEXIST") {
76
+ throw error
77
+ }
78
+
79
+ storedName = candidates.withCounter(counter)
80
+ absolutePath = path.join(uploadDir, storedName)
81
+ counter += 1
82
+ }
83
+ }
84
+
85
+ return {
86
+ id: randomUUID(),
87
+ kind: mimeType.startsWith(IMAGE_MIME_PREFIX) ? "image" : "file",
88
+ displayName: args.fileName,
89
+ absolutePath,
90
+ relativePath: `./.kanna/uploads/${storedName}`,
91
+ contentUrl: `/api/projects/${args.projectId}/uploads/${encodeURIComponent(storedName)}/content`,
92
+ mimeType,
93
+ size: args.bytes.byteLength,
94
+ }
95
+ }
96
+
97
+ export function inferAttachmentContentType(fileName: string, fallbackType?: string): string {
98
+ const extension = path.extname(fileName).toLowerCase()
99
+ const mappedType = TEXT_CONTENT_TYPE_BY_EXTENSION.get(extension)
100
+ if (mappedType) {
101
+ return mappedType
102
+ }
103
+
104
+ if (TEXT_LIKE_EXTENSIONS.has(extension)) {
105
+ return TEXT_PLAIN_CONTENT_TYPE
106
+ }
107
+
108
+ return fallbackType || DEFAULT_BINARY_MIME_TYPE
109
+ }
110
+
111
+ export async function deleteProjectUpload(args: {
112
+ localPath: string
113
+ storedName: string
114
+ }): Promise<boolean> {
115
+ const storedName = args.storedName
116
+ if (!storedName || storedName.includes("/") || storedName.includes("\\") || storedName === "." || storedName === "..") {
117
+ return false
118
+ }
119
+
120
+ const absolutePath = path.join(getProjectUploadDir(args.localPath), storedName)
121
+ try {
122
+ await rm(absolutePath, { force: true })
123
+ return true
124
+ } catch {
125
+ return false
126
+ }
127
+ }
@@ -176,6 +176,187 @@ describe("ws-router", () => {
176
176
  })
177
177
  })
178
178
 
179
+ test("marks chats read and rebroadcasts sidebar snapshots", async () => {
180
+ const state = createEmptyState()
181
+ state.projectsById.set("project-1", {
182
+ id: "project-1",
183
+ localPath: "/tmp/project",
184
+ title: "Project",
185
+ createdAt: 1,
186
+ updatedAt: 1,
187
+ })
188
+ state.projectIdsByPath.set("/tmp/project", "project-1")
189
+ state.chatsById.set("chat-1", {
190
+ id: "chat-1",
191
+ projectId: "project-1",
192
+ title: "Chat",
193
+ createdAt: 1,
194
+ updatedAt: 1,
195
+ unread: true,
196
+ provider: null,
197
+ planMode: false,
198
+ sessionToken: null,
199
+ lastTurnOutcome: null,
200
+ })
201
+
202
+ const store = {
203
+ state,
204
+ async setChatReadState(chatId: string, unread: boolean) {
205
+ const chat = state.chatsById.get(chatId)
206
+ if (!chat) throw new Error("Chat not found")
207
+ chat.unread = unread
208
+ },
209
+ }
210
+
211
+ const router = createWsRouter({
212
+ store: store as never,
213
+ agent: { getActiveStatuses: () => new Map() } as never,
214
+ terminals: {
215
+ getSnapshot: () => null,
216
+ onEvent: () => () => {},
217
+ } as never,
218
+ keybindings: {
219
+ getSnapshot: () => DEFAULT_KEYBINDINGS_SNAPSHOT,
220
+ onChange: () => () => {},
221
+ } as never,
222
+ refreshDiscovery: async () => [],
223
+ getDiscoveredProjects: () => [],
224
+ machineDisplayName: "Local Machine",
225
+ updateManager: null,
226
+ })
227
+ const wsA = new FakeWebSocket()
228
+ const wsB = new FakeWebSocket()
229
+
230
+ router.handleOpen(wsA as never)
231
+ router.handleOpen(wsB as never)
232
+
233
+ router.handleMessage(
234
+ wsA as never,
235
+ JSON.stringify({
236
+ v: 1,
237
+ type: "subscribe",
238
+ id: "sidebar-a",
239
+ topic: { type: "sidebar" },
240
+ })
241
+ )
242
+ router.handleMessage(
243
+ wsB as never,
244
+ JSON.stringify({
245
+ v: 1,
246
+ type: "subscribe",
247
+ id: "sidebar-b",
248
+ topic: { type: "sidebar" },
249
+ })
250
+ )
251
+
252
+ router.handleMessage(
253
+ wsA as never,
254
+ JSON.stringify({
255
+ v: 1,
256
+ type: "command",
257
+ id: "mark-read-1",
258
+ command: { type: "chat.markRead", chatId: "chat-1" },
259
+ })
260
+ )
261
+
262
+ await Promise.resolve()
263
+
264
+ expect(wsA.sent.at(-2)).toEqual({
265
+ v: PROTOCOL_VERSION,
266
+ type: "ack",
267
+ id: "mark-read-1",
268
+ })
269
+ expect(wsA.sent.at(-1)).toEqual({
270
+ v: PROTOCOL_VERSION,
271
+ type: "snapshot",
272
+ id: "sidebar-a",
273
+ snapshot: {
274
+ type: "sidebar",
275
+ data: {
276
+ projectGroups: [{
277
+ groupKey: "project-1",
278
+ localPath: "/tmp/project",
279
+ chats: [{
280
+ _id: "chat-1",
281
+ _creationTime: 1,
282
+ chatId: "chat-1",
283
+ title: "Chat",
284
+ status: "idle",
285
+ unread: false,
286
+ localPath: "/tmp/project",
287
+ provider: null,
288
+ lastMessageAt: undefined,
289
+ hasAutomation: false,
290
+ }],
291
+ }],
292
+ },
293
+ },
294
+ })
295
+ expect(wsB.sent.at(-1)).toEqual({
296
+ v: PROTOCOL_VERSION,
297
+ type: "snapshot",
298
+ id: "sidebar-b",
299
+ snapshot: {
300
+ type: "sidebar",
301
+ data: {
302
+ projectGroups: [{
303
+ groupKey: "project-1",
304
+ localPath: "/tmp/project",
305
+ chats: [{
306
+ _id: "chat-1",
307
+ _creationTime: 1,
308
+ chatId: "chat-1",
309
+ title: "Chat",
310
+ status: "idle",
311
+ unread: false,
312
+ localPath: "/tmp/project",
313
+ provider: null,
314
+ lastMessageAt: undefined,
315
+ hasAutomation: false,
316
+ }],
317
+ }],
318
+ },
319
+ },
320
+ })
321
+ })
322
+
323
+ test("broadcasts background title-generation errors to connected clients", () => {
324
+ let reportBackgroundError: ((message: string) => void) | null | undefined
325
+ const router = createWsRouter({
326
+ store: { state: createEmptyState() } as never,
327
+ agent: {
328
+ getActiveStatuses: () => new Map(),
329
+ setBackgroundErrorReporter: (reporter: ((message: string) => void) | null) => {
330
+ reportBackgroundError = reporter
331
+ },
332
+ } as never,
333
+ terminals: {
334
+ getSnapshot: () => null,
335
+ onEvent: () => () => {},
336
+ } as never,
337
+ keybindings: {
338
+ getSnapshot: () => DEFAULT_KEYBINDINGS_SNAPSHOT,
339
+ onChange: () => () => {},
340
+ } as never,
341
+ refreshDiscovery: async () => [],
342
+ getDiscoveredProjects: () => [],
343
+ machineDisplayName: "Local Machine",
344
+ updateManager: null,
345
+ })
346
+ const ws = new FakeWebSocket()
347
+ router.handleOpen(ws as never)
348
+
349
+ reportBackgroundError?.("[title-generation] chat chat-1 failed")
350
+
351
+ expect(ws.sent).toEqual([
352
+ {
353
+ v: PROTOCOL_VERSION,
354
+ type: "error",
355
+ message: "[title-generation] chat chat-1 failed",
356
+ },
357
+ ])
358
+ })
359
+
179
360
  test("subscribes to keybindings snapshots and writes keybindings through the router", async () => {
180
361
  const initialSnapshot: KeybindingsSnapshot = DEFAULT_KEYBINDINGS_SNAPSHOT
181
362
  const keybindings = {
@@ -138,6 +138,16 @@ export function createWsRouter({
138
138
  }
139
139
  }
140
140
 
141
+ function broadcastError(message: string) {
142
+ for (const ws of sockets) {
143
+ send(ws, {
144
+ v: PROTOCOL_VERSION,
145
+ type: "error",
146
+ message,
147
+ })
148
+ }
149
+ }
150
+
141
151
  function pushTerminalSnapshot(terminalId: string) {
142
152
  for (const ws of sockets) {
143
153
  for (const [id, topic] of ws.data.subscriptions.entries()) {
@@ -183,6 +193,8 @@ export function createWsRouter({
183
193
  }
184
194
  }) ?? (() => {})
185
195
 
196
+ agent.setBackgroundErrorReporter?.(broadcastError)
197
+
186
198
  async function handleCommand(ws: ServerWebSocket<ClientState>, message: Extract<ClientEnvelope, { type: "command" }>) {
187
199
  const { command, id } = message
188
200
  try {
@@ -275,6 +287,11 @@ export function createWsRouter({
275
287
  send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
276
288
  break
277
289
  }
290
+ case "chat.markRead": {
291
+ await store.setChatReadState(command.chatId, false)
292
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
293
+ break
294
+ }
278
295
  case "chat.send": {
279
296
  const result = await agent.send(command)
280
297
  send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result })
@@ -374,6 +391,7 @@ export function createWsRouter({
374
391
  void handleCommand(ws, parsed)
375
392
  },
376
393
  dispose() {
394
+ agent.setBackgroundErrorReporter?.(null)
377
395
  disposeTerminalEvents()
378
396
  disposeKeybindingEvents()
379
397
  disposeUpdateEvents()
@@ -1,5 +1,6 @@
1
1
  import type {
2
2
  AgentProvider,
3
+ ChatAttachment,
3
4
  ChatSnapshot,
4
5
  KeybindingsSnapshot,
5
6
  LocalProjectsSnapshot,
@@ -61,12 +62,14 @@ export type ClientCommand =
61
62
  | { type: "chat.create"; projectId: string }
62
63
  | { type: "chat.rename"; chatId: string; title: string }
63
64
  | { type: "chat.delete"; chatId: string }
65
+ | { type: "chat.markRead"; chatId: string }
64
66
  | {
65
67
  type: "chat.send"
66
68
  chatId?: string
67
69
  projectId?: string
68
70
  provider?: AgentProvider
69
71
  content: string
72
+ attachments?: ChatAttachment[]
70
73
  model?: string
71
74
  modelOptions?: ModelOptions
72
75
  effort?: string