@openchambery/web 1.18.4-beta.9 → 1.18.5

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-n8llxdCo.js +1 -0
  3. package/dist/assets/{ChatContainer-CSHDg3oS.js → ChatContainer-B7H_fTkm.js} +35 -35
  4. package/dist/assets/{DiagramView-DUciKofo.js → DiagramView-DK5RkG9r.js} +1 -1
  5. package/dist/assets/{FadeInOnReveal-QxF1vuqX.js → FadeInOnReveal-By2F9vcI.js} +1 -1
  6. package/dist/assets/{Icon-D-X70irR.js → Icon-DpjYnXxn.js} +1 -1
  7. package/dist/assets/{MarkdownRendererImpl-BjAOpViW.js → MarkdownRendererImpl-DOpja779.js} +1 -1
  8. package/dist/assets/{MobileDetailNavigation-EXZlPK6E.js → MobileDetailNavigation-k1tBlKW5.js} +1 -1
  9. package/dist/assets/{MobileShareBridge-CevrYlt3.js → MobileShareBridge-HxyfCuga.js} +3 -3
  10. package/dist/assets/MobileSurface-BRouCuNF.js +2 -0
  11. package/dist/assets/{MultiRunWindow-CNWuOY4y.js → MultiRunWindow-CwzxTb3p.js} +1 -1
  12. package/dist/assets/{OnboardingScreen-BhSzVmYo.js → OnboardingScreen-BKmuT7AL.js} +1 -1
  13. package/dist/assets/{OverlayScrollbar-CHXk_jvF.js → OverlayScrollbar-Ch3J6ASR.js} +1 -1
  14. package/dist/assets/{SettingsView-YyQ-C-qq.js → SettingsView-Bx9tuWxH.js} +11 -11
  15. package/dist/assets/{SettingsWindow-uUezVVwi.js → SettingsWindow-sVzjzaGh.js} +1 -1
  16. package/dist/assets/{TerminalView-DPto-DoO.js → TerminalView-DSwK1UwL.js} +1 -1
  17. package/dist/assets/{ToolOutputDialog-CgPYRmDX.js → ToolOutputDialog-D9di4LaC.js} +1 -1
  18. package/dist/assets/appThemeRegistry-CN1tLL9G.js +2 -0
  19. package/dist/assets/{button-CwLFtUNT.js → button-C7OXr4ae.js} +1 -1
  20. package/dist/assets/{clipboard-DhOMUCwT.js → clipboard-DWuULPzo.js} +1 -1
  21. package/dist/assets/{collapsible-CP1-_pY6.js → collapsible-DaEya6b6.js} +1 -1
  22. package/dist/assets/{context-menu-BPxTMlbo.js → context-menu-BrLPLxWa.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/{diagram-rYL80jaS.js → diagram-CHfginjS.js} +1 -1
  26. package/dist/assets/{dialog-BoAAoL5Z.js → dialog-DPS0W9eS.js} +1 -1
  27. package/dist/assets/{es-BXQb1dPR.js → es-BX0Vm3Ac.js} +2 -2
  28. package/dist/assets/fileQueries-BrUKKgPW.js +1 -0
  29. package/dist/assets/{fr-BTyDIvxw.js → fr-CuPzXTYx.js} +2 -2
  30. package/dist/assets/{gitApi-BktvSPdR.js → gitApi-C_tpQa_z.js} +2 -2
  31. package/dist/assets/{input-D52Jicb1.js → input-BzYA603C.js} +1 -1
  32. package/dist/assets/insertionBoundaries-CzVPcHK4.js +1 -0
  33. package/dist/assets/{ja-fcQgtBFy.js → ja-DGtzjrFO.js} +2 -2
  34. package/dist/assets/{ko-CgoVt1eb.js → ko-DZ9Di0je.js} +2 -2
  35. package/dist/assets/{main-DOGudNxM.js → main-BJBQp1tB.js} +2 -2
  36. package/dist/assets/{main-UJICKUVV.js → main-BUGDfAIY.js} +23 -23
  37. package/dist/assets/{markdown-worker-N9d_Z-7s.js → markdown-worker-W06-MhF5.js} +1 -1
  38. package/dist/assets/{markdownTheme-Bl0Sy0Lf.js → markdownTheme-B89dbj3N.js} +1 -1
  39. package/dist/assets/{miniChat-C465ME90.js → miniChat-BLtGuyQZ.js} +2 -2
  40. package/dist/assets/mobile-0Sb3IsCl.js +2 -0
  41. package/dist/assets/multirun-BC-EFVZ0.js +4 -0
  42. package/dist/assets/{persistence-vZ7H8WKh.js → persistence-6sclr7B6.js} +2 -2
  43. package/dist/assets/{pl-DVDnk2fT.js → pl-BuCO3qb1.js} +2 -2
  44. package/dist/assets/{platform-CTaznShj.js → platform-CbeLpKPk.js} +1 -1
  45. package/dist/assets/projectMeta-CYvQd9iC.js +3 -0
  46. package/dist/assets/{pt-BR-ofV_Qcd_.js → pt-BR-Brf4RMsw.js} +2 -2
  47. package/dist/assets/radio-CEt2UjvA.js +1 -0
  48. package/dist/assets/renderElectronMiniChatApp-ByA4BElP.js +2 -0
  49. package/dist/assets/renderMobileApp-upKrXoPH.js +23 -0
  50. package/dist/assets/{runtimeConfig-Dofg9S27.js → runtimeConfig-BAx9ix2e.js} +1 -1
  51. package/dist/assets/runtimeEndpointReset-Dst0Yqyz.js +147 -0
  52. package/dist/assets/{sortable-tabs-strip-N3j5So4j.js → sortable-tabs-strip-nLsPC06f.js} +1 -1
  53. package/dist/assets/src-BDOLh3pc.css +2 -0
  54. package/dist/assets/{store-Hr-xvtDJ.js → store-DQY8Fkb0.js} +3 -3
  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-Dzob0yMx.js → uk-C-wmaPTi.js} +2 -2
  58. package/dist/assets/{url-B-FWrnxu.js → url-BsaBG5-1.js} +1 -1
  59. package/dist/assets/useAppFontEffects-Bo__MTWV.js +7 -0
  60. package/dist/assets/{useAssistantUIStore-ifqNyja4.js → useAssistantUIStore-BHsHXCKE.js} +1 -1
  61. package/dist/assets/{useDesktopWindowControlsLayout-CGYSjr_T.js → useDesktopWindowControlsLayout-DkmRLrOT.js} +1 -1
  62. package/dist/assets/{useEffectiveDirectory-BtkZPQZ2.js → useEffectiveDirectory-ByQPsbk0.js} +1 -1
  63. package/dist/assets/{useFontPreferences-D_LFFhFS.js → useFontPreferences-ckzpXQaJ.js} +1 -1
  64. package/dist/assets/useMobileNavigationStore-DLYb8f5L.js +475 -0
  65. package/dist/assets/useSessionAutoCleanup-C5knpuh5.js +1 -0
  66. package/dist/assets/useUIStore-DzwCfRqr.js +1 -0
  67. package/dist/assets/{useWorkerHighlightedLines-Bo9meAHp.js → useWorkerHighlightedLines-Ce2Bs8_N.js} +1 -1
  68. package/dist/assets/{utils-C2P0wcWO.js → utils-B1EK5zKa.js} +1 -1
  69. package/dist/assets/{zh-CN-Sndj2nhS.js → zh-CN-DueRrIql.js} +2 -2
  70. package/dist/assets/{zh-TW-S24a8EMD.js → zh-TW-6875X80V.js} +2 -2
  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-assist/DOCUMENTATION.md +4 -2
  83. package/server/lib/session-assist/runtime.js +67 -30
  84. package/server/lib/session-assist/runtime.test.js +114 -0
  85. package/server/lib/session-index/DOCUMENTATION.md +9 -3
  86. package/server/lib/session-index/service.js +45 -22
  87. package/server/lib/session-index/service.test.js +81 -0
  88. package/server/lib/session-title/DOCUMENTATION.md +15 -10
  89. package/server/lib/session-title/runtime.js +24 -0
  90. package/server/lib/session-title/runtime.test.js +143 -0
  91. package/server/lib/session-turn-pages/DOCUMENTATION.md +2 -1
  92. package/server/lib/session-turn-pages/service.js +14 -1
  93. package/server/lib/session-turn-pages/service.test.js +133 -0
  94. package/server/lib/ui-auth/ui-auth.js +3 -1
  95. package/server/lib/ui-auth/ui-auth.test.js +25 -0
  96. package/server/sse-routes.test.js +64 -1
  97. package/dist/assets/AssistantView-CbCe7NJG.js +0 -1
  98. package/dist/assets/MobileSurface-cYYd428z.js +0 -2
  99. package/dist/assets/appThemeRegistry-CPVFQqR9.js +0 -2
  100. package/dist/assets/desktopRelayRestore-VfMK9fdk.js +0 -1
  101. package/dist/assets/fileQueries-CXaw4b1W.js +0 -1
  102. package/dist/assets/insertionBoundaries-Cqk1aLwK.js +0 -1
  103. package/dist/assets/mobile-BHRw1m5c.js +0 -2
  104. package/dist/assets/multirun-BCvIqAk-.js +0 -4
  105. package/dist/assets/projectMeta-Wxow1DQz.js +0 -3
  106. package/dist/assets/radio-CS9DITGQ.js +0 -1
  107. package/dist/assets/renderElectronMiniChatApp-DAIgdBk9.js +0 -2
  108. package/dist/assets/renderMobileApp-BXOtgQHy.js +0 -23
  109. package/dist/assets/runtimeEndpointReset-Cc3yypUQ.js +0 -147
  110. package/dist/assets/src-Dbdlm5x1.css +0 -2
  111. package/dist/assets/useAppFontEffects-BpT_sNyZ.js +0 -7
  112. package/dist/assets/useMobileNavigationStore-BNnnW08z.js +0 -476
  113. package/dist/assets/useSessionAutoCleanup-C5V-GCiC.js +0 -1
  114. package/dist/assets/useUIStore-NfqK-HA7.js +0 -1
