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.
- package/README.md +143 -147
- package/dist/client/index.html +4 -2
- package/dist/client/static/css/index.5f630421b7.css +2 -0
- package/dist/client/static/js/77.0d81c74cf0.js +139 -0
- package/dist/client/static/js/async/1k.973e8e21fc.js +2 -0
- package/dist/client/static/js/async/{3y.3186ecdd63.js → 3y.3530b824ad.js} +6 -6
- package/dist/client/static/js/async/{q.b453193538.js → 4m.928a1dde85.js} +10 -10
- package/dist/client/static/js/async/66.5dfa29f957.js +93 -0
- package/dist/client/static/js/async/7g.a7814350c3.js +1 -0
- package/dist/client/static/js/async/ar.9303e5f2e8.js +2 -0
- package/dist/client/static/js/async/{cx.9c8268d542.js → cx.3cb0242a3a.js} +1 -1
- package/dist/client/static/js/async/e.93214d622b.js +79 -0
- package/dist/client/static/js/async/g.cb81980498.js +2 -0
- package/dist/client/static/js/async/h1.393f066d09.js +1 -0
- package/dist/client/static/js/async/jl.874d352278.js +1 -0
- package/dist/client/static/js/async/{k.d16cd09ae7.js → k.3210286401.js} +1 -1
- package/dist/client/static/js/async/{m.2e1ea3db9e.js → m.eb2e6752e2.js} +3 -3
- package/dist/client/static/js/async/m.eb2e6752e2.js.LICENSE.txt +11 -0
- package/dist/client/static/js/async/mt.0352e976d8.js +1 -0
- package/dist/client/static/js/async/{oc.eb22093f05.js → oc.b73042e8bd.js} +1 -1
- package/dist/client/static/js/async/{og.53f068804c.js → og.8eb577893a.js} +1 -1
- package/dist/client/static/js/async/px.fc6877924b.js +1 -0
- package/dist/client/static/js/async/q.22588845a9.js +21 -0
- package/dist/client/static/js/async/{q5.88b50ffa95.js → q5.97dc1481b6.js} +1 -1
- package/dist/client/static/js/async/s.0f395cdda5.js +94 -0
- package/dist/client/static/js/async/{tr.2a0d2b7600.js → tr.e878658140.js} +1 -1
- package/dist/client/static/js/async/vt.42416b707d.js +1 -0
- package/dist/client/static/js/async/x1.da8687159b.js +90 -0
- package/dist/client/static/js/async/xe.da8dcc2015.js +2 -0
- package/dist/client/static/js/async/xe.da8dcc2015.js.LICENSE.txt +11 -0
- package/dist/client/static/js/async/{xn.6360379f71.js → xn.6510c0abe2.js} +2 -2
- package/dist/client/static/js/async/{yx.af2936c368.js → xn1.83a7784fe1.js} +3 -3
- package/dist/client/static/js/async/xn1.83a7784fe1.js.LICENSE.txt +11 -0
- package/dist/client/static/js/async/yg.35bebd6d0c.js +149 -0
- package/dist/client/static/js/async/yv.d99d829136.js +1 -0
- package/dist/client/static/js/index.1fd9e1c543.js +38 -0
- package/package.json +3 -3
- package/src/client/components/common/ComposerTextarea.tsx +9 -1
- package/src/client/components/common/MarkdownRenderer.tsx +50 -12
- package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +9 -3
- package/src/client/components/settings/Modal.tsx +11 -2
- package/src/client/components/settings/categories/AppearanceSettings.tsx +7 -30
- package/src/client/components/settings/categories/ProviderSettings.tsx +2 -0
- package/src/client/components/settings/categories/appearance-settings/ThemeCard.tsx +61 -0
- package/src/client/components/settings/categories/appearance-settings/ThemeGrid.tsx +80 -0
- package/src/client/components/settings/categories/chat-settings/TitleSection.tsx +1 -1
- package/src/client/components/settings/categories/provider-settings/AddProviderModal.tsx +43 -6
- package/src/client/components/settings/categories/provider-settings/ModelConfigModal.tsx +54 -54
- package/src/client/components/workspace/SearchPanel.tsx +16 -3
- package/src/client/components/workspace/btw-panel/AskCard.tsx +11 -6
- package/src/client/components/workspace/btw-panel/Messages.tsx +105 -51
- package/src/client/components/workspace/btw-panel/index.tsx +124 -45
- package/src/client/components/workspace/chat-timeline/AiMessageFooter.tsx +30 -14
- package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +10 -1
- package/src/client/components/workspace/chat-timeline/GeneratingIndicator.tsx +8 -2
- package/src/client/components/workspace/chat-timeline/MessageItem.tsx +27 -16
- package/src/client/components/workspace/chat-timeline/MessageList.tsx +11 -0
- package/src/client/components/workspace/chat-timeline/RunFooter.tsx +8 -0
- package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +12 -1
- package/src/client/components/workspace/chat-timeline/index.tsx +7 -2
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/QuestionBlock.tsx +42 -15
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.tsx +13 -1
- package/src/client/components/workspace/chat-timeline/tool-renderers/extension-dialog/index.tsx +13 -4
- package/src/client/components/workspace/diff-panel/SplitView.tsx +4 -1
- package/src/client/components/workspace/diff-panel/UnifiedView.tsx +70 -65
- package/src/client/components/workspace/diff-panel/index.tsx +8 -1
- package/src/client/components/workspace/editor/index.tsx +18 -2
- package/src/client/components/workspace/file-explorer/GitRepoDropdown.tsx +15 -41
- package/src/client/components/workspace/file-explorer/index.tsx +86 -16
- package/src/client/components/workspace/git-panel/index.tsx +60 -78
- package/src/client/components/workspace/terminal-panel/Header.tsx +16 -1
- package/src/client/components/workspace/terminal-panel/RealtimeXtermView.tsx +5 -5
- package/src/client/components/workspace/terminal-panel/index.tsx +6 -2
- package/src/client/components/workspace/terminal-panel/mount.ts +2 -2
- package/src/client/data/theme/terminal.ts +50 -52
- package/src/client/hooks/chat/btw/index.ts +139 -18
- package/src/client/hooks/chat/btw/intercept.ts +8 -8
- package/src/client/hooks/chat/btw/mode.ts +111 -16
- package/src/client/hooks/chat/composer/index.ts +10 -4
- package/src/client/hooks/chat/timeline/access-mode.ts +41 -0
- package/src/client/hooks/chat/timeline/ask-frames.test.ts +87 -0
- package/src/client/hooks/chat/timeline/ask-frames.ts +20 -1
- package/src/client/hooks/chat/timeline/auto-scroll.ts +13 -4
- package/src/client/hooks/chat/timeline/index.ts +8 -20
- package/src/client/hooks/chat/timeline/queue.ts +66 -9
- package/src/client/hooks/chat/timeline/scroll.test.ts +260 -0
- package/src/client/hooks/chat/timeline/scroll.ts +93 -20
- package/src/client/hooks/chat/timeline/session-load.ts +7 -1
- package/src/client/hooks/chat/timeline/session-pagination.test.ts +167 -0
- package/src/client/hooks/chat/timeline/session-pagination.ts +37 -11
- package/src/client/hooks/editor/save-file.ts +62 -0
- package/src/client/hooks/editor/use-file-editor.test.ts +61 -5
- package/src/client/hooks/editor/use-file-editor.ts +38 -29
- package/src/client/hooks/editor/use-file-transfer.ts +5 -2
- package/src/client/hooks/settings/providers.ts +34 -3
- package/src/client/hooks/ui/open-file.ts +7 -1
- package/src/client/hooks/ui/theme.ts +82 -57
- package/src/client/hooks/workspace/file-tabs.ts +5 -1
- package/src/client/hooks/workspace/repo-scope/index.ts +119 -0
- package/src/client/hooks/workspace/repo-scope/store.test.ts +161 -0
- package/src/client/hooks/workspace/repo-scope/store.ts +201 -0
- package/src/client/hooks/workspace/search-stream.ts +24 -5
- package/src/client/main.tsx +6 -0
- package/src/client/tailwind.css +19 -56
- package/src/server/lib/btw/child.server.ts +41 -0
- package/src/server/lib/btw/lifecycle.server.ts +254 -0
- package/src/server/lib/btw/prompt.ts +13 -5
- package/src/server/lib/btw/registry.server.ts +13 -4
- package/src/server/lib/btw/runtime.server.ts +149 -156
- package/src/server/lib/btw/schema.server.ts +27 -0
- package/src/server/lib/btw/service.server.ts +106 -5
- package/src/server/lib/btw/store.server.ts +57 -6
- package/src/server/lib/db/schema.ts +4 -0
- package/src/server/lib/db/settings-store.test.ts +65 -0
- package/src/server/lib/db/settings-store.ts +25 -0
- package/src/server/lib/fs/atomic-write.ts +42 -4
- package/src/server/lib/fs/config-lock.ts +74 -0
- package/src/server/lib/fs/git-ignore.ts +55 -5
- package/src/server/lib/fs/ripgrep.ts +119 -0
- package/src/server/lib/lifecycle/identity.ts +10 -64
- package/src/server/lib/lifecycle/proc/darwin.ts +238 -0
- package/src/server/lib/lifecycle/proc/index.ts +38 -0
- package/src/server/lib/lifecycle/proc/linux.ts +80 -0
- package/src/server/lib/lifecycle/proc/ps.ts +86 -0
- package/src/server/lib/lifecycle/proc/types.ts +33 -0
- package/src/server/lib/lifecycle/proc/win32.ts +53 -0
- package/src/server/lib/models/provider-registry.server.ts +2 -2
- package/src/server/lib/models/registry.server.ts +1 -1
- package/src/server/lib/omp/config/behavior.ts +17 -17
- package/src/server/lib/omp/config/disabled-providers.ts +84 -35
- package/src/server/lib/omp/config/document.ts +128 -0
- package/src/server/lib/omp/config/extensions.ts +11 -10
- package/src/server/lib/omp/config/mcp.ts +4 -57
- package/src/server/lib/omp/config/model-overrides.ts +123 -0
- package/src/server/lib/omp/config/models-config.ts +92 -0
- package/src/server/lib/omp/config/models-validation.ts +159 -0
- package/src/server/lib/omp/config/path-scoped.ts +74 -0
- package/src/server/lib/omp/config/providers.ts +241 -211
- package/src/server/lib/omp/config/roles.ts +6 -14
- package/src/server/lib/omp/core/auth-credentials.ts +1 -1
- package/src/server/lib/omp/rpc/frame-fold.test.ts +124 -0
- package/src/server/lib/omp/rpc/frame-fold.ts +43 -24
- package/src/server/lib/omp/rpc/lifecycle.test.ts +41 -0
- package/src/server/lib/omp/rpc/manager.ts +21 -21
- package/src/server/lib/omp/rpc/pending-ui-dialogs.ts +11 -7
- package/src/server/lib/omp/rpc/session-commands.ts +17 -29
- package/src/server/lib/omp/rpc/session-registry.ts +5 -66
- package/src/server/lib/omp/rpc/web-state.ts +0 -6
- package/src/server/lib/omp/session/auto-title.server.test.ts +109 -11
- package/src/server/lib/omp/session/auto-title.server.ts +95 -20
- package/src/server/lib/omp/session/sidebar-data.server.ts +11 -6
- package/src/server/lib/queue/delivery-dispatch.server.test.ts +162 -0
- package/src/server/lib/queue/delivery-harness.ts +88 -0
- package/src/server/lib/queue/delivery.server.test.ts +78 -149
- package/src/server/lib/queue/delivery.server.ts +105 -13
- package/src/server/lib/queue/store.server.ts +30 -5
- package/src/server/lib/terminal/foreground.test.ts +164 -0
- package/src/server/lib/terminal/foreground.ts +34 -6
- package/src/server/lib/terminal/history.ts +8 -7
- package/src/server/plugins/compress.ts +33 -5
- package/src/server/plugins/ssr.ts +14 -7
- package/src/server/routes/btw/topics.ts +89 -2
- package/src/server/routes/fs/action.test.ts +64 -0
- package/src/server/routes/fs/action.ts +10 -1
- package/src/server/routes/fs/git.ts +5 -6
- package/src/server/routes/fs/search.ts +17 -26
- package/src/server/routes/sessions/queue.ts +17 -4
- package/src/server/routes/settings/index.ts +2 -0
- package/src/server/routes/settings/model-override.ts +69 -0
- package/src/server/routes/settings/provider-models.ts +35 -12
- package/src/server/routes/settings/providers.ts +31 -1
- package/src/server/routes/settings/root.ts +8 -3
- package/src/shared/lib/chat/btw/client.ts +34 -0
- package/src/shared/lib/chat/timeline/tool-phrases.ts +7 -0
- package/src/shared/lib/code/line-endings.test.ts +38 -0
- package/src/shared/lib/code/line-endings.ts +56 -0
- package/src/shared/lib/code/shiki-themes.ts +9 -5
- package/src/shared/lib/fs/file-tree.ts +17 -12
- package/src/shared/lib/markdown/autolink.test.ts +32 -0
- package/src/shared/lib/markdown/autolink.ts +50 -0
- package/src/shared/lib/markdown/document-urls.test.ts +68 -0
- package/src/shared/lib/markdown/document-urls.ts +118 -0
- package/src/shared/lib/markdown/marked.ts +27 -15
- package/src/shared/lib/markdown/mermaid.ts +2 -2
- package/src/shared/lib/models/catalog.ts +1 -1
- package/src/shared/lib/omp/session/schema-migrations.ts +30 -0
- package/src/shared/lib/omp/session/stream-state.server.test.ts +54 -0
- package/src/shared/lib/omp/session/stream-state.server.ts +70 -21
- package/src/shared/lib/settings/client.ts +10 -4
- package/src/shared/lib/settings/diff.test.ts +31 -0
- package/src/shared/lib/settings/diff.ts +37 -0
- package/src/shared/lib/theme/catalog.test.ts +168 -0
- package/src/shared/lib/theme/catalog.ts +98 -0
- package/src/shared/lib/theme/color.ts +88 -0
- package/src/shared/lib/theme/css.ts +70 -0
- package/src/shared/lib/theme/palettes/chamber.ts +61 -0
- package/src/shared/lib/theme/palettes/dark.ts +313 -0
- package/src/shared/lib/theme/palettes/light.ts +313 -0
- package/src/shared/types/btw.ts +83 -4
- package/src/shared/types/index.ts +1 -0
- package/src/shared/types/settings/provider.ts +8 -1
- package/src/shared/types/settings/state.ts +10 -4
- package/src/shared/types/theme.ts +48 -0
- package/dist/client/static/css/index.b938f4a693.css +0 -2
- package/dist/client/static/js/77.8022a800b5.js +0 -139
- package/dist/client/static/js/async/1k.a411fe5b43.js +0 -2
- package/dist/client/static/js/async/66.86544ad741.js +0 -93
- package/dist/client/static/js/async/7g.db82aca7ba.js +0 -1
- package/dist/client/static/js/async/ar.ad5c8822a8.js +0 -2
- package/dist/client/static/js/async/e.6443e739a2.js +0 -79
- package/dist/client/static/js/async/g.ee41bfed7f.js +0 -2
- package/dist/client/static/js/async/h1.d7091cef78.js +0 -1
- package/dist/client/static/js/async/jl.4911fa3383.js +0 -1
- package/dist/client/static/js/async/m.2e1ea3db9e.js.LICENSE.txt +0 -6
- package/dist/client/static/js/async/mt.853be2b5aa.js +0 -1
- package/dist/client/static/js/async/ot.84c71b308f.js +0 -2
- package/dist/client/static/js/async/px.783b6f91f2.js +0 -1
- package/dist/client/static/js/async/q8.7f1b48e9cd.js +0 -21
- package/dist/client/static/js/async/q8.7f1b48e9cd.js.LICENSE.txt +0 -6
- package/dist/client/static/js/async/s.aad2ecebc6.js +0 -94
- package/dist/client/static/js/async/vt.20406a5604.js +0 -1
- package/dist/client/static/js/async/x1.fe8af17016.js +0 -90
- package/dist/client/static/js/async/xn.6360379f71.js.LICENSE.txt +0 -6
- package/dist/client/static/js/async/yg.5a94e1fc8a.js +0 -149
- package/dist/client/static/js/async/yv.2fa22f009a.js +0 -1
- package/dist/client/static/js/index.5fe7a2cb64.js +0 -34
- package/src/client/components/workspace/btw-panel/Composer.tsx +0 -183
- package/src/client/components/workspace/btw-panel/attachments.ts +0 -80
- /package/dist/client/static/js/{77.8022a800b5.js.LICENSE.txt → 77.0d81c74cf0.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{g.ee41bfed7f.js.LICENSE.txt → 1k.973e8e21fc.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{3y.3186ecdd63.js.LICENSE.txt → 3y.3530b824ad.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{ot.84c71b308f.js.LICENSE.txt → 4m.928a1dde85.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{q.b453193538.js.LICENSE.txt → ar.9303e5f2e8.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{yx.af2936c368.js.LICENSE.txt → g.cb81980498.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{1k.a411fe5b43.js.LICENSE.txt → q.22588845a9.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{ar.ad5c8822a8.js.LICENSE.txt → xn.6510c0abe2.js.LICENSE.txt} +0 -0
- /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
|
|
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)
|
|
126
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ||
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
119
|
-
|
|
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
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
if (
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
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([
|
|
75
|
-
}, [target,
|
|
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
|
-
|
|
258
|
-
|
|
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,
|