@openchambery/web 1.18.2-beta.8 → 1.18.3-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 (149) hide show
  1. package/dist/assets/AgentAvatar-BvhXxFvg.js +1 -0
  2. package/dist/assets/AssistantView-Cqqwrumr.js +1 -0
  3. package/dist/assets/{ChatContainer-CReoQTua.js → ChatContainer-DmDmufVy.js} +43 -43
  4. package/dist/assets/{DiagramView-D_WMXwYz.js → DiagramView-BdutEd2B.js} +1 -1
  5. package/dist/assets/{FadeInOnReveal-oeyB_nIi.js → FadeInOnReveal-98buIVdv.js} +1 -1
  6. package/dist/assets/{Icon-DHaR0peD.js → Icon-BhSwbLaF.js} +1 -1
  7. package/dist/assets/{MarkdownRendererImpl-DhYt2Oq2.js → MarkdownRendererImpl-D3rG27vo.js} +2 -2
  8. package/dist/assets/{MobileDetailNavigation-CgiLWb4L.js → MobileDetailNavigation-8Na-l3IA.js} +1 -1
  9. package/dist/assets/MobileShareBridge-BJNmoLdX.js +4 -0
  10. package/dist/assets/MobileSurface-DtB0737A.js +2 -0
  11. package/dist/assets/{MultiRunWindow-BUsyw0Ws.js → MultiRunWindow-BaOXHVax.js} +1 -1
  12. package/dist/assets/{OnboardingScreen-BZHN7C2r.js → OnboardingScreen-D4jsavBK.js} +1 -1
  13. package/dist/assets/OverlayScrollbar-CXFN6Cqq.js +2 -0
  14. package/dist/assets/SettingsView-DGh9nGVd.js +25 -0
  15. package/dist/assets/{SettingsWindow-CAW6MEmX.js → SettingsWindow-BAdWKqH6.js} +1 -1
  16. package/dist/assets/{TerminalView-BlHms0VM.js → TerminalView-BwsbvoCj.js} +1 -1
  17. package/dist/assets/{ToolOutputDialog-C6tIj91N.js → ToolOutputDialog-p7xe_D4A.js} +1 -1
  18. package/dist/assets/appThemeRegistry-CSZVlqLO.js +2 -0
  19. package/dist/assets/assistant-guide-hero-wide-DiFIThB0.jpg +0 -0
  20. package/dist/assets/{button-DCUMIXDC.js → button-BQY5ugjh.js} +1 -1
  21. package/dist/assets/capgoAdapter-DCowZ2ab.js +1 -0
  22. package/dist/assets/{clipboard-DF5tFLDb.js → clipboard-CSaKrCWk.js} +1 -1
  23. package/dist/assets/{collapsible-DNg5bLKV.js → collapsible-CqcZGw3W.js} +1 -1
  24. package/dist/assets/{context-menu-DpH6U8oW.js → context-menu-zEQM3m-S.js} +1 -1
  25. package/dist/assets/{desktopHosts-DSgGxDpI.js → desktopHosts-aA40MHzB.js} +1 -1
  26. package/dist/assets/{desktopRelayRestore-DVIx7jsz.js → desktopRelayRestore-CxUYRC6v.js} +1 -1
  27. package/dist/assets/{diagram-cjvFqVa8.js → diagram-B8D9Klct.js} +1 -1
  28. package/dist/assets/{dialog-DaSIYgrj.js → dialog-DS65fQJH.js} +1 -1
  29. package/dist/assets/{es-DCoGZljP.js → es-CUu9UM-5.js} +4 -4
  30. package/dist/assets/{fileQueries-DXvhIL_U.js → fileQueries-BSE4ycQt.js} +1 -1
  31. package/dist/assets/{fr-dkEoZpPz.js → fr-D74z6dA0.js} +4 -4
  32. package/dist/assets/gitApi-DfM5TpNW.js +7 -0
  33. package/dist/assets/{input-QS3IWgY5.js → input-CgIoxUJc.js} +1 -1
  34. package/dist/assets/insertionBoundaries-BkDH-P9U.js +1 -0
  35. package/dist/assets/{ja-bQwSsvRr.js → ja-CEiZgxEa.js} +4 -4
  36. package/dist/assets/{ko-Kq5C3hsv.js → ko-BwT91AGB.js} +4 -4
  37. package/dist/assets/main-C8Y3zrIO.js +2 -0
  38. package/dist/assets/main-Cm4Spw2a.js +172 -0
  39. package/dist/assets/{markdown-worker-D-OR2e_G.js → markdown-worker-C5Om4TVK.js} +1 -1
  40. package/dist/assets/{markdownTheme-y_Rf6mLa.js → markdownTheme-BdF4C4-o.js} +1 -1
  41. package/dist/assets/miniChat-CZjvn38r.js +2 -0
  42. package/dist/assets/mobile-C3finp5s.js +2 -0
  43. package/dist/assets/multirun-RWCgJ2Sf.js +4 -0
  44. package/dist/assets/persistence-DwXJK4-j.js +3 -0
  45. package/dist/assets/pl-DxxarF_s.js +15 -0
  46. package/dist/assets/platform-CXJwZIZe.js +2 -0
  47. package/dist/assets/projectMeta-CI-agdGd.js +3 -0
  48. package/dist/assets/{pt-BR-MSPTLfTm.js → pt-BR-BKo7iaa0.js} +4 -4
  49. package/dist/assets/radio-DqwJGxRE.js +1 -0
  50. package/dist/assets/renderElectronMiniChatApp-BFQSHOJt.js +2 -0
  51. package/dist/assets/renderMobileApp-B0IBEDa2.js +23 -0
  52. package/dist/assets/runtimeConfig-B2b1-Y8N.js +2 -0
  53. package/dist/assets/runtimeEndpointReset-Ci4hHJWD.js +147 -0
  54. package/dist/assets/{sortable-tabs-strip-D2vXQoD8.js → sortable-tabs-strip-ZyYqprbu.js} +1 -1
  55. package/dist/assets/src-D8ZPwFWd.css +2 -0
  56. package/dist/assets/{store-OURVnLSN.js → store-BFYITbNu.js} +4 -4
  57. package/dist/assets/terminalApi-jfes-HIU.js +1 -0
  58. package/dist/assets/{tooltip-xEnco9xY.js → tooltip-rN8LrxMF.js} +1 -1
  59. package/dist/assets/types-BuiqMmh_.js +1 -0
  60. package/dist/assets/{uk-cSkRAYFY.js → uk-COMEIAWc.js} +4 -4
  61. package/dist/assets/{url-DIZgk80M.js → url-B0Ak7X6b.js} +1 -1
  62. package/dist/assets/useAppFontEffects-D4VpDoYq.js +7 -0
  63. package/dist/assets/{useAssistantUIStore-Bq3cPj2x.js → useAssistantUIStore-SPh025NE.js} +1 -1
  64. package/dist/assets/useDesktopWindowControlsLayout-CdQWzNss.js +1 -0
  65. package/dist/assets/useEffectiveDirectory-BsdXP5uD.js +1 -0
  66. package/dist/assets/{useFontPreferences-B-QHyR3C.js → useFontPreferences-Bz3crRRO.js} +1 -1
  67. package/dist/assets/useMobileNavigationStore-DUmxzvX1.js +476 -0
  68. package/dist/assets/useSessionAutoCleanup-DSAI3g76.js +1 -0
  69. package/dist/assets/{useUIStore-5agS8bsW.js → useUIStore-BJpkPwky.js} +1 -1
  70. package/dist/assets/{useWorkerHighlightedLines-Ds6gLNsj.js → useWorkerHighlightedLines-C3e2tW2K.js} +1 -1
  71. package/dist/assets/{utils-Dxsi3J3l.js → utils-DfPDhqys.js} +1 -1
  72. package/dist/assets/{zh-CN-BKqeT_uz.js → zh-CN-3PwbVrPm.js} +4 -4
  73. package/dist/assets/{zh-TW-BaNjJ8Y4.js → zh-TW-CU3zh3yX.js} +4 -4
  74. package/dist/index.html +13 -11
  75. package/dist/mini-chat.html +11 -9
  76. package/dist/mobile.html +11 -9
  77. package/package.json +1 -1
  78. package/server/index.js +7 -0
  79. package/server/lib/assistants/DOCUMENTATION.md +2 -2
  80. package/server/lib/assistants/history-state.js +23 -0
  81. package/server/lib/assistants/history-state.test.js +57 -0
  82. package/server/lib/assistants/service.js +44 -15
  83. package/server/lib/assistants/service.test.js +120 -5
  84. package/server/lib/event-stream/DOCUMENTATION.md +2 -2
  85. package/server/lib/event-stream/diff-summary.js +28 -19
  86. package/server/lib/event-stream/diff-summary.test.js +37 -10
  87. package/server/lib/event-stream/protocol.test.js +66 -3
  88. package/server/lib/message-queue/DOCUMENTATION.md +1 -1
  89. package/server/lib/notifications/DOCUMENTATION.md +2 -2
  90. package/server/lib/notifications/runtime.js +68 -27
  91. package/server/lib/notifications/runtime.test.js +176 -0
  92. package/server/lib/opencode/DOCUMENTATION.md +2 -2
  93. package/server/lib/opencode/settings-helpers.js +2 -2
  94. package/server/lib/opencode/settings-helpers.test.js +42 -0
  95. package/server/lib/opencode/settings-normalization-runtime.js +7 -2
  96. package/server/lib/session-index/DOCUMENTATION.md +12 -6
  97. package/server/lib/session-index/routes.js +34 -0
  98. package/server/lib/session-index/routes.test.js +76 -0
  99. package/server/lib/session-index/service.js +53 -4
  100. package/server/lib/session-index/service.test.js +72 -2
  101. package/server/lib/session-title/DOCUMENTATION.md +19 -14
  102. package/server/lib/session-title/runtime.js +8 -8
  103. package/server/lib/session-title/runtime.test.js +231 -0
  104. package/server/lib/session-turn-pages/DOCUMENTATION.md +4 -4
  105. package/server/lib/session-turn-pages/reconcile.routes.test.js +6 -2
  106. package/server/lib/session-turn-pages/reconcile.service.test.js +6 -2
  107. package/server/lib/session-turn-pages/routes.js +3 -3
  108. package/server/lib/session-turn-pages/routes.test.js +41 -13
  109. package/server/lib/session-turn-pages/service.js +4 -2
  110. package/server/lib/session-turn-pages/service.test.js +13 -3
  111. package/server/lib/small-model/DOCUMENTATION.md +93 -38
  112. package/server/lib/small-model/catalog.js +10 -2
  113. package/server/lib/small-model/catalog.test.js +3 -1
  114. package/server/lib/small-model/index.js +52 -28
  115. package/server/lib/small-model/opencode-session.js +339 -0
  116. package/server/lib/small-model/opencode-session.test.js +177 -0
  117. package/server/lib/small-model/resolve.js +116 -5
  118. package/server/lib/small-model/resolve.test.js +76 -10
  119. package/server/lib/small-model/summary-settings.test.js +60 -3
  120. package/dist/assets/AgentAvatar-BzRb9sP6.js +0 -1
  121. package/dist/assets/AssistantView-C0v6WVEB.js +0 -1
  122. package/dist/assets/MobileShareBridge-CED5MFr5.js +0 -4
  123. package/dist/assets/MobileSurface-CH2_uvbK.js +0 -2
  124. package/dist/assets/OverlayScrollbar-DP76aSig.js +0 -1
  125. package/dist/assets/SettingsView-BkC2EjbS.js +0 -25
  126. package/dist/assets/appThemeRegistry-DVSq4EWU.js +0 -2
  127. package/dist/assets/gitApi-Bhdx5iUS.js +0 -7
  128. package/dist/assets/gitApiHttp-SLrnr8a5.js +0 -2
  129. package/dist/assets/insertionBoundaries-nU86u9Wm.js +0 -1
  130. package/dist/assets/main-B1xvOgYh.js +0 -172
  131. package/dist/assets/main-CE-WJrYW.js +0 -2
  132. package/dist/assets/miniChat-BFIjJLav.js +0 -2
  133. package/dist/assets/mobile-CAhmWqvi.js +0 -2
  134. package/dist/assets/multirun-CgvZwd2c.js +0 -4
  135. package/dist/assets/persistence-oSFWyKgV.js +0 -3
  136. package/dist/assets/pl-CYpMAgVn.js +0 -15
  137. package/dist/assets/projectMeta-1-jW5f2j.js +0 -3
  138. package/dist/assets/radio-B8jlOicg.js +0 -1
  139. package/dist/assets/renderElectronMiniChatApp-B99BmjcT.js +0 -2
  140. package/dist/assets/renderMobileApp-Dk7PNioy.js +0 -23
  141. package/dist/assets/runtimeConfig-n4QAk1lU.js +0 -1
  142. package/dist/assets/runtimeEndpointReset-Ca3v4eoA.js +0 -145
  143. package/dist/assets/src-BhvNwAr-.css +0 -2
  144. package/dist/assets/terminalApi-_NnfkxgP.js +0 -1
  145. package/dist/assets/useAppFontEffects-CFXV7nrE.js +0 -7
  146. package/dist/assets/useDesktopWindowControlsLayout-BN0ppWAs.js +0 -1
  147. package/dist/assets/useEffectiveDirectory-DGlRozSn.js +0 -1
  148. package/dist/assets/useMobileNavigationStore-BUsW7lSl.js +0 -476
  149. package/dist/assets/useSessionAutoCleanup-BRoQwvvb.js +0 -1
