mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
parent
85b48a3f52
commit
09f73f1509
@@ -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(
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
Reference in New Issue
Block a user