kanna-code 0.36.1 → 0.38.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-DoZWMQxo.js"></script>
9
+ <link rel="stylesheet" crossorigin href="./assets/index-CmyWpbI0.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.38.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
@@ -1,11 +1,19 @@
1
1
  import { describe, expect, test } from "bun:test"
2
- import { mkdtemp, rm } from "node:fs/promises"
2
+ import { mkdtemp, rm, writeFile } from "node:fs/promises"
3
3
  import { tmpdir } from "node:os"
4
4
  import path from "node:path"
5
5
  import type { AppSettingsSnapshot, KeybindingsSnapshot, LlmProviderSnapshot, UpdateSnapshot } from "../shared/types"
6
6
  import { PROTOCOL_VERSION } from "../shared/types"
7
7
  import { createEmptyState } from "./events"
8
- import { createWsRouter } from "./ws-router"
8
+ import {
9
+ assertSafeSkillId,
10
+ assertSafeSkillSource,
11
+ buildInstallSkillCommand,
12
+ buildUninstallSkillCommand,
13
+ createWsRouter,
14
+ listInstalledSkills,
15
+ parseInstalledSkillsLock,
16
+ } from "./ws-router"
9
17
 
10
18
  function withSidebarGroupDefaults(group: {
11
19
  groupKey: string
@@ -96,6 +104,99 @@ const DEFAULT_APP_SETTINGS_SNAPSHOT: AppSettingsSnapshot = {
96
104
  filePathDisplay: "~/.kanna/data/settings.json",
97
105
  }
98
106
 
107
+ describe("skills helpers", () => {
108
+ test("parses installed global skills from a lock payload", () => {
109
+ const snapshot = parseInstalledSkillsLock({
110
+ version: 1,
111
+ skills: {
112
+ zeta: {
113
+ source: "owner/zeta",
114
+ sourceType: "github",
115
+ sourceUrl: "https://github.com/owner/zeta",
116
+ skillPath: "skills/zeta/SKILL.md",
117
+ installedAt: "2026-05-01T01:00:00.000Z",
118
+ updatedAt: "2026-05-01T02:00:00.000Z",
119
+ pluginName: "zeta-plugin",
120
+ },
121
+ alpha: {
122
+ source: "owner/alpha",
123
+ sourceType: "github",
124
+ },
125
+ ignored: "not an object",
126
+ },
127
+ }, "/tmp/.skill-lock.json")
128
+
129
+ expect(snapshot.lockFilePath).toBe("/tmp/.skill-lock.json")
130
+ expect(snapshot.skills.map((skill) => skill.name)).toEqual(["alpha", "zeta"])
131
+ expect(snapshot.skills[0]).toMatchObject({
132
+ name: "alpha",
133
+ source: "owner/alpha",
134
+ sourceType: "github",
135
+ sourceUrl: "",
136
+ installedAt: "",
137
+ updatedAt: "",
138
+ })
139
+ expect(snapshot.skills[1]).toMatchObject({
140
+ name: "zeta",
141
+ source: "owner/zeta",
142
+ skillPath: "skills/zeta/SKILL.md",
143
+ pluginName: "zeta-plugin",
144
+ })
145
+ })
146
+
147
+ test("returns an empty installed skills snapshot when the lock file is missing or invalid", async () => {
148
+ const dir = await mkdtemp(path.join(tmpdir(), "kanna-skills-"))
149
+ try {
150
+ const missingPath = path.join(dir, "missing.json")
151
+ expect(await listInstalledSkills(missingPath)).toEqual({
152
+ lockFilePath: missingPath,
153
+ skills: [],
154
+ })
155
+
156
+ const invalidPath = path.join(dir, ".skill-lock.json")
157
+ await writeFile(invalidPath, "{", "utf8")
158
+ expect(await listInstalledSkills(invalidPath)).toEqual({
159
+ lockFilePath: invalidPath,
160
+ skills: [],
161
+ })
162
+ } finally {
163
+ await rm(dir, { recursive: true, force: true })
164
+ }
165
+ })
166
+
167
+ test("validates skill source and id before building commands", () => {
168
+ expect(assertSafeSkillSource(" owner/repo ")).toBe("owner/repo")
169
+ expect(assertSafeSkillId(" my-skill_1 ")).toBe("my-skill_1")
170
+ expect(() => assertSafeSkillSource("https://github.com/owner/repo")).toThrow("owner/repo")
171
+ expect(() => assertSafeSkillId("../nope")).toThrow("Skill id is invalid.")
172
+ })
173
+
174
+ test("builds global install and uninstall commands for universal and Claude Code aliases", () => {
175
+ expect(buildInstallSkillCommand("owner/repo", "my-skill").slice(1)).toEqual([
176
+ "skills",
177
+ "add",
178
+ "owner/repo",
179
+ "--skill",
180
+ "my-skill",
181
+ "--global",
182
+ "--agent",
183
+ "universal",
184
+ "claude-code",
185
+ "--yes",
186
+ ])
187
+ expect(buildUninstallSkillCommand("my-skill").slice(1)).toEqual([
188
+ "skills",
189
+ "remove",
190
+ "my-skill",
191
+ "--global",
192
+ "--agent",
193
+ "universal",
194
+ "claude-code",
195
+ "--yes",
196
+ ])
197
+ })
198
+ })
199
+
99
200
  const DEFAULT_UPDATE_SNAPSHOT: UpdateSnapshot = {
100
201
  currentVersion: "0.12.0",
101
202
  latestVersion: null,
@@ -615,8 +716,6 @@ describe("ws-router", () => {
615
716
  "project_opened",
616
717
  "project_created",
617
718
  "project_removed",
618
- "chat_deleted",
619
- "chat_deleted",
620
719
  ])
621
720
  } finally {
622
721
  await rm(projectPath, { recursive: true, force: true })