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
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
@@ -5,8 +5,8 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <link rel="icon" type="image/png" href="/favicon.png" />
7
7
  <title>Kanna</title>
8
- <script type="module" crossorigin src="/assets/index-CtKZIZdD.js"></script>
9
- <link rel="stylesheet" crossorigin href="/assets/index-Cwex1U8S.css">
8
+ <script type="module" crossorigin src="/assets/index-lgfU0zw2.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/assets/index-C952_xJD.css">
10
10
  </head>
11
11
  <body>
12
12
  <div id="root"></div>
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "kanna-code",
3
3
  "type": "module",
4
- "version": "0.17.0",
4
+ "version": "0.19.0",
5
5
  "description": "A beautiful web UI for Claude Code",
6
6
  "license": "MIT",
7
7
  "keywords": [
@@ -54,6 +54,7 @@
54
54
  "@xterm/xterm": "^6.0.0",
55
55
  "cloudflared": "^0.7.1",
56
56
  "default-shell": "^2.2.0",
57
+ "file-type": "^22.0.0",
57
58
  "qrcode": "^1.5.4",
58
59
  "react-resizable-panels": "^4.7.3"
59
60
  },
@@ -1,7 +1,7 @@
1
1
  import { describe, expect, test } from "bun:test"
2
- import { AgentCoordinator, normalizeClaudeStreamMessage } from "./agent"
2
+ import { AgentCoordinator, buildAttachmentHintText, buildPromptText, normalizeClaudeStreamMessage } from "./agent"
3
3
  import type { HarnessTurn } from "./harness-types"
4
- import type { TranscriptEntry } from "../shared/types"
4
+ import type { ChatAttachment, TranscriptEntry } from "../shared/types"
5
5
 
6
6
  function timestamped<T extends Omit<TranscriptEntry, "_id" | "createdAt">>(entry: T): TranscriptEntry {
7
7
  return {
@@ -63,8 +63,74 @@ describe("normalizeClaudeStreamMessage", () => {
63
63
  })
64
64
  })
65
65
 
