From fd565a6555e8bc9f37f21cf9d900523ef3be531b Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Fri, 12 Jun 2026 00:22:42 +0900 Subject: feat(workspace,smart-scroll): per-conversation cwd + LSP view; smart auto-scroll MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit workspace (transport-contract@0.5.0): 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. --- src/features/smart-scroll/ui/ScrollToBottom.svelte | 36 +++++ src/features/smart-scroll/ui/controller.svelte.ts | 130 ++++++++++++++++ src/features/smart-scroll/ui/controller.test.ts | 172 +++++++++++++++++++++ 3 files changed, 338 insertions(+) create mode 100644 src/features/smart-scroll/ui/ScrollToBottom.svelte create mode 100644 src/features/smart-scroll/ui/controller.svelte.ts create mode 100644 src/features/smart-scroll/ui/controller.test.ts (limited to 'src/features/smart-scroll/ui') diff --git a/src/features/smart-scroll/ui/ScrollToBottom.svelte b/src/features/smart-scroll/ui/ScrollToBottom.svelte new file mode 100644 index 0000000..6fbd326 --- /dev/null +++ b/src/features/smart-scroll/ui/ScrollToBottom.svelte @@ -0,0 +1,36 @@ + + + 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)); + }, + }; +} diff --git a/src/features/smart-scroll/ui/controller.test.ts b/src/features/smart-scroll/ui/controller.test.ts new file mode 100644 index 0000000..614f4b0 --- /dev/null +++ b/src/features/smart-scroll/ui/controller.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, it, vi } from "vitest"; +import { createSmartScrollController } from "./controller.svelte"; + +// A minimal fake of the only DOM surface the controller touches: scroll +// geometry, scrollTo, and add/removeEventListener for "scroll"/"scrollend". +// Faking this outermost edge is the sanctioned mock (no internal modules mocked). +function createFakeScrollEl(opts?: { scrollHeight?: number; clientHeight?: number }) { + const listeners = new Map>(); + const el = { + scrollTop: 0, + scrollHeight: opts?.scrollHeight ?? 1000, + clientHeight: opts?.clientHeight ?? 100, + scrollTo: vi.fn((arg: ScrollToOptions) => { + // Emulate the browser: jump scrollTop, then (for "instant") fire scrollend. + el.scrollTop = (arg.top ?? 0) - 0; + if (arg.behavior !== "smooth") { + fire("scroll"); + fire("scrollend"); + } + }), + addEventListener: (type: string, fn: EventListener) => { + if (!listeners.has(type)) listeners.set(type, new Set()); + listeners.get(type)?.add(fn); + }, + removeEventListener: (type: string, fn: EventListener) => { + listeners.get(type)?.delete(fn); + }, + }; + function fire(type: string): void { + for (const fn of listeners.get(type) ?? []) fn(new Event(type)); + } + // Simulate the USER scrolling to a given offset (fires scroll, not self-driven). + function userScrollTo(top: number): void { + el.scrollTop = top; + fire("scroll"); + } + return { + el: el as unknown as HTMLElement, + scrollTo: el.scrollTo, + fire, + userScrollTo, + listenerCount: () => listeners, + }; +} + +describe("smart-scroll controller", () => { + it("starts with the button hidden", () => { + const c = createSmartScrollController(); + expect(c.showButton).toBe(false); + }); + + it("contentChanged while stuck scrolls to the bottom instantly", () => { + const c = createSmartScrollController(); + const fake = createFakeScrollEl(); + c.attach(fake.el); + c.contentChanged(); + expect(fake.scrollTo).toHaveBeenCalledWith({ + top: 1000, + behavior: "instant", + }); + expect(c.showButton).toBe(false); + }); + + it("a user scroll up shows the button and stops auto-following", () => { + const c = createSmartScrollController(); + const fake = createFakeScrollEl(); + c.attach(fake.el); + fake.userScrollTo(200); // far from the bottom + expect(c.showButton).toBe(true); + + const scrollTo = fake.scrollTo; + scrollTo.mockClear(); + c.contentChanged(); // streaming more content... + expect(scrollTo).not.toHaveBeenCalled(); // ...must NOT yank the reader down + expect(c.showButton).toBe(true); + }); + + it("self-driven scrolls are not misread as the user scrolling up", () => { + const c = createSmartScrollController(); + const fake = createFakeScrollEl(); + c.attach(fake.el); + // contentChanged drives an instant scrollTo, whose synthetic scroll event + // must NOT flip us to unstuck (selfScrolling guard). + c.contentChanged(); + expect(c.showButton).toBe(false); + }); + + it("resume re-sticks and smooth-scrolls to the bottom", () => { + const c = createSmartScrollController(); + const fake = createFakeScrollEl(); + c.attach(fake.el); + fake.userScrollTo(200); + expect(c.showButton).toBe(true); + + c.resume(); + expect(fake.scrollTo).toHaveBeenCalledWith({ + top: 1000, + behavior: "smooth", + }); + expect(c.showButton).toBe(false); + }); + + it("reset snaps to the bottom and hides the button", () => { + const c = createSmartScrollController(); + const fake = createFakeScrollEl(); + c.attach(fake.el); + fake.userScrollTo(200); + expect(c.showButton).toBe(true); + c.reset(); + expect(fake.scrollTo).toHaveBeenCalledWith({ + top: 1000, + behavior: "instant", + }); + expect(c.showButton).toBe(false); + }); + + it("observes content via a ResizeObserver: follows growth while stuck, not while unstuck", () => { + const holder: { cb: ResizeObserverCallback | null } = { cb: null }; + const observed: unknown[] = []; + const disconnect = vi.fn(); + class FakeResizeObserver { + constructor(cb: ResizeObserverCallback) { + holder.cb = cb; + } + observe(target: Element): void { + observed.push(target); + } + unobserve(): void {} + disconnect = disconnect; + } + vi.stubGlobal("ResizeObserver", FakeResizeObserver); + try { + const c = createSmartScrollController(); + const fake = createFakeScrollEl(); + const content = { id: "content" } as unknown as HTMLElement; + const teardown = c.attach(fake.el, content); + + // Observes both the content (it grows) and the scroll container (viewport resize). + expect(observed).toContain(content); + expect(observed).toContain(fake.el); + + // Stuck → a resize keeps us pinned to the bottom. + fake.scrollTo.mockClear(); + holder.cb?.([], {} as ResizeObserver); + expect(fake.scrollTo).toHaveBeenCalledWith({ top: 1000, behavior: "instant" }); + + // Reader scrolls up → a later resize must NOT yank them down. + fake.userScrollTo(200); + fake.scrollTo.mockClear(); + holder.cb?.([], {} as ResizeObserver); + expect(fake.scrollTo).not.toHaveBeenCalled(); + + // Teardown disconnects the observer. + teardown(); + expect(disconnect).toHaveBeenCalled(); + } finally { + vi.unstubAllGlobals(); + } + }); + + it("attach returns a teardown that removes both listeners", () => { + const c = createSmartScrollController(); + const fake = createFakeScrollEl(); + const teardown = c.attach(fake.el); + const before = fake.listenerCount(); + expect(before.get("scroll")?.size).toBe(1); + expect(before.get("scrollend")?.size).toBe(1); + teardown(); + expect(before.get("scroll")?.size).toBe(0); + expect(before.get("scrollend")?.size).toBe(0); + }); +}); -- cgit v1.2.3