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 | fd565a6555e8bc9f37f21cf9d900523ef3be531b (patch) | |
| tree | ecf2c365c0c5e0ccdfc1a9ae350af933e4860ed2 /src/features/smart-scroll/ui/controller.svelte.ts | |
| parent | e45cab2a2d9d7bf5e48ace7111fd84b1b9bf2df3 (diff) | |
| download | dispatch-web-fd565a6555e8bc9f37f21cf9d900523ef3be531b.tar.gz dispatch-web-fd565a6555e8bc9f37f21cf9d900523ef3be531b.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/ui/controller.svelte.ts')
| -rw-r--r-- | src/features/smart-scroll/ui/controller.svelte.ts | 130 |
1 files changed, 130 insertions, 0 deletions
diff --git a/src/features/smart-scroll/ui/controller.svelte.ts b/src/features/smart-scroll/ui/controller.svelte.ts new file mode 100644 index 0000000..99d53ca --- /dev/null +++ b/src/features/smart-scroll/ui/controller.svelte.ts @@ -0,0 +1,130 @@ +// Injected shell for smart-scroll: binds a real scrollable element to the pure +// reducer (logic/smart-scroll). It owns the reactive `showButton` flag (a thin +// rune wrapper over the reducer state), runs the scroll COMMANDS the reducer +// emits against the element, and listens at the outermost edges (the element's +// `scroll`/`scrollend` events + a ResizeObserver on the content). No ambient +// state: the consumer instantiates ONE controller per scroll region and disposes +// it on unmount. + +import { + createSmartScrollState, + onContentChange, + onReset, + onResume, + onScroll, + type ScrollCommand, + type ScrollGeometry, + type SmartScrollResult, + type SmartScrollState, +} from "../logic/smart-scroll"; + +export interface SmartScrollController { + /** Reactive: show the "scroll to bottom" affordance (the user has scrolled up). */ + readonly showButton: boolean; + /** + * Attach to the scroll container; returns a teardown to call on unmount. + * Pass the inner CONTENT element to also follow height changes that aren't a + * transcript update (async markdown/highlight, image loads, a collapse toggling, + * viewport reflow) via a ResizeObserver. + */ + attach(el: HTMLElement, content?: HTMLElement): () => void; + /** + * Notify that the transcript content changed (a streamed delta / new message). + * While stuck, keeps the view pinned to the bottom. + */ + contentChanged(): void; + /** Reset for a new transcript context (e.g. conversation switch): snap to bottom. */ + reset(): void; + /** The user clicked the affordance: re-stick and smooth-scroll to the bottom. */ + resume(): void; +} + +function geometryOf(el: HTMLElement): ScrollGeometry { + return { + scrollTop: el.scrollTop, + scrollHeight: el.scrollHeight, + clientHeight: el.clientHeight, + }; +} + +export function createSmartScrollController(): SmartScrollController { + let state: SmartScrollState = createSmartScrollState(); + let showButton = $state(false); + let el: HTMLElement | null = null; + // True while WE drive a programmatic scroll, so the resulting `scroll` event + // doesn't get misread as the user scrolling up. Cleared on `scrollend`. + let selfScrolling = false; + + function run(command: ScrollCommand | null): void { + if (!command || !el) return; + selfScrolling = true; + el.scrollTo({ + top: el.scrollHeight, + behavior: command.animate ? "smooth" : "instant", + }); + } + + function apply(r: SmartScrollResult): void { + state = r.state; + showButton = r.showButton; + run(r.command); + } + + function handleScroll(): void { + if (!el || selfScrolling) return; + apply(onScroll(state, geometryOf(el))); + } + + function handleScrollEnd(): void { + selfScrolling = false; + } + + return { + get showButton(): boolean { + return showButton; + }, + + attach(node: HTMLElement, content?: HTMLElement): () => void { + el = node; + node.addEventListener("scroll", handleScroll, { passive: true }); + node.addEventListener("scrollend", handleScrollEnd); + + // A ResizeObserver keeps the view pinned through height changes that are + // NOT a transcript update — async markdown/syntax-highlight, image loads, a + // collapse toggling, font swaps, viewport reflow — which a content-count + // signal can't see. Observe the CONTENT (it grows) and the container (it + // changes on viewport resize). Routed through `onContentChange`, so it only + // scrolls while stuck and never fights the reader. The `selfScrolling` guard + // (and the fact that scrolling doesn't resize content) prevents any loop. + let ro: ResizeObserver | null = null; + if (typeof ResizeObserver !== "undefined") { + ro = new ResizeObserver(() => { + if (!el || selfScrolling) return; + apply(onContentChange(state, geometryOf(el))); + }); + if (content) ro.observe(content); + ro.observe(node); + } + + return () => { + node.removeEventListener("scroll", handleScroll); + node.removeEventListener("scrollend", handleScrollEnd); + ro?.disconnect(); + if (el === node) el = null; + }; + }, + + contentChanged(): void { + if (!el) return; + apply(onContentChange(state, geometryOf(el))); + }, + + reset(): void { + apply(onReset()); + }, + + resume(): void { + apply(onResume(state)); + }, + }; +} |
