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
|
@@ -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
|
+
});
|
|
@@ -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
|