ompchamber 1.2.1 → 2.0.0
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 +131 -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.74ff36419f.js +2 -0
- package/dist/client/static/js/async/{m.2e1ea3db9e.js.LICENSE.txt → g.74ff36419f.js.LICENSE.txt} +5 -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.33d6779f98.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/yg.35bebd6d0c.js +149 -0
- package/dist/client/static/js/async/yv.d99d829136.js +1 -0
- package/dist/client/static/js/index.c05535dbde.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/editor/index.tsx +11 -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/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/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/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/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/{1k.a411fe5b43.js.LICENSE.txt → q.33d6779f98.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.c05535dbde.js.LICENSE.txt} +0 -0
|
@@ -19,6 +19,12 @@
|
|
|
19
19
|
* the tail disengages it (the scroll fight is gone — they can read while the
|
|
20
20
|
* AI generates); scrolling back near the tail, or clicking the scroll-bottom
|
|
21
21
|
* button, re-engages it. Programmatic jumps (`scrollTo`) never flip the mode.
|
|
22
|
+
*
|
|
23
|
+
* `jumpToBottom` is the unconditional bypass (`follow` is re-engaged, geometry
|
|
24
|
+
* is re-baselined, and the paging trigger stands down for a moment) — the
|
|
25
|
+
* button's jump, the open-session jump, and the send jump all go through it.
|
|
26
|
+
* `follow` lives across session switches, so an open jump that honoured it was
|
|
27
|
+
* simply refused for a session opened after the user had scrolled up.
|
|
22
28
|
*/
|
|
23
29
|
|
|
24
30
|
import type { RefObject } from 'preact/compat';
|
|
@@ -27,6 +33,13 @@ import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
|
|
|
27
33
|
/** Distance from the tail, in pixels, that still counts as "at the bottom". */
|
|
28
34
|
const FOLLOW_BOTTOM_THRESHOLD_PX = 100;
|
|
29
35
|
|
|
36
|
+
/** How long a bottom jump counts as settling. The scroll handler reads "the
|
|
37
|
+
* viewport is at the top, fetch older history" from geometry, and a jump that
|
|
38
|
+
* STARTS at the top (the button, on a session scrolled back to its head)
|
|
39
|
+
* passes through those first pixels — so the paging trigger stands down for
|
|
40
|
+
* the jump's duration instead of paging a window the jump must then outrun. */
|
|
41
|
+
const JUMP_SETTLE_MS = 1000;
|
|
42
|
+
|
|
30
43
|
export interface ChatTimelineScrollOptions {
|
|
31
44
|
/** Called when the viewport reaches the top (history paging trigger).
|
|
32
45
|
* Ref-indirect; the hook never depends on the callback's identity. */
|
|
@@ -41,15 +54,29 @@ export interface ChatTimelineScrollResult {
|
|
|
41
54
|
isScrolling: boolean;
|
|
42
55
|
/** Streaming auto-scroll gates on this: pinned to the tail = true. */
|
|
43
56
|
followRef: RefObject<boolean>;
|
|
57
|
+
/** Bottom jumps issued so far. A paging request stamps its viewport anchor
|
|
58
|
+
* with this and drops the anchor once it has moved on — otherwise a window
|
|
59
|
+
* that lands AFTER the user asked for the tail writes `scrollTop` straight
|
|
60
|
+
* over the jump (reproduced: the jump stopped ~26k px short of the tail and
|
|
61
|
+
* only the next click reached it, once no window was left to page in). */
|
|
62
|
+
jumpCountRef: RefObject<number>;
|
|
63
|
+
/** True while a bottom jump is settling ({@link JUMP_SETTLE_MS}); history
|
|
64
|
+
* paging stands down meanwhile. */
|
|
65
|
+
jumpActiveRef: RefObject<boolean>;
|
|
44
66
|
handleScroll: () => void;
|
|
45
67
|
scrollToBottom: (behavior?: ScrollBehavior) => void;
|
|
46
|
-
/** Jump to the tail and re-engage follow mode (scroll-bottom button
|
|
68
|
+
/** Jump to the tail and re-engage follow mode (scroll-bottom button, the
|
|
69
|
+
* session-open jump, the post-send jump). */
|
|
47
70
|
jumpToBottom: (behavior?: ScrollBehavior) => void;
|
|
48
71
|
}
|
|
49
72
|
|
|
50
73
|
/** Distance from the top that counts as "reached the history boundary". */
|
|
51
74
|
const SCROLL_TOP_TRIGGER_PX = 40;
|
|
52
75
|
|
|
76
|
+
/** Pending timeout handle. Named here rather than re-derived per ref because
|
|
77
|
+
* the DOM and Bun/Node typings disagree on what `setTimeout` returns. */
|
|
78
|
+
type TimeoutHandle = ReturnType<typeof setTimeout>;
|
|
79
|
+
|
|
53
80
|
export function useChatTimelineScroll(options: ChatTimelineScrollOptions = {}): ChatTimelineScrollResult {
|
|
54
81
|
const onScrollTopRef = useRef<(() => void) | undefined>(options.onScrollTop);
|
|
55
82
|
onScrollTopRef.current = options.onScrollTop ?? onScrollTopRef.current;
|
|
@@ -57,17 +84,33 @@ export function useChatTimelineScroll(options: ChatTimelineScrollOptions = {}):
|
|
|
57
84
|
const [contentNode, setContentNode] = useState<HTMLDivElement | null>(null);
|
|
58
85
|
const [showScrollBottom, setShowScrollBottom] = useState(false);
|
|
59
86
|
const [isScrolling, setIsScrolling] = useState(false);
|
|
60
|
-
const scrollTimerRef = useRef<
|
|
87
|
+
const scrollTimerRef = useRef<TimeoutHandle>();
|
|
61
88
|
// True while the viewport is (or should stay) pinned to the tail. A ref, not
|
|
62
89
|
// state: scroll chunks read it every frame and a flip must not re-render.
|
|
63
90
|
const followRef = useRef(true);
|
|
64
91
|
// Previous scrollTop, for upward-scroll detection in handleScroll.
|
|
65
92
|
const lastScrollTopRef = useRef(0);
|
|
93
|
+
// Bottom-jump bookkeeping; see ChatTimelineScrollResult for each ref.
|
|
94
|
+
const jumpCountRef = useRef(0);
|
|
95
|
+
const jumpActiveRef = useRef(false);
|
|
96
|
+
const jumpTimerRef = useRef<TimeoutHandle>();
|
|
66
97
|
|
|
67
98
|
const contentRef = useCallback((node: HTMLDivElement | null) => {
|
|
68
99
|
setContentNode(node);
|
|
69
100
|
}, []);
|
|
70
101
|
|
|
102
|
+
/** Move the viewport to the tail unconditionally, then re-baseline the
|
|
103
|
+
* upward-scroll detector. Both matter: `scrollTo` applies the new position
|
|
104
|
+
* for 'instant', and the previous session's (larger) scrollTop would
|
|
105
|
+
* otherwise read as the user scrolling UP on the very event this jump
|
|
106
|
+
* produces — disengaging follow and cancelling a jump in flight. */
|
|
107
|
+
const pinToBottom = useCallback((behavior: ScrollBehavior) => {
|
|
108
|
+
const el = scrollRef.current;
|
|
109
|
+
if (!el) return;
|
|
110
|
+
el.scrollTo({ top: el.scrollHeight, behavior });
|
|
111
|
+
lastScrollTopRef.current = el.scrollTop;
|
|
112
|
+
}, []);
|
|
113
|
+
|
|
71
114
|
const syncFollowBottom = useCallback(() => {
|
|
72
115
|
const el = scrollRef.current;
|
|
73
116
|
if (!el) return;
|
|
@@ -83,7 +126,12 @@ export function useChatTimelineScroll(options: ChatTimelineScrollOptions = {}):
|
|
|
83
126
|
// increasing scrollTop, so a geometry check alone would flip the flag
|
|
84
127
|
// mid-animation as content outgrows the animation. Re-engage whenever
|
|
85
128
|
// the viewport is back near the tail, whichever way it got there.
|
|
86
|
-
if (el.scrollTop < lastScrollTopRef.current - 1)
|
|
129
|
+
if (el.scrollTop < lastScrollTopRef.current - 1) {
|
|
130
|
+
followRef.current = false;
|
|
131
|
+
// The user took the viewport back: a jump still settling must not keep
|
|
132
|
+
// re-pinning the tail under them.
|
|
133
|
+
jumpActiveRef.current = false;
|
|
134
|
+
}
|
|
87
135
|
if (el.scrollHeight - el.scrollTop - el.clientHeight < FOLLOW_BOTTOM_THRESHOLD_PX) followRef.current = true;
|
|
88
136
|
lastScrollTopRef.current = el.scrollTop;
|
|
89
137
|
// History paging: the viewport hit the top — ask for the older window.
|
|
@@ -92,43 +140,68 @@ export function useChatTimelineScroll(options: ChatTimelineScrollOptions = {}):
|
|
|
92
140
|
}
|
|
93
141
|
syncFollowBottom();
|
|
94
142
|
setIsScrolling(true);
|
|
95
|
-
|
|
143
|
+
clearTimeout(scrollTimerRef.current);
|
|
96
144
|
scrollTimerRef.current = setTimeout(() => setIsScrolling(false), 600);
|
|
97
145
|
}, [syncFollowBottom]);
|
|
98
146
|
|
|
99
147
|
const scrollToBottom = useCallback((behavior: ScrollBehavior = 'smooth') => {
|
|
100
|
-
const el = scrollRef.current;
|
|
101
|
-
if (!el) return;
|
|
102
148
|
// Follow-gated: a stream chunk in read mode (user scrolled away) must not
|
|
103
149
|
// yank the viewport down. jumpToBottom is the explicit bypass.
|
|
104
150
|
if (!followRef.current) return;
|
|
105
|
-
|
|
106
|
-
}, []);
|
|
151
|
+
pinToBottom(behavior);
|
|
152
|
+
}, [pinToBottom]);
|
|
107
153
|
|
|
108
154
|
const jumpToBottom = useCallback((behavior: ScrollBehavior = 'smooth') => {
|
|
109
|
-
|
|
110
|
-
if (!el) return;
|
|
155
|
+
if (!scrollRef.current) return;
|
|
111
156
|
followRef.current = true;
|
|
112
|
-
|
|
113
|
-
|
|
157
|
+
jumpActiveRef.current = true;
|
|
158
|
+
jumpCountRef.current += 1;
|
|
159
|
+
clearTimeout(jumpTimerRef.current);
|
|
160
|
+
jumpTimerRef.current = setTimeout(() => { jumpActiveRef.current = false; }, JUMP_SETTLE_MS);
|
|
161
|
+
pinToBottom(behavior);
|
|
162
|
+
}, [pinToBottom]);
|
|
114
163
|
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
//
|
|
164
|
+
// Geometry, not events: content that grows without the user scrolling
|
|
165
|
+
// (history prepending, an accordion expanding, an image decoding, a late
|
|
166
|
+
// Shiki/mermaid layout, the panel resizing) never fires `scroll`, so nothing
|
|
167
|
+
// else notices the tail moving away. While the viewport is pinned that growth
|
|
168
|
+
// is exactly when to re-pin it — a prepended window shifts the tail down by
|
|
169
|
+
// its own height, which strands a jump target measured a frame earlier (and
|
|
170
|
+
// a one-shot open jump by anything that lays out late). Unpinned (the user
|
|
171
|
+
// reading back) nothing is touched: the prepend anchor owns the position.
|
|
172
|
+
//
|
|
173
|
+
// `contentNode` is a dep so the observer is rebuilt when the timeline mounts
|
|
174
|
+
// — a session picked from the workspace picker swaps the whole subtree in,
|
|
175
|
+
// and the callback ref fires before this effect, so the node is always
|
|
176
|
+
// available here.
|
|
119
177
|
useEffect(() => {
|
|
120
178
|
syncFollowBottom();
|
|
121
179
|
if (typeof ResizeObserver === 'undefined') return;
|
|
122
180
|
const container = scrollRef.current;
|
|
123
|
-
const observer = new ResizeObserver(
|
|
181
|
+
const observer = new ResizeObserver(() => {
|
|
182
|
+
if (followRef.current) pinToBottom('instant');
|
|
183
|
+
syncFollowBottom();
|
|
184
|
+
});
|
|
124
185
|
if (container) observer.observe(container);
|
|
125
186
|
if (contentNode) observer.observe(contentNode);
|
|
126
187
|
return () => observer.disconnect();
|
|
127
|
-
}, [contentNode, syncFollowBottom]);
|
|
188
|
+
}, [contentNode, pinToBottom, syncFollowBottom]);
|
|
128
189
|
|
|
129
190
|
useEffect(() => () => {
|
|
130
|
-
|
|
191
|
+
clearTimeout(scrollTimerRef.current);
|
|
192
|
+
clearTimeout(jumpTimerRef.current);
|
|
131
193
|
}, []);
|
|
132
194
|
|
|
133
|
-
return {
|
|
195
|
+
return {
|
|
196
|
+
scrollRef,
|
|
197
|
+
contentRef,
|
|
198
|
+
showScrollBottom,
|
|
199
|
+
isScrolling,
|
|
200
|
+
followRef,
|
|
201
|
+
jumpCountRef,
|
|
202
|
+
jumpActiveRef,
|
|
203
|
+
handleScroll,
|
|
204
|
+
scrollToBottom,
|
|
205
|
+
jumpToBottom,
|
|
206
|
+
};
|
|
134
207
|
}
|
|
@@ -60,10 +60,14 @@ export interface UseSessionLoadDeps {
|
|
|
60
60
|
/** Scroll container of the timeline: lets loadOlder preserve the viewport
|
|
61
61
|
* position when older rows are prepended above it. */
|
|
62
62
|
scrollRef?: RefObject<HTMLDivElement>;
|
|
63
|
+
/** Scroll hook's bottom-jump guard and counter, forwarded to the pagination
|
|
64
|
+
* hook: a jump to the tail outranks an in-flight older window. */
|
|
65
|
+
jumpActiveRef?: RefObject<boolean>;
|
|
66
|
+
jumpCountRef?: RefObject<number>;
|
|
63
67
|
}
|
|
64
68
|
|
|
65
69
|
export function useSessionLoad(deps: UseSessionLoadDeps) {
|
|
66
|
-
const { sessionId, setLocalMessages, setGenerating, isGeneratingRef, aiPlaceholderIdRef, localMessagesRef, optimisticUserIdRef, cancelStreamingCoalescer, metaRefreshedRef, scrollRef } = deps;
|
|
70
|
+
const { sessionId, setLocalMessages, setGenerating, isGeneratingRef, aiPlaceholderIdRef, localMessagesRef, optimisticUserIdRef, cancelStreamingCoalescer, metaRefreshedRef, scrollRef, jumpActiveRef, jumpCountRef } = deps;
|
|
67
71
|
|
|
68
72
|
const [sessionData, setSessionData] = useState<SessionDataShape | null>(null);
|
|
69
73
|
const [sessionLoading, setSessionLoading] = useState(false);
|
|
@@ -99,6 +103,8 @@ export function useSessionLoad(deps: UseSessionLoadDeps) {
|
|
|
99
103
|
sessionIdRef,
|
|
100
104
|
setLocalMessages,
|
|
101
105
|
scrollRef,
|
|
106
|
+
jumpActiveRef,
|
|
107
|
+
jumpCountRef,
|
|
102
108
|
});
|
|
103
109
|
|
|
104
110
|
/** Re-fetch the session's title/metadata after the omp JSONL has been
|
|
@@ -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]);
|
|
@@ -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,
|
|
@@ -5,6 +5,10 @@ export interface OpenFilePayload {
|
|
|
5
5
|
name?: string;
|
|
6
6
|
id?: number;
|
|
7
7
|
content?: string;
|
|
8
|
+
/** Absolute workspace root the path belongs to; the panel defaults it to the active project. */
|
|
9
|
+
root?: string;
|
|
10
|
+
/** Repo picked in the panel, relative to `root` — the path's own scope when set. */
|
|
11
|
+
repo?: string;
|
|
8
12
|
}
|
|
9
13
|
|
|
10
14
|
/**
|
|
@@ -26,7 +30,9 @@ export function openFileInEditor(payloadOrPath: string | OpenFilePayload) {
|
|
|
26
30
|
path: cleanPath,
|
|
27
31
|
name: fileName,
|
|
28
32
|
id: payload.id,
|
|
29
|
-
content: payload.content
|
|
33
|
+
content: payload.content,
|
|
34
|
+
root: payload.root,
|
|
35
|
+
repo: payload.repo,
|
|
30
36
|
}
|
|
31
37
|
})
|
|
32
38
|
);
|