66
+ describe("attachment prompt helpers", () => {
67
+ test("appends a structured attachment hint block for all attachment kinds", () => {
68
+ const attachments: ChatAttachment[] = [
69
+ {
70
+ id: "image-1",
71
+ kind: "image",
72
+ displayName: "shot.png",
73
+ absolutePath: "/tmp/project/.kanna/uploads/shot.png",
74
+ relativePath: "./.kanna/uploads/shot.png",
75
+ contentUrl: "/api/projects/project-1/uploads/shot.png/content",
76
+ mimeType: "image/png",
77
+ size: 512,
78
+ },
79
+ {
80
+ id: "file-1",
81
+ kind: "file",
82
+ displayName: "spec.pdf",
83
+ absolutePath: "/tmp/project/.kanna/uploads/spec.pdf",
84
+ relativePath: "./.kanna/uploads/spec.pdf",
85
+ contentUrl: "/api/projects/project-1/uploads/spec.pdf/content",
86
+ mimeType: "application/pdf",
87
+ size: 1234,
88
+ },
89
+ ]
90
+
91
+ const prompt = buildPromptText("Review these", attachments)
92
+ expect(prompt).toContain("<kanna-attachments>")
93
+ expect(prompt).toContain('path="/tmp/project/.kanna/uploads/shot.png"')
94
+ expect(prompt).toContain('project_path="./.kanna/uploads/spec.pdf"')
95
+ })
96
+
97
+ test("supports attachment-only prompts", () => {
98
+ const attachments: ChatAttachment[] = [{
99
+ id: "file-1",
100
+ kind: "file",
101
+ displayName: "todo.txt",
102
+ absolutePath: "/tmp/project/.kanna/uploads/todo.txt",
103
+ relativePath: "./.kanna/uploads/todo.txt",
104
+ contentUrl: "/api/projects/project-1/uploads/todo.txt/content",
105
+ mimeType: "text/plain",
106
+ size: 32,
107
+ }]
108
+
109
+ expect(buildPromptText("", attachments)).toContain("Please inspect the attached files.")
110
+ })
111
+
112
+ test("escapes xml attribute values for attachment hint markup", () => {
113
+ const hint = buildAttachmentHintText([{
114
+ id: "file-1",
115
+ kind: "file",
116
+ displayName: "\"report\" <draft>.txt",
117
+ absolutePath: "/tmp/project/.kanna/uploads/report.txt",
118
+ relativePath: "./.kanna/uploads/report.txt",
119
+ contentUrl: "/api/projects/project-1/uploads/report.txt/content",
120
+ mimeType: "text/plain",
121
+ size: 64,
122
+ }])
123
+
124
+ expect(hint).toContain("&quot;report&quot; &lt;draft&gt;.txt")
125
+ })
126
+ })
127
+
66
128
  describe("AgentCoordinator codex integration", () => {
67
129
  test("generates a chat title in the background on the first user message", async () => {
130
+ let releaseTitle!: () => void
131
+ const titleGate = new Promise<void>((resolve) => {
132
+ releaseTitle = resolve
133
+ })
68
134
  const fakeCodexManager = {
69
135
  async startSession() {},
70
136
  async startTurn(): Promise<HarnessTurn> {
@@ -107,7 +173,14 @@ describe("AgentCoordinator codex integration", () => {
107
173
  store: store as never,
108
174
  onStateChange: () => {},
109
175
  codexManager: fakeCodexManager as never,
110
- generateTitle: async () => "Generated title",
176
+ generateTitle: async () => {
177
+ await titleGate
178
+ return {
179
+ title: "Generated title",
180
+ usedFallback: false,
181
+ failureMessage: null,
182
+ }
183
+ },
111
184
  })
112
185
 
113
186
  await coordinator.send({
@@ -118,6 +191,8 @@ describe("AgentCoordinator codex integration", () => {
118
191
  model: "gpt-5.4",
119
192
  })
120
193
 
194
+ expect(store.chat.title).toBe("first message")
195
+ releaseTitle()
121
196
  await waitFor(() => store.chat.title === "Generated title")
122
197
  expect(store.messages[0]?.kind).toBe("user_prompt")
123
198
  })
@@ -171,7 +246,11 @@ describe("AgentCoordinator codex integration", () => {
171
246
  codexManager: fakeCodexManager as never,
172
247
  generateTitle: async () => {
173
248
  await titleGate
174
- return "Generated title"
249
+ return {
250
+ title: "Generated title",
251
+ usedFallback: false,
252
+ failureMessage: null,
253
+ }
175
254
  },
176
255
  })
177
256
 
@@ -190,6 +269,76 @@ describe("AgentCoordinator codex integration", () => {
190
269
  expect(store.chat.title).toBe("Manual title")
191
270
  })
192
271
 
272
+ test("reports provider failure without a second rename after the optimistic title", async () => {
273
+ const fakeCodexManager = {
274
+ async startSession() {},
275
+ async startTurn(): Promise<HarnessTurn> {
276
+ async function* stream() {
277
+ yield {
278
+ type: "transcript" as const,
279
+ entry: timestamped({
280
+ kind: "system_init",
281
+ provider: "codex",
282
+ model: "gpt-5.4",
283
+ tools: [],
284
+ agents: [],
285
+ slashCommands: [],
286
+ mcpServers: [],
287
+ }),
288
+ }
289
+ yield {
290
+ type: "transcript" as const,
291
+ entry: timestamped({
292
+ kind: "result",
293
+ subtype: "success",
294
+ isError: false,
295
+ durationMs: 0,
296
+ result: "",
297
+ }),
298
+ }
299
+ }
300
+
301
+ return {
302
+ provider: "codex",
303
+ stream: stream(),
304
+ interrupt: async () => {},
305
+ close: () => {},
306
+ }
307
+ },
308
+ }
309
+
310
+ const store = createFakeStore()
311
+ const backgroundErrors: string[] = []
312
+ const coordinator = new AgentCoordinator({
313
+ store: store as never,
314
+ onStateChange: () => {},
315
+ codexManager: fakeCodexManager as never,
316
+ generateTitle: async () => ({
317
+ title: "first message",
318
+ usedFallback: true,
319
+ failureMessage: "claude failed conversation title generation: Not authenticated",
320
+ }),
321
+ })
322
+ coordinator.setBackgroundErrorReporter((message) => {
323
+ backgroundErrors.push(message)
324
+ })
325
+
326
+ await coordinator.send({
327
+ type: "chat.send",
328
+ chatId: "chat-1",
329
+ provider: "codex",
330
+ content: "first message",
331
+ model: "gpt-5.4",
332
+ })
333
+
334
+ expect(store.chat.title).toBe("first message")
335
+ await waitFor(() => store.turnFinishedCount === 1)
336
+ expect(store.chat.title).toBe("first message")
337
+ expect(backgroundErrors).toEqual([
338
+ "[title-generation] chat chat-1 failed provider title generation: claude failed conversation title generation: Not authenticated",
339
+ ])
340
+ })
341
+
193
342
  test("binds codex provider and reuses the session token on later turns", async () => {
194
343
  const sessionCalls: Array<{ chatId: string; sessionToken: string | null }> = []
195
344
  const fakeCodexManager = {
@@ -1,6 +1,7 @@
1
1
  import { query, type CanUseTool, type PermissionResult, type Query } from "@anthropic-ai/claude-agent-sdk"
2
2
  import type {
3
3
  AgentProvider,
4
+ ChatAttachment,
4
5
  NormalizedToolCall,
5
6
  PendingToolSnapshot,
6
7
  KannaStatus,
@@ -10,7 +11,7 @@ import { normalizeToolCall } from "../shared/tools"
10
11
  import type { ClientCommand } from "../shared/protocol"
11
12
  import { EventStore } from "./event-store"
12
13
  import { CodexAppServerManager } from "./codex-app-server"
13
- import { generateTitleForChat } from "./generate-title"
14
+ import { type GenerateChatTitleResult, generateTitleForChatDetailed } from "./generate-title"
14
15
  import type { HarnessEvent, HarnessToolRequest, HarnessTurn } from "./harness-types"
15
16
  import {
16
17
  codexServiceTierFromModelOptions,
@@ -20,6 +21,7 @@ import {
20
21
  normalizeServerModel,
21
22
  } from "./provider-catalog"
22
23
  import { resolveClaudeApiModelId } from "../shared/types"
24
+ import { fallbackTitleFromMessage } from "./generate-title"
23
25
 
24
26
  const CLAUDE_TOOLSET = [
25
27
  "Skill",
@@ -66,7 +68,7 @@ interface AgentCoordinatorArgs {
66
68
  store: EventStore
67
69
  onStateChange: () => void
68
70
  codexManager?: CodexAppServerManager
69
- generateTitle?: (messageContent: string, cwd: string) => Promise<string | null>
71
+ generateTitle?: (messageContent: string, cwd: string) => Promise<GenerateChatTitleResult>
70
72
  }
71
73
 
72
74
  function timestamped<T extends Omit<TranscriptEntry, "_id" | "createdAt">>(
@@ -89,6 +91,41 @@ function stringFromUnknown(value: unknown) {
89
91
  }
90
92
  }
91
93
 
94
+ function escapeXmlAttribute(value: string) {
95
+ return value
96
+ .replaceAll("&", "&amp;")
97
+ .replaceAll("\"", "&quot;")
98
+ .replaceAll("<", "&lt;")
99
+ .replaceAll(">", "&gt;")
100
+ }
101
+
102
+ export function buildAttachmentHintText(attachments: ChatAttachment[]) {
103
+ if (attachments.length === 0) return ""
104
+
105
+ const lines = attachments.map((attachment) => (
106
+ `<attachment kind="${escapeXmlAttribute(attachment.kind)}" mime_type="${escapeXmlAttribute(attachment.mimeType)}" path="${escapeXmlAttribute(attachment.absolutePath)}" project_path="${escapeXmlAttribute(attachment.relativePath)}" size_bytes="${attachment.size}" display_name="${escapeXmlAttribute(attachment.displayName)}" />`
107
+ ))
108
+
109
+ return [
110
+ "<kanna-attachments>",
111
+ ...lines,
112
+ "</kanna-attachments>",
113
+ ].join("\n")
114
+ }
115
+
116
+ export function buildPromptText(content: string, attachments: ChatAttachment[]) {
117
+ const attachmentHint = buildAttachmentHintText(attachments)
118
+ if (!attachmentHint) {
119
+ return content.trim()
120
+ }
121
+
122
+ const trimmed = content.trim()
123
+ return [
124
+ trimmed || "Please inspect the attached files.",
125
+ attachmentHint,
126
+ ].join("\n\n").trim()
127
+ }
128
+
92
129
  function discardedToolResult(
93
130
  tool: NormalizedToolCall & { toolKind: "ask_user_question" | "exit_plan_mode" }
94
131
  ) {
@@ -334,14 +371,19 @@ export class AgentCoordinator {
334
371
  private readonly store: EventStore
335
372
  private readonly onStateChange: () => void
336
373
  private readonly codexManager: CodexAppServerManager
337
- private readonly generateTitle: (messageContent: string, cwd: string) => Promise<string | null>
374
+ private readonly generateTitle: (messageContent: string, cwd: string) => Promise<GenerateChatTitleResult>
375
+ private reportBackgroundError: ((message: string) => void) | null = null
338
376
  readonly activeTurns = new Map<string, ActiveTurn>()
339
377
 
340
378
  constructor(args: AgentCoordinatorArgs) {
341
379
  this.store = args.store
342
380
  this.onStateChange = args.onStateChange
343
381
  this.codexManager = args.codexManager ?? new CodexAppServerManager()
344
- this.generateTitle = args.generateTitle ?? generateTitleForChat
382
+ this.generateTitle = args.generateTitle ?? generateTitleForChatDetailed
383
+ }
384
+
385
+ setBackgroundErrorReporter(report: ((message: string) => void) | null) {
386
+ this.reportBackgroundError = report
345
387
  }
346
388
 
347
389
  getActiveStatuses() {
@@ -389,6 +431,7 @@ export class AgentCoordinator {
389
431
  chatId: string
390
432
  provider: AgentProvider
391
433
  content: string
434
+ attachments: ChatAttachment[]
392
435
  model: string
393
436
  effort?: string
394
437
  serviceTier?: "fast"
@@ -407,9 +450,17 @@ export class AgentCoordinator {
407
450
 
408
451
  const existingMessages = this.store.getMessages(args.chatId)
409
452
  const shouldGenerateTitle = args.appendUserPrompt && chat.title === "New Chat" && existingMessages.length === 0
453
+ const optimisticTitle = shouldGenerateTitle ? fallbackTitleFromMessage(args.content) : null
454
+
455
+ if (optimisticTitle) {
456
+ await this.store.renameChat(args.chatId, optimisticTitle)
457
+ }
410
458
 
411
459
  if (args.appendUserPrompt) {
412
- await this.store.appendMessage(args.chatId, timestamped({ kind: "user_prompt", content: args.content }, Date.now()))
460
+ await this.store.appendMessage(
461
+ args.chatId,
462
+ timestamped({ kind: "user_prompt", content: args.content, attachments: args.attachments }, Date.now())
463
+ )
413
464
  }
414
465
  await this.store.recordTurnStarted(args.chatId)
415
466
 
@@ -419,7 +470,7 @@ export class AgentCoordinator {
419
470
  }
420
471
 
421
472
  if (shouldGenerateTitle) {
422
- void this.generateTitleInBackground(args.chatId, args.content, project.localPath)
473
+ void this.generateTitleInBackground(args.chatId, args.content, project.localPath, optimisticTitle ?? "New Chat")
423
474
  }
424
475
 
425
476
  const onToolRequest = async (request: HarnessToolRequest): Promise<unknown> => {
@@ -443,7 +494,7 @@ export class AgentCoordinator {
443
494
  let turn: HarnessTurn
444
495
  if (args.provider === "claude") {
445
496
  turn = await startClaudeTurn({
446
- content: args.content,
497
+ content: buildPromptText(args.content, args.attachments),
447
498
  localPath: project.localPath,
448
499
  model: args.model,
449
500
  effort: args.effort,
@@ -461,7 +512,7 @@ export class AgentCoordinator {
461
512
  })
462
513
  turn = await this.codexManager.startTurn({
463
514
  chatId: args.chatId,
464
- content: args.content,
515
+ content: buildPromptText(args.content, args.attachments),
465
516
  model: args.model,
466
517
  effort: args.effort as any,
467
518
  serviceTier: args.serviceTier,
@@ -519,6 +570,7 @@ export class AgentCoordinator {
519
570
  chatId,
520
571
  provider,
521
572
  content: command.content,
573
+ attachments: command.attachments ?? [],
522
574
  model: settings.model,
523
575
  effort: settings.effort,
524
576
  serviceTier: settings.serviceTier,
@@ -529,18 +581,26 @@ export class AgentCoordinator {
529
581
  return { chatId }
530
582
  }
531
583
 
532
- private async generateTitleInBackground(chatId: string, messageContent: string, cwd: string) {
584
+ private async generateTitleInBackground(chatId: string, messageContent: string, cwd: string, expectedCurrentTitle: string) {
533
585
  try {
534
- const title = await this.generateTitle(messageContent, cwd)
535
- if (!title) return
586
+ const result = await this.generateTitle(messageContent, cwd)
587
+ if (result.failureMessage) {
588
+ this.reportBackgroundError?.(
589
+ `[title-generation] chat ${chatId} failed provider title generation: ${result.failureMessage}`
590
+ )
591
+ }
592
+ if (!result.title || result.usedFallback) return
536
593
 
537
594
  const chat = this.store.requireChat(chatId)
538
- if (chat.title !== "New Chat") return
595
+ if (chat.title !== expectedCurrentTitle) return
539
596
 
540
- await this.store.renameChat(chatId, title)
597
+ await this.store.renameChat(chatId, result.title)
541
598
  this.onStateChange()
542
- } catch {
543
- // Ignore background title generation failures.
599
+ } catch (error) {
600
+ const message = error instanceof Error ? error.message : String(error)
601
+ this.reportBackgroundError?.(
602
+ `[title-generation] chat ${chatId} failed background title generation: ${message}`
603
+ )
544
604
  }
545
605
  }
546
606
 
@@ -600,6 +660,7 @@ export class AgentCoordinator {
600
660
  chatId: active.chatId,
601
661
  provider: active.provider,
602
662
  content: active.postToolFollowUp.content,
663
+ attachments: [],
603
664
  model: active.model,
604
665
  effort: active.effort,
605
666
  serviceTier: active.serviceTier,
@@ -65,6 +65,7 @@ describe("EventStore", () => {
65
65
  title: "Chat",
66
66
  createdAt: 1,
67
67
  updatedAt: 5,
68
+ unread: false,
68
69
  provider: null,
69
70
  planMode: false,
70
71
  sessionToken: null,
@@ -130,4 +131,68 @@ describe("EventStore", () => {
130
131
  expect(snapshot.messages).toBeUndefined()
131
132
  expect(existsSync(join(dataDir, "transcripts", `${chat.id}.jsonl`))).toBe(true)
132
133
  })
134
+
135
+ test("marks chats unread on completed turns and clears unread when marked read", async () => {
136
+ const dataDir = await createTempDataDir()
137
+ const store = new EventStore(dataDir)
138
+ await store.initialize()
139
+
140
+ const project = await store.openProject("/tmp/project")
141
+ const chat = await store.createChat(project.id)
142
+
143
+ expect(store.getChat(chat.id)?.unread).toBe(false)
144
+
145
+ await store.recordTurnFinished(chat.id)
146
+ expect(store.getChat(chat.id)?.unread).toBe(true)
147
+
148
+ await store.setChatReadState(chat.id, false)
149
+ expect(store.getChat(chat.id)?.unread).toBe(false)
150
+
151
+ await store.recordTurnFailed(chat.id, "boom")
152
+ expect(store.getChat(chat.id)?.unread).toBe(true)
153
+
154
+ await store.recordTurnCancelled(chat.id)
155
+ expect(store.getChat(chat.id)?.unread).toBe(true)
156
+
157
+ await store.compact()
158
+
159
+ const reloaded = new EventStore(dataDir)
160
+ await reloaded.initialize()
161
+ expect(reloaded.getChat(chat.id)?.unread).toBe(true)
162
+ })
163
+
164
+ test("loads chats without unread from older snapshots as read", async () => {
165
+ const dataDir = await createTempDataDir()
166
+ const snapshotPath = join(dataDir, "snapshot.json")
167
+
168
+ const snapshot = {
169
+ v: 2,
170
+ generatedAt: 10,
171
+ projects: [{
172
+ id: "project-1",
173
+ localPath: "/tmp/project",
174
+ title: "Project",
175
+ createdAt: 1,
176
+ updatedAt: 5,
177
+ }],
178
+ chats: [{
179
+ id: "chat-1",
180
+ projectId: "project-1",
181
+ title: "Chat",
182
+ createdAt: 1,
183
+ updatedAt: 5,
184
+ provider: null,
185
+ planMode: false,
186
+ sessionToken: null,
187
+ lastTurnOutcome: null,
188
+ }],
189
+ }
190
+
191
+ await writeFile(snapshotPath, JSON.stringify(snapshot, null, 2), "utf8")
192
+
193
+ const store = new EventStore(dataDir)
194
+ await store.initialize()
195
+
196
+ expect(store.getChat("chat-1")?.unread).toBe(false)
197
+ })
133
198
  })
@@ -105,7 +105,10 @@ export class EventStore {
105
105
  this.state.projectIdsByPath.set(project.localPath, project.id)
106
106
  }
107
107
  for (const chat of parsed.chats) {
108
- this.state.chatsById.set(chat.id, { ...chat })
108
+ this.state.chatsById.set(chat.id, {
109
+ ...chat,
110
+ unread: chat.unread ?? false,
111
+ })
109
112
  }
110
113
  if (parsed.messages?.length) {
111
114
  this.snapshotHasLegacyMessages = true
@@ -210,6 +213,7 @@ export class EventStore {
210
213
  title: event.title,
211
214
  createdAt: event.timestamp,
212
215
  updatedAt: event.timestamp,
216
+ unread: false,
213
217
  provider: null,
214
218
  planMode: false,
215
219
  sessionToken: null,
@@ -246,6 +250,13 @@ export class EventStore {
246
250
  chat.updatedAt = event.timestamp
247
251
  break
248
252
  }
253
+ case "chat_read_state_set": {
254
+ const chat = this.state.chatsById.get(event.chatId)
255
+ if (!chat) break
256
+ chat.unread = event.unread
257
+ chat.updatedAt = event.timestamp
258
+ break
259
+ }
249
260
  case "message_appended": {
250
261
  this.applyMessageMetadata(event.chatId, event.entry)
251
262
  const existing = this.legacyMessagesByChatId.get(event.chatId) ?? []
@@ -263,6 +274,7 @@ export class EventStore {
263
274
  const chat = this.state.chatsById.get(event.chatId)
264
275
  if (!chat) break
265
276
  chat.updatedAt = event.timestamp
277
+ chat.unread = true
266
278
  chat.lastTurnOutcome = "success"
267
279
  break
268
280
  }
@@ -270,6 +282,7 @@ export class EventStore {
270
282
  const chat = this.state.chatsById.get(event.chatId)
271
283
  if (!chat) break
272
284
  chat.updatedAt = event.timestamp
285
+ chat.unread = true
273
286
  chat.lastTurnOutcome = "failed"
274
287
  break
275
288
  }
@@ -438,6 +451,19 @@ export class EventStore {
438
451
  await this.append(this.chatsLogPath, event)
439
452
  }
440
453
 
454
+ async setChatReadState(chatId: string, unread: boolean) {
455
+ const chat = this.requireChat(chatId)
456
+ if (chat.unread === unread) return
457
+ const event: ChatEvent = {
458
+ v: STORE_VERSION,
459
+ type: "chat_read_state_set",
460
+ timestamp: Date.now(),
461
+ chatId,
462
+ unread,
463
+ }
464
+ await this.append(this.chatsLogPath, event)
465
+ }
466
+
441
467
  async appendMessage(chatId: string, entry: TranscriptEntry) {
442
468
  this.requireChat(chatId)
443
469
  const payload = `${JSON.stringify(entry)}\n`
@@ -11,6 +11,7 @@ export interface ChatRecord {
11
11
  createdAt: number
12
12
  updatedAt: number
13
13
  deletedAt?: number
14
+ unread: boolean
14
15
  provider: AgentProvider | null
15
16
  planMode: boolean
16
17
  sessionToken: string | null
@@ -82,6 +83,13 @@ export type ChatEvent =
82
83
  chatId: string
83
84
  planMode: boolean
84
85
  }
86
+ | {
87
+ v: 2
88
+ type: "chat_read_state_set"
89
+ timestamp: number
90
+ chatId: string
91
+ unread: boolean
92
+ }
85
93
 
86
94
  export type MessageEvent = {
87
95
  v: 2
@@ -16,12 +16,39 @@ function normalizeGeneratedTitle(value: unknown): string | null {
16
16
  return normalized
17
17
  }
18
18
 
19
+ export function fallbackTitleFromMessage(messageContent: string): string | null {
20
+ const normalized = messageContent.replace(/\s+/g, " ").trim()
21
+ if (!normalized) return null
22
+ if (normalized.length <= 35) return normalized
23
+ return `${normalized.slice(0, 35)}...`
24
+ }
25
+
26
+ export interface GenerateChatTitleResult {
27
+ title: string | null
28
+ usedFallback: boolean
29
+ failureMessage: string | null
30
+ }
31
+
32
+ function summarizeFailures(failures: Array<{ provider: "claude" | "codex"; reason: string }>) {
33
+ if (failures.length === 0) return null
34
+ return failures.map((failure) => failure.reason).join("; ")
35
+ }
36
+
19
37
  export async function generateTitleForChat(
20
38
  messageContent: string,
21
39
  cwd: string,
22
40
  adapter = new QuickResponseAdapter()
23
41
  ): Promise<string | null> {
24
- const result = await adapter.generateStructured<string>({
42
+ const result = await generateTitleForChatDetailed(messageContent, cwd, adapter)
43
+ return result.title
44
+ }
45
+
46
+ export async function generateTitleForChatDetailed(
47
+ messageContent: string,
48
+ cwd: string,
49
+ adapter = new QuickResponseAdapter()
50
+ ): Promise<GenerateChatTitleResult> {
51
+ const result = await adapter.generateStructuredWithDiagnostics<string>({
25
52
  cwd,
26
53
  task: "conversation title generation",
27
54
  prompt: `Generate a short, descriptive title (under 30 chars) for a conversation that starts with this message.\n\n${messageContent}`,
@@ -32,5 +59,18 @@ export async function generateTitleForChat(
32
59
  },
33
60
  })
34
61
 
35
- return result
62
+ if (result.value) {
63
+ return {
64
+ title: result.value,
65
+ usedFallback: false,
66
+ failureMessage: null,
67
+ }
68
+ }
69
+
70
+ const fallbackTitle = fallbackTitleFromMessage(messageContent)
71
+ return {
72
+ title: fallbackTitle,
73
+ usedFallback: true,
74
+ failureMessage: summarizeFailures(result.failures),
75
+ }
36
76
  }
@@ -25,3 +25,7 @@ export async function ensureProjectDirectory(localPath: string) {
25
25
  throw new Error("Project path must be a directory")
26
26
  }
27
27
  }
28
+
29
+ export function getProjectUploadDir(localPath: string) {
30
+ return path.join(resolveLocalPath(localPath), ".kanna", "uploads")
31
+ }