kanna-code 0.26.0 → 0.26.2

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,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-D3z0bCam.js"></script>
9
- <link rel="stylesheet" crossorigin href="/assets/index-DFoGPygA.css">
8
+ <script type="module" crossorigin src="/assets/index-CzKoT6ON.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/assets/index-CzFdfKR3.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.26.0",
4
+ "version": "0.26.2",
5
5
  "description": "A beautiful web UI for Claude Code",
6
6
  "license": "MIT",
7
7
  "keywords": [
@@ -225,4 +225,64 @@ describe("EventStore", () => {
225
225
 
226
226
  expect(store.getChat("chat-1")?.unread).toBe(false)
227
227
  })
228
+
229
+ test("prunes stale empty chats after five minutes", async () => {
230
+ const dataDir = await createTempDataDir()
231
+ const store = new EventStore(dataDir)
232
+ await store.initialize()
233
+
234
+ const project = await store.openProject("/tmp/project")
235
+ const chat = await store.createChat(project.id)
236
+ const staleNow = chat.createdAt + 5 * 60 * 1000
237
+
238
+ const pruned = await store.pruneStaleEmptyChats({ now: staleNow })
239
+
240
+ expect(pruned).toEqual([chat.id])
241
+ expect(store.getChat(chat.id)).toBeNull()
242
+ })
243
+
244
+ test("does not prune recent empty chats", async () => {
245
+ const dataDir = await createTempDataDir()
246
+ const store = new EventStore(dataDir)
247
+ await store.initialize()
248
+
249
+ const project = await store.openProject("/tmp/project")
250
+ const chat = await store.createChat(project.id)
251
+ const pruned = await store.pruneStaleEmptyChats({ now: chat.createdAt + 5 * 60 * 1000 - 1 })
252
+
253
+ expect(pruned).toEqual([])
254
+ expect(store.getChat(chat.id)?.id).toBe(chat.id)
255
+ })
256
+
257
+ test("does not prune chats once they have transcript messages", async () => {
258
+ const dataDir = await createTempDataDir()
259
+ const store = new EventStore(dataDir)
260
+ await store.initialize()
261
+
262
+ const project = await store.openProject("/tmp/project")
263
+ const chat = await store.createChat(project.id)
264
+ await store.appendMessage(chat.id, entry("user_prompt", chat.createdAt + 1, { content: "hello" }))
265
+
266
+ const pruned = await store.pruneStaleEmptyChats({ now: chat.createdAt + 5 * 60 * 1000 })
267
+
268
+ expect(pruned).toEqual([])
269
+ expect(store.getChat(chat.id)?.id).toBe(chat.id)
270
+ })
271
+
272
+ test("does not prune stale chats that are currently active", async () => {
273
+ const dataDir = await createTempDataDir()
274
+ const store = new EventStore(dataDir)
275
+ await store.initialize()
276
+
277
+ const project = await store.openProject("/tmp/project")
278
+ const chat = await store.createChat(project.id)
279
+
280
+ const pruned = await store.pruneStaleEmptyChats({
281
+ now: chat.createdAt + 5 * 60 * 1000,
282
+ activeChatIds: [chat.id],
283
+ })
284
+
285
+ expect(pruned).toEqual([])
286
+ expect(store.getChat(chat.id)?.id).toBe(chat.id)
287
+ })
228
288
  })
@@ -1,4 +1,4 @@
1
- import { appendFile, mkdir, rename, writeFile } from "node:fs/promises"
1
+ import { appendFile, mkdir, rename, rm, writeFile } from "node:fs/promises"
2
2
  import { existsSync, readFileSync as readFileSyncImmediate } from "node:fs"
3
3
  import { homedir } from "node:os"
4
4
  import path from "node:path"
