@openchambery/web 1.18.4 → 1.18.6-beta.1

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.
Files changed (114) hide show
  1. package/dist/assets/{AgentAvatar-DAnelAnB.js → AgentAvatar-DDcjamcJ.js} +1 -1
  2. package/dist/assets/AssistantView-LPfaliRV.js +1 -0
  3. package/dist/assets/{ChatContainer-BVPeKR-K.js → ChatContainer-BYdx_DXz.js} +40 -40
  4. package/dist/assets/ContextPanel-BwwT_VfC.js +288 -0
  5. package/dist/assets/DiagramView-DvFv9-nP.js +1 -0
  6. package/dist/assets/{FadeInOnReveal-QxF1vuqX.js → FadeInOnReveal-By2F9vcI.js} +1 -1
  7. package/dist/assets/{Icon-D-X70irR.js → Icon-DpjYnXxn.js} +1 -1
  8. package/dist/assets/{MarkdownRendererImpl-TCIXUwWr.js → MarkdownRendererImpl-Cm08lbud.js} +2 -2
  9. package/dist/assets/{MobileDetailNavigation-BVwAAhoU.js → MobileDetailNavigation-BB2pDQj8.js} +1 -1
  10. package/dist/assets/{MobileShareBridge-2J_Tfuji.js → MobileShareBridge-DlWPsLUN.js} +3 -3
  11. package/dist/assets/MobileSurface-CN0sClQE.js +2 -0
  12. package/dist/assets/{MultiRunWindow-UffGnrG2.js → MultiRunWindow-DB8M7hTi.js} +1 -1
  13. package/dist/assets/{OnboardingScreen-Cx79VlTN.js → OnboardingScreen-BKmuT7AL.js} +1 -1
  14. package/dist/assets/{OverlayScrollbar-CHXk_jvF.js → OverlayScrollbar-Ch3J6ASR.js} +1 -1
  15. package/dist/assets/{SettingsView-Da1ATyc8.js → SettingsView-DGLS2Mg6.js} +11 -11
  16. package/dist/assets/{SettingsWindow-nVdVW9KM.js → SettingsWindow-C30XBMKE.js} +1 -1
  17. package/dist/assets/{TerminalView-BTY7HznA.js → TerminalView-4tdrrTVF.js} +1 -1
  18. package/dist/assets/{ToolOutputDialog-BJfWelnd.js → ToolOutputDialog-CkkvQ6I2.js} +1 -1
  19. package/dist/assets/appThemeRegistry-BNiZ1vQk.js +2 -0
  20. package/dist/assets/{button-CwLFtUNT.js → button-C7OXr4ae.js} +1 -1
  21. package/dist/assets/{clipboard-BfgFef1i.js → clipboard-DWuULPzo.js} +1 -1
  22. package/dist/assets/{collapsible-CP1-_pY6.js → collapsible-DaEya6b6.js} +1 -1
  23. package/dist/assets/{desktopHosts-Cr3LE55y.js → desktopHosts-Txp4xOVQ.js} +1 -1
  24. package/dist/assets/desktopRelayRestore-C_FSEHKM.js +1 -0
  25. package/dist/assets/{dialog-GR7KoP1o.js → dialog-DPS0W9eS.js} +1 -1
  26. package/dist/assets/{es-Bi4m8Mm6.js → es-BX0Vm3Ac.js} +1 -1
  27. package/dist/assets/fileQueries-B_fTkH6s.js +1 -0
  28. package/dist/assets/{fr-B5UdepyS.js → fr-CuPzXTYx.js} +1 -1
  29. package/dist/assets/{gitApi-BfOQScwC.js → gitApi-DfuIaByA.js} +3 -3
  30. package/dist/assets/{input-D52Jicb1.js → input-BzYA603C.js} +1 -1
  31. package/dist/assets/insertionBoundaries-C642g-Iw.js +1 -0
  32. package/dist/assets/{ja-KtFuRjvm.js → ja-DGtzjrFO.js} +1 -1
  33. package/dist/assets/{ko-CWpFVSCH.js → ko-DZ9Di0je.js} +1 -1
  34. package/dist/assets/main-C0AmH89S.js +29 -0
  35. package/dist/assets/{main-CTzdOaGn.js → main-M0K2vB13.js} +2 -2
  36. package/dist/assets/{markdown-worker-N9d_Z-7s.js → markdown-worker-W06-MhF5.js} +1 -1
  37. package/dist/assets/{markdownTheme-Bl0Sy0Lf.js → markdownTheme-B89dbj3N.js} +1 -1
  38. package/dist/assets/{miniChat-CAbceO-F.js → miniChat-B3KVwxjN.js} +2 -2
  39. package/dist/assets/mobile-rzYyWvdR.js +2 -0
  40. package/dist/assets/{multirun-C2J5Yp8V.js → multirun-qDXHE6q-.js} +1 -1
  41. package/dist/assets/{persistence-vZ7H8WKh.js → persistence-6sclr7B6.js} +2 -2
  42. package/dist/assets/{pl-30X8TD5i.js → pl-BuCO3qb1.js} +1 -1
  43. package/dist/assets/{platform-CTaznShj.js → platform-CbeLpKPk.js} +1 -1
  44. package/dist/assets/projectMeta-CyhrgevV.js +3 -0
  45. package/dist/assets/{pt-BR-Cm-Klf0E.js → pt-BR-Brf4RMsw.js} +1 -1
  46. package/dist/assets/radio-J9GiJ7x9.js +1 -0
  47. package/dist/assets/renderElectronMiniChatApp-DASrZbVL.js +2 -0
  48. package/dist/assets/renderMobileApp-OOhkEOlN.js +23 -0
  49. package/dist/assets/{runtimeConfig-CCrS3B1v.js → runtimeConfig-D8mh3gFs.js} +1 -1
  50. package/dist/assets/runtimeEndpointReset-BSJKTLkz.js +4 -0
  51. package/dist/assets/shikiHighlight-BcXbHYOl.js +1 -0
  52. package/dist/assets/{sortable-tabs-strip-BYmWSdS9.js → sortable-tabs-strip-nLsPC06f.js} +1 -1
  53. package/dist/assets/src-BDOLh3pc.css +2 -0
  54. package/dist/assets/{store-Z1MxUaP4.js → store-DQY8Fkb0.js} +2 -2
  55. package/dist/assets/{terminalApi-KDUc5mh2.js → terminalApi-BpPeXWIu.js} +1 -1
  56. package/dist/assets/{tooltip-CaCe-GAm.js → tooltip-D_orJMYd.js} +1 -1
  57. package/dist/assets/{uk-Cgc5wXrs.js → uk-C-wmaPTi.js} +1 -1
  58. package/dist/assets/{url-B-FWrnxu.js → url-BsaBG5-1.js} +1 -1
  59. package/dist/assets/useAppFontEffects-Bgx8zwRg.js +7 -0
  60. package/dist/assets/{useAssistantUIStore-ifqNyja4.js → useAssistantUIStore-BHsHXCKE.js} +1 -1
  61. package/dist/assets/{useDesktopWindowControlsLayout-C7JocspE.js → useDesktopWindowControlsLayout-D8rr0PdL.js} +1 -1
  62. package/dist/assets/{useEffectiveDirectory-DMWATHLS.js → useEffectiveDirectory-Dfo_EXBo.js} +1 -1
  63. package/dist/assets/{useFontPreferences-D_LFFhFS.js → useFontPreferences-ckzpXQaJ.js} +1 -1
  64. package/dist/assets/useMobileNavigationStore-hrmD9bA5.js +475 -0
  65. package/dist/assets/useSessionAutoCleanup-B2TGbr2W.js +1 -0
  66. package/dist/assets/useUIStore-DzwCfRqr.js +1 -0
  67. package/dist/assets/{useWorkerHighlightedLines-NddS5-lr.js → useWorkerHighlightedLines-0_GDJima.js} +1 -1
  68. package/dist/assets/{utils-C2P0wcWO.js → utils-B1EK5zKa.js} +1 -1
  69. package/dist/assets/{zh-CN-BdRwDJuj.js → zh-CN-DueRrIql.js} +1 -1
  70. package/dist/assets/{zh-TW-C3LubKzZ.js → zh-TW-6875X80V.js} +1 -1
  71. package/dist/index.html +9 -9
  72. package/dist/mini-chat.html +9 -9
  73. package/dist/mobile.html +9 -9
  74. package/package.json +1 -1
  75. package/server/lib/config-sync/config-sync.test.js +52 -0
  76. package/server/lib/config-sync/scripts.js +10 -2
  77. package/server/lib/notifications/DOCUMENTATION.md +2 -1
  78. package/server/lib/notifications/emitter-runtime.js +78 -1
  79. package/server/lib/notifications/emitter-runtime.test.js +94 -1
  80. package/server/lib/scheduled-tasks/DOCUMENTATION.md +4 -1
  81. package/server/lib/scheduled-tasks/routes.js +51 -8
  82. package/server/lib/session-index/DOCUMENTATION.md +9 -3
  83. package/server/lib/session-index/service.js +45 -22
  84. package/server/lib/session-index/service.test.js +81 -0
  85. package/server/lib/session-title/DOCUMENTATION.md +15 -10
  86. package/server/lib/session-title/runtime.js +24 -0
  87. package/server/lib/session-title/runtime.test.js +143 -0
  88. package/server/lib/session-turn-pages/DOCUMENTATION.md +2 -1
  89. package/server/lib/session-turn-pages/service.js +14 -1
  90. package/server/lib/session-turn-pages/service.test.js +133 -0
  91. package/server/lib/ui-auth/ui-auth.js +3 -1
  92. package/server/lib/ui-auth/ui-auth.test.js +25 -0
  93. package/server/sse-routes.test.js +64 -1
  94. package/dist/assets/AssistantView-DM2dKfoR.js +0 -1
  95. package/dist/assets/DiagramView-Cmf-1RrR.js +0 -1
  96. package/dist/assets/MobileSurface-CDiflo1b.js +0 -2
  97. package/dist/assets/appThemeRegistry-BQGUOtTV.js +0 -2
  98. package/dist/assets/context-menu-BwxsmiaI.js +0 -1
  99. package/dist/assets/desktopRelayRestore-VfMK9fdk.js +0 -1
  100. package/dist/assets/diagram-rYL80jaS.js +0 -1
  101. package/dist/assets/fileQueries-CXaw4b1W.js +0 -1
  102. package/dist/assets/insertionBoundaries-Gk_rVlpX.js +0 -1
  103. package/dist/assets/main-7rfc5WXp.js +0 -172
  104. package/dist/assets/mobile-BX6ehEeM.js +0 -2
  105. package/dist/assets/projectMeta-mqvgqR8a.js +0 -3
  106. package/dist/assets/radio-nnW-6V1r.js +0 -1
  107. package/dist/assets/renderElectronMiniChatApp-C2Ya6FdB.js +0 -2
  108. package/dist/assets/renderMobileApp-BgDGLev_.js +0 -23
  109. package/dist/assets/runtimeEndpointReset-C1NE1NgZ.js +0 -147
  110. package/dist/assets/src-Dbdlm5x1.css +0 -2
  111. package/dist/assets/useAppFontEffects-BoCsqVCV.js +0 -7
  112. package/dist/assets/useMobileNavigationStore-CLiqmN1d.js +0 -476
  113. package/dist/assets/useSessionAutoCleanup-BPKBXJg4.js +0 -1
  114. package/dist/assets/useUIStore-NfqK-HA7.js +0 -1