@@ -0,0 +1,176 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ import { createNotificationTriggerRuntime } from './runtime.js';
4
+
5
+ const defaultSettings = {
6
+ notifyOnCompletion: true,
7
+ notifyOnQuestion: true,
8
+ notificationMode: 'always',
9
+ nativeNotificationsEnabled: true,
10
+ };
11
+
12
+ const createRuntime = (overrides = {}) => {
13
+ const emitDesktopNotification = vi.fn(() => true);
14
+ const broadcastUiNotification = vi.fn();
15
+ const sendPushToAllUiSessions = vi.fn(async () => {});
16
+ const sendApnsToAllUiSessions = vi.fn(async () => {});
17
+ const runtime = createNotificationTriggerRuntime({
18
+ readSettingsFromDisk: vi.fn(async () => defaultSettings),
19
+ prepareNotificationLastMessage: vi.fn(async ({ message }) => message || ''),
20
+ buildTemplateVariables: vi.fn(async () => ({ session_name: 'Session' })),
21
+ extractLastMessageText: vi.fn(() => ''),
22
+ fetchLastAssistantMessageText: vi.fn(async () => ''),
23
+ resolveNotificationTemplate: vi.fn((template) => template),
24
+ shouldApplyResolvedTemplateMessage: vi.fn(() => true),
25
+ emitDesktopNotification,
26
+ broadcastUiNotification,
27
+ sendPushToAllUiSessions,
28
+ sendApnsToAllUiSessions,
29
+ isAnyInteractiveClientVisible: () => false,
30
+ buildOpenCodeUrl: (path) => `http://opencode.test${path}`,
31
+ getOpenCodeAuthHeaders: () => ({}),
32
+ getIsWindowFocused: () => false,
33
+ ...overrides,
34
+ });
35
+ return {
36
+ runtime,
37
+ emitDesktopNotification,
38
+ broadcastUiNotification,
39
+ sendPushToAllUiSessions,
40
+ sendApnsToAllUiSessions,
41
+ };
42
+ };
43
+
44
+ const jsonResponse = (body, status = 200) => new Response(JSON.stringify(body), {
45
+ status,
46
+ headers: { 'Content-Type': 'application/json' },
47
+ });
48
+
49
+ afterEach(() => {
50
+ vi.unstubAllGlobals();
51
+ vi.useRealTimers();
52
+ });
53
+
54
+ describe('notification trigger runtime smallModel suppression', () => {
55
+ it('skips ready notifications for smallModel sessions', async () => {
56
+ vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({
57
+ id: 'ses_small',
58
+ parentID: null,
59
+ metadata: { openchamber: { smallModel: { purpose: 'session-title' } } },
60
+ })));
61
+
62
+ const { runtime, emitDesktopNotification, sendPushToAllUiSessions } = createRuntime();
63
+ await runtime.maybeSendPushForTrigger({
64
+ type: 'message.updated',
65
+ properties: {
66
+ directory: '/repo',
67
+ info: {
68
+ id: 'msg_1',
69
+ sessionID: 'ses_small',
70
+ role: 'assistant',
71
+ finish: 'stop',
72
+ mode: 'build',
73
+ modelID: 'small',
74
+ },
75
+ },
76
+ });
77
+
78
+ expect(emitDesktopNotification).not.toHaveBeenCalled();
79
+ expect(sendPushToAllUiSessions).not.toHaveBeenCalled();
80
+ });
81
+
82
+ it('skips ready notifications synthesized from session.idle for smallModel sessions', async () => {
83
+ vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({
84
+ id: 'ses_small',
85
+ parentID: null,
86
+ metadata: { openchamber: { smallModel: { purpose: 'commit' } } },
87
+ })));
88
+
89
+ const { runtime, emitDesktopNotification, sendPushToAllUiSessions } = createRuntime();
90
+ await runtime.maybeSendPushForTrigger({
91
+ type: 'session.idle',
92
+ properties: {
93
+ directory: '/repo',
94
+ sessionID: 'ses_small',
95
+ },
96
+ });
97
+
98
+ expect(emitDesktopNotification).not.toHaveBeenCalled();
99
+ expect(sendPushToAllUiSessions).not.toHaveBeenCalled();
100
+ });
101
+
102
+ it('skips question notifications for smallModel sessions', async () => {
103
+ vi.useFakeTimers();
104
+ vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({
105
+ id: 'ses_small',
106
+ parentID: null,
107
+ metadata: { openchamber: { smallModel: { purpose: 'session-title' } } },
108
+ })));
109
+
110
+ const { runtime, emitDesktopNotification, sendPushToAllUiSessions } = createRuntime();
111
+ await runtime.maybeSendPushForTrigger({
112
+ type: 'question.asked',
113
+ properties: {
114
+ directory: '/repo',
115
+ sessionID: 'ses_small',
116
+ questions: [{ header: 'Input needed', question: 'Continue?' }],
117
+ },
118
+ });
119
+ await vi.runAllTimersAsync();
120
+
121
+ expect(emitDesktopNotification).not.toHaveBeenCalled();
122
+ expect(sendPushToAllUiSessions).not.toHaveBeenCalled();
123
+ });
124
+
125
+ it('skips permission notifications for smallModel sessions', async () => {
126
+ vi.useFakeTimers();
127
+ vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({
128
+ id: 'ses_small',
129
+ parentID: null,
130
+ metadata: { openchamber: { smallModel: { purpose: 'session-title' } } },
131
+ })));
132
+
133
+ const { runtime, emitDesktopNotification, sendPushToAllUiSessions } = createRuntime();
134
+ await runtime.maybeSendPushForTrigger({
135
+ type: 'permission.asked',
136
+ properties: {
137
+ directory: '/repo',
138
+ sessionID: 'ses_small',
139
+ id: 'perm_1',
140
+ permission: 'edit',
141
+ },
142
+ });
143
+ await vi.runAllTimersAsync();
144
+
145
+ expect(emitDesktopNotification).not.toHaveBeenCalled();
146
+ expect(sendPushToAllUiSessions).not.toHaveBeenCalled();
147
+ });
148
+
149
+ it('still notifies ordinary root sessions', async () => {
150
+ vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({
151
+ id: 'ses_root',
152
+ parentID: null,
153
+ title: 'Ordinary',
154
+ metadata: {},
155
+ })));
156
+
157
+ const { runtime, emitDesktopNotification, sendPushToAllUiSessions } = createRuntime();
158
+ await runtime.maybeSendPushForTrigger({
159
+ type: 'message.updated',
160
+ properties: {
161
+ directory: '/repo',
162
+ info: {
163
+ id: 'msg_2',
164
+ sessionID: 'ses_root',
165
+ role: 'assistant',
166
+ finish: 'stop',
167
+ mode: 'build',
168
+ modelID: 'gpt',
169
+ },
170
+ },
171
+ });
172
+
173
+ expect(emitDesktopNotification).toHaveBeenCalledTimes(1);
174
+ expect(sendPushToAllUiSessions).toHaveBeenCalledTimes(1);
175
+ });
176
+ });
@@ -28,7 +28,7 @@ This module provides OpenCode server integration utilities for the web server ru
28
28
  - `packages/web/server/lib/opencode/static-routes-runtime.js`: static asset/SPA fallback route registration and manifest route wiring.
