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
@@ -1,8 +1,8 @@
1
1
  import { json, type ActionFunctionArgs } from '@/server/lib/remix-compat';
2
- import { ripgrep } from 'ripgrep';
3
2
  import { isMockMode } from '@/server/mock.server';
4
3
  import { getDefaultFsRoot, resolveRoot } from '@/server/lib/fs/root';
5
4
  import { scopeToRepo } from '@/server/lib/fs/repo-scope';
5
+ import { runRipgrep } from '@/server/lib/fs/ripgrep';
6
6
  import { createSseStream } from '@/server/lib/sse';
7
7
 
8
8
  export interface SearchMatch {
@@ -86,38 +86,29 @@ export async function action({ request }: ActionFunctionArgs) {
86
86
  async onStart(handlers) {
87
87
  const send = (event: string, data: unknown) => handlers.send(event, data);
88
88
 
89
- // WASI preopens map the guest "." onto the real target directory.
90
- const preopens = { '.': targetDir };
91
89
  const decoder = new TextDecoder();
92
90
  let pending = '';
93
91
  let count = 0;
94
92
  let stderrText = '';
95
93
 
96
94
  try {
97
- const { code } = await ripgrep(buildArgs(q, matchCase, wholeWord, useRegex, includeFiles), {
98
- preopens,
99
- // ripgrep accepts any `{ write(chunk) }` sink — feed it the raw
100
- // WritableStream sink so matches flush incrementally.
101
- stdout: {
102
- write(chunk) {
103
- pending += decoder.decode(chunk, { stream: true });
104
- const lines = pending.split('\n');
105
- pending = lines.pop() ?? '';
106
- const batch: SearchMatch[] = [];
107
- for (const line of lines) {
108
- const match = parseRgLine(line);
109
- if (match) batch.push(match);
110
- }
111
- if (batch.length) {
112
- count += batch.length;
113
- send('matches', batch);
114
- }
115
- },
95
+ const code = await runRipgrep(buildArgs(q, matchCase, wholeWord, useRegex, includeFiles), targetDir, {
96
+ onStdout(chunk) {
97
+ pending += decoder.decode(chunk, { stream: true });
98
+ const lines = pending.split('\n');
99
+ pending = lines.pop() ?? '';
100
+ const batch: SearchMatch[] = [];
101
+ for (const line of lines) {
102
+ const match = parseRgLine(line);
103
+ if (match) batch.push(match);
104
+ }
105
+ if (batch.length) {
106
+ count += batch.length;
107
+ send('matches', batch);
108
+ }
116
109
  },
117
- stderr: {
118
- write(chunk) {
119
- stderrText += decoder.decode(chunk, { stream: true });
120
- },
110
+ onStderr(chunk) {
111
+ stderrText += decoder.decode(chunk, { stream: true });
121
112
  },
122
113
  });
123
114
  // rg exit codes: 0 = matches, 1 = no matches, 2 = error (bad regex, IO failure).
@@ -22,7 +22,7 @@ import {
22
22
  reorderQueue,
23
23
  } from '@/server/lib/queue/store.server';
24
24
  import { getRpcSession } from '@/server/lib/omp/rpc/manager';
25
- import { scheduleQueueDelivery } from '@/server/lib/queue/delivery.server';
25
+ import { deliverQueueNow } from '@/server/lib/queue/delivery.server';
26
26
 
27
27
  /** GET — the session's queued follow-ups in delivery order. */
28
28
  export async function getQueue({ params }: LoaderFunctionArgs) {
@@ -108,6 +108,17 @@ export async function reorderQueueItems({ request, params }: ActionFunctionArgs)
108
108
  * the server a delivery slot. The server-side claim makes this a no-op when a
109
109
  * run is active or the head was already claimed; an idle session with queued
110
110
  * items gets its head delivered exactly once.
111
+ *
112
+ * The attempt runs IMMEDIATELY rather than arming the usual settle timer. This
113
+ * is the recovery path, and the failure it recovers from is a lost timer — a
114
+ * long-lived `--hot` dev server whose `setTimeout` callbacks stop firing leaves
115
+ * `scheduleQueueDelivery` scheduling into the void, so a nudge that merely
116
+ * re-armed another timer would fail exactly like the delivery it was meant to
117
+ * rescue. The 500ms settle exists to let omp's own turn-end bookkeeping finish;
118
+ * by the time a client nudges, that window has long passed.
119
+ *
120
+ * The response carries the CANONICAL queue, so a client polling this as a
121
+ * safety net gets its panel reconciled in the same round trip.
111
122
  */
112
123
  export async function nudgeQueueDelivery({ request, params }: ActionFunctionArgs) {
113
124
  const sessionId = requireParam(params, 'sessionId');
@@ -115,7 +126,9 @@ export async function nudgeQueueDelivery({ request, params }: ActionFunctionArgs
115
126
  if (request.method !== 'POST') return json({ error: 'Method not allowed' }, { status: 405 });
116
127
 
117
128
  const session = getRpcSession(sessionId);
118
- if (!session?.isAlive()) return json({ success: true, delivered: false, reason: 'session-not-live' });
119
- scheduleQueueDelivery(session);
120
- return json({ success: true });
129
+ if (!session?.isAlive()) {
130
+ return json({ success: true, delivered: false, reason: 'session-not-live', queue: await listQueue(sessionId) });
131
+ }
132
+ const delivered = await deliverQueueNow(session);
133
+ return json({ success: true, delivered, queue: await listQueue(sessionId) });
121
134
  }
@@ -4,6 +4,7 @@ import * as settingsAgents from '@/server/routes/settings/agents';
4
4
  import * as settingsBehavior from '@/server/routes/settings/behavior';
5
5
  import * as settingsCommands from '@/server/routes/settings/commands';
6
6
  import * as settingsMcp from '@/server/routes/settings/mcp';
7
+ import * as settingsModelOverride from '@/server/routes/settings/model-override';
7
8
  import * as settingsMcpTest from '@/server/routes/settings/mcp-test';
8
9
  import * as settingsOmpConfig from '@/server/routes/settings/omp-config';
9
10
  import * as settingsProjects from '@/server/routes/settings/projects';
@@ -18,6 +19,7 @@ export const settingsBindings: HandlerBinding[] = [
18
19
  ...bindingsFor(settingsBehavior, '/api/settings/behavior'),
19
20
  ...bindingsFor(settingsCommands, '/api/settings/commands'),
20
21
  ...bindingsFor(settingsMcp, '/api/settings/mcp'),
22
+ ...bindingsFor(settingsModelOverride, '/api/settings/model-override'),
21
23
  ...bindingsFor(settingsMcpTest, '/api/settings/mcp-test'),
22
24
  ...bindingsFor(settingsOmpConfig, '/api/settings/omp-config'),
23
25
  ...bindingsFor(settingsProjects, '/api/settings/projects'),
@@ -0,0 +1,69 @@
1
+ import { json } from '@/server/lib/remix-compat';
2
+ import type { ActionFunctionArgs } from '@/server/lib/remix-compat';
3
+ import { methodNotAllowed } from '@/server/lib/route-adapter';
4
+ import { isMockMode } from '@/server/mock.server';
5
+ import { writeModelOverride, type ReasoningEffort } from '@/server/lib/omp/config/model-overrides';
6
+ import { invalidateModelsCaches } from '@/shared/lib/models/server-cache';
7
+
8
+ /**
9
+ * PUT /api/settings/model-override — the per-model knobs omp actually honours.
10
+ *
11
+ * omp resolves `maxTokens` and the reasoning `thinking` block per model from
12
+ * models.yml `modelOverrides`, and has no per-model temperature/topP at all
13
+ * (those exist only as global `config.yml` settings, editable in Settings →
14
+ * OMP Engine). Writing the chamber's model dialog anywhere else made its
15
+ * controls inert, so this route is the one that reaches the agent.
16
+ *
17
+ * Body: { provider, modelId, maxTokens?: number|null, reasoningEffort?: string|null }
18
+ */
19
+
20
+ const EFFORTS: readonly ReasoningEffort[] = ['minimal', 'low', 'medium', 'high', 'xhigh', 'max'];
21
+
22
+ export async function action({ request, params }: ActionFunctionArgs) {
23
+ if (request.method !== 'PUT' && request.method !== 'POST') {
24
+ return methodNotAllowed({ request, params });
25
+ }
26
+ try {
27
+ const body = await request.json() as {
28
+ provider?: unknown;
29
+ modelId?: unknown;
30
+ maxTokens?: unknown;
31
+ reasoningEffort?: unknown;
32
+ };
33
+ const provider = typeof body.provider === 'string' ? body.provider.trim() : '';
34
+ const modelId = typeof body.modelId === 'string' ? body.modelId.trim() : '';
35
+ if (!provider || !modelId) {
36
+ return json({ error: 'provider and modelId are required' }, { status: 400 });
37
+ }
38
+
39
+ // `null` means "clear the override"; `undefined` means "leave it alone".
40
+ let maxTokens: number | null | undefined;
41
+ if (body.maxTokens === null) maxTokens = null;
42
+ else if (typeof body.maxTokens === 'number' && Number.isFinite(body.maxTokens) && body.maxTokens > 0) {
43
+ maxTokens = Math.round(body.maxTokens);
44
+ } else if (body.maxTokens !== undefined) {
45
+ return json({ error: 'maxTokens must be a positive number or null' }, { status: 400 });
46
+ }
47
+
48
+ let reasoningEffort: ReasoningEffort | null | undefined;
49
+ if (body.reasoningEffort === null) reasoningEffort = null;
50
+ else if (typeof body.reasoningEffort === 'string' && (EFFORTS as readonly string[]).includes(body.reasoningEffort)) {
51
+ reasoningEffort = body.reasoningEffort as ReasoningEffort;
52
+ } else if (body.reasoningEffort !== undefined) {
53
+ return json({ error: `reasoningEffort must be one of ${EFFORTS.join(', ')} or null` }, { status: 400 });
54
+ }
55
+
56
+ if (maxTokens === undefined && reasoningEffort === undefined) {
57
+ return json({ error: 'nothing to write' }, { status: 400 });
58
+ }
59
+
60
+ // Mock mode has no models.yml to edit; the overlay already carries the value.
61
+ if (isMockMode()) return json({ success: true, written: false, isMock: true });
62
+
63
+ const result = await writeModelOverride({ provider, modelId, maxTokens, reasoningEffort });
64
+ if (result.written) invalidateModelsCaches();
65
+ return json({ success: result.written, ...result });
66
+ } catch (error) {
67
+ return json({ error: error instanceof Error ? error.message : String(error) }, { status: 400 });
68
+ }
69
+ }
@@ -124,10 +124,26 @@ async function fetchRemoteModels(baseUrl: string, apiKey?: string): Promise<Fetc
124
124
  };
125
125
  }
126
126
 
127
+ /**
128
+ * omp's `api` for a custom provider, inferred from its endpoint. omp accepts
129
+ * eleven `api` values and there is no way to ask a gateway which one it speaks,
130
+ * so the chamber only claims the two dialects it can recognise by URL; every
131
+ * other endpoint gets the OpenAI-compatible default, which is what the
132
+ * model-listing probe already assumes. A gateway that speaks something else
133
+ * (Azure, Bedrock, Vertex, …) needs its `api` set by hand in models.yml.
134
+ */
135
+ function inferProviderApi(baseUrl: string): 'openai-completions' | 'anthropic-messages' | 'google-generative-ai' {
136
+ if (/anthropic\./i.test(baseUrl)) return 'anthropic-messages';
137
+ if (/generativelanguage\.googleapis\.com/i.test(baseUrl)) return 'google-generative-ai';
138
+ return 'openai-completions';
139
+ }
140
+
127
141
  /**
128
142
  * omp's models.yml schema requires all four cost fields when cost is present,
129
143
  * so a price is only seeded when both sides are known; missing ones default
130
144
  * to 0. Context/output labels ("128K ctx · 16K out") parse back to tokens.
145
+ * Cache prices come from the catalog and are kept when known — dropping them
146
+ * makes every cached turn look free in omp's usage accounting.
131
147
  */
132
148
  function toOmpSeed(model: ProviderModel): {
133
149
  id: string;
@@ -148,7 +164,14 @@ function toOmpSeed(model: ProviderModel): {
148
164
  };
149
165
  const contextWindow = parseTokens(model.contextWindow, 'ctx');
150
166
  const outTokens = parseTokens(model.contextWindow, 'out');
151
- const hasCost = typeof model.priceInput === 'number' && typeof model.priceOutput === 'number';
167
+ // omp rejects the whole file on a non-finite number, so a price has to be a
168
+ // real finite value before it may be written at all.
169
+ const price = (value: unknown): number | undefined => (
170
+ typeof value === 'number' && Number.isFinite(value) ? value : undefined
171
+ );
172
+ const priceInput = price(model.priceInput);
173
+ const priceOutput = price(model.priceOutput);
174
+ const hasCost = priceInput !== undefined && priceOutput !== undefined;
152
175
  return {
153
176
  id: model.id,
154
177
  ...(model.name && model.name !== model.id ? { name: model.name } : {}),
@@ -158,10 +181,10 @@ function toOmpSeed(model: ProviderModel): {
158
181
  ...(outTokens && outTokens > 0 ? { maxTokens: Math.round(outTokens) } : {}),
159
182
  ...(hasCost ? {
160
183
  cost: {
161
- input: model.priceInput as number,
162
- output: model.priceOutput as number,
163
- cacheRead: 0,
164
- cacheWrite: 0,
184
+ input: priceInput,
185
+ output: priceOutput,
186
+ cacheRead: price(model.priceCacheRead) ?? 0,
187
+ cacheWrite: price(model.priceCacheWrite) ?? 0,
165
188
  },
166
189
  } : {}),
167
190
  };
@@ -170,8 +193,8 @@ function toOmpSeed(model: ProviderModel): {
170
193
  /**
171
194
  * Fill in metadata the listing endpoint did not provide from the models.dev
172
195
  * catalog: vision/reasoning/tool flags, context + output window, and
173
- * per-1M-token pricing. Only empty fields are filled — anything the provider
174
- * reported itself is kept.
196
+ * per-1M-token pricing (cache rates included). Only empty fields are filled —
197
+ * anything the provider reported itself is kept.
175
198
  */
176
199
  async function enrichFromCatalog(
177
200
  models: ProviderModel[],
@@ -201,6 +224,10 @@ async function enrichFromCatalog(
201
224
  hasTools: model.hasTools || info.tool_call === true,
202
225
  priceInput: model.priceInput ?? (typeof info.cost?.input === 'number' ? info.cost.input : undefined),
203
226
  priceOutput: model.priceOutput ?? (typeof info.cost?.output === 'number' ? info.cost.output : undefined),
227
+ priceCacheRead: model.priceCacheRead
228
+ ?? (typeof info.cost?.cache_read === 'number' ? info.cost.cache_read : undefined),
229
+ priceCacheWrite: model.priceCacheWrite
230
+ ?? (typeof info.cost?.cache_write === 'number' ? info.cost.cache_write : undefined),
204
231
  };
205
232
  });
206
233
  }
@@ -244,11 +271,7 @@ export async function action({ request }: ActionFunctionArgs) {
244
271
  const upsert = await upsertOmpProviderModels(providerSlug, {
245
272
  baseUrl,
246
273
  apiKey,
247
- api: /anthropic\.com/i.test(baseUrl)
248
- ? 'anthropic-messages'
249
- : /generativelanguage\.googleapis\.com/i.test(baseUrl)
250
- ? 'google-generative-ai'
251
- : 'openai-completions',
274
+ api: inferProviderApi(baseUrl),
252
275
  models: enriched.map(toOmpSeed),
253
276
  });
254
277
  if (upsert.written) invalidateModelsCaches();
@@ -7,6 +7,7 @@ import { isMockMode } from '@/server/mock.server';
7
7
  import type { ProviderItem } from '@/shared/types';
8
8
  import { createSettingsListStore } from '@/server/lib/db/settings-store';
9
9
  import { disableNativeProvider, enableNativeProvider } from '@/server/lib/omp/config/disabled-providers';
10
+ import { removeOmpProvider } from '@/server/lib/omp/config/providers';
10
11
  import { PROVIDERS_SETTINGS_KEY as SETTINGS_KEY, deduplicateProviderItems, mergeProviders } from '@/server/lib/models/provider-registry.server';
11
12
  import { invalidateModelsCaches } from '@/shared/lib/models/server-cache';
12
13
 
@@ -111,8 +112,37 @@ export async function action({ request, params }: ActionFunctionArgs) {
111
112
  const id = url.searchParams.get('id');
112
113
  if (!id) return json({ error: 'id is required' }, { status: 400 });
113
114
 
115
+ const stored = await readStoredProviders();
116
+ const target = stored.find((provider) => provider.id === id);
114
117
  const list = await providersStore.remove(db, id, { absent: DEFAULT_PROVIDERS_LIST, empty: DEFAULT_PROVIDERS_LIST });
115
- return respondWithProviders(list);
118
+
119
+ // Removing the overlay row alone left the provider registered in
120
+ // models.yml, so omp kept serving it and the next load merged it straight
121
+ // back into this list — a delete that visibly did nothing. Unregister it
122
+ // from the agent's own registry too.
123
+ let removedFromOmp = false;
124
+ if (target?.slug && !isMockMode()) {
125
+ try {
126
+ removedFromOmp = (await removeOmpProvider(target.slug)).removed;
127
+ // A provider omp still holds in disabledProviders would linger as a
128
+ // "disconnected" ghost entry after its models are gone.
129
+ await enableNativeProvider(target.slug);
130
+ } catch (error) {
131
+ // The overlay row is already gone; report the native failure instead
132
+ // of failing the whole request, so the UI can tell the user the entry
133
+ // is still in models.yml.
134
+ const merged = await mergeProviders(list).catch(() => null);
135
+ return json({
136
+ success: true,
137
+ providers: merged?.providers ?? list,
138
+ removedFromOmp: false,
139
+ warning: error instanceof Error ? error.message : 'models.yml cleanup failed',
140
+ });
141
+ }
142
+ }
143
+ invalidateModelsCaches();
144
+ const merged = await mergeProviders(list);
145
+ return json({ success: true, providers: merged.providers, removedFromOmp });
116
146
  }
117
147
 
118
148
  if (request.method === 'POST' || request.method === 'PUT') {
@@ -3,7 +3,7 @@ import type { ActionFunctionArgs, LoaderFunctionArgs } from '@/server/lib/remix-
3
3
  import { methodNotAllowed } from '@/server/lib/route-adapter';
4
4
  import { getDb } from '@/server/db.server';
5
5
  import { isMockMode } from '@/server/mock.server';
6
- import { readAllSettingsJson, writeSettingsJson, writeSettingsRaw } from '@/server/lib/db/settings-store';
6
+ import { isPlainObject, mergeSettingsJson, readAllSettingsJson, writeSettingsJson, writeSettingsRaw } from '@/server/lib/db/settings-store';
7
7
 
8
8
  export async function loader({ request: _request }: LoaderFunctionArgs) {
9
9
  try {
@@ -24,9 +24,14 @@ export async function action({ request, params }: ActionFunctionArgs) {
24
24
  const data = await request.json();
25
25
  const db = await getDb();
26
26
 
27
- // Data should be an object of key-value pairs
27
+ // Data should be an object of key-value pairs. Object values merge into
28
+ // whatever the key already holds, so a writer that sends one key's patch —
29
+ // the settings modal now sends only what changed — cannot discard the keys
30
+ // another writer stored in between. Arrays and scalars still replace.
28
31
  for (const [key, value] of Object.entries(data)) {
29
- if (typeof value === 'object') {
32
+ if (isPlainObject(value)) {
33
+ await mergeSettingsJson(db, key, value);
34
+ } else if (typeof value === 'object') {
30
35
  await writeSettingsJson(db, key, value);
31
36
  } else {
32
37
  await writeSettingsRaw(db, key, String(value));
@@ -14,6 +14,8 @@
14
14
  */
15
15
 
16
16
  import type { AgentImage, BtwFrame, BtwState, StreamTransport } from '@/shared/types';
17
+ import type { AttachedTextFileData } from '@/shared/lib/chat/attachments';
18
+ import type { ApprovalMode } from '@/shared/lib/omp/config/access-mode';
17
19
  import { connectEvents } from '@/shared/lib/chat/omp/sse';
18
20
  import { connectSocket } from '@/shared/lib/chat/omp/socket';
19
21
  import { btwEventsUrl, btwSocketUrl } from '@/shared/lib/chat/omp/transport';
@@ -70,6 +72,13 @@ export interface AskBtwInput {
70
72
  topicId?: string;
71
73
  question: string;
72
74
  images?: AgentImage[];
75
+ /** Text files inlined for this question (read at attach time by the composer). */
76
+ textFiles?: AttachedTextFileData[];
77
+ /** Composer picks for a FIRST question — a topic that does not exist yet has
78
+ * no row to hold them, so they ride the ask and reach the spawn. */
79
+ model?: { provider: string; id: string };
80
+ thinkingLevel?: string;
81
+ approvalMode?: ApprovalMode;
73
82
  }
74
83
 
75
84
  export function askBtwQuestion(sessionId: string, input: AskBtwInput): Promise<BtwState> {
@@ -78,6 +87,10 @@ export function askBtwQuestion(sessionId: string, input: AskBtwInput): Promise<B
78
87
  question: input.question,
79
88
  ...(input.topicId ? { topicId: input.topicId } : {}),
80
89
  ...(input.images?.length ? { images: input.images } : {}),
90
+ ...(input.textFiles?.length ? { textFiles: input.textFiles } : {}),
91
+ ...(input.model ? { provider: input.model.provider, modelId: input.model.id } : {}),
92
+ ...(input.thinkingLevel ? { thinkingLevel: input.thinkingLevel } : {}),
93
+ ...(input.approvalMode ? { accessMode: input.approvalMode } : {}),
81
94
  });
82
95
  }
83
96
 
@@ -85,6 +98,27 @@ export function abortBtwQuestion(sessionId: string, topicId: string): Promise<Bt
85
98
  return requestBtw<BtwState>(sessionId, 'POST', { action: 'abort', topicId });
86
99
  }
87
100
 
101
+ export function setBtwModel(sessionId: string, topicId: string, provider: string, modelId: string): Promise<BtwState> {
102
+ return requestBtw<BtwState>(sessionId, 'POST', { action: 'set_model', topicId, provider, modelId });
103
+ }
104
+
105
+ export function setBtwThinkingLevel(sessionId: string, topicId: string, level: string): Promise<BtwState> {
106
+ return requestBtw<BtwState>(sessionId, 'POST', { action: 'set_thinking_level', topicId, level });
107
+ }
108
+
109
+ export function setBtwAccessMode(sessionId: string, topicId: string, accessMode: ApprovalMode): Promise<BtwState> {
110
+ return requestBtw<BtwState>(sessionId, 'POST', { action: 'set_access_mode', topicId, accessMode });
111
+ }
112
+
113
+ export function respondBtwDialog(
114
+ sessionId: string,
115
+ topicId: string,
116
+ id: string,
117
+ response: { value: string } | { confirmed: boolean } | { cancelled: true },
118
+ ): Promise<BtwState> {
119
+ return requestBtw<BtwState>(sessionId, 'POST', { action: 'dialog_response', topicId, id, response });
120
+ }
121
+
88
122
  export function promoteBtwTopic(sessionId: string, topicId: string): Promise<{ sessionId: string }> {
89
123
  return requestBtw<{ sessionId: string }>(sessionId, 'POST', { action: 'promote', topicId });
90
124
  }
@@ -90,10 +90,17 @@ export const DEVICE_VERBS: Record<string, string> = {
90
90
  * Phrase shown while the model streams reasoning or prose rather than running a
91
91
  * tool. A tool call always names itself from its own name (see
92
92
  * `describeAssistantPhase`), so there is no in-between "preparing" state.
93
+ *
94
+ * `sideQuestion` is the BTW panel's FALLBACK phase, used only before the side
95
+ * child has reported anything: once it does, the panel shows the child's own
96
+ * per-phase verb (`Thinking`, `Writing response`, or the running tool's name).
97
+ * It is named for the feature rather than reusing `thinking`, which would claim
98
+ * a reasoning phase that has not been observed yet.
93
99
  */
94
100
  export const PHASE_VERBS = {
95
101
  thinking: 'Thinking',
96
102
  writing: 'Writing response',
103
+ sideQuestion: 'Answering side question',
97
104
  } as const;
98
105
 
99
106
  export function asString(value: unknown): string | undefined {
@@ -0,0 +1,38 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ import { describe, expect, test } from 'bun:test';
7
+ import { detectLineEnding, toDiskText, toLf } from '@/shared/lib/code/line-endings';
8
+
9
+ describe('line endings', () => {
10
+ test('the first terminator decides the style', () => {
11
+ expect(detectLineEnding('a\r\nb\n')).toBe('crlf');
12
+ expect(detectLineEnding('a\nb\r\n')).toBe('lf');
13
+ });
14
+
15
+ test('a file with no terminator, or one using lone CR, reads as lf', () => {
16
+ expect(detectLineEnding('single line')).toBe('lf');
17
+ expect(detectLineEnding('\rleading')).toBe('lf');
18
+ expect(detectLineEnding('a\rb\r')).toBe('lf');
19
+ });
20
+
21
+ test('the buffer form carries no CR', () => {
22
+ expect(toLf('a\r\nb\r\n')).toBe('a\nb\n');
23
+ expect(toLf('a\rb\r')).toBe('a\nb\n');
24
+ expect(toLf('a\nb\n')).toBe('a\nb\n');
25
+ });
26
+
27
+ test('writing back restores the detected ending', () => {
28
+ expect(toDiskText('a\nb\n', 'crlf')).toBe('a\r\nb\r\n');
29
+ expect(toDiskText('a\nb\n', 'lf')).toBe('a\nb\n');
30
+ // A mixed buffer still normalizes before it is re-encoded.
31
+ expect(toDiskText('a\r\nb\n', 'crlf')).toBe('a\r\nb\r\n');
32
+ });
33
+
34
+ test('an unedited CRLF round trip is byte-identical', () => {
35
+ const disk = 'const a = 1;\r\nconst b = 2;\r\n';
36
+ expect(toDiskText(toLf(disk), detectLineEnding(disk))).toBe(disk);
37
+ });
38
+ });
@@ -0,0 +1,56 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Line-ending preservation for the file-editing surfaces.
8
+ *
9
+ * Two independent transports destroy the distinction, so the file's own ending
10
+ * has to be carried out of band rather than inferred from the buffer:
11
+ *
12
+ * - A `<textarea>` cannot hold a CRLF. The HTML spec defines its *API value* as
13
+ * the raw value with every carriage return removed, so the instant a CRLF file
14
+ * is placed in the editing surface the buffer is LF-only — verified in
15
+ * Chromium: `textarea.value = 'a\r\nb'` reads back `'a\nb'`.
16
+ * - `multipart/form-data` normalizes the other way: every bare LF in a field
17
+ * value becomes CRLF in transit (the HTML serializer on the way out, Bun's
18
+ * parser agreeing on the way in). A save payload therefore cannot say "LF" no
19
+ * matter what the buffer held.
20
+ *
21
+ * Saving the buffer as-is rewrote the whole file's line endings on a
22
+ * one-character edit — every line showed as changed in `git diff`. The ending is
23
+ * detected at read and travels as its own field; the server builds the bytes.
24
+ * An unedited save is then byte-identical and an edited save a one-line diff.
25
+ *
26
+ * Detection is by the FIRST terminator rather than a vote over all of them: a
27
+ * file is overwhelmingly uniform, and the first ending is the one the author's
28
+ * editor writes. A file with no terminator, or one using lone CR (classic Mac
29
+ * OS), reads as `lf`.
30
+ */
31
+
32
+ export type LineEnding = 'lf' | 'crlf';
33
+
34
+ /** The ending the file uses, judged by its first terminator. */
35
+ export function detectLineEnding(text: string): LineEnding {
36
+ const index = text.indexOf('\n');
37
+ return index > 0 && text[index - 1] === '\r' ? 'crlf' : 'lf';
38
+ }
39
+
40
+ /**
41
+ * The buffer form: CRLF and lone CR collapsed to LF, matching what a textarea
42
+ * can actually hold. A string already free of CR is returned unchanged.
43
+ */
44
+ export function toLf(text: string): string {
45
+ return text.includes('\r') ? text.replace(/\r\n?/g, '\n') : text;
46
+ }
47
+
48
+ /**
49
+ * The disk form: `text` normalized — whatever mix of endings it carries, and
50
+ * whether or not it came back through a transport that rewrote them — and then
51
+ * written with `ending`.
52
+ */
53
+ export function toDiskText(text: string, ending: LineEnding): string {
54
+ const lf = toLf(text);
55
+ return ending === 'crlf' ? lf.replace(/\n/g, '\r\n') : lf;
56
+ }
@@ -3,12 +3,16 @@
3
3
  * SPDX-License-Identifier: Apache-2.0
4
4
  */
5
5
 
6
+ /**
7
+ * The two Shiki palettes every token carries.
8
+ *
9
+ * Shiki emits both palettes on every token span as `--shiki-light` /
10
+ * `--shiki-dark` custom properties with no inline color, so switching themes
11
+ * recolors code with zero JS and no re-highlight. `tailwind.css` selects which
12
+ * one paints, keyed off `<html data-theme-variant>` rather than a list of theme
13
+ * ids — that list is what a new theme used to fall through.
14
+ */
6
15
  export const SHIKI_LIGHT = 'one-light';
7
16
  export const SHIKI_DARK = 'one-dark-pro';
8
17
 
9
18
  export const SHIKI_THEMES = { light: SHIKI_LIGHT, dark: SHIKI_DARK } as const;
10
-
11
- /** Collapse any app theme id to the syntax theme key it uses. */
12
- export function resolveSyntaxThemeKey(theme?: string): 'light' | 'dark' {
13
- return theme === 'noir' || theme === 'one-dark-pro-soft' ? 'dark' : 'light';
14
- }
@@ -1,6 +1,9 @@
1
- export function setChildrenAt(nodes: any[], path: string, children: any[]): any[] {
1
+ import type { FsNode } from '@/shared/types';
2
+
3
+ /** Replace the children of the node at `path`, rebuilding only that branch. */
4
+ export function setChildrenAt(nodes: FsNode[], path: string, children: FsNode[]): FsNode[] {
2
5
  return nodes.map(node => {
3
- if (node.path === path) return { ...node, children, is_expanded: 1 };
6
+ if (node.path === path) return { ...node, children };
4
7
  if (Array.isArray(node.children)) {
5
8
  const nested = setChildrenAt(node.children, path, children);
6
9
  if (nested !== node.children) return { ...node, children: nested };
@@ -9,18 +12,20 @@ export function setChildrenAt(nodes: any[], path: string, children: any[]): any[
9
12
  });
10
13
  }
11
14
 
12
- export function rehydrateTree(nodes: any[], cache: Record<string, any[]>, expanded: Set<string>): any[] {
15
+ /**
16
+ * Re-attach the children already loaded for a folder, so a re-listing of the
17
+ * same tree does not collapse what the user had open.
18
+ *
19
+ * Which folders are open is the caller's `expandedPaths` set, keyed by paths
20
+ * RELATIVE to the listed root — which is why the caller must drop both that set
21
+ * and the cache when the listed root changes: `src/` under one workspace would
22
+ * otherwise rehydrate into `src/` under the next.
23
+ */
24
+ export function rehydrateTree(nodes: FsNode[], cache: Record<string, FsNode[]>): FsNode[] {
13
25
  return nodes.map(node => {
14
26
  if (node.type !== 'folder') return node;
15
27
  const cached = cache[node.path];
16
- const wasExpanded = expanded.has(node.path);
17
- if (cached) {
18
- return {
19
- ...node,
20
- children: rehydrateTree(cached, cache, expanded),
21
- is_expanded: wasExpanded ? 1 : 0,
22
- };
23
- }
24
- return node;
28
+ if (!cached) return node;
29
+ return { ...node, children: rehydrateTree(cached, cache) };
25
30
  });
26
31
  }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ import { describe, expect, test } from 'bun:test';
7
+ import { renderMarkdown } from '@/shared/lib/markdown/marked';
8
+
9
+ describe('autolink vs code span', () => {
10
+ test('a backticked email stays a code span, not a mailto link', () => {
11
+ const html = renderMarkdown('addresses (`NNN+login@users.noreply.github.com`) and more');
12
+ expect(html).toContain('<code class="inline-code">NNN+login@users.noreply.github.com</code>');
13
+ expect(html).not.toContain('mailto:');
14
+ expect(html).not.toContain('`');
15
+ });
16
+
17
+ test('a backticked URL stays a code span', () => {
18
+ const html = renderMarkdown('see `https://bun.sh/docs` for more');
19
+ expect(html).toContain('<code class="inline-code">https://bun.sh/docs</code>');
20
+ });
21
+
22
+ test('ordinary autolinks are untouched', () => {
23
+ expect(renderMarkdown('see https://bun.sh/docs for more')).toContain('<a href="https://bun.sh/docs">');
24
+ expect(renderMarkdown('write to a@b.com now')).toContain('<a href="mailto:a@b.com">');
25
+ });
26
+
27
+ test('a URL next to a code span keeps both', () => {
28
+ const html = renderMarkdown('https://bun.sh then `npm i`');
29
+ expect(html).toContain('<a href="https://bun.sh">');
30
+ expect(html).toContain('<code class="inline-code">npm i</code>');
31
+ });
32
+ });