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,167 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The prepend anchor vs. a bottom jump. The anchor exists to hold a READING
8
+ * position when older history is paged in above the viewport, and it writes
9
+ * `scrollTop` directly (which CSS `scroll-behavior: smooth` then animates). A
10
+ * window that landed after the user asked for the tail therefore wrote
11
+ * `scrollTop` straight over the jump: reproduced live in the chamber against a
12
+ * session with older pages — the viewport stopped ~26k px short of the tail
13
+ * (the anchor's own target), and only the next click reached the bottom, once
14
+ * no window was left to page in.
15
+ *
16
+ * Runs against a real DOM (happy-dom) because the hook is a Preact hook;
17
+ * geometry and `fetch` are faked (happy-dom does no layout and no network).
18
+ */
19
+
20
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
21
+ import { Window } from 'happy-dom';
22
+ import type { Dispatch, SetStateAction } from 'preact/compat';
23
+ import type { h as PreactH, render as PreactRender } from 'preact';
24
+ import type { act as PreactAct } from 'preact/test-utils';
25
+ import type { useSessionPagination as UsePagination, SessionPagination } from '@/client/hooks/chat/timeline/session-pagination';
26
+ import type { ChatMessageData } from '@/shared/types';
27
+
28
+ let useSessionPagination: typeof UsePagination;
29
+ let h: typeof PreactH;
30
+ let render: typeof PreactRender;
31
+ let act: typeof PreactAct;
32
+
33
+ let win: Window;
34
+ let container: HTMLDivElement;
35
+ let scrollEl: HTMLDivElement;
36
+ /** Prepend this many pixels of older rows when the stubbed window resolves. */
37
+ let prependHeightPx = 1000;
38
+ let fetchCount = 0;
39
+
40
+ const geometry = { top: 0, height: 5000, viewport: 500 };
41
+
42
+ function installScroller(el: HTMLDivElement) {
43
+ Object.defineProperty(el, 'scrollHeight', { get: () => geometry.height, configurable: true });
44
+ Object.defineProperty(el, 'clientHeight', { get: () => geometry.viewport, configurable: true });
45
+ Object.defineProperty(el, 'scrollTop', {
46
+ get: () => geometry.top,
47
+ set: (value: number) => {
48
+ geometry.top = Math.max(0, Math.min(value, geometry.height - geometry.viewport));
49
+ },
50
+ configurable: true,
51
+ });
52
+ Object.defineProperty(el, 'scrollTo', { value: () => {}, configurable: true });
53
+ }
54
+
55
+ /** The two refs the scroll hook owns and the pagination hook reads. */
56
+ const jumpActiveRef = { current: false };
57
+ const jumpCountRef = { current: 0 };
58
+ const sessionIdRef = { current: 's1' };
59
+ const scrollRef: { current: HTMLDivElement | null } = { current: null };
60
+ /** The hook prepends through this; the commit under test comes from its own
61
+ * prepend tick, so recording the update is all this needs to do. */
62
+ const setLocalMessages: Dispatch<SetStateAction<ChatMessageData[]>> = () => {};
63
+
64
+ let pagination: SessionPagination;
65
+
66
+ function Probe() {
67
+ pagination = useSessionPagination({
68
+ sessionId: 's1',
69
+ sessionIdRef,
70
+ setLocalMessages,
71
+ scrollRef,
72
+ jumpActiveRef,
73
+ jumpCountRef,
74
+ });
75
+ return null;
76
+ }
77
+
78
+ /** A window that prepends `prependHeightPx` of older rows when it lands. */
79
+ function stubWindowFetch() {
80
+ (globalThis as Record<string, unknown>).fetch = async () => {
81
+ fetchCount += 1;
82
+ // The prepend commit is where the anchor reads the new scrollHeight.
83
+ geometry.height += prependHeightPx;
84
+ return {
85
+ ok: true,
86
+ json: async () => ({
87
+ session: { messages: [{ id: 'old-1', role: 'user', content: 'old' }] },
88
+ hasMore: false,
89
+ oldestIndex: 0,
90
+ }),
91
+ };
92
+ };
93
+ }
94
+
95
+ async function settle() {
96
+ for (let i = 0; i < 10; i++) await Promise.resolve();
97
+ await act(async () => { for (let i = 0; i < 10; i++) await Promise.resolve(); });
98
+ }
99
+
100
+ beforeAll(async () => {
101
+ win = new Window({ url: 'http://localhost' });
102
+ for (const key of ['window', 'document', 'navigator', 'Node', 'Element', 'HTMLElement', 'Event'] as const) {
103
+ (globalThis as Record<string, unknown>)[key] = (win as unknown as Record<string, unknown>)[key];
104
+ }
105
+ // Static imports cannot work here: Preact binds its environment at module
106
+ // evaluation time, so the modules below must load after those DOM globals.
107
+ ({ useSessionPagination } = await import('@/client/hooks/chat/timeline/session-pagination'));
108
+ ({ h, render } = await import('preact'));
109
+ ({ act } = await import('preact/test-utils'));
110
+ });
111
+
112
+ afterAll(() => {
113
+ delete (globalThis as Record<string, unknown>).fetch;
114
+ for (const key of ['window', 'document', 'navigator', 'Node', 'Element', 'HTMLElement', 'Event']) {
115
+ delete (globalThis as Record<string, unknown>)[key];
116
+ }
117
+ });
118
+
119
+ beforeEach(async () => {
120
+ geometry.top = 0;
121
+ geometry.height = 5000;
122
+ fetchCount = 0;
123
+ jumpActiveRef.current = false;
124
+ jumpCountRef.current = 0;
125
+ stubWindowFetch();
126
+ container = document.createElement('div');
127
+ document.body.appendChild(container);
128
+ scrollEl = document.createElement('div');
129
+ installScroller(scrollEl);
130
+ scrollRef.current = scrollEl;
131
+ await act(async () => { render(h(Probe, {}), container); });
132
+ await act(async () => { pagination.applyWindow(true, 100); });
133
+ });
134
+
135
+ describe('prepend anchor vs bottom jump', () => {
136
+ test('holds the reading position when the user has not asked for the tail', async () => {
137
+ await act(async () => { pagination.loadOlder(); });
138
+ await settle();
139
+
140
+ expect(fetchCount).toBe(1);
141
+ expect(geometry.height).toBe(6000);
142
+ // prevTop (0) + the prepended height: the rows being read stay put.
143
+ expect(scrollEl.scrollTop).toBe(1000);
144
+ });
145
+
146
+ test('is dropped when the user asked for the tail after the request went out', async () => {
147
+ const requested = pagination.loadOlder();
148
+ // The user clicks scroll-to-bottom while the window is still in flight.
149
+ jumpCountRef.current += 1;
150
+ await requested;
151
+ await settle();
152
+
153
+ expect(fetchCount).toBe(1);
154
+ expect(geometry.height).toBe(6000);
155
+ // The jump owns the position; the anchor wrote nothing over it.
156
+ expect(scrollEl.scrollTop).toBe(0);
157
+ });
158
+
159
+ test('a jump in flight blocks the paging trigger', async () => {
160
+ jumpActiveRef.current = true;
161
+ await act(async () => { pagination.loadOlder(); });
162
+ await settle();
163
+
164
+ expect(fetchCount).toBe(0);
165
+ expect(geometry.height).toBe(5000);
166
+ });
167
+ });
@@ -31,6 +31,15 @@ export interface UseSessionPaginationDeps {
31
31
  /** Scroll container of the timeline: lets loadOlder preserve the viewport
32
32
  * position when older rows are prepended above it. */
33
33
  scrollRef?: RefObject<HTMLDivElement>;
34
+ /** Scroll hook's bottom-jump guard: history paging stands down while a jump
35
+ * to the tail is settling, so the jump does not race a window it never
36
+ * asked for (the jump starts at the top, where the trigger lives). */
37
+ jumpActiveRef?: RefObject<boolean>;
38
+ /** Scroll hook's bottom-jump counter: a page request stamps its viewport
39
+ * anchor with the count it saw and drops the anchor when it has moved on.
40
+ * The anchor exists to hold a READING position, and applying it over a jump
41
+ * the user asked for parked the viewport back where they used to be. */
42
+ jumpCountRef?: RefObject<number>;
34
43
  }
35
44
 
36
45
  export interface SessionPagination {
@@ -67,7 +76,7 @@ interface WindowPayload {
67
76
  }
68
77
 
69
78
  export function useSessionPagination(deps: UseSessionPaginationDeps): SessionPagination {
70
- const { sessionId, sessionIdRef, setLocalMessages, scrollRef } = deps;
79
+ const { sessionId, sessionIdRef, setLocalMessages, scrollRef, jumpActiveRef: bottomJumpActiveRef, jumpCountRef } = deps;
71
80
 
72
81
  const [hasMore, setHasMore] = useState(false);
73
82
  const [oldestIndex, setOldestIndex] = useState(0);
@@ -83,14 +92,14 @@ export function useSessionPagination(deps: UseSessionPaginationDeps): SessionPag
83
92
  // the scroll handler would read "the user reached the top" and page an extra
84
93
  // window nobody asked for — and that window's own scroll anchor would fight
85
94
  // the jump's.
86
- const jumpActiveRef = useRef(false);
95
+ const railJumpActiveRef = useRef(false);
87
96
  // Live cursor: a jump walks it across several requests, and state does not
88
97
  // update between iterations of that loop.
89
98
  const oldestIndexRef = useRef(0);
90
99
  oldestIndexRef.current = oldestIndex;
91
100
  // Set while prepending older rows; the layout effect below re-anchors the
92
101
  // viewport to the content the user was reading instead of jumping.
93
- const pendingScrollAnchorRef = useRef<{ prevHeight: number; prevTop: number } | null>(null);
102
+ const pendingScrollAnchorRef = useRef<{ prevHeight: number; prevTop: number; jumpCount: number } | null>(null);
94
103
 
95
104
  const resetPages = useCallback(() => {
96
105
  setHasMore(false);
@@ -101,6 +110,10 @@ export function useSessionPagination(deps: UseSessionPaginationDeps): SessionPag
101
110
  loadingOlderRef.current = false;
102
111
  setLoadingOlder(false);
103
112
  setLoadOlderError(false);
113
+ // A recorded anchor belongs to the previous session's geometry, and its
114
+ // request was dropped on the switch — applying it would write THAT window's
115
+ // offset into this session's scrollTop.
116
+ pendingScrollAnchorRef.current = null;
104
117
  }, []);
105
118
 
106
119
  const applyWindow = useCallback((hasMoreFromLoad: unknown, oldestIndexFromLoad: unknown) => {
@@ -119,11 +132,15 @@ export function useSessionPagination(deps: UseSessionPaginationDeps): SessionPag
119
132
  return { messages: data.session?.messages ?? [], hasMore: data.hasMore, oldestIndex: data.oldestIndex };
120
133
  }, [sessionId]);
121
134
 
122
- /** Record the viewport geometry an older-window prepend is about to shift. */
135
+ /** Record the viewport geometry an older-window prepend is about to shift,
136
+ * stamped with the bottom-jump count so the anchor can be dropped if the
137
+ * user asked for the tail after this request went out. */
123
138
  const anchorViewport = useCallback(() => {
124
139
  const el = scrollRef?.current;
125
- if (el) pendingScrollAnchorRef.current = { prevHeight: el.scrollHeight, prevTop: el.scrollTop };
126
- }, [scrollRef]);
140
+ if (el) {
141
+ pendingScrollAnchorRef.current = { prevHeight: el.scrollHeight, prevTop: el.scrollTop, jumpCount: jumpCountRef?.current ?? 0 };
142
+ }
143
+ }, [scrollRef, jumpCountRef]);
127
144
 
128
145
  /** Drop the jump's scroll-handler guard once its target scroll has settled.
129
146
  * Until then the viewport is still near the top of history (where the paging
@@ -132,7 +149,7 @@ export function useSessionPagination(deps: UseSessionPaginationDeps): SessionPag
132
149
  const releaseJumpGuard = useCallback(() => {
133
150
  const el = scrollRef?.current;
134
151
  if (!el) {
135
- jumpActiveRef.current = false;
152
+ railJumpActiveRef.current = false;
136
153
  return;
137
154
  }
138
155
  let lastTop = el.scrollTop;
@@ -145,7 +162,7 @@ export function useSessionPagination(deps: UseSessionPaginationDeps): SessionPag
145
162
  }
146
163
  // A few still frames = the smooth scroll is done (or never started).
147
164
  if (settledFrames >= 5) {
148
- jumpActiveRef.current = false;
165
+ railJumpActiveRef.current = false;
149
166
  return;
150
167
  }
151
168
  requestAnimationFrame(watch);
@@ -162,7 +179,11 @@ export function useSessionPagination(deps: UseSessionPaginationDeps): SessionPag
162
179
  * placeholder, and blocking it made the button feel dead mid-run. */
163
180
  const loadOlder = useCallback(() => {
164
181
  if (!sessionId || sessionId.startsWith('new-')) return;
165
- if (!hasMore || loadingOlderRef.current || jumpActiveRef.current) return;
182
+ if (!hasMore || loadingOlderRef.current || railJumpActiveRef.current) return;
183
+ // A jump to the tail starts at the top, where this trigger lives: paging a
184
+ // window then is work the jump never asked for, and the window's own
185
+ // anchor would race the jump's landing.
186
+ if (bottomJumpActiveRef?.current) return;
166
187
  loadingOlderRef.current = true;
167
188
  setLoadingOlder(true);
168
189
  setLoadOlderError(false);
@@ -204,7 +225,7 @@ export function useSessionPagination(deps: UseSessionPaginationDeps): SessionPag
204
225
  loadingOlderRef.current = false;
205
226
  setLoadingOlder(false);
206
227
  });
207
- }, [sessionId, sessionIdRef, hasMore, oldestIndex, fetchWindow, anchorViewport, setLocalMessages, applyWindow]);
228
+ }, [sessionId, sessionIdRef, hasMore, oldestIndex, fetchWindow, anchorViewport, setLocalMessages, applyWindow, bottomJumpActiveRef]);
208
229
 
