kanna-code 0.26.4 → 0.26.6

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,7 +5,7 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <link rel="icon" type="image/png" href="/favicon.png" />
7
7
  <title>Kanna</title>
8
- <script type="module" crossorigin src="/assets/index-DDI3pFzk.js"></script>
8
+ <script type="module" crossorigin src="/assets/index-CcdP0kaK.js"></script>
9
9
  <link rel="stylesheet" crossorigin href="/assets/index-C5RBxQW4.css">
10
10
  </head>
11
11
  <body>
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "kanna-code",
3
3
  "type": "module",
4
- "version": "0.26.4",
4
+ "version": "0.26.6",
5
5
  "description": "A beautiful web UI for Claude Code",
6
6
  "license": "MIT",
7
7
  "keywords": [
@@ -148,6 +148,52 @@ describe("DiffStore", () => {
148
148
  expect((await run(["git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], repoRoot)).trim()).toBe("origin/feature/publish-me")
149
149
  })
150
150
 
151
+ test("commit_and_push degrades to a local commit when origin is missing", async () => {
152
+ const repoRoot = await createRepo()
153
+ tempDirs.push(repoRoot)
154
+ await writeFile(path.join(repoRoot, "app.txt"), "base\n", "utf8")
155
+ await run(["git", "add", "."], repoRoot)
156
+ await run(["git", "commit", "-m", "init"], repoRoot)
157
+ await writeFile(path.join(repoRoot, "app.txt"), "changed\n", "utf8")
158
+
159
+ const store = new DiffStore(repoRoot)
160
+ await store.initialize()
161
+ await store.refreshSnapshot("project-1", repoRoot)
162
+
163
+ const result = await store.commitFiles({
164
+ projectId: "project-1",
165
+ projectPath: repoRoot,
166
+ paths: ["app.txt"],
167
+ summary: "Local only",
168
+ mode: "commit_and_push",
169
+ })
170
+
171
+ expect(result).toMatchObject({
172
+ ok: true,
173
+ mode: "commit_and_push",
174
+ pushed: false,
175
+ })
176
+ expect((await run(["git", "log", "-1", "--pretty=%s"], repoRoot)).trim()).toBe("Local only")
177
+ })
178
+
179
+ test("refreshSnapshot reports origin presence before the first commit", async () => {
180
+ const repoRoot = await createRepo()
181
+ tempDirs.push(repoRoot)
182
+ await run(["git", "remote", "add", "origin", "https://github.com/jakemor/test224.git"], repoRoot)
183
+ await writeFile(path.join(repoRoot, "poem.md"), "rose\n", "utf8")
184
+
185
+ const store = new DiffStore(repoRoot)
186
+ await store.initialize()
187
+ await store.refreshSnapshot("project-1", repoRoot)
188
+
189
+ expect(store.getProjectSnapshot("project-1")).toMatchObject({
190
+ status: "ready",
191
+ branchName: "main",
192
+ hasOriginRemote: true,
193
+ originRepoSlug: "jakemor/test224",
194
+ })
195
+ })
196
+
151
197
  test("detects renamed files", async () => {
152
198
  const repoRoot = await createRepo()
153
199
  tempDirs.push(repoRoot)
@@ -37,6 +37,7 @@ function createEmptyState(): StoredChatDiffState {
37
37
  status: "unknown",
38
38
  branchName: undefined,
39
39
  defaultBranchName: undefined,
40
+ hasOriginRemote: undefined,
40
41
  originRepoSlug: undefined,
41
42
  hasUpstream: undefined,
42
43
  aheadCount: undefined,
@@ -50,6 +51,7 @@ function createEmptyState(): StoredChatDiffState {
50
51
  function branchMetadataEqual(left: BranchMetadata, right: BranchMetadata) {
51
52
  return left.branchName === right.branchName
52
53
  && left.defaultBranchName === right.defaultBranchName
54
+ && left.hasOriginRemote === right.hasOriginRemote
53
55
  && left.originRepoSlug === right.originRepoSlug
54
56
  && left.hasUpstream === right.hasUpstream
55
57
  }
@@ -196,6 +198,9 @@ function createPushFailure(mode: DiffCommitMode, detail: string, snapshotChanged
196
198
  if (normalized.includes("non-fast-forward") || normalized.includes("fetch first")) {
197
199
  title = "Branch is not up to date"
198
200
  message = "Your branch is behind its remote. Pull or rebase, then try pushing again."
201
+ } else if (normalized.includes("does not appear to be a git repository")) {
202
+ title = "No origin remote configured"
203
+ message = "This repository does not have an origin remote configured."
199
204
  } else if (normalized.includes("has no upstream branch") || normalized.includes("set-upstream")) {
200
205
  title = "No upstream branch configured"
201
206
  message = "This branch does not have an upstream remote branch configured yet."
@@ -1317,6 +1322,7 @@ export class DiffStore {
1317
1322
  status: state.status,
1318
1323
  branchName: state.branchName,
1319
1324
  defaultBranchName: state.defaultBranchName,
1325
+ hasOriginRemote: state.hasOriginRemote,
1320
1326
  originRepoSlug: state.originRepoSlug,
1321
1327
  hasUpstream: state.hasUpstream,
1322
1328
  aheadCount: state.aheadCount,
@@ -1339,6 +1345,7 @@ export class DiffStore {
1339
1345
  status: "no_repo",
1340
1346
  branchName: undefined,
1341
1347
  defaultBranchName: undefined,
1348
+ hasOriginRemote: undefined,
1342
1349
  originRepoSlug: undefined,
1343
1350
  hasUpstream: undefined,
1344
1351
  aheadCount: undefined,
@@ -1356,6 +1363,7 @@ export class DiffStore {
1356
1363
  const branchName = await getBranchName(repo.repoRoot)
1357
1364
  const defaultBranchName = await resolveDefaultBranchName(repo.repoRoot)
1358
1365
  const originRemoteUrl = await getOriginRemoteUrl(repo.repoRoot)
1366
+ const hasOriginRemote = originRemoteUrl !== null
1359
1367
  const originRepoSlug = extractGitHubRepoSlug(originRemoteUrl) ?? undefined
1360
1368
  const hasUpstream = await hasUpstreamBranch(repo.repoRoot)
1361
1369
  const { aheadCount, behindCount } = hasUpstream
@@ -1373,6 +1381,7 @@ export class DiffStore {
1373
1381
  status: "ready",
1374
1382
  branchName,
1375
1383
  defaultBranchName,
1384
+ hasOriginRemote,
1376
1385
  originRepoSlug,
1377
1386
  hasUpstream,
1378
1387
  aheadCount,
@@ -1808,7 +1817,11 @@ export class DiffStore {
1808
1817
  throw new Error("Project is not in a git repository")
1809
1818
  }
1810
1819
 
1811
- const hasUpstream = await hasUpstreamBranch(repo.repoRoot)
1820
+ const [hasUpstream, originRemoteUrl] = await Promise.all([
1821
+ hasUpstreamBranch(repo.repoRoot),
1822
+ getOriginRemoteUrl(repo.repoRoot),
1823
+ ])
1824
+ const hasOriginRemote = originRemoteUrl !== null
1812
1825
  if (args.action === "publish") {
1813
1826
  const publishResult = await runGit(["push", "-u", "origin", "HEAD"], repo.repoRoot)
1814
1827
  if (publishResult.exitCode !== 0) {
@@ -2002,7 +2015,11 @@ export class DiffStore {
2002
2015
  if (!repo) {
2003
2016
  throw new Error("Project is not in a git repository")
2004
2017
  }
2005
- const hasUpstream = await hasUpstreamBranch(repo.repoRoot)
2018
+ const [hasUpstream, originRemoteUrl] = await Promise.all([
2019
+ hasUpstreamBranch(repo.repoRoot),
2020
+ getOriginRemoteUrl(repo.repoRoot),
2021
+ ])
2022
+ const hasOriginRemote = originRemoteUrl !== null
2006
2023
 
2007
2024
  const currentDirtyPaths = new Set((await listDirtyPaths(repo.repoRoot)).map((entry) => entry.path))
2008
2025
  const missingPaths = normalizedPaths.filter((relativePath) => !currentDirtyPaths.has(relativePath))
@@ -2039,6 +2056,16 @@ export class DiffStore {
2039
2056
  } satisfies DiffCommitResult
2040
2057
  }
2041
2058
 
2059
+ if (!hasUpstream && !hasOriginRemote) {
2060
+ return {
2061
+ ok: true,
2062
+ mode: args.mode,
2063
+ branchName,
2064
+ pushed: false,
2065
+ snapshotChanged,
2066
+ } satisfies DiffCommitResult
2067
+ }
2068
+
2042
2069
  const pushResult = hasUpstream
2043
2070
  ? await runGit(["push"], repo.repoRoot)
2044
2071
  : await runGit(["push", "-u", "origin", "HEAD"], repo.repoRoot)
@@ -191,6 +191,86 @@ describe("EventStore", () => {
191
191
  expect(reloaded.getChat(chat.id)?.unread).toBe(true)
192
192
  })
193
193
 
194
+ test("preserves read state after a finished turn across restart", async () => {
195
+ const dataDir = await createTempDataDir()
196
+ const store = new EventStore(dataDir)
197
+ await store.initialize()
198
+
199
+ const project = await store.openProject("/tmp/project")
200
+ const chat = await store.createChat(project.id)
201
+
202
+ await store.recordTurnFinished(chat.id)
203
+ await store.setChatReadState(chat.id, false)
204
+
205
+ expect(store.getChat(chat.id)?.unread).toBe(false)
206
+
207
+ const reloaded = new EventStore(dataDir)
208
+ await reloaded.initialize()
209
+
210
+ expect(reloaded.getChat(chat.id)?.unread).toBe(false)
211
+ })
212
+
213
+ test("preserves read state after a failed turn across restart", async () => {
214
+ const dataDir = await createTempDataDir()
215
+ const store = new EventStore(dataDir)
216
+ await store.initialize()
217
+
218
+ const project = await store.openProject("/tmp/project")
219
+ const chat = await store.createChat(project.id)
220
+
221
+ await store.recordTurnFailed(chat.id, "boom")
222
+ await store.setChatReadState(chat.id, false)
223
+
224
+ expect(store.getChat(chat.id)?.unread).toBe(false)
225
+
226
+ const reloaded = new EventStore(dataDir)
227
+ await reloaded.initialize()
228
+
229
+ expect(reloaded.getChat(chat.id)?.unread).toBe(false)
230
+ })
231
+
232
+ test("prefers mark-read over turn completion when replay timestamps tie", async () => {
233
+ const dataDir = await createTempDataDir()
234
+ const chatsLogPath = join(dataDir, "chats.jsonl")
235
+ const turnsLogPath = join(dataDir, "turns.jsonl")
236
+ const projectId = "project-1"
237
+ const chatId = "chat-1"
238
+ const timestamp = 100
239
+
240
+ await writeFile(chatsLogPath, [
241
+ JSON.stringify({
242
+ v: 2,
243
+ type: "chat_created",
244
+ timestamp,
245
+ chatId,
246
+ projectId,
247
+ title: "Chat",
248
+ }),
249
+ JSON.stringify({
250
+ v: 2,
251
+ type: "chat_read_state_set",
252
+ timestamp,
253
+ chatId,
254
+ unread: false,
255
+ }),
256
+ "",
257
+ ].join("\n"), "utf8")
258
+ await writeFile(turnsLogPath, [
259
+ JSON.stringify({
260
+ v: 2,
261
+ type: "turn_finished",
262
+ timestamp,
263
+ chatId,
264
+ }),
265
+ "",
266
+ ].join("\n"), "utf8")
267
+
268
+ const store = new EventStore(dataDir)
269
+ await store.initialize()
270
+
271
+ expect(store.getChat(chatId)?.unread).toBe(false)
272
+ })
273
+
194
274
  test("loads chats without unread from older snapshots as read", async () => {
195
275
  const dataDir = await createTempDataDir()
196
276
  const snapshotPath = join(dataDir, "snapshot.json")
@@ -226,14 +306,14 @@ describe("EventStore", () => {
226
306
  expect(store.getChat("chat-1")?.unread).toBe(false)
227
307
  })
228
308
 
229
- test("prunes stale empty chats after five minutes", async () => {
309
+ test("prunes stale empty chats after thirty minutes", async () => {
230
310
  const dataDir = await createTempDataDir()
231
311
  const store = new EventStore(dataDir)
232
312
  await store.initialize()
233
313
 
234
314
  const project = await store.openProject("/tmp/project")
235
315
  const chat = await store.createChat(project.id)
236
- const staleNow = chat.createdAt + 5 * 60 * 1000
316
+ const staleNow = chat.createdAt + 30 * 60 * 1000
237
317
 
238
318
  const pruned = await store.pruneStaleEmptyChats({ now: staleNow })
239
319
 
@@ -248,7 +328,7 @@ describe("EventStore", () => {
248
328
 
249
329
  const project = await store.openProject("/tmp/project")
250
330
  const chat = await store.createChat(project.id)
251
- const pruned = await store.pruneStaleEmptyChats({ now: chat.createdAt + 5 * 60 * 1000 - 1 })
331
+ const pruned = await store.pruneStaleEmptyChats({ now: chat.createdAt + 30 * 60 * 1000 - 1 })
252
332
 
253
333
  expect(pruned).toEqual([])
254
334
  expect(store.getChat(chat.id)?.id).toBe(chat.id)
@@ -263,7 +343,7 @@ describe("EventStore", () => {
263
343
  const chat = await store.createChat(project.id)
264
344
  await store.appendMessage(chat.id, entry("user_prompt", chat.createdAt + 1, { content: "hello" }))
265
345
 
266
- const pruned = await store.pruneStaleEmptyChats({ now: chat.createdAt + 5 * 60 * 1000 })
346
+ const pruned = await store.pruneStaleEmptyChats({ now: chat.createdAt + 30 * 60 * 1000 })
267
347
 
268
348
  expect(pruned).toEqual([])
269
349
  expect(store.getChat(chat.id)?.id).toBe(chat.id)
@@ -278,11 +358,28 @@ describe("EventStore", () => {
278
358
  const chat = await store.createChat(project.id)
279
359
 
280
360
  const pruned = await store.pruneStaleEmptyChats({
281
- now: chat.createdAt + 5 * 60 * 1000,
361
+ now: chat.createdAt + 30 * 60 * 1000,
282
362
  activeChatIds: [chat.id],
283
363
  })
284
364
 
285
365
  expect(pruned).toEqual([])
286
366
  expect(store.getChat(chat.id)?.id).toBe(chat.id)
287
367
  })
368
+
369
+ test("does not prune stale chats with protected draft state", async () => {
370
+ const dataDir = await createTempDataDir()
371
+ const store = new EventStore(dataDir)
372
+ await store.initialize()
373
+
374
+ const project = await store.openProject("/tmp/project")
375
+ const chat = await store.createChat(project.id)
376
+
377
+ const pruned = await store.pruneStaleEmptyChats({
378
+ now: chat.createdAt + 30 * 60 * 1000,
379
+ protectedChatIds: [chat.id],
380
+ })
381
+
382
+ expect(pruned).toEqual([])
383
+ expect(store.getChat(chat.id)?.id).toBe(chat.id)
384
+ })
288
385
  })
@@ -7,7 +7,6 @@ import type { AgentProvider, ChatHistoryPage, ChatHistorySnapshot, TranscriptEnt
7
7
  import { STORE_VERSION } from "../shared/types"
8
8
  import {
9
9
  type ChatEvent,
10
- type MessageEvent,
11
10
  type ProjectEvent,
12
11
  type SnapshotFile,
13
12
  type StoreEvent,
@@ -19,7 +18,7 @@ import {
19
18
  import { resolveLocalPath } from "./paths"
20
19
 
21
20
  const COMPACTION_THRESHOLD_BYTES = 2 * 1024 * 1024
22
- const STALE_EMPTY_CHAT_MAX_AGE_MS = 5 * 60 * 1000
21
+ const STALE_EMPTY_CHAT_MAX_AGE_MS = 30 * 60 * 1000
23
22
  interface LegacyTranscriptStats {
24
23
  hasLegacyData: boolean
25
24
  sources: Array<"snapshot" | "messages_log">
@@ -33,6 +32,41 @@ interface TranscriptPageResult {
33
32
  olderCursor: string | null
34
33
  }
35
34
 
35
+ interface ParsedReplayEvent {
36
+ event: StoreEvent
37
+ sourceIndex: number
38
+ lineIndex: number
39
+ }
40
+
41
+ function getReplayEventPriority(event: StoreEvent) {
42
+ switch (event.type) {
43
+ case "project_opened":
44
+ case "project_removed":
45
+ return 0
46
+ case "chat_created":
47
+ return 1
48
+ case "chat_renamed":
49
+ case "chat_provider_set":
50
+ case "chat_plan_mode_set":
51
+ return 2
52
+ case "message_appended":
53
+ return 3
54
+ case "turn_started":
55
+ return 4
56
+ case "session_token_set":
57
+ return 5
58
+ case "turn_cancelled":
59
+ return 6
60
+ case "turn_finished":
61
+ case "turn_failed":
62
+ return 7
63
+ case "chat_read_state_set":
64
+ return 8
65
+ case "chat_deleted":
66
+ return 9
67
+ }
68
+ }
69
+
36
70
  function encodeHistoryCursor(index: number) {
37
71
  return `idx:${index}`
38
72
  }
@@ -166,21 +200,33 @@ export class EventStore {
166
200
 
167
201
  private async replayLogs() {
168
202
  if (this.storageReset) return
169
- await this.replayLog<ProjectEvent>(this.projectsLogPath)
170
- if (this.storageReset) return
171
- await this.replayLog<ChatEvent>(this.chatsLogPath)
172
- if (this.storageReset) return
173
- await this.replayLog<MessageEvent>(this.messagesLogPath)
203
+ const replayEvents = [
204
+ ...await this.loadReplayEvents(this.projectsLogPath, 0),
205
+ ...await this.loadReplayEvents(this.chatsLogPath, 1),
206
+ ...await this.loadReplayEvents(this.messagesLogPath, 2),
207
+ ...await this.loadReplayEvents(this.turnsLogPath, 3),
208
+ ]
174
209
  if (this.storageReset) return
175
- await this.replayLog<TurnEvent>(this.turnsLogPath)
210
+
211
+ replayEvents
212
+ .sort((left, right) => (
213
+ left.event.timestamp - right.event.timestamp
214
+ || getReplayEventPriority(left.event) - getReplayEventPriority(right.event)
215
+ || left.sourceIndex - right.sourceIndex
216
+ || left.lineIndex - right.lineIndex
217
+ ))
218
+ .forEach(({ event }) => {
219
+ this.applyEvent(event)
220
+ })
176
221
  }
177
222
 
178
- private async replayLog<TEvent extends StoreEvent>(filePath: string) {
223
+ private async loadReplayEvents(filePath: string, sourceIndex: number): Promise<ParsedReplayEvent[]> {
179
224
  const file = Bun.file(filePath)
180
- if (!(await file.exists())) return
225
+ if (!(await file.exists())) return []
181
226
  const text = await file.text()
182
- if (!text.trim()) return
227
+ if (!text.trim()) return []
183
228
 
229
+ const parsedEvents: ParsedReplayEvent[] = []
184
230
  const lines = text.split("\n")
185
231
  let lastNonEmpty = -1
186
232
  for (let index = lines.length - 1; index >= 0; index -= 1) {
@@ -198,19 +244,25 @@ export class EventStore {
198
244
  if (event.v !== STORE_VERSION) {
199
245
  console.warn(`${LOG_PREFIX} Resetting local history from incompatible event log`)
200
246
  await this.clearStorage()
201
- return
247
+ return []
202
248
  }
203
- this.applyEvent(event as StoreEvent)
249
+ parsedEvents.push({
250
+ event: event as StoreEvent,
251
+ sourceIndex,
252
+ lineIndex: index,
253
+ })
204
254
  } catch (error) {
205
255
  if (index === lastNonEmpty) {
206
256
  console.warn(`${LOG_PREFIX} Ignoring corrupt trailing line in ${path.basename(filePath)}`)
207
- return
257
+ return parsedEvents
208
258
  }
209
259
  console.warn(`${LOG_PREFIX} Failed to replay ${path.basename(filePath)}, resetting local history:`, error)
210
260
  await this.clearStorage()
211
- return
261
+ return []
212
262
  }
213
263
  }
264
+
265
+ return parsedEvents
214
266
  }
215
267
 
216
268
  private applyEvent(event: StoreEvent) {
@@ -459,14 +511,18 @@ export class EventStore {
459
511
  now?: number
460
512
  maxAgeMs?: number
461
513
  activeChatIds?: Iterable<string>
514
+ protectedChatIds?: Iterable<string>
462
515
  }) {
463
516
  const now = args?.now ?? Date.now()
464
517
  const maxAgeMs = args?.maxAgeMs ?? STALE_EMPTY_CHAT_MAX_AGE_MS
465
- const activeChatIds = new Set(args?.activeChatIds ?? [])
518
+ const protectedChatIds = new Set([
519
+ ...(args?.activeChatIds ?? []),
520
+ ...(args?.protectedChatIds ?? []),
521
+ ])
466
522
  const prunedChatIds: string[] = []
467
523
 
468
524
  for (const chat of this.state.chatsById.values()) {
469
- if (chat.deletedAt || activeChatIds.has(chat.id)) continue
525
+ if (chat.deletedAt || protectedChatIds.has(chat.id)) continue
470
526
  if (now - chat.createdAt < maxAgeMs) continue
471
527
  if (this.getMessages(chat.id).length > 0) continue
472
528
 
@@ -8,6 +8,7 @@ class FakeWebSocket {
8
8
  readonly sent: unknown[] = []
9
9
  readonly data = {
10
10
  subscriptions: new Map(),
11
+ protectedDraftChatIds: new Set<string>(),
11
12
  }
12
13
 
13
14
  send(message: string) {
@@ -742,6 +743,85 @@ describe("ws-router", () => {
742
743
  })
743
744
  })
744
745
 
746
+ test("protects draft-bearing chats from stale pruning before sidebar snapshots", async () => {
747
+ const state = createEmptyState()
748
+ state.projectsById.set("project-1", {
749
+ id: "project-1",
750
+ localPath: "/tmp/project",
751
+ title: "Project",
752
+ createdAt: 1,
753
+ updatedAt: 1,
754
+ })
755
+ state.projectIdsByPath.set("/tmp/project", "project-1")
756
+ state.chatsById.set("chat-stale", {
757
+ id: "chat-stale",
758
+ projectId: "project-1",
759
+ title: "New Chat",
760
+ createdAt: 1,
761
+ updatedAt: 1,
762
+ unread: false,
763
+ provider: null,
764
+ planMode: false,
765
+ sessionToken: null,
766
+ lastTurnOutcome: null,
767
+ })
768
+
769
+ let capturedProtectedChatIds: string[] = []
770
+ const router = createWsRouter({
771
+ store: {
772
+ state,
773
+ async pruneStaleEmptyChats(args?: { protectedChatIds?: Iterable<string> }) {
774
+ capturedProtectedChatIds = [...(args?.protectedChatIds ?? [])]
775
+ return []
776
+ },
777
+ } as never,
778
+ agent: { getActiveStatuses: () => new Map(), getDrainingChatIds: () => new Set() } as never,
779
+ terminals: {
780
+ getSnapshot: () => null,
781
+ onEvent: () => () => {},
782
+ } as never,
783
+ keybindings: {
784
+ getSnapshot: () => DEFAULT_KEYBINDINGS_SNAPSHOT,
785
+ onChange: () => () => {},
786
+ } as never,
787
+ refreshDiscovery: async () => [],
788
+ getDiscoveredProjects: () => [],
789
+ machineDisplayName: "Local Machine",
790
+ updateManager: null,
791
+ })
792
+ const ws = new FakeWebSocket()
793
+
794
+ await router.handleMessage(
795
+ ws as never,
796
+ JSON.stringify({
797
+ v: 1,
798
+ type: "command",
799
+ id: "draft-protection-1",
800
+ command: {
801
+ type: "chat.setDraftProtection",
802
+ chatIds: ["chat-stale"],
803
+ },
804
+ })
805
+ )
806
+
807
+ await router.handleMessage(
808
+ ws as never,
809
+ JSON.stringify({
810
+ v: 1,
811
+ type: "subscribe",
812
+ id: "sidebar-sub-1",
813
+ topic: { type: "sidebar" },
814
+ })
815
+ )
816
+
817
+ expect(capturedProtectedChatIds).toEqual(["chat-stale"])
818
+ expect(ws.sent[0]).toEqual({
819
+ v: PROTOCOL_VERSION,
820
+ type: "ack",
821
+ id: "draft-protection-1",
822
+ })
823
+ })
824
+
745
825
  test("broadcasts background title-generation errors to connected clients", () => {
746
826
  let reportBackgroundError: ((message: string) => void) | null | undefined
747
827
  const router = createWsRouter({
@@ -18,6 +18,7 @@ const DEFAULT_CHAT_RECENT_LIMIT = 200
18
18
  export interface ClientState {
19
19
  subscriptions: Map<string, SubscriptionTopic>
20
20
  snapshotSignatures: Map<string, string>
21
+ protectedDraftChatIds?: Set<string>
21
22
  }
22
23
 
23
24
  interface CreateWsRouterArgs {
@@ -57,7 +58,7 @@ export function createWsRouter({
57
58
  }: CreateWsRouterArgs) {
58
59
  const sockets = new Set<ServerWebSocket<ClientState>>()
59
60
  const resolvedDiffStore = diffStore ?? {
60
- getProjectSnapshot: () => ({ status: "unknown", branchName: undefined, defaultBranchName: undefined, originRepoSlug: undefined, hasUpstream: undefined, aheadCount: undefined, behindCount: undefined, lastFetchedAt: undefined, files: [] as const, branchHistory: { entries: [] as const } }),
61
+ getProjectSnapshot: () => ({ status: "unknown", branchName: undefined, defaultBranchName: undefined, hasOriginRemote: undefined, originRepoSlug: undefined, hasUpstream: undefined, aheadCount: undefined, behindCount: undefined, lastFetchedAt: undefined, files: [] as const, branchHistory: { entries: [] as const } }),
61
62
  refreshSnapshot: async () => false,
62
63
  initializeGit: async () => ({ ok: true, branchName: undefined, snapshotChanged: false }),
63
64
  getGitHubPublishInfo: async () => ({ ghInstalled: false, authenticated: false, activeAccountLogin: undefined, owners: [], suggestedRepoName: "my-repo" }),
@@ -83,9 +84,28 @@ export function createWsRouter({
83
84
  ])
84
85
  }
85
86
 
86
- async function maybePruneStaleEmptyChats() {
87
+ function getProtectedDraftChatIds(extraSockets?: Iterable<ServerWebSocket<ClientState>>) {
88
+ const protectedChatIds = new Set<string>()
89
+
90
+ for (const socket of sockets) {
91
+ for (const chatId of socket.data.protectedDraftChatIds ?? []) {
92
+ protectedChatIds.add(chatId)
93
+ }
94
+ }
95
+
96
+ for (const socket of extraSockets ?? []) {
97
+ for (const chatId of socket.data.protectedDraftChatIds ?? []) {
98
+ protectedChatIds.add(chatId)
99
+ }
100
+ }
101
+
102
+ return protectedChatIds
103
+ }
104
+
105
+ async function maybePruneStaleEmptyChats(extraSockets?: Iterable<ServerWebSocket<ClientState>>) {
87
106
  await store.pruneStaleEmptyChats?.({
88
107
  activeChatIds: getProtectedChatIds(),
108
+ protectedChatIds: getProtectedDraftChatIds(extraSockets),
89
109
  })
90
110
  }
91
111
 
@@ -194,7 +214,7 @@ export function createWsRouter({
194
214
 
195
215
  async function pushSnapshots(ws: ServerWebSocket<ClientState>, options?: { skipPrune?: boolean }) {
196
216
  if (!options?.skipPrune) {
197
- await maybePruneStaleEmptyChats()
217
+ await maybePruneStaleEmptyChats([ws])
198
218
  }
199
219
  const snapshotSignatures = ensureSnapshotSignatures(ws)
200
220
  for (const [id, topic] of ws.data.subscriptions.entries()) {
@@ -410,6 +430,11 @@ export function createWsRouter({
410
430
  send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
411
431
  break
412
432
  }
433
+ case "chat.setDraftProtection": {
434
+ ws.data.protectedDraftChatIds = new Set(command.chatIds)
435
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
436
+ break
437
+ }
413
438
  case "chat.send": {
414
439
  const result = await agent.send(command)
415
440
  send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result })
@@ -67,6 +67,7 @@ export type ClientCommand =
67
67
  | { type: "chat.create"; projectId: string }
68
68
  | { type: "chat.rename"; chatId: string; title: string }
69
69
  | { type: "chat.delete"; chatId: string }
70
+ | { type: "chat.setDraftProtection"; chatIds: string[] }
70
71
  | { type: "chat.markRead"; chatId: string }
71
72
  | {
72
73
  type: "chat.send"
@@ -544,6 +544,7 @@ export interface GitHubRepoAvailabilityResult {
544
544
  export interface BranchMetadata {
545
545
  branchName?: string
546
546
  defaultBranchName?: string
547
+ hasOriginRemote?: boolean
547
548
  originRepoSlug?: string
548
549
  hasUpstream?: boolean
549
550
  }