kanna-code 0.26.5 → 0.26.7

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.
@@ -5,7 +5,7 @@
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-BaEB4jie.js"></script>
8
+ <script type="module" crossorigin src="/assets/index-CH3ZbXse.js"></script>
9
9
  <link rel="stylesheet" crossorigin href="/assets/index-C5RBxQW4.css">
10
10
  </head>
11
11
  <body>
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "kanna-code",
3
3
  "type": "module",
4
- "version": "0.26.5",
4
+ "version": "0.26.7",
5
5
  "description": "A beautiful web UI for Claude Code",
6
6
  "license": "MIT",
7
7
  "keywords": [
@@ -63,6 +63,8 @@ interface ActiveTurn {
63
63
  hasFinalResult: boolean
64
64
  cancelRequested: boolean
65
65
  cancelRecorded: boolean
66
+ clientTraceId?: string
67
+ profilingStartedAt?: number
66
68
  }
67
69
 
68
70
  interface ClaudeSessionHandle {
@@ -102,6 +104,11 @@ interface AgentCoordinatorArgs {
102
104
  }) => Promise<ClaudeSessionHandle>
103
105
  }
104
106
 
107
+ interface SendToStartingProfile {
108
+ traceId: string
109
+ startedAt: number
110
+ }
111
+
105
112
  function timestamped<T extends Omit<TranscriptEntry, "_id" | "createdAt">>(
106
113
  entry: T,
107
114
  createdAt = Date.now()
@@ -138,6 +145,31 @@ function escapeXmlAttribute(value: string) {
138
145
  .replaceAll(">", "&gt;")
139
146
  }
140
147
 
148
+ function isSendToStartingProfilingEnabled() {
149
+ return process.env.KANNA_PROFILE_SEND_TO_STARTING === "1"
150
+ }
151
+
152
+ function elapsedProfileMs(startedAt: number) {
153
+ return Number((performance.now() - startedAt).toFixed(1))
154
+ }
155
+
156
+ function logSendToStartingProfile(
157
+ profile: SendToStartingProfile | null | undefined,
158
+ stage: string,
159
+ details?: Record<string, unknown>
160
+ ) {
161
+ if (!profile || !isSendToStartingProfilingEnabled()) {
162
+ return
163
+ }
164
+
165
+ console.log("[kanna/send->starting][server]", JSON.stringify({
166
+ traceId: profile.traceId,
167
+ stage,
168
+ elapsedMs: elapsedProfileMs(profile.startedAt),
169
+ ...details,
170
+ }))
171
+ }
172
+
141
173
  export function buildAttachmentHintText(attachments: ChatAttachment[]) {
142
174
  if (attachments.length === 0) return ""
143
175
 
@@ -636,6 +668,18 @@ export class AgentCoordinator {
636
668
  return new Set(this.drainingStreams.keys())
637
669
  }
638
670
 
671
+ getActiveTurnProfile(chatId: string): SendToStartingProfile | null {
672
+ const active = this.activeTurns.get(chatId)
673
+ if (!active?.clientTraceId || active.profilingStartedAt === undefined) {
674
+ return null
675
+ }
676
+
677
+ return {
678
+ traceId: active.clientTraceId,
679
+ startedAt: active.profilingStartedAt,
680
+ }
681
+ }
682
+
639
683
  async stopDraining(chatId: string) {
640
684
  const draining = this.drainingStreams.get(chatId)
641
685
  if (!draining) return
@@ -691,7 +735,15 @@ export class AgentCoordinator {
691
735
  serviceTier?: "fast"
692
736
  planMode: boolean
693
737
  appendUserPrompt: boolean
738
+ profile?: SendToStartingProfile | null
694
739
  }) {
740
+ logSendToStartingProfile(args.profile, "start_turn.begin", {
741
+ chatId: args.chatId,
742
+ provider: args.provider,
743
+ appendUserPrompt: args.appendUserPrompt,
744
+ planMode: args.planMode,
745
+ })
746
+
695
747
  // Close any lingering draining stream before starting a new turn.
696
748
  const draining = this.drainingStreams.get(args.chatId)
697
749
  if (draining) {
@@ -706,8 +758,16 @@ export class AgentCoordinator {
706
758
 
707
759
  if (!chat.provider) {
708
760
  await this.store.setChatProvider(args.chatId, args.provider)
761
+ logSendToStartingProfile(args.profile, "start_turn.provider_set", {
762
+ chatId: args.chatId,
763
+ provider: args.provider,
764
+ })
709
765
  }
710
766
  await this.store.setPlanMode(args.chatId, args.planMode)
767
+ logSendToStartingProfile(args.profile, "start_turn.plan_mode_set", {
768
+ chatId: args.chatId,
769
+ planMode: args.planMode,
770
+ })
711
771
 
712
772
  const existingMessages = this.store.getMessages(args.chatId)
713
773
  const shouldGenerateTitle = args.appendUserPrompt && chat.title === "New Chat" && existingMessages.length === 0
@@ -715,6 +775,10 @@ export class AgentCoordinator {
715
775
 
716
776
  if (optimisticTitle) {
717
777
  await this.store.renameChat(args.chatId, optimisticTitle)
778
+ logSendToStartingProfile(args.profile, "start_turn.optimistic_title_set", {
779
+ chatId: args.chatId,
780
+ title: optimisticTitle,
781
+ })
718
782
  }
719
783
 
720
784
  const project = this.store.getProject(chat.projectId)
@@ -728,8 +792,15 @@ export class AgentCoordinator {
728
792
  Date.now()
729
793
  )
730
794
  await this.store.appendMessage(args.chatId, userPromptEntry)
795
+ logSendToStartingProfile(args.profile, "start_turn.user_prompt_appended", {
796
+ chatId: args.chatId,
797
+ entryId: userPromptEntry._id,
798
+ })
731
799
  }
732
800
  await this.store.recordTurnStarted(args.chatId)
801
+ logSendToStartingProfile(args.profile, "start_turn.turn_started_recorded", {
802
+ chatId: args.chatId,
803
+ })
733
804
 
734
805
  if (shouldGenerateTitle) {
735
806
  void this.generateTitleInBackground(args.chatId, args.content, project.localPath, optimisticTitle ?? "New Chat")
@@ -755,6 +826,11 @@ export class AgentCoordinator {
755
826
 
756
827
  let turn: HarnessTurn
757
828
  if (args.provider === "claude") {
829
+ logSendToStartingProfile(args.profile, "start_turn.provider_boot.begin", {
830
+ chatId: args.chatId,
831
+ provider: args.provider,
832
+ model: args.model,
833
+ })
758
834
  turn = await this.startClaudeTurn({
759
835
  chatId: args.chatId,
760
836
  localPath: project.localPath,
@@ -764,7 +840,17 @@ export class AgentCoordinator {
764
840
  sessionToken: chat.sessionToken,
765
841
  onToolRequest,
766
842
  })
843
+ logSendToStartingProfile(args.profile, "start_turn.provider_boot.ready", {
844
+ chatId: args.chatId,
845
+ provider: args.provider,
846
+ model: args.model,
847
+ })
767
848
  } else {
849
+ logSendToStartingProfile(args.profile, "start_turn.provider_boot.begin", {
850
+ chatId: args.chatId,
851
+ provider: args.provider,
852
+ model: args.model,
853
+ })
768
854
  await this.codexManager.startSession({
769
855
  chatId: args.chatId,
770
856
  cwd: project.localPath,
@@ -772,6 +858,11 @@ export class AgentCoordinator {
772
858
  serviceTier: args.serviceTier,
773
859
  sessionToken: chat.sessionToken,
774
860
  })
861
+ logSendToStartingProfile(args.profile, "start_turn.session_ready", {
862
+ chatId: args.chatId,
863
+ provider: args.provider,
864
+ model: args.model,
865
+ })
775
866
  turn = await this.codexManager.startTurn({
776
867
  chatId: args.chatId,
777
868
  content: buildPromptText(args.content, args.attachments),
@@ -781,6 +872,11 @@ export class AgentCoordinator {
781
872
  planMode: args.planMode,
782
873
  onToolRequest,
783
874
  })
875
+ logSendToStartingProfile(args.profile, "start_turn.provider_boot.ready", {
876
+ chatId: args.chatId,
877
+ provider: args.provider,
878
+ model: args.model,
879
+ })
784
880
  }
785
881
 
786
882
  const active: ActiveTurn = {
@@ -797,9 +893,19 @@ export class AgentCoordinator {
797
893
  hasFinalResult: false,
798
894
  cancelRequested: false,
799
895
  cancelRecorded: false,
896
+ clientTraceId: args.profile?.traceId,
897
+ profilingStartedAt: args.profile?.startedAt,
800
898
  }
801
899
  this.activeTurns.set(args.chatId, active)
900
+ logSendToStartingProfile(args.profile, "start_turn.active_turn_registered", {
901
+ chatId: args.chatId,
902
+ status: active.status,
903
+ })
802
904
  this.onStateChange()
905
+ logSendToStartingProfile(args.profile, "start_turn.state_change_emitted", {
906
+ chatId: args.chatId,
907
+ status: active.status,
908
+ })
803
909
 
804
910
  if (turn.getAccountInfo) {
805
911
  void turn.getAccountInfo()
@@ -826,6 +932,9 @@ export class AgentCoordinator {
826
932
  throw new Error("Claude session was not initialized")
827
933
  }
828
934
  await session.session.sendPrompt(buildPromptText(args.content, args.attachments))
935
+ logSendToStartingProfile(args.profile, "start_turn.claude_prompt_sent", {
936
+ chatId: args.chatId,
937
+ })
829
938
  return
830
939
  }
831
940
 
@@ -893,14 +1002,26 @@ export class AgentCoordinator {
893
1002
  }
894
1003
 
895
1004
  async send(command: Extract<ClientCommand, { type: "chat.send" }>) {
1005
+ const profile = command.clientTraceId
1006
+ ? { traceId: command.clientTraceId, startedAt: performance.now() }
1007
+ : null
896
1008
  let chatId = command.chatId
897
1009
 
1010
+ logSendToStartingProfile(profile, "chat_send.received", {
1011
+ existingChatId: command.chatId ?? null,
1012
+ projectId: command.projectId ?? null,
1013
+ })
1014
+
898
1015
  if (!chatId) {
899
1016
  if (!command.projectId) {
900
1017
  throw new Error("Missing projectId for new chat")
901
1018
  }
902
1019
  const created = await this.store.createChat(command.projectId)
903
1020
  chatId = created.id
1021
+ logSendToStartingProfile(profile, "chat_send.chat_created", {
1022
+ chatId,
1023
+ projectId: command.projectId,
1024
+ })
904
1025
  }
905
1026
 
906
1027
  const chat = this.store.requireChat(chatId)
@@ -916,6 +1037,13 @@ export class AgentCoordinator {
916
1037
  serviceTier: settings.serviceTier,
917
1038
  planMode: settings.planMode,
918
1039
  appendUserPrompt: true,
1040
+ profile,
1041
+ })
1042
+
1043
+ logSendToStartingProfile(profile, "chat_send.ready_for_ack", {
1044
+ chatId,
1045
+ provider,
1046
+ model: settings.model,
919
1047
  })
920
1048
 
921
1049
  return { chatId }
@@ -191,6 +191,86 @@ describe("EventStore", () => {
191
191
  expect(reloaded.getChat(chat.id)?.unread).toBe(true)
192
192
  })
193
193
 
194
+ test("preserves read state after a finished turn across restart", async () => {
195
+ const dataDir = await createTempDataDir()
196
+ const store = new EventStore(dataDir)
197
+ await store.initialize()
198
+
199
+ const project = await store.openProject("/tmp/project")
200
+ const chat = await store.createChat(project.id)
201
+
202
+ await store.recordTurnFinished(chat.id)
203
+ await store.setChatReadState(chat.id, false)
204
+
205
+ expect(store.getChat(chat.id)?.unread).toBe(false)
206
+
207
+ const reloaded = new EventStore(dataDir)
208
+ await reloaded.initialize()
209
+
210
+ expect(reloaded.getChat(chat.id)?.unread).toBe(false)
211
+ })
212
+
213
+ test("preserves read state after a failed turn across restart", async () => {
214
+ const dataDir = await createTempDataDir()
215
+ const store = new EventStore(dataDir)
216
+ await store.initialize()
217
+
218
+ const project = await store.openProject("/tmp/project")
219
+ const chat = await store.createChat(project.id)
220
+
221
+ await store.recordTurnFailed(chat.id, "boom")
222
+ await store.setChatReadState(chat.id, false)
223
+
224
+ expect(store.getChat(chat.id)?.unread).toBe(false)
225
+
226
+ const reloaded = new EventStore(dataDir)
227
+ await reloaded.initialize()
228
+
229
+ expect(reloaded.getChat(chat.id)?.unread).toBe(false)
230
+ })
231
+
232
+ test("prefers mark-read over turn completion when replay timestamps tie", async () => {
233
+ const dataDir = await createTempDataDir()
234
+ const chatsLogPath = join(dataDir, "chats.jsonl")
235
+ const turnsLogPath = join(dataDir, "turns.jsonl")
236
+ const projectId = "project-1"
237
+ const chatId = "chat-1"
238
+ const timestamp = 100
239
+
240
+ await writeFile(chatsLogPath, [
241
+ JSON.stringify({
242
+ v: 2,
243
+ type: "chat_created",
244
+ timestamp,
245
+ chatId,
246
+ projectId,
247
+ title: "Chat",
248
+ }),
249
+ JSON.stringify({
250
+ v: 2,
251
+ type: "chat_read_state_set",
252
+ timestamp,
253
+ chatId,
254
+ unread: false,
255
+ }),
256
+ "",
257
+ ].join("\n"), "utf8")
258
+ await writeFile(turnsLogPath, [
259
+ JSON.stringify({
260
+ v: 2,
261
+ type: "turn_finished",
262
+ timestamp,
263
+ chatId,
264
+ }),
265
+ "",
266
+ ].join("\n"), "utf8")
267
+
268
+ const store = new EventStore(dataDir)
269
+ await store.initialize()
270
+
271
+ expect(store.getChat(chatId)?.unread).toBe(false)
272
+ })
273
+
194
274
  test("loads chats without unread from older snapshots as read", async () => {
195
275
  const dataDir = await createTempDataDir()
196
276
  const snapshotPath = join(dataDir, "snapshot.json")
@@ -226,14 +306,14 @@ describe("EventStore", () => {
226
306
  expect(store.getChat("chat-1")?.unread).toBe(false)
227
307
  })
228
308
 
229
- test("prunes stale empty chats after five minutes", async () => {
309
+ test("prunes stale empty chats after thirty minutes", async () => {
230
310
  const dataDir = await createTempDataDir()
231
311
  const store = new EventStore(dataDir)
232
312
  await store.initialize()
233
313
 
234
314
  const project = await store.openProject("/tmp/project")
235
315
  const chat = await store.createChat(project.id)
236
- const staleNow = chat.createdAt + 5 * 60 * 1000
316
+ const staleNow = chat.createdAt + 30 * 60 * 1000
237
317
 
238
318
  const pruned = await store.pruneStaleEmptyChats({ now: staleNow })
239
319
 
@@ -248,7 +328,7 @@ describe("EventStore", () => {
248
328
 
249
329
  const project = await store.openProject("/tmp/project")
250
330
  const chat = await store.createChat(project.id)
251
- const pruned = await store.pruneStaleEmptyChats({ now: chat.createdAt + 5 * 60 * 1000 - 1 })
331
+ const pruned = await store.pruneStaleEmptyChats({ now: chat.createdAt + 30 * 60 * 1000 - 1 })
252
332
 
253
333
  expect(pruned).toEqual([])
254
334
  expect(store.getChat(chat.id)?.id).toBe(chat.id)
@@ -263,7 +343,7 @@ describe("EventStore", () => {
263
343
  const chat = await store.createChat(project.id)
264
344
  await store.appendMessage(chat.id, entry("user_prompt", chat.createdAt + 1, { content: "hello" }))
265
345
 
266
- const pruned = await store.pruneStaleEmptyChats({ now: chat.createdAt + 5 * 60 * 1000 })
346
+ const pruned = await store.pruneStaleEmptyChats({ now: chat.createdAt + 30 * 60 * 1000 })
267
347
 
268
348
  expect(pruned).toEqual([])
269
349
  expect(store.getChat(chat.id)?.id).toBe(chat.id)
@@ -278,11 +358,28 @@ describe("EventStore", () => {
278
358
  const chat = await store.createChat(project.id)
279
359
 
280
360
  const pruned = await store.pruneStaleEmptyChats({
281
- now: chat.createdAt + 5 * 60 * 1000,
361
+ now: chat.createdAt + 30 * 60 * 1000,
282
362
  activeChatIds: [chat.id],
283
363
  })
284
364
 
285
365
  expect(pruned).toEqual([])
286
366
  expect(store.getChat(chat.id)?.id).toBe(chat.id)
287
367
  })
368
+
369
+ test("does not prune stale chats with protected draft state", async () => {
370
+ const dataDir = await createTempDataDir()
371
+ const store = new EventStore(dataDir)
372
+ await store.initialize()
373
+
374
+ const project = await store.openProject("/tmp/project")
375
+ const chat = await store.createChat(project.id)
376
+
377
+ const pruned = await store.pruneStaleEmptyChats({
378
+ now: chat.createdAt + 30 * 60 * 1000,
379
+ protectedChatIds: [chat.id],
380
+ })
381
+
382
+ expect(pruned).toEqual([])
383
+ expect(store.getChat(chat.id)?.id).toBe(chat.id)
384
+ })
288
385
  })
@@ -7,7 +7,6 @@ import type { AgentProvider, ChatHistoryPage, ChatHistorySnapshot, TranscriptEnt
7
7
  import { STORE_VERSION } from "../shared/types"
8
8
  import {
9
9
  type ChatEvent,
10
- type MessageEvent,
11
10
  type ProjectEvent,
12
11
  type SnapshotFile,
13
12
  type StoreEvent,
@@ -19,7 +18,7 @@ import {
19
18
  import { resolveLocalPath } from "./paths"
20
19
 
21
20
  const COMPACTION_THRESHOLD_BYTES = 2 * 1024 * 1024
22
- const STALE_EMPTY_CHAT_MAX_AGE_MS = 5 * 60 * 1000
21
+ const STALE_EMPTY_CHAT_MAX_AGE_MS = 30 * 60 * 1000
23
22
  interface LegacyTranscriptStats {
24
23
  hasLegacyData: boolean
25
24
  sources: Array<"snapshot" | "messages_log">
@@ -33,6 +32,41 @@ interface TranscriptPageResult {
33
32
  olderCursor: string | null
34
33
  }
35
34
 
35
+ interface ParsedReplayEvent {
36
+ event: StoreEvent
37
+ sourceIndex: number
38
+ lineIndex: number
39
+ }
40
+
41
+ function getReplayEventPriority(event: StoreEvent) {
42
+ switch (event.type) {
43
+ case "project_opened":
44
+ case "project_removed":
45
+ return 0
46
+ case "chat_created":
47
+ return 1
48
+ case "chat_renamed":
49
+ case "chat_provider_set":
50
+ case "chat_plan_mode_set":
51
+ return 2
52
+ case "message_appended":
53
+ return 3
54
+ case "turn_started":
55
+ return 4
56
+ case "session_token_set":
57
+ return 5
58
+ case "turn_cancelled":
59
+ return 6
60
+ case "turn_finished":
61
+ case "turn_failed":
62
+ return 7
63
+ case "chat_read_state_set":
64
+ return 8
65
+ case "chat_deleted":
66
+ return 9
67
+ }
68
+ }
69
+
36
70
  function encodeHistoryCursor(index: number) {
37
71
  return `idx:${index}`
38
72
  }
@@ -166,21 +200,33 @@ export class EventStore {
166
200
 
167
201
  private async replayLogs() {
168
202
  if (this.storageReset) return
169
- await this.replayLog<ProjectEvent>(this.projectsLogPath)
170
- if (this.storageReset) return
171
- await this.replayLog<ChatEvent>(this.chatsLogPath)
172
- if (this.storageReset) return
173
- await this.replayLog<MessageEvent>(this.messagesLogPath)
203
+ const replayEvents = [
204
+ ...await this.loadReplayEvents(this.projectsLogPath, 0),
205
+ ...await this.loadReplayEvents(this.chatsLogPath, 1),
206
+ ...await this.loadReplayEvents(this.messagesLogPath, 2),
207
+ ...await this.loadReplayEvents(this.turnsLogPath, 3),
208
+ ]
174
209
  if (this.storageReset) return
175
- await this.replayLog<TurnEvent>(this.turnsLogPath)
210
+
211
+ replayEvents
212
+ .sort((left, right) => (
213
+ left.event.timestamp - right.event.timestamp
214
+ || getReplayEventPriority(left.event) - getReplayEventPriority(right.event)
215
+ || left.sourceIndex - right.sourceIndex
216
+ || left.lineIndex - right.lineIndex
217
+ ))
218
+ .forEach(({ event }) => {
219
+ this.applyEvent(event)
220
+ })
176
221
  }
177
222
 
178
- private async replayLog<TEvent extends StoreEvent>(filePath: string) {
223
+ private async loadReplayEvents(filePath: string, sourceIndex: number): Promise<ParsedReplayEvent[]> {
179
224
  const file = Bun.file(filePath)
180
- if (!(await file.exists())) return
225
+ if (!(await file.exists())) return []
181
226
  const text = await file.text()
182
- if (!text.trim()) return
227
+ if (!text.trim()) return []
183
228
 
229
+ const parsedEvents: ParsedReplayEvent[] = []
184
230
  const lines = text.split("\n")
185
231
  let lastNonEmpty = -1
186
232
  for (let index = lines.length - 1; index >= 0; index -= 1) {
@@ -198,19 +244,25 @@ export class EventStore {
198
244
  if (event.v !== STORE_VERSION) {
199
245
  console.warn(`${LOG_PREFIX} Resetting local history from incompatible event log`)
200
246
  await this.clearStorage()
201
- return
247
+ return []
202
248
  }
203
- this.applyEvent(event as StoreEvent)
249
+ parsedEvents.push({
250
+ event: event as StoreEvent,
251
+ sourceIndex,
252
+ lineIndex: index,
253
+ })
204
254
  } catch (error) {
205
255
  if (index === lastNonEmpty) {
206
256
  console.warn(`${LOG_PREFIX} Ignoring corrupt trailing line in ${path.basename(filePath)}`)
207
- return
257
+ return parsedEvents
208
258
  }
209
259
  console.warn(`${LOG_PREFIX} Failed to replay ${path.basename(filePath)}, resetting local history:`, error)
210
260
  await this.clearStorage()
211
- return
261
+ return []
212
262
  }
213
263
  }
264
+
265
+ return parsedEvents
214
266
  }
215
267
 
216
268
  private applyEvent(event: StoreEvent) {
@@ -459,14 +511,18 @@ export class EventStore {
459
511
  now?: number
460
512
  maxAgeMs?: number
461
513
  activeChatIds?: Iterable<string>
514
+ protectedChatIds?: Iterable<string>
462
515
  }) {
463
516
  const now = args?.now ?? Date.now()
464
517
  const maxAgeMs = args?.maxAgeMs ?? STALE_EMPTY_CHAT_MAX_AGE_MS
465
- const activeChatIds = new Set(args?.activeChatIds ?? [])
518
+ const protectedChatIds = new Set([
519
+ ...(args?.activeChatIds ?? []),
520
+ ...(args?.protectedChatIds ?? []),
521
+ ])
466
522
  const prunedChatIds: string[] = []
467
523
 
468
524
  for (const chat of this.state.chatsById.values()) {
469
- if (chat.deletedAt || activeChatIds.has(chat.id)) continue
525
+ if (chat.deletedAt || protectedChatIds.has(chat.id)) continue
470
526
  if (now - chat.createdAt < maxAgeMs) continue
471
527
  if (this.getMessages(chat.id).length > 0) continue
472
528
 
@@ -8,6 +8,7 @@ class FakeWebSocket {
8
8
  readonly sent: unknown[] = []
9
9
  readonly data = {
10
10
  subscriptions: new Map(),
11
+ protectedDraftChatIds: new Set<string>(),
11
12
  }
12
13
 
13
14
  send(message: string) {
@@ -742,6 +743,85 @@ describe("ws-router", () => {
742
743
  })
743
744
  })
744
745
 
746
+ test("protects draft-bearing chats from stale pruning before sidebar snapshots", async () => {
747
+ const state = createEmptyState()
748
+ state.projectsById.set("project-1", {
749
+ id: "project-1",
750
+ localPath: "/tmp/project",
751
+ title: "Project",
752
+ createdAt: 1,
753
+ updatedAt: 1,
754
+ })
755
+ state.projectIdsByPath.set("/tmp/project", "project-1")
756
+ state.chatsById.set("chat-stale", {
757
+ id: "chat-stale",
758
+ projectId: "project-1",
759
+ title: "New Chat",
760
+ createdAt: 1,
761
+ updatedAt: 1,
762
+ unread: false,
763
+ provider: null,
764
+ planMode: false,
765
+ sessionToken: null,
766
+ lastTurnOutcome: null,
767
+ })
768
+
769
+ let capturedProtectedChatIds: string[] = []
770
+ const router = createWsRouter({
771
+ store: {
772
+ state,
773
+ async pruneStaleEmptyChats(args?: { protectedChatIds?: Iterable<string> }) {
774
+ capturedProtectedChatIds = [...(args?.protectedChatIds ?? [])]
775
+ return []
776
+ },
777
+ } as never,
778
+ agent: { getActiveStatuses: () => new Map(), getDrainingChatIds: () => new Set() } as never,
779
+ terminals: {
780
+ getSnapshot: () => null,
781
+ onEvent: () => () => {},
782
+ } as never,
783
+ keybindings: {
784
+ getSnapshot: () => DEFAULT_KEYBINDINGS_SNAPSHOT,
785
+ onChange: () => () => {},
786
+ } as never,
787
+ refreshDiscovery: async () => [],
788
+ getDiscoveredProjects: () => [],
789
+ machineDisplayName: "Local Machine",
790
+ updateManager: null,
791
+ })
792
+ const ws = new FakeWebSocket()
793
+
794
+ await router.handleMessage(
795
+ ws as never,
796
+ JSON.stringify({
797
+ v: 1,
798
+ type: "command",
799
+ id: "draft-protection-1",
800
+ command: {
801
+ type: "chat.setDraftProtection",
802
+ chatIds: ["chat-stale"],
803
+ },
804
+ })
805
+ )
806
+
807
+ await router.handleMessage(
808
+ ws as never,
809
+ JSON.stringify({
810
+ v: 1,
811
+ type: "subscribe",
812
+ id: "sidebar-sub-1",
813
+ topic: { type: "sidebar" },
814
+ })
815
+ )
816
+
817
+ expect(capturedProtectedChatIds).toEqual(["chat-stale"])
818
+ expect(ws.sent[0]).toEqual({
819
+ v: PROTOCOL_VERSION,
820
+ type: "ack",
821
+ id: "draft-protection-1",
822
+ })
823
+ })
824
+
745
825
  test("broadcasts background title-generation errors to connected clients", () => {
746
826
  let reportBackgroundError: ((message: string) => void) | null | undefined
747
827
  const router = createWsRouter({