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.test.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.test.ts')
| -rw-r--r-- | src/features/smart-scroll/logic/smart-scroll.test.ts | 103 |
1 files changed, 103 insertions, 0 deletions
diff --git a/src/features/smart-scroll/logic/smart-scroll.test.ts b/src/features/smart-scroll/logic/smart-scroll.test.ts new file mode 100644 index 0000000..fc3e3d1 --- /dev/null +++ b/src/features/smart-scroll/logic/smart-scroll.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "vitest"; +import { + createSmartScrollState, + isNearBottom, + NEAR_BOTTOM_THRESHOLD, + onContentChange, + onReset, + onResume, + onScroll, + type ScrollGeometry, +} from "./smart-scroll"; + +// A viewport 100px tall over 1000px of content: scrollTop 900 == pinned to bottom. +const atBottom: ScrollGeometry = { scrollTop: 900, scrollHeight: 1000, clientHeight: 100 }; +const nearBottom: ScrollGeometry = { + scrollTop: 900 - NEAR_BOTTOM_THRESHOLD, + scrollHeight: 1000, + clientHeight: 100, +}; +const scrolledUp: ScrollGeometry = { scrollTop: 200, scrollHeight: 1000, clientHeight: 100 }; + +describe("isNearBottom", () => { + it("is true exactly at the bottom", () => { + expect(isNearBottom(atBottom)).toBe(true); + }); + + it("is true within the threshold of the bottom", () => { + expect(isNearBottom(nearBottom)).toBe(true); + }); + + it("is false just beyond the threshold", () => { + expect( + isNearBottom({ + scrollTop: 900 - NEAR_BOTTOM_THRESHOLD - 1, + scrollHeight: 1000, + clientHeight: 100, + }), + ).toBe(false); + }); + + it("is false when scrolled well up", () => { + expect(isNearBottom(scrolledUp)).toBe(false); + }); + + it("honours a custom threshold", () => { + const geom: ScrollGeometry = { scrollTop: 800, scrollHeight: 1000, clientHeight: 100 }; + expect(isNearBottom(geom, 50)).toBe(false); + expect(isNearBottom(geom, 150)).toBe(true); + }); +}); + +describe("smart-scroll reducer", () => { + it("starts stuck and hides the button", () => { + const s = createSmartScrollState(); + expect(s.stuck).toBe(true); + }); + + it("onScroll up unsticks and shows the button, with no command", () => { + const r = onScroll(createSmartScrollState(), scrolledUp); + expect(r.state.stuck).toBe(false); + expect(r.showButton).toBe(true); + expect(r.command).toBeNull(); + }); + + it("onScroll back to the bottom re-sticks and hides the button", () => { + const up = onScroll(createSmartScrollState(), scrolledUp).state; + const r = onScroll(up, atBottom); + expect(r.state.stuck).toBe(true); + expect(r.showButton).toBe(false); + expect(r.command).toBeNull(); + }); + + it("onContentChange while stuck emits a NON-animated scroll (keep up with the stream)", () => { + const r = onContentChange(createSmartScrollState(), atBottom); + expect(r.command).toEqual({ kind: "scroll-to-bottom", animate: false }); + expect(r.state.stuck).toBe(true); + }); + + it("onContentChange while unstuck emits NO command (leave the reader in place)", () => { + const up = onScroll(createSmartScrollState(), scrolledUp).state; + const r = onContentChange(up, scrolledUp); + expect(r.command).toBeNull(); + expect(r.state.stuck).toBe(false); + expect(r.showButton).toBe(true); + }); + + it("onResume re-sticks and emits an ANIMATED scroll", () => { + const up = onScroll(createSmartScrollState(), scrolledUp).state; + const r = onResume(up); + expect(r.state.stuck).toBe(true); + expect(r.showButton).toBe(false); + expect(r.command).toEqual({ kind: "scroll-to-bottom", animate: true }); + }); + + it("onReset returns to stuck and snaps (non-animated) to the bottom", () => { + const up = onScroll(createSmartScrollState(), scrolledUp).state; + const r = onReset(); + void up; + expect(r.state.stuck).toBe(true); + expect(r.command).toEqual({ kind: "scroll-to-bottom", animate: false }); + expect(r.showButton).toBe(false); + }); +}); |
