@openchambery/web 1.18.2-beta.14 → 1.18.2-beta.16

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 (68) hide show
  1. package/dist/assets/{AssistantView-BKlbxZyK.js → AssistantView-5b0FsCQa.js} +1 -1
  2. package/dist/assets/{ChatContainer-BH5Q0OcG.js → ChatContainer-QtcnvWoA.js} +22 -22
  3. package/dist/assets/{DiagramView-fK3UrzlE.js → DiagramView-BoWXd8X4.js} +1 -1
  4. package/dist/assets/{MarkdownRendererImpl-DrRS_Mj-.js → MarkdownRendererImpl-0Gq_Uqs_.js} +2 -2
  5. package/dist/assets/{MobileDetailNavigation-iyCRQGq6.js → MobileDetailNavigation-CBWFnBIu.js} +1 -1
  6. package/dist/assets/{MobileShareBridge-CEz3kBX3.js → MobileShareBridge-CTg5aHEn.js} +1 -1
  7. package/dist/assets/MobileSurface-Dw6Ina7A.js +2 -0
  8. package/dist/assets/{MultiRunWindow-5nJ89UeU.js → MultiRunWindow-De0C4bCt.js} +1 -1
  9. package/dist/assets/{OnboardingScreen-CNMknKIz.js → OnboardingScreen-7mFH7iBm.js} +1 -1
  10. package/dist/assets/{OverlayScrollbar-C2PKulfj.js → OverlayScrollbar-DzX65Ew5.js} +1 -1
  11. package/dist/assets/SettingsView-Bs2kVAA1.js +25 -0
  12. package/dist/assets/{SettingsWindow-BMGjq7sz.js → SettingsWindow-axr7ewCq.js} +1 -1
  13. package/dist/assets/{TerminalView-CjsjAgBp.js → TerminalView-B_5ml6J4.js} +1 -1
  14. package/dist/assets/{ToolOutputDialog-CnO6gAC7.js → ToolOutputDialog-CR5Ifk2Z.js} +1 -1
  15. package/dist/assets/{appThemeRegistry-CXo91lud.js → appThemeRegistry-ih8IT_2E.js} +2 -2
  16. package/dist/assets/{clipboard-DDtEAXwp.js → clipboard-Cm4VHQ3n.js} +1 -1
  17. package/dist/assets/{context-menu-DbYwL067.js → context-menu-DyfEPvnE.js} +1 -1
  18. package/dist/assets/{dialog-CLUnqzNl.js → dialog-ruyemRYh.js} +1 -1
  19. package/dist/assets/{es-DCoGZljP.js → es-W_z2kSYf.js} +1 -1
  20. package/dist/assets/{fr-dkEoZpPz.js → fr-MYuf2QNQ.js} +1 -1
  21. package/dist/assets/{gitApi-DbudVv-1.js → gitApi-BrvHOAWU.js} +1 -1
  22. package/dist/assets/insertionBoundaries-BW7YHXi6.js +1 -0
  23. package/dist/assets/{ja-bQwSsvRr.js → ja-DBkl4NXl.js} +1 -1
  24. package/dist/assets/{ko-Kq5C3hsv.js → ko-DwSsHsxa.js} +1 -1
  25. package/dist/assets/{main-DNIq2ApI.js → main-BBKtt1Y9.js} +20 -20
  26. package/dist/assets/{main-CoT0vDkM.js → main-fdHtRqqY.js} +2 -2
  27. package/dist/assets/{miniChat-MmffZf1Z.js → miniChat-AFFBcZuq.js} +2 -2
  28. package/dist/assets/{mobile-DWXVmge1.js → mobile-BjoYS6yy.js} +2 -2
  29. package/dist/assets/{multirun-CkvHavte.js → multirun-BUCHfy2k.js} +1 -1
  30. package/dist/assets/{pl-CYpMAgVn.js → pl-BGLpnSSE.js} +1 -1
  31. package/dist/assets/projectMeta-GWaQA3Nh.js +3 -0
  32. package/dist/assets/{pt-BR-MSPTLfTm.js → pt-BR-DuEh49lp.js} +1 -1
  33. package/dist/assets/{radio-CgKdnGSl.js → radio-DLT8WkB1.js} +1 -1
  34. package/dist/assets/renderElectronMiniChatApp-mk15OpZl.js +2 -0
  35. package/dist/assets/renderMobileApp-1_-KjQ6K.js +23 -0
  36. package/dist/assets/runtimeEndpointReset-C-zHA9mH.js +145 -0
  37. package/dist/assets/{sortable-tabs-strip-B6nysrlL.js → sortable-tabs-strip-B8ZHvoDm.js} +1 -1
  38. package/dist/assets/{store-OURVnLSN.js → store-Cb8Q6EtD.js} +2 -2
  39. package/dist/assets/{uk-cSkRAYFY.js → uk-CUoSz-Q8.js} +1 -1
  40. package/dist/assets/useAppFontEffects-BmoxCfbH.js +7 -0
  41. package/dist/assets/useDesktopWindowControlsLayout-BLOLzV-B.js +1 -0
  42. package/dist/assets/{useEffectiveDirectory-DvMBtKLj.js → useEffectiveDirectory-DYoU9MD8.js} +1 -1
  43. package/dist/assets/{useMobileNavigationStore-Y1jVRW3U.js → useMobileNavigationStore-DpFAK6sE.js} +21 -21
  44. package/dist/assets/{useSessionAutoCleanup-C1joiK7e.js → useSessionAutoCleanup-B5wYiGWJ.js} +1 -1
  45. package/dist/assets/{useWorkerHighlightedLines-BsIvNEvi.js → useWorkerHighlightedLines-DkDOqMcg.js} +1 -1
  46. package/dist/assets/{zh-CN-BKqeT_uz.js → zh-CN-CJP5FWCK.js} +1 -1
  47. package/dist/assets/{zh-TW-BaNjJ8Y4.js → zh-TW-B_2h1yuo.js} +1 -1
  48. package/dist/index.html +1 -1
  49. package/dist/mini-chat.html +1 -1
  50. package/dist/mobile.html +1 -1
  51. package/package.json +1 -1
  52. package/server/lib/session-index/DOCUMENTATION.md +6 -1
  53. package/server/lib/session-index/routes.js +34 -0
  54. package/server/lib/session-index/routes.test.js +76 -0
  55. package/server/lib/session-index/service.js +52 -4
  56. package/server/lib/session-index/service.test.js +65 -0
  57. package/server/lib/session-title/DOCUMENTATION.md +16 -12
  58. package/server/lib/session-title/runtime.js +7 -8
  59. package/server/lib/session-title/runtime.test.js +225 -0
  60. package/dist/assets/MobileSurface-BW5JgI5O.js +0 -2
  61. package/dist/assets/SettingsView-DlMRauNf.js +0 -25
  62. package/dist/assets/insertionBoundaries-C3LQiAH4.js +0 -1
  63. package/dist/assets/projectMeta-B493xmWa.js +0 -3
  64. package/dist/assets/renderElectronMiniChatApp-KQLIgpz7.js +0 -2
  65. package/dist/assets/renderMobileApp-Dv-6ehQM.js +0 -23
  66. package/dist/assets/runtimeEndpointReset-COE06B3r.js +0 -145
  67. package/dist/assets/useAppFontEffects-BxqiSjTE.js +0 -7
  68. package/dist/assets/useDesktopWindowControlsLayout-tw70hQqL.js +0 -1
