summaryrefslogtreecommitdiffhomepage
path: root/src/features/smart-scroll/ui/controller.svelte.ts
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-12 00:22:42 +0900
committerAdam Malczewski <[email protected]>2026-06-12 00:22:42 +0900
commitfd565a6555e8bc9f37f21cf9d900523ef3be531b (patch)
treeecf2c365c0c5e0ccdfc1a9ae350af933e4860ed2 /src/features/smart-scroll/ui/controller.svelte.ts
parente45cab2a2d9d7bf5e48ace7111fd84b1b9bf2df3 (diff)
downloaddispatch-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.ts130
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));
+ },
+ };
+}