kanna-code 0.35.1 → 0.36.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,6 +3,7 @@ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"
3
3
  import { tmpdir } from "node:os"
4
4
  import path from "node:path"
5
5
  import { AppSettingsManager, readAppSettingsSnapshot } from "./app-settings"
6
+ import type { AppSettingsSnapshot } from "../shared/types"
6
7
 
7
8
  let tempDirs: string[] = []
8
9
 
@@ -17,16 +18,52 @@ async function createTempFilePath() {
17
18
  return path.join(dir, "settings.json")
18
19
  }
19
20
 
21
+ function expectedSettingsSnapshot(filePath: string, overrides: Partial<AppSettingsSnapshot> = {}): AppSettingsSnapshot {
22
+ return {
23
+ analyticsEnabled: true,
24
+ browserSettingsMigrated: false,
25
+ theme: "system",
26
+ chatSoundPreference: "always",
27
+ chatSoundId: "funk",
28
+ terminal: {
29
+ scrollbackLines: 1_000,
30
+ minColumnWidth: 450,
31
+ },
32
+ editor: {
33
+ preset: "cursor",
34
+ commandTemplate: "cursor {path}",
35
+ },
36
+ defaultProvider: "last_used",
37
+ providerDefaults: {
38
+ claude: {
39
+ model: "claude-opus-4-7",
40
+ modelOptions: {
41
+ reasoningEffort: "high",
42
+ contextWindow: "200k",
43
+ },
44
+ planMode: false,
45
+ },
46
+ codex: {
47
+ model: "gpt-5.5",
48
+ modelOptions: {
49
+ reasoningEffort: "high",
50
+ fastMode: false,
51
+ },
52
+ planMode: false,
53
+ },
54
+ },
55
+ warning: null,
56
+ filePathDisplay: filePath,
57
+ ...overrides,
58
+ }
59
+ }
60
+
20
61
  describe("readAppSettingsSnapshot", () => {
21
62
  test("returns defaults when the file does not exist", async () => {
22
63
  const filePath = await createTempFilePath()
23
64
  const snapshot = await readAppSettingsSnapshot(filePath)
24
65
 
25
- expect(snapshot).toEqual({
26
- analyticsEnabled: true,
27
- warning: null,
28
- filePathDisplay: filePath,
29
- })
66
+ expect(snapshot).toEqual(expectedSettingsSnapshot(filePath))
30
67
  })
31
68
 
32
69
  test("returns a warning when the file contains invalid json", async () => {
@@ -52,11 +89,7 @@ describe("AppSettingsManager", () => {
52
89
  }
53
90
  expect(payload.analyticsEnabled).toBe(true)
54
91
  expect(payload.analyticsUserId).toMatch(/^anon_/)
55
- expect(manager.getSnapshot()).toEqual({
56
- analyticsEnabled: true,
57
- warning: null,
58
- filePathDisplay: filePath,
59
- })
92
+ expect(manager.getSnapshot()).toEqual(expectedSettingsSnapshot(filePath))
60
93
 
61
94
  manager.dispose()
62
95
  })
@@ -77,14 +110,53 @@ describe("AppSettingsManager", () => {
77
110
  analyticsUserId: string
78
111
  }
79
112
 
80
- expect(snapshot).toEqual({
81
- analyticsEnabled: false,
82
- warning: null,
83
- filePathDisplay: filePath,
84
- })
113
+ expect(snapshot).toEqual(expectedSettingsSnapshot(filePath, { analyticsEnabled: false }))
85
114
  expect(nextPayload.analyticsEnabled).toBe(false)
86
115
  expect(nextPayload.analyticsUserId).toBe(initialPayload.analyticsUserId)
87
116
 
88
117
  manager.dispose()
89
118
  })
119
+
120
+ test("patches expanded settings without replacing the stored user id", async () => {
121
+ const filePath = await createTempFilePath()
122
+ const manager = new AppSettingsManager(filePath)
123
+
124
+ await manager.initialize()
125
+ const initialPayload = JSON.parse(await readFile(filePath, "utf8")) as {
126
+ analyticsUserId: string
127
+ }
128
+
129
+ const snapshot = await manager.writePatch({
130
+ theme: "dark",
131
+ chatSoundId: "glass",
132
+ terminal: { scrollbackLines: 2_500 },
133
+ editor: { preset: "vscode" },
134
+ providerDefaults: {
135
+ codex: {
136
+ modelOptions: { reasoningEffort: "high", fastMode: true },
137
+ },
138
+ },
139
+ })
140
+ const nextPayload = JSON.parse(await readFile(filePath, "utf8")) as {
141
+ analyticsUserId: string
142
+ theme: string
143
+ chatSoundId: string
144
+ terminal: { scrollbackLines: number; minColumnWidth: number }
145
+ editor: { preset: string; commandTemplate: string }
146
+ providerDefaults: { codex: { modelOptions: { fastMode: boolean } } }
147
+ }
148
+
149
+ expect(snapshot.theme).toBe("dark")
150
+ expect(snapshot.chatSoundId).toBe("glass")
151
+ expect(snapshot.terminal.scrollbackLines).toBe(2_500)
152
+ expect(snapshot.terminal.minColumnWidth).toBe(450)
153
+ expect(snapshot.editor.preset).toBe("vscode")
154
+ expect(snapshot.editor.commandTemplate).toBe("cursor {path}")
155
+ expect(snapshot.providerDefaults.codex.modelOptions.fastMode).toBe(true)
156
+ expect(nextPayload.analyticsUserId).toBe(initialPayload.analyticsUserId)
157
+ expect(nextPayload.theme).toBe("dark")
158
+ expect(nextPayload.chatSoundId).toBe("glass")
159
+
160
+ manager.dispose()
161
+ })
90
162
  })
