kanna-code 0.31.0 → 0.32.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.
@@ -3,8 +3,56 @@ import { fallbackTitleFromMessage, generateTitleForChat, generateTitleForChatDet
3
3
  import { getQuickResponseWorkspace, QuickResponseAdapter } from "./quick-response"
4
4
 
5
5
  describe("QuickResponseAdapter", () => {
6
+ test("returns the SDK structured result when configured and it validates", async () => {
7
+ const adapter = new QuickResponseAdapter({
8
+ readLlmProvider: async () => ({
9
+ provider: "openai",
10
+ apiKey: "test-key",
11
+ model: "gpt-5-mini",
12
+ baseUrl: "",
13
+ resolvedBaseUrl: "https://api.openai.com/v1",
14
+ enabled: true,
15
+ warning: null,
16
+ filePathDisplay: "~/.kanna/llm-provider.json",
17
+ }),
18
+ runOpenAIStructured: async () => ({ title: "SDK title" }),
19
+ runClaudeStructured: async () => ({ title: "Claude title" }),
20
+ runCodexStructured: async () => ({ title: "Codex title" }),
21
+ })
22
+
23
+ const result = await adapter.generateStructured({
24
+ cwd: "/tmp/project",
25
+ task: "title generation",
26
+ prompt: "Generate a title",
27
+ schema: {
28
+ type: "object",
29
+ properties: {
30
+ title: { type: "string" },
31
+ },
32
+ required: ["title"],
33
+ additionalProperties: false,
34
+ },
35
+ parse: (value) => {
36
+ const output = value && typeof value === "object" ? value as { title?: unknown } : {}
37
+ return typeof output.title === "string" ? output.title : null
38
+ },
39
+ })
40
+
41
+ expect(result).toBe("SDK title")
42
+ })
43
+
6
44
  test("returns the Claude structured result when it validates", async () => {
7
45
  const adapter = new QuickResponseAdapter({
46
+ readLlmProvider: async () => ({
47
+ provider: "openai",
48
+ apiKey: "",
49
+ model: "",
50
+ baseUrl: "",
51
+ resolvedBaseUrl: "https://api.openai.com/v1",
52
+ enabled: false,
53
+ warning: null,
54
+ filePathDisplay: "~/.kanna/llm-provider.json",
55
+ }),
8
56
  runClaudeStructured: async () => ({ title: "Claude title" }),
9
57
  runCodexStructured: async () => ({ title: "Codex title" }),
10
58
  })
@@ -32,6 +80,16 @@ describe("QuickResponseAdapter", () => {
32
80
 
33
81
  test("falls back to Codex when Claude fails validation", async () => {
34
82
  const adapter = new QuickResponseAdapter({
83
+ readLlmProvider: async () => ({
84
+ provider: "openai",
85
+ apiKey: "",
86
+ model: "",
87
+ baseUrl: "",
88
+ resolvedBaseUrl: "https://api.openai.com/v1",
89
+ enabled: false,
90
+ warning: null,
91
+ filePathDisplay: "~/.kanna/llm-provider.json",
92
+ }),
35
93
  runClaudeStructured: async () => ({ bad: true }),
36
94
  runCodexStructured: async () => ({ title: "Codex title" }),
37
95
  })
@@ -59,6 +117,16 @@ describe("QuickResponseAdapter", () => {
59
117
 
60
118
  test("falls back to Codex when Claude throws", async () => {
61
119
  const adapter = new QuickResponseAdapter({
120
+ readLlmProvider: async () => ({
121
+ provider: "openai",
122
+ apiKey: "",
123
+ model: "",
124
+ baseUrl: "",
125
+ resolvedBaseUrl: "https://api.openai.com/v1",
126
+ enabled: false,
127
+ warning: null,
128
+ filePathDisplay: "~/.kanna/llm-provider.json",
129
+ }),
62
130
  runClaudeStructured: async () => {
63
131
  throw new Error("Not authenticated")
64
132
  },
@@ -93,6 +161,16 @@ describe("QuickResponseAdapter", () => {
93
161
  try {
94
162
  let claudeCwd = ""
95
163
  const adapter = new QuickResponseAdapter({
164
+ readLlmProvider: async () => ({
165
+ provider: "openai",
166
+ apiKey: "",
167
+ model: "",
168
+ baseUrl: "",
169
+ resolvedBaseUrl: "https://api.openai.com/v1",
170
+ enabled: false,
171
+ warning: null,
172
+ filePathDisplay: "~/.kanna-dev/llm-provider.json",
173
+ }),
96
174
  runClaudeStructured: async (args) => {
97
175
  claudeCwd = args.cwd
98
176
  return { title: "Claude title" }
@@ -131,6 +209,16 @@ describe("QuickResponseAdapter", () => {
131
209
  test("uses gpt-5.4-mini for Codex title generation fallback", async () => {
132
210
  const requests: Array<{ cwd: string; prompt: string; model?: string }> = []
133
211
  const adapter = new QuickResponseAdapter({
212
+ readLlmProvider: async () => ({
213
+ provider: "openai",
214
+ apiKey: "",
215
+ model: "",
216
+ baseUrl: "",
217
+ resolvedBaseUrl: "https://api.openai.com/v1",
218
+ enabled: false,
219
+ warning: null,
220
+ filePathDisplay: "~/.kanna/llm-provider.json",
221
+ }),
134
222
  codexManager: {
135
223
  async generateStructured(args: { cwd: string; prompt: string; model?: string }) {
136
224
  requests.push(args)
@@ -162,6 +250,48 @@ describe("QuickResponseAdapter", () => {
162
250
  expect(requests).toHaveLength(1)
163
251
  expect(requests[0]?.model).toBe("gpt-5.4-mini")
164
252
  })
253
+
254
+ test("falls through to Claude when the SDK is not configured", async () => {
255
+ let openAICalls = 0
256
+ const adapter = new QuickResponseAdapter({
257
+ readLlmProvider: async () => ({
258
+ provider: "openai",
259
+ apiKey: "",
260
+ model: "",
261
+ baseUrl: "",
262
+ resolvedBaseUrl: "https://api.openai.com/v1",
263
+ enabled: false,
264
+ warning: null,
265
+ filePathDisplay: "~/.kanna/llm-provider.json",
266
+ }),
267
+ runOpenAIStructured: async () => {
268
+ openAICalls += 1
269
+ return { title: "SDK title" }
270
+ },
271
+ runClaudeStructured: async () => ({ title: "Claude title" }),
272
+ })
273
+
274
+ const result = await adapter.generateStructured({
275
+ cwd: "/tmp/project",
276
+ task: "title generation",
277
+ prompt: "Generate a title",
278
+ schema: {
279
+ type: "object",
280
+ properties: {
281
+ title: { type: "string" },
282
+ },
283
+ required: ["title"],
284
+ additionalProperties: false,
285
+ },
286
+ parse: (value) => {
287
+ const output = value && typeof value === "object" ? value as { title?: unknown } : {}
288
+ return typeof output.title === "string" ? output.title : null
289
+ },
290
+ })
291
+
292
+ expect(result).toBe("Claude title")
293
+ expect(openAICalls).toBe(0)
294
+ })
165
295
  })
166
296
 
167
297
  describe("generateTitleForChat", () => {
@@ -170,6 +300,16 @@ describe("generateTitleForChat", () => {
170
300
  "hello",
171
301
  "/tmp/project",
172
302
  new QuickResponseAdapter({
303
+ readLlmProvider: async () => ({
304
+ provider: "openai",
305
+ apiKey: "",
306
+ model: "",
307
+ baseUrl: "",
308
+ resolvedBaseUrl: "https://api.openai.com/v1",
309
+ enabled: false,
310
+ warning: null,
311
+ filePathDisplay: "~/.kanna/llm-provider.json",
312
+ }),
173
313
  runClaudeStructured: async () => ({ title: " Example\nTitle " }),
174
314
  })
175
315
  )
@@ -182,6 +322,16 @@ describe("generateTitleForChat", () => {
182
322
  "hello",
183
323
  "/tmp/project",
184
324
  new QuickResponseAdapter({
325
+ readLlmProvider: async () => ({
326
+ provider: "openai",
327
+ apiKey: "",
328
+ model: "",
329
+ baseUrl: "",
330
+ resolvedBaseUrl: "https://api.openai.com/v1",
331
+ enabled: false,
332
+ warning: null,
333
+ filePathDisplay: "~/.kanna/llm-provider.json",
334
+ }),
185
335
  runClaudeStructured: async () => ({ title: " " }),
186
336
  runCodexStructured: async () => ({ title: "New Chat" }),
187
337
  })
@@ -195,6 +345,16 @@ describe("generateTitleForChat", () => {
195
345
  "This message is definitely longer than thirty five characters",
196
346
  "/tmp/project",
197
347
  new QuickResponseAdapter({
348
+ readLlmProvider: async () => ({
349
+ provider: "openai",
350
+ apiKey: "",
351
+ model: "",
352
+ baseUrl: "",
353
+ resolvedBaseUrl: "https://api.openai.com/v1",
354
+ enabled: false,
355
+ warning: null,
356
+ filePathDisplay: "~/.kanna/llm-provider.json",
357
+ }),
198
358
  runClaudeStructured: async () => {
199
359
  throw new Error("Not authenticated")
200
360
  },
@@ -210,6 +370,16 @@ describe("generateTitleForChat", () => {
210
370
  "hello there",
211
371
  "/tmp/project",
212
372
  new QuickResponseAdapter({
373
+ readLlmProvider: async () => ({
374
+ provider: "openai",
375
+ apiKey: "",
376
+ model: "",
377
+ baseUrl: "",
378
+ resolvedBaseUrl: "https://api.openai.com/v1",
379
+ enabled: false,
380
+ warning: null,
381
+ filePathDisplay: "~/.kanna/llm-provider.json",
382
+ }),
213
383
  runClaudeStructured: async () => {
214
384
  throw new Error("Not authenticated")
215
385
  },
@@ -225,6 +395,38 @@ describe("generateTitleForChat", () => {
225
395
  failureMessage: "claude failed conversation title generation: Not authenticated; codex failed conversation title generation: Codex unavailable",
226
396
  })
227
397
  })
398
+
399
+ test("includes SDK failure details before Claude and Codex", async () => {
400
+ const result = await generateTitleForChatDetailed(
401
+ "hello there",
402
+ "/tmp/project",
403
+ new QuickResponseAdapter({
404
+ readLlmProvider: async () => ({
405
+ provider: "openai",
406
+ apiKey: "test-key",
407
+ model: "gpt-5-mini",
408
+ baseUrl: "",
409
+ resolvedBaseUrl: "https://api.openai.com/v1",
410
+ enabled: true,
411
+ warning: null,
412
+ filePathDisplay: "~/.kanna/llm-provider.json",
413
+ }),
414
+ runOpenAIStructured: async () => {
415
+ throw new Error("SDK unavailable")
416
+ },
417
+ runClaudeStructured: async () => {
418
+ throw new Error("Not authenticated")
419
+ },
420
+ runCodexStructured: async () => {
421
+ throw new Error("Codex unavailable")
422
+ },
423
+ })
424
+ )
425
+
426
+ expect(result.failureMessage).toBe(
427
+ "openai failed conversation title generation: SDK unavailable; claude failed conversation title generation: Not authenticated; codex failed conversation title generation: Codex unavailable"
428
+ )
429
+ })
228
430
  })
229
431
 
230
432
  describe("fallbackTitleFromMessage", () => {
@@ -1,7 +1,10 @@
1
1
  import { query } from "@anthropic-ai/claude-agent-sdk"
2
2
  import { homedir } from "node:os"
3
+ import OpenAI from "openai"
3
4
  import { getDataRootDir } from "../shared/branding"
5
+ import type { LlmProviderSnapshot } from "../shared/types"
4
6
  import { CodexAppServerManager } from "./codex-app-server"
7
+ import { readLlmProviderSnapshot } from "./llm-provider"
5
8
 
6
9
  const CLAUDE_STRUCTURED_TIMEOUT_MS = 5_000
7
10
 
@@ -22,12 +25,17 @@ export interface StructuredQuickResponseArgs<T> {
22
25
 
23
26
  interface QuickResponseAdapterArgs {
24
27
  codexManager?: CodexAppServerManager
28
+ readLlmProvider?: () => Promise<LlmProviderSnapshot>
29
+ runOpenAIStructured?: (
30
+ config: LlmProviderSnapshot,
31
+ args: Omit<StructuredQuickResponseArgs<unknown>, "parse">
32
+ ) => Promise<unknown | null>
25
33
  runClaudeStructured?: (args: Omit<StructuredQuickResponseArgs<unknown>, "parse">) => Promise<unknown | null>
26
34
  runCodexStructured?: (args: Omit<StructuredQuickResponseArgs<unknown>, "parse">) => Promise<unknown | null>
27
35
  }
28
36
 
29
37
  export interface StructuredQuickResponseFailure {
30
- provider: "claude" | "codex"
38
+ provider: "openai" | "claude" | "codex"
31
39
  reason: string
32
40
  }
33
41
 
@@ -133,6 +141,31 @@ export async function runClaudeStructured(args: Omit<StructuredQuickResponseArgs
133
141
  }
134
142
  }
135
143
 
144
+ export async function runOpenAIStructured(
145
+ config: LlmProviderSnapshot,
146
+ args: Omit<StructuredQuickResponseArgs<unknown>, "parse">
147
+ ): Promise<unknown | null> {
148
+ const client = new OpenAI({
149
+ apiKey: config.apiKey,
150
+ baseURL: config.resolvedBaseUrl,
151
+ })
152
+
153
+ const response = await client.responses.create({
154
+ model: config.model,
155
+ input: args.prompt,
156
+ text: {
157
+ format: {
158
+ type: "json_schema",
159
+ name: "quick_response",
160
+ schema: args.schema,
161
+ strict: true,
162
+ },
163
+ },
164
+ })
165
+
166
+ return parseJsonText(response.output_text)
167
+ }
168
+
136
169
  export async function runCodexStructured(
137
170
  codexManager: CodexAppServerManager,
138
171
  args: Omit<StructuredQuickResponseArgs<unknown>, "parse">
@@ -148,11 +181,18 @@ export async function runCodexStructured(
148
181
 
149
182
  export class QuickResponseAdapter {
150
183
  private readonly codexManager: CodexAppServerManager
184
+ private readonly readLlmProvider: () => Promise<LlmProviderSnapshot>
185
+ private readonly runOpenAIStructured: (
186
+ config: LlmProviderSnapshot,
187
+ args: Omit<StructuredQuickResponseArgs<unknown>, "parse">
188
+ ) => Promise<unknown | null>
151
189
  private readonly runClaudeStructured: (args: Omit<StructuredQuickResponseArgs<unknown>, "parse">) => Promise<unknown | null>
152
190
  private readonly runCodexStructured: (args: Omit<StructuredQuickResponseArgs<unknown>, "parse">) => Promise<unknown | null>
153
191
 
154
192
  constructor(args: QuickResponseAdapterArgs = {}) {
155
193
  this.codexManager = args.codexManager ?? new CodexAppServerManager()
194
+ this.readLlmProvider = args.readLlmProvider ?? (() => readLlmProviderSnapshot())
195
+ this.runOpenAIStructured = args.runOpenAIStructured ?? runOpenAIStructured
156
196
  this.runClaudeStructured = args.runClaudeStructured ?? runClaudeStructured
157
197
  this.runCodexStructured = args.runCodexStructured ?? ((structuredArgs) =>
158
198
  runCodexStructured(this.codexManager, structuredArgs))
@@ -171,6 +211,20 @@ export class QuickResponseAdapter {
171
211
  }
172
212
 
173
213
  const failures: StructuredQuickResponseFailure[] = []
214
+ const llmProvider = await this.readLlmProvider()
215
+ if (llmProvider.enabled) {
216
+ const openAIResult = await this.tryProvider("openai", args.task, args.parse, () => this.runOpenAIStructured(llmProvider, request))
217
+ if (openAIResult.value !== null) {
218
+ return {
219
+ value: openAIResult.value,
220
+ failures,
221
+ }
222
+ }
223
+ if (openAIResult.failure) {
224
+ failures.push(openAIResult.failure)
225
+ }
226
+ }
227
+
174
228
  const claudeResult = await this.tryProvider("claude", args.task, args.parse, () => this.runClaudeStructured(request))
175
229
  if (claudeResult.value !== null) {
176
230
  return {
@@ -200,7 +254,7 @@ export class QuickResponseAdapter {
200
254
  }
201
255
 
202
256
  private async tryProvider<T>(
203
- provider: "claude" | "codex",
257
+ provider: "openai" | "claude" | "codex",
204
258
  task: string,
205
259
  parse: (value: unknown) => T | null,
206
260
  run: () => Promise<unknown | null>
@@ -26,9 +26,12 @@ describe("read models", () => {
26
26
  lastTurnOutcome: null,
27
27
  })
28
28
 
29
- const sidebar = deriveSidebarData(state, new Map())
29
+ const sidebar = deriveSidebarData(state, new Map(), 1_000_000)
30
30
  expect(sidebar.projectGroups[0]?.chats[0]?.provider).toBe("codex")
31
31
  expect(sidebar.projectGroups[0]?.chats[0]?.unread).toBe(true)
32
+ expect(sidebar.projectGroups[0]?.previewChats.map((chat) => chat.chatId)).toEqual(["chat-1"])
33
+ expect(sidebar.projectGroups[0]?.olderChats).toEqual([])
34
+ expect(sidebar.projectGroups[0]?.defaultCollapsed).toBe(false)
32
35
  })
33
36
 
34
37
  test("includes available providers in chat snapshots", () => {
@@ -171,4 +174,78 @@ describe("read models", () => {
171
174
  const sidebar = deriveSidebarData(state, new Map())
172
175
  expect(sidebar.projectGroups[0]?.chats.map((chat) => chat.chatId)).toEqual(["chat-new", "chat-old"])
173
176
  })
177
+
178
+ test("honors persisted project order before fallback updated-at ordering", () => {
179
+ const state = createEmptyState()
180
+ state.projectsById.set("project-1", {
181
+ id: "project-1",
182
+ localPath: "/tmp/project-1",
183
+ title: "One",
184
+ createdAt: 1,
185
+ updatedAt: 10,
186
+ })
187
+ state.projectsById.set("project-2", {
188
+ id: "project-2",
189
+ localPath: "/tmp/project-2",
190
+ title: "Two",
191
+ createdAt: 2,
192
+ updatedAt: 20,
193
+ })
194
+ state.projectsById.set("project-3", {
195
+ id: "project-3",
196
+ localPath: "/tmp/project-3",
197
+ title: "Three",
198
+ createdAt: 3,
199
+ updatedAt: 15,
200
+ })
201
+ state.sidebarProjectOrder = ["project-1"]
202
+
203
+ const sidebar = deriveSidebarData(state, new Map())
204
+
205
+ expect(sidebar.projectGroups.map((group) => group.groupKey)).toEqual(["project-1", "project-2", "project-3"])
206
+ })
207
+
208
+ test("builds preview and older chat slices using the current sidebar rules", () => {
209
+ const state = createEmptyState()
210
+ state.projectsById.set("project-1", {
211
+ id: "project-1",
212
+ localPath: "/tmp/project",
213
+ title: "Project",
214
+ createdAt: 1,
215
+ updatedAt: 1,
216
+ })
217
+ state.projectIdsByPath.set("/tmp/project", "project-1")
218
+ state.chatsById.set("chat-1", {
219
+ id: "chat-1",
220
+ projectId: "project-1",
221
+ title: "Recent",
222
+ createdAt: 10,
223
+ updatedAt: 10,
224
+ unread: false,
225
+ provider: "claude",
226
+ planMode: false,
227
+ sessionToken: null,
228
+ lastMessageAt: 1_000_000 - 60 * 60 * 1_000,
229
+ lastTurnOutcome: null,
230
+ })
231
+ state.chatsById.set("chat-2", {
232
+ id: "chat-2",
233
+ projectId: "project-1",
234
+ title: "Older",
235
+ createdAt: 20,
236
+ updatedAt: 20,
237
+ unread: false,
238
+ provider: "claude",
239
+ planMode: false,
240
+ sessionToken: null,
241
+ lastMessageAt: 1_000_000 - 26 * 60 * 60 * 1_000,
242
+ lastTurnOutcome: null,
243
+ })
244
+
245
+ const sidebar = deriveSidebarData(state, new Map(), 1_000_000)
246
+
247
+ expect(sidebar.projectGroups[0]?.previewChats.map((chat) => chat.chatId)).toEqual(["chat-1"])
248
+ expect(sidebar.projectGroups[0]?.olderChats.map((chat) => chat.chatId)).toEqual(["chat-2"])
249
+ expect(sidebar.projectGroups[0]?.defaultCollapsed).toBe(false)
250
+ })
174
251
  })
@@ -11,6 +11,10 @@ import type { ChatRecord, StoreState } from "./events"
11
11
  import { resolveLocalPath } from "./paths"
12
12
  import { SERVER_PROVIDERS } from "./provider-catalog"
13
13
 
14
+ const SIDEBAR_RECENT_WINDOW_MS = 24 * 60 * 60 * 1_000
15
+ const SIDEBAR_RECENT_PREVIEW_LIMIT = 10
16
+ const SIDEBAR_FALLBACK_PREVIEW_LIMIT = 5
17
+
14
18
  export function deriveStatus(chat: ChatRecord, activeStatus?: KannaStatus): KannaStatus {
15
19
  if (activeStatus) return activeStatus
16
20
  if (chat.lastTurnOutcome === "failed") return "failed"
@@ -21,17 +25,59 @@ function getSidebarChatSortTimestamp(chat: ChatRecord) {
21
25
  return chat.lastMessageAt ?? chat.createdAt
22
26
  }
23
27
 
28
+ function getSidebarChatTimestamp(chat: Pick<SidebarChatRow, "lastMessageAt" | "_creationTime">) {
29
+ return chat.lastMessageAt ?? chat._creationTime
30
+ }
31
+
32
+ function isSidebarChatRecent(chat: Pick<SidebarChatRow, "lastMessageAt" | "_creationTime">, nowMs: number) {
33
+ return Math.max(0, nowMs - getSidebarChatTimestamp(chat)) < SIDEBAR_RECENT_WINDOW_MS
34
+ }
35
+
36
+ function getSidebarChatBuckets(chats: SidebarChatRow[], nowMs: number) {
37
+ const recentChats = chats.filter((chat) => isSidebarChatRecent(chat, nowMs))
38
+ const previewChats = recentChats.length > 0
39
+ ? recentChats.slice(0, SIDEBAR_RECENT_PREVIEW_LIMIT)
40
+ : chats.slice(0, Math.min(SIDEBAR_FALLBACK_PREVIEW_LIMIT, chats.length))
41
+ const previewChatIds = new Set(previewChats.map((chat) => chat.chatId))
42
+
43
+ return {
44
+ previewChats,
45
+ olderChats: chats.filter((chat) => !previewChatIds.has(chat.chatId)),
46
+ }
47
+ }
48
+
24
49
  export function deriveSidebarData(
25
50
  state: StoreState,
26
- activeStatuses: Map<string, KannaStatus>
51
+ activeStatuses: Map<string, KannaStatus>,
52
+ nowMs = Date.now()
27
53
  ): SidebarData {
28
- const projects = [...state.projectsById.values()]
54
+ const chatsByProjectId = new Map<string, ChatRecord[]>()
55
+ for (const chat of state.chatsById.values()) {
56
+ if (chat.deletedAt) continue
57
+ const projectChats = chatsByProjectId.get(chat.projectId)
58
+ if (projectChats) {
59
+ projectChats.push(chat)
60
+ continue
61
+ }
62
+ chatsByProjectId.set(chat.projectId, [chat])
63
+ }
64
+
65
+ const allProjects = [...state.projectsById.values()]
29
66
  .filter((project) => !project.deletedAt)
67
+ const unorderedProjects = allProjects
30
68
  .sort((a, b) => b.updatedAt - a.updatedAt)
69
+ const projectById = new Map(unorderedProjects.map((project) => [project.id, project]))
70
+ const orderedProjects = state.sidebarProjectOrder
71
+ .map((projectId) => projectById.get(projectId))
72
+ .filter((project): project is NonNullable<typeof project> => Boolean(project))
73
+ const orderedProjectIds = new Set(orderedProjects.map((project) => project.id))
74
+ const projects = [
75
+ ...orderedProjects,
76
+ ...unorderedProjects.filter((project) => !orderedProjectIds.has(project.id)),
77
+ ]
31
78
 
32
79
  const projectGroups: SidebarProjectGroup[] = projects.map((project) => {
33
- const chats: SidebarChatRow[] = [...state.chatsById.values()]
34
- .filter((chat) => chat.projectId === project.id && !chat.deletedAt)
80
+ const chats = (chatsByProjectId.get(project.id) ?? [])
35
81
  .sort((a, b) => getSidebarChatSortTimestamp(b) - getSidebarChatSortTimestamp(a))
36
82
  .map((chat) => ({
37
83
  _id: chat.id,
@@ -45,11 +91,15 @@ export function deriveSidebarData(
45
91
  lastMessageAt: chat.lastMessageAt,
46
92
  hasAutomation: false,
47
93
  }))
94
+ const { previewChats, olderChats } = getSidebarChatBuckets(chats, nowMs)
48
95
 
49
96
  return {
50
97
  groupKey: project.id,
51
98
  localPath: project.localPath,
52
99
  chats,
100
+ previewChats,
101
+ olderChats,
102
+ defaultCollapsed: chats.every((chat) => !isSidebarChatRecent(chat, nowMs)),
53
103
  }
54
104
  })
55
105
 
@@ -8,6 +8,7 @@ import { AgentCoordinator } from "./agent"
8
8
  import { DiffStore } from "./diff-store"
9
9
  import { discoverProjects, type DiscoveredProject } from "./discovery"
10
10
  import { KeybindingsManager } from "./keybindings"
11
+ import { readLlmProviderSnapshot, writeLlmProviderSnapshot } from "./llm-provider"
11
12
  import { getMachineDisplayName } from "./machine-name"
12
13
  import { TerminalManager } from "./terminal-manager"
13
14
  import { UpdateManager } from "./update-manager"
@@ -120,6 +121,10 @@ export async function startKannaServer(options: StartKannaServerOptions = {}) {
120
121
  agent,
121
122
  terminals,
122
123
  keybindings,
124
+ llmProvider: {
125
+ read: readLlmProviderSnapshot,
126
+ write: writeLlmProviderSnapshot,
127
+ },
123
128
  refreshDiscovery,
124
129
  getDiscoveredProjects: () => discoveredProjects,
125
130
  machineDisplayName,