diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 7ce7f4838..9f1ab21a2 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -93,6 +93,7 @@ export default defineConfig({ "**/live-broadcast-reply-timeline.spec.ts", "**/markdown-parse-cache.spec.ts", "**/overscroll-boundary.spec.ts", + "**/terminal-wheel.spec.ts", "**/cold-switch-longtask.perf.ts", "**/timeline-no-shift.spec.ts", "**/human-edit-agent-content.spec.ts", diff --git a/desktop/src/shared/hooks/useWebviewScrollBoundaryLock.ts b/desktop/src/shared/hooks/useWebviewScrollBoundaryLock.ts index a37b2a7ce..88717b9ea 100644 --- a/desktop/src/shared/hooks/useWebviewScrollBoundaryLock.ts +++ b/desktop/src/shared/hooks/useWebviewScrollBoundaryLock.ts @@ -2,6 +2,7 @@ import * as React from "react"; const BOUNDARY_EPSILON_PX = 1; const CONVERSATION_SCROLL_SELECTOR = "[data-buzz-conversation-scroll]"; +const TERMINAL_SUBSTRATE_SELECTOR = "[data-terminal-owner]"; const SCROLLABLE_OVERFLOW_VALUES = new Set(["auto", "scroll", "overlay"]); function isHTMLElement(value: EventTarget | null): value is HTMLElement { @@ -96,12 +97,14 @@ export function useWebviewScrollBoundaryLock(enabled = true) { const path = event.composedPath(); let firstScrollable: HTMLElement | null = null; + let targetsTerminal = false; for (const target of path) { if (!isHTMLElement(target)) { continue; } + targetsTerminal ||= target.matches(TERMINAL_SUBSTRATE_SELECTOR); const scrollableY = deltaY !== 0 && isScrollableY(target); const scrollableX = deltaX !== 0 && isScrollableX(target); if (!scrollableY && !scrollableX) { @@ -117,6 +120,13 @@ export function useWebviewScrollBoundaryLock(enabled = true) { } } + // Custom terminal scrollback consumes vertical wheel gestures without a + // native scroll container, so it must not be mistaken for dead space. + // Predominantly horizontal gestures remain locked below. + if (targetsTerminal && Math.abs(deltaY) >= Math.abs(deltaX)) { + return; + } + // Only the vertical elastic affordance of conversation scrollers is // preserved; a predominantly horizontal gesture must never pan the // webview, even over a conversation pane. diff --git a/desktop/tests/e2e/overscroll-boundary.spec.ts b/desktop/tests/e2e/overscroll-boundary.spec.ts index 5aab62d89..64a931e60 100644 --- a/desktop/tests/e2e/overscroll-boundary.spec.ts +++ b/desktop/tests/e2e/overscroll-boundary.spec.ts @@ -64,6 +64,21 @@ test("locks viewport rubber-band outside conversation scrollers", async ({ deltaY: -120, }), ).resolves.toBe(false); + + // Buzz Term consumes wheel gestures as custom scrollback rather than through + // a native scroll container. The viewport lock must leave that vertical + // gesture alone so the substrate's own handler can receive it. + await page.evaluate(() => { + const terminal = document.createElement("section"); + terminal.dataset.terminalOwner = "buzz"; + terminal.dataset.testid = "terminal-wheel-target"; + document.body.append(terminal); + }); + await expect( + dispatchWheelPrevented(page, '[data-testid="terminal-wheel-target"]', { + deltaY: -120, + }), + ).resolves.toBe(false); }); test("locks horizontal viewport pan everywhere", async ({ page }) => { @@ -95,6 +110,20 @@ test("locks horizontal viewport pan everywhere", async ({ page }) => { ).resolves.toBe(true); } + await page.evaluate(() => { + const terminal = document.createElement("section"); + terminal.dataset.terminalOwner = "buzz"; + terminal.dataset.testid = "terminal-wheel-target"; + document.body.append(terminal); + }); + for (const deltaX of [-120, 120]) { + await expect( + dispatchWheelPrevented(page, '[data-testid="terminal-wheel-target"]', { + deltaX, + }), + ).resolves.toBe(true); + } + // A predominantly vertical gesture with slight horizontal drift still // reaches the conversation scroller. await expect( diff --git a/desktop/tests/e2e/terminal-wheel.spec.ts b/desktop/tests/e2e/terminal-wheel.spec.ts new file mode 100644 index 000000000..bd3d742e1 --- /dev/null +++ b/desktop/tests/e2e/terminal-wheel.spec.ts @@ -0,0 +1,189 @@ +import { expect, test, type Page } from "@playwright/test"; +import { installMockBridge } from "../helpers/bridge"; + +const TERM = 'section[aria-label="Buzz Term"]'; +const NAMED = 0x0100_0000; +const FG = NAMED | 256; +const BG = NAMED | 257; + +/** + * The shipped mock bridge throws on every `terminal_*` command, so Buzz Term is + * unreachable through `installMockBridge` alone. This pre-creates + * `__TAURI_INTERNALS__` and traps the `invoke` assignment `mockIPC` makes: + * terminal commands are answered here, everything else falls through to the + * real mock bridge. + */ +async function installTerminalBackend(page: Page) { + await page.addInitScript(() => { + const w = window as typeof window & { + isTauri?: boolean; + __TAURI_INTERNALS__?: Record; + __SAMI_TERM__?: unknown; + }; + w.isTauri = true; + const state = { + deliver: null as ((message: unknown) => void) | null, + scrolls: [] as number[], + inputs: [] as string[], + subscriptionId: "sami-sub-1", + columns: 80, + screenLines: 24, + sequence: 0, + }; + w.__SAMI_TERM__ = state; + + const internals: Record = {}; + let inner: ((cmd: string, args: unknown, opts: unknown) => unknown) | null = + null; + Object.defineProperty(internals, "invoke", { + configurable: true, + get: + () => (cmd: string, args: Record, opts: unknown) => { + switch (cmd) { + case "terminal_attach": { + const channel = args.onFrame as { + onmessage: (m: unknown) => void; + }; + state.deliver = (message) => channel.onmessage(message); + return Promise.resolve({ + sessionId: `sami-session-${state.sequence}`, + subscriptionId: state.subscriptionId, + viewport: { + generation: 1, + columns: state.columns, + screenLines: state.screenLines, + }, + }); + } + case "terminal_resize": + state.columns = args.columns as number; + state.screenLines = args.rows as number; + return Promise.resolve({ + generation: 1, + columns: state.columns, + screenLines: state.screenLines, + }); + case "terminal_input": + state.inputs.push(args.data as string); + return Promise.resolve(null); + case "terminal_scroll": + state.scrolls.push(args.lines as number); + return Promise.resolve(null); + case "terminal_viewport_ready": + case "terminal_ack": + case "terminal_focus": + case "terminal_detach": + case "terminal_close": + return Promise.resolve(null); + default: + if (!inner) throw new Error(`no mock bridge for ${cmd}`); + return inner(cmd, args, opts); + } + }, + set: (fn: (cmd: string, args: unknown, opts: unknown) => unknown) => { + inner = fn; + }, + }); + w.__TAURI_INTERNALS__ = internals; + }); +} + +async function pushFrame( + page: Page, + rows: { line: number; text: string }[], + cursor: { line: number; column: number }, +) { + await page.evaluate( + ({ rows, cursor, fg, bg }) => { + const state = ( + window as typeof window & { + __SAMI_TERM__: { + deliver: ((m: unknown) => void) | null; + subscriptionId: string; + columns: number; + screenLines: number; + sequence: number; + }; + } + ).__SAMI_TERM__; + state.sequence += 1; + state.deliver?.({ + type: "frame", + payload: { + subscriptionId: state.subscriptionId, + sequence: state.sequence, + bracketedPaste: false, + focusReporting: false, + full: true, + viewport: { + generation: 1, + columns: state.columns, + screenLines: state.screenLines, + }, + cursor: { ...cursor, visible: true }, + rows: rows.map(({ line, text }) => ({ + line, + spans: [ + { + style: { fg, bg, flags: 0 }, + clusters: [...text].map((ch, index) => ({ + column: index, + text: ch, + width: 1, + })), + }, + ], + })), + }, + }); + }, + { rows, cursor, fg: FG, bg: BG }, + ); +} + +async function reveal(page: Page) { + await page.setViewportSize({ width: 1280, height: 800 }); + await installTerminalBackend(page); + await installMockBridge(page); + await page.goto("/"); + await expect(page.getByTestId("home-inbox-list")).toBeVisible(); + // Buzz Term needs a channel: TerminalBootstrap's context is null on Home, so + // no session spawns and the chord is inert. + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + await page.keyboard.press("Meta+j"); + await expect(page.locator(TERM)).toHaveAttribute( + "data-terminal-owner", + "terminal", + ); + // data-terminal-owner flips synchronously at chord-up; the 360ms reveal fade + // is still running. Every capture below must wait for it to settle or it + // photographs a half-faded app surface and reads as a rendering defect. + await expect + .poll(async () => + page + .locator(".buzz-huddle-app-surface") + .evaluate((el) => getComputedStyle(el).opacity), + ) + .toBe("0"); +} + +test("scrollback: wheel over Buzz Term reaches terminal_scroll", async ({ + page, +}) => { + await reveal(page); + await pushFrame(page, [{ line: 0, text: "buzz@term:~$ " }], { + line: 0, + column: 13, + }); + await page.mouse.move(640, 500); + await page.mouse.wheel(0, -17 * 5); + await page.mouse.wheel(0, 17 * 2); + const scrolls = await page.evaluate( + () => + (window as typeof window & { __SAMI_TERM__: { scrolls: number[] } }) + .__SAMI_TERM__.scrolls, + ); + console.log("SCROLLS", JSON.stringify(scrolls)); + expect(scrolls).toEqual([-5, 2]); +});