@@ -33,6 +33,8 @@ const isSessionRecapEnabled = () => {
33
33
 
34
34
  const IDLE_QUIET_MS = 60_000;
35
35
  const TRANSCRIPT_MESSAGE_LIMIT = 12;
36
+ /** Latest real user messages fed to the recap prompt. */
37
+ const TRANSCRIPT_USER_MESSAGE_LIMIT = 3;
36
38
  const TRANSCRIPT_PART_CHAR_LIMIT = 6_000;
37
39
  const RECAP_CHAR_LIMIT = 320;
38
40
  const FETCH_TIMEOUT_MS = 5_000;
@@ -92,13 +94,63 @@ const extractUserMessage = (payload) => {
92
94
  };
93
95
  };
94
96
 
95
- const messagePartsToText = (message) => {
97
+ /** Real (non-synthetic) text parts of a message — hidden instructions excluded. */
98
+ const realTextParts = (message) => {
96
99
  const parts = Array.isArray(message?.parts) ? message.parts : [];
97
100
  return parts
98
- .map((part) => (part?.type === 'text' && typeof part.text === 'string' ? part.text : ''))
99
- .filter(Boolean)
100
- .join('\n')
101
- .slice(0, TRANSCRIPT_PART_CHAR_LIMIT);
101
+ .filter((part) => part?.type === 'text'
102
+ && typeof part.text === 'string'
103
+ && part.text.trim().length > 0
104
+ && part.synthetic !== true)
105
+ .map((part) => part.text);
106
+ };
107
+
108
+ /** Body text of a message: only its LAST text block, char-clamped. */
109
+ const lastMessageText = (message) => {
110
+ const texts = realTextParts(message);
111
+ return texts.length > 0 ? texts[texts.length - 1].slice(0, TRANSCRIPT_PART_CHAR_LIMIT) : '';
112
+ };
113
+
114
+ /**
115
+ * Build the recap transcript from a bounded recent-message window.
116
+ * - Assistant: only the LAST text block of the last assistant message —
117
+ * earlier blocks, reasoning, and tool output are token waste for a
118
+ * one-line recap.
119
+ * - User: the latest real user messages (default 3), chronological.
120
+ */
121
+ export const buildAssistTranscript = (
122
+ messages,
123
+ { userLimit = TRANSCRIPT_USER_MESSAGE_LIMIT } = {},
124
+ ) => {
125
+ if (!Array.isArray(messages) || messages.length === 0) {
126
+ return { transcript: '', assistantInfo: null, userTexts: [], assistantText: '' };
127
+ }
128
+ let lastAssistant = null;
129
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
130
+ if (messages[i]?.info?.role === 'assistant') {
131
+ lastAssistant = messages[i];
132
+ break;
133
+ }
134
+ }
135
+ const lastAssistantInfo = lastAssistant?.info ?? null;
136
+ if (!lastAssistantInfo?.id) {
137
+ return { transcript: '', assistantInfo: null, userTexts: [], assistantText: '' };
138
+ }
139
+
140
+ const userTexts = [];
141
+ for (let i = messages.length - 1; i >= 0 && userTexts.length < userLimit; i -= 1) {
142
+ const message = messages[i];
143
+ if (message?.info?.role !== 'user') continue;
144
+ const text = lastMessageText(message);
145
+ if (!text) continue;
146
+ userTexts.unshift(text);
147
+ }
148
+ const assistantText = lastMessageText(lastAssistant);
149
+ const transcript = [
150
+ ...userTexts.map((text) => `User:\n${text}`),
151
+ assistantText ? `Assistant:\n${assistantText}` : '',
152
+ ].filter(Boolean).join('\n\n');
153
+ return { transcript, assistantInfo: lastAssistantInfo, userTexts, assistantText };
102
154
  };