package/dist/index.html CHANGED
@@ -552,7 +552,7 @@
552
552
  opacity: 0.72;
553
553
  }
554
554
  </style>
555
- <script type="module" crossorigin src="/assets/main-CoT0vDkM.js"></script>
555
+ <script type="module" crossorigin src="/assets/main-fdHtRqqY.js"></script>
556
556
  <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-C0FnF6B9.js">
557
557
  <link rel="modulepreload" crossorigin href="/assets/vendor-base-ui-CnHfUmDN.js">
558
558
  <link rel="modulepreload" crossorigin href="/assets/vendor-zustand-B4dlGEM2.js">
@@ -74,7 +74,7 @@
74
74
  }
75
75
  </style>
76
76
 
77
- <script type="module" crossorigin src="/assets/miniChat-MmffZf1Z.js"></script>
77
+ <script type="module" crossorigin src="/assets/miniChat-AFFBcZuq.js"></script>
78
78
  <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-C0FnF6B9.js">
79
79
  <link rel="modulepreload" crossorigin href="/assets/vendor-base-ui-CnHfUmDN.js">
80
80
  <link rel="modulepreload" crossorigin href="/assets/vendor-zustand-B4dlGEM2.js">
package/dist/mobile.html CHANGED
@@ -63,7 +63,7 @@
63
63
  })();
64
64
  </script>
65
65
 
66
- <script type="module" crossorigin src="/assets/mobile-DWXVmge1.js"></script>
66
+ <script type="module" crossorigin src="/assets/mobile-BjoYS6yy.js"></script>
67
67
  <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-C0FnF6B9.js">
68
68
  <link rel="modulepreload" crossorigin href="/assets/vendor-base-ui-CnHfUmDN.js">
69
69
  <link rel="modulepreload" crossorigin href="/assets/vendor-zustand-B4dlGEM2.js">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openchambery/web",
3
- "version": "1.18.2-beta.14",
3
+ "version": "1.18.2-beta.16",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "main": "./server/index.js",
@@ -18,9 +18,14 @@ authoritative metadata only: a non-empty
18
18
  `metadata.openchamber.assistant.assistantID` or
19
19
  `metadata.openchamber.scheduledTask.taskID`. Title prefixes are human labels and
20
20
  never participate in this filter. Metadata is the ownership/isolation signal;
21
- `time.archived` is archive state; titles are for recognition. The index does
21
+ `time.archived` is archive state; `time.pinned` is OpenChamber-owned pin state
22
+ (persisted as `pinned_at`, rescued across directory rebuilds, never overwritten
23
+ by live OpenCode upserts); titles are for recognition. The index does
22
24
  not cache system sessions, so cold-start and live upserts stay aligned with