209
230
  /**
210
231
  * Walk the cursor down until the row with `id` is mounted, prepending every
@@ -226,7 +247,7 @@ export function useSessionPagination(deps: UseSessionPaginationDeps): SessionPag
226
247
  if (loadingOlderRef.current) return false;
227
248
  const requestedSessionId = sessionId;
228
249
  loadingOlderRef.current = true;
229
- jumpActiveRef.current = true;
250
+ railJumpActiveRef.current = true;
230
251
  setLoadingOlder(true);
231
252
  setLoadOlderError(false);
232
253
  // No viewport anchor here, deliberately: the anchor exists to hold the
@@ -283,6 +304,11 @@ export function useSessionPagination(deps: UseSessionPaginationDeps): SessionPag
283
304
  const el = scrollRef?.current;
284
305
  if (!el) return;
285
306
  pendingScrollAnchorRef.current = null;
307
+ // The user asked for the tail after this window was requested: holding the
308
+ // old reading position would write scrollTop straight over that jump (a
309
+ // window landing mid-jump parked the viewport ~26k px short of the tail).
310
+ // The jump's own landing, plus the scroll hook's re-pin, owns the position.
311
+ if (anchor.jumpCount !== (jumpCountRef?.current ?? 0)) return;
286
312
  const delta = el.scrollHeight - anchor.prevHeight;
287
313
  if (delta > 0) el.scrollTop = anchor.prevTop + delta;
288
314
  }, [prependTick, scrollRef]);
@@ -0,0 +1,62 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The write half of the file-editing contract: POST one buffer to
8
+ * `/api/fs/action` and report whether the server took it.
9
+ *
10
+ * Kept out of `useFileEditor` because the multipart shape, the scope fields and
11
+ * the failure rule are one self-contained concern, and because the hook carries
12
+ * the read, cache and history concerns already.
13
+ */
14
+
15
+ import type { LineEnding } from '@/shared/lib/code/line-endings';
16
+
17
+ interface SavableFile {
18
+ path?: string;
19
+ root?: string;
20
+ repo?: string;
21
+ }
22
+
23
+ /**
24
+ * Writes `lfText` to `file.path` in the file's own `eol`. Returns false on any
25
+ * refusal — a non-2xx response, an `error` body, or a dropped connection —
26
+ * because the caller's only decision is whether to mark the buffer clean.
27
+ *
28
+ * The ending travels as its own field and the server rebuilds the bytes: a
29
+ * multipart body normalizes bare LF to CRLF, so the payload cannot carry the
30
+ * distinction itself.
31
+ */
32
+ export async function saveEditorFile(file: SavableFile, lfText: string, eol: LineEnding): Promise<boolean> {
33
+ if (!file.path) return false;
34
+ const formData = new FormData();
35
+ formData.append('actionType', 'save');
36
+ formData.append('path', file.path);
37
+ formData.append('content', lfText);
38
+ formData.append('eol', eol);
39
+ if (file.root) formData.append('root', file.root);
40
+ if (file.repo && file.repo !== '.') formData.append('repo', file.repo);
41
+ try {
42
+ const res = await fetch('/api/fs/action', { method: 'POST', body: formData });
43
+ const data = await res.json().catch(() => null);
44
+ return Boolean(res.ok && data?.success);
45
+ } catch {
46
+ return false;
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Keeps only `keep`'s keys, returning the same object when nothing was
52
+ * dropped so a render whose tab set is unchanged does not re-key its children.
53
+ */
54
+ export function retainKeys<T>(record: Record<string, T>, keep: Set<string>): Record<string, T> {
55
+ const keys = Object.keys(record);
56
+ if (keys.every((key) => keep.has(key))) return record;
57
+ const next: Record<string, T> = {};
58
+ for (const key of keys) {
59
+ if (keep.has(key)) next[key] = record[key];
60
+ }
61
+ return next;
62
+ }
@@ -19,7 +19,7 @@
19
19
  * bind their environment at evaluation time.
20
20
  */
21
21
 
22
- import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
22
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
23
23
  import { Window } from 'happy-dom';
24
24
  import type { h as PreactH, render as PreactRender } from 'preact';
25
25
  import type { act as PreactAct } from 'preact/test-utils';
@@ -42,17 +42,21 @@ interface FsState {
42
42
  reads: number;
43
43
  writes: number;
44
44
  body: string;
45
+ /** The `eol` field of the last write — the ending the server is asked to use. */
46
+ sentEol: string | null;
45
47
  }
46
48
 
47
- /** Serves `state.body` for reads and counts writes, without touching the network. */
49
+ /** Serves `state.body` for reads and records writes, without touching the network. */
48
50
  function stubFetch(state: FsState) {
49
51
  globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
50
52
  if (String(input).startsWith('/api/fs/read')) {
51
53
  state.reads += 1;
52
54
  return { ok: true, json: async () => ({ content: state.body }) };
53
55
  }
56
+ const form = init?.body as FormData;
54
57
  state.writes += 1;
55
- state.body = String((init?.body as FormData).get('content'));
58
+ state.body = String(form.get('content'));
59
+ state.sentEol = form.get('eol') === null ? null : String(form.get('eol'));
56
60
  return { ok: true, json: async () => ({ success: true }) };
57
61
  }) as unknown as typeof fetch;
58
62
  }