@@ -19,6 +19,7 @@ import {
19
19
  import { resolveLocalPath } from "./paths"
20
20
 
21
21
  const COMPACTION_THRESHOLD_BYTES = 2 * 1024 * 1024
22
+ const STALE_EMPTY_CHAT_MAX_AGE_MS = 5 * 60 * 1000
22
23
  interface LegacyTranscriptStats {
23
24
  hasLegacyData: boolean
24
25
  sources: Array<"snapshot" | "messages_log">
@@ -454,6 +455,41 @@ export class EventStore {
454
455
  await this.append(this.chatsLogPath, event)
455
456
  }
456
457
 
458
+ async pruneStaleEmptyChats(args?: {
459
+ now?: number
460
+ maxAgeMs?: number
461
+ activeChatIds?: Iterable<string>
462
+ }) {
463
+ const now = args?.now ?? Date.now()
464
+ const maxAgeMs = args?.maxAgeMs ?? STALE_EMPTY_CHAT_MAX_AGE_MS
465
+ const activeChatIds = new Set(args?.activeChatIds ?? [])
466
+ const prunedChatIds: string[] = []
467
+
468
+ for (const chat of this.state.chatsById.values()) {
469
+ if (chat.deletedAt || activeChatIds.has(chat.id)) continue
470
+ if (now - chat.createdAt < maxAgeMs) continue
471
+ if (this.getMessages(chat.id).length > 0) continue
472
+
473
+ const event: ChatEvent = {
474
+ v: STORE_VERSION,
475
+ type: "chat_deleted",
476
+ timestamp: now,
477
+ chatId: chat.id,
478
+ }
479
+ await this.append(this.chatsLogPath, event)
480
+
481
+ const transcriptPath = this.transcriptPath(chat.id)
482
+ await rm(transcriptPath, { force: true })
483
+ if (this.cachedTranscript?.chatId === chat.id) {
484
+ this.cachedTranscript = null
485
+ }
486
+
487
+ prunedChatIds.push(chat.id)
488
+ }
489
+
490
+ return prunedChatIds
491
+ }
492
+
457
493
  async setChatProvider(chatId: string, provider: AgentProvider) {
458
494
  const chat = this.requireChat(chatId)
459
495
  if (chat.provider === provider) return
@@ -17,6 +17,7 @@ import { getProjectUploadDir } from "./paths"
17
17
 
18
18
  const MAX_UPLOAD_FILES = 10
19
19
  const MAX_UPLOAD_SIZE_BYTES = 25 * 1024 * 1024
20
+ const STALE_EMPTY_CHAT_PRUNE_INTERVAL_MS = 60 * 1000
20
21
 
21
22
  export async function persistUploadedFiles(args: {
22
23
  projectId: string
@@ -99,7 +100,7 @@ export async function startKannaServer(options: StartKannaServerOptions = {}) {
99
100
  const agent = new AgentCoordinator({
100
101
  store,
101
102
  onStateChange: () => {
102
- router.broadcastSnapshots()
103
+ void router.broadcastSnapshots()
103
104
  },
104
105
  })
105
106
  router = createWsRouter({
@@ -113,6 +114,9 @@ export async function startKannaServer(options: StartKannaServerOptions = {}) {
113
114
  machineDisplayName,
114
115
  updateManager,
115
116
  })
117
+ const staleEmptyChatPruneInterval = setInterval(() => {
118
+ void router.broadcastSnapshots()
119
+ }, STALE_EMPTY_CHAT_PRUNE_INTERVAL_MS)
116
120
 
117
121
  const distDir = path.join(import.meta.dir, "..", "..", "dist", "client")
118
122
 
@@ -188,6 +192,7 @@ export async function startKannaServer(options: StartKannaServerOptions = {}) {
188
192
  }
189
193
 
190
194
  const shutdown = async () => {
195
+ clearInterval(staleEmptyChatPruneInterval)
191
196
  for (const chatId of [...agent.activeTurns.keys()]) {
192
197
  await agent.cancel(chatId)
193
198
  }
@@ -577,7 +577,7 @@ describe("ws-router", () => {
577
577
  router.handleOpen(wsA as never)
578
578
  router.handleOpen(wsB as never)
579
579
 
580
- router.handleMessage(
580
+ await router.handleMessage(
581
581
  wsA as never,
582
582
  JSON.stringify({
583
583
  v: 1,
@@ -586,7 +586,7 @@ describe("ws-router", () => {
586
586
  topic: { type: "sidebar" },
587
587
  })
588
588
  )
589
- router.handleMessage(
589
+ await router.handleMessage(
590
590
  wsB as never,
591
591
  JSON.stringify({
592
592
  v: 1,
@@ -596,7 +596,7 @@ describe("ws-router", () => {
596
596
  })
597
597
  )
598
598
 
599
- router.handleMessage(
599
+ await router.handleMessage(
600
600
  wsA as never,
601
601
  JSON.stringify({
602
602
  v: 1,
@@ -606,8 +606,6 @@ describe("ws-router", () => {
606
606
  })
607
607
  )
608
608
 
609
- await Promise.resolve()
610
-
611
609
  expect(wsA.sent.at(-2)).toEqual({
612
610
  v: PROTOCOL_VERSION,
613
611
  type: "ack",
@@ -667,6 +665,83 @@ describe("ws-router", () => {
667
665
  })
668
666
  })
669
667
 
668
+ test("prunes stale empty chats before sending sidebar snapshots", async () => {
669
+ const state = createEmptyState()
670
+ state.projectsById.set("project-1", {
671
+ id: "project-1",
672
+ localPath: "/tmp/project",
673
+ title: "Project",
674
+ createdAt: 1,
675
+ updatedAt: 1,
676
+ })
677
+ state.projectIdsByPath.set("/tmp/project", "project-1")
678
+ state.chatsById.set("chat-stale", {
679
+ id: "chat-stale",
680
+ projectId: "project-1",
681
+ title: "New Chat",
682
+ createdAt: 1,
683
+ updatedAt: 1,
684
+ unread: false,
685
+ provider: null,
686
+ planMode: false,
687
+ sessionToken: null,
688
+ lastTurnOutcome: null,
689
+ })
690
+
691
+ let pruneCalls = 0
692
+ const router = createWsRouter({
693
+ store: {
694
+ state,
695
+ async pruneStaleEmptyChats() {
696
+ pruneCalls += 1
697
+ state.chatsById.delete("chat-stale")
698
+ return ["chat-stale"]
699
+ },
700
+ } as never,
701
+ agent: { getActiveStatuses: () => new Map(), getDrainingChatIds: () => new Set() } as never,
702
+ terminals: {
703
+ getSnapshot: () => null,
704
+ onEvent: () => () => {},
705
+ } as never,
706
+ keybindings: {
707
+ getSnapshot: () => DEFAULT_KEYBINDINGS_SNAPSHOT,
708
+ onChange: () => () => {},
709
+ } as never,
710
+ refreshDiscovery: async () => [],
711
+ getDiscoveredProjects: () => [],
712
+ machineDisplayName: "Local Machine",
713
+ updateManager: null,
714
+ })
715
+ const ws = new FakeWebSocket()
716
+
717
+ await router.handleMessage(
718
+ ws as never,
719
+ JSON.stringify({
720
+ v: 1,
721
+ type: "subscribe",
722
+ id: "sidebar-sub-1",
723
+ topic: { type: "sidebar" },
724
+ })
725
+ )
726
+
727
+ expect(pruneCalls).toBe(1)
728
+ expect(ws.sent[0]).toEqual({
729
+ v: PROTOCOL_VERSION,
730
+ type: "snapshot",
731
+ id: "sidebar-sub-1",
732
+ snapshot: {
733
+ type: "sidebar",
734
+ data: {
735
+ projectGroups: [{
736
+ groupKey: "project-1",
737
+ localPath: "/tmp/project",
738
+ chats: [],
739
+ }],
740
+ },
741
+ },
742
+ })
743
+ })
744
+
670
745
  test("broadcasts background title-generation errors to connected clients", () => {
671
746
  let reportBackgroundError: ((message: string) => void) | null | undefined
672
747
  const router = createWsRouter({
@@ -76,6 +76,19 @@ export function createWsRouter({
76
76
  readPatch: async () => ({ patch: "" }),
77
77
  }
78
78
 
79
+ function getProtectedChatIds() {
80
+ return new Set([
81
+ ...agent.getActiveStatuses().keys(),
82
+ ...agent.getDrainingChatIds().values(),
83
+ ])
84
+ }
85
+
86
+ async function maybePruneStaleEmptyChats() {
87
+ await store.pruneStaleEmptyChats?.({
88
+ activeChatIds: getProtectedChatIds(),
89
+ })
90
+ }
91
+
79
92
  function createEnvelope(id: string, topic: SubscriptionTopic): ServerEnvelope {
80
93
  if (topic.type === "sidebar") {
81
94
  return {
@@ -179,7 +192,10 @@ export function createWsRouter({
179
192
  }
180
193
  }
181
194
 
182
- function pushSnapshots(ws: ServerWebSocket<ClientState>) {
195
+ async function pushSnapshots(ws: ServerWebSocket<ClientState>, options?: { skipPrune?: boolean }) {
196
+ if (!options?.skipPrune) {
197
+ await maybePruneStaleEmptyChats()
198
+ }
183
199
  const snapshotSignatures = ensureSnapshotSignatures(ws)
184
200
  for (const [id, topic] of ws.data.subscriptions.entries()) {
185
201
  const envelope = createEnvelope(id, topic)
@@ -193,9 +209,10 @@ export function createWsRouter({
193
209
  }
194
210
  }
195
211
 
196
- function broadcastSnapshots() {
212
+ async function broadcastSnapshots() {
213
+ await maybePruneStaleEmptyChats()
197
214
  for (const ws of sockets) {
198
- pushSnapshots(ws)
215
+ await pushSnapshots(ws, { skipPrune: true })
199
216
  }
200
217
  }
201
218
 
@@ -403,7 +420,7 @@ export function createWsRouter({
403
420
  const changed = await resolvedDiffStore.refreshSnapshot(project.id, project.localPath)
404
421
  send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
405
422
  if (changed) {
406
- broadcastSnapshots()
423
+ void broadcastSnapshots()
407
424
  }
408
425
  return
409
426
  }
@@ -415,7 +432,7 @@ export function createWsRouter({
415
432
  })
416
433
  send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result })
417
434
  if (result.snapshotChanged) {
418
- broadcastSnapshots()
435
+ void broadcastSnapshots()
419
436
  }
420
437
  return
421
438
  }
@@ -447,7 +464,7 @@ export function createWsRouter({
447
464
  })
448
465
  send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result })
449
466
  if (result.snapshotChanged) {
450
- broadcastSnapshots()
467
+ void broadcastSnapshots()
451
468
  }
452
469
  return
453
470
  }
@@ -477,7 +494,7 @@ export function createWsRouter({
477
494
  })
478
495
  send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result })
479
496
  if (result.snapshotChanged) {
480
- broadcastSnapshots()
497
+ void broadcastSnapshots()
481
498
  }
482
499
  return
483
500
  }
@@ -491,7 +508,7 @@ export function createWsRouter({
491
508
  })
492
509
  send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result })
493
510
  if (result.snapshotChanged) {
494
- broadcastSnapshots()
511
+ void broadcastSnapshots()
495
512
  }
496
513
  return
497
514
  }
@@ -504,7 +521,7 @@ export function createWsRouter({
504
521
  })
505
522
  send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result })
506
523
  if (result.snapshotChanged) {
507
- broadcastSnapshots()
524
+ void broadcastSnapshots()
508
525
  }
509
526
  return
510
527
  }
@@ -518,7 +535,7 @@ export function createWsRouter({
518
535
  })
519
536
  send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result })
520
537
  if (result.snapshotChanged) {
521
- broadcastSnapshots()
538
+ void broadcastSnapshots()
522
539
  }
523
540
  return
524
541
  }
@@ -543,7 +560,7 @@ export function createWsRouter({
543
560
  })
544
561
  send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result })
545
562
  if (result.snapshotChanged) {
546
- broadcastSnapshots()
563
+ void broadcastSnapshots()
547
564
  }
548
565
  return
549
566
  }
@@ -556,7 +573,7 @@ export function createWsRouter({
556
573
  })
557
574
  send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result })
558
575
  if (result.snapshotChanged) {
559
- broadcastSnapshots()
576
+ void broadcastSnapshots()
560
577
  }
561
578
  return
562
579
  }
@@ -569,7 +586,7 @@ export function createWsRouter({
569
586
  })
570
587
  send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result })
571
588
  if (result.snapshotChanged) {
572
- broadcastSnapshots()
589
+ void broadcastSnapshots()
573
590
  }
574
591
  return
575
592
  }
@@ -628,7 +645,7 @@ export function createWsRouter({
628
645
  }
629
646
  }
630
647
 
631
- broadcastSnapshots()
648
+ await broadcastSnapshots()
632
649
  } catch (error) {
633
650
  const messageText = error instanceof Error ? error.message : String(error)
634
651
  console.error("[ws-router] command failed", {
@@ -648,7 +665,7 @@ export function createWsRouter({
648
665
  sockets.delete(ws)
649
666
  },
650
667
  broadcastSnapshots,
651
- handleMessage(ws: ServerWebSocket<ClientState>, raw: string | Buffer | ArrayBuffer | Uint8Array) {
668
+ async handleMessage(ws: ServerWebSocket<ClientState>, raw: string | Buffer | ArrayBuffer | Uint8Array) {
652
669
  let parsed: unknown
653
670
  try {
654
671
  parsed = JSON.parse(String(raw))
@@ -669,12 +686,12 @@ export function createWsRouter({
669
686
  if (parsed.topic.type === "local-projects") {
670
687
  void refreshDiscovery().then(() => {
671
688
  if (ws.data.subscriptions.has(parsed.id)) {
672
- pushSnapshots(ws)
689
+ void pushSnapshots(ws)
673
690
  }
674
691
  })
675
692
  return
676
693
  }
677
- pushSnapshots(ws)
694
+ await pushSnapshots(ws)
678
695
  return
679
696
  }
680
697
 
@@ -686,7 +703,7 @@ export function createWsRouter({
686
703
  return
687
704
  }
688
705
 
689
- void handleCommand(ws, parsed)
706
+ await handleCommand(ws, parsed)
690
707
  },
691
708
  dispose() {
692
709
  agent.setBackgroundErrorReporter?.(null)