29
29
  - `packages/web/server/lib/opencode/feature-routes-runtime.js`: feature route composition runtime for dynamic import-backed config/skill/provider route registration.
30
30
  - `packages/web/server/lib/conversations/`: combined create-session-and-prompt orchestration (OpenChamber-owned, registered before generic proxy).
31
- - `packages/web/server/lib/session-turn-pages/`: turn-window session message pagination (`GET /api/openchamber/sessions/:sessionID/messages`), anchor reconcile (`GET .../messages/reconcile`), Changes L2/L3 (`GET .../changes`), and exact message L1 projection (`GET /api/session/:sessionID/message/:messageID`); loops official OpenCode `session.messages` for turn pages and head→anchor gap recovery with continuation/budgets/`resetRequired`; all registered before generic proxy. L1 first-packet / SSE / exact-message paths project `summary.diffs` to `diffCount`/`hasDiffs` only. See `session-turn-pages/DOCUMENTATION.md`.
31
+ - `packages/web/server/lib/session-turn-pages/`: turn-window session message pagination (`GET /api/openchamber/sessions/:sessionID/messages`), anchor reconcile (`GET .../messages/reconcile`), Changes L2/L3 (`GET .../changes`), and exact message L1 projection (`GET /api/session/:sessionID/message/:messageID`); loops official OpenCode `session.messages` for turn pages and head→anchor gap recovery with continuation/budgets/`resetRequired`; all registered before generic proxy. L1 first-packet / SSE / exact-message paths keep `summary.diffs` as a slim `{ file, status?, additions, deletions }` list plus additive `diffCount`/`hasDiffs` (patch bodies stay on L3). See `session-turn-pages/DOCUMENTATION.md`.
32
32
  - `packages/web/server/lib/opencode/opencode-resolution-runtime.js`: OpenCode binary resolution snapshot runtime for settings routes and diagnostics.
