diff options
| author | Adam Malczewski <[email protected]> | 2026-06-12 00:22:42 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-12 00:22:42 +0900 |
| commit | 4866ebe724d247ab9c1a7615104712cf0ff2e508 (patch) | |
| tree | 67d178201c50dca60e81091375988ea582140f47 /src/features/smart-scroll/logic/smart-scroll.ts | |
| parent | db86285444aab921663eb1dfce5c84c554204c94 (diff) | |
| download | dispatch-web-4866ebe724d247ab9c1a7615104712cf0ff2e508.tar.gz dispatch-web-4866ebe724d247ab9c1a7615104712cf0ff2e508.zip | |
feat(workspace,smart-scroll): per-conversation cwd + LSP view; smart auto-scroll
workspace ([email protected]): a cwd field in the Model sidebar view (GET/PUT /conversations/:id/cwd) + a new 'Language Servers' view (GET /conversations/:id/lsp) with per-server connected/starting/error badges, spinner, error text, and refresh. Store-owned reactive cwd, re-seeded on focus change; works for DRAFTS too (targets the draft's client-minted id, which survives promotion, so turn 1 runs in the chosen cwd). Network seam normalizes the untyped LSP body.
smart-scroll: pure stick-to-bottom reducer + injected controller shell (scroll/scrollend + a ResizeObserver on the content so the view follows async height changes — markdown/highlight, images, collapses, viewport reflow), plus a floating scroll-to-bottom button. FIX: restore the transcript scrollbar — the refactor moved overflow-y-auto to an inner child, so the flex-1 container needed min-h-0 to constrain instead of growing to content.
harness: vitest-setup polyfills Element.scrollTo + ResizeObserver (jsdom implements neither), fixing App component tests. docs: backend-handoff pruned (CR-3 resolved/removed); added cwd/LSP verification courier (backend confirmed all 6 asks ✅); removed the resolved cache-warming-timer courier.
Verified: svelte-check 0 errors, biome clean, 523 tests pass, vite build OK.
Diffstat (limited to 'src/features/smart-scroll/logic/smart-scroll.ts')
| -rw-r--r-- | src/features/smart-scroll/logic/smart-scroll.ts | 93 |
1 files changed, 93 insertions, 0 deletions
diff --git a/src/features/smart-scroll/logic/smart-scroll.ts b/src/features/smart-scroll/logic/smart-scroll.ts new file mode 100644 index 0000000..021b3fe --- /dev/null +++ b/src/features/smart-scroll/logic/smart-scroll.ts @@ -0,0 +1,93 @@ +// Pure smart-scroll reducer — "stick the transcript to the bottom while it grows, +// unless the user has scrolled up". Zero DOM, zero Svelte: it takes scroll +// GEOMETRY snapshots in and returns the next state plus an optional scroll +// COMMAND for the shell to execute. The injected shell (the Svelte action) reads +// the geometry off a real element and runs the commands. + +/** A snapshot of a scroll container's vertical geometry (in CSS pixels). */ +export interface ScrollGeometry { + /** Current scroll offset from the top. */ + readonly scrollTop: number; + /** Total scrollable content height. */ + readonly scrollHeight: number; + /** Visible viewport height. */ + readonly clientHeight: number; +} + +/** Distance (px) from the bottom within which we still consider the view "at bottom". */ +export const NEAR_BOTTOM_THRESHOLD = 64; + +/** True when the viewport is within `threshold` px of the content's bottom edge. */ +export function isNearBottom( + geom: ScrollGeometry, + threshold: number = NEAR_BOTTOM_THRESHOLD, +): boolean { + return geom.scrollHeight - geom.scrollTop - geom.clientHeight <= threshold; +} + +/** A scroll the shell should perform on the real element. */ +export interface ScrollCommand { + readonly kind: "scroll-to-bottom"; + /** Smooth-scroll (a deliberate resume) vs. jump (keeping up with a stream). */ + readonly animate: boolean; +} + +export interface SmartScrollState { + /** + * Whether the view is currently following the bottom. While `stuck`, new + * content keeps the view pinned to the bottom; once the user scrolls up it + * goes false and stays false until they return to the bottom (or resume). + */ + readonly stuck: boolean; +} + +/** A reducer step's result: the next state, an optional command, and whether to show the button. */ +export interface SmartScrollResult { + readonly state: SmartScrollState; + readonly command: ScrollCommand | null; + /** Show the "scroll to bottom" affordance exactly when not stuck. */ + readonly showButton: boolean; +} + +/** Initial state — start stuck so the first content snaps to the bottom. */ +export function createSmartScrollState(): SmartScrollState { + return { stuck: true }; +} + +function result(state: SmartScrollState, command: ScrollCommand | null): SmartScrollResult { + return { state, command, showButton: !state.stuck }; +} + +/** + * The user scrolled (or the viewport resized). Re-derive `stuck` purely from + * geometry: near the bottom ⇒ stuck (follow), otherwise unstuck. Never emits a + * command — reacting to the user's own scroll with a scroll would fight them. + */ +export function onScroll(_state: SmartScrollState, geom: ScrollGeometry): SmartScrollResult { + return result({ stuck: isNearBottom(geom) }, null); +} + +/** + * Content changed (a streamed delta, a new message, history loaded). If we're + * stuck, emit a non-animated scroll to keep up; otherwise leave the user where + * they are. State is unchanged — content growth alone never flips `stuck`. + */ +export function onContentChange(state: SmartScrollState, _geom: ScrollGeometry): SmartScrollResult { + return result(state, state.stuck ? { kind: "scroll-to-bottom", animate: false } : null); +} + +/** + * The user asked to return to the bottom (clicked the button). Force-stick and + * emit an animated scroll. + */ +export function onResume(_state: SmartScrollState): SmartScrollResult { + return result({ stuck: true }, { kind: "scroll-to-bottom", animate: true }); +} + +/** + * The transcript context changed entirely (e.g. a conversation/tab switch). + * Reset to stuck and snap (non-animated) to the bottom of the new content. + */ +export function onReset(): SmartScrollResult { + return result(createSmartScrollState(), { kind: "scroll-to-bottom", animate: false }); +} |