@@ -4,11 +4,48 @@ import { mkdir, readFile, writeFile } from "node:fs/promises"
4
4
  import { homedir } from "node:os"
5
5
  import path from "node:path"
6
6
  import { getSettingsFilePath, LOG_PREFIX } from "../shared/branding"
7
- import type { AppSettingsSnapshot } from "../shared/types"
7
+ import {
8
+ DEFAULT_CLAUDE_MODEL_OPTIONS,
9
+ DEFAULT_CODEX_MODEL_OPTIONS,
10
+ isClaudeReasoningEffort,
11
+ isCodexReasoningEffort,
12
+ normalizeClaudeContextWindow,
13
+ normalizeClaudeModelId,
14
+ normalizeCodexModelId,
15
+ supportsClaudeMaxReasoningEffort,
16
+ type AppSettingsPatch,
17
+ type AppSettingsSnapshot,
18
+ type AppThemePreference,
19
+ type ChatProviderPreferences,
20
+ type ChatSoundId,
21
+ type ChatSoundPreference,
22
+ type ClaudeModelOptions,
23
+ type CodexModelOptions,
24
+ type DefaultProviderPreference,
25
+ type EditorPreset,
26
+ type ProviderPreference,
27
+ } from "../shared/types"
8
28
 
9
29
  interface AppSettingsFile {
10
30
  analyticsEnabled?: unknown
11
31
  analyticsUserId?: unknown
32
+ browserSettingsMigrated?: unknown
33
+ theme?: unknown
34
+ chatSoundPreference?: unknown
35
+ chatSoundId?: unknown
36
+ terminal?: {
37
+ scrollbackLines?: unknown
38
+ minColumnWidth?: unknown
39
+ }
40
+ editor?: {
41
+ preset?: unknown
42
+ commandTemplate?: unknown
43
+ }
44
+ defaultProvider?: unknown
45
+ providerDefaults?: {
46
+ claude?: Partial<ProviderPreference<Partial<ClaudeModelOptions>>> & { effort?: unknown }
47
+ codex?: Partial<ProviderPreference<Partial<CodexModelOptions>>> & { effort?: unknown }
48
+ }
12
49
  }
13
50
 