33
33
  - `packages/web/server/lib/opencode/tunnel-wiring-runtime.js`: active loopback-port wiring shared by pairing/LAN URLs and the private relay host.
34
34
  - `packages/web/server/lib/opencode/startup-pipeline-runtime.js`: server startup tail orchestration runtime for terminal/proxy/static/start-listen flow.
@@ -214,7 +214,7 @@ This module provides OpenCode server integration utilities for the web server ru
214
214
  - `isUnsafeSkillRelativePath(value)`
215
215
  - `sanitizeTypographySizesPartial(input)`
216
216
  - `normalizeStringArray(input)`
217
- - `sanitizeModelRefs(input, limit)`
217
+ - `sanitizeModelRefs(input, limit, options?)` — optional `{ preserveVariant: true }` keeps a non-empty trimmed `variant` on favorite/recent model refs; hidden models omit it
218
218
  - `sanitizeSkillCatalogs(input)`
219
219
  - `sanitizeProjects(input)`
220
220
 
@@ -616,12 +616,12 @@ export const createSettingsHelpers = (dependencies) => {
616
616
  result.shortcutOverrides = shortcutOverrides;
617
617
  }
618
618
 
619
- const favoriteModels = sanitizeModelRefs(candidate.favoriteModels, 64);
619
+ const favoriteModels = sanitizeModelRefs(candidate.favoriteModels, 64, { preserveVariant: true });
620
620
  if (favoriteModels) {
621
621
  result.favoriteModels = favoriteModels;
622
622
  }
