kanna-code 0.36.1 → 0.37.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.
@@ -5,8 +5,8 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
6
6
  <meta name="theme-color" content="#ffffff" />
7
7
  <title>Kanna Transcript</title>
8
- <script type="module" crossorigin src="./assets/index-Cghqxose.js"></script>
9
- <link rel="stylesheet" crossorigin href="./assets/index-D3nRTJ38.css">
8
+ <script type="module" crossorigin src="./assets/index-Dw6GRRjm.js"></script>
9
+ <link rel="stylesheet" crossorigin href="./assets/index-CY-dlB67.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.36.1",
4
+ "version": "0.37.0",
5
5
  "description": "A beautiful web UI for Claude Code",
6
6
  "license": "MIT",
7
7
  "keywords": [
@@ -537,4 +537,42 @@ describe("EventStore", () => {
537
537
  expect(forked.lastMessageAt).toBeUndefined()
538
538
  expect(store.getMessages(forked.id)).toEqual(store.getMessages(source.id))
539
539
  })
540
+
541
+ test("reopening a removed project restores its existing chats", async () => {
542
+ const dataDir = await createTempDataDir()
543
+ const store = new EventStore(dataDir)
544
+ await store.initialize()
545
+
546
+ const project = await store.openProject("/tmp/project")
547
+ const chat = await store.createChat(project.id)
548
+
549
+ await store.removeProject(project.id)
550
+ expect(store.getProject(project.id)).toBeNull()
551
+
552
+ const reopened = await store.openProject("/tmp/project")
553
+
554
+ expect(reopened.id).toBe(project.id)
555
+ expect(store.listChatsByProject(reopened.id).map((entry) => entry.id)).toEqual([chat.id])
556
+ })
557
+
558
+ test("archives chats without deleting their transcript", async () => {
559
+ const dataDir = await createTempDataDir()
560
+ const store = new EventStore(dataDir)
561
+ await store.initialize()
562
+
563
+ const project = await store.openProject("/tmp/project")
564
+ const chat = await store.createChat(project.id)
565
+ await store.appendMessage(chat.id, entry("user_prompt", chat.createdAt + 1, { content: "keep this" }))
566
+
567
+ await store.archiveChat(chat.id)
568
+
569
+ expect(store.getChat(chat.id)?.archivedAt).toBeNumber()
570
+ expect(store.listChatsByProject(project.id)).toEqual([])
571
+ expect(store.getMessages(chat.id).map((message) => message.kind)).toEqual(["user_prompt"])
572
+
573
+ await store.unarchiveChat(chat.id)
574
+
575
+ expect(store.getChat(chat.id)?.archivedAt).toBeUndefined()
576
+ expect(store.listChatsByProject(project.id).map((entry) => entry.id)).toEqual([chat.id])
577
+ })
540
578
  })
@@ -104,6 +104,8 @@ function getReplayEventPriority(event: StoreEvent) {
104
104
  case "chat_read_state_set":
105
105
  return 9
106
106
  case "chat_deleted":
107
+ case "chat_archived":
108
+ case "chat_unarchived":
107
109
  return 10
108
110
  }
109
111
  }
@@ -474,6 +476,20 @@ export class EventStore {
474
476
  this.state.queuedMessagesByChatId.delete(event.chatId)
475
477
  break
476
478
  }
479
+ case "chat_archived": {
480
+ const chat = this.state.chatsById.get(event.chatId)
481
+ if (!chat) break
482
+ chat.archivedAt = event.timestamp
483
+ chat.updatedAt = event.timestamp
484
+ break
485
+ }
486
+ case "chat_unarchived": {
487
+ const chat = this.state.chatsById.get(event.chatId)
488
+ if (!chat) break
489
+ delete chat.archivedAt
490
+ chat.updatedAt = event.timestamp
491
+ break
492
+ }
477
493
  case "chat_provider_set": {
478
494
  const chat = this.state.chatsById.get(event.chatId)
479
495
  if (!chat) break
@@ -626,7 +642,9 @@ export class EventStore {
626
642
  }
627
643
  }
628
644
 
