fix(desktop): let terminal consume wheel scroll

Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@buzz.block.builderlab.xyz>

Signed-off-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@buzz.block.builderlab.xyz>
This commit is contained in:
npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta
2026-08-02 19:40:11 -04:00
parent 85b48a3f52
commit 09f73f1509
4 changed files with 229 additions and 0 deletions
+1
View File
@@ -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",
@@ -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.
@@ -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(
+189
View File
@@ -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<string, unknown>;
__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<string, unknown> = {};
let inner: ((cmd: string, args: unknown, opts: unknown) => unknown) | null =
null;
Object.defineProperty(internals, "invoke", {
configurable: true,
get:
() => (cmd: string, args: Record<string, unknown>, 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]);
});