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
|
@@ -18,6 +18,17 @@
|
|
|
18
18
|
* Any frame left over is modal material — including an ask-shaped frame whose
|
|
19
19
|
* tool call the timeline has not seen, which must stay answerable rather than
|
|
20
20
|
* silently vanish.
|
|
21
|
+
*
|
|
22
|
+
* That fallback must wait for the session's own history, though. Ownership is
|
|
23
|
+
* decided against the ask tool calls in `messages`, and on session open the
|
|
24
|
+
* pending dialogs are replayed from the agent-state probe — which answers out
|
|
25
|
+
* of the wrapper's local flags — long before the JSONL fetch lands. Every
|
|
26
|
+
* replayed frame therefore looks unowned for that window, and rendering one as
|
|
27
|
+
* a modal painted the question on screen and yanked it away the instant the
|
|
28
|
+
* timeline arrived and its card claimed it: the flash seen when switching to a
|
|
29
|
+
* session parked on an ask. `historyLoaded` holds the modal — never the card
|
|
30
|
+
* claim — until there is a timeline to decide against; a frame no card claims
|
|
31
|
+
* after that still gets its modal.
|
|
21
32
|
*/
|
|
22
33
|
|
|
23
34
|
import { createContext, useContext } from 'preact/compat';
|
|
@@ -51,6 +62,9 @@ export function useAskFrames(): AskFramesHandle {
|
|
|
51
62
|
export function splitAskFrames(
|
|
52
63
|
messages: ChatMessageData[],
|
|
53
64
|
pending: ExtensionUiDialogRequest[],
|
|
65
|
+
/** Whether `messages` is the session's committed history yet (see the module
|
|
66
|
+
* note). False holds the modal back; it never blocks a card from claiming. */
|
|
67
|
+
historyLoaded: boolean,
|
|
54
68
|
): AskFramesSplit {
|
|
55
69
|
const framesByTool = new Map<string, ExtensionUiDialogRequest[][]>();
|
|
56
70
|
if (pending.length === 0) return { framesByTool, modalRequest: null };
|
|
@@ -98,5 +112,10 @@ export function splitAskFrames(
|
|
|
98
112
|
framesByTool.set(toolId, groupAskFrames(openAsks.get(toolId) ?? [], frames));
|
|
99
113
|
}
|
|
100
114
|
|
|
101
|
-
|
|
115
|
+
// The frame stays queued either way — only the modal waits for the history,
|
|
116
|
+
// so nothing is dropped and nothing flashes.
|
|
117
|
+
return {
|
|
118
|
+
framesByTool,
|
|
119
|
+
modalRequest: historyLoaded ? pending.find((request) => !claimed.has(request)) ?? null : null,
|
|
120
|
+
};
|
|
102
121
|
}
|
|
@@ -10,6 +10,14 @@
|
|
|
10
10
|
* because every other trigger lives in the streaming callbacks. This jumps to
|
|
11
11
|
* the tail exactly once per session, on the commit where that session's
|
|
12
12
|
* messages appear.
|
|
13
|
+
*
|
|
14
|
+
* The jump is the UNCONDITIONAL one (`jumpToBottom`), not the follow-gated
|
|
15
|
+
* `scrollToBottom`. Follow mode is a property of this mounted view, not of the
|
|
16
|
+
* session being opened: a user who scrolled up before switching left it
|
|
17
|
+
* disengaged, and the open then painted the top of the new session with no
|
|
18
|
+
* scroll event to re-engage it — the session stayed at the head until the
|
|
19
|
+
* button was clicked (reproduced: scroll up in session A, open session B,
|
|
20
|
+
* `scrollTop` pinned at 0 with the jump button showing).
|
|
13
21
|
*/
|
|
14
22
|
|
|
15
23
|
import type { RefObject } from 'preact/compat';
|
|
@@ -24,14 +32,15 @@ export interface UseTimelineAutoScrollDeps {
|
|
|
24
32
|
messages: ChatMessageData[];
|
|
25
33
|
/** Scroll container owned by useChatTimelineScroll. */
|
|
26
34
|
scrollRef: RefObject<HTMLDivElement>;
|
|
27
|
-
|
|
35
|
+
/** Unconditional tail jump (bypasses follow mode, re-engages it). */
|
|
36
|
+
jumpToBottom: (behavior?: ScrollBehavior) => void;
|
|
28
37
|
/** Pending "new-…" sessions render the workspace picker, which owns its own
|
|
29
38
|
* timeline and scroll handling. */
|
|
30
39
|
enabled: boolean;
|
|
31
40
|
}
|
|
32
41
|
|
|
33
42
|
export function useTimelineAutoScroll(deps: UseTimelineAutoScrollDeps): void {
|
|
34
|
-
const { sessionId, messages, scrollRef,
|
|
43
|
+
const { sessionId, messages, scrollRef, jumpToBottom, enabled } = deps;
|
|
35
44
|
// Session the container was last rendering. On the commit where this flips
|
|
36
45
|
// the DOM still holds the previous session's rows, so scrolling then would
|
|
37
46
|
// aim at content that is about to be replaced.
|
|
@@ -53,6 +62,6 @@ export function useTimelineAutoScroll(deps: UseTimelineAutoScrollDeps): void {
|
|
|
53
62
|
// scrollHeight forces layout, so the jump lands before the browser paints.
|
|
54
63
|
// 'instant' is required because the container carries Tailwind's
|
|
55
64
|
// `scroll-smooth`, which 'auto' would resolve to.
|
|
56
|
-
|
|
57
|
-
}, [sessionId, messages, enabled, scrollRef,
|
|
65
|
+
jumpToBottom('instant');
|
|
66
|
+
}, [sessionId, messages, enabled, scrollRef, jumpToBottom]);
|
|
58
67
|
}
|
|
@@ -15,10 +15,8 @@ import { useBrowserPageContextInsert } from '@/client/hooks/chat/timeline/browse
|
|
|
15
15
|
import { createOmpAgentCallbacks } from '@/shared/lib/chat/timeline/omp-callbacks';
|
|
16
16
|
import { cancelStreamingCoalescer } from '@/shared/lib/chat/timeline/stream-coalescer';
|
|
17
17
|
import { readStreamTransport } from '@/shared/lib/chat/omp/transport';
|
|
18
|
-
import {
|
|
19
|
-
import type { ApprovalMode } from '@/shared/lib/omp/config/access-mode';
|
|
18
|
+
import { useChatTimelineAccessMode } from '@/client/hooks/chat/timeline/access-mode';
|
|
20
19
|
import { useSessionState } from '@/client/hooks/workspace/session-state';
|
|
21
|
-
import { writeSetting } from '@/shared/lib/settings/client';
|
|
22
20
|
|
|
23
21
|
interface UseChatTimelineOptions {
|
|
24
22
|
folders?: any[];
|
|
@@ -51,7 +49,7 @@ export function useChatTimeline({ folders = [], appSettings = {} }: UseChatTimel
|
|
|
51
49
|
// without re-creating it (useSessionLoad is defined below the scroll hook).
|
|
52
50
|
const loadOlderRef = useRef<() => void>(() => {});
|
|
53
51
|
|
|
54
|
-
const { scrollRef, contentRef, showScrollBottom, isScrolling, handleScroll, scrollToBottom, jumpToBottom } = useChatTimelineScroll({
|
|
52
|
+
const { scrollRef, contentRef, showScrollBottom, isScrolling, handleScroll, scrollToBottom, jumpToBottom, jumpActiveRef, jumpCountRef } = useChatTimelineScroll({
|
|
55
53
|
onScrollTop: () => loadOlderRef.current(),
|
|
56
54
|
});
|
|
57
55
|
|
|
@@ -65,11 +63,12 @@ export function useChatTimeline({ folders = [], appSettings = {} }: UseChatTimel
|
|
|
65
63
|
const isPendingSession = Boolean(sessionId?.startsWith('new-'));
|
|
66
64
|
// Committed history lands asynchronously (session-load.ts), so the container
|
|
67
65
|
// paints at the top first; jump to the tail once per session when it does.
|
|
66
|
+
// Unconditional: follow mode is this view's, not the opened session's.
|
|
68
67
|
useTimelineAutoScroll({
|
|
69
68
|
sessionId,
|
|
70
69
|
messages: localMessages,
|
|
71
70
|
scrollRef,
|
|
72
|
-
|
|
71
|
+
jumpToBottom,
|
|
73
72
|
enabled: !isPendingSession,
|
|
74
73
|
});
|
|
75
74
|
|
|
@@ -135,6 +134,8 @@ export function useChatTimeline({ folders = [], appSettings = {} }: UseChatTimel
|
|
|
135
134
|
cancelStreamingCoalescer,
|
|
136
135
|
metaRefreshedRef,
|
|
137
136
|
scrollRef,
|
|
137
|
+
jumpActiveRef,
|
|
138
|
+
jumpCountRef,
|
|
138
139
|
});
|
|
139
140
|
loadOlderRef.current = loadOlder;
|
|
140
141
|
|
|
@@ -165,21 +166,8 @@ export function useChatTimeline({ folders = [], appSettings = {} }: UseChatTimel
|
|
|
165
166
|
}, [sessionId]);
|
|
166
167
|
|
|
167
168
|
// Access-control mode is a global, persisted user preference (unlike the
|
|
168
|
-
// per-session model/thinking picks)
|
|
169
|
-
|
|
170
|
-
// without re-creating executeSend.
|
|
171
|
-
const [accessMode, setAccessMode] = useState<ApprovalMode>(() => normalizeApprovalMode(appSettings.omp_access_mode));
|
|
172
|
-
const accessModeRef = useRef<ApprovalMode>(accessMode);
|
|
173
|
-
accessModeRef.current = accessMode;
|
|
174
|
-
|
|
175
|
-
const handleAccessModeChange = useCallback((mode: ApprovalMode) => {
|
|
176
|
-
setAccessMode(mode);
|
|
177
|
-
accessModeRef.current = mode;
|
|
178
|
-
// Persist the last selection; the server reads this key as the spawn-time
|
|
179
|
-
// default. Fire-and-forget: the in-memory value is already authoritative
|
|
180
|
-
// for this session's requests, which carry it explicitly.
|
|
181
|
-
writeSetting(ACCESS_MODE_SETTING_KEY, mode);
|
|
182
|
-
}, []);
|
|
169
|
+
// per-session model/thinking picks).
|
|
170
|
+
const { accessMode, accessModeRef, setAccessMode: handleAccessModeChange } = useChatTimelineAccessMode(appSettings);
|
|
183
171
|
|
|
184
172
|
const persistMessages = useCallback((messagesToSave: any[]) => {
|
|
185
173
|
if (!sessionId) return;
|
|
@@ -23,6 +23,24 @@ import { useSessionState } from '@/client/hooks/workspace/session-state';
|
|
|
23
23
|
|
|
24
24
|
const STEERING_STATE_KEY = 'chat.steeringQueue';
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* How often a mounted session with a non-empty queue re-offers a delivery slot.
|
|
28
|
+
*
|
|
29
|
+
* This is the safety net for a server whose own timers have stopped firing: a
|
|
30
|
+
* long-lived `bun --hot` process can stop running `setTimeout` callbacks
|
|
31
|
+
* entirely while still answering HTTP normally (observed live — a 4-hour dev
|
|
32
|
+
* server whose SSE heartbeat never fired and whose queued follow-ups never
|
|
33
|
+
* drained, while every request returned in 0.01s). Auto-delivery is timer-driven
|
|
34
|
+
* server-side, so it dies with those timers; a client poll runs in a different
|
|
35
|
+
* process and is immune.
|
|
36
|
+
*
|
|
37
|
+
* Only armed while something is actually queued, and each tick is one POST that
|
|
38
|
+
* doubles as the panel's reconcile, so an idle session costs nothing. 3s is a
|
|
39
|
+
* deliberate compromise: slow enough to be invisible on the wire, fast enough
|
|
40
|
+
* that a stranded item lands before the user wonders about it.
|
|
41
|
+
*/
|
|
42
|
+
const QUEUE_POLL_INTERVAL_MS = 3_000;
|
|
43
|
+
|
|
26
44
|
function queueUrl(sessionId: string): string {
|
|
27
45
|
return `/api/sessions/${encodeURIComponent(sessionId)}/queue`;
|
|
28
46
|
}
|
|
@@ -74,25 +92,64 @@ export function useChatTimelineQueue(sessionId: string | null): ChatTimelineQueu
|
|
|
74
92
|
})
|
|
75
93
|
.catch(() => {});
|
|
76
94
|
}, [sessionId]);
|
|
95
|
+
// Offer the server a delivery slot for this session. The server-side claim
|
|
96
|
+
// makes this harmless when a run is active or another tab already took the
|
|
97
|
+
// head — this used to be the client auto-delivery effect, which fired into
|
|
98
|
+
// whatever session was open at the time.
|
|
99
|
+
//
|
|
100
|
+
// The response carries the canonical queue, so this doubles as a reconcile.
|
|
101
|
+
const nudgeDelivery = useCallback(() => {
|
|
102
|
+
if (!sessionId) return;
|
|
103
|
+
const epoch = hydrateEpochRef.current;
|
|
104
|
+
fetch(`${queueUrl(sessionId)}/deliver`, { method: 'POST' })
|
|
105
|
+
.then((res) => readQueueResponse(res))
|
|
106
|
+
.then((queue) => {
|
|
107
|
+
// Only reconcile when this is still the session on screen: the poll can
|
|
108
|
+
// outlive a switch, and writing another session's queue here is the
|
|
109
|
+
// exact cross-session bug the server-owned store was built to stop.
|
|
110
|
+
if (cancelledRef.current || hydrateEpochRef.current !== epoch) return;
|
|
111
|
+
if (queue) setMessageQueueState(queue);
|
|
112
|
+
})
|
|
113
|
+
.catch(() => {});
|
|
114
|
+
}, [sessionId]);
|
|
115
|
+
|
|
77
116
|
useEffect(() => {
|
|
78
117
|
cancelledRef.current = false;
|
|
79
118
|
refresh();
|
|
80
|
-
|
|
119
|
+
// Mount and every focus regain offer a delivery slot. Focus is the trigger
|
|
120
|
+
// the recovery path depends on, so it must re-nudge even when the queue
|
|
121
|
+
// length is unchanged: keying this on `messageQueue.length > 0` (a boolean
|
|
122
|
+
// that stays true while the item is stranded) meant the effect never re-ran
|
|
123
|
+
// and a stuck queue stayed stuck.
|
|
124
|
+
nudgeDelivery();
|
|
125
|
+
const onFocus = () => {
|
|
126
|
+
refresh();
|
|
127
|
+
nudgeDelivery();
|
|
128
|
+
};
|
|
81
129
|
window.addEventListener('focus', onFocus);
|
|
82
130
|
return () => {
|
|
83
131
|
cancelledRef.current = true;
|
|
84
132
|
window.removeEventListener('focus', onFocus);
|
|
85
133
|
};
|
|
86
|
-
}, [refresh]);
|
|
134
|
+
}, [refresh, nudgeDelivery]);
|
|
135
|
+
|
|
136
|
+
// Items appearing while the session is already mounted (a send while
|
|
137
|
+
// streaming, or another tab's write): offer a slot without waiting for focus.
|
|
138
|
+
useEffect(() => {
|
|
139
|
+
if (messageQueue.length > 0) nudgeDelivery();
|
|
140
|
+
}, [messageQueue.length, nudgeDelivery]);
|
|
87
141
|
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
//
|
|
142
|
+
// Safety net: while anything is queued, keep offering a slot on an interval.
|
|
143
|
+
// Server-side auto-delivery is timer-driven and a long-lived `--hot` server
|
|
144
|
+
// can lose that capability silently; this poll lives in the browser and is
|
|
145
|
+
// unaffected. The effect is torn down the moment the queue empties, so a
|
|
146
|
+
// session with nothing queued never polls.
|
|
147
|
+
const hasQueued = messageQueue.length > 0;
|
|
92
148
|
useEffect(() => {
|
|
93
|
-
if (!sessionId ||
|
|
94
|
-
|
|
95
|
-
|
|
149
|
+
if (!sessionId || !hasQueued) return;
|
|
150
|
+
const timer = setInterval(nudgeDelivery, QUEUE_POLL_INTERVAL_MS);
|
|
151
|
+
return () => clearInterval(timer);
|
|
152
|
+
}, [sessionId, hasQueued, nudgeDelivery]);
|
|
96
153
|
|
|
97
154
|
const enqueueMessage = useCallback((item: Omit<QueuedMessage, 'id'>) => {
|
|
98
155
|
if (!sessionId) return;
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Chat timeline scroll contract. Both behaviours here were the bug:
|
|
8
|
+
*
|
|
9
|
+
* 1. The jump that follows a session's committed history used the follow-gated
|
|
10
|
+
* `scrollToBottom`, and follow mode belongs to the mounted view, not to the
|
|
11
|
+
* session being opened — a user who scrolled up before switching left it
|
|
12
|
+
* disengaged, so the newly opened session painted at `scrollTop = 0` with no
|
|
13
|
+
* scroll event to re-engage it and stayed there. Verified live in the
|
|
14
|
+
* chamber: scroll up in session A, open session B, viewport pinned at the
|
|
15
|
+
* head with the jump button showing until it was clicked.
|
|
16
|
+
* 2. Nothing re-pinned the tail when content grew while the viewport was
|
|
17
|
+
* pinned (a prepended window, a late Shiki/mermaid/image layout), so a jump
|
|
18
|
+
* target measured a frame earlier — or at open time — was stranded.
|
|
19
|
+
*
|
|
20
|
+
* Runs against a real DOM (happy-dom) because these are Preact hooks; geometry
|
|
21
|
+
* is faked on the container, since happy-dom does no layout.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { afterAll, beforeAll, describe, expect, jest as fakeTimers, test } from 'bun:test';
|
|
25
|
+
import { Window } from 'happy-dom';
|
|
26
|
+
import type { h as PreactH, render as PreactRender } from 'preact';
|
|
27
|
+
import type { act as PreactAct } from 'preact/test-utils';
|
|
28
|
+
import type { useChatTimelineScroll as UseScroll, ChatTimelineScrollResult } from '@/client/hooks/chat/timeline/scroll';
|
|
29
|
+
import type { useTimelineAutoScroll as UseAuto } from '@/client/hooks/chat/timeline/auto-scroll';
|
|
30
|
+
import type { ChatMessageData } from '@/shared/types';
|
|
31
|
+
|
|
32
|
+
let useChatTimelineScroll: typeof UseScroll;
|
|
33
|
+
let useTimelineAutoScroll: typeof UseAuto;
|
|
34
|
+
let h: typeof PreactH;
|
|
35
|
+
let render: typeof PreactRender;
|
|
36
|
+
let act: typeof PreactAct;
|
|
37
|
+
|
|
38
|
+
let win: Window;
|
|
39
|
+
const scrolled: Array<{ top: number; behavior?: ScrollBehavior }> = [];
|
|
40
|
+
const geometry = { top: 0, height: 5000, viewport: 500 };
|
|
41
|
+
|
|
42
|
+
/** Observe what the hook asks for; 'instant' also lands (a smooth scroll would
|
|
43
|
+
* animate in a browser, so the fake keeps it as a recorded intent only). */
|
|
44
|
+
function applyTop(el: HTMLDivElement, value: number) {
|
|
45
|
+
geometry.top = Math.max(0, Math.min(value, geometry.height - geometry.viewport));
|
|
46
|
+
// The global `Event` the hook's listener expects: happy-dom's, installed
|
|
47
|
+
// into globalThis in beforeAll.
|
|
48
|
+
el.dispatchEvent(new Event('scroll'));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function installScroller(el: HTMLDivElement) {
|
|
52
|
+
Object.defineProperty(el, 'scrollHeight', { get: () => geometry.height, configurable: true });
|
|
53
|
+
Object.defineProperty(el, 'clientHeight', { get: () => geometry.viewport, configurable: true });
|
|
54
|
+
Object.defineProperty(el, 'scrollTop', {
|
|
55
|
+
get: () => geometry.top,
|
|
56
|
+
set: (value: number) => applyTop(el, value),
|
|
57
|
+
configurable: true,
|
|
58
|
+
});
|
|
59
|
+
Object.defineProperty(el, 'scrollTo', {
|
|
60
|
+
value: (options: { top: number; behavior?: ScrollBehavior }) => {
|
|
61
|
+
scrolled.push(options);
|
|
62
|
+
if (options.behavior !== 'smooth') applyTop(el, options.top);
|
|
63
|
+
},
|
|
64
|
+
configurable: true,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** ResizeObserver the tests drive by hand — the hook rebuilds one per content
|
|
69
|
+
* node, so the last instance of the current mount is the live one. */
|
|
70
|
+
class StubResizeObserver {
|
|
71
|
+
static instances: StubResizeObserver[] = [];
|
|
72
|
+
static latest(): StubResizeObserver { return StubResizeObserver.instances[StubResizeObserver.instances.length - 1]; }
|
|
73
|
+
constructor(private readonly callback: () => void) {
|
|
74
|
+
StubResizeObserver.instances.push(this);
|
|
75
|
+
}
|
|
76
|
+
observe() {}
|
|
77
|
+
unobserve() {}
|
|
78
|
+
disconnect() {}
|
|
79
|
+
fire() { this.callback(); }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
interface ProbeProps {
|
|
83
|
+
sessionId: string;
|
|
84
|
+
messages: ChatMessageData[];
|
|
85
|
+
/** Holder owned by ONE test: a stale tree can only write its own hook result. */
|
|
86
|
+
holder: { current: ChatTimelineScrollResult | null };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function Probe(props: ProbeProps) {
|
|
90
|
+
const scroll = useChatTimelineScroll({});
|
|
91
|
+
useTimelineAutoScroll({
|
|
92
|
+
sessionId: props.sessionId,
|
|
93
|
+
messages: props.messages,
|
|
94
|
+
scrollRef: scroll.scrollRef,
|
|
95
|
+
jumpToBottom: scroll.jumpToBottom,
|
|
96
|
+
enabled: true,
|
|
97
|
+
});
|
|
98
|
+
props.holder.current = scroll;
|
|
99
|
+
return h('div', { ref: scroll.scrollRef, onScroll: scroll.handleScroll },
|
|
100
|
+
h('div', { ref: scroll.contentRef }));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
interface ProbeHandle {
|
|
104
|
+
holder: { current: ChatTimelineScrollResult | null };
|
|
105
|
+
node: HTMLDivElement;
|
|
106
|
+
el: HTMLDivElement;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const message = (id: string) => ({ id, role: 'user', content: id } as unknown as ChatMessageData);
|
|
110
|
+
|
|
111
|
+
/** Fresh mount, own container: `follow`, the jump counter and the observers are
|
|
112
|
+
* per-hook state, so sharing them across tests would hide the behaviour. */
|
|
113
|
+
async function mountProbe(sessionId: string, messages: ChatMessageData[]): Promise<ProbeHandle> {
|
|
114
|
+
const holder: ProbeHandle['holder'] = { current: null };
|
|
115
|
+
const node = document.createElement('div');
|
|
116
|
+
document.body.appendChild(node);
|
|
117
|
+
await act(async () => { render(h(Probe, { sessionId, messages, holder }), node); });
|
|
118
|
+
const el = holder.current!.scrollRef.current!;
|
|
119
|
+
installScroller(el);
|
|
120
|
+
return { holder, node, el };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Re-render with new props: a session switch, then that session's history. */
|
|
124
|
+
async function updateProbe(probe: ProbeHandle, sessionId: string, messages: ChatMessageData[]) {
|
|
125
|
+
await act(async () => {
|
|
126
|
+
render(h(Probe, { sessionId, messages, holder: probe.holder }), probe.node);
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function unmountProbe(probe: ProbeHandle) {
|
|
131
|
+
await act(async () => { render(null, probe.node); });
|
|
132
|
+
probe.node.remove();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
beforeAll(async () => {
|
|
136
|
+
win = new Window({ url: 'http://localhost' });
|
|
137
|
+
for (const key of ['window', 'document', 'navigator', 'Node', 'Element', 'HTMLElement', 'Event'] as const) {
|
|
138
|
+
(globalThis as Record<string, unknown>)[key] = (win as unknown as Record<string, unknown>)[key];
|
|
139
|
+
}
|
|
140
|
+
(globalThis as Record<string, unknown>).ResizeObserver = StubResizeObserver;
|
|
141
|
+
// Static imports cannot work here: Preact binds its environment at module
|
|
142
|
+
// evaluation time, so the modules below must load after those DOM globals.
|
|
143
|
+
({ useChatTimelineScroll } = await import('@/client/hooks/chat/timeline/scroll'));
|
|
144
|
+
({ useTimelineAutoScroll } = await import('@/client/hooks/chat/timeline/auto-scroll'));
|
|
145
|
+
({ h, render } = await import('preact'));
|
|
146
|
+
({ act } = await import('preact/test-utils'));
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
afterAll(() => {
|
|
150
|
+
for (const key of ['window', 'document', 'navigator', 'Node', 'Element', 'HTMLElement', 'Event', 'ResizeObserver']) {
|
|
151
|
+
delete (globalThis as Record<string, unknown>)[key];
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe('timeline scroll', () => {
|
|
156
|
+
test('opening a session jumps to the tail even after the user scrolled up', async () => {
|
|
157
|
+
scrolled.length = 0;
|
|
158
|
+
geometry.top = 0;
|
|
159
|
+
geometry.height = 5000;
|
|
160
|
+
const probe = await mountProbe('a', [message('a1')]);
|
|
161
|
+
const scroll = () => probe.holder.current!;
|
|
162
|
+
|
|
163
|
+
await updateProbe(probe, 'a', [message('a1'), message('a2')]);
|
|
164
|
+
expect(probe.el.scrollTop).toBe(geometry.height - geometry.viewport);
|
|
165
|
+
|
|
166
|
+
// The user reads back — follow disengages, and that state outlives the
|
|
167
|
+
// session it was set in.
|
|
168
|
+
probe.el.scrollTop = 100;
|
|
169
|
+
expect(scroll().followRef.current).toBe(false);
|
|
170
|
+
|
|
171
|
+
// Session switch: the container paints empty first (the skeleton), the
|
|
172
|
+
// committed history lands a commit later.
|
|
173
|
+
await updateProbe(probe, 'b', []);
|
|
174
|
+
scrolled.length = 0;
|
|
175
|
+
await updateProbe(probe, 'b', [message('b1')]);
|
|
176
|
+
|
|
177
|
+
expect(scroll().followRef.current).toBe(true);
|
|
178
|
+
expect(scrolled.length).toBe(1);
|
|
179
|
+
expect(scrolled[0].behavior).toBe('instant');
|
|
180
|
+
expect(scrolled[0].top).toBe(geometry.height);
|
|
181
|
+
expect(probe.el.scrollTop).toBe(geometry.height - geometry.viewport);
|
|
182
|
+
await unmountProbe(probe);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test('a stream chunk may not move an unpinned viewport, a jump may', async () => {
|
|
186
|
+
scrolled.length = 0;
|
|
187
|
+
geometry.top = 0;
|
|
188
|
+
geometry.height = 5000;
|
|
189
|
+
const probe = await mountProbe('a', [message('a1')]);
|
|
190
|
+
const scroll = () => probe.holder.current!;
|
|
191
|
+
|
|
192
|
+
await act(async () => { scroll().jumpToBottom('instant'); });
|
|
193
|
+
expect(scroll().followRef.current).toBe(true);
|
|
194
|
+
expect(probe.el.scrollTop).toBe(geometry.height - geometry.viewport);
|
|
195
|
+
expect(scroll().jumpCountRef.current).toBe(1);
|
|
196
|
+
expect(scroll().jumpActiveRef.current).toBe(true);
|
|
197
|
+
|
|
198
|
+
// The user reads back. A stream chunk must not yank the viewport down...
|
|
199
|
+
probe.el.scrollTop = 100;
|
|
200
|
+
expect(scroll().followRef.current).toBe(false);
|
|
201
|
+
scrolled.length = 0;
|
|
202
|
+
await act(async () => { scroll().scrollToBottom('smooth'); });
|
|
203
|
+
expect(scrolled.length).toBe(0);
|
|
204
|
+
|
|
205
|
+
// ...while the explicit jump bypasses the gate and re-engages it.
|
|
206
|
+
await act(async () => { scroll().jumpToBottom('instant'); });
|
|
207
|
+
expect(scrolled.length).toBe(1);
|
|
208
|
+
expect(scroll().followRef.current).toBe(true);
|
|
209
|
+
expect(probe.el.scrollTop).toBe(geometry.height - geometry.viewport);
|
|
210
|
+
expect(scroll().jumpCountRef.current).toBe(2);
|
|
211
|
+
await unmountProbe(probe);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test('the jump guard releases so history paging is not blocked for good', async () => {
|
|
215
|
+
scrolled.length = 0;
|
|
216
|
+
geometry.top = 0;
|
|
217
|
+
geometry.height = 5000;
|
|
218
|
+
const probe = await mountProbe('a', [message('a1')]);
|
|
219
|
+
const scroll = () => probe.holder.current!;
|
|
220
|
+
|
|
221
|
+
fakeTimers.useFakeTimers();
|
|
222
|
+
await act(async () => { scroll().jumpToBottom('instant'); });
|
|
223
|
+
expect(scroll().jumpActiveRef.current).toBe(true);
|
|
224
|
+
|
|
225
|
+
await act(async () => { fakeTimers.advanceTimersByTime(1000); });
|
|
226
|
+
expect(scroll().jumpActiveRef.current).toBe(false);
|
|
227
|
+
fakeTimers.useRealTimers();
|
|
228
|
+
await unmountProbe(probe);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test('content growing while pinned re-pins the tail; while reading it does not', async () => {
|
|
232
|
+
scrolled.length = 0;
|
|
233
|
+
geometry.top = 0;
|
|
234
|
+
geometry.height = 5000;
|
|
235
|
+
const probe = await mountProbe('a', [message('a1')]);
|
|
236
|
+
const scroll = () => probe.holder.current!;
|
|
237
|
+
|
|
238
|
+
await updateProbe(probe, 'a', [message('a1')]);
|
|
239
|
+
expect(probe.el.scrollTop).toBe(geometry.height - geometry.viewport);
|
|
240
|
+
|
|
241
|
+
// A prepended window / a late layout: the tail moved down by 800px.
|
|
242
|
+
geometry.height += 800;
|
|
243
|
+
const observer = StubResizeObserver.latest();
|
|
244
|
+
scrolled.length = 0;
|
|
245
|
+
await act(async () => { observer.fire(); });
|
|
246
|
+
expect(scrolled.length).toBe(1);
|
|
247
|
+
expect(scrolled[0].behavior).toBe('instant');
|
|
248
|
+
expect(probe.el.scrollTop).toBe(geometry.height - geometry.viewport);
|
|
249
|
+
|
|
250
|
+
// Reading back: the same growth must leave the viewport alone.
|
|
251
|
+
probe.el.scrollTop = 400;
|
|
252
|
+
scrolled.length = 0;
|
|
253
|
+
geometry.height += 800;
|
|
254
|
+
await act(async () => { StubResizeObserver.latest().fire(); });
|
|
255
|
+
expect(scrolled.length).toBe(0);
|
|
256
|
+
expect(probe.el.scrollTop).toBe(400);
|
|
257
|
+
expect(scroll().followRef.current).toBe(false);
|
|
258
|
+
await unmountProbe(probe);
|
|
259
|
+
});
|
|
260
|
+
});
|