623
623
 
624
- const recentModels = sanitizeModelRefs(candidate.recentModels, 16);
624
+ const recentModels = sanitizeModelRefs(candidate.recentModels, 16, { preserveVariant: true });
625
625
  if (recentModels) {
626
626
  result.recentModels = recentModels;
627
627
  }
@@ -439,5 +439,47 @@ describe('settings helpers', () => {
439
439
  expect(sanitized.favoriteModels).toEqual(payload.favoriteModels);
440
440
  expect(sanitized.recentModels).toEqual(payload.recentModels);
441
441
  });
442
+
443
+ it('preserves trimmed thinking variants on favoriteModels and recentModels', () => {
444
+ const helpers = createTestHelpersWithRealSanitizers();
445
+
446
+ expect(
447
+ helpers.sanitizeSettingsUpdate({
448
+ favoriteModels: [{ providerID: 'anthropic', modelID: 'claude-haiku-4', variant: ' high ' }],
449
+ recentModels: [
450
+ { providerID: 'openai', modelID: 'gpt-5', variant: 'medium' },
451
+ { providerID: 'google', modelID: 'gemini-pro' },
452
+ ],
453
+ }),
454
+ ).toEqual({
455
+ favoriteModels: [{ providerID: 'anthropic', modelID: 'claude-haiku-4', variant: 'high' }],
456
+ recentModels: [
457
+ { providerID: 'openai', modelID: 'gpt-5', variant: 'medium' },
458
+ { providerID: 'google', modelID: 'gemini-pro' },
459
+ ],
460
+ });
461
+ });
462
+
463
+ it('strips invalid favorite/recent variants and never preserves variant on hiddenModels', () => {
464
+ const helpers = createTestHelpersWithRealSanitizers();
465
+
466
+ expect(
467
+ helpers.sanitizeSettingsUpdate({
468
+ favoriteModels: [
469
+ { providerID: 'anthropic', modelID: 'claude-haiku-4', variant: ' ' },
470
+ { providerID: 'openai', modelID: 'gpt-5', variant: 12 },
471
+ ],
472
+ recentModels: [{ providerID: 'google', modelID: 'gemini-pro', variant: null }],
473
+ hiddenModels: [{ providerID: 'anthropic', modelID: 'claude-opus-4', variant: 'high' }],
474
+ }),
475
+ ).toEqual({
476
+ favoriteModels: [
477
+ { providerID: 'anthropic', modelID: 'claude-haiku-4' },
478
+ { providerID: 'openai', modelID: 'gpt-5' },
479
+ ],
480
+ recentModels: [{ providerID: 'google', modelID: 'gemini-pro' }],
481
+ hiddenModels: [{ providerID: 'anthropic', modelID: 'claude-opus-4' }],
482
+ });
483
+ });
442
484
  });