@@ -140,6 +140,12 @@ export const createSessionIndexService = ({ dbPath, getRuntimeConfig = () => nul
140
140
  ON session_summary(runtime_key, directory, activity_updated_at DESC, session_id DESC);
141
141
  CREATE INDEX IF NOT EXISTS session_child_parent
142
142
  ON session_child(runtime_key, directory, parent_id);
143
+ CREATE TABLE IF NOT EXISTS session_pin (
144
+ runtime_key TEXT NOT NULL,
145
+ session_id TEXT NOT NULL,
146
+ pinned_at INTEGER NOT NULL,
147
+ PRIMARY KEY (runtime_key, session_id)
148
+ );
143
149
  `);
144
150
  const sessionSummaryColumns = new Set(
145
151
  db.prepare("SELECT name FROM pragma_table_info('session_summary')").all().map((column) => column.name),
@@ -147,6 +153,12 @@ export const createSessionIndexService = ({ dbPath, getRuntimeConfig = () => nul
147
153
  if (!sessionSummaryColumns.has('pinned_at')) {
148
154
  db.exec('ALTER TABLE session_summary ADD COLUMN pinned_at INTEGER');
149
155
  }
156
+ db.prepare(`
157
+ INSERT OR IGNORE INTO session_pin(runtime_key, session_id, pinned_at)
158
+ SELECT runtime_key, session_id, pinned_at
159
+ FROM session_summary
160
+ WHERE pinned_at IS NOT NULL
161
+ `).run();
150
162
  db.prepare('INSERT OR REPLACE INTO session_index_meta(key, value) VALUES (?, ?)')
151
163
  .run('schema_version', String(SCHEMA_VERSION));
152
164
 
@@ -240,6 +252,23 @@ export const createSessionIndexService = ({ dbPath, getRuntimeConfig = () => nul
240
252
  SELECT COUNT(*) AS count FROM session_child
241
253
  WHERE runtime_key = ? AND directory = ? AND parent_id = ?
242
254
  `);
255
+ const readPins = db.prepare(`
256
+ SELECT session_id AS id, pinned_at AS pinnedAt
257
+ FROM session_pin
258
+ WHERE runtime_key = ?
259
+ ORDER BY pinned_at DESC, session_id DESC
260
+ `);
261
+ const upsertPin = db.prepare(`
262
+ INSERT INTO session_pin(runtime_key, session_id, pinned_at)
263
+ VALUES (?, ?, ?)
264
+ ON CONFLICT(runtime_key, session_id) DO UPDATE SET pinned_at = excluded.pinned_at
265
+ `);
266
+ const deletePin = db.prepare('DELETE FROM session_pin WHERE runtime_key = ? AND session_id = ?');
267
+ const writeSummaryPinnedAt = db.prepare(`
268
+ UPDATE session_summary
269
+ SET pinned_at = ?
270
+ WHERE runtime_key = ? AND session_id = ?
271
+ `);
243
272
 
244
273
  const replaceDirectoryRows = ({ directory, sessions, cursor, hasMore, fullSync = true, now = Date.now() }) => {
245
274
  const normalizedDirectory = normalizeDirectory(directory);
@@ -363,6 +392,8 @@ export const createSessionIndexService = ({ dbPath, getRuntimeConfig = () => nul
363
392
 
364
393
  const snapshot = () => {
365
394
  const key = runtimeKey();
395
+ const pinRows = readPins.all(key);
396
+ const pinById = new Map(pinRows.map((row) => [row.id, row.pinnedAt]));
366
397
  const directories = db.prepare(`
367
398
  SELECT directory, cursor, has_more AS hasMore, last_synced_at AS lastSyncedAt,
368
399
  last_full_synced_at AS lastFullSyncedAt, last_accessed_at AS lastAccessedAt
@@ -387,7 +418,9 @@ export const createSessionIndexService = ({ dbPath, getRuntimeConfig = () => nul
387
418
  created: session.createdAt,
388
419
  updated: session.updatedAt,
389
420
  ...(session.archivedAt ? { archived: session.archivedAt } : {}),
390
- ...(session.pinnedAt ? { pinned: session.pinnedAt } : {}),
421
+ ...((pinById.get(session.id) ?? session.pinnedAt)
422
+ ? { pinned: pinById.get(session.id) ?? session.pinnedAt }
423
+ : {}),
391
424
  },
392
425
  metadata: {
393
426
  openchamber: {
@@ -401,13 +434,17 @@ export const createSessionIndexService = ({ dbPath, getRuntimeConfig = () => nul
401
434
  }))
402
435
  .filter(isVisibleSession),
403
436
  }));
404
- return { directories };
437
+ return {
438
+ directories,
439
+ pinnedSessionIds: pinRows.map((row) => row.id),
440
+ };
405
441
  };
406
442
 
407
443
  function remove(sessionID) {
408
444
  if (typeof sessionID !== 'string' || !sessionID) return false;
409
445
  const key = runtimeKey();
410
446
  const parent = parentForChild.get(key, sessionID);
447
+ const pinResult = deletePin.run(key, sessionID);
411
448
  const result = db.prepare('DELETE FROM session_summary WHERE runtime_key = ? AND session_id = ?').run(key, sessionID);
412
449
  deleteChildrenWithParent.run(key, sessionID);
413
450
  deleteChild.run(key, sessionID);
@@ -419,7 +456,7 @@ export const createSessionIndexService = ({ dbPath, getRuntimeConfig = () => nul
419
456
  parent.parentID,
420
457
  );
421
458
  }
422
- return result.changes > 0;
459
+ return result.changes > 0 || pinResult.changes > 0;
423
460
  }
424
461
 
425
462
  /**
@@ -480,36 +517,22 @@ export const createSessionIndexService = ({ dbPath, getRuntimeConfig = () => nul
480
517
  return result.changes > 0;
481
518
  };
482
519
 
483
- const summaryExists = db.prepare(`
484
- SELECT 1 AS present FROM session_summary
485
- WHERE runtime_key = ? AND session_id = ?
486
- LIMIT 1
487
- `);
488
-
489
520
  const setPinned = (sessionID, pinnedAt = Date.now()) => {
490
521
  if (typeof sessionID !== 'string' || !sessionID) return false;
491
522
  const timestamp = toTimestamp(pinnedAt);
492
523
  if (!timestamp) return false;
493
524
  const key = runtimeKey();
494
- if (!summaryExists.get(key, sessionID)) return false;
495
- db.prepare(`
496
- UPDATE session_summary
497
- SET pinned_at = ?
498
- WHERE runtime_key = ? AND session_id = ?
499
- `).run(timestamp, key, sessionID);
525
+ upsertPin.run(key, sessionID, timestamp);
526
+ writeSummaryPinnedAt.run(timestamp, key, sessionID);
500
527
  return true;
501
528
  };
502
529
 
503
530
  const clearPinned = (sessionID) => {
504
531
  if (typeof sessionID !== 'string' || !sessionID) return false;
505
532
  const key = runtimeKey();
506
- if (!summaryExists.get(key, sessionID)) return false;
507
- db.prepare(`
508
- UPDATE session_summary
509
- SET pinned_at = NULL
510
- WHERE runtime_key = ? AND session_id = ?
511
- `).run(key, sessionID);
512
- return true;
533
+ const pinResult = deletePin.run(key, sessionID);
534
+ const summaryResult = writeSummaryPinnedAt.run(null, key, sessionID);
535
+ return pinResult.changes > 0 || summaryResult.changes > 0;
513
536
  };
514
537
 
515
538
  const replaceChildSessions = db.transaction((directory, parentSessionID, children) => {
@@ -385,7 +385,88 @@ describe('Electron session index', () => {
385
385
 
386
386
  expect(service.upsert({ ...session('ses_a', 11), time: { created: 9, updated: 11, archived: 12 } })).toBe(true);
387
387
  expect(service.snapshot().directories[0].sessions).toEqual([]);
388
+ expect(service.snapshot().pinnedSessionIds ?? []).not.toContain('ses_a');
388
389
  expect(service.clearPinned('ses_a')).toBe(false);
389
390
  service.close();
390
391
  });
392
+
393
+ it('keeps a pin after the session falls outside the newest-20 rebuild window', () => {
394
+ const runtimeRef = { value: 'http://runtime-a.test' };
395
+ const service = createService(runtimeRef);
396
+ service.replaceDirectory({
397
+ directory: '/repo',
398
+ sessions: [session('ses_pinned', 10)],
399
+ cursor: null,
400
+ hasMore: false,
401
+ });
402
+ expect(service.setPinned('ses_pinned', 42)).toBe(true);
403
+
404
+ const newer = Array.from({ length: 20 }, (_, index) => session(`ses_new_${index}`, 200 - index));
405
+ service.replaceDirectory({ directory: '/repo', sessions: newer, cursor: null, hasMore: true, now: 3000 });
406
+
407
+ const snapshot = service.snapshot();
408
+ expect(snapshot.directories[0].sessions).toHaveLength(20);
409
+ expect(snapshot.directories[0].sessions.some((item) => item.id === 'ses_pinned')).toBe(false);
410
+ expect(snapshot.pinnedSessionIds).toEqual(['ses_pinned']);
411
+ service.close();
412
+ });
413
+
414
+ it('keeps a pin after live upserts evict the pinned summary row', () => {
415
+ const runtimeRef = { value: 'http://runtime-a.test' };
416
+ const service = createService(runtimeRef);
417
+ service.replaceDirectory({
418
+ directory: '/repo',
419
+ sessions: [session('ses_pinned', 10)],
420
+ cursor: null,
421
+ hasMore: false,
422
+ });
423
+ expect(service.setPinned('ses_pinned', 43)).toBe(true);
424
+
425
+ for (let index = 0; index < 20; index += 1) {
426
+ service.upsert(session(`ses_new_${index}`, 200 - index));
427
+ }
428
+
429
+ const snapshot = service.snapshot();
430
+ expect(snapshot.directories[0].sessions).toHaveLength(20);
431
+ expect(snapshot.directories[0].sessions.some((item) => item.id === 'ses_pinned')).toBe(false);
432
+ expect(snapshot.pinnedSessionIds).toEqual(['ses_pinned']);
433
+ service.close();
434
+ });
435
+
436
+ it('backfills session_pin from existing summary pinned_at on open', () => {
437
+ const runtimeRef = { value: 'http://runtime-a.test' };
438
+ const service = createService(runtimeRef);
439
+ service.replaceDirectory({ directory: '/repo', sessions: [session('ses_a', 10)], cursor: null, hasMore: false });
440
+ expect(service.setPinned('ses_a', 66)).toBe(true);
441
+ service.close();
442
+
443
+ const dbPath = path.join(tempDirectories[tempDirectories.length - 1], 'session-index.sqlite');
444
+ const Database = createRequire(import.meta.url)('better-sqlite3');
445
+ const db = new Database(dbPath);
446
+ db.exec('DELETE FROM session_pin');
447
+ db.close();
448
+
449
+ const reopened = createSessionIndexService({
450
+ dbPath,
451
+ getRuntimeConfig: () => ({ apiBaseUrl: runtimeRef.value }),
452
+ });
453
+ expect(reopened.snapshot().pinnedSessionIds).toEqual(['ses_a']);
454
+ expect(reopened.snapshot().directories[0].sessions[0].time.pinned).toBe(66);
455
+ reopened.close();
456
+ });
457
+
458
+ it('pins a session that is not in the newest-20 index', () => {
459
+ const runtimeRef = { value: 'http://runtime-a.test' };
460
+ const service = createService(runtimeRef);
461
+ const newest = Array.from({ length: 20 }, (_, index) => session(`ses_${index}`, 100 - index));
462
+ service.replaceDirectory({ directory: '/repo', sessions: newest, cursor: 80, hasMore: true, now: 1000 });
463
+
464
+ expect(service.setPinned('ses_old', 55)).toBe(true);
465
+ expect(service.snapshot().pinnedSessionIds).toEqual(['ses_old']);
466
+ expect(service.snapshot().directories[0].sessions.some((item) => item.id === 'ses_old')).toBe(false);
467
+
468
+ expect(service.clearPinned('ses_old')).toBe(true);
469
+ expect(service.snapshot().pinnedSessionIds ?? []).toEqual([]);
470
+ service.close();
471
+ });
391
472
  });
@@ -18,16 +18,21 @@ throttle (`TITLE_THROTTLE_MS`) when a refresh is armed.
18
18
  pattern as session-assist. Purely event-driven — dormant sessions never
19
19
  generate anything.
20
20
  2. Auto title refresh is intentionally sparse (title stability first). A newly
21
- observed root `session.created` generates its first title immediately on the
22
- first `session.status: idle`. A fork title (`(fork #n)`) waits for its first
23
- newly-created user message; the matching assistant completion triggers an
24
- immediate title refresh that bypasses inherited title metadata and throttle.
25
- Ordinary later idle transitions do **not** arm another refresh. Any
26
- `busy`/`retry` status or a fresh user `message.updated` still clears an
27
- already-armed timer (so initial/fork timers cancel if the user keeps going).
28
- A sidebar smart-title request sets `titleRefresh.requestedAt`; its
29
- `session.updated` event arms the same flow immediately (forced / manual
30
- refresh is unaffected by the background gate).
21
+ observed root `session.created` generates its first title immediately on the
22
+ first `session.status: idle`. A fork title (`(fork #n)`) waits for its first
23
+ newly-created user message; the matching assistant completion triggers an
24
+ immediate title refresh that bypasses inherited title metadata and throttle.
25
+ If the fork's `session.created` was lost (SSE reconnect gap, server or
26
+ OpenCode restart), the first newly-created user message lazily re-registers
27
+ the pending fork from the session read inside `recordUserActivity` (fork
28
+ title + message created after the fork + activity timestamp not yet past
29
+ the fork time), so the first-reply refresh still fires.
30
+ Ordinary later idle transitions do **not** arm another refresh. Any
31
+ `busy`/`retry` status or a fresh user `message.updated` still clears an
32
+ already-armed timer (so initial/fork timers cancel if the user keeps going).
33
+ A sidebar smart-title request sets `titleRefresh.requestedAt`; its
34
+ `session.updated` event arms the same flow immediately (forced / manual
35
+ refresh is unaffected by the background gate).
31
36
  3. On fire:
32
37
  - Skip when Settings → Chat → session title refresh is off.
33
38
  - Skip system-owned sessions whose metadata carries a non-empty
@@ -484,6 +484,30 @@ export const createSessionTitleRuntime = ({
484
484
  if (!session || typeof session !== 'object') return;
485
485
 
486
486
  const meta = readTitleRefreshMeta(session);
487
+
488
+ // Lazy fork registration: when the fork's `session.created` event was lost
489
+ // (SSE reconnect gap, server/OpenCode restart), the in-memory pending map
490
+ // never learned about the fork. The first newly-created user message on a
491
+ // fork-titled session (message created after the fork, before any activity
492
+ // timestamp advanced past the fork) rebuilds that pending entry so the
493
+ // matching idle still triggers the first-refresh. Idempotent: the normal
494
+ // session.created path already holds a pending entry, and once activity
495
+ // advances past the fork time later messages never re-register.
496
+ const forkCreated = Number(session?.time?.created);
497
+ if (
498
+ !forkFirstSendPending.has(sessionId)
499
+ && isForkedSessionTitle(typeof session.title === 'string' ? session.title : '')
500
+ && Number.isFinite(forkCreated)
501
+ && createdAt > forkCreated
502
+ && !(Number(meta.titleRefresh.activityUpdatedAt) > forkCreated)
503
+ ) {
504
+ forkFirstSendPending.set(sessionId, {
505
+ createdAt: forkCreated,
506
+ directory: directory || '',
507
+ hasNewUserMessage: true,
508
+ });
509
+ }
510
+
487
511
  const previous = Number(meta.titleRefresh.activityUpdatedAt);
488
512
  const activityUpdatedAt = Number.isFinite(previous) ? Math.max(previous, createdAt) : createdAt;
489
513
  if (activityUpdatedAt === previous) return;
@@ -410,6 +410,149 @@ describe('session-title helpers', () => {
410
410
  }
411
411
  });
412
412
 
413
+ it('recovers the fork first-refresh when the fork session.created event was lost', async () => {
414
+ const originalFetch = globalThis.fetch;
415
+ const forkCreated = 100;
416
+ const session = {
417
+ id: 'ses-fork-lost',
418
+ title: 'Original work (fork #1)',
419
+ time: { created: forkCreated },
420
+ metadata: {
421
+ openchamber: {
422
+ titleRefresh: {
423
+ // Inherited from the parent: activity predates the fork.
424
+ activityUpdatedAt: 50,
425
+ lastAutoTitle: 'Original work',
426
+ generatedAt: 40,
427
+ forMessageID: 'assistant-original',
428
+ },
429
+ },
430
+ },
431
+ };
432
+ const messages = [
433
+ { info: { id: 'user-fork', role: 'user' }, parts: [{ type: 'text', text: 'Try a different approach' }] },
434
+ { info: { id: 'assistant-fork', role: 'assistant' }, parts: [{ type: 'text', text: 'Different approach ready' }] },
435
+ ];
436
+ let generationCalls = 0;
437
+
438
+ globalThis.fetch = async (input, init = {}) => {
439
+ const url = String(input);
440
+ if (url.includes('/message')) {
441
+ return new Response(JSON.stringify(messages), { status: 200 });
442
+ }
443
+ if (init.method === 'PATCH') {
444
+ const body = typeof init.body === 'string' ? JSON.parse(init.body) : init.body;
445
+ if (typeof body?.title === 'string') session.title = body.title;
446
+ if (body?.metadata) session.metadata = body.metadata;
447
+ return new Response(JSON.stringify(session), { status: 200 });
448
+ }
449
+ return new Response(JSON.stringify(session), { status: 200 });
450
+ };
451
+
452
+ try {
453
+ const runtime = createSessionTitleRuntime({
454
+ buildOpenCodeUrl: (pathname) => `http://opencode${pathname}`,
455
+ getOpenCodeAuthHeaders: () => ({}),
456
+ getSmallModelService: async () => ({
457
+ generateSmallModelText: async () => {
458
+ generationCalls += 1;
459
+ return { text: 'Different approach', providerID: 'test', modelID: 'test' };
460
+ },
461
+ }),
462
+ now: () => 1_000,
463
+ isTitleRefreshEnabled: () => true,
464
+ });
465
+
466
+ // No session.created — it was lost across an SSE reconnect gap. The first
467
+ // newly-sent fork message lazily re-registers the pending fork entry.
468
+ runtime.processPayload({
469
+ type: 'message.updated',
470
+ properties: { directory: '/repo', info: { sessionID: 'ses-fork-lost', role: 'user', id: 'user-fork', time: { created: 200 } } },
471
+ });
472
+ await new Promise((resolve) => setTimeout(resolve, 25));
473
+
474
+ runtime.processPayload({
475
+ type: 'session.status',
476
+ properties: { sessionID: 'ses-fork-lost', directory: '/repo', status: { type: 'idle' } },
477
+ });
478
+ await new Promise((resolve) => setTimeout(resolve, 25));
479
+
480
+ expect(generationCalls).toBe(1);
481
+ expect(session.title).toBe('Different approach');
482
+ runtime.stop();
483
+ } finally {
484
+ globalThis.fetch = originalFetch;
485
+ }
486
+ });
487
+
488
+ it('does not lazily re-register a fork after its activity already moved past the fork', async () => {
489
+ const originalFetch = globalThis.fetch;
490
+ const forkCreated = 100;
491
+ const session = {
492
+ id: 'ses-fork-advanced',
493
+ title: 'Original work (fork #1)',
494
+ time: { created: forkCreated },
495
+ metadata: {
496
+ openchamber: {
497
+ titleRefresh: {
498
+ // A fork message already advanced activity past the fork time.
499
+ activityUpdatedAt: 300,
500
+ lastAutoTitle: 'Original work',
501
+ generatedAt: 40,
502
+ forMessageID: 'assistant-original',
503
+ },
504
+ },
505
+ },
506
+ };
507
+ let generationCalls = 0;
508
+
509
+ globalThis.fetch = async (input, init = {}) => {
510
+ if (String(input).includes('/message')) {
511
+ return new Response(JSON.stringify([
512
+ { info: { id: 'user-x', role: 'user' }, parts: [{ type: 'text', text: 'later message' }] },
513
+ { info: { id: 'assistant-x', role: 'assistant' }, parts: [{ type: 'text', text: 'later reply' }] },
514
+ ]), { status: 200 });
515
+ }
516
+ if (init.method === 'PATCH') {
517
+ const body = typeof init.body === 'string' ? JSON.parse(init.body) : init.body;
518
+ if (body?.metadata) session.metadata = body.metadata;
519
+ return new Response(JSON.stringify(session), { status: 200 });
520
+ }
521
+ return new Response(JSON.stringify(session), { status: 200 });
522
+ };
523
+
524
+ try {
525
+ const runtime = createSessionTitleRuntime({
526
+ buildOpenCodeUrl: (pathname) => `http://opencode${pathname}`,
527
+ getOpenCodeAuthHeaders: () => ({}),
528
+ getSmallModelService: async () => ({
529
+ generateSmallModelText: async () => {
530
+ generationCalls += 1;
531
+ return { text: 'Should not run', providerID: 'test', modelID: 'test' };
532
+ },
533
+ }),
534
+ now: () => 1_000,
535
+ isTitleRefreshEnabled: () => true,
536
+ });
537
+
538
+ runtime.processPayload({
539
+ type: 'message.updated',
540
+ properties: { directory: '/repo', info: { sessionID: 'ses-fork-advanced', role: 'user', id: 'user-x', time: { created: 400 } } },
541
+ });
542
+ await new Promise((resolve) => setTimeout(resolve, 25));
543
+ runtime.processPayload({
544
+ type: 'session.status',
545
+ properties: { sessionID: 'ses-fork-advanced', directory: '/repo', status: { type: 'idle' } },
546
+ });
547
+ await new Promise((resolve) => setTimeout(resolve, 25));
548
+
549
+ expect(generationCalls).toBe(0);
550
+ runtime.stop();
551
+ } finally {
552
+ globalThis.fetch = originalFetch;
553
+ }
554
+ });
555
+
413
556
  it('refreshes a new session on first idle, then ignores later idle transitions', async () => {
414
557
  const originalFetch = globalThis.fetch;
415
558
  const session = {
@@ -68,7 +68,7 @@ Success `200`:
68
68
  - `records`: chronological oldest → newest, including intermediate non-boundary rows (synthetic, tools, subtask, compaction) inside the window.
69
69
  - `turnCount`: count of authored user boundaries in `records`.
70
70
  - `cursor`: host-owned opaque token for the next `before=` request; `null` when `complete` is true. The token represents the position **just before** the earliest returned authored user (so the client can load older history without overlap).
71
- - `complete`: `upstreamComplete && selected.length === accumulated.length`. True when upstream history is exhausted **and** `selectTurnRecords` did not trim any older scanned rows (nothing left for the client). When the scan window held more than N turns and older rows were trimmed, `complete` stays `false` with a `cursor` so the client can fetch the trimmed history.
71
+ - `complete`: `upstreamComplete && selected.length === accumulated.length`. True when upstream history is exhausted **and** `selectTurnRecords` did not trim any older scanned rows (nothing left for the client). When the scan window held more than N turns and older rows were trimmed, or when upstream stopped on a full page with a missing `nextCursor` (suspicious truncation), `complete` stays `false` with a `cursor` so the client can fetch / retry.
72
72
  - `partsProjection`: `"slim-v1"` on every turn-page response (first packet and prepend). Names the projection applied to `records`; see below. Reconcile responses omit this field and keep full parts.
73
73
 
74
74
  ### Turn-page payload projection
@@ -147,6 +147,7 @@ A message is an authored user turn boundary when:
147
147
 
148
148
  - Upstream pages are chronological **old→new** within each page; **do not reverse**. Older pages are **prepended** while deduping by `info.id` (global order remains old→new).
149
149
  - Continues paging with raw upstream `before` until `turns` authored boundaries are collected or upstream reports no next cursor.
150
+ - **Suspicious truncation:** an upstream page whose `records.length >= scanLimit` (full page) but `nextCursor == null` is treated as a non-authoritative cut (observed OpenCode `x-next-cursor` race), not as end-of-history. The host stops scanning that round with `upstreamComplete=false`, so the response stays `complete=false` and still emits a boundary host cursor the client can retry. A short page (`records.length < scanLimit`) with no cursor, an empty page with no cursor, or an explicit `page.complete === true` remain authoritative exhaustion. Client no-growth handling still bounds retries if the upstream view stays truncated.
150
151
  - Every upstream record must have a non-empty `info.id`; otherwise structured error, no partial `records`, HTTP 502.
151
152
  - Hard scan caps (no partial success): **50 pages** / **5000 messages** → structured error, HTTP 413.
152
153
  - Repeated / stalled cursor, or empty page that still carries a next cursor → structured error, no partial `records`, HTTP 502.
@@ -719,7 +719,14 @@ export const createSessionTurnPageService = ({
719
719
 
720
720
  const rawNext = page.nextCursor;
721
721
  const nextCursor = typeof rawNext === 'string' && rawNext.length > 0 ? rawNext : null;
722
- const pageComplete = page.complete === true || nextCursor == null;
722
+ // Explicit page.complete wins. Otherwise: no nextCursor on a short page
723
+ // is authoritative exhaustion; a *full* page with no nextCursor is a
724
+ // suspicious upstream truncation (x-next-cursor race) — stop scanning
725
+ // but do not mark upstreamComplete so the host still emits a retryable
726
+ // boundary cursor instead of permanently exhausting the client.
727
+ const fullPageNoCursor = nextCursor == null && records.length >= pageLimit;
728
+ const pageComplete = page.complete === true
729
+ || (nextCursor == null && !fullPageNoCursor);
723
730
 
724
731
  if (records.length === 0) {
725
732
  if (nextCursor) {
@@ -795,6 +802,12 @@ export const createSessionTurnPageService = ({
795
802
  break;
796
803
  }
797
804
 
805
+ if (fullPageNoCursor) {
806
+ // Suspicious truncation: keep upstreamComplete=false so the response
807
+ // stays complete=false with a host cursor the client can retry.
808
+ break;
809
+ }
810
+
798
811
  // Enough authored boundaries collected — stop without requiring exhaustion.
799
812
  if (countAuthoredBoundaries(accumulated) >= turnBudget) {
800
813
  break;
@@ -1107,4 +1107,137 @@ describe('createSessionTurnPageService', () => {
1107
1107
  signal: controller.signal,
1108
1108
  }));
1109
1109
  });
1110
+
1111
+ describe('full-page missing nextCursor (suspicious upstream truncation)', () => {
1112
+ it('short page without cursor remains authoritative exhaustion', async () => {
1113
+ // records < scanLimit + no cursor → real end of history.
1114
+ const fetchPage = vi.fn(async () => pageResult([
1115
+ user('msg_u1'),
1116
+ assistant('msg_a1'),
1117
+ user('msg_u2'),
1118
+ assistant('msg_a2'),
1119
+ ], null));
1120
+ const service = createSessionTurnPageService({ fetchPage });
1121
+
1122
+ const result = await service.loadPage({
1123
+ sessionID: 'ses_1',
1124
+ turns: 3,
1125
+ scanLimit: 100,
1126
+ });
1127
+
1128
+ expect(result).toMatchObject({
1129
+ ok: true,
1130
+ complete: true,
1131
+ cursor: null,
1132
+ turnCount: 2,
1133
+ });
1134
+ });
1135
+
1136
+ it('full page without cursor stays incomplete and emits a host cursor', async () => {
1137
+ // Upstream race: page is full (records >= scanLimit) but x-next-cursor is
1138
+ // missing. Must not permanently exhaust — client needs a retryable cursor.
1139
+ const records = [];
1140
+ for (let i = 1; i <= 10; i += 1) {
1141
+ records.push(user(`msg_u${i}`), assistant(`msg_a${i}`));
1142
+ }
1143
+ expect(records.length).toBe(20);
1144
+ const fetchPage = vi.fn(async () => pageResult(records, null));
1145
+ const service = createSessionTurnPageService({ fetchPage });
1146
+
1147
+ const result = await service.loadPage({
1148
+ sessionID: 'ses_1',
1149
+ turns: 3,
1150
+ scanLimit: 20,
1151
+ });
1152
+
1153
+ expect(result.ok).toBe(true);
1154
+ expect(result.complete).toBe(false);
1155
+ expect(typeof result.cursor).toBe('string');
1156
+ expect(result.cursor.startsWith('oc1.')).toBe(true);
1157
+ expect(result.turnCount).toBe(3);
1158
+ expect(decodeHostCursor(result.cursor)).toEqual({
1159
+ ok: true,
1160
+ value: { before: null, boundaryID: 'msg_u8' },
1161
+ });
1162
+ });
1163
+
1164
+ it('page with nextCursor keeps paging (unchanged)', async () => {
1165
+ const pages = new Map([
1166
+ [undefined, pageResult([
1167
+ user('msg_u2'),
1168
+ assistant('msg_a2'),
1169
+ user('msg_u3'),
1170
+ assistant('msg_a3'),
1171
+ ], 'opaque_older')],
1172
+ ['opaque_older', pageResult([
1173
+ user('msg_u1'),
1174
+ assistant('msg_a1'),
1175
+ ], null)],
1176
+ ]);
1177
+ const fetchPage = vi.fn(async ({ before }) => pages.get(before) ?? pageResult([], null));
1178
+ const service = createSessionTurnPageService({ fetchPage });
1179
+
1180
+ const result = await service.loadPage({
1181
+ sessionID: 'ses_1',
1182
+ turns: 3,
1183
+ scanLimit: 100,
1184
+ });
1185
+
1186
+ expect(result).toMatchObject({
1187
+ ok: true,
1188
+ complete: true,
1189
+ cursor: null,
1190
+ turnCount: 3,
1191
+ });
1192
+ expect(fetchPage.mock.calls.length).toBe(2);
1193
+ });
1194
+
1195
+ it('empty page without cursor remains authoritative exhaustion', async () => {
1196
+ const fetchPage = vi.fn(async () => pageResult([], null));
1197
+ const service = createSessionTurnPageService({ fetchPage });
1198
+
1199
+ const result = await service.loadPage({
1200
+ sessionID: 'ses_1',
1201
+ turns: 3,
1202
+ scanLimit: 100,
1203
+ });
1204
+
1205
+ expect(result).toMatchObject({
1206
+ ok: true,
1207
+ complete: true,
1208
+ cursor: null,
1209
+ turnCount: 0,
1210
+ });
1211
+ expect(result.records).toEqual([]);
1212
+ });
1213
+
1214
+ it('explicit page.complete=true on a full page without cursor still exhausts', async () => {
1215
+ // Explicit complete declaration outranks the full-page heuristic.
1216
+ // Exactly turns=budget so selectTurnRecords does not trim — complete
1217
+ // stays true solely because upstreamComplete was set by page.complete.
1218
+ const records = [];
1219
+ for (let i = 1; i <= 10; i += 1) {
1220
+ records.push(user(`msg_u${i}`), assistant(`msg_a${i}`));
1221
+ }
1222
+ const fetchPage = vi.fn(async () => ({
1223
+ records,
1224
+ nextCursor: null,
1225
+ complete: true,
1226
+ }));
1227
+ const service = createSessionTurnPageService({ fetchPage });
1228
+
1229
+ const result = await service.loadPage({
1230
+ sessionID: 'ses_1',
1231
+ turns: 10,
1232
+ scanLimit: 20,
1233
+ });
1234
+
1235
+ expect(result).toMatchObject({
1236
+ ok: true,
1237
+ complete: true,
1238
+ cursor: null,
1239
+ turnCount: 10,
1240
+ });
1241
+ });
1242
+ });
1110
1243
  });
@@ -714,7 +714,9 @@ export const createUiAuth = ({
714
714
  const respondUnauthorized = (req, res) => {
715
715
  res.status(401);
716
716
  const acceptsJson = req.headers.accept?.includes('application/json');
717
- if (acceptsJson || req.path?.startsWith('/api')) {
717
+ // Express strips the mount prefix from req.path (e.g. app.use('/api') turns
718
+ // /api/mcp into /mcp), so resolve the original pathname instead.
719
+ if (acceptsJson || getRequestPathname(req).startsWith('/api')) {
718
720
  res.json({ error: 'UI authentication required', locked: true });
719
721
  } else {
720
722
  res.type('text/plain').send('Authentication required');
@@ -259,6 +259,31 @@ describe('ui auth client credential seam', () => {
259
259
  }
260
260
  });
261
261
 
262
+ it('returns JSON 401 for unauthenticated API requests mounted under a sub-app', async () => {
263
+ const createUiAuth = await loadCreateUiAuth();
264
+ const auth = createUiAuth({ password: 'secret' });
265
+
266
+ // Express strips the mount prefix: app.use('/api') turns /api/mcp into
267
+ // req.path '/mcp'. The unauthorized response must still be identified as an
268
+ // API request (JSON + locked) so clients can react to the auth gate.
269
+ const req = {
270
+ method: 'GET',
271
+ baseUrl: '/api',
272
+ path: '/mcp',
273
+ originalUrl: '/api/mcp',
274
+ url: '/api/mcp',
275
+ headers: {},
276
+ };
277
+ const res = createResponse();
278
+ let called = false;
279
+ await auth.requireAuth(req, res, () => {
280
+ called = true;
281
+ });
282
+ expect(called).toBe(false);
283
+ expect(res.statusCode).toBe(401);
284
+ expect(res.body).toEqual({ error: 'UI authentication required', locked: true });
285
+ });
286
+
262
287
  it('issues desktop client tokens with the UI session expiry', async () => {
263
288
  const createUiAuth = await loadCreateUiAuth();
264
289
  let createClientInput = null;