kanna-code 0.18.0 → 0.20.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.
@@ -1,4 +1,4 @@
1
- import { query, type CanUseTool, type PermissionResult, type Query } from "@anthropic-ai/claude-agent-sdk"
1
+ import { query, type CanUseTool, type PermissionResult, type Query, type SDKUserMessage } from "@anthropic-ai/claude-agent-sdk"
2
2
  import type {
3
3
  AgentProvider,
4
4
  ChatAttachment,
@@ -11,7 +11,7 @@ import { normalizeToolCall } from "../shared/tools"
11
11
  import type { ClientCommand } from "../shared/protocol"
12
12
  import { EventStore } from "./event-store"
13
13
  import { CodexAppServerManager } from "./codex-app-server"
14
- import { generateTitleForChat } from "./generate-title"
14
+ import { type GenerateChatTitleResult, generateTitleForChatDetailed } from "./generate-title"
15
15
  import type { HarnessEvent, HarnessToolRequest, HarnessTurn } from "./harness-types"
16
16
  import {
17
17
  codexServiceTierFromModelOptions,
@@ -21,6 +21,7 @@ import {
21
21
  normalizeServerModel,
22
22
  } from "./provider-catalog"
23
23
  import { resolveClaudeApiModelId } from "../shared/types"
24
+ import { fallbackTitleFromMessage } from "./generate-title"
24
25
 
25
26
  const CLAUDE_TOOLSET = [
26
27
  "Skill",
@@ -63,11 +64,41 @@ interface ActiveTurn {
63
64
  cancelRecorded: boolean
64
65
  }
65
66
 
67
+ interface ClaudeSessionHandle {
68
+ provider: "claude"
69
+ stream: AsyncIterable<HarnessEvent>
70
+ getAccountInfo?: () => Promise<any>
71
+ interrupt: () => Promise<void>
72
+ close: () => void
73
+ sendPrompt: (content: string) => Promise<void>
74
+ setModel: (model: string) => Promise<void>
75
+ setPermissionMode: (planMode: boolean) => Promise<void>
76
+ }
77
+
78
+ interface ClaudeSessionState {
79
+ chatId: string
80
+ session: ClaudeSessionHandle
81
+ localPath: string
82
+ model: string
83
+ effort?: string
84
+ planMode: boolean
85
+ sessionToken: string | null
86
+ accountInfoLoaded: boolean
87
+ }
88
+
66
89
  interface AgentCoordinatorArgs {
67
90
  store: EventStore
68
91
  onStateChange: () => void
69
92
  codexManager?: CodexAppServerManager
70
- generateTitle?: (messageContent: string, cwd: string) => Promise<string | null>
93
+ generateTitle?: (messageContent: string, cwd: string) => Promise<GenerateChatTitleResult>
94
+ startClaudeSession?: (args: {
95
+ localPath: string
96
+ model: string
97
+ effort?: string
98
+ planMode: boolean
99
+ sessionToken: string | null
100
+ onToolRequest: (request: HarnessToolRequest) => Promise<unknown>
101
+ }) => Promise<ClaudeSessionHandle>
71
102
  }
72
103
 
73
104
  function timestamped<T extends Omit<TranscriptEntry, "_id" | "createdAt">>(
@@ -268,15 +299,61 @@ async function* createClaudeHarnessStream(q: Query): AsyncGenerator<HarnessEvent
268
299
  }
269
300
  }
270
301
 
271
- async function startClaudeTurn(args: {
272
- content: string
302
+ class AsyncMessageQueue<T> implements AsyncIterable<T> {
303
+ private readonly values: T[] = []
304
+ private readonly waiters: Array<(result: IteratorResult<T>) => void> = []
305
+ private closed = false
306
+
307
+ push(value: T) {
308
+ if (this.closed) {
309
+ throw new Error("Cannot push to a closed queue")
310
+ }
311
+
312
+ const waiter = this.waiters.shift()
313
+ if (waiter) {
314
+ waiter({ done: false, value })
315
+ return
316
+ }
317
+
318
+ this.values.push(value)
319
+ }
320
+
321
+ close() {
322
+ if (this.closed) return
323
+ this.closed = true
324
+ while (this.waiters.length > 0) {
325
+ const waiter = this.waiters.shift()
326
+ waiter?.({ done: true, value: undefined as never })
327
+ }
328
+ }
329
+
330
+ [Symbol.asyncIterator](): AsyncIterator<T> {
331
+ return {
332
+ next: async () => {
333
+ if (this.values.length > 0) {
334
+ return { done: false, value: this.values.shift() as T }
335
+ }
336
+
337
+ if (this.closed) {
338
+ return { done: true, value: undefined as never }
339
+ }
340
+
341
+ return await new Promise<IteratorResult<T>>((resolve) => {
342
+ this.waiters.push(resolve)
343
+ })
344
+ },
345
+ }
346
+ }
347
+ }
348
+
349
+ async function startClaudeSession(args: {
273
350
  localPath: string
274
351
  model: string
275
352
  effort?: string
276
353
  planMode: boolean
277
354
  sessionToken: string | null
278
355
  onToolRequest: (request: HarnessToolRequest) => Promise<unknown>
279
- }): Promise<HarnessTurn> {
356
+ }): Promise<ClaudeSessionHandle> {
280
357
  const canUseTool: CanUseTool = async (toolName, input, options) => {
281
358
  if (toolName !== "AskUserQuestion" && toolName !== "ExitPlanMode") {
282
359
  return {
@@ -332,8 +409,10 @@ async function startClaudeTurn(args: {
332
409
  } satisfies PermissionResult
333
410
  }
334
411
 
412
+ const promptQueue = new AsyncMessageQueue<SDKUserMessage>()
413
+
335
414
  const q = query({
336
- prompt: args.content,
415
+ prompt: promptQueue,
337
416
  options: {
338
417
  cwd: args.localPath,
339
418
  model: args.model,
@@ -360,7 +439,25 @@ async function startClaudeTurn(args: {
360
439
  interrupt: async () => {
361
440
  await q.interrupt()
362
441
  },
442
+ sendPrompt: async (content: string) => {
443
+ promptQueue.push({
444
+ type: "user",
445
+ message: {
446
+ role: "user",
447
+ content,
448
+ },
449
+ parent_tool_use_id: null,
450
+ session_id: args.sessionToken ?? "",
451
+ })
452
+ },
453
+ setModel: async (model: string) => {
454
+ await q.setModel(model)
455
+ },
456
+ setPermissionMode: async (planMode: boolean) => {
457
+ await q.setPermissionMode(planMode ? "plan" : "acceptEdits")
458
+ },
363
459
  close: () => {
460
+ promptQueue.close()
364
461
  q.close()
365
462
  },
366
463
  }
@@ -370,14 +467,23 @@ export class AgentCoordinator {
370
467
  private readonly store: EventStore
371
468
  private readonly onStateChange: () => void
372
469
  private readonly codexManager: CodexAppServerManager
373
- private readonly generateTitle: (messageContent: string, cwd: string) => Promise<string | null>
470
+ private readonly generateTitle: (messageContent: string, cwd: string) => Promise<GenerateChatTitleResult>
471
+ private readonly startClaudeSessionFn: NonNullable<AgentCoordinatorArgs["startClaudeSession"]>
472
+ private reportBackgroundError: ((message: string) => void) | null = null
374
473
  readonly activeTurns = new Map<string, ActiveTurn>()
474
+ readonly drainingStreams = new Map<string, { turn: HarnessTurn }>()
475
+ readonly claudeSessions = new Map<string, ClaudeSessionState>()
375
476
 
376
477
  constructor(args: AgentCoordinatorArgs) {
377
478
  this.store = args.store
378
479
  this.onStateChange = args.onStateChange
379
480
  this.codexManager = args.codexManager ?? new CodexAppServerManager()
380
- this.generateTitle = args.generateTitle ?? generateTitleForChat
481
+ this.generateTitle = args.generateTitle ?? generateTitleForChatDetailed
482
+ this.startClaudeSessionFn = args.startClaudeSession ?? startClaudeSession
483
+ }
484
+
485
+ setBackgroundErrorReporter(report: ((message: string) => void) | null) {
486
+ this.reportBackgroundError = report
381
487
  }
382
488
 
383
489
  getActiveStatuses() {
@@ -394,6 +500,28 @@ export class AgentCoordinator {
394
500
  return { toolUseId: pending.toolUseId, toolKind: pending.tool.toolKind }
395
501
  }
396
502
 
503
+ getDrainingChatIds(): Set<string> {
504
+ return new Set(this.drainingStreams.keys())
505
+ }
506
+
507
+ async stopDraining(chatId: string) {
508
+ const draining = this.drainingStreams.get(chatId)
509
+ if (!draining) return
510
+ draining.turn.close()
511
+ this.drainingStreams.delete(chatId)
512
+ this.onStateChange()
513
+ }
514
+
515
+ async closeChat(chatId: string) {
516
+ await this.stopDraining(chatId)
517
+ const claudeSession = this.claudeSessions.get(chatId)
518
+ if (claudeSession) {
519
+ claudeSession.session.close()
520
+ this.claudeSessions.delete(chatId)
521
+ }
522
+ this.onStateChange()
523
+ }
524
+
397
525
  private resolveProvider(command: Extract<ClientCommand, { type: "chat.send" }>, currentProvider: AgentProvider | null) {
398
526
  if (currentProvider) return currentProvider
399
527
  return command.provider ?? "claude"
@@ -432,6 +560,13 @@ export class AgentCoordinator {
432
560
  planMode: boolean
433
561
  appendUserPrompt: boolean
434
562
  }) {
563
+ // Close any lingering draining stream before starting a new turn.
564
+ const draining = this.drainingStreams.get(args.chatId)
565
+ if (draining) {
566
+ draining.turn.close()
567
+ this.drainingStreams.delete(args.chatId)
568
+ }
569
+
435
570
  const chat = this.store.requireChat(args.chatId)
436
571
  if (this.activeTurns.has(args.chatId)) {
437
572
  throw new Error("Chat is already running")
@@ -444,6 +579,11 @@ export class AgentCoordinator {
444
579
 
445
580
  const existingMessages = this.store.getMessages(args.chatId)
446
581
  const shouldGenerateTitle = args.appendUserPrompt && chat.title === "New Chat" && existingMessages.length === 0
582
+ const optimisticTitle = shouldGenerateTitle ? fallbackTitleFromMessage(args.content) : null
583
+
584
+ if (optimisticTitle) {
585
+ await this.store.renameChat(args.chatId, optimisticTitle)
586
+ }
447
587
 
448
588
  if (args.appendUserPrompt) {
449
589
  await this.store.appendMessage(
@@ -459,7 +599,7 @@ export class AgentCoordinator {
459
599
  }
460
600
 
461
601
  if (shouldGenerateTitle) {
462
- void this.generateTitleInBackground(args.chatId, args.content, project.localPath)
602
+ void this.generateTitleInBackground(args.chatId, args.content, project.localPath, optimisticTitle ?? "New Chat")
463
603
  }
464
604
 
465
605
  const onToolRequest = async (request: HarnessToolRequest): Promise<unknown> => {
@@ -482,8 +622,8 @@ export class AgentCoordinator {
482
622
 
483
623
  let turn: HarnessTurn
484
624
  if (args.provider === "claude") {
485
- turn = await startClaudeTurn({
486
- content: buildPromptText(args.content, args.attachments),
625
+ turn = await this.startClaudeTurn({
626
+ chatId: args.chatId,
487
627
  localPath: project.localPath,
488
628
  model: args.model,
489
629
  effort: args.effort,
@@ -518,7 +658,7 @@ export class AgentCoordinator {
518
658
  effort: args.effort,
519
659
  serviceTier: args.serviceTier,
520
660
  planMode: args.planMode,
521
- status: "starting",
661
+ status: args.provider === "claude" ? "running" : "starting",
522
662
  pendingTool: null,
523
663
  postToolFollowUp: null,
524
664
  hasFinalResult: false,
@@ -532,15 +672,93 @@ export class AgentCoordinator {
532
672
  void turn.getAccountInfo()
533
673
  .then(async (accountInfo) => {
534
674
  if (!accountInfo) return
675
+ if (args.provider === "claude") {
676
+ const session = this.claudeSessions.get(args.chatId)
677
+ if (session) {
678
+ if (session.accountInfoLoaded) return
679
+ session.accountInfoLoaded = true
680
+ } else {
681
+ return
682
+ }
683
+ }
535
684
  await this.store.appendMessage(args.chatId, timestamped({ kind: "account_info", accountInfo }))
536
685
  this.onStateChange()
537
686
  })
538
687
  .catch(() => undefined)
539
688
  }
540
689
 
690
+ if (args.provider === "claude") {
691
+ const session = this.claudeSessions.get(args.chatId)
692
+ if (!session) {
693
+ throw new Error("Claude session was not initialized")
694
+ }
695
+ await session.session.sendPrompt(buildPromptText(args.content, args.attachments))
696
+ return
697
+ }
698
+
541
699
  void this.runTurn(active)
542
700
  }
543
701
 
702
+ private async startClaudeTurn(args: {
703
+ chatId: string
704
+ localPath: string
705
+ model: string
706
+ effort?: string
707
+ planMode: boolean
708
+ sessionToken: string | null
709
+ onToolRequest: (request: HarnessToolRequest) => Promise<unknown>
710
+ }): Promise<HarnessTurn> {
711
+ let session = this.claudeSessions.get(args.chatId)
712
+
713
+ if (!session || session.localPath !== args.localPath || session.effort !== args.effort) {
714
+ if (session) {
715
+ session.session.close()
716
+ this.claudeSessions.delete(args.chatId)
717
+ }
718
+
719
+ const started = await this.startClaudeSessionFn({
720
+ localPath: args.localPath,
721
+ model: args.model,
722
+ effort: args.effort,
723
+ planMode: args.planMode,
724
+ sessionToken: args.sessionToken,
725
+ onToolRequest: args.onToolRequest,
726
+ })
727
+
728
+ session = {
729
+ chatId: args.chatId,
730
+ session: started,
731
+ localPath: args.localPath,
732
+ model: args.model,
733
+ effort: args.effort,
734
+ planMode: args.planMode,
735
+ sessionToken: args.sessionToken,
736
+ accountInfoLoaded: false,
737
+ }
738
+ this.claudeSessions.set(args.chatId, session)
739
+ void this.runClaudeSession(session)
740
+ } else {
741
+ if (session.model !== args.model) {
742
+ await session.session.setModel(args.model)
743
+ session.model = args.model
744
+ }
745
+ if (session.planMode !== args.planMode) {
746
+ await session.session.setPermissionMode(args.planMode)
747
+ session.planMode = args.planMode
748
+ }
749
+ }
750
+
751
+ return {
752
+ provider: "claude",
753
+ stream: {
754
+ async *[Symbol.asyncIterator]() {},
755
+ },
756
+ getAccountInfo: session.session.getAccountInfo,
757
+ interrupt: session.session.interrupt,
758
+ close: () => {},
759
+ }
760
+ }
761
+
544
762
  async send(command: Extract<ClientCommand, { type: "chat.send" }>) {
545
763
  let chatId = command.chatId
546
764
 
@@ -570,24 +788,96 @@ export class AgentCoordinator {
570
788
  return { chatId }
571
789
  }
572
790
 
573
- private async generateTitleInBackground(chatId: string, messageContent: string, cwd: string) {
791
+ private async runClaudeSession(session: ClaudeSessionState) {
792
+ try {
793
+ for await (const event of session.session.stream) {
794
+ if (event.type === "session_token" && event.sessionToken) {
795
+ session.sessionToken = event.sessionToken
796
+ await this.store.setSessionToken(session.chatId, event.sessionToken)
797
+ this.onStateChange()
798
+ continue
799
+ }
800
+
801
+ if (!event.entry) continue
802
+ await this.store.appendMessage(session.chatId, event.entry)
803
+
804
+ const active = this.activeTurns.get(session.chatId)
805
+ if (event.entry.kind === "system_init" && active) {
806
+ active.status = "running"
807
+ }
808
+
809
+ if (event.entry.kind === "result" && active) {
810
+ active.hasFinalResult = true
811
+ if (event.entry.isError) {
812
+ await this.store.recordTurnFailed(session.chatId, event.entry.result || "Turn failed")
813
+ } else if (!active.cancelRequested) {
814
+ await this.store.recordTurnFinished(session.chatId)
815
+ }
816
+ this.activeTurns.delete(session.chatId)
817
+ }
818
+
819
+ this.onStateChange()
820
+ }
821
+ } catch (error) {
822
+ const active = this.activeTurns.get(session.chatId)
823
+ if (active && !active.cancelRequested) {
824
+ const message = error instanceof Error ? error.message : String(error)
825
+ await this.store.appendMessage(
826
+ session.chatId,
827
+ timestamped({
828
+ kind: "result",
829
+ subtype: "error",
830
+ isError: true,
831
+ durationMs: 0,
832
+ result: message,
833
+ })
834
+ )
835
+ await this.store.recordTurnFailed(session.chatId, message)
836
+ }
837
+ } finally {
838
+ this.claudeSessions.delete(session.chatId)
839
+ const active = this.activeTurns.get(session.chatId)
840
+ if (active?.provider === "claude") {
841
+ if (active.cancelRequested && !active.cancelRecorded) {
842
+ await this.store.recordTurnCancelled(session.chatId)
843
+ }
844
+ this.activeTurns.delete(session.chatId)
845
+ }
846
+ session.session.close()
847
+ this.onStateChange()
848
+ }
849
+ }
850
+
851
+ private async generateTitleInBackground(chatId: string, messageContent: string, cwd: string, expectedCurrentTitle: string) {
574
852
  try {
575
- const title = await this.generateTitle(messageContent, cwd)
576
- if (!title) return
853
+ const result = await this.generateTitle(messageContent, cwd)
854
+ if (result.failureMessage) {
855
+ this.reportBackgroundError?.(
856
+ `[title-generation] chat ${chatId} failed provider title generation: ${result.failureMessage}`
857
+ )
858
+ }
859
+ if (!result.title || result.usedFallback) return
577
860
 
578
861
  const chat = this.store.requireChat(chatId)
579
- if (chat.title !== "New Chat") return
862
+ if (chat.title !== expectedCurrentTitle) return
580
863
 
581
- await this.store.renameChat(chatId, title)
864
+ await this.store.renameChat(chatId, result.title)
582
865
  this.onStateChange()
583
- } catch {
584
- // Ignore background title generation failures.
866
+ } catch (error) {
867
+ const message = error instanceof Error ? error.message : String(error)
868
+ this.reportBackgroundError?.(
869
+ `[title-generation] chat ${chatId} failed background title generation: ${message}`
870
+ )
585
871
  }
586
872
  }
587
873
 
588
874
  private async runTurn(active: ActiveTurn) {
589
875
  try {
590
876
  for await (const event of active.turn.stream) {
877
+ // Once cancelled, stop processing further stream events.
878
+ // cancel() already removed us from activeTurns and notified the UI.
879
+ if (active.cancelRequested) break
880
+
591
881
  if (event.type === "session_token" && event.sessionToken) {
592
882
  await this.store.setSessionToken(active.chatId, event.sessionToken)
593
883
  this.onStateChange()
@@ -608,6 +898,14 @@ export class AgentCoordinator {
608
898
  } else if (!active.cancelRequested) {
609
899
  await this.store.recordTurnFinished(active.chatId)
610
900
  }
901
+ // Remove from activeTurns as soon as the result arrives so the UI
902
+ // transitions to idle immediately. The stream may still be open
903
+ // (e.g. background tasks), but the user should be able to send
904
+ // new messages without having to hit stop first.
905
+ this.activeTurns.delete(active.chatId)
906
+ // Track the still-open stream so the UI can show a draining
907
+ // indicator and the user can stop background tasks.
908
+ this.drainingStreams.set(active.chatId, { turn: active.turn })
611
909
  }
612
910
 
613
911
  this.onStateChange()
@@ -632,7 +930,14 @@ export class AgentCoordinator {
632
930
  await this.store.recordTurnCancelled(active.chatId)
633
931
  }
634
932
  active.turn.close()
635
- this.activeTurns.delete(active.chatId)
933
+ // Only remove if we're still the active turn for this chat.
934
+ // We may have already been removed by result handling or cancel(),
935
+ // and a new turn may have started for the same chatId.
936
+ if (this.activeTurns.get(active.chatId) === active) {
937
+ this.activeTurns.delete(active.chatId)
938
+ }
939
+ // Stream has fully ended — no longer draining.
940
+ this.drainingStreams.delete(active.chatId)
636
941
  this.onStateChange()
637
942
 
638
943
  if (active.postToolFollowUp && !active.cancelRequested) {
@@ -668,9 +973,18 @@ export class AgentCoordinator {
668
973
  }
669
974
 
670
975
  async cancel(chatId: string) {
976
+ // Also clean up any draining stream for this chat.
977
+ const draining = this.drainingStreams.get(chatId)
978
+ if (draining) {
979
+ draining.turn.close()
980
+ this.drainingStreams.delete(chatId)
981
+ }
982
+
671
983
  const active = this.activeTurns.get(chatId)
672
984
  if (!active) return
673
985
 
986
+ // Guard against concurrent cancel() calls — only the first one does work.
987
+ if (active.cancelRequested) return
674
988
  active.cancelRequested = true
675
989
 
676
990
  const pendingTool = active.pendingTool
@@ -696,14 +1010,23 @@ export class AgentCoordinator {
696
1010
  active.cancelRecorded = true
697
1011
  active.hasFinalResult = true
698
1012
 
1013
+ // Remove from activeTurns immediately so the UI reflects the cancellation
1014
+ // right away, rather than waiting for interrupt() which may hang.
1015
+ this.activeTurns.delete(chatId)
1016
+ this.onStateChange()
1017
+
1018
+ // Now attempt to interrupt/close the underlying stream in the background.
1019
+ // This is best-effort — the turn is already removed from active state above,
1020
+ // and runTurn()'s finally block will also call close().
699
1021
  try {
700
- await active.turn.interrupt()
1022
+ await Promise.race([
1023
+ active.turn.interrupt(),
1024
+ new Promise((resolve) => setTimeout(resolve, 5_000)),
1025
+ ])
701
1026
  } catch {
702
- active.turn.close()
1027
+ // interrupt() failed — force close
703
1028
  }
704
-
705
- this.activeTurns.delete(chatId)
706
- this.onStateChange()
1029
+ active.turn.close()
707
1030
  }
708
1031
 
709
1032
  async respondTool(command: Extract<ClientCommand, { type: "chat.respondTool" }>) {
@@ -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`