443
485
  });
@@ -309,13 +309,14 @@ export const createSettingsNormalizationRuntime = (dependencies) => {
309
309
  return populated ? result : undefined;
310
310
  };
311
311
 
312
- const sanitizeModelRefs = (input, limit) => {
312
+ const sanitizeModelRefs = (input, limit, options) => {
313
313
  if (!Array.isArray(input)) {
314
314
  return undefined;
315
315
  }
316
316
 
317
317
  const result = [];
318
318
  const seen = new Set();
319
+ const preserveVariant = options?.preserveVariant === true;
319
320
 
320
321
  for (const entry of input) {
321
322
  if (!entry || typeof entry !== 'object') continue;
@@ -325,7 +326,11 @@ export const createSettingsNormalizationRuntime = (dependencies) => {
325
326
  const key = `${providerID}/${modelID}`;
326
327
  if (seen.has(key)) continue;
327
328
  seen.add(key);
328
- result.push({ providerID, modelID });
329
+ if (preserveVariant && typeof entry.variant === 'string' && entry.variant.trim().length > 0) {
330
+ result.push({ providerID, modelID, variant: entry.variant.trim() });
331
+ } else {
332
+ result.push({ providerID, modelID });
333
+ }
329
334
  if (result.length >= limit) break;
330
335
  }
331
336
 
@@ -13,14 +13,20 @@ newest 20 root-session summaries per runtime and directory. It never stores
13
13
  messages, attachments, permissions, provider data, or model metadata.
14
14
  Sessions titled `smartfetch-secondary` are temporary SmartFetch model calls;
15
15
  the index excludes them from every snapshot and clears prior summaries when a
16
- matching session update arrives. System-owned sessions are excluded by
17
- authoritative metadata only: a non-empty
18
- `metadata.openchamber.assistant.assistantID` or
19
- `metadata.openchamber.scheduledTask.taskID`. Title prefixes are human labels and
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
16
+ matching session update arrives. System-owned sessions are excluded by
17
+ authoritative metadata only: a non-empty
18
+ `metadata.openchamber.assistant.assistantID`,
19
+ `metadata.openchamber.scheduledTask.taskID`, or
20
+ `metadata.openchamber.smallModel.purpose`. Title prefixes are human labels and
21
+ never participate in this filter. Metadata is the ownership/isolation signal;
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
22
25
  not cache system sessions, so cold-start and live upserts stay aligned with
23
26
  ordinary sidebar lists.
27
+ Pin and unpin use `POST` / `DELETE`
28
+ `/api/openchamber/session-index/session/:id/pin`, update `pinned_at`, then
29
+ `publishChange()` so revision tips broadcast `openchamber:session-index-changed`.
24
30
 
25
31
  The server-side global OpenCode event subscriber writes session summary events
26
32
  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
  });
@@ -15,6 +15,7 @@ const isSystemSession = (session) => {
15
15
  if (!openchamber || typeof openchamber !== 'object') return false;
16
16
  if (nonEmptySystemID(openchamber.assistant?.assistantID)) return true;
17
17
  if (nonEmptySystemID(openchamber.scheduledTask?.taskID)) return true;
18
+ if (nonEmptySystemID(openchamber.smallModel?.purpose)) return true;
18
19
  return false;
19
20
  };
20
21
 
@@ -116,6 +117,7 @@ export const createSessionIndexService = ({ dbPath, getRuntimeConfig = () => nul
116
117
  updated_at INTEGER NOT NULL,
117
118
  activity_updated_at INTEGER NOT NULL,
118
119
  archived_at INTEGER NOT NULL DEFAULT 0,
120
+ pinned_at INTEGER,
119
121
  status TEXT,
120
122
  status_changed_at INTEGER NOT NULL DEFAULT 0,
121
123
  parent_id TEXT,
@@ -139,6 +141,12 @@ export const createSessionIndexService = ({ dbPath, getRuntimeConfig = () => nul
139
141
  CREATE INDEX IF NOT EXISTS session_child_parent
140
142
  ON session_child(runtime_key, directory, parent_id);
141
143
  `);
144
+ const sessionSummaryColumns = new Set(
145
+ db.prepare("SELECT name FROM pragma_table_info('session_summary')").all().map((column) => column.name),
146
+ );
147
+ if (!sessionSummaryColumns.has('pinned_at')) {
148
+ db.exec('ALTER TABLE session_summary ADD COLUMN pinned_at INTEGER');
149
+ }
142
150
  db.prepare('INSERT OR REPLACE INTO session_index_meta(key, value) VALUES (?, ?)')
143
151
  .run('schema_version', String(SCHEMA_VERSION));
144
152
 
@@ -165,7 +173,7 @@ export const createSessionIndexService = ({ dbPath, getRuntimeConfig = () => nul
165
173
  const deleteDirectoryRows = db.prepare('DELETE FROM session_summary WHERE runtime_key = ? AND directory = ?');
166
174
  const existingSummaryState = db.prepare(`
167
175
  SELECT session_id, has_children AS hasChildren, activity_updated_at AS activityUpdatedAt,
168
- status, status_changed_at AS statusChangedAt
176
+ status, status_changed_at AS statusChangedAt, pinned_at AS pinnedAt
169
177
  FROM session_summary WHERE runtime_key = ? AND directory = ?
170
178
  `);
171
179
  const readSummary = db.prepare(`
@@ -183,8 +191,8 @@ export const createSessionIndexService = ({ dbPath, getRuntimeConfig = () => nul
183
191
  LIMIT 1 OFFSET ?
184
192
  `);
185
193
  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)
194
+ 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)
195
+ VALUES (@runtimeKey, @directory, @id, @title, @createdAt, @updatedAt, @activityUpdatedAt, @archivedAt, @pinnedAt, @status, @statusChangedAt, @parentID, @hasChildren, @assistantID, @assistantName)
188
196
  `);
189
197
  const upsertSummary = db.prepare(`
190
198
  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 +267,7 @@ export const createSessionIndexService = ({ dbPath, getRuntimeConfig = () => nul
259
267
  insertSummary.run({
260
268
  ...summary,
261
269
  activityUpdatedAt: Math.max(summary.activityUpdatedAt, toTimestamp(previous?.activityUpdatedAt)),
270
+ pinnedAt: previous?.pinnedAt != null ? toTimestamp(previous.pinnedAt) : null,
262
271
  status: typeof previous?.status === 'string' ? previous.status : null,
263
272
  statusChangedAt: toTimestamp(previous?.statusChangedAt),
264
273
  hasChildren: toBooleanInt(summary.hasChildren ?? Boolean(previous?.hasChildren)),
@@ -364,6 +373,7 @@ export const createSessionIndexService = ({ dbPath, getRuntimeConfig = () => nul
364
373
  sessions: db.prepare(`
365
374
  SELECT session_id AS id, title, created_at AS createdAt, updated_at AS updatedAt,
366
375
  activity_updated_at AS activityUpdatedAt, archived_at AS archivedAt,
376
+ pinned_at AS pinnedAt,
367
377
  status, status_changed_at AS statusChangedAt, parent_id AS parentID,
368
378
  has_children AS hasChildren, assistant_id AS assistantID, assistant_name AS assistantName
369
379
  FROM session_summary WHERE runtime_key = ? AND directory = ?
@@ -373,7 +383,12 @@ export const createSessionIndexService = ({ dbPath, getRuntimeConfig = () => nul
373
383
  id: session.id,
374
384
  title: session.title,
375
385
  directory: directory.directory,
376
- time: { created: session.createdAt, updated: session.updatedAt, ...(session.archivedAt ? { archived: session.archivedAt } : {}) },
386
+ time: {
387
+ created: session.createdAt,
388
+ updated: session.updatedAt,
389
+ ...(session.archivedAt ? { archived: session.archivedAt } : {}),
390
+ ...(session.pinnedAt ? { pinned: session.pinnedAt } : {}),
391
+ },
377
392
  metadata: {
378
393
  openchamber: {
379
394
  titleRefresh: { activityUpdatedAt: session.activityUpdatedAt },
@@ -465,6 +480,38 @@ export const createSessionIndexService = ({ dbPath, getRuntimeConfig = () => nul
465
480
  return result.changes > 0;
466
481
  };
467
482
 
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
+ const setPinned = (sessionID, pinnedAt = Date.now()) => {
490
+ if (typeof sessionID !== 'string' || !sessionID) return false;
491
+ const timestamp = toTimestamp(pinnedAt);
492
+ if (!timestamp) return false;
493
+ 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);
500
+ return true;
501
+ };
502
+
503
+ const clearPinned = (sessionID) => {
504
+ if (typeof sessionID !== 'string' || !sessionID) return false;
505
+ 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;
513
+ };
514
+
468
515
  const replaceChildSessions = db.transaction((directory, parentSessionID, children) => {
469
516
  const normalizedDirectory = normalizeDirectory(directory);
470
517
  if (!normalizedDirectory || typeof parentSessionID !== 'string' || !parentSessionID) return false;
@@ -487,6 +534,8 @@ export const createSessionIndexService = ({ dbPath, getRuntimeConfig = () => nul
487
534
  upsertAndReportChange,
488
535
  touchActivity,
489
536
  updateStatus,
537
+ setPinned,
538
+ clearPinned,
490
539
  remove,
491
540
  findBySessionId,
492
541
  replaceChildSessions,
@@ -68,7 +68,7 @@ describe('Electron session index', () => {
68
68
  service.close();
69
69
  });
70
70
 
71
- it('excludes Assistant and Scheduled system sessions from ordinary sidebar summaries', () => {
71
+ it('excludes Assistant, Scheduled, and smallModel system sessions from ordinary sidebar summaries', () => {
72
72
  const runtimeRef = { value: 'http://runtime-a.test' };
73
73
  const service = createService(runtimeRef);
74
74
  const assistantSession = {
@@ -79,11 +79,15 @@ describe('Electron session index', () => {
79
79
  ...session('ses_scheduled', 99),
80
80
  metadata: { openchamber: { scheduledTask: { taskID: 'task_1' } } },
81
81
  };
82
+ const smallModelSession = {
83
+ ...session('ses_small_model', 97),
84
+ metadata: { openchamber: { smallModel: { purpose: 'session-title' } } },
85
+ };
82
86
  const ordinary = session('ses_ordinary', 98);
83
87
 
84
88
  service.replaceDirectory({
85
89
  directory: '/repo',
86
- sessions: [assistantSession, scheduledSession, ordinary],
90
+ sessions: [assistantSession, scheduledSession, smallModelSession, ordinary],
87
91
  cursor: null,
88
92
  hasMore: false,
89
93
  });
@@ -91,6 +95,7 @@ describe('Electron session index', () => {
91
95
 
92
96
  service.upsert(assistantSession);
93
97
  service.upsert(scheduledSession);
98
+ service.upsert(smallModelSession);
94
99
  expect(service.snapshot().directories[0].sessions.map((item) => item.id)).toEqual(['ses_ordinary']);
95
100
  service.close();
96
101
  });
@@ -318,4 +323,69 @@ describe('Electron session index', () => {
318
323
  expect(rebuilt.snapshot().directories).toEqual([]);
319
324
  rebuilt.close();
320
325
  });
326
+
327
+ it('exposes time.pinned in snapshots and clears it on unpin', () => {
328
+ const runtimeRef = { value: 'http://runtime-a.test' };
329
+ const service = createService(runtimeRef);
330
+ service.replaceDirectory({ directory: '/repo', sessions: [session('ses_a', 10)], cursor: null, hasMore: false });
331
+
332
+ expect(service.setPinned('ses_a', 55)).toBe(true);
333
+ expect(service.snapshot().directories[0].sessions[0].time).toMatchObject({
334
+ created: 9,
335
+ updated: 10,
336
+ pinned: 55,
337
+ });
338
+
339
+ expect(service.clearPinned('ses_a')).toBe(true);
340
+ expect(service.snapshot().directories[0].sessions[0].time).toEqual({
341
+ created: 9,
342
+ updated: 10,
343
+ });
344
+ service.close();
345
+ });
346
+
347
+ it('rescues pinned_at across replaceDirectory rebuilds', () => {
348
+ const runtimeRef = { value: 'http://runtime-a.test' };
349
+ const service = createService(runtimeRef);
350
+ service.replaceDirectory({ directory: '/repo', sessions: [session('ses_a', 10)], cursor: null, hasMore: false });
351
+ expect(service.setPinned('ses_a', 77)).toBe(true);
352
+
353
+ service.replaceDirectory({
354
+ directory: '/repo',
355
+ sessions: [session('ses_a', 20)],
356
+ cursor: null,
357
+ hasMore: false,
358
+ now: 2000,
359
+ });
360
+
361
+ expect(service.snapshot().directories[0].sessions[0].time).toMatchObject({
362
+ updated: 20,
363
+ pinned: 77,
364
+ });
365
+ service.close();
366
+ });
367
+
368
+ it('does not let live upsert overwrite pinned_at', () => {
369
+ const runtimeRef = { value: 'http://runtime-a.test' };
370
+ const service = createService(runtimeRef);
371
+ service.replaceDirectory({ directory: '/repo', sessions: [session('ses_a', 10)], cursor: null, hasMore: false });
372
+ expect(service.setPinned('ses_a', 88)).toBe(true);
373
+
374
+ service.upsert(session('ses_a', 30));
375
+
376
+ expect(service.snapshot().directories[0].sessions[0].time.pinned).toBe(88);
377
+ service.close();
378
+ });
379
+
380
+ it('removes pinned rows when an archived upsert deletes the session', () => {
381
+ const runtimeRef = { value: 'http://runtime-a.test' };
382
+ const service = createService(runtimeRef);
383
+ service.replaceDirectory({ directory: '/repo', sessions: [session('ses_a', 10)], cursor: null, hasMore: false });
384
+ expect(service.setPinned('ses_a', 99)).toBe(true);
385
+
386
+ expect(service.upsert({ ...session('ses_a', 11), time: { created: 9, updated: 11, archived: 12 } })).toBe(true);
387
+ expect(service.snapshot().directories[0].sessions).toEqual([]);
388
+ expect(service.clearPinned('ses_a')).toBe(false);
389
+ service.close();
390
+ });
321
391
  });