kanna-code 0.26.5 → 0.26.6

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-CcdP0kaK.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.6",
5
5
  "description": "A beautiful web UI for Claude Code",
6
6
  "license": "MIT",
7
7
  "keywords": [
@@ -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({
@@ -18,6 +18,7 @@ const DEFAULT_CHAT_RECENT_LIMIT = 200
18
18
  export interface ClientState {
19
19
  subscriptions: Map<string, SubscriptionTopic>
20
20
  snapshotSignatures: Map<string, string>
21
+ protectedDraftChatIds?: Set<string>
21
22
  }
22
23
 
23
24
  interface CreateWsRouterArgs {
@@ -83,9 +84,28 @@ export function createWsRouter({
83
84
  ])
84
85
  }
85
86
 
86
- async function maybePruneStaleEmptyChats() {
87
+ function getProtectedDraftChatIds(extraSockets?: Iterable<ServerWebSocket<ClientState>>) {
88
+ const protectedChatIds = new Set<string>()
89
+
90
+ for (const socket of sockets) {
91
+ for (const chatId of socket.data.protectedDraftChatIds ?? []) {
92
+ protectedChatIds.add(chatId)
93
+ }
94
+ }
95
+
96
+ for (const socket of extraSockets ?? []) {
97
+ for (const chatId of socket.data.protectedDraftChatIds ?? []) {
98
+ protectedChatIds.add(chatId)
99
+ }
100
+ }
101
+
102
+ return protectedChatIds
103
+ }
104
+
105
+ async function maybePruneStaleEmptyChats(extraSockets?: Iterable<ServerWebSocket<ClientState>>) {
87
106
  await store.pruneStaleEmptyChats?.({
88
107
  activeChatIds: getProtectedChatIds(),
108
+ protectedChatIds: getProtectedDraftChatIds(extraSockets),
89
109
  })
90
110
  }
91
111
 
@@ -194,7 +214,7 @@ export function createWsRouter({
194
214
 
195
215
  async function pushSnapshots(ws: ServerWebSocket<ClientState>, options?: { skipPrune?: boolean }) {
196
216
  if (!options?.skipPrune) {
197
- await maybePruneStaleEmptyChats()
217
+ await maybePruneStaleEmptyChats([ws])
198
218
  }
199
219
  const snapshotSignatures = ensureSnapshotSignatures(ws)
200
220
  for (const [id, topic] of ws.data.subscriptions.entries()) {
@@ -410,6 +430,11 @@ export function createWsRouter({
410
430
  send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
411
431
  break
412
432
  }
433
+ case "chat.setDraftProtection": {
434
+ ws.data.protectedDraftChatIds = new Set(command.chatIds)
435
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
436
+ break
437
+ }
413
438
  case "chat.send": {
414
439
  const result = await agent.send(command)
415
440
  send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result })
@@ -67,6 +67,7 @@ export type ClientCommand =
67
67
  | { type: "chat.create"; projectId: string }
68
68
  | { type: "chat.rename"; chatId: string; title: string }
69
69
  | { type: "chat.delete"; chatId: string }
70
+ | { type: "chat.setDraftProtection"; chatIds: string[] }
70
71
  | { type: "chat.markRead"; chatId: string }
71
72
  | {
72
73
  type: "chat.send"