23
25
  ordinary sidebar lists.
26
+ Pin and unpin use `POST` / `DELETE`
27
+ `/api/openchamber/session-index/session/:id/pin`, update `pinned_at`, then
28
+ `publishChange()` so revision tips broadcast `openchamber:session-index-changed`.
24
29
 
25
30
  The server-side global OpenCode event subscriber writes session summary events
26
31
  directly into this index. User `message.updated` events and `session.idle`
@@ -62,4 +62,38 @@ export const registerSessionIndexRoutes = (app, { sessionIndexService, sessionIn
62
62
  }
63
63
  res.json({ available: true, session: hit });
64
64
  });
65
+
66
+ app.post('/api/openchamber/session-index/session/:id/pin', (req, res) => {
67
+ if (!sessionIndexService) return unsupported(res);
68
+ if (typeof sessionIndexService.setPinned !== 'function') {
69
+ return res.status(501).json({ error: 'Session pin is unavailable' });
70
+ }
71
+ const sessionId = req.params.id;
72
+ if (typeof sessionId !== 'string' || !sessionId.trim()) {
73
+ return res.status(400).json({ error: 'sessionId is required' });
74
+ }
75
+ const changed = sessionIndexService.setPinned(sessionId.trim());
76
+ if (!changed) {
77
+ return res.status(404).json({ error: 'session_not_found', sessionId: sessionId.trim() });
78
+ }
79
+ sessionIndexSyncRuntime?.publishChange?.();
80
+ res.status(204).end();
81
+ });
82
+
83
+ app.delete('/api/openchamber/session-index/session/:id/pin', (req, res) => {
84
+ if (!sessionIndexService) return unsupported(res);
85
+ if (typeof sessionIndexService.clearPinned !== 'function') {
86
+ return res.status(501).json({ error: 'Session unpin is unavailable' });
87
+ }
88
+ const sessionId = req.params.id;
89
+ if (typeof sessionId !== 'string' || !sessionId.trim()) {
90
+ return res.status(400).json({ error: 'sessionId is required' });
91
+ }
92
+ const changed = sessionIndexService.clearPinned(sessionId.trim());
93
+ if (!changed) {
94
+ return res.status(404).json({ error: 'session_not_found', sessionId: sessionId.trim() });
95
+ }
96
+ sessionIndexSyncRuntime?.publishChange?.();
97
+ res.status(204).end();
98
+ });
65
99
  };
@@ -137,4 +137,80 @@ describe('session index routes', () => {
137
137
  expect(res.statusCode).toBe(404);
138
138
  expect(res.body).toMatchObject({ error: 'session_not_found' });
139
139
  });
140
+
141
+ it('pins a session and publishes a session-index change', () => {
142
+ const { app, route } = registry();
143
+ const setPinned = vi.fn(() => true);
144
+ const publishChange = vi.fn();
145
+ registerSessionIndexRoutes(app, {
146
+ sessionIndexService: {
147
+ snapshot: () => ({ directories: [] }),
148
+ setPinned,
149
+ },
150
+ sessionIndexSyncRuntime: { publishChange },
151
+ });
152
+ const res = response();
153
+
154
+ route('POST', '/api/openchamber/session-index/session/:id/pin')(
155
+ { params: { id: 'ses_abc' } },
156
+ res,
157
+ );
158
+
159
+ expect(setPinned).toHaveBeenCalledWith('ses_abc');
160
+ expect(publishChange).toHaveBeenCalledTimes(1);
161
+ expect(res.statusCode).toBe(204);
162
+ });
163
+
164
+ it('unpins a session and publishes a session-index change', () => {
165
+ const { app, route } = registry();
166
+ const clearPinned = vi.fn(() => true);
167
+ const publishChange = vi.fn();
168
+ registerSessionIndexRoutes(app, {
169
+ sessionIndexService: {
170
+ snapshot: () => ({ directories: [] }),
171
+ clearPinned,
172
+ },
173
+ sessionIndexSyncRuntime: { publishChange },
174
+ });
175
+ const res = response();
176
+
177
+ route('DELETE', '/api/openchamber/session-index/session/:id/pin')(
178
+ { params: { id: 'ses_abc' } },
179
+ res,
180
+ );
181
+
182
+ expect(clearPinned).toHaveBeenCalledWith('ses_abc');
183
+ expect(publishChange).toHaveBeenCalledTimes(1);
184
+ expect(res.statusCode).toBe(204);
185
+ });
186
+
187
+ it('returns 404 for pin and unpin when the session is missing', () => {
188
+ const { app, route } = registry();
189
+ const publishChange = vi.fn();
190
+ registerSessionIndexRoutes(app, {
191
+ sessionIndexService: {
192
+ snapshot: () => ({ directories: [] }),
193
+ setPinned: () => false,
194
+ clearPinned: () => false,
195
+ },
196
+ sessionIndexSyncRuntime: { publishChange },
197
+ });
198
+
199
+ const pinRes = response();
200
+ route('POST', '/api/openchamber/session-index/session/:id/pin')(
201
+ { params: { id: 'ses_missing' } },
202
+ pinRes,
203
+ );
204
+ expect(pinRes.statusCode).toBe(404);
205
+ expect(pinRes.body).toMatchObject({ error: 'session_not_found' });
206
+
207
+ const unpinRes = response();
208
+ route('DELETE', '/api/openchamber/session-index/session/:id/pin')(
209
+ { params: { id: 'ses_missing' } },
210
+ unpinRes,
211
+ );
212
+ expect(unpinRes.statusCode).toBe(404);
213
+ expect(unpinRes.body).toMatchObject({ error: 'session_not_found' });
214
+ expect(publishChange).not.toHaveBeenCalled();
215
+ });
140
216
  });