@@ -123,9 +127,18 @@ beforeEach(() => {
123
127
  document.body.appendChild(container);
124
128
  });
125
129
 
130
+ // Unmount while the DOM globals still exist, so the hook's effect cleanups run
131
+ // here. A landed save arms a timer that returns the status to `idle`; an effect
132
+ // timer that outlived this file would fire during the next file's test and, with
133
+ // `window` already removed by `afterAll`, break its render.
134
+ afterEach(() => {
135
+ render(null, container);
136
+ container.remove();
137
+ });
138
+
126
139
  describe('useFileEditor revalidation', () => {
127
140
  test('reset re-reads from disk without blanking the open file', async () => {
128
- const state: FsState = { reads: 0, writes: 0, body: DISK };
141
+ const state: FsState = { reads: 0, writes: 0, sentEol: null, body: DISK };
129
142
  stubFetch(state);
130
143
  await mountEditor();
131
144
  expect(mounted?.content).toBe(DISK);
@@ -142,7 +155,7 @@ describe('useFileEditor revalidation', () => {
142
155
  });
143
156
 
144
157
  test('a read that lands mid-typing does not replace the buffer', async () => {
145
- const state: FsState = { reads: 0, writes: 0, body: DISK };
158
+ const state: FsState = { reads: 0, writes: 0, sentEol: null, body: DISK };
146
159
  stubFetch(state);
147
160
  await mountEditor();
148
161
 
@@ -155,4 +168,47 @@ describe('useFileEditor revalidation', () => {
155
168
  expect(state.reads).toBe(2);
156
169
  expect(mounted?.content).toBe('edited by hand');
157
170
  });
171
+
172
+ // A textarea's API value drops every CR, so a CRLF file reaches the buffer as
173
+ // LF — and multipart/form-data turns a bare LF back into CRLF, so the payload
174
+ // cannot carry the ending. The file's own ending therefore travels as its own
175
+ // field, and losing it rewrote every line of the file on a one-character edit.
176
+ test('a CRLF file is saved as CRLF after an edit', async () => {
177
+ const state: FsState = { reads: 0, writes: 0, sentEol: null, body: 'const a = 1;\r\nconst b = 2;\r\n' };
178
+ stubFetch(state);
179
+ await mountEditor();
180
+
181
+ expect(mounted?.content).toBe('const a = 1;\nconst b = 2;\n');
182
+
183
+ await act(async () => {
184
+ mounted?.onChange('const a = 1;\nconst c = 3;\n');
185
+ });
186
+ await settleEditor();
187
+ await act(async () => {
188
+ mounted?.saveNow();
189
+ });
190
+ await settleEditor();
191
+
192
+ expect(state.writes).toBe(1);
193
+ expect(state.body).toBe('const a = 1;\nconst c = 3;\n');
194
+ expect(state.sentEol).toBe('crlf');
195
+ });
196
+
197
+ test('an LF file is saved as LF', async () => {
198
+ const state: FsState = { reads: 0, writes: 0, sentEol: null, body: DISK };
199
+ stubFetch(state);
200
+ await mountEditor();
201
+
202
+ await act(async () => {
203
+ mounted?.onChange('const a = 1;\n');
204
+ });
205
+ await settleEditor();
206
+ await act(async () => {
207
+ mounted?.saveNow();
208
+ });
209
+ await settleEditor();
210
+
211
+ expect(state.body).toBe('const a = 1;\n');
212
+ expect(state.sentEol).toBe('lf');
213
+ });
158
214
  });
@@ -2,6 +2,8 @@ import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
2
2
  import { getLanguageFromPath } from '@/shared/lib/code/syntax-highlight';
3
3
  import { getImageMimeType } from '@/shared/lib/fs/file-kind';
4
4
  import { buildFsRawUrl } from '@/shared/lib/fs/paths';
5
+ import { detectLineEnding, toDiskText, toLf, type LineEnding } from '@/shared/lib/code/line-endings';
6
+ import { retainKeys, saveEditorFile } from '@/client/hooks/editor/save-file';
5
7
  import { useFileTransfer } from '@/client/hooks/editor/use-file-transfer';
6
8
 
7
9
  export type FileEditorSaveStatus = 'idle' | 'saving' | 'saved' | 'error';
@@ -82,6 +84,12 @@ export function useFileEditor(
82
84
 
83
85
  const [contents, setContents] = useState<Record<string, string>>({});
84
86
  const [baselines, setBaselines] = useState<Record<string, string>>({});
87
+ /**
88
+ * The line ending each cached file arrived with. The buffer itself can only
89
+ * hold LF (a textarea strips CR), so the style has to live beside it or a
90
+ * CRLF file would be rewritten as LF by a one-character edit.
91
+ */
92
+ const [lineEndings, setLineEndings] = useState<Record<string, LineEnding>>({});
85
93
  const [isLoading, setIsLoading] = useState(false);
86
94
  const [loadError, setLoadError] = useState<string | null>(null);
87
95
  const [saveStatus, setSaveStatus] = useState<FileEditorSaveStatus>('idle');
@@ -89,7 +97,9 @@ export function useFileEditor(
89
97
  const [revision, setRevision] = useState(0);
90
98
 
91
99
  const key = target ? targetKey(target) : '';
92
- const content = target ? (contents[key] ?? target.content ?? '') : '';
100
+ // An inline `content` (attachment chip) can carry CRLF too, and the buffer it
101
+ // feeds is LF-only — normalizing here keeps `isDirty` comparing like with like.
102
+ const content = target ? (contents[key] ?? (target.content !== undefined ? toLf(target.content) : '')) : '';
93
103
  const isDirty = content !== (baselines[key] ?? content);
94
104
 
95
105
  // Extension-keyed, matching the server's own classification: an image tab
@@ -113,33 +123,34 @@ export function useFileEditor(
113
123
  contentsRef.current = contents;
114
124
  const baselinesRef = useRef(baselines);
115
125
  baselinesRef.current = baselines;
126
+ // `persist` is a stable callback, so the ending it writes back is read from a
127
+ // live mirror rather than captured in its closure.
128
+ const lineEndingsRef = useRef(lineEndings);
129
+ lineEndingsRef.current = lineEndings;
116
130
 
117
131
  const remember = useCallback((fileKey: string, text: string) => {
118
- setContents((prev) => ({ ...prev, [fileKey]: text }));
119
- setBaselines((prev) => ({ ...prev, [fileKey]: text }));
132
+ // The buffer holds LF only; the style the bytes arrived with is kept beside
133
+ // it so the write can put the file back the way it was found.
134
+ const ending = detectLineEnding(text);
135
+ const lf = toLf(text);
136
+ setContents((prev) => ({ ...prev, [fileKey]: lf }));
137
+ setBaselines((prev) => ({ ...prev, [fileKey]: lf }));
138
+ setLineEndings((prev) => ({ ...prev, [fileKey]: ending }));
120
139
  }, []);
121
140
 
122
141
  const persist = useCallback(
123
142
  async (file: FileEditorTarget, text: string) => {
124
143
  if (!file.path) return;
125
144
  setSaveStatus('saving');
126
- const formData = new FormData();
127
- formData.append('actionType', 'save');
128
- formData.append('path', file.path);
129
- formData.append('content', text);
130
- if (file.root) formData.append('root', file.root);
131
- if (file.repo && file.repo !== '.') formData.append('repo', file.repo);
132
- try {
133
- const res = await fetch('/api/fs/action', { method: 'POST', body: formData });
134
- const data = await res.json().catch(() => null);
135
- if (res.ok && data?.success) {
136
- setSaveStatus('saved');
137
- setBaselines((prev) => ({ ...prev, [targetKey(file)]: text }));
138
- onFileSaved?.();
139
- } else {
140
- setSaveStatus(reportSaveError ? 'error' : 'idle');
141
- }
142
- } catch {
145
+ const fileKey = targetKey(file);
146
+ // The buffer is LF; the file's own ending is applied by the server, so an
147
+ // untouched CRLF file stays byte-identical and an LF file stays LF.
148
+ const written = await saveEditorFile(file, text, lineEndingsRef.current[fileKey] ?? 'lf');
149
+ if (written) {
150
+ setSaveStatus('saved');
151
+ setBaselines((prev) => ({ ...prev, [fileKey]: text }));
152
+ onFileSaved?.();
153
+ } else {
143
154
  setSaveStatus(reportSaveError ? 'error' : 'idle');
144
155
  }
145
156
  },
@@ -269,6 +280,11 @@ export function useFileEditor(
269
280
  const { copied, copy, download } = useFileTransfer({
270
281
  target,
271
282
  content,
283
+ // A download is the file leaving the app, so it carries the file's own line
284
+ // endings — the LF buffer is an artifact of the textarea, not of the file.
285
+ // Copy stays the buffer: it is a text selection, and CRLF in a pasted
286
+ // snippet is noise every destination editor would normalize anyway.
287
+ downloadContent: target ? toDiskText(content, lineEndings[key] ?? 'lf') : content,
272
288
  imageUrl,
273
289
  downloadMimeType,
274
290
  });
@@ -287,15 +303,8 @@ export function useFileEditor(
287
303
  for (const loaded of Array.from(loadedRef.current)) {
288
304
  if (!keep.has(loaded)) loadedRef.current.delete(loaded);
289
305
  }
290
- setContents((prev) => {
291
- const keys = Object.keys(prev);
292
- if (keys.every((k) => keep.has(k))) return prev;
293
- const next: Record<string, string> = {};
294
- for (const k of keys) {
295
- if (keep.has(k)) next[k] = prev[k];
296
- }
297
- return next;
298
- });
306
+ setContents((prev) => retainKeys(prev, keep));
307
+ setLineEndings((prev) => retainKeys(prev, keep));
299
308
  }, []);
300
309
 
301
310
  useEffect(() => {
@@ -25,6 +25,8 @@ interface UseFileTransferOptions {
25
25
  target: FileTransferTarget | null;
26
26
  /** Current text buffer for `target`; ignored for images. */
27
27
  content: string;
28
+ /** Disk form of `content` (the file's own line endings), used by download. */
29
+ downloadContent: string;
28
30
  /** Raw-byte URL of the active image, else null. */
29
31
  imageUrl: string | null;
30
32
  /** MIME type used for the text Blob. */
@@ -41,6 +43,7 @@ export interface UseFileTransferResult {
41
43
  export function useFileTransfer({
42
44
  target,
43
45
  content,
46
+ downloadContent,
44
47
  imageUrl,
45
48
  downloadMimeType,
46
49
  }: UseFileTransferOptions): UseFileTransferResult {
@@ -71,8 +74,8 @@ export function useFileTransfer({
71
74
  void downloadUrl(imageUrl, target.name).catch(() => {});
72
75
  return;
73
76
  }
74
- saveBlob(new Blob([content], { type: downloadMimeType }), target.name);
75
- }, [target, content, downloadMimeType, imageUrl]);
77
+ saveBlob(new Blob([downloadContent], { type: downloadMimeType }), target.name);
78
+ }, [target, downloadContent, downloadMimeType, imageUrl]);
76
79
 
77
80
  return { copied, copy, download };
78
81
  }
@@ -254,9 +254,40 @@ export function useProviderSettings({
254
254
  (models) => models.map((m) => (m.id === modelId ? { ...m, isVisible: !m.isVisible } : m)),
255
255
  );
256
256
 
257
- const handleSaveModelConfig = (modelId: string, updates: Partial<ProviderModel>) => updateSelectedModels(
258
- (models) => models.map((m) => (m.id === modelId ? { ...m, ...updates } : m)),
259
- );
257
+ /**
258
+ * Save the per-model knobs omp honours (`models.yml` modelOverrides). The
259
+ * overlay still stores the display value so the dialog reopens where the user
260
+ * left it, but omp only reads the override — writing just the overlay was the
261
+ * bug that made these controls inert.
262
+ */
263
+ const handleSaveModelConfig = async (modelId: string, updates: Partial<ProviderModel>) => {
264
+ if (!selectedProvider) return;
265
+ const provider = selectedProvider;
266
+ updateSelectedModels((models) => models.map((m) => (m.id === modelId ? { ...m, ...updates } : m)));
267
+
268
+ const body: Record<string, unknown> = { provider: provider.slug, modelId };
269
+ if ('maxTokens' in updates) body.maxTokens = updates.maxTokens ?? null;
270
+ if ('reasoningEffort' in updates) body.reasoningEffort = updates.reasoningEffort ?? null;
271
+ if (Object.keys(body).length === 2) return;
272
+
273
+ try {
274
+ const response = await fetch('/api/settings/model-override', {
275
+ method: 'PUT',
276
+ headers: { 'Content-Type': 'application/json' },
277
+ body: JSON.stringify(body),
278
+ });
279
+ const data = await response.json() as { success?: boolean; written?: boolean; reason?: string; error?: string };
280
+ if (!response.ok) {
281
+ pushToast(data.error || 'Failed to save the model configuration.', 'error');
282
+ return;
283
+ }
284
+ if (data.written) notifyModelsUpdated();
285
+ else if (data.reason) pushToast(data.reason, 'error');
286
+ } catch (error) {
287
+ console.error('Failed to save model override:', error);
288
+ pushToast('Failed to save the model configuration.', 'error');
289
+ }
290
+ };
260
291
 
261
292
  return {
262
293
  providers,