629
- const projectId = crypto.randomUUID()
645
+ const hiddenProject = [...this.state.projectsById.values()]
646
+ .find((project) => project.localPath === normalized && project.deletedAt)
647
+ const projectId = hiddenProject?.id ?? crypto.randomUUID()
630
648
  const event: ProjectEvent = {
631
649
  v: STORE_VERSION,
632
650
  type: "project_opened",
@@ -764,6 +782,28 @@ export class EventStore {
764
782
  await this.append(this.chatsLogPath, event)
765
783
  }
766
784
 
785
+ async archiveChat(chatId: string) {
786
+ this.requireChat(chatId)
787
+ const event: ChatEvent = {
788
+ v: STORE_VERSION,
789
+ type: "chat_archived",
790
+ timestamp: Date.now(),
791
+ chatId,
792
+ }
793
+ await this.append(this.chatsLogPath, event)
794
+ }
795
+
796
+ async unarchiveChat(chatId: string) {
797
+ this.requireChat(chatId)
798
+ const event: ChatEvent = {
799
+ v: STORE_VERSION,
800
+ type: "chat_unarchived",
801
+ timestamp: Date.now(),
802
+ chatId,
803
+ }
804
+ await this.append(this.chatsLogPath, event)
805
+ }
806
+
767
807
  async pruneStaleEmptyChats(args?: {
768
808
  now?: number
769
809
  maxAgeMs?: number
@@ -779,7 +819,7 @@ export class EventStore {
779
819
  const prunedChatIds: string[] = []
780
820
 
781
821
  for (const chat of this.state.chatsById.values()) {
782
- if (chat.deletedAt || protectedChatIds.has(chat.id)) continue
822
+ if (chat.deletedAt || chat.archivedAt || protectedChatIds.has(chat.id)) continue
783
823
  if (now - chat.createdAt < maxAgeMs) continue
784
824
  if (chat.hasMessages) continue
785
825
  if (this.getMessages(chat.id).length > 0) {
@@ -1100,7 +1140,7 @@ export class EventStore {
1100
1140
 
1101
1141
  listChatsByProject(projectId: string) {
1102
1142
  return [...this.state.chatsById.values()]
1103
- .filter((chat) => chat.projectId === projectId && !chat.deletedAt)
1143
+ .filter((chat) => chat.projectId === projectId && !chat.deletedAt && !chat.archivedAt)
1104
1144
  .sort((a, b) => (b.lastMessageAt ?? b.updatedAt) - (a.lastMessageAt ?? a.updatedAt))
1105
1145
  }
1106
1146
 
@@ -11,6 +11,7 @@ export interface ChatRecord {
11
11
  createdAt: number
12
12
  updatedAt: number
13
13
  deletedAt?: number
14
+ archivedAt?: number
14
15
  unread: boolean
15
16
  provider: AgentProvider | null
16
17
  planMode: boolean
@@ -74,6 +75,18 @@ export type ChatEvent =
74
75
  timestamp: number
75
76
  chatId: string
76
77
  }
78
+ | {
79
+ v: 2
80
+ type: "chat_archived"
81
+ timestamp: number
82
+ chatId: string
83
+ }
84
+ | {
85
+ v: 2
86
+ type: "chat_unarchived"
87
+ timestamp: number
88
+ chatId: string
89
+ }
77
90
  | {
78
91
  v: 2
79
92
  type: "chat_provider_set"
@@ -35,6 +35,48 @@ describe("read models", () => {
35
35
  expect(sidebar.projectGroups[0]?.defaultCollapsed).toBe(false)
36
36
  })
37
37
 
38
+ test("keeps archived chats out of the main sidebar rows", () => {
39
+ const state = createEmptyState()
40
+ state.projectsById.set("project-1", {
41
+ id: "project-1",
42
+ localPath: "/tmp/project",
43
+ title: "Project",
44
+ createdAt: 1,
45
+ updatedAt: 1,
46
+ })
47
+ state.projectIdsByPath.set("/tmp/project", "project-1")
48
+ state.chatsById.set("chat-active", {
49
+ id: "chat-active",
50
+ projectId: "project-1",
51
+ title: "Active",
52
+ createdAt: 1,
53
+ updatedAt: 1,
54
+ unread: false,
55
+ provider: null,
56
+ planMode: false,
57
+ sessionToken: null,
58
+ lastTurnOutcome: null,
59
+ })
60
+ state.chatsById.set("chat-archived", {
61
+ id: "chat-archived",
62
+ projectId: "project-1",
63
+ title: "Archived",
64
+ createdAt: 2,
65
+ updatedAt: 3,
66
+ archivedAt: 3,
67
+ unread: false,
68
+ provider: null,
69
+ planMode: false,
70
+ sessionToken: null,
71
+ lastTurnOutcome: null,
72
+ })
73
+
74
+ const sidebar = deriveSidebarData(state, new Map(), { nowMs: 1_000_000 })
75
+
76
+ expect(sidebar.projectGroups[0]?.chats.map((chat) => chat.chatId)).toEqual(["chat-active"])
77
+ expect(sidebar.projectGroups[0]?.archivedChats?.map((chat) => chat.chatId)).toEqual(["chat-archived"])
78
+ })
79
+
38
80
  test("includes available providers in chat snapshots", () => {
39
81
  const state = createEmptyState()
40
82
  state.projectsById.set("project-1", {
@@ -249,7 +291,7 @@ describe("read models", () => {
249
291
  expect(sidebar.projectGroups[0]?.defaultCollapsed).toBe(false)
250
292
  })
251
293
 
252
- test("limits recent chat previews to five before folding into older chats", () => {
294
+ test("shows all recent chats in the preview before folding older chats", () => {
253
295
  const state = createEmptyState()
254
296
  state.projectsById.set("project-1", {
255
297
  id: "project-1",
@@ -285,8 +327,9 @@ describe("read models", () => {
285
327
  "chat-3",
286
328
  "chat-4",
287
329
  "chat-5",
330
+ "chat-6",
288
331
  ])
289
- expect(sidebar.projectGroups[0]?.olderChats.map((chat) => chat.chatId)).toEqual(["chat-6"])
332
+ expect(sidebar.projectGroups[0]?.olderChats.map((chat) => chat.chatId)).toEqual([])
290
333
  })
291
334
 
292
335
  test("disables forking for active and draining chats, but allows pending fork chats", () => {
@@ -13,7 +13,6 @@ import { resolveLocalPath } from "./paths"
13
13
  import { SERVER_PROVIDERS } from "./provider-catalog"
14
14
 
15
15
  const SIDEBAR_RECENT_WINDOW_MS = 24 * 60 * 60 * 1_000
16
- const SIDEBAR_RECENT_PREVIEW_LIMIT = 5
17
16
  const SIDEBAR_FALLBACK_PREVIEW_LIMIT = 5
18
17
 
19
18
  export function deriveStatus(chat: ChatRecord, activeStatus?: KannaStatus): KannaStatus {
@@ -49,7 +48,7 @@ function isSidebarChatRecent(chat: Pick<SidebarChatRow, "lastMessageAt" | "_crea
49
48
  function getSidebarChatBuckets(chats: SidebarChatRow[], nowMs: number) {
50
49
  const recentChats = chats.filter((chat) => isSidebarChatRecent(chat, nowMs))
51
50
  const previewChats = recentChats.length > 0
52
- ? recentChats.slice(0, SIDEBAR_RECENT_PREVIEW_LIMIT)
51
+ ? recentChats
53
52
  : chats.slice(0, Math.min(SIDEBAR_FALLBACK_PREVIEW_LIMIT, chats.length))
54
53
  const previewChatIds = new Set(previewChats.map((chat) => chat.chatId))
55
54
 
@@ -71,14 +70,16 @@ export function deriveSidebarData(
71
70
  const nowMs = options?.nowMs ?? Date.now()
72
71
  const drainingChatIds = options?.drainingChatIds ?? new Set<string>()
73
72
  const chatsByProjectId = new Map<string, ChatRecord[]>()
73
+ const archivedChatsByProjectId = new Map<string, ChatRecord[]>()
74
74
  for (const chat of state.chatsById.values()) {
75
75
  if (chat.deletedAt) continue
76
- const projectChats = chatsByProjectId.get(chat.projectId)
76
+ const targetMap = chat.archivedAt ? archivedChatsByProjectId : chatsByProjectId
77
+ const projectChats = targetMap.get(chat.projectId)
77
78
  if (projectChats) {
78
79
  projectChats.push(chat)
79
80
  continue
80
81
  }
81
- chatsByProjectId.set(chat.projectId, [chat])
82
+ targetMap.set(chat.projectId, [chat])
82
83
  }
83
84
 
84
85
  const allProjects = [...state.projectsById.values()]
@@ -95,8 +96,8 @@ export function deriveSidebarData(
95
96
  ...unorderedProjects.filter((project) => !orderedProjectIds.has(project.id)),
96
97
  ]
97
98
 
98
- const projectGroups: SidebarProjectGroup[] = projects.map((project) => {
99
- const chats = (chatsByProjectId.get(project.id) ?? [])
99
+ function toSidebarChatRows(project: NonNullable<typeof projects[number]>, projectChats: ChatRecord[]) {
100
+ return projectChats
100
101
  .sort((a, b) => getSidebarChatSortTimestamp(b) - getSidebarChatSortTimestamp(a))
101
102
  .map((chat) => ({
102
103
  _id: chat.id,
@@ -111,6 +112,11 @@ export function deriveSidebarData(
111
112
  hasAutomation: false,
112
113
  canFork: canForkChat(chat, activeStatuses, drainingChatIds) || undefined,
113
114
  }))
115
+ }
116
+
117
+ const projectGroups: SidebarProjectGroup[] = projects.map((project) => {
118
+ const chats = toSidebarChatRows(project, chatsByProjectId.get(project.id) ?? [])
119
+ const archivedChats = toSidebarChatRows(project, archivedChatsByProjectId.get(project.id) ?? [])
114
120
  const { previewChats, olderChats } = getSidebarChatBuckets(chats, nowMs)
115
121
 
116
122
  return {
@@ -119,6 +125,7 @@ export function deriveSidebarData(
119
125
  chats,
120
126
  previewChats,
121
127
  olderChats,
128
+ ...(archivedChats.length ? { archivedChats } : {}),
122
129
  defaultCollapsed: chats.every((chat) => !isSidebarChatRecent(chat, nowMs)),
123
130
  }
124
131
  })
@@ -145,7 +152,7 @@ export function deriveLocalProjectsSnapshot(
145
152
  }
146
153
 
147
154
  for (const project of [...state.projectsById.values()].filter((entry) => !entry.deletedAt)) {
148
- const chats = [...state.chatsById.values()].filter((chat) => chat.projectId === project.id && !chat.deletedAt)
155
+ const chats = [...state.chatsById.values()].filter((chat) => chat.projectId === project.id && !chat.deletedAt && !chat.archivedAt)
149
156
  const lastOpenedAt = chats.reduce(
150
157
  (latest, chat) => Math.max(latest, getSidebarChatSortTimestamp(chat)),
151
158
  project.updatedAt
@@ -615,8 +615,6 @@ describe("ws-router", () => {
615
615
  "project_opened",
616
616
  "project_created",
617
617
  "project_removed",
618
- "chat_deleted",
619
- "chat_deleted",
620
618
  ])
621
619
  } finally {
622
620
  await rm(projectPath, { recursive: true, force: true })
@@ -939,21 +939,9 @@ export function createWsRouter({
939
939
  break
940
940
  }
941
941
  case "project.remove": {
942
- const project = store.getProject(command.projectId)
943
- const chats = store.listChatsByProject(command.projectId)
944
- for (const chat of chats) {
945
- await agent.cancel(chat.id)
946
- await agent.closeChat(chat.id)
947
- }
948
- if (project) {
949
- terminals.closeByCwd(project.localPath)
950
- }
951
942
  await store.removeProject(command.projectId)
952
943
  send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
953
944
  resolvedAnalytics.track("project_removed")
954
- for (const _chat of chats) {
955
- resolvedAnalytics.track("chat_deleted")
956
- }
957
945
  break
958
946
  }
959
947
  case "sidebar.reorderProjectGroups": {
@@ -998,6 +986,18 @@ export function createWsRouter({
998
986
  await broadcastChatAndSidebar(command.chatId)
999
987
  return
1000
988
  }
989
+ case "chat.archive": {
990
+ await store.archiveChat(command.chatId)
991
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
992
+ await broadcastFilteredSnapshots({ includeSidebar: true })
993
+ return
994
+ }
995
+ case "chat.unarchive": {
996
+ await store.unarchiveChat(command.chatId)
997
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
998
+ await broadcastChatAndSidebar(command.chatId)
999
+ return
1000
+ }
1001
1001
  case "chat.delete": {
1002
1002
  await agent.cancel(command.chatId)
1003
1003
  await agent.closeChat(command.chatId)
@@ -93,6 +93,8 @@ export type ClientCommand =
93
93
  | { type: "chat.create"; projectId: string }
94
94
  | { type: "chat.fork"; chatId: string }
95
95
  | { type: "chat.rename"; chatId: string; title: string }
96
+ | { type: "chat.archive"; chatId: string }
97
+ | { type: "chat.unarchive"; chatId: string }
96
98
  | { type: "chat.delete"; chatId: string }
97
99
  | { type: "chat.setDraftProtection"; chatIds: string[] }
98
100
  | { type: "chat.markRead"; chatId: string }
@@ -344,6 +344,7 @@ export interface SidebarProjectGroup {
344
344
  chats: SidebarChatRow[]
345
345
  previewChats: SidebarChatRow[]
346
346
  olderChats: SidebarChatRow[]
347
+ archivedChats?: SidebarChatRow[]
347
348
  defaultCollapsed: boolean
348
349
  }
349
350