ompchamber 1.2.1 → 2.0.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 (237) hide show
  1. package/README.md +143 -147
  2. package/dist/client/index.html +4 -2
  3. package/dist/client/static/css/index.5f630421b7.css +2 -0
  4. package/dist/client/static/js/77.0d81c74cf0.js +139 -0
  5. package/dist/client/static/js/async/1k.973e8e21fc.js +2 -0
  6. package/dist/client/static/js/async/{3y.3186ecdd63.js → 3y.3530b824ad.js} +6 -6
  7. package/dist/client/static/js/async/{q.b453193538.js → 4m.928a1dde85.js} +10 -10
  8. package/dist/client/static/js/async/66.5dfa29f957.js +93 -0
  9. package/dist/client/static/js/async/7g.a7814350c3.js +1 -0
  10. package/dist/client/static/js/async/ar.9303e5f2e8.js +2 -0
  11. package/dist/client/static/js/async/{cx.9c8268d542.js → cx.3cb0242a3a.js} +1 -1
  12. package/dist/client/static/js/async/e.93214d622b.js +79 -0
  13. package/dist/client/static/js/async/g.cb81980498.js +2 -0
  14. package/dist/client/static/js/async/h1.393f066d09.js +1 -0
  15. package/dist/client/static/js/async/jl.874d352278.js +1 -0
  16. package/dist/client/static/js/async/{k.d16cd09ae7.js → k.3210286401.js} +1 -1
  17. package/dist/client/static/js/async/{m.2e1ea3db9e.js → m.eb2e6752e2.js} +3 -3
  18. package/dist/client/static/js/async/m.eb2e6752e2.js.LICENSE.txt +11 -0
  19. package/dist/client/static/js/async/mt.0352e976d8.js +1 -0
  20. package/dist/client/static/js/async/{oc.eb22093f05.js → oc.b73042e8bd.js} +1 -1
  21. package/dist/client/static/js/async/{og.53f068804c.js → og.8eb577893a.js} +1 -1
  22. package/dist/client/static/js/async/px.fc6877924b.js +1 -0
  23. package/dist/client/static/js/async/q.22588845a9.js +21 -0
  24. package/dist/client/static/js/async/{q5.88b50ffa95.js → q5.97dc1481b6.js} +1 -1
  25. package/dist/client/static/js/async/s.0f395cdda5.js +94 -0
  26. package/dist/client/static/js/async/{tr.2a0d2b7600.js → tr.e878658140.js} +1 -1
  27. package/dist/client/static/js/async/vt.42416b707d.js +1 -0
  28. package/dist/client/static/js/async/x1.da8687159b.js +90 -0
  29. package/dist/client/static/js/async/xe.da8dcc2015.js +2 -0
  30. package/dist/client/static/js/async/xe.da8dcc2015.js.LICENSE.txt +11 -0
  31. package/dist/client/static/js/async/{xn.6360379f71.js → xn.6510c0abe2.js} +2 -2
  32. package/dist/client/static/js/async/{yx.af2936c368.js → xn1.83a7784fe1.js} +3 -3
  33. package/dist/client/static/js/async/xn1.83a7784fe1.js.LICENSE.txt +11 -0
  34. package/dist/client/static/js/async/yg.35bebd6d0c.js +149 -0
  35. package/dist/client/static/js/async/yv.d99d829136.js +1 -0
  36. package/dist/client/static/js/index.1fd9e1c543.js +38 -0
  37. package/package.json +3 -3
  38. package/src/client/components/common/ComposerTextarea.tsx +9 -1
  39. package/src/client/components/common/MarkdownRenderer.tsx +50 -12
  40. package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +9 -3
  41. package/src/client/components/settings/Modal.tsx +11 -2
  42. package/src/client/components/settings/categories/AppearanceSettings.tsx +7 -30
  43. package/src/client/components/settings/categories/ProviderSettings.tsx +2 -0
  44. package/src/client/components/settings/categories/appearance-settings/ThemeCard.tsx +61 -0
  45. package/src/client/components/settings/categories/appearance-settings/ThemeGrid.tsx +80 -0
  46. package/src/client/components/settings/categories/chat-settings/TitleSection.tsx +1 -1
  47. package/src/client/components/settings/categories/provider-settings/AddProviderModal.tsx +43 -6
  48. package/src/client/components/settings/categories/provider-settings/ModelConfigModal.tsx +54 -54
  49. package/src/client/components/workspace/SearchPanel.tsx +16 -3
  50. package/src/client/components/workspace/btw-panel/AskCard.tsx +11 -6
  51. package/src/client/components/workspace/btw-panel/Messages.tsx +105 -51
  52. package/src/client/components/workspace/btw-panel/index.tsx +124 -45
  53. package/src/client/components/workspace/chat-timeline/AiMessageFooter.tsx +30 -14
  54. package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +10 -1
  55. package/src/client/components/workspace/chat-timeline/GeneratingIndicator.tsx +8 -2
  56. package/src/client/components/workspace/chat-timeline/MessageItem.tsx +27 -16
  57. package/src/client/components/workspace/chat-timeline/MessageList.tsx +11 -0
  58. package/src/client/components/workspace/chat-timeline/RunFooter.tsx +8 -0
  59. package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +12 -1
  60. package/src/client/components/workspace/chat-timeline/index.tsx +7 -2
  61. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/QuestionBlock.tsx +42 -15
  62. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.tsx +13 -1
  63. package/src/client/components/workspace/chat-timeline/tool-renderers/extension-dialog/index.tsx +13 -4
  64. package/src/client/components/workspace/diff-panel/SplitView.tsx +4 -1
  65. package/src/client/components/workspace/diff-panel/UnifiedView.tsx +70 -65
  66. package/src/client/components/workspace/diff-panel/index.tsx +8 -1
  67. package/src/client/components/workspace/editor/index.tsx +18 -2
  68. package/src/client/components/workspace/file-explorer/GitRepoDropdown.tsx +15 -41
  69. package/src/client/components/workspace/file-explorer/index.tsx +86 -16
  70. package/src/client/components/workspace/git-panel/index.tsx +60 -78
  71. package/src/client/components/workspace/terminal-panel/Header.tsx +16 -1
  72. package/src/client/components/workspace/terminal-panel/RealtimeXtermView.tsx +5 -5
  73. package/src/client/components/workspace/terminal-panel/index.tsx +6 -2
  74. package/src/client/components/workspace/terminal-panel/mount.ts +2 -2
  75. package/src/client/data/theme/terminal.ts +50 -52
  76. package/src/client/hooks/chat/btw/index.ts +139 -18
  77. package/src/client/hooks/chat/btw/intercept.ts +8 -8
  78. package/src/client/hooks/chat/btw/mode.ts +111 -16
  79. package/src/client/hooks/chat/composer/index.ts +10 -4
  80. package/src/client/hooks/chat/timeline/access-mode.ts +41 -0
  81. package/src/client/hooks/chat/timeline/ask-frames.test.ts +87 -0
  82. package/src/client/hooks/chat/timeline/ask-frames.ts +20 -1
  83. package/src/client/hooks/chat/timeline/auto-scroll.ts +13 -4
  84. package/src/client/hooks/chat/timeline/index.ts +8 -20
  85. package/src/client/hooks/chat/timeline/queue.ts +66 -9
  86. package/src/client/hooks/chat/timeline/scroll.test.ts +260 -0
  87. package/src/client/hooks/chat/timeline/scroll.ts +93 -20
  88. package/src/client/hooks/chat/timeline/session-load.ts +7 -1
  89. package/src/client/hooks/chat/timeline/session-pagination.test.ts +167 -0
  90. package/src/client/hooks/chat/timeline/session-pagination.ts +37 -11
  91. package/src/client/hooks/editor/save-file.ts +62 -0
  92. package/src/client/hooks/editor/use-file-editor.test.ts +61 -5
  93. package/src/client/hooks/editor/use-file-editor.ts +38 -29
  94. package/src/client/hooks/editor/use-file-transfer.ts +5 -2
  95. package/src/client/hooks/settings/providers.ts +34 -3
  96. package/src/client/hooks/ui/open-file.ts +7 -1
  97. package/src/client/hooks/ui/theme.ts +82 -57
  98. package/src/client/hooks/workspace/file-tabs.ts +5 -1
  99. package/src/client/hooks/workspace/repo-scope/index.ts +119 -0
  100. package/src/client/hooks/workspace/repo-scope/store.test.ts +161 -0
  101. package/src/client/hooks/workspace/repo-scope/store.ts +201 -0
  102. package/src/client/hooks/workspace/search-stream.ts +24 -5
  103. package/src/client/main.tsx +6 -0
  104. package/src/client/tailwind.css +19 -56
  105. package/src/server/lib/btw/child.server.ts +41 -0
  106. package/src/server/lib/btw/lifecycle.server.ts +254 -0
  107. package/src/server/lib/btw/prompt.ts +13 -5
  108. package/src/server/lib/btw/registry.server.ts +13 -4
  109. package/src/server/lib/btw/runtime.server.ts +149 -156
  110. package/src/server/lib/btw/schema.server.ts +27 -0
  111. package/src/server/lib/btw/service.server.ts +106 -5
  112. package/src/server/lib/btw/store.server.ts +57 -6
  113. package/src/server/lib/db/schema.ts +4 -0
  114. package/src/server/lib/db/settings-store.test.ts +65 -0
  115. package/src/server/lib/db/settings-store.ts +25 -0
  116. package/src/server/lib/fs/atomic-write.ts +42 -4
  117. package/src/server/lib/fs/config-lock.ts +74 -0
  118. package/src/server/lib/fs/git-ignore.ts +55 -5
  119. package/src/server/lib/fs/ripgrep.ts +119 -0
  120. package/src/server/lib/lifecycle/identity.ts +10 -64
  121. package/src/server/lib/lifecycle/proc/darwin.ts +238 -0
  122. package/src/server/lib/lifecycle/proc/index.ts +38 -0
  123. package/src/server/lib/lifecycle/proc/linux.ts +80 -0
  124. package/src/server/lib/lifecycle/proc/ps.ts +86 -0
  125. package/src/server/lib/lifecycle/proc/types.ts +33 -0
  126. package/src/server/lib/lifecycle/proc/win32.ts +53 -0
  127. package/src/server/lib/models/provider-registry.server.ts +2 -2
  128. package/src/server/lib/models/registry.server.ts +1 -1
  129. package/src/server/lib/omp/config/behavior.ts +17 -17
  130. package/src/server/lib/omp/config/disabled-providers.ts +84 -35
  131. package/src/server/lib/omp/config/document.ts +128 -0
  132. package/src/server/lib/omp/config/extensions.ts +11 -10
  133. package/src/server/lib/omp/config/mcp.ts +4 -57
  134. package/src/server/lib/omp/config/model-overrides.ts +123 -0
  135. package/src/server/lib/omp/config/models-config.ts +92 -0
  136. package/src/server/lib/omp/config/models-validation.ts +159 -0
  137. package/src/server/lib/omp/config/path-scoped.ts +74 -0
  138. package/src/server/lib/omp/config/providers.ts +241 -211
  139. package/src/server/lib/omp/config/roles.ts +6 -14
  140. package/src/server/lib/omp/core/auth-credentials.ts +1 -1
  141. package/src/server/lib/omp/rpc/frame-fold.test.ts +124 -0
  142. package/src/server/lib/omp/rpc/frame-fold.ts +43 -24
  143. package/src/server/lib/omp/rpc/lifecycle.test.ts +41 -0
  144. package/src/server/lib/omp/rpc/manager.ts +21 -21
  145. package/src/server/lib/omp/rpc/pending-ui-dialogs.ts +11 -7
  146. package/src/server/lib/omp/rpc/session-commands.ts +17 -29
  147. package/src/server/lib/omp/rpc/session-registry.ts +5 -66
  148. package/src/server/lib/omp/rpc/web-state.ts +0 -6
  149. package/src/server/lib/omp/session/auto-title.server.test.ts +109 -11
  150. package/src/server/lib/omp/session/auto-title.server.ts +95 -20
  151. package/src/server/lib/omp/session/sidebar-data.server.ts +11 -6
  152. package/src/server/lib/queue/delivery-dispatch.server.test.ts +162 -0
  153. package/src/server/lib/queue/delivery-harness.ts +88 -0
  154. package/src/server/lib/queue/delivery.server.test.ts +78 -149
  155. package/src/server/lib/queue/delivery.server.ts +105 -13
  156. package/src/server/lib/queue/store.server.ts +30 -5
  157. package/src/server/lib/terminal/foreground.test.ts +164 -0
  158. package/src/server/lib/terminal/foreground.ts +34 -6
  159. package/src/server/lib/terminal/history.ts +8 -7
  160. package/src/server/plugins/compress.ts +33 -5
  161. package/src/server/plugins/ssr.ts +14 -7
  162. package/src/server/routes/btw/topics.ts +89 -2
  163. package/src/server/routes/fs/action.test.ts +64 -0
  164. package/src/server/routes/fs/action.ts +10 -1
  165. package/src/server/routes/fs/git.ts +5 -6
  166. package/src/server/routes/fs/search.ts +17 -26
  167. package/src/server/routes/sessions/queue.ts +17 -4
  168. package/src/server/routes/settings/index.ts +2 -0
  169. package/src/server/routes/settings/model-override.ts +69 -0
  170. package/src/server/routes/settings/provider-models.ts +35 -12
  171. package/src/server/routes/settings/providers.ts +31 -1
  172. package/src/server/routes/settings/root.ts +8 -3
  173. package/src/shared/lib/chat/btw/client.ts +34 -0
  174. package/src/shared/lib/chat/timeline/tool-phrases.ts +7 -0
  175. package/src/shared/lib/code/line-endings.test.ts +38 -0
  176. package/src/shared/lib/code/line-endings.ts +56 -0
  177. package/src/shared/lib/code/shiki-themes.ts +9 -5
  178. package/src/shared/lib/fs/file-tree.ts +17 -12
  179. package/src/shared/lib/markdown/autolink.test.ts +32 -0
  180. package/src/shared/lib/markdown/autolink.ts +50 -0
  181. package/src/shared/lib/markdown/document-urls.test.ts +68 -0
  182. package/src/shared/lib/markdown/document-urls.ts +118 -0
  183. package/src/shared/lib/markdown/marked.ts +27 -15
  184. package/src/shared/lib/markdown/mermaid.ts +2 -2
  185. package/src/shared/lib/models/catalog.ts +1 -1
  186. package/src/shared/lib/omp/session/schema-migrations.ts +30 -0
  187. package/src/shared/lib/omp/session/stream-state.server.test.ts +54 -0
  188. package/src/shared/lib/omp/session/stream-state.server.ts +70 -21
  189. package/src/shared/lib/settings/client.ts +10 -4
  190. package/src/shared/lib/settings/diff.test.ts +31 -0
  191. package/src/shared/lib/settings/diff.ts +37 -0
  192. package/src/shared/lib/theme/catalog.test.ts +168 -0
  193. package/src/shared/lib/theme/catalog.ts +98 -0
  194. package/src/shared/lib/theme/color.ts +88 -0
  195. package/src/shared/lib/theme/css.ts +70 -0
  196. package/src/shared/lib/theme/palettes/chamber.ts +61 -0
  197. package/src/shared/lib/theme/palettes/dark.ts +313 -0
  198. package/src/shared/lib/theme/palettes/light.ts +313 -0
  199. package/src/shared/types/btw.ts +83 -4
  200. package/src/shared/types/index.ts +1 -0
  201. package/src/shared/types/settings/provider.ts +8 -1
  202. package/src/shared/types/settings/state.ts +10 -4
  203. package/src/shared/types/theme.ts +48 -0
  204. package/dist/client/static/css/index.b938f4a693.css +0 -2
  205. package/dist/client/static/js/77.8022a800b5.js +0 -139
  206. package/dist/client/static/js/async/1k.a411fe5b43.js +0 -2
  207. package/dist/client/static/js/async/66.86544ad741.js +0 -93
  208. package/dist/client/static/js/async/7g.db82aca7ba.js +0 -1
  209. package/dist/client/static/js/async/ar.ad5c8822a8.js +0 -2
  210. package/dist/client/static/js/async/e.6443e739a2.js +0 -79
  211. package/dist/client/static/js/async/g.ee41bfed7f.js +0 -2
  212. package/dist/client/static/js/async/h1.d7091cef78.js +0 -1
  213. package/dist/client/static/js/async/jl.4911fa3383.js +0 -1
  214. package/dist/client/static/js/async/m.2e1ea3db9e.js.LICENSE.txt +0 -6
  215. package/dist/client/static/js/async/mt.853be2b5aa.js +0 -1
  216. package/dist/client/static/js/async/ot.84c71b308f.js +0 -2
  217. package/dist/client/static/js/async/px.783b6f91f2.js +0 -1
  218. package/dist/client/static/js/async/q8.7f1b48e9cd.js +0 -21
  219. package/dist/client/static/js/async/q8.7f1b48e9cd.js.LICENSE.txt +0 -6
  220. package/dist/client/static/js/async/s.aad2ecebc6.js +0 -94
  221. package/dist/client/static/js/async/vt.20406a5604.js +0 -1
  222. package/dist/client/static/js/async/x1.fe8af17016.js +0 -90
  223. package/dist/client/static/js/async/xn.6360379f71.js.LICENSE.txt +0 -6
  224. package/dist/client/static/js/async/yg.5a94e1fc8a.js +0 -149
  225. package/dist/client/static/js/async/yv.2fa22f009a.js +0 -1
  226. package/dist/client/static/js/index.5fe7a2cb64.js +0 -34
  227. package/src/client/components/workspace/btw-panel/Composer.tsx +0 -183
  228. package/src/client/components/workspace/btw-panel/attachments.ts +0 -80
  229. /package/dist/client/static/js/{77.8022a800b5.js.LICENSE.txt → 77.0d81c74cf0.js.LICENSE.txt} +0 -0
  230. /package/dist/client/static/js/async/{g.ee41bfed7f.js.LICENSE.txt → 1k.973e8e21fc.js.LICENSE.txt} +0 -0
  231. /package/dist/client/static/js/async/{3y.3186ecdd63.js.LICENSE.txt → 3y.3530b824ad.js.LICENSE.txt} +0 -0
  232. /package/dist/client/static/js/async/{ot.84c71b308f.js.LICENSE.txt → 4m.928a1dde85.js.LICENSE.txt} +0 -0
  233. /package/dist/client/static/js/async/{q.b453193538.js.LICENSE.txt → ar.9303e5f2e8.js.LICENSE.txt} +0 -0
  234. /package/dist/client/static/js/async/{yx.af2936c368.js.LICENSE.txt → g.cb81980498.js.LICENSE.txt} +0 -0
  235. /package/dist/client/static/js/async/{1k.a411fe5b43.js.LICENSE.txt → q.22588845a9.js.LICENSE.txt} +0 -0
  236. /package/dist/client/static/js/async/{ar.ad5c8822a8.js.LICENSE.txt → xn.6510c0abe2.js.LICENSE.txt} +0 -0
  237. /package/dist/client/static/js/{index.5fe7a2cb64.js.LICENSE.txt → index.1fd9e1c543.js.LICENSE.txt} +0 -0
