diff options
| author | Adam Malczewski <[email protected]> | 2026-06-27 01:13:28 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-27 01:13:31 +0900 |
| commit | 2fa03f8d7410c2b8d6be8e10ad088863e83d7177 (patch) | |
| tree | 94e1923180ae38d571d34b578afecb0a18913c24 /src/features/smart-scroll/ui | |
| parent | 80f99665034a0e510300793205c162fc7a46769f (diff) | |
| parent | 08b12478636f4a5c86a1f3c40a843f2906b7c82f (diff) | |
| download | dispatch-web-2fa03f8d7410c2b8d6be8e10ad088863e83d7177.tar.gz dispatch-web-2fa03f8d7410c2b8d6be8e10ad088863e83d7177.zip | |
Merge branch 'dev' into feature/heartbeat
# Conflicts:
# src/app/App.svelte
# src/app/store.svelte.ts
# src/app/store.test.ts
# src/features/workspaces/ui/WorkspaceCard.test.ts
Diffstat (limited to 'src/features/smart-scroll/ui')
| -rw-r--r-- | src/features/smart-scroll/ui/ScrollToBottom.svelte | 60 | ||||
| -rw-r--r-- | src/features/smart-scroll/ui/controller.svelte.ts | 242 | ||||
| -rw-r--r-- | src/features/smart-scroll/ui/controller.test.ts | 296 |
3 files changed, 299 insertions, 299 deletions
diff --git a/src/features/smart-scroll/ui/ScrollToBottom.svelte b/src/features/smart-scroll/ui/ScrollToBottom.svelte index 6fbd326..38ec1f5 100644 --- a/src/features/smart-scroll/ui/ScrollToBottom.svelte +++ b/src/features/smart-scroll/ui/ScrollToBottom.svelte @@ -1,36 +1,36 @@ <script lang="ts"> - // Thin affordance: a floating "scroll to bottom" button shown while the reader - // has scrolled up. Holds no logic — `show` and `onResume` come from the - // smart-scroll controller. - let { - show, - onResume, - }: { - show: boolean; - onResume: () => void; - } = $props(); + // Thin affordance: a floating "scroll to bottom" button shown while the reader + // has scrolled up. Holds no logic — `show` and `onResume` come from the + // smart-scroll controller. + let { + show, + onResume, + }: { + show: boolean; + onResume: () => void; + } = $props(); </script> <button - type="button" - class="btn btn-circle btn-sm absolute bottom-4 left-1/2 -translate-x-1/2 shadow-lg transition-opacity duration-200" - class:opacity-0={!show} - class:pointer-events-none={!show} - class:opacity-100={show} - onclick={onResume} - aria-label="Scroll to bottom" - aria-hidden={!show} - tabindex={show ? 0 : -1} + type="button" + class="btn btn-circle btn-sm absolute bottom-4 left-1/2 -translate-x-1/2 shadow-lg transition-opacity duration-200" + class:opacity-0={!show} + class:pointer-events-none={!show} + class:opacity-100={show} + onclick={onResume} + aria-label="Scroll to bottom" + aria-hidden={!show} + tabindex={show ? 0 : -1} > - <svg - xmlns="http://www.w3.org/2000/svg" - viewBox="0 0 24 24" - fill="none" - stroke="currentColor" - stroke-width="2.5" - class="size-4" - aria-hidden="true" - > - <path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" /> - </svg> + <svg + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="2.5" + class="size-4" + aria-hidden="true" + > + <path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" /> + </svg> </button> diff --git a/src/features/smart-scroll/ui/controller.svelte.ts b/src/features/smart-scroll/ui/controller.svelte.ts index dbe65d1..fee6561 100644 --- a/src/features/smart-scroll/ui/controller.svelte.ts +++ b/src/features/smart-scroll/ui/controller.svelte.ts @@ -7,134 +7,134 @@ // it on unmount. import { - createSmartScrollState, - onContentChange, - onReset, - onResume, - onScroll, - type ScrollCommand, - type ScrollGeometry, - type SmartScrollResult, - type SmartScrollState, + 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; - /** - * Non-reactive point-in-time query: is the view stuck to the bottom right now? - * For imperative callers (e.g. the chat-limit unload gate) that poll at event - * time rather than subscribing — reads the reducer state, not a rune. - */ - isAtBottom(): 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; + /** Reactive: show the "scroll to bottom" affordance (the user has scrolled up). */ + readonly showButton: boolean; + /** + * Non-reactive point-in-time query: is the view stuck to the bottom right now? + * For imperative callers (e.g. the chat-limit unload gate) that poll at event + * time rather than subscribing — reads the reducer state, not a rune. + */ + isAtBottom(): 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, - }; + 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; - }, - - isAtBottom(): boolean { - return state.stuck; - }, - - 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)); - }, - }; + 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; + }, + + isAtBottom(): boolean { + return state.stuck; + }, + + 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 index 614f4b0..906cc91 100644 --- a/src/features/smart-scroll/ui/controller.test.ts +++ b/src/features/smart-scroll/ui/controller.test.ts @@ -5,168 +5,168 @@ import { createSmartScrollController } from "./controller.svelte"; // 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<string, Set<EventListener>>(); - 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, - }; + const listeners = new Map<string, Set<EventListener>>(); + 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("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("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); + 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); - }); + 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("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); + 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); - }); + 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("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); + 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); + // 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" }); + // 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(); + // 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(); - } - }); + // 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); - }); + 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); + }); }); |
