From 85feb433ae11d20c3ee8b584ecb721e79472800d Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sat, 5 Sep 2026 11:09:42 +0100 Subject: [PATCH] fix(web): follow composer and rail layout --- web/src/structured.test.ts | 72 ++++++++++++++++++++++++++++++++++++++ web/src/structured.ts | 11 ++++-- 2 files changed, 80 insertions(+), 3 deletions(-) diff --git a/web/src/structured.test.ts b/web/src/structured.test.ts index 327ad6f..2bc64e1 100644 --- a/web/src/structured.test.ts +++ b/web/src/structured.test.ts @@ -935,6 +935,7 @@ describe("structured connection: reconnection/error/loading", () => { describe("structured connection: transcript scrolling", () => { let frames: Map; let nextFrame: number; + let resizeCallback: ResizeObserverCallback; beforeEach(() => { vi.clearAllMocks(); @@ -949,6 +950,14 @@ describe("structured connection: transcript scrolling", () => { vi.stubGlobal("cancelAnimationFrame", (id: number) => { frames.delete(id); }); + vi.stubGlobal("ResizeObserver", class { + constructor(callback: ResizeObserverCallback) { + resizeCallback = callback; + } + observe(): void {} + unobserve(): void {} + disconnect(): void {} + }); }); afterEach(() => { @@ -977,6 +986,13 @@ describe("structured connection: transcript scrolling", () => { for (const [, cb] of pending) cb(0); } + function resizeBody(width: number): void { + resizeCallback( + [{ contentRect: { width } } as ResizeObserverEntry], + {} as ResizeObserver, + ); + } + it("shows the bottom after the initial transcript snapshot is laid out", () => { const ws = new FakeSocket(); const conn = mount({ sockets: [ws] }); @@ -1034,6 +1050,55 @@ describe("structured connection: transcript scrolling", () => { conn.dispose(); }); + it.each([ + { position: "pinned", top: 800, expected: 880, manuallyScrolled: false }, + { position: "manually scrolled", top: 180, expected: 180, manuallyScrolled: true }, + ])("preserves the $position position when the composer grows", ({ top, expected, manuallyScrolled }) => { + const ws = new FakeSocket(); + const conn = mount({ sockets: [ws] }); + const { scroll, values } = metrics(conn, 1000, 200); + ws.open(); + ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } }); + runFrames(); + + values.top = top; + if (manuallyScrolled) scroll.dispatchEvent(new Event("scroll")); + const input = conn.el.querySelector(".structured-input")!; + Object.defineProperty(input, "scrollHeight", { configurable: true, value: 120 }); + input.value = "one\ntwo\nthree"; + input.dispatchEvent(new Event("input")); + values.height = 1080; + runFrames(); + + expect(input.style.height).toBe("120px"); + expect(values.top).toBe(expected); + conn.dispose(); + }); + + it.each([ + { position: "pinned", top: 800, expected: 850, manuallyScrolled: false }, + { position: "manually scrolled", top: 180, expected: 180, manuallyScrolled: true }, + ])("preserves the $position position across the narrow breakpoint", ({ top, expected, manuallyScrolled }) => { + const ws = new FakeSocket(); + const conn = mount({ sockets: [ws] }); + const { scroll, values } = metrics(conn, 1000, 200); + ws.open(); + ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } }); + runFrames(); + + values.top = top; + if (manuallyScrolled) scroll.dispatchEvent(new Event("scroll")); + resizeBody(500); + values.clientHeight = 150; + runFrames(); + + expect(conn.el.querySelector(".structured-body")!.classList).toContain("st-narrow"); + expect(values.top).toBe(expected); + resizeBody(500); + expect(frames.size).toBe(0); + conn.dispose(); + }); + it("preserves a user-scrolled position across updates, approvals, and tool expansion", () => { const ws = new FakeSocket(); const conn = mount({ sockets: [ws] }); @@ -1143,10 +1208,17 @@ describe("structured connection: transcript scrolling", () => { conn.onUnmount(); expect(frames.size).toBe(0); + const input = conn.el.querySelector(".structured-input")!; + Object.defineProperty(input, "scrollHeight", { configurable: true, value: 120 }); + input.dispatchEvent(new Event("input")); + resizeBody(500); + expect(frames.size).toBe(0); conn.onMount({ focus: false }); expect(frames.size).toBe(2); conn.dispose(); expect(frames.size).toBe(0); + resizeBody(700); + expect(conn.el.querySelector(".structured-body")!.classList).toContain("st-narrow"); runFrames(); expect(values.top).toBe(25); }); diff --git a/web/src/structured.ts b/web/src/structured.ts index 00f2aea..3c97853 100644 --- a/web/src/structured.ts +++ b/web/src/structured.ts @@ -389,8 +389,10 @@ export function createStructuredConnection( // ---- composer ---- function autoResize(): void { - input.style.height = "auto"; - input.style.height = `${Math.min(input.scrollHeight, 160)}px`; + updateTranscriptLayout(() => { + input.style.height = "auto"; + input.style.height = `${Math.min(input.scrollHeight, 160)}px`; + }); } input.addEventListener("input", autoResize); input.addEventListener("keydown", (e) => { @@ -446,8 +448,11 @@ export function createStructuredConnection( // ---- narrow-pane rail collapse (a split pane, not just the viewport) ---- const NARROW_BREAKPOINT = 560; const resizeObserver = new ResizeObserver((entries) => { + if (disposed) return; const width = entries[0]?.contentRect.width ?? bodyEl.clientWidth; - bodyEl.classList.toggle("st-narrow", width < NARROW_BREAKPOINT); + const narrow = width < NARROW_BREAKPOINT; + if (bodyEl.classList.contains("st-narrow") === narrow) return; + updateTranscriptLayout(() => bodyEl.classList.toggle("st-narrow", narrow)); }); resizeObserver.observe(bodyEl);