@@ -0,0 +1,50 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * linkify-it autolinking that yields to code spans.
8
+ *
9
+ * linkify-it accepts a backtick as an email local-part character (RFC 5321's
10
+ * `!#$%&'*+/=?^_`{|}~-` set), so `` `NNN+login@users.noreply.github.com` ``
11
+ * matches as ONE autolink whose raw text spans both backticks. The extension
12
+ * runs before marked's own inline rules, so that match is consumed and the code
13
+ * span never forms: the backticks end up as link text and the href carries
14
+ * `%60` escapes (`mailto:%60NNN+…%60`) — a broken link AND a code span that
15
+ * shows its delimiters.
16
+ *
17
+ * A code span wins over an autolink in CommonMark, and the inline lexer reaches
18
+ * the OPENING backtick first — so refusing every match whose raw contains a
19
+ * backtick is enough to hand that position back to the `codespan` rule, which
20
+ * then swallows the whole span. Autolinks without a backtick in them are
21
+ * untouched, which is every ordinary URL and email.
22
+ */
23
+
24
+ import type {
25
+ MarkedExtension,
26
+ RendererExtension,
27
+ Token,
28
+ TokenizerAndRendererExtension,
29
+ TokenizerThis,
30
+ } from 'marked';
31
+ import markedLinkifyIt from 'marked-linkify-it';
32
+
33
+ /** Refuse an autolink whose raw text carries a backtick (see the file header). */
34
+ function guardAutolink(
35
+ extension: TokenizerAndRendererExtension | RendererExtension,
36
+ ): TokenizerAndRendererExtension | RendererExtension {
37
+ if (!('tokenizer' in extension) || !extension.tokenizer) return extension;
38
+ const tokenize = extension.tokenizer;
39
+ return {
40
+ ...extension,
41
+ tokenizer(this: TokenizerThis, source: string, tokens: Token[]) {
42
+ const token = tokenize.call(this, source, tokens);
43
+ return token && !token.raw.includes('`') ? token : undefined;
44
+ },
45
+ };
46
+ }
47
+
48
+ export function codeSafeAutolink(): MarkedExtension {
49
+ return { extensions: (markedLinkifyIt().extensions ?? []).map(guardAutolink) };
50
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ import { describe, expect, test } from 'bun:test';
7
+ import { resolveDocumentReference, rewriteDocumentReferences } from '@/shared/lib/markdown/document-urls';
8
+
9
+ const doc = (path: string, root?: string, repo?: string) => ({ path, root, repo });
10
+
11
+ describe('resolveDocumentReference', () => {
12
+ test('resolves beside the document, not beside the app', () => {
13
+ expect(resolveDocumentReference('docs/hero.png', doc('README.md'))?.path).toBe('docs/hero.png');
14
+ expect(resolveDocumentReference('img/a.png', doc('docs/guide.md'))?.path).toBe('docs/img/a.png');
15
+ });
16
+
17
+ test('walks out of the document folder', () => {
18
+ expect(resolveDocumentReference('../README.md', doc('docs/guide.md'))?.path).toBe('README.md');
19
+ expect(resolveDocumentReference('./a.png', doc('docs/guide.md'))?.path).toBe('docs/a.png');
20
+ });
21
+
22
+ test('a leading slash is the scope root, not the document folder', () => {
23
+ expect(resolveDocumentReference('/docs/a.png', doc('manual/v2/guide.md'))?.path).toBe('docs/a.png');
24
+ });
25
+
26
+ test('cannot climb above the scope root', () => {
27
+ expect(resolveDocumentReference('../../../../etc/passwd', doc('README.md'))?.path).toBe('etc/passwd');
28
+ });
29
+
30
+ test('keeps the scope in the raw URL', () => {
31
+ const resolved = resolveDocumentReference('a.png', doc('docs/guide.md', '/ws', 'projects/x'));
32
+ expect(resolved?.url).toBe('/api/fs/raw?path=docs%2Fa.png&root=%2Fws&repo=projects%2Fx');
33
+ // `repo: '.'` means the root itself, so the param is omitted.
34
+ expect(resolveDocumentReference('a.png', doc('docs/guide.md', '/ws', '.'))?.url)
35
+ .toBe('/api/fs/raw?path=docs%2Fa.png&root=%2Fws');
36
+ });
37
+
38
+ test('a query or fragment belongs to the reference, not the path', () => {
39
+ expect(resolveDocumentReference('img/a.png?v=2#frag', doc('docs/guide.md'))?.path).toBe('docs/img/a.png');
40
+ });
41
+
42
+ test('decodes percent escapes', () => {
43
+ expect(resolveDocumentReference('my%20image.png', doc('README.md'))?.path).toBe('my image.png');
44
+ });
45
+
46
+ test('leaves anything that is not a relative file reference alone', () => {
47
+ for (const reference of ['https://x.test/a.png', 'mailto:a@b.c', 'data:image/png;base64,AA', '//cdn.test/a.png', '#section', ' ', '']) {
48
+ expect(resolveDocumentReference(reference, doc('README.md'))).toBeNull();
49
+ }
50
+ });
51
+ });
52
+
53
+ describe('rewriteDocumentReferences', () => {
54
+ test('points images at the raw route and links at the file they name', () => {
55
+ expect(rewriteDocumentReferences('<img src="docs/a.png"><a href="AGENTS.md">x</a>', doc('README.md')))
56
+ .toBe('<img src="/api/fs/raw?path=docs%2Fa.png"><a href="/api/fs/raw?path=AGENTS.md" data-file-path="AGENTS.md">x</a>');
57
+ });
58
+
59
+ test('escapes the query separator so the attribute survives HTML parsing', () => {
60
+ expect(rewriteDocumentReferences('<img src="a.png">', doc('docs/g.md', '/ws')))
61
+ .toBe('<img src="/api/fs/raw?path=docs%2Fa.png&amp;root=%2Fws">');
62
+ });
63
+
64
+ test('leaves external references and escaped code untouched', () => {
65
+ const html = '<a href="https://x.test">x</a><code class="language-html">&lt;img src=&quot;a.png&quot;&gt;</code>';
66
+ expect(rewriteDocumentReferences(html, doc('README.md'))).toBe(html);
67
+ });
68
+ });
@@ -0,0 +1,118 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Resolve the relative references of a markdown *document* (an opened file's
8
+ * preview) to the workspace files they actually name.
9
+ *
10
+ * A previewed file is not a chat message: `![](docs/hero.png)` means the image
11
+ * on disk beside the document, and `[Architecture](AGENTS.md)` means that file.
12
+ * Neither resolves on its own — the preview is an app route, so a relative URL
13
+ * is requested from the server's own origin (`/docs/hero.png` → 404, and a
14
+ * click would navigate the console away from itself). Each one is rewritten to
15
+ * the workspace file it points at: images through `/api/fs/raw` (real bytes,
16
+ * real content-type) and links through the same URL plus the resolved path in
17
+ * `data-file-path`, which the panel turns back into an editor tab.
18
+ *
19
+ * Only plain relative references are touched. Anything with a scheme
20
+ * (`https:`, `mailto:`, `data:`), a protocol-relative `//host`, or a bare
21
+ * `#anchor` is the document's own business and is left exactly as written.
22
+ */
23
+
24
+ import { buildFsRawUrl } from '@/shared/lib/fs/paths';
25
+
26
+ /**
27
+ * Where the document lives. `path` is relative to the picked scope exactly the
28
+ * way `/api/fs/raw` and the file panel read it: relative to `repo` when one is
29
+ * picked, otherwise to `root`.
30
+ */
31
+ export interface DocumentScope {
32
+ path: string;
33
+ root?: string;
34
+ repo?: string;
35
+ }
36
+
37
+ export interface ResolvedDocumentReference {
38
+ /** Scope-relative path of the referenced file — what an editor tab opens. */
39
+ path: string;
40
+ /** Raw-bytes URL of the same file, for an `<img src>` or a middle-click. */
41
+ url: string;
42
+ }
43
+
44
+ /** A scheme (`https:`), protocol-relative host, fragment, or empty ref. */
45
+ const ABSOLUTE_REFERENCE_RE = /^(?:[a-z][a-z0-9+.-]*:|\/\/|#|$)/i;
46
+
47
+ /** Collapse `.`/`..` segments; ascending above the scope root is dropped. */
48
+ function normalizeSegments(segments: string[]): string {
49
+ const out: string[] = [];
50
+ for (const segment of segments) {
51
+ if (!segment || segment === '.') continue;
52
+ if (segment === '..') {
53
+ out.pop();
54
+ continue;
55
+ }
56
+ out.push(segment);
57
+ }
58
+ return out.join('/');
59
+ }
60
+
61
+ /** `%20`-style escapes are how a markdown link writes a space. */
62
+ function decodeReference(value: string): string {
63
+ try {
64
+ return decodeURIComponent(value);
65
+ } catch {
66
+ return value;
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Resolve one reference written inside `scope`'s document, or null when it is
72
+ * not a relative file reference (leave those untouched).
73
+ */
74
+ export function resolveDocumentReference(
75
+ reference: string,
76
+ scope: DocumentScope,
77
+ ): ResolvedDocumentReference | null {
78
+ const raw = reference.trim();
79
+ if (ABSOLUTE_REFERENCE_RE.test(raw)) return null;
80
+
81
+ // `?v=1` and `#section` address the preview, not the file on disk.
82
+ const withoutQuery = raw.split(/[?#]/)[0];
83
+ const decoded = decodeReference(withoutQuery);
84
+ if (!decoded) return null;
85
+
86
+ // A leading slash is root-relative (GitHub's repo-root rule), and the scope
87
+ // frame already IS that root, so it resolves without the document's folder.
88
+ const rootRelative = /^[/\\]/.test(decoded);
89
+ const documentDir = scope.path.replace(/\\/g, '/').split('/').slice(0, -1);
90
+ const segments = decoded.split(/[\\/]+/);
91
+ const path = normalizeSegments(rootRelative ? segments : [...documentDir, ...segments]);
92
+ if (!path) return null;
93
+
94
+ return { path, url: buildFsRawUrl({ path, root: scope.root, repo: scope.repo }) };
95
+ }
96
+
97
+ /** Attribute values are injected as HTML, so `&` and `"` must be entities. */
98
+ function escapeAttribute(value: string): string {
99
+ return value.replace(/&/g, '&amp;').replace(/"/g, '&quot;');
100
+ }
101
+
102
+ /**
103
+ * Rewrite the relative `src`/`href` attributes of rendered document HTML.
104
+ * Fenced code is already escaped by the renderer, so a literal `src="` inside
105
+ * a code block cannot be mistaken for an attribute.
106
+ */
107
+ export function rewriteDocumentReferences(html: string, scope: DocumentScope): string {
108
+ return html.replace(/(\s(?:src|href))="([^"]*)"/g, (match, attribute: string, value: string) => {
109
+ const resolved = resolveDocumentReference(value, scope);
110
+ if (!resolved) return match;
111
+ const url = ` ${attribute.trim()}="${escapeAttribute(resolved.url)}"`;
112
+ // The resolved path rides along on links only: it is what a click opens as
113
+ // a tab, and a mid-click still has the raw URL to fall back to.
114
+ return attribute.trim() === 'href'
115
+ ? `${url} data-file-path="${escapeAttribute(resolved.path)}"`
116
+ : url;
117
+ });
118
+ }
@@ -8,17 +8,19 @@
8
8
  *
9
9
  * Stack (run in order, all server/client safe — parsed synchronously):
10
10
  * 1. remend — heal incomplete markdown while streaming (before parse)
11
- * 2. marked — GFM true, breaks true, autolink via marked-linkify-it (CJK
12
- * punctuation is not swallowed into URLs), plus a KaTeX extension for
13
- * \(...\) inline, \[...\] block, and $$...$$ display math
11
+ * 2. marked — GFM true, breaks true, autolink via linkify-it (CJK
12
+ * punctuation is not swallowed into URLs, and a match carrying a backtick
13
+ * is left to the code-span rule — see autolink.ts), plus a KaTeX
14
+ * extension for \(...\) inline, \[...\] block, and $$...$$ display math
14
15
  *
15
16
  * The resulting HTML is NOT sanitized here — run the output through
16
- * DOMPurify (see sanitize.ts) before injecting, so raw HTML from the
17
- * assistant is escaped rather than executed.
17
+ * DOMPurify (see sanitize.ts) before injecting. What the source's own HTML
18
+ * means is the caller's call: `renderMarkdown` escapes it (assistant output,
19
+ * where markup is data) unless `allowHtml` says the source is a document.
18
20
  */
19
21
 
20
22
  import { Marked } from 'marked';
21
- import markedLinkifyIt from 'marked-linkify-it';
23
+ import { codeSafeAutolink } from '@/shared/lib/markdown/autolink';
22
24
  import remend from 'remend';
23
25
  import { highlightCode } from '@/shared/lib/code/syntax-highlight';
24
26
  import { MATH_PENDING_CLASS } from '@/shared/lib/markdown/katex';
@@ -64,7 +66,7 @@ const marked = new Marked({
64
66
  breaks: true,
65
67
  });
66
68
 
67
- marked.use(markedLinkifyIt());
69
+ marked.use(codeSafeAutolink());
68
70
 
69
71
  interface CodeToken {
70
72
  type: string;
@@ -217,19 +219,29 @@ function escapeHtmlOutsideCode(source: string): string {
217
219
  * parsing so the placeholder URL never leaks into the DOM as an href. */
218
220
  const INCOMPLETE_LINK_RE = /\[([^\]]*)\]\(streamdown:incomplete-link\)/g;
219
221
 
222
+ export interface RenderMarkdownOptions {
223
+ /**
224
+ * Leave the source's own HTML markup intact so marked parses it as markup.
225
+ *
226
+ * OFF (default) is the agent-output contract: assistant HTML is data, never
227
+ * markup, so `<div align="center">` and `<br>` arrive as visible text and
228
+ * DOMPurify is only the second line of defence. ON is the file-preview
229
+ * contract, where the source IS the document — a README's `<p align="center">`
230
+ * block, badge `<img>` and inline `<br>` are the layout its author wrote, and
231
+ * escaping them shows the tags instead of the page. DOMPurify still sanitizes
232
+ * the result, so scripts and event handlers never survive either way.
233
+ */
234
+ allowHtml?: boolean;
235
+ }
236
+
220
237
  /**
221
238
  * Heal streaming markdown (remend) then parse to HTML via marked.
222
239
  * Synchronous and side-effect free — safe in both server loaders and the
223
240
  * browser. The output must be sanitized with DOMPurify before injection.
224
- *
225
- * Raw HTML in the source is escaped (instruksi: assistant HTML is escaped,
226
- * never rendered) — `<script>`/`<img onerror>` come through as literal text
227
- * while backtick code is preserved. DOMPurify remains the second line of
228
- * defence.
229
241
  */
230
- export function renderMarkdown(markdown: string): string {
231
- const escaped = escapeHtmlOutsideCode(markdown);
232
- const healed = remend(escaped, { katex: true, inlineKatex: true })
242
+ export function renderMarkdown(markdown: string, options: RenderMarkdownOptions = {}): string {
243
+ const source = options.allowHtml ? markdown : escapeHtmlOutsideCode(markdown);
244
+ const healed = remend(source, { katex: true, inlineKatex: true })
233
245
  .replace(INCOMPLETE_LINK_RE, (_, text: string) => `<span class="md-incomplete-link">${text}</span>`);
234
246
  return (marked.parse(healed) as string).trim();
235
247
  }
@@ -18,6 +18,7 @@
18
18
  */
19
19
 
20
20
  import { sanitizeMermaidSvg } from '@/shared/lib/markdown/sanitize';
21
+ import { isDarkTheme } from '@/shared/lib/theme/catalog';
21
22
 
22
23
  export interface MermaidRenderResult {
23
24
  rendered: number;
@@ -34,8 +35,7 @@ let mermaidPromise: Promise<MermaidModule['default']> | null = null;
34
35
  let lastThemeKey: ThemeKey | null = null;
35
36
 
36
37
  function resolveThemeKey(): ThemeKey {
37
- const theme = document.documentElement.dataset.theme || document.documentElement.getAttribute('data-theme');
38
- return theme === 'one-dark-pro-soft' || theme === 'noir' || theme === 'dark' ? 'dark' : 'default';
38
+ return isDarkTheme(document.documentElement.dataset.theme || document.documentElement.getAttribute('data-theme')) ? 'dark' : 'default';
39
39
  }
40
40
 
41
41
  function cacheKey(themeKey: ThemeKey, source: string): string {
@@ -17,7 +17,7 @@ export interface CatalogModelInfo {
17
17
  reasoning?: boolean;
18
18
  tool_call?: boolean;
19
19
  limit?: { context?: number; output?: number };
20
- cost?: { input?: number; output?: number; cache_read?: number };
20
+ cost?: { input?: number; output?: number; cache_read?: number; cache_write?: number };
21
21
  }
22
22
 
23
23
  type ModelsDevCatalog = Record<string, { models?: Record<string, CatalogModelInfo> }>;
@@ -0,0 +1,30 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Additive migrations for `session_stream_state`. `CREATE TABLE IF NOT EXISTS`
8
+ * leaves an existing table alone, so a database created before `owner_pid`
9
+ * existed would miss the column and every status read would fail on it.
10
+ *
11
+ * Split out of `@/server/lib/db/schema` for the same reason
12
+ * `@/shared/lib/workspace/schema-migrations` is: the DDL block there owns table
13
+ * creation, and each later column arrives in the module that explains it.
14
+ */
15
+
16
+ import type { DbClient } from '@/server/lib/db/client';
17
+
18
+ async function addColumn(db: DbClient, statement: string): Promise<void> {
19
+ try {
20
+ await db.exec(statement);
21
+ } catch {
22
+ // Already present — a fresh install gets the column from the DDL.
23
+ }
24
+ }
25
+
26
+ export async function migrateSessionStreamStateColumns(db: DbClient): Promise<void> {
27
+ // Which chamber process is running the session, so any instance reading this
28
+ // table can judge a `stream` row it does not own (see stream-state.server.ts).
29
+ await addColumn(db, 'ALTER TABLE session_stream_state ADD COLUMN owner_pid INTEGER;');
30
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The staleness rule behind the sidebar's self-heal. Getting it wrong is
8
+ * user-visible in both directions: too eager clears the spinner of a run that
9
+ * is still working, too lazy leaves a spinner turning forever for a run whose
10
+ * process is gone.
11
+ *
12
+ * The rule is deliberately a pure function of the ROW plus OS liveness, with no
13
+ * caller context. That is what makes several chamber instances agree: they all
14
+ * read the same row and ask the same question about the same pid.
15
+ */
16
+
17
+ import { describe, expect, test } from 'bun:test';
18
+
19
+ import { isStaleStreamRow } from '@/shared/lib/omp/session/stream-state.server';
20
+
21
+ const OWNER = 4242;
22
+ const otherInstance = 9999;
23
+ /** OWNER and the other instance are alive; anything else, including 999999, is not. */
24
+ const alive = (pid: number) => pid === OWNER || pid === otherInstance;
25
+
26
+ describe('isStaleStreamRow', () => {
27
+ test('a row whose owner is alive is live, whoever is asking', () => {
28
+ expect(isStaleStreamRow({ session_id: 's1', owner_pid: OWNER }, alive)).toBe(false);
29
+ });
30
+
31
+ test('a row whose owner is gone is stale', () => {
32
+ expect(isStaleStreamRow({ session_id: 's1', owner_pid: 999999 }, alive)).toBe(true);
33
+ });
34
+
35
+ test('the verdict does not depend on which instance is asking', () => {
36
+ // The regression this shape exists for: several instances read ONE row and
37
+ // must report the same status. Judging by the reader's own runtime registry
38
+ // made a second instance call another instance's live run stale — the row
39
+ // is live here, and every asker must say so.
40
+ const row = { session_id: 's1', owner_pid: otherInstance };
41
+ const verdicts = [otherInstance, OWNER, 12345].map(() => isStaleStreamRow(row, alive));
42
+ expect(verdicts).toEqual([false, false, false]);
43
+ });
44
+
45
+ test('an ownerless row is stale, because nothing can vouch for its run', () => {
46
+ expect(isStaleStreamRow({ session_id: 's1', owner_pid: null }, alive)).toBe(true);
47
+ });
48
+
49
+ test('a row owned by the reader is judged by liveness like any other', () => {
50
+ // Self-ownership is not special-cased: the reader's own pid is alive while
51
+ // it runs, so its live rows survive.
52
+ expect(isStaleStreamRow({ session_id: 's1', owner_pid: OWNER }, (pid) => pid === OWNER)).toBe(false);
53
+ });
54
+ });
@@ -5,35 +5,49 @@
5
5
 
6
6
  /**
7
7
  * Server-authoritative per-session stream status, persisted in SQLite so the
8
- * sidebar can render spinner/check from the SAME loader that already refresh
8
+ * sidebar can render spinner/check from the SAME loader that already refreshes
9
9
  * on stream events (`omp:session-updated` → revalidator). Statuses:
10
10
  *
11
- * stream — a run is in flight (prompt dispatch → agent_start)
11
+ * stream — a run is in flight (prompt dispatch → agent_end)
12
12
  * finish — the run ended normally (agent_end), not yet seen
13
13
  * abort — the user stopped it, not yet seen
14
14
  *
15
15
  * A terminal status is a one-shot badge: opening the session acknowledges it
16
16
  * (`markStreamSeen` deletes the row), which is exactly the "check appears
17
- * once, disappears when the session is opened" contract. Rows marked
18
- * `stream` whose session is no longer running when the sidebar loads are
19
- * stale (chamber restart mid-run) and self-heal to `finish`.
17
+ * once, disappears when the session is opened" contract.
18
+ *
19
+ * `stream` rows record the pid of the chamber process running that session, so
20
+ * a `stream` row whose owner is gone self-heals to `finish` — see
21
+ * `healStaleStreamStatuses`. Ownership travels in the row because the database
22
+ * is SHARED by every chamber instance on the machine (~/.ompchamber/db.sqlite)
23
+ * while the runtime session registry is per process: without the column, a
24
+ * second instance opening its sidebar judged the first instance's live run by
25
+ * its own empty registry and cleared a spinner that was still working.
20
26
  *
21
27
  * All writes are fire-and-forget: a status write must never fail a prompt,
22
28
  * and the sidebar re-reads on the next revalidate anyway.
23
29
  */
24
30
 
25
31
  import { getDb } from '@/server/db.server';
32
+ import { isProcessAlive } from '@/server/lib/lifecycle/identity';
26
33
 
27
34
  export type SessionStreamStatus = 'stream' | 'finish' | 'abort';
28
35
 
29
36
  export async function markStreamStatus(sessionId: string, status: SessionStreamStatus): Promise<void> {
30
37
  try {
31
38
  const db = await getDb();
39
+ // The owner describes the process running a live row; a terminal badge
40
+ // belongs to no process, and leaving a pid on it would invite a later read
41
+ // to judge the badge by the liveness of a process it never concerned.
42
+ const ownerPid = status === 'stream' ? process.pid : null;
32
43
  await db.run(
33
- `INSERT INTO session_stream_state (session_id, status, updated_at)
34
- VALUES (?, ?, CURRENT_TIMESTAMP)
35
- ON CONFLICT(session_id) DO UPDATE SET status = excluded.status, updated_at = excluded.updated_at`,
36
- [sessionId, status],
44
+ `INSERT INTO session_stream_state (session_id, status, owner_pid, updated_at)
45
+ VALUES (?, ?, ?, CURRENT_TIMESTAMP)
46
+ ON CONFLICT(session_id) DO UPDATE SET
47
+ status = excluded.status,
48
+ owner_pid = excluded.owner_pid,
49
+ updated_at = excluded.updated_at`,
50
+ [sessionId, status, ownerPid],
37
51
  );
38
52
  } catch {
39
53
  // Status tracking is best-effort by design.
@@ -97,23 +111,58 @@ export async function loadStreamStatuses(): Promise<Record<string, SessionStream
97
111
  }
98
112
 
99
113
  /**
100
- * Self-heal: `stream` rows belong to a live chamber process. After a restart
101
- * mid-run nothing will ever write the terminal status, so at read time a
102
- * `stream` row with no running session flips to `finish`.
114
+ * Whether a `stream` row has no live run behind it and must heal to `finish`.
115
+ *
116
+ * The owner pid is the only input: a row is live exactly while the process that
117
+ * wrote it is still running. EVERY chamber instance therefore reaches the same
118
+ * verdict from the same row — which is the point. Judging a row by the reader's
119
+ * own runtime registry instead made instances disagree about one run: a second
120
+ * instance opening its sidebar saw an empty registry, called the first
121
+ * instance's working run stale, and cleared a spinner that was still turning.
122
+ *
123
+ * A row with no owner predates the column and heals: new code always records a
124
+ * pid for `stream`, so an ownerless `stream` row can only come from an older
125
+ * process, whose run nothing can vouch for.
126
+ */
127
+ export function isStaleStreamRow(
128
+ row: { session_id: string; owner_pid: number | null },
129
+ isOwnerAlive: (ownerPid: number) => boolean,
130
+ ): boolean {
131
+ if (row.owner_pid === null) return true;
132
+ return !isOwnerAlive(row.owner_pid);
133
+ }
134
+
135
+ /**
136
+ * Self-heal: a `stream` row outlives its owner when a chamber process exits
137
+ * mid-run (crash, restart, SIGKILL) — nothing will ever write the terminal
138
+ * status for it. At read time every such row flips to `finish`, which is the
139
+ * honest outcome: the run is not going to finish.
140
+ *
141
+ * Deliberately takes no caller context. Liveness is the same question for every
142
+ * instance, so it is answered once, here, from the OS — the database is shared
143
+ * by all of them and they must all read the same status out of it.
144
+ *
145
+ * Runs on the sidebar loader's path, so the authoritative status travels with
146
+ * the same fetch that refreshes the list.
103
147
  */
104
- export async function healStaleStreamStatuses(runningIds: Set<string>): Promise<void> {
148
+ export async function healStaleStreamStatuses(): Promise<void> {
105
149
  try {
106
150
  const db = await getDb();
107
- const rows = (await db.all("SELECT session_id FROM session_stream_state WHERE status = 'stream'")) as {
151
+ const rows = (await db.all("SELECT session_id, owner_pid FROM session_stream_state WHERE status = 'stream'")) as {
108
152
  session_id: string;
153
+ owner_pid: number | null;
109
154
  }[];
110
- for (const row of rows) {
111
- if (!runningIds.has(row.session_id)) {
112
- await db.run("UPDATE session_stream_state SET status = 'finish', updated_at = CURRENT_TIMESTAMP WHERE session_id = ?", [
113
- row.session_id,
114
- ]);
115
- }
116
- }
155
+ const stale = rows.filter((row) => isStaleStreamRow(row, isProcessAlive)).map((row) => row.session_id);
156
+ if (stale.length === 0) return;
157
+ // `status = 'stream'` is re-checked in the WHERE clause: a run that started
158
+ // between the read above and this write owns its row now, and healing it
159
+ // would clear the spinner of a session that is demonstrably working.
160
+ const placeholders = stale.map(() => '?').join(', ');
161
+ await db.run(
162
+ `UPDATE session_stream_state SET status = 'finish', updated_at = CURRENT_TIMESTAMP
163
+ WHERE status = 'stream' AND session_id IN (${placeholders})`,
164
+ stale,
165
+ );
117
166
  } catch {
118
167
  // Best-effort.
119
168
  }
@@ -76,14 +76,20 @@ export function writeSetting(key: string, value: unknown): void {
76
76
  }
77
77
 
78
78
  /**
79
- * Write a patch of chamber-blob keys (`theme`, `streamTransport`, …). The blob
80
- * is kept whole so the durable copy stays one round-trippable object, while the
81
- * snapshot exposes its keys flat.
79
+ * Write a patch of chamber-blob keys (`theme`, `streamTransport`, …). The
80
+ * snapshot exposes the blob's keys flat, so an edit applies to the next read
81
+ * without a round-trip.
82
+ *
83
+ * Only the patch travels, and the server merges it into the stored blob. The
84
+ * whole local blob must not be republished: this page's copy was taken when it
85
+ * booted, so a tab left open across an external change (a second tab, a direct
86
+ * API call) would overwrite the newer keys with its own stale ones on the next
87
+ * unrelated write — which is exactly how a theme chosen elsewhere reverted.
82
88
  */
83
89
  export function writeChamberSettings(patch: Record<string, any>): void {
84
90
  chamberBlob = { ...chamberBlob, ...patch };
85
91
  snapshot = { ...current(), ...patch };
86
- sendSettings({ [CHAMBER_SETTINGS_KEY]: chamberBlob });
92
+ sendSettings({ [CHAMBER_SETTINGS_KEY]: patch });
87
93
  }
88
94
 
89
95
  /** POST a settings patch to SQLite. Fire-and-forget; failures are non-fatal. */
@@ -0,0 +1,31 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+
3
+ import { diffSettings } from '@/shared/lib/settings/diff';
4
+
5
+ describe('diffSettings', () => {
6
+ test('an object patch persists only the keys it actually changed', () => {
7
+ const prev = { theme: 'paper', soundAlerts: true, binaryPath: '/bin/omp' };
8
+ const next = { ...prev, theme: 'nord-dark' };
9
+ expect(diffSettings(prev, next, { theme: 'nord-dark' })).toEqual({ theme: 'nord-dark' });
10
+ });
11
+
12
+ test('a no-op patch persists nothing', () => {
13
+ const prev = { theme: 'paper', soundAlerts: true };
14
+ expect(diffSettings(prev, { ...prev }, { theme: 'paper' })).toEqual({});
15
+ });
16
+
17
+ test('an updater function persists every key whose value it moved', () => {
18
+ const prev = { keybindingSend: 'Enter', keybindingNewLine: 'Shift + Enter' };
19
+ const next = { keybindingSend: 'Ctrl / Cmd + Enter', keybindingNewLine: 'Enter' };
20
+ expect(diffSettings(prev, next, (state) => ({ ...state, ...next }))).toEqual(next);
21
+ });
22
+
23
+ test('an unrelated update never republishes the theme it read', () => {
24
+ // The regression this exists for: a tab that booted on `paper` toggling an
25
+ // unrelated switch in a settings modal whose snapshot predates an external
26
+ // theme change must not write `theme` back.
27
+ const prev = { theme: 'paper', chatCompletionSound: true };
28
+ const patch = { chatCompletionSound: false };
29
+ expect(diffSettings(prev, { ...prev, ...patch }, patch)).toEqual({ chatCompletionSound: false });
30
+ });
31
+ });
@@ -0,0 +1,37 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The subset of a settings update that should be persisted.
8
+ *
9
+ * The settings modal keeps a full `SettingsState` snapshot from the moment it
10
+ * opened and hands every edit to one writer. Writing the whole object back
11
+ * republishes values the user never touched, from a snapshot that may be older
12
+ * than what another writer (a second tab, an API call) has since stored — the
13
+ * stale copy wins purely because it was written last.
14
+ *
15
+ * A key is persisted only when the update changed it:
16
+ *
17
+ * - **Object patch** — the keys it names, but only those whose value actually
18
+ * differs, so a no-op re-render does not touch the row.
19
+ * - **Updater function** — every key whose value differs between `prev` and
20
+ * `next`; the function's own intent is not inspectable, so the diff is.
21
+ *
22
+ * Values are compared by identity, which is what `SettingsState` holds: strings,
23
+ * numbers and booleans. A nested object would compare by reference and be
24
+ * reported as changed — acceptable here, and better than dropping a real edit.
25
+ */
26
+ export function diffSettings<T extends Record<string, any>>(
27
+ prev: T,
28
+ next: T,
29
+ update: Partial<T> | ((prev: T) => T),
30
+ ): Partial<T> {
31
+ const patch: Partial<T> = {};
32
+ const keys = typeof update === 'function' ? (Object.keys(next) as (keyof T)[]) : (Object.keys(update) as (keyof T)[]);
33
+ for (const key of keys) {
34
+ if (next[key] !== prev[key]) patch[key] = next[key];
35
+ }
36
+ return patch;
37
+ }