103
155
 
104
156
  export const createSessionAssistRuntime = ({
@@ -169,36 +221,21 @@ export const createSessionAssistRuntime = ({
169
221
  return;
170
222
  }
171
223
 
172
- let lastAssistant = null;
173
- for (let i = messages.length - 1; i >= 0; i -= 1) {
174
- const info = messages[i]?.info;
175
- if (info?.role === 'assistant') {
176
- lastAssistant = messages[i];
177
- break;
178
- }
179
- }
180
- const lastAssistantInfo = lastAssistant?.info;
224
+ // Trimmed extraction for a one-line recap: the last assistant text block
225
+ // plus the latest real user messages. Everything else (earlier blocks,
226
+ // reasoning, tool output, older turns) is token waste.
227
+ const { transcript, assistantInfo: lastAssistantInfo, userTexts, assistantText } = buildAssistTranscript(messages);
181
228
  if (!lastAssistantInfo?.id) return;
182
-
183
- // Only the last exchange: the assistant reply plus the user message it
184
- // answered (assistant info.parentID → user info.id). Everything else is
185
- // token waste for a one-line recap.
186
- const parentUserMessage = typeof lastAssistantInfo.parentID === 'string' && lastAssistantInfo.parentID
187
- ? messages.find((message) => message?.info?.id === lastAssistantInfo.parentID && message?.info?.role === 'user')
188
- : null;
189
- const userText = parentUserMessage ? messagePartsToText(parentUserMessage) : '';
190
- const assistantText = messagePartsToText(lastAssistant);
191
- const transcript = [
192
- userText ? `User:\n${userText}` : '',
193
- assistantText ? `Assistant:\n${assistantText}` : '',
194
- ].filter(Boolean).join('\n\n');
195
229
  if (!transcript) return;
196
230
 
197
231
  const { generateSmallModelText } = await getSmallModelService();
198
232
  // Instruct the language by example, not by description — account-side
199
233
  // personalization (e.g. the ChatGPT backend knowing the user's locale)
200
234
  // otherwise leaks a different language into the output.
201
- const languageSample = (userText || assistantText).slice(0, 200).replace(/\s+/g, ' ').trim();
235
+ const languageSample = (userTexts[userTexts.length - 1] || assistantText)
236
+ .slice(0, 200)
237
+ .replace(/\s+/g, ' ')
238
+ .trim();
202
239
  let generated;
203
240
  try {
204
241
  generated = await generateSmallModelText({
@@ -206,7 +243,7 @@ export const createSessionAssistRuntime = ({
206
243
  // session's own provider unless the user explicitly picked a small
207
244
  // model (settings override / opencode config).
208
245
  restrictToPreferredProvider: true,
209
- prompt: `The latest exchange in the conversation:\n\n${transcript}\n\nWrite recap in the SAME language as this sample from the conversation: "${languageSample}"`,
246
+ prompt: `The latest conversation excerpt:\n\n${transcript}\n\nWrite recap in the SAME language as this sample from the conversation: "${languageSample}"`,
210
247
  system: ASSIST_SYSTEM_PROMPT,
211
248
  directory,
212
249
  preferredProviderID: typeof lastAssistantInfo.providerID === 'string' ? lastAssistantInfo.providerID : undefined,
@@ -228,7 +265,7 @@ export const createSessionAssistRuntime = ({
228
265
  // no Cyrillic/CJK at all, the output must not either.
229
266
  const hasCyrillic = (text) => /[\u0400-\u04FF]/.test(text);
230
267
  const hasCjk = (text) => /[\u3040-\u30FF\u4E00-\u9FFF\uAC00-\uD7AF]/.test(text);
231
- const inputText = `${userText}\n${assistantText}`;
268
+ const inputText = `${userTexts.join('\n')}\n${assistantText}`;
232
269
  const scriptMismatch = (text) => (hasCyrillic(text) && !hasCyrillic(inputText))
233
270
  || (hasCjk(text) && !hasCjk(inputText));
234
271
  if (recap && scriptMismatch(recap)) {
@@ -0,0 +1,114 @@
1
+ import { describe, expect, test } from 'vitest';
2
+ import { buildAssistTranscript } from './runtime.js';
3
+
4
+ const textPart = (text, { synthetic = false } = {}) => ({
5
+ type: 'text',
6
+ text,
7
+ ...(synthetic ? { synthetic: true } : {}),
8
+ });
9
+
10
+ const userMessage = (id, parts) => ({ info: { id, role: 'user' }, parts });
11
+ const assistantMessage = (id, parts) => ({ info: { id, role: 'assistant' }, parts });
12
+
13
+ describe('buildAssistTranscript', () => {
14
+ test('assistant keeps only the LAST text block of the last assistant message', () => {
15
+ const result = buildAssistTranscript([
16
+ userMessage('u1', [textPart('first user message')]),
17
+ assistantMessage('a1', [textPart('old assistant reply')]),
18
+ userMessage('u2', [textPart('second user message')]),
19
+ assistantMessage('a2', [
20
+ textPart('intro before tools'),
21
+ { type: 'tool', tool: 'bash' },
22
+ textPart('final body text'),
23
+ ]),
24
+ ]);
25
+ expect(result.assistantInfo.id).toBe('a2');
26
+ expect(result.assistantText).toBe('final body text');
27
+ expect(result.transcript).toBe(
28
+ 'User:\nfirst user message\n\nUser:\nsecond user message\n\nAssistant:\nfinal body text',
29
+ );
30
+ });
31
+
32
+ test('reasoning parts never enter the transcript', () => {
33
+ const result = buildAssistTranscript([
34
+ userMessage('u1', [textPart('请帮我看看')]),
35
+ assistantMessage('a1', [
36
+ { type: 'reasoning', text: 'thinking about the problem' },
37
+ textPart('最终答复'),
38
+ ]),
39
+ ]);
40
+ expect(result.transcript).toBe('User:\n请帮我看看\n\nAssistant:\n最终答复');
41
+ expect(result.transcript).not.toContain('thinking');
42
+ });
43
+
44
+ test('user side keeps only the latest 3 real user messages, chronologically', () => {
45
+ const result = buildAssistTranscript([
46
+ userMessage('u1', [textPart('question 1')]),
47
+ assistantMessage('a1', [textPart('answer 1')]),
48
+ userMessage('u2', [textPart('question 2')]),
49
+ assistantMessage('a2', [textPart('answer 2')]),
50
+ userMessage('u3', [textPart('question 3')]),
51
+ userMessage('u4', [textPart('question 4')]),
52
+ assistantMessage('a3', [textPart('answer 3')]),
53
+ ]);
54
+ expect(result.userTexts).toEqual(['question 2', 'question 3', 'question 4']);
55
+ expect(result.transcript).toBe(
56
+ 'User:\nquestion 2\n\nUser:\nquestion 3\n\nUser:\nquestion 4\n\nAssistant:\nanswer 3',
57
+ );
58
+ });
59
+
60
+ test('synthetic-only user messages do not count toward the user window', () => {
61
+ const result = buildAssistTranscript([
62
+ userMessage('u1', [textPart('visible request')]),
63
+ userMessage('u2', [textPart('hidden instruction', { synthetic: true })]),
64
+ userMessage('u3', [textPart('real follow-up')]),
65
+ assistantMessage('a1', [textPart('reply body')]),
66
+ ]);
67
+ expect(result.userTexts).toEqual(['visible request', 'real follow-up']);
68
+ expect(result.transcript).not.toContain('hidden instruction');
69
+ });
70
+
71
+ test('synthetic instruction part on a real user message is dropped', () => {
72
+ const result = buildAssistTranscript([
73
+ userMessage('u1', [
74
+ textPart('visible request'),
75
+ textPart('/summary hidden instructions', { synthetic: true }),
76
+ ]),
77
+ assistantMessage('a1', [textPart('reply body')]),
78
+ ]);
79
+ expect(result.userTexts).toEqual(['visible request']);
80
+ expect(result.transcript).not.toContain('/summary hidden instructions');
81
+ });
82
+
83
+ test('fewer than 3 user messages keeps all of them', () => {
84
+ const result = buildAssistTranscript([
85
+ userMessage('u1', [textPart('only question')]),
86
+ assistantMessage('a1', [textPart('only answer')]),
87
+ ]);
88
+ expect(result.userTexts).toEqual(['only question']);
89
+ expect(result.transcript).toBe('User:\nonly question\n\nAssistant:\nonly answer');
90
+ });
91
+
92
+ test('no assistant message yields an empty transcript', () => {
93
+ const result = buildAssistTranscript([
94
+ userMessage('u1', [textPart('unanswered question')]),
95
+ ]);
96
+ expect(result.transcript).toBe('');
97
+ expect(result.assistantInfo).toBeNull();
98
+ expect(result.userTexts).toEqual([]);
99
+ });
100
+
101
+ test('assistant with no text parts still summarizes the user side', () => {
102
+ const result = buildAssistTranscript([
103
+ userMessage('u1', [textPart('question')]),
104
+ assistantMessage('a1', [{ type: 'tool', tool: 'bash' }]),
105
+ ]);
106
+ expect(result.assistantInfo.id).toBe('a1');
107
+ expect(result.transcript).toBe('User:\nquestion');
108
+ });
109
+
110
+ test('empty input yields an empty transcript', () => {
111
+ expect(buildAssistTranscript([]).transcript).toBe('');
112
+ expect(buildAssistTranscript(null).transcript).toBe('');
113
+ });
114
+ });
@@ -20,13 +20,19 @@ the index excludes them from every snapshot and clears prior summaries when a
20
20
  `metadata.openchamber.smallModel.purpose`. Title prefixes are human labels and
21
21
  never participate in this filter. Metadata is the ownership/isolation signal;
22
22
  `time.archived` is archive state; `time.pinned` is OpenChamber-owned pin state
23
- (persisted as `pinned_at`, rescued across directory rebuilds, never overwritten
24
- by live OpenCode upserts); titles are for recognition. The index does
23
+ overlaid from the independent `session_pin` table (also mirrored onto in-window
24
+ `session_summary.pinned_at` for older readers). Pins are not subject to the
25
+ newest-20 summary bound: evicting or rebuilding a directory keeps pin
26
+ membership, and `setPinned` can target a session that is not currently in the
27
+ index. Archive or `remove` still clears the pin. Live OpenCode upserts never
28
+ overwrite pin membership. Titles are for recognition. The index does
25
29
  not cache system sessions, so cold-start and live upserts stay aligned with
26
30
  ordinary sidebar lists.
27
31
  Pin and unpin use `POST` / `DELETE`
28
- `/api/openchamber/session-index/session/:id/pin`, update `pinned_at`, then
32
+ `/api/openchamber/session-index/session/:id/pin`, write `session_pin`, then
29
33
  `publishChange()` so revision tips broadcast `openchamber:session-index-changed`.
34
+ Snapshots include `pinnedSessionIds` plus `time.pinned` on any matching
35
+ summary row.
30
36
 
31
37
  The server-side global OpenCode event subscriber writes session summary events
32
38
  directly into this index. User `message.updated` events and `session.idle`
@@ -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;