14
51
  interface AppSettingsState extends AppSettingsSnapshot {
@@ -16,14 +53,21 @@ interface AppSettingsState extends AppSettingsSnapshot {
16
53
  }
17
54
 
18
55
  interface NormalizedAppSettings {
19
- payload: {
20
- analyticsEnabled: boolean
21
- analyticsUserId: string
22
- }
56
+ payload: AppSettingsState
23
57
  warning: string | null
24
58
  shouldWrite: boolean
25
59
  }
26
60
 
61
+ const DEFAULT_TERMINAL_SCROLLBACK = 1_000
62
+ const MIN_TERMINAL_SCROLLBACK = 500
63
+ const MAX_TERMINAL_SCROLLBACK = 5_000
64
+ const DEFAULT_TERMINAL_MIN_COLUMN_WIDTH = 450
65
+ const MIN_TERMINAL_MIN_COLUMN_WIDTH = 250
66
+ const MAX_TERMINAL_MIN_COLUMN_WIDTH = 900
67
+ const DEFAULT_EDITOR_PRESET: EditorPreset = "cursor"
68
+ const DEFAULT_CHAT_SOUND_PREFERENCE: ChatSoundPreference = "always"
69
+ const DEFAULT_CHAT_SOUND_ID: ChatSoundId = "funk"
70
+
27
71
  function formatDisplayPath(filePath: string) {
28
72
  const homePath = homedir()
29
73
  if (filePath === homePath) return "~"
@@ -37,6 +81,168 @@ function createAnalyticsUserId() {
37
81
  return `anon_${randomUUID()}`
38
82
  }
39
83
 
84
+ function getDefaultEditorCommandTemplate(preset: EditorPreset) {
85
+ switch (preset) {
86
+ case "vscode":
87
+ return "code {path}"
88
+ case "xcode":
89
+ return "xed {path}"
90
+ case "windsurf":
91
+ return "windsurf {path}"
92
+ case "custom":
93
+ case "cursor":
94
+ default:
95
+ return "cursor {path}"
96
+ }
97
+ }
98
+
99
+ function createDefaultProviderDefaults(): ChatProviderPreferences {
100
+ return {
101
+ claude: {
102
+ model: "claude-opus-4-7",
103
+ modelOptions: { ...DEFAULT_CLAUDE_MODEL_OPTIONS },
104
+ planMode: false,
105
+ },
106
+ codex: {
107
+ model: "gpt-5.5",
108
+ modelOptions: { ...DEFAULT_CODEX_MODEL_OPTIONS },
109
+ planMode: false,
110
+ },
111
+ }
112
+ }
113
+
114
+ function clampNumber(value: unknown, fallback: number, min: number, max: number) {
115
+ const numberValue = typeof value === "number" ? value : Number(value)
116
+ if (!Number.isFinite(numberValue)) return fallback
117
+ return Math.min(max, Math.max(min, Math.round(numberValue)))
118
+ }
119
+
120
+ function normalizeTheme(value: unknown): AppThemePreference {
121
+ return value === "light" || value === "dark" || value === "system" ? value : "system"
122
+ }
123
+
124
+ function normalizeChatSoundPreference(value: unknown): ChatSoundPreference {
125
+ return value === "never" || value === "unfocused" || value === "always" ? value : DEFAULT_CHAT_SOUND_PREFERENCE
126
+ }
127
+
128
+ function normalizeChatSoundId(value: unknown): ChatSoundId {
129
+ switch (value) {
130
+ case "blow":
131
+ case "bottle":
132
+ case "frog":
133
+ case "funk":
134
+ case "glass":
135
+ case "ping":
136
+ case "pop":
137
+ case "purr":
138
+ case "tink":
139
+ return value
140
+ default:
141
+ return DEFAULT_CHAT_SOUND_ID
142
+ }
143
+ }
144
+
145
+ function normalizeDefaultProvider(value: unknown): DefaultProviderPreference {
146
+ return value === "claude" || value === "codex" || value === "last_used" ? value : "last_used"
147
+ }
148
+
149
+ function normalizeEditorPreset(value: unknown): EditorPreset {
150
+ return value === "vscode" || value === "xcode" || value === "windsurf" || value === "custom" || value === "cursor"
151
+ ? value
152
+ : DEFAULT_EDITOR_PRESET
153
+ }
154
+
155
+ function normalizeEditorCommandTemplate(value: unknown, preset: EditorPreset) {
156
+ const trimmed = typeof value === "string" ? value.trim() : ""
157
+ return trimmed || getDefaultEditorCommandTemplate(preset)
158
+ }
159
+
160
+ function normalizeClaudePreference(value?: {
161
+ model?: unknown
162
+ effort?: unknown
163
+ modelOptions?: Partial<Record<keyof ClaudeModelOptions, unknown>>
164
+ planMode?: unknown
165
+ }): ProviderPreference<ClaudeModelOptions> {
166
+ const model = normalizeClaudeModelId(typeof value?.model === "string" ? value.model : undefined)
167
+ const reasoningEffort = value?.modelOptions?.reasoningEffort
168
+ const normalizedEffort = isClaudeReasoningEffort(reasoningEffort)
169
+ ? reasoningEffort
170
+ : isClaudeReasoningEffort(value?.effort)
171
+ ? value.effort
172
+ : DEFAULT_CLAUDE_MODEL_OPTIONS.reasoningEffort
173
+
174
+ return {
175
+ model,
176
+ modelOptions: {
177
+ reasoningEffort: !supportsClaudeMaxReasoningEffort(model) && normalizedEffort === "max" ? "high" : normalizedEffort,
178
+ contextWindow: normalizeClaudeContextWindow(model, value?.modelOptions?.contextWindow),
179
+ },
180
+ planMode: value?.planMode === true,
181
+ }
182
+ }
183
+
184
+ function normalizeCodexPreference(value?: {
185
+ model?: unknown
186
+ effort?: unknown
187
+ modelOptions?: Partial<Record<keyof CodexModelOptions, unknown>>
188
+ planMode?: unknown
189
+ }): ProviderPreference<CodexModelOptions> {
190
+ const reasoningEffort = value?.modelOptions?.reasoningEffort
191
+ return {
192
+ model: normalizeCodexModelId(typeof value?.model === "string" ? value.model : undefined),
193
+ modelOptions: {
194
+ reasoningEffort: isCodexReasoningEffort(reasoningEffort)
195
+ ? reasoningEffort
196
+ : isCodexReasoningEffort(value?.effort)
197
+ ? value.effort
198
+ : DEFAULT_CODEX_MODEL_OPTIONS.reasoningEffort,
199
+ fastMode: typeof value?.modelOptions?.fastMode === "boolean"
200
+ ? value.modelOptions.fastMode
201
+ : DEFAULT_CODEX_MODEL_OPTIONS.fastMode,
202
+ },
203
+ planMode: value?.planMode === true,
204
+ }
205
+ }
206
+
207
+ function normalizeProviderDefaults(value: AppSettingsFile["providerDefaults"] | undefined): ChatProviderPreferences {
208
+ const defaults = createDefaultProviderDefaults()
209
+ return {
210
+ claude: normalizeClaudePreference(value?.claude ?? defaults.claude),
211
+ codex: normalizeCodexPreference(value?.codex ?? defaults.codex),
212
+ }
213
+ }
214
+
215
+ function toFilePayload(state: AppSettingsState) {
216
+ return {
217
+ analyticsEnabled: state.analyticsEnabled,
218
+ analyticsUserId: state.analyticsUserId,
219
+ browserSettingsMigrated: state.browserSettingsMigrated,
220
+ theme: state.theme,
221
+ chatSoundPreference: state.chatSoundPreference,
222
+ chatSoundId: state.chatSoundId,
223
+ terminal: state.terminal,
224
+ editor: state.editor,
225
+ defaultProvider: state.defaultProvider,
226
+ providerDefaults: state.providerDefaults,
227
+ }
228
+ }
229
+
230
+ function toSnapshot(state: AppSettingsState): AppSettingsSnapshot {
231
+ return {
232
+ analyticsEnabled: state.analyticsEnabled,
233
+ browserSettingsMigrated: state.browserSettingsMigrated,
234
+ theme: state.theme,
235
+ chatSoundPreference: state.chatSoundPreference,
236
+ chatSoundId: state.chatSoundId,
237
+ terminal: state.terminal,
238
+ editor: state.editor,
239
+ defaultProvider: state.defaultProvider,
240
+ providerDefaults: state.providerDefaults,
241
+ warning: state.warning,
242
+ filePathDisplay: state.filePathDisplay,
243
+ }
244
+ }
245
+
40
246
  function normalizeAppSettings(
41
247
  value: unknown,
42
248
  filePath = getSettingsFilePath(homedir())
@@ -50,80 +256,122 @@ function normalizeAppSettings(
50
256
  warnings.push("Settings file must contain a JSON object")
51
257
  }
52
258
 
53
- const analyticsEnabled = typeof source?.analyticsEnabled === "boolean"
54
- ? source.analyticsEnabled
55
- : true
259
+ const analyticsEnabled = typeof source?.analyticsEnabled === "boolean" ? source.analyticsEnabled : true
56
260
  if (source?.analyticsEnabled !== undefined && typeof source.analyticsEnabled !== "boolean") {
57
261
  warnings.push("analyticsEnabled must be a boolean")
58
262
  }
59
263
 
60
- const rawAnalyticsUserId = typeof source?.analyticsUserId === "string"
61
- ? source.analyticsUserId.trim()
62
- : ""
264
+ const rawAnalyticsUserId = typeof source?.analyticsUserId === "string" ? source.analyticsUserId.trim() : ""
63
265
  if (source?.analyticsUserId !== undefined && typeof source.analyticsUserId !== "string") {
64
266
  warnings.push("analyticsUserId must be a string")
65
267
  }
66
-
67
268
  const analyticsUserId = rawAnalyticsUserId || createAnalyticsUserId()
68
269
  if (!rawAnalyticsUserId && source?.analyticsUserId !== undefined) {
69
270
  warnings.push("analyticsUserId must be a non-empty string")
70
271
  }
71
272
 
72
- const shouldWrite = !source
73
- || source.analyticsEnabled !== analyticsEnabled
74
- || rawAnalyticsUserId !== analyticsUserId
273
+ const editorPreset = normalizeEditorPreset(source?.editor?.preset)
274
+ const state: AppSettingsState = {
275
+ analyticsEnabled,
276
+ analyticsUserId,
277
+ browserSettingsMigrated: source?.browserSettingsMigrated === true,
278
+ theme: normalizeTheme(source?.theme),
279
+ chatSoundPreference: normalizeChatSoundPreference(source?.chatSoundPreference),
280
+ chatSoundId: normalizeChatSoundId(source?.chatSoundId),
281
+ terminal: {
282
+ scrollbackLines: clampNumber(source?.terminal?.scrollbackLines, DEFAULT_TERMINAL_SCROLLBACK, MIN_TERMINAL_SCROLLBACK, MAX_TERMINAL_SCROLLBACK),
283
+ minColumnWidth: clampNumber(source?.terminal?.minColumnWidth, DEFAULT_TERMINAL_MIN_COLUMN_WIDTH, MIN_TERMINAL_MIN_COLUMN_WIDTH, MAX_TERMINAL_MIN_COLUMN_WIDTH),
284
+ },
285
+ editor: {
286
+ preset: editorPreset,
287
+ commandTemplate: normalizeEditorCommandTemplate(source?.editor?.commandTemplate, editorPreset),
288
+ },
289
+ defaultProvider: normalizeDefaultProvider(source?.defaultProvider),
290
+ providerDefaults: normalizeProviderDefaults(source?.providerDefaults),
291
+ warning: null,
292
+ filePathDisplay: formatDisplayPath(filePath),
293
+ }
294
+
295
+ const shouldWrite = JSON.stringify(source ? toComparablePayload(source) : null) !== JSON.stringify(toFilePayload(state))
296
+ state.warning = warnings.length > 0
297
+ ? `Some settings were reset to defaults: ${warnings.join("; ")}`
298
+ : null
75
299
 
76
300
  return {
77
- payload: {
78
- analyticsEnabled,
79
- analyticsUserId,
80
- },
81
- warning: warnings.length > 0
82
- ? `Some settings were reset to defaults: ${warnings.join("; ")}`
83
- : null,
301
+ payload: state,
302
+ warning: state.warning,
84
303
  shouldWrite,
85
304
  }
86
305
  }
87
306
 
88
- function toSnapshot(state: AppSettingsState): AppSettingsSnapshot {
307
+ function toComparablePayload(source: AppSettingsFile) {
89
308
  return {
90
- analyticsEnabled: state.analyticsEnabled,
91
- warning: state.warning,
92
- filePathDisplay: state.filePathDisplay,
309
+ analyticsEnabled: source.analyticsEnabled,
310
+ analyticsUserId: typeof source.analyticsUserId === "string" ? source.analyticsUserId.trim() : source.analyticsUserId,
311
+ browserSettingsMigrated: source.browserSettingsMigrated,
312
+ theme: source.theme,
313
+ chatSoundPreference: source.chatSoundPreference,
314
+ chatSoundId: source.chatSoundId,
315
+ terminal: source.terminal,
316
+ editor: source.editor,
317
+ defaultProvider: source.defaultProvider,
318
+ providerDefaults: source.providerDefaults,
93
319
  }
94
320
  }
95
321
 
322
+ function applyPatch(state: AppSettingsState, patch: AppSettingsPatch): AppSettingsState {
323
+ return normalizeAppSettings({
324
+ ...toFilePayload(state),
325
+ ...patch,
326
+ terminal: {
327
+ ...state.terminal,
328
+ ...patch.terminal,
329
+ },
330
+ editor: {
331
+ ...state.editor,
332
+ ...patch.editor,
333
+ },
334
+ providerDefaults: {
335
+ claude: {
336
+ ...state.providerDefaults.claude,
337
+ ...patch.providerDefaults?.claude,
338
+ modelOptions: {
339
+ ...state.providerDefaults.claude.modelOptions,
340
+ ...patch.providerDefaults?.claude?.modelOptions,
341
+ },
342
+ },
343
+ codex: {
344
+ ...state.providerDefaults.codex,
345
+ ...patch.providerDefaults?.codex,
346
+ modelOptions: {
347
+ ...state.providerDefaults.codex.modelOptions,
348
+ ...patch.providerDefaults?.codex?.modelOptions,
349
+ },
350
+ },
351
+ },
352
+ }, state.filePathDisplay).payload
353
+ }
354
+
96
355
  export async function readAppSettingsSnapshot(filePath = getSettingsFilePath(homedir())) {
97
356
  try {
98
357
  const text = await readFile(filePath, "utf8")
99
358
  if (!text.trim()) {
100
359
  const normalized = normalizeAppSettings(undefined, filePath)
101
360
  return {
102
- analyticsEnabled: normalized.payload.analyticsEnabled,
361
+ ...toSnapshot(normalized.payload),
103
362
  warning: "Settings file was empty. Using defaults.",
104
- filePathDisplay: formatDisplayPath(filePath),
105
363
  } satisfies AppSettingsSnapshot
106
364
  }
107
365
 
108
- const normalized = normalizeAppSettings(JSON.parse(text), filePath)
109
- return {
110
- analyticsEnabled: normalized.payload.analyticsEnabled,
111
- warning: normalized.warning,
112
- filePathDisplay: formatDisplayPath(filePath),
113
- } satisfies AppSettingsSnapshot
366
+ return toSnapshot(normalizeAppSettings(JSON.parse(text), filePath).payload)
114
367
  } catch (error) {
115
368
  if ((error as NodeJS.ErrnoException)?.code === "ENOENT") {
116
- return {
117
- analyticsEnabled: true,
118
- warning: null,
119
- filePathDisplay: formatDisplayPath(filePath),
120
- } satisfies AppSettingsSnapshot
369
+ return toSnapshot(normalizeAppSettings(undefined, filePath).payload)
121
370
  }
122
371
  if (error instanceof SyntaxError) {
123
372
  return {
124
- analyticsEnabled: true,
373
+ ...toSnapshot(normalizeAppSettings(undefined, filePath).payload),
125
374
  warning: "Settings file is invalid JSON. Using defaults.",
126
- filePathDisplay: formatDisplayPath(filePath),
127
375
  } satisfies AppSettingsSnapshot
128
376
  }
129
377
  throw error
@@ -138,13 +386,7 @@ export class AppSettingsManager {
138
386
 
139
387
  constructor(filePath = getSettingsFilePath(homedir())) {
140
388
  this.filePath = filePath
141
- const displayPath = formatDisplayPath(this.filePath)
142
- this.state = {
143
- analyticsEnabled: true,
144
- analyticsUserId: createAnalyticsUserId(),
145
- warning: null,
146
- filePathDisplay: displayPath,
147
- }
389
+ this.state = normalizeAppSettings(undefined, filePath).payload
148
390
  }
149
391
 
150
392
  async initialize() {
@@ -180,58 +422,47 @@ export class AppSettingsManager {
180
422
  }
181
423
 
182
424
  async write(value: { analyticsEnabled: boolean }) {
183
- const payload = {
184
- analyticsEnabled: value.analyticsEnabled,
185
- analyticsUserId: this.state.analyticsUserId || createAnalyticsUserId(),
186
- }
187
- await mkdir(path.dirname(this.filePath), { recursive: true })
188
- await writeFile(this.filePath, `${JSON.stringify(payload, null, 2)}\n`, "utf8")
189
- const nextState: AppSettingsState = {
190
- analyticsEnabled: payload.analyticsEnabled,
191
- analyticsUserId: payload.analyticsUserId,
425
+ return this.writePatch({ analyticsEnabled: value.analyticsEnabled })
426
+ }
427
+
428
+ async writePatch(patch: AppSettingsPatch) {
429
+ const nextState = {
430
+ ...applyPatch(this.state, patch),
192
431
  warning: null,
193
432
  filePathDisplay: formatDisplayPath(this.filePath),
194
433
  }
434
+ await mkdir(path.dirname(this.filePath), { recursive: true })
435
+ await writeFile(this.filePath, `${JSON.stringify(toFilePayload(nextState), null, 2)}\n`, "utf8")
195
436
  this.setState(nextState)
196
437
  return toSnapshot(nextState)
197
438
  }
198
439
 
199
440
  private async readState(options?: { persistNormalized?: boolean }) {
200
441
  const file = Bun.file(this.filePath)
201
- const displayPath = formatDisplayPath(this.filePath)
202
442
 
203
443
  try {
204
444
  const text = await file.text()
205
445
  const hasText = text.trim().length > 0
206
446
  const normalized = normalizeAppSettings(hasText ? JSON.parse(text) : undefined, this.filePath)
207
447
  if (options?.persistNormalized && (!hasText || normalized.shouldWrite)) {
208
- await writeFile(this.filePath, `${JSON.stringify(normalized.payload, null, 2)}\n`, "utf8")
448
+ await writeFile(this.filePath, `${JSON.stringify(toFilePayload(normalized.payload), null, 2)}\n`, "utf8")
209
449
  }
210
450
  return {
211
- analyticsEnabled: normalized.payload.analyticsEnabled,
212
- analyticsUserId: normalized.payload.analyticsUserId,
213
- warning: !hasText
214
- ? "Settings file was empty. Using defaults."
215
- : normalized.warning,
216
- filePathDisplay: displayPath,
451
+ ...normalized.payload,
452
+ warning: !hasText ? "Settings file was empty. Using defaults." : normalized.warning,
217
453
  } satisfies AppSettingsState
218
454
  } catch (error) {
219
455
  if ((error as NodeJS.ErrnoException)?.code !== "ENOENT" && !(error instanceof SyntaxError)) {
220
456
  throw error
221
457
  }
222
458
 
223
- const warning = error instanceof SyntaxError
224
- ? "Settings file is invalid JSON. Using defaults."
225
- : null
226
459
  const normalized = normalizeAppSettings(undefined, this.filePath)
227
460
  if (options?.persistNormalized) {
228
- await writeFile(this.filePath, `${JSON.stringify(normalized.payload, null, 2)}\n`, "utf8")
461
+ await writeFile(this.filePath, `${JSON.stringify(toFilePayload(normalized.payload), null, 2)}\n`, "utf8")
229
462
  }
230
463
  return {
231
- analyticsEnabled: normalized.payload.analyticsEnabled,
232
- analyticsUserId: normalized.payload.analyticsUserId,
233
- warning,
234
- filePathDisplay: displayPath,
464
+ ...normalized.payload,
465
+ warning: error instanceof SyntaxError ? "Settings file is invalid JSON. Using defaults." : null,
235
466
  } satisfies AppSettingsState
236
467
  }
237
468
  }
@@ -96,6 +96,10 @@ describe("writeStandaloneTranscriptExport", () => {
96
96
  now: new Date("2026-04-23T12:34:56.000Z"),
97
97
  })
98
98
 
99
+ expect(result.ok).toBe(true)
100
+ if (!result.ok) {
101
+ throw new Error(result.error)
102
+ }
99
103
  expect(await Bun.file(result.indexHtmlPath).exists()).toBe(true)
100
104
  expect(await Bun.file(path.join(result.outputDir, "assets", "viewer.js")).exists()).toBe(true)
101
105
  expect(result.totalAttachmentCount).toBe(1)
@@ -153,6 +157,10 @@ describe("writeStandaloneTranscriptExport", () => {
153
157
  now: new Date("2026-04-23T12:34:56.000Z"),
154
158
  })
155
159
 
160
+ expect(result.ok).toBe(true)
161
+ if (!result.ok) {
162
+ throw new Error(result.error)
163
+ }
156
164
  expect(result.totalAttachmentCount).toBe(1)
157
165
  expect(result.bundledAttachmentCount).toBe(1)
158
166
  expect(result.shareUrl).toBe("https://share.example.com/release-review-bundle123")
@@ -170,4 +178,47 @@ describe("writeStandaloneTranscriptExport", () => {
170
178
  expect.stringContaining("https://upload.example.com/api/share/release-review-bundle123/attachments/"),
171
179
  ])
172
180
  })
181
+
182
+ test("returns transcript json for download when share upload fails", async () => {
183
+ const viewerDistDir = await createViewerDist()
184
+ const projectDir = await createTempDir("kanna-project-")
185
+ const uploadsDir = path.join(projectDir, ".kanna", "uploads")
186
+ await mkdir(uploadsDir, { recursive: true })
187
+ const attachmentPath = path.join(uploadsDir, "mock.png")
188
+ await writeFile(attachmentPath, "mock", "utf8")
189
+
190
+ const result = await writeStandaloneTranscriptExport({
191
+ chatId: "chat-1",
192
+ title: "Release Review",
193
+ localPath: projectDir,
194
+ theme: "light",
195
+ attachmentMode: "bundle",
196
+ messages: createMessages(attachmentPath),
197
+ }, {
198
+ fetch: async () => new Response(JSON.stringify({ error: "No release viewer assets were found for 0.34.5." }), {
199
+ status: 400,
200
+ headers: {
201
+ "content-type": "application/json",
202
+ },
203
+ }),
204
+ sharePublicBaseUrl: "https://share.example.com",
205
+ shareSlugSuffix: "failed123",
206
+ shareUploadBaseUrl: "https://upload.example.com/api/share",
207
+ viewerDistDir,
208
+ now: new Date("2026-04-23T12:34:56.000Z"),
209
+ })
210
+
211
+ expect(result.ok).toBe(false)
212
+ if (result.ok) {
213
+ throw new Error("Expected export upload to fail")
214
+ }
215
+
216
+ expect(result.error).toContain("Failed to upload shared transcript file transcript.json")
217
+ expect(result.error).toContain("No release viewer assets were found")
218
+ expect(result.shareUrl).toBe("https://share.example.com/release-review-failed123")
219
+ expect(result.transcriptFileName).toBe("Release-Review-2026-04-23T12-34-56Z-transcript.json")
220
+ expect(result.transcriptJsonPath).toEndWith("/transcript.json")
221
+ expect(JSON.parse(result.transcriptJson).title).toBe("Release Review")
222
+ expect(JSON.stringify(JSON.parse(result.transcriptJson))).not.toContain(projectDir)
223
+ })
173
224
  })