kanna-code 0.21.0 → 0.22.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.
@@ -54,8 +54,23 @@ describe("read models", () => {
54
54
  lastTurnOutcome: null,
55
55
  })
56
56
 
57
- const chat = deriveChatSnapshot(state, new Map(), new Set(), "chat-1", () => [], () => ({ status: "unknown", files: [] }))
57
+ const chat = deriveChatSnapshot(
58
+ state,
59
+ new Map(),
60
+ new Set(),
61
+ "chat-1",
62
+ () => ({
63
+ messages: [],
64
+ history: {
65
+ hasOlder: false,
66
+ olderCursor: null,
67
+ recentLimit: 200,
68
+ },
69
+ }),
70
+ () => ({ status: "unknown", files: [] })
71
+ )
58
72
  expect(chat?.runtime.provider).toBe("claude")
73
+ expect(chat?.history.recentLimit).toBe(200)
59
74
  expect(chat?.availableProviders.length).toBeGreaterThan(1)
60
75
  expect(chat?.availableProviders.find((provider) => provider.id === "codex")?.models.map((model) => model.id)).toEqual([
61
76
  "gpt-5.4",
@@ -106,4 +121,45 @@ describe("read models", () => {
106
121
  },
107
122
  ])
108
123
  })
124
+
125
+ test("orders sidebar chats by user-visible activity instead of internal updatedAt churn", () => {
126
+ const state = createEmptyState()
127
+ state.projectsById.set("project-1", {
128
+ id: "project-1",
129
+ localPath: "/tmp/project",
130
+ title: "Project",
131
+ createdAt: 1,
132
+ updatedAt: 1,
133
+ })
134
+ state.projectIdsByPath.set("/tmp/project", "project-1")
135
+ state.chatsById.set("chat-old", {
136
+ id: "chat-old",
137
+ projectId: "project-1",
138
+ title: "Older user activity",
139
+ createdAt: 10,
140
+ updatedAt: 500,
141
+ unread: false,
142
+ provider: "claude",
143
+ planMode: false,
144
+ sessionToken: null,
145
+ lastMessageAt: 100,
146
+ lastTurnOutcome: null,
147
+ })
148
+ state.chatsById.set("chat-new", {
149
+ id: "chat-new",
150
+ projectId: "project-1",
151
+ title: "Newer user activity",
152
+ createdAt: 20,
153
+ updatedAt: 50,
154
+ unread: false,
155
+ provider: "claude",
156
+ planMode: false,
157
+ sessionToken: null,
158
+ lastMessageAt: 200,
159
+ lastTurnOutcome: null,
160
+ })
161
+
162
+ const sidebar = deriveSidebarData(state, new Map())
163
+ expect(sidebar.projectGroups[0]?.chats.map((chat) => chat.chatId)).toEqual(["chat-new", "chat-old"])
164
+ })
109
165
  })
@@ -18,6 +18,10 @@ export function deriveStatus(chat: ChatRecord, activeStatus?: KannaStatus): Kann
18
18
  return "idle"
19
19
  }
20
20
 