@@ -116,6 +116,7 @@ export const createSessionIndexService = ({ dbPath, getRuntimeConfig = () => nul
116
116
  updated_at INTEGER NOT NULL,
117
117
  activity_updated_at INTEGER NOT NULL,
118
118
  archived_at INTEGER NOT NULL DEFAULT 0,
119
+ pinned_at INTEGER,
119
120
  status TEXT,
120
121
  status_changed_at INTEGER NOT NULL DEFAULT 0,
121
122
  parent_id TEXT,
@@ -139,6 +140,12 @@ export const createSessionIndexService = ({ dbPath, getRuntimeConfig = () => nul
139
140
  CREATE INDEX IF NOT EXISTS session_child_parent
140
141
  ON session_child(runtime_key, directory, parent_id);
141
142
  `);
143
+ const sessionSummaryColumns = new Set(
144
+ db.prepare("SELECT name FROM pragma_table_info('session_summary')").all().map((column) => column.name),
145
+ );
146
+ if (!sessionSummaryColumns.has('pinned_at')) {
147
+ db.exec('ALTER TABLE session_summary ADD COLUMN pinned_at INTEGER');
148
+ }
142
149
  db.prepare('INSERT OR REPLACE INTO session_index_meta(key, value) VALUES (?, ?)')
143
150
  .run('schema_version', String(SCHEMA_VERSION));
144
151
 
@@ -165,7 +172,7 @@ export const createSessionIndexService = ({ dbPath, getRuntimeConfig = () => nul
165
172
  const deleteDirectoryRows = db.prepare('DELETE FROM session_summary WHERE runtime_key = ? AND directory = ?');
166
173
  const existingSummaryState = db.prepare(`
167
174
  SELECT session_id, has_children AS hasChildren, activity_updated_at AS activityUpdatedAt,
168
- status, status_changed_at AS statusChangedAt
175
+ status, status_changed_at AS statusChangedAt, pinned_at AS pinnedAt
169
176
  FROM session_summary WHERE runtime_key = ? AND directory = ?
170
177
  `);
171
178
  const readSummary = db.prepare(`
@@ -183,8 +190,8 @@ export const createSessionIndexService = ({ dbPath, getRuntimeConfig = () => nul
183
190
  LIMIT 1 OFFSET ?
184
191
  `);
185
192
  const insertSummary = db.prepare(`
186
- INSERT INTO session_summary(runtime_key, directory, session_id, title, created_at, updated_at, activity_updated_at, archived_at, status, status_changed_at, parent_id, has_children, assistant_id, assistant_name)
187
- VALUES (@runtimeKey, @directory, @id, @title, @createdAt, @updatedAt, @activityUpdatedAt, @archivedAt, @status, @statusChangedAt, @parentID, @hasChildren, @assistantID, @assistantName)
193
+ INSERT INTO session_summary(runtime_key, directory, session_id, title, created_at, updated_at, activity_updated_at, archived_at, pinned_at, status, status_changed_at, parent_id, has_children, assistant_id, assistant_name)
194
+ VALUES (@runtimeKey, @directory, @id, @title, @createdAt, @updatedAt, @activityUpdatedAt, @archivedAt, @pinnedAt, @status, @statusChangedAt, @parentID, @hasChildren, @assistantID, @assistantName)
188
195
  `);
189
196
  const upsertSummary = db.prepare(`
190
197
  INSERT INTO session_summary(runtime_key, directory, session_id, title, created_at, updated_at, activity_updated_at, archived_at, status, status_changed_at, parent_id, has_children, assistant_id, assistant_name)
@@ -259,6 +266,7 @@ export const createSessionIndexService = ({ dbPath, getRuntimeConfig = () => nul
259
266
  insertSummary.run({
260
267
  ...summary,
261
268
  activityUpdatedAt: Math.max(summary.activityUpdatedAt, toTimestamp(previous?.activityUpdatedAt)),
269
+ pinnedAt: previous?.pinnedAt != null ? toTimestamp(previous.pinnedAt) : null,
262
270
  status: typeof previous?.status === 'string' ? previous.status : null,
263
271
  statusChangedAt: toTimestamp(previous?.statusChangedAt),
264
272
  hasChildren: toBooleanInt(summary.hasChildren ?? Boolean(previous?.hasChildren)),
@@ -364,6 +372,7 @@ export const createSessionIndexService = ({ dbPath, getRuntimeConfig = () => nul
364
372
  sessions: db.prepare(`
365
373
  SELECT session_id AS id, title, created_at AS createdAt, updated_at AS updatedAt,
366
374
  activity_updated_at AS activityUpdatedAt, archived_at AS archivedAt,
375
+ pinned_at AS pinnedAt,
367
376
  status, status_changed_at AS statusChangedAt, parent_id AS parentID,
368
377
  has_children AS hasChildren, assistant_id AS assistantID, assistant_name AS assistantName
369
378
  FROM session_summary WHERE runtime_key = ? AND directory = ?
@@ -373,7 +382,12 @@ export const createSessionIndexService = ({ dbPath, getRuntimeConfig = () => nul
373
382
  id: session.id,
374
383
  title: session.title,
375
384
  directory: directory.directory,
376
- time: { created: session.createdAt, updated: session.updatedAt, ...(session.archivedAt ? { archived: session.archivedAt } : {}) },
385
+ time: {
386
+ created: session.createdAt,
387
+ updated: session.updatedAt,
388
+ ...(session.archivedAt ? { archived: session.archivedAt } : {}),
389
+ ...(session.pinnedAt ? { pinned: session.pinnedAt } : {}),
390
+ },
377
391
  metadata: {
378
392
  openchamber: {
379
393
  titleRefresh: { activityUpdatedAt: session.activityUpdatedAt },
@@ -465,6 +479,38 @@ export const createSessionIndexService = ({ dbPath, getRuntimeConfig = () => nul
465
479
  return result.changes > 0;
466
480
  };
467
481
 
482
+ const summaryExists = db.prepare(`
483
+ SELECT 1 AS present FROM session_summary
484
+ WHERE runtime_key = ? AND session_id = ?
485
+ LIMIT 1
486
+ `);
487
+
488
+ const setPinned = (sessionID, pinnedAt = Date.now()) => {
489
+ if (typeof sessionID !== 'string' || !sessionID) return false;
490
+ const timestamp = toTimestamp(pinnedAt);
491
+ if (!timestamp) return false;
492
+ const key = runtimeKey();
493
+ if (!summaryExists.get(key, sessionID)) return false;
494
+ db.prepare(`
495
+ UPDATE session_summary
496
+ SET pinned_at = ?
497
+ WHERE runtime_key = ? AND session_id = ?
498
+ `).run(timestamp, key, sessionID);
499
+ return true;
500
+ };
501
+
502
+ const clearPinned = (sessionID) => {
503
+ if (typeof sessionID !== 'string' || !sessionID) return false;
504
+ const key = runtimeKey();
505
+ if (!summaryExists.get(key, sessionID)) return false;
506
+ db.prepare(`
507
+ UPDATE session_summary
508
+ SET pinned_at = NULL
509
+ WHERE runtime_key = ? AND session_id = ?
510
+ `).run(key, sessionID);
511
+ return true;
512
+ };
513
+
468
514
  const replaceChildSessions = db.transaction((directory, parentSessionID, children) => {
469
515
  const normalizedDirectory = normalizeDirectory(directory);
470
516
  if (!normalizedDirectory || typeof parentSessionID !== 'string' || !parentSessionID) return false;
@@ -487,6 +533,8 @@ export const createSessionIndexService = ({ dbPath, getRuntimeConfig = () => nul
487
533
  upsertAndReportChange,
488
534
  touchActivity,
489
535
  updateStatus,
536
+ setPinned,
537
+ clearPinned,
490
538
  remove,
491
539
  findBySessionId,
492
540
  replaceChildSessions,
@@ -318,4 +318,69 @@ describe('Electron session index', () => {
318
318
  expect(rebuilt.snapshot().directories).toEqual([]);
319
319
  rebuilt.close();
320
320
  });
321
+
322
+ it('exposes time.pinned in snapshots and clears it on unpin', () => {
323
+ const runtimeRef = { value: 'http://runtime-a.test' };
324
+ const service = createService(runtimeRef);
325
+ service.replaceDirectory({ directory: '/repo', sessions: [session('ses_a', 10)], cursor: null, hasMore: false });
326
+
327
+ expect(service.setPinned('ses_a', 55)).toBe(true);
328
+ expect(service.snapshot().directories[0].sessions[0].time).toMatchObject({
329
+ created: 9,
330
+ updated: 10,
331
+ pinned: 55,
332
+ });
333
+
334
+ expect(service.clearPinned('ses_a')).toBe(true);
335
+ expect(service.snapshot().directories[0].sessions[0].time).toEqual({
336
+ created: 9,
337
+ updated: 10,
338
+ });
339
+ service.close();
340
+ });
341
+
342
+ it('rescues pinned_at across replaceDirectory rebuilds', () => {
343
+ const runtimeRef = { value: 'http://runtime-a.test' };
344
+ const service = createService(runtimeRef);
345
+ service.replaceDirectory({ directory: '/repo', sessions: [session('ses_a', 10)], cursor: null, hasMore: false });
346
+ expect(service.setPinned('ses_a', 77)).toBe(true);
347
+
348
+ service.replaceDirectory({
349
+ directory: '/repo',
350
+ sessions: [session('ses_a', 20)],
351
+ cursor: null,
352
+ hasMore: false,
353
+ now: 2000,
354
+ });
355
+
356
+ expect(service.snapshot().directories[0].sessions[0].time).toMatchObject({
357
+ updated: 20,
358
+ pinned: 77,
359
+ });
360
+ service.close();
361
+ });
362
+
363
+ it('does not let live upsert overwrite pinned_at', () => {
364
+ const runtimeRef = { value: 'http://runtime-a.test' };
365
+ const service = createService(runtimeRef);
366
+ service.replaceDirectory({ directory: '/repo', sessions: [session('ses_a', 10)], cursor: null, hasMore: false });
367
+ expect(service.setPinned('ses_a', 88)).toBe(true);
368
+
369
+ service.upsert(session('ses_a', 30));
370
+
371
+ expect(service.snapshot().directories[0].sessions[0].time.pinned).toBe(88);
372
+ service.close();
373
+ });
374
+
375
+ it('removes pinned rows when an archived upsert deletes the session', () => {
376
+ const runtimeRef = { value: 'http://runtime-a.test' };
377
+ const service = createService(runtimeRef);
378
+ service.replaceDirectory({ directory: '/repo', sessions: [session('ses_a', 10)], cursor: null, hasMore: false });
379
+ expect(service.setPinned('ses_a', 99)).toBe(true);
380
+
381
+ expect(service.upsert({ ...session('ses_a', 11), time: { created: 9, updated: 11, archived: 12 } })).toBe(true);
382
+ expect(service.snapshot().directories[0].sessions).toEqual([]);
383
+ expect(service.clearPinned('ses_a')).toBe(false);
384
+ service.close();
385
+ });
321
386
  });
@@ -5,10 +5,11 @@ conversation's **main subject** (overall feature / goal) with the small model
5
5
  (`lib/small-model`), then PATCHes `title` plus `metadata.openchamber.titleRefresh`.
6
6
 
7
7
  OpenCode only auto-titles once from the first user message
8
- (`SessionPrompt.ensureTitle`). Long sessions often need a refresh; this module
9
- keeps the sidebar title aligned with the durable work being done not the last
10
- wrap-up utterance like "commit and push"throttled to at most once per 5
11
- minutes per session.
8
+ (`SessionPrompt.ensureTitle`). This module auto-refreshes sparsely (first idle
9
+ of a new session, first newly-sent reply on a fork) and on explicit smart-title
10
+ requests, naming the durable work being done not the last wrap-up utterance
11
+ like "commit and push". Background auto refreshes still respect a 5-minute
12
+ throttle (`TITLE_THROTTLE_MS`) when a refresh is armed.
12
13
 
13
14
  ## Flow
14
15
 
@@ -16,14 +17,17 @@ minutes per session.
16
17
  fan-out (`index.js` → `globalMessageStreamHub.subscribeEvent`), same
17
18
  pattern as session-assist. Purely event-driven — dormant sessions never
18
19
  generate anything.
19
- 2. A newly observed root `session.created` generates its first title immediately
20
- on the first `session.status: idle`. A fork title (`(fork #n)`) waits for its
21
- first newly-created user message; the matching assistant completion triggers
22
- an immediate title refresh that bypasses inherited title metadata and throttle.
23
- Later idle transitions arm a 15-second quiet timer; any `busy`/`retry` status
24
- or a fresh user `message.updated` clears it.
25
- A sidebar smart-title request sets `titleRefresh.requestedAt`; its
26
- `session.updated` event arms the same flow immediately.
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).
27
31
  3. On fire:
28
32
  - Skip when Settings → Chat → session title refresh is off.
29
33
  - Skip system-owned sessions whose metadata carries a non-empty
@@ -1,13 +1,14 @@
1
- // Session title refresh: after a session goes idle, regenerate the sidebar
2
- // title from the conversation's MAIN SUBJECT (overall feature / goal), not
3
- // just the last wrap-up utterance. OpenCode only titles once from the first
4
- // user message; long sessions need refresh, but continuing the same work
1
+ // Session title refresh: regenerate the sidebar title from the conversation's
2
+ // MAIN SUBJECT (overall feature / goal), not just the last wrap-up utterance.
3
+ // OpenCode only titles once from the first user message. Auto refresh is sparse
4
+ // for title stability: first idle of a new session, first newly-sent reply on a
5
+ // fork, plus explicit smart-title / forced refresh. Continuing the same work
5
6
  // should keep naming the thing being done — not "commit and push". This
6
7
  // watcher throttles to at most one refresh per session every TITLE_THROTTLE_MS
7
8
  // and never overwrites a user-renamed title.
8
9
  //
9
- // Purely event-driven: only sessions that transition busy→idle while the
10
- // server is running ever generate anything. No backfill, no session scans.
10
+ // Purely event-driven: only sessions that arm a refresh while the server is
11
+ // running ever generate anything. No backfill, no session scans.
11
12
 
12
13
  import fs from 'fs';
13
14
  import os from 'os';
@@ -742,8 +743,6 @@ export const createSessionTitleRuntime = ({
742
743
  if (newSessions.delete(status.sessionId)) {
743
744
  initialRefreshes.add(status.sessionId);
744
745
  armTimer(status.sessionId, status.directory || directoryHint, 0);
745
- } else {
746
- armTimer(status.sessionId, status.directory || directoryHint, quietMs);
747
746
  }
748
747
  } else {
749
748
  clearTimer(status.sessionId);
@@ -403,4 +403,229 @@ describe('session-title helpers', () => {
403
403
  globalThis.fetch = originalFetch;
404
404
  }
405
405
  });
406
+
407
+ it('refreshes a new session on first idle, then ignores later idle transitions', async () => {
408
+ const originalFetch = globalThis.fetch;
409
+ const session = {
410
+ id: 'ses-new',
411
+ title: 'New session - 2026-07-10T12:00:00.000Z',
412
+ metadata: {},
413
+ };
414
+ const messages = [
415
+ { info: { id: 'u1', role: 'user' }, parts: [{ type: 'text', text: 'Add rate limiting' }] },
416
+ { info: { id: 'a1', role: 'assistant' }, parts: [{ type: 'text', text: 'Implemented rate limit' }] },
417
+ ];
418
+ let generationCalls = 0;
419
+ let patchedTitle = session.title;
420
+
421
+ globalThis.fetch = async (input, init = {}) => {
422
+ const url = String(input);
423
+ if (url.includes('/message')) {
424
+ return new Response(JSON.stringify(messages), { status: 200 });
425
+ }
426
+ if (init.method === 'PATCH') {
427
+ const body = typeof init.body === 'string' ? JSON.parse(init.body) : init.body;
428
+ if (typeof body?.title === 'string') {
429
+ patchedTitle = body.title;
430
+ session.title = body.title;
431
+ session.metadata = body.metadata || session.metadata;
432
+ } else if (body?.metadata) {
433
+ session.metadata = body.metadata;
434
+ }
435
+ return new Response(JSON.stringify(session), { status: 200 });
436
+ }
437
+ return new Response(JSON.stringify({ ...session, title: patchedTitle }), { status: 200 });
438
+ };
439
+
440
+ try {
441
+ const runtime = createSessionTitleRuntime({
442
+ buildOpenCodeUrl: (pathname) => `http://opencode${pathname}`,
443
+ getOpenCodeAuthHeaders: () => ({}),
444
+ getSmallModelService: async () => ({
445
+ generateSmallModelText: async () => {
446
+ generationCalls += 1;
447
+ return { text: 'Rate limiting', providerID: 'test', modelID: 'test' };
448
+ },
449
+ }),
450
+ now: () => 1_000,
451
+ isTitleRefreshEnabled: () => true,
452
+ });
453
+
454
+ runtime.processPayload({
455
+ type: 'session.created',
456
+ properties: {
457
+ directory: '/repo',
458
+ info: { id: 'ses-new', title: session.title, time: { created: 100 } },
459
+ },
460
+ });
461
+ runtime.processPayload({
462
+ type: 'session.status',
463
+ properties: { sessionID: 'ses-new', directory: '/repo', status: { type: 'idle' } },
464
+ });
465
+ await new Promise((resolve) => setTimeout(resolve, 25));
466
+ expect(generationCalls).toBe(1);
467
+
468
+ // Later conversation progress + idle must not auto-refresh again.
469
+ messages.push(
470
+ { info: { id: 'u2', role: 'user' }, parts: [{ type: 'text', text: 'Also add retries' }] },
471
+ { info: { id: 'a2', role: 'assistant' }, parts: [{ type: 'text', text: 'Retries added' }] },
472
+ );
473
+ runtime.processPayload({
474
+ type: 'message.updated',
475
+ properties: { directory: '/repo', info: { sessionID: 'ses-new', role: 'user', time: { created: 200 } } },
476
+ });
477
+ runtime.processPayload({
478
+ type: 'session.status',
479
+ properties: { sessionID: 'ses-new', directory: '/repo', status: { type: 'busy' } },
480
+ });
481
+ runtime.processPayload({
482
+ type: 'session.status',
483
+ properties: { sessionID: 'ses-new', directory: '/repo', status: { type: 'idle' } },
484
+ });
485
+ await new Promise((resolve) => setTimeout(resolve, 25));
486
+ expect(generationCalls).toBe(1);
487
+
488
+ runtime.stop();
489
+ } finally {
490
+ globalThis.fetch = originalFetch;
491
+ }
492
+ });
493
+
494
+ it('still runs an explicit smart-title forced refresh after initial title', async () => {
495
+ const originalFetch = globalThis.fetch;
496
+ const session = {
497
+ id: 'ses-forced',
498
+ title: 'Rate limiting',
499
+ metadata: {
500
+ openchamber: {
501
+ titleRefresh: {
502
+ lastAutoTitle: 'Rate limiting',
503
+ generatedAt: 500,
504
+ forMessageID: 'a1',
505
+ },
506
+ },
507
+ },
508
+ };
509
+ const messages = [
510
+ { info: { id: 'u1', role: 'user' }, parts: [{ type: 'text', text: 'Add rate limiting' }] },
511
+ { info: { id: 'a1', role: 'assistant' }, parts: [{ type: 'text', text: 'Implemented rate limit' }] },
512
+ { info: { id: 'u2', role: 'user' }, parts: [{ type: 'text', text: 'Switch to token bucket' }] },
513
+ { info: { id: 'a2', role: 'assistant' }, parts: [{ type: 'text', text: 'Token bucket ready' }] },
514
+ ];
515
+ let generationCalls = 0;
516
+
517
+ globalThis.fetch = async (input, init = {}) => {
518
+ const url = String(input);
519
+ if (url.includes('/message')) {
520
+ return new Response(JSON.stringify(messages), { status: 200 });
521
+ }
522
+ if (init.method === 'PATCH') {
523
+ return new Response(JSON.stringify(session), { status: 200 });
524
+ }
525
+ return new Response(JSON.stringify(session), { status: 200 });
526
+ };
527
+
528
+ try {
529
+ const runtime = createSessionTitleRuntime({
530
+ buildOpenCodeUrl: (pathname) => `http://opencode${pathname}`,
531
+ getOpenCodeAuthHeaders: () => ({}),
532
+ getSmallModelService: async () => ({
533
+ generateSmallModelText: async () => {
534
+ generationCalls += 1;
535
+ return { text: 'Token bucket rate limiting', providerID: 'test', modelID: 'test' };
536
+ },
537
+ }),
538
+ now: () => 1_000,
539
+ isTitleRefreshEnabled: () => true,
540
+ });
541
+
542
+ runtime.processPayload({
543
+ type: 'session.status',
544
+ properties: { sessionID: 'ses-forced', directory: '/repo', status: { type: 'idle' } },
545
+ });
546
+ await new Promise((resolve) => setTimeout(resolve, 25));
547
+ expect(generationCalls).toBe(0);
548
+
549
+ runtime.processPayload({
550
+ type: 'session.updated',
551
+ properties: {
552
+ directory: '/repo',
553
+ info: {
554
+ id: 'ses-forced',
555
+ title: session.title,
556
+ metadata: {
557
+ openchamber: {
558
+ titleRefresh: {
559
+ ...session.metadata.openchamber.titleRefresh,
560
+ requestedAt: 1_000,
561
+ },
562
+ },
563
+ },
564
+ },
565
+ },
566
+ });
567
+ await new Promise((resolve) => setTimeout(resolve, 25));
568
+ expect(generationCalls).toBe(1);
569
+
570
+ runtime.stop();
571
+ } finally {
572
+ globalThis.fetch = originalFetch;
573
+ }
574
+ });
575
+
576
+ it('skips automatic initial refresh when session title refresh is disabled', async () => {
577
+ const originalFetch = globalThis.fetch;
578
+ const session = {
579
+ id: 'ses-disabled',
580
+ title: 'New session - 2026-07-10T12:00:00.000Z',
581
+ metadata: {},
582
+ };
583
+ let generationCalls = 0;
584
+
585
+ globalThis.fetch = async (input, init = {}) => {
586
+ if (String(input).includes('/message')) {
587
+ return new Response(JSON.stringify([
588
+ { info: { id: 'u1', role: 'user' }, parts: [{ type: 'text', text: 'hello' }] },
589
+ { info: { id: 'a1', role: 'assistant' }, parts: [{ type: 'text', text: 'world' }] },
590
+ ]), { status: 200 });
591
+ }
592
+ if (init.method === 'PATCH') {
593
+ return new Response(JSON.stringify(session), { status: 200 });
594
+ }
595
+ return new Response(JSON.stringify(session), { status: 200 });
596
+ };
597
+
598
+ try {
599
+ const runtime = createSessionTitleRuntime({
600
+ buildOpenCodeUrl: (pathname) => `http://opencode${pathname}`,
601
+ getOpenCodeAuthHeaders: () => ({}),
602
+ getSmallModelService: async () => ({
603
+ generateSmallModelText: async () => {
604
+ generationCalls += 1;
605
+ return { text: 'Should not run', providerID: 'test', modelID: 'test' };
606
+ },
607
+ }),
608
+ now: () => 1_000,
609
+ isTitleRefreshEnabled: () => false,
610
+ });
611
+
612
+ runtime.processPayload({
613
+ type: 'session.created',
614
+ properties: {
615
+ directory: '/repo',
616
+ info: { id: 'ses-disabled', title: session.title, time: { created: 100 } },
617
+ },
618
+ });
619
+ runtime.processPayload({
620
+ type: 'session.status',
621
+ properties: { sessionID: 'ses-disabled', directory: '/repo', status: { type: 'idle' } },
622
+ });
623
+ await new Promise((resolve) => setTimeout(resolve, 25));
624
+ expect(generationCalls).toBe(0);
625
+
626
+ runtime.stop();
627
+ } finally {
628
+ globalThis.fetch = originalFetch;
629
+ }
630
+ });
406
631
  });