21
+ function getSidebarChatSortTimestamp(chat: ChatRecord) {
22
+ return chat.lastMessageAt ?? chat.createdAt
23
+ }
24
+
21
25
  export function deriveSidebarData(
22
26
  state: StoreState,
23
27
  activeStatuses: Map<string, KannaStatus>
@@ -29,7 +33,7 @@ export function deriveSidebarData(
29
33
  const projectGroups: SidebarProjectGroup[] = projects.map((project) => {
30
34
  const chats: SidebarChatRow[] = [...state.chatsById.values()]
31
35
  .filter((chat) => chat.projectId === project.id && !chat.deletedAt)
32
- .sort((a, b) => (b.lastMessageAt ?? b.updatedAt) - (a.lastMessageAt ?? a.updatedAt))
36
+ .sort((a, b) => getSidebarChatSortTimestamp(b) - getSidebarChatSortTimestamp(a))
33
37
  .map((chat) => ({
34
38
  _id: chat.id,
35
39
  _creationTime: chat.createdAt,
@@ -74,7 +78,7 @@ export function deriveLocalProjectsSnapshot(
74
78
  for (const project of [...state.projectsById.values()].filter((entry) => !entry.deletedAt)) {
75
79
  const chats = [...state.chatsById.values()].filter((chat) => chat.projectId === project.id && !chat.deletedAt)
76
80
  const lastOpenedAt = chats.reduce(
77
- (latest, chat) => Math.max(latest, chat.lastMessageAt ?? chat.updatedAt ?? 0),
81
+ (latest, chat) => Math.max(latest, getSidebarChatSortTimestamp(chat)),
78
82
  project.updatedAt
79
83
  )
80
84
 
@@ -101,7 +105,7 @@ export function deriveChatSnapshot(
101
105
  activeStatuses: Map<string, KannaStatus>,
102
106
  drainingChatIds: Set<string>,
103
107
  chatId: string,
104
- getMessages: (chatId: string) => ChatSnapshot["messages"],
108
+ getMessages: (chatId: string) => Pick<ChatSnapshot, "messages" | "history">,
105
109
  getDiffs: (chatId: string) => ChatDiffSnapshot
106
110
  ): ChatSnapshot | null {
107
111
  const chat = state.chatsById.get(chatId)
@@ -121,9 +125,12 @@ export function deriveChatSnapshot(
121
125
  sessionToken: chat.sessionToken,
122
126
  }
123
127
 
128
+ const transcript = getMessages(chat.id)
129
+
124
130
  return {
125
131
  runtime,
126
- messages: getMessages(chat.id),
132
+ messages: transcript.messages,
133
+ history: transcript.history,
127
134
  diffs: getDiffs(chat.id),
128
135
  availableProviders: [...SERVER_PROVIDERS],
129
136
  }
@@ -176,6 +176,92 @@ describe("ws-router", () => {
176
176
  })
177
177
  })
178
178
 
179
+ test("loads older chat history pages", () => {
180
+ const state = createEmptyState()
181
+ state.projectsById.set("project-1", {
182
+ id: "project-1",
183
+ localPath: "/tmp/project",
184
+ title: "Project",
185
+ createdAt: 1,
186
+ updatedAt: 1,
187
+ })
188
+ state.projectIdsByPath.set("/tmp/project", "project-1")
189
+ state.chatsById.set("chat-1", {
190
+ id: "chat-1",
191
+ projectId: "project-1",
192
+ title: "Chat",
193
+ createdAt: 1,
194
+ updatedAt: 1,
195
+ unread: false,
196
+ provider: null,
197
+ planMode: false,
198
+ sessionToken: null,
199
+ lastTurnOutcome: null,
200
+ })
201
+
202
+ const router = createWsRouter({
203
+ store: {
204
+ state,
205
+ getMessagesPageBefore: () => ({
206
+ messages: [{
207
+ _id: "msg-1",
208
+ kind: "assistant_text",
209
+ createdAt: 1,
210
+ text: "older message",
211
+ }],
212
+ hasOlder: false,
213
+ olderCursor: null,
214
+ }),
215
+ getChat: () => state.chatsById.get("chat-1") ?? null,
216
+ } as never,
217
+ agent: { getActiveStatuses: () => new Map(), getDrainingChatIds: () => new Set() } as never,
218
+ terminals: {
219
+ getSnapshot: () => null,
220
+ onEvent: () => () => {},
221
+ } as never,
222
+ keybindings: {
223
+ getSnapshot: () => DEFAULT_KEYBINDINGS_SNAPSHOT,
224
+ onChange: () => () => {},
225
+ } as never,
226
+ refreshDiscovery: async () => [],
227
+ getDiscoveredProjects: () => [],
228
+ machineDisplayName: "Local Machine",
229
+ updateManager: null,
230
+ })
231
+ const ws = new FakeWebSocket()
232
+
233
+ router.handleMessage(
234
+ ws as never,
235
+ JSON.stringify({
236
+ v: 1,
237
+ type: "command",
238
+ id: "history-1",
239
+ command: {
240
+ type: "chat.loadHistory",
241
+ chatId: "chat-1",
242
+ beforeCursor: "idx:100",
243
+ limit: 100,
244
+ },
245
+ })
246
+ )
247
+
248
+ expect(ws.sent[0]).toEqual({
249
+ v: PROTOCOL_VERSION,
250
+ type: "ack",
251
+ id: "history-1",
252
+ result: {
253
+ messages: [{
254
+ _id: "msg-1",
255
+ kind: "assistant_text",
256
+ createdAt: 1,
257
+ text: "older message",
258
+ }],
259
+ hasOlder: false,
260
+ olderCursor: null,
261
+ },
262
+ })
263
+ })
264
+
179
265
  test("marks chats read and rebroadcasts sidebar snapshots", async () => {
180
266
  const state = createEmptyState()
181
267
  state.projectsById.set("project-1", {
@@ -13,6 +13,8 @@ import { TerminalManager } from "./terminal-manager"
13
13
  import type { UpdateManager } from "./update-manager"
14
14
  import { deriveChatSnapshot, deriveLocalProjectsSnapshot, deriveSidebarData } from "./read-models"
15
15
 
16
+ const DEFAULT_CHAT_RECENT_LIMIT = 200
17
+
16
18
  export interface ClientState {
17
19
  subscriptions: Map<string, SubscriptionTopic>
18
20
  snapshotSignatures: Map<string, string>
@@ -55,10 +57,10 @@ export function createWsRouter({
55
57
  }: CreateWsRouterArgs) {
56
58
  const sockets = new Set<ServerWebSocket<ClientState>>()
57
59
  const resolvedDiffStore = diffStore ?? {
58
- getSnapshot: () => ({ status: "unknown", branchName: undefined, files: [] as const }),
60
+ getSnapshot: () => ({ status: "unknown", branchName: undefined, hasUpstream: undefined, files: [] as const }),
59
61
  refreshSnapshot: async () => false,
60
62
  generateCommitMessage: async () => ({ subject: "Update selected files", body: "", usedFallback: true, failureMessage: null }),
61
- commitFiles: async () => false,
63
+ commitFiles: async () => ({ ok: true, mode: "commit_only" as const, branchName: undefined, pushed: false, snapshotChanged: false }),
62
64
  }
63
65
 
64
66
  function createEnvelope(id: string, topic: SubscriptionTopic): ServerEnvelope {
@@ -144,7 +146,7 @@ export function createWsRouter({
144
146
  agent.getActiveStatuses(),
145
147
  agent.getDrainingChatIds(),
146
148
  topic.chatId,
147
- (chatId) => store.getMessages(chatId),
149
+ (chatId) => store.getRecentChatHistory(chatId, topic.recentLimit ?? DEFAULT_CHAT_RECENT_LIMIT),
148
150
  (chatId) => resolvedDiffStore.getSnapshot(chatId)
149
151
  ),
150
152
  },
@@ -155,6 +157,7 @@ export function createWsRouter({
155
157
  const snapshotSignatures = ensureSnapshotSignatures(ws)
156
158
  for (const [id, topic] of ws.data.subscriptions.entries()) {
157
159
  const envelope = createEnvelope(id, topic)
160
+ if (envelope.type !== "snapshot") continue
158
161
  const signature = JSON.stringify(envelope.snapshot)
159
162
  if (snapshotSignatures.get(id) === signature) {
160
163
  continue
@@ -186,6 +189,7 @@ export function createWsRouter({
186
189
  for (const [id, topic] of ws.data.subscriptions.entries()) {
187
190
  if (topic.type !== "terminal" || topic.terminalId !== terminalId) continue
188
191
  const envelope = createEnvelope(id, topic)
192
+ if (envelope.type !== "snapshot") continue
189
193
  const signature = JSON.stringify(envelope.snapshot)
190
194
  if (snapshotSignatures.get(id) === signature) continue
191
195
  snapshotSignatures.set(id, signature)
@@ -218,6 +222,7 @@ export function createWsRouter({
218
222
  for (const [id, topic] of ws.data.subscriptions.entries()) {
219
223
  if (topic.type !== "keybindings") continue
220
224
  const envelope = createEnvelope(id, topic)
225
+ if (envelope.type !== "snapshot") continue
221
226
  const signature = JSON.stringify(envelope.snapshot)
222
227
  if (snapshotSignatures.get(id) === signature) continue
223
228
  snapshotSignatures.set(id, signature)
@@ -232,6 +237,7 @@ export function createWsRouter({
232
237
  for (const [id, topic] of ws.data.subscriptions.entries()) {
233
238
  if (topic.type !== "update") continue
234
239
  const envelope = createEnvelope(id, topic)
240
+ if (envelope.type !== "snapshot") continue
235
241
  const signature = JSON.stringify(envelope.snapshot)
236
242
  if (snapshotSignatures.get(id) === signature) continue
237
243
  snapshotSignatures.set(id, signature)
@@ -387,15 +393,16 @@ export function createWsRouter({
387
393
  if (!project) {
388
394
  throw new Error("Project not found")
389
395
  }
390
- const changed = await resolvedDiffStore.commitFiles({
396
+ const result = await resolvedDiffStore.commitFiles({
391
397
  chatId: command.chatId,
392
398
  projectPath: project.localPath,
393
399
  paths: command.paths,
394
400
  summary: command.summary,
395
401
  description: command.description,
402
+ mode: command.mode,
396
403
  })
397
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
398
- if (changed) {
404
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result })
405
+ if (result.snapshotChanged) {
399
406
  broadcastSnapshots()
400
407
  }
401
408
  return
@@ -410,6 +417,15 @@ export function createWsRouter({
410
417
  send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
411
418
  break
412
419
  }
420
+ case "chat.loadHistory": {
421
+ const chat = store.getChat(command.chatId)
422
+ if (!chat) {
423
+ throw new Error("Chat not found")
424
+ }
425
+ const page = store.getMessagesPageBefore(command.chatId, command.beforeCursor, command.limit)
426
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result: page })
427
+ return
428
+ }
413
429
  case "chat.respondTool": {
414
430
  await agent.respondTool(command)
415
431
  send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
@@ -1,7 +1,9 @@
1
1
  import type {
2
2
  AgentProvider,
3
3
  ChatAttachment,
4
+ ChatHistoryPage,
4
5
  ChatSnapshot,
6
+ DiffCommitMode,
5
7
  KeybindingsSnapshot,
6
8
  LocalProjectsSnapshot,
7
9
  ModelOptions,
@@ -21,7 +23,7 @@ export type SubscriptionTopic =
21
23
  | { type: "local-projects" }
22
24
  | { type: "update" }
23
25
  | { type: "keybindings" }
24
- | { type: "chat"; chatId: string }
26
+ | { type: "chat"; chatId: string; recentLimit?: number }
25
27
  | { type: "terminal"; terminalId: string }
26
28
 
27
29
  export interface TerminalSnapshot {
@@ -77,9 +79,10 @@ export type ClientCommand =
77
79
  }
78
80
  | { type: "chat.refreshDiffs"; chatId: string }
79
81
  | { type: "chat.generateCommitMessage"; chatId: string; paths: string[] }
80
- | { type: "chat.commitDiffs"; chatId: string; paths: string[]; summary: string; description?: string }
82
+ | { type: "chat.commitDiffs"; chatId: string; paths: string[]; summary: string; description?: string; mode: DiffCommitMode }
81
83
  | { type: "chat.cancel"; chatId: string }
82
84
  | { type: "chat.stopDraining"; chatId: string }
85
+ | { type: "chat.loadHistory"; chatId: string; beforeCursor: string; limit: number }
83
86
  | { type: "chat.respondTool"; chatId: string; toolUseId: string; result: unknown }
84
87
  | { type: "terminal.create"; projectId: string; terminalId: string; cols: number; rows: number; scrollback: number }
85
88
  | { type: "terminal.input"; terminalId: string; data: string }
@@ -102,7 +105,7 @@ export type ServerSnapshot =
102
105
  export type ServerEnvelope =
103
106
  | { v: 1; type: "snapshot"; id: string; snapshot: ServerSnapshot }
104
107
  | { v: 1; type: "event"; id: string; event: TerminalEvent }
105
- | { v: 1; type: "ack"; id: string; result?: unknown }
108
+ | { v: 1; type: "ack"; id: string; result?: unknown | ChatHistoryPage }
106
109
  | { v: 1; type: "error"; id?: string; message: string }
107
110
 
108
111
  export function isClientEnvelope(value: unknown): value is ClientEnvelope {
@@ -479,9 +479,33 @@ export interface ChatDiffFile {
479
479
  export interface ChatDiffSnapshot {
480
480
  status: "unknown" | "ready" | "no_repo"
481
481
  branchName?: string
482
+ hasUpstream?: boolean
482
483
  files: ChatDiffFile[]
483
484
  }
484
485
 
486
+ export type DiffCommitMode = "commit_and_push" | "commit_only"
487
+
488
+ export interface DiffCommitSuccess {
489
+ ok: true
490
+ mode: DiffCommitMode
491
+ branchName?: string
492
+ pushed: boolean
493
+ snapshotChanged: boolean
494
+ }
495
+
496
+ export interface DiffCommitFailure {
497
+ ok: false
498
+ mode: DiffCommitMode
499
+ phase: "commit" | "push"
500
+ title: string
501
+ message: string
502
+ detail?: string
503
+ localCommitCreated?: boolean
504
+ snapshotChanged?: boolean
505
+ }
506
+
507
+ export type DiffCommitResult = DiffCommitSuccess | DiffCommitFailure
508
+
485
509
  export interface ContextWindowUpdatedEntry extends TranscriptEntryBase {
486
510
  kind: "context_window_updated"
487
511
  usage: ContextWindowUsageSnapshot
@@ -647,13 +671,26 @@ export interface ChatRuntime {
647
671
  sessionToken: string | null
648
672
  }
649
673
 
674
+ export interface ChatHistorySnapshot {
675
+ hasOlder: boolean
676
+ olderCursor: string | null
677
+ recentLimit: number
678
+ }
679
+
650
680
  export interface ChatSnapshot {
651
681
  runtime: ChatRuntime
652
682
  messages: TranscriptEntry[]
683
+ history: ChatHistorySnapshot
653
684
  diffs: ChatDiffSnapshot
654
685
  availableProviders: ProviderCatalogEntry[]
655
686
  }
656
687
 
688
+ export interface ChatHistoryPage {
689
+ messages: TranscriptEntry[]
690
+ hasOlder: boolean
691
+ olderCursor: string | null
692
+ }
693
+
657
694
  export interface KannaSnapshot {
658
695
  sidebar: SidebarData
659
696
  chat?: ChatSnapshot | null