fix(web): enable coarse-pointer terminal scrolling (#2808) (#3301)

This commit is contained in:
Carlos Florêncio
2026-09-03 19:47:36 +00:00
committed by GitHub
parent a279a7ff5f
commit a3c19ec0a2
15 changed files with 639 additions and 62 deletions
@@ -75,7 +75,6 @@ function TerminalSlot({
autoFocus={isActive}
disableWebgl
manualInputRouting
enableTouchScroll
onXtermReady={setXterm}
onWsReady={handleWsReady}
/>
@@ -32,6 +32,7 @@ import { useTerminalBusyTracking } from "./use-terminal-busy-tracking";
import { useTerminalTheme } from "./use-terminal-theme";
import { useTranslation } from "react-i18next";
import { useClarificationEscapeGuard } from "@/hooks/use-clarification-escape-guard";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
type BaseProps = {
autoFocus?: boolean;
@@ -54,9 +55,9 @@ type BaseProps = {
* Mobile uses this to register a key-bar sender that writes raw bytes
* directly to this terminal's socket. */
onWsReady?: (ws: WebSocket) => void;
/** Translate single-finger touch swipes on the terminal area into xterm
* scrollback navigation. Mobile callers set this so the xterm canvas no
* longer silently absorbs touch gestures. */
/** Optional touch-scroll override. Coarse-pointer shell terminals enable
* touch scrolling by default, while fine pointers never install the custom
* handler. */
enableTouchScroll?: boolean;
};
type AgentTerminalProps = BaseProps & { mode: "agent"; sessionId?: string | null; label?: string };
@@ -148,6 +149,15 @@ export function computeCanConnect(
return !environmentEnded;
}
export function resolveTouchScrollEnabled(
mode: "agent" | "shell",
isFinePointer: boolean,
requested?: boolean,
): boolean {
if (isFinePointer) return false;
return requested ?? mode === "shell";
}
const SESSION_TERMINAL_STATES = new Set<TaskSessionState>(["COMPLETED", "FAILED", "CANCELLED"]);
export function isSessionEnded(state: TaskSessionState | null | undefined): boolean {
@@ -405,6 +415,7 @@ function usePassthroughEffects({
export function PassthroughTerminal(props: PassthroughTerminalProps) {
const { mode, autoFocus, onXtermReady } = props;
const { isFinePointer } = useResponsiveBreakpoint();
const { resolvedTheme } = useTheme();
const terminalId = mode === "shell" ? props.terminalId : undefined;
const environmentId = mode === "shell" ? props.environmentId : undefined;
@@ -428,8 +439,12 @@ export function PassthroughTerminal(props: PassthroughTerminalProps) {
wsRef,
});
const paneState = computeTerminalPaneState(mode, environmentEnded, connection.isConnected);
const effectiveProps: PassthroughTerminalProps = {
...props,
enableTouchScroll: resolveTouchScrollEnabled(mode, isFinePointer, props.enableTouchScroll),
};
const { containerRef, search } = usePassthroughEffects({
props,
props: effectiveProps,
refs,
terminalId,
environmentId,
@@ -31,6 +31,7 @@ let mockKeyboardShortcuts: Record<string, { key: string; modifiers?: Record<stri
let mockPendingByFile: Record<string, import("@/lib/state/slices/comments").DiffComment[]> = {};
let mockPlanModeEnabled = false;
let mockImplementPlanHandler: ((fresh: boolean) => void) | undefined;
let mockIsFinePointer = true;
let mockNextStep: {
proceedStepName: string | null;
proceed: ReturnType<typeof vi.fn>;
@@ -48,6 +49,9 @@ const chatInputMock = vi.hoisted(() => ({
focusInput: vi.fn(),
getTextareaElement: vi.fn(() => null as HTMLElement | null),
}));
const passthroughTerminalMock = vi.hoisted(() => ({
renderProps: vi.fn(),
}));
// --- Module mocks (hoisted by Vitest) ---
@@ -107,13 +111,23 @@ vi.mock("@/hooks/use-file-editors", () => ({
useFileEditors: () => ({ openFile: mockOpenFile }),
}));
vi.mock("@/hooks/use-responsive-breakpoint", () => ({
useResponsiveBreakpoint: () => ({
isMobile: false,
isFinePointer: mockIsFinePointer,
}),
}));
vi.mock("@/lib/ws/connection", () => ({
getWebSocketClient: () => ({ request: mockWsRequestFn }),
}));
// Stub heavy sub-components that involve xterm / canvas / WebGL.
vi.mock("./passthrough-terminal", () => ({
PassthroughTerminal: () => <div data-testid="passthrough-terminal-stub" />,
PassthroughTerminal: (props: { enableTouchScroll?: boolean }) => {
passthroughTerminalMock.renderProps(props);
return <div data-testid="passthrough-terminal-stub" />;
},
}));
vi.mock("@/components/github/pr-status-chip", () => ({
@@ -280,9 +294,11 @@ function resetMocks() {
mockPendingByFile = {};
mockPlanModeEnabled = false;
mockImplementPlanHandler = undefined;
mockIsFinePointer = true;
mockNextStep = { proceedStepName: null, proceed: vi.fn(), isMoving: false };
mockWsRequestFn = vi.fn().mockResolvedValue(undefined);
chatInputMock.renderProps.mockClear();
passthroughTerminalMock.renderProps.mockClear();
vi.clearAllMocks();
}
@@ -291,6 +307,11 @@ function latestChatInputProps(): Record<string, unknown> {
return (calls[calls.length - 1]?.[0] ?? {}) as Record<string, unknown>;
}
function latestPassthroughTerminalProps(): { enableTouchScroll?: boolean } {
const calls = passthroughTerminalMock.renderProps.mock.calls;
return (calls[calls.length - 1]?.[0] ?? {}) as { enableTouchScroll?: boolean };
}
// ---------------------------------------------------------------------------
// Default / idle state
// ---------------------------------------------------------------------------
@@ -332,6 +353,27 @@ describe("PassthroughToolbar – default state", () => {
});
});
describe("PassthroughToolbar – touch-scroll activation", () => {
beforeEach(resetMocks);
afterEach(cleanup);
it("enables touch scrolling for a coarse pointer at tablet width", () => {
// @covers AC-UI-TERMINAL-TOUCH-SCROLLING-001.2
mockIsFinePointer = false;
renderToolbar();
expect(latestPassthroughTerminalProps().enableTouchScroll).toBe(true);
});
it("keeps the custom touch handler disabled for a fine pointer", () => {
// @covers AC-UI-TERMINAL-TOUCH-SCROLLING-001.5
mockIsFinePointer = true;
renderToolbar();
expect(latestPassthroughTerminalProps().enableTouchScroll).toBe(false);
});
});
// ---------------------------------------------------------------------------
// Composer open / close
// ---------------------------------------------------------------------------
@@ -107,7 +107,7 @@ export function PassthroughToolbar({
const isAgentBusy = sessionState === "RUNNING" || sessionState === "STARTING";
const { pendingComments, pendingCount } = usePendingPassthroughComments(sessionId);
const { isMobile } = useResponsiveBreakpoint();
const { isFinePointer: finePointer } = useResponsiveBreakpoint();
const { openFile } = useFileEditors();
const panelState = useChatPanelState({ sessionId: sessionId ?? null, onOpenFile: openFile });
const planActions = usePlanActions({
@@ -157,7 +157,7 @@ export function PassthroughToolbar({
return (
<div className="flex h-full flex-col bg-card" data-testid="passthrough-toolbar">
<div className="flex-1 min-h-0">
<PassthroughTerminal sessionId={sessionId} mode="agent" enableTouchScroll={isMobile} />
<PassthroughTerminal sessionId={sessionId} mode="agent" enableTouchScroll={!finePointer} />
</div>
{commentsOpen && pendingCount > 0 && (
@@ -4,6 +4,7 @@ import {
computeCanConnect,
computeTerminalPaneState,
isEnvironmentEnded,
resolveTouchScrollEnabled,
shouldGuardPassthroughEscape,
} from "./passthrough-terminal";
import { reconnectDelayMs, startReconnectLoop } from "./ws-reconnect";
@@ -60,6 +61,26 @@ describe("computeCanConnect", () => {
});
});
describe("resolveTouchScrollEnabled", () => {
it("enables shell terminals by default for a coarse pointer", () => {
expect(resolveTouchScrollEnabled("shell", false)).toBe(true);
});
it("keeps the custom handler disabled for a fine pointer", () => {
expect(resolveTouchScrollEnabled("shell", true, true)).toBe(false);
expect(resolveTouchScrollEnabled("agent", true, true)).toBe(false);
});
it("keeps an explicit coarse-pointer opt-out", () => {
expect(resolveTouchScrollEnabled("shell", false, false)).toBe(false);
});
it("does not opt agent terminals in unless their caller requests it", () => {
expect(resolveTouchScrollEnabled("agent", false)).toBe(false);
expect(resolveTouchScrollEnabled("agent", false, true)).toBe(true);
});
});
describe("reconnectDelayMs", () => {
it("returns 300ms for attempt 0", () => {
expect(reconnectDelayMs(0)).toBe(300);
+49
View File
@@ -0,0 +1,49 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { ApiClient } from "./api-client";
describe("ApiClient.createAgentProfile", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("forwards auto_approve while preserving the profile request contract", async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url.endsWith("/api/v1/app-state?path=%2Fsettings%2Fagents")) {
return Response.json({ interimSettingsInterlockToken: "test-token" });
}
if (url.endsWith("/api/v1/agents/agent-1/profiles")) {
const body = JSON.parse(String(init?.body)) as Record<string, unknown>;
expect(body).toMatchObject({
name: "TUI profile",
model: "mock-fast",
auto_approve: true,
cli_passthrough: true,
});
expect(init?.headers).toMatchObject({
"Content-Type": "application/json",
"X-Kandev-Interim-Settings-Interlock": "test-token",
});
return Response.json({
id: "profile-1",
name: "TUI profile",
agent_id: "agent-1",
model: "mock-fast",
auto_approve: true,
cli_passthrough: true,
});
}
throw new Error(`unexpected request: ${url}`);
});
vi.stubGlobal("fetch", fetchMock);
const profile = await new ApiClient("http://backend.test").createAgentProfile(
"agent-1",
"TUI profile",
{ model: "mock-fast", auto_approve: true, cli_passthrough: true },
);
expect(profile.id).toBe("profile-1");
expect(fetchMock).toHaveBeenCalledTimes(2);
});
});
+2
View File
@@ -634,6 +634,7 @@ export class ApiClient {
model: string;
fallback_model?: string;
auto_fallback?: boolean;
auto_approve?: boolean;
mode?: string;
config_options?: Record<string, string>;
cli_passthrough?: boolean;
@@ -647,6 +648,7 @@ export class ApiClient {
model: opts.model,
fallback_model: opts.fallback_model,
auto_fallback: opts.auto_fallback,
auto_approve: opts.auto_approve,
mode: opts.mode,
config_options: opts.config_options,
cli_passthrough: opts.cli_passthrough ?? false,
@@ -281,6 +281,8 @@ export class WorkflowSettingsPage {
this.page.getByTestId(`${stepId}-profile-session-end-${lifecycle.endPolicy}`),
touch,
);
await this.page.keyboard.press("Escape");
await expect(this.stepAgentProfileSelect(card)).toHaveAttribute("aria-expanded", "false");
}
/** Hover over a step node to reveal the trash button, then click it. */
@@ -206,12 +206,17 @@ test.describe("Automation run composer", () => {
.poll(async () => (await apiClient.listSessionTurns(task.session_id!)).turns.length, {
timeout: 15_000,
})
.toBe(2);
.toBeGreaterThanOrEqual(2);
const updatedTurns = await apiClient.listSessionTurns(task.session_id!);
const replyTurnId = updatedTurns.turns.find((turn) => turn.id !== initialTurnId)?.id;
expect(replyTurnId).toBeTruthy();
await expect(transcript).toContainText(reply, { timeout: 15_000 });
await expect(transcript.locator(`[data-turn-id="${replyTurnId}"]`)).toBeVisible();
// A turn can also contain a synthetic empty-turn status row. Scope the
// assertion to the row that contains the submitted reply instead of
// requiring the turn id to identify a single DOM node.
await expect(
transcript.locator(`[data-turn-id="${replyTurnId}"]`).filter({ hasText: reply }),
).toBeVisible();
});
test("sits on the run page's own background, not the task workbench's card", async ({
@@ -1,5 +1,5 @@
// Routing: /t/{taskId} (task-keyed). File name starts with "mobile-" so it
// runs on the mobile-chrome Playwright project (Pixel 5 emulation).
// runs on the mobile-chrome Playwright project with coarse-pointer emulation.
//
// Covers the bug: xterm.js's canvas absorbs touch events, so a vertical swipe
// over the mobile terminal does nothing. The mobile passthrough wires the
@@ -21,7 +21,37 @@ async function seedTaskWithSession(
apiClient: ApiClient,
seedData: SeedData,
title: string,
): Promise<SessionPage> {
): Promise<void> {
const { agents } = await apiClient.listAgents();
if (agents.length === 0) throw new Error("no agents registered in this e2e profile");
const profile = await apiClient.createAgentProfile(agents[0].id, `${title} profile`, {
model: "mock-fast",
auto_approve: true,
cli_passthrough: true,
});
const scrollback = Array.from({ length: 200 }, (_, index) => `scrollback line ${index + 1}`).join(
"\n",
);
const task = await apiClient.createTaskWithAgent(seedData.workspaceId, title, profile.id, {
description: scrollback,
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
});
if (!task.session_id) throw new Error("expected passthrough task to start a session");
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
await session.waitForPassthroughLoad(20_000);
await session.waitForPassthroughLoaded(20_000);
await session.expectPassthroughHasText("scrollback line 200", 20_000);
}
async function seedShellTaskWithSession(
testPage: Page,
apiClient: ApiClient,
seedData: SeedData,
title: string,
): Promise<void> {
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
title,
@@ -37,37 +67,32 @@ async function seedTaskWithSession(
const session = new SessionPage(testPage);
await session.waitForLoad();
await session.waitForChatIdle();
return session;
}
async function readViewportY(page: Page): Promise<number> {
return page.evaluate(() => {
const panels = Array.from(document.querySelectorAll('[data-testid="terminal-panel"]'));
async function readViewportY(
page: Page,
rootSelector = '[data-testid="passthrough-terminal"]',
): Promise<number> {
return page.evaluate((selector) => {
const panels = Array.from(document.querySelectorAll(selector));
const visiblePanels = panels.filter((panel) => panel.getClientRects().length > 0);
const panel = visiblePanels.at(-1) ?? panels.at(-1);
type XC = HTMLElement & { __xtermReadViewportY?: () => number };
const container = panel?.querySelector('[data-testid="terminal-xterm-host"]') as XC | null;
return container?.__xtermReadViewportY?.() ?? -1;
});
}, rootSelector);
}
/**
* Dispatch a synthetic single-finger swipe on the xterm container. We bypass
* Playwright's touchscreen because the gesture coordinates need to be relative
* to the .xterm element's bounding box, and we want guaranteed delivery
* regardless of overlay z-index.
*/
async function swipe(
page: Page,
direction: "down" | "up",
steps = 5,
rowsToScroll = 8,
rootSelector = '[data-testid="passthrough-terminal"]',
): Promise<void> {
await page.evaluate(
({ direction, steps, rowsToScroll }) => {
const panel = document.querySelector('[data-testid="terminal-panel"]');
// xterm registers touch handling on its generated element, so this test
// intentionally locates that implementation node for event dispatch.
const points = await page.evaluate(
({ direction, rootSelector, steps, rowsToScroll }) => {
const panel = document.querySelector(rootSelector);
const xterms = Array.from(panel?.querySelectorAll(".xterm") ?? []);
const xtermEl = (panel?.querySelector(".xterm.focus") ?? xterms.at(-1)) as HTMLElement | null;
if (!xtermEl) throw new Error("xterm element not found");
@@ -75,27 +100,41 @@ async function swipe(
const cx = rect.left + rect.width / 2;
const startY = direction === "down" ? rect.top + 16 : rect.bottom - 16;
const rowHeight = rect.height / 24;
const totalDy = rowHeight * rowsToScroll * (direction === "down" ? 1 : -1);
const requestedDy = rowHeight * rowsToScroll;
const maxDy = Math.max(0, rect.height - 32);
const totalDy = Math.min(requestedDy, maxDy) * (direction === "down" ? 1 : -1);
const stepDy = totalDy / steps;
const makeTouch = (clientX: number, clientY: number) =>
({ clientX, clientY, identifier: 1 }) as unknown as Touch;
const fire = (type: string, clientY: number) => {
const event = new Event(type, { bubbles: true, cancelable: true }) as TouchEvent;
const touches = [makeTouch(cx, clientY)];
Object.defineProperty(event, "touches", { value: touches, configurable: true });
Object.defineProperty(event, "changedTouches", { value: touches, configurable: true });
xtermEl.dispatchEvent(event);
};
fire("touchstart", startY);
for (let i = 1; i <= steps; i++) {
fire("touchmove", startY + stepDy * i);
}
fire("touchend", startY + totalDy);
return Array.from({ length: steps + 1 }, (_, index) => ({
x: cx,
y: startY + stepDy * index,
}));
},
{ direction, steps, rowsToScroll },
{ direction, rootSelector, steps, rowsToScroll },
);
// CDP dispatches browser-level touch input. Unlike page.evaluate-created
// TouchEvents, these events are trusted and exercise Chromium's real input
// path into xterm's canvas.
const client = await page.context().newCDPSession(page);
try {
await client.send("Input.dispatchTouchEvent", {
type: "touchStart",
touchPoints: [{ ...points[0], id: 1 }],
});
for (const point of points.slice(1)) {
await client.send("Input.dispatchTouchEvent", {
type: "touchMove",
touchPoints: [{ ...point, id: 1 }],
});
}
await client.send("Input.dispatchTouchEvent", {
type: "touchEnd",
touchPoints: [],
});
} finally {
await client.detach();
}
}
async function typeAndRun(page: Page, command: string): Promise<void> {
@@ -112,23 +151,19 @@ test.describe("Mobile passthrough terminal — touch scroll", () => {
seedData,
}) => {
test.setTimeout(120_000);
await seedTaskWithSession(testPage, apiClient, seedData, "Touch scroll");
await switchToTerminalPanel(testPage);
await waitForShellReady(testPage);
await focusTerminalForTyping(testPage);
// Produce enough output to populate the scrollback past one viewport.
await typeAndRun(testPage, "for i in $(seq 1 200); do echo line $i; done");
await testPage.setViewportSize({ width: 820, height: 851 });
await expect
.poll(() => readTerminalBuffer(testPage), {
timeout: 30_000,
message: "Waiting for the 200th echo to land",
.poll(() => testPage.evaluate(() => matchMedia("(pointer: coarse)").matches), {
message:
"Playwright project must emulate coarse pointer; check the mobile-chrome project config",
})
.toContain("line 200");
.toBe(true);
await seedTaskWithSession(testPage, apiClient, seedData, "Touch scroll");
await testPage.getByTestId("passthrough-terminal").locator(".xterm").click();
const bottomViewportY = await readViewportY(testPage);
expect(bottomViewportY).toBeGreaterThan(0);
const documentScrollY = await testPage.evaluate(() => window.scrollY);
// Swipe down — finger drags down → reveals older lines → viewportY drops.
await swipe(testPage, "down");
@@ -141,7 +176,7 @@ test.describe("Mobile passthrough terminal — touch scroll", () => {
.toBeLessThan(bottomViewportY);
// Swipe up enough rows to return to the bottom of the buffer.
await swipe(testPage, "up", 5, 50);
await swipe(testPage, "up", 5, 20);
// Match-or-beat: any late shell output that arrives between captures bumps
// the buffer's bottom further down, so the new viewportY may legitimately
@@ -153,5 +188,59 @@ test.describe("Mobile passthrough terminal — touch scroll", () => {
message: "Upward swipe should return viewportY to the bottom",
})
.toBeGreaterThanOrEqual(bottomViewportY);
await expect.poll(() => testPage.evaluate(() => window.scrollY)).toBe(documentScrollY);
});
test("keeps the mobile shell terminal scrollable without moving the document", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(120_000);
await testPage.setViewportSize({ width: 393, height: 851 });
await expect
.poll(() => testPage.evaluate(() => matchMedia("(pointer: coarse)").matches), {
message:
"Playwright project must emulate coarse pointer; check the mobile-chrome project config",
})
.toBe(true);
await seedShellTaskWithSession(testPage, apiClient, seedData, "Shell touch scroll");
await switchToTerminalPanel(testPage);
await waitForShellReady(testPage);
await focusTerminalForTyping(testPage);
await typeAndRun(testPage, "for i in $(seq 1 200); do echo line $i; done");
await expect
.poll(() => readTerminalBuffer(testPage), {
timeout: 30_000,
message: "Waiting for the 200th shell echo to land",
})
.toContain("line 200");
const shellRootSelector = '[data-testid^="mobile-terminal-slot-"][data-state="active"]';
const bottomViewportY = await readViewportY(testPage, shellRootSelector);
expect(bottomViewportY).toBeGreaterThan(0);
const documentScrollY = await testPage.evaluate(() => window.scrollY);
await swipe(testPage, "down", 5, 8, shellRootSelector);
await expect
.poll(() => readViewportY(testPage, shellRootSelector), {
timeout: 5_000,
message: "Downward shell swipe should scroll xterm into the scrollback",
})
.toBeLessThan(bottomViewportY);
await swipe(testPage, "up", 5, 20, shellRootSelector);
await expect
.poll(() => readViewportY(testPage, shellRootSelector), {
timeout: 5_000,
message: "Upward shell swipe should return viewportY to the bottom",
})
.toBeGreaterThanOrEqual(bottomViewportY);
await expect.poll(() => testPage.evaluate(() => window.scrollY)).toBe(documentScrollY);
});
});
@@ -0,0 +1,100 @@
---
created: 2026-09-02
updated: 2026-09-03
status: done
requirements:
- REQ-UI-TERMINAL-TOUCH-SCROLLING-001
system_design:
- ../../specs/ui/system-design/terminal-touch-scrolling.md
legacy_specs: []
---
# Implementation Plan: Passthrough Terminal Touch Scroll
## Overview
The current touch-scroll handler works in the phone layout. Coarse-pointer layouts at 768 CSS pixels or more do not activate it consistently across terminal callers.
This plan adds a failing coarse-pointer TUI regression. It then changes activation from a width rule to the existing pointer-capability rule.
## Scope
### In scope
- Enable passthrough TUI touch scrolling for coarse pointers at each viewport width.
- Keep the existing touch-scroll handler and gesture rules.
- Add component evidence for fine-pointer and coarse-pointer activation.
- Add trusted browser-touch evidence for the tablet task layout and the phone shell pane.
### Out of scope
- Replace the handler with an xterm addon or CSS event pass-through.
- Add momentum, inertia, or new selection behavior.
- Change the terminal buffer, transport, WebSocket, or layout.
- Change Quick Chat or non-`PassthroughTerminal` products.
## Technical approach
Have `PassthroughTerminal` resolve touch activation from terminal mode, pointer precision, and the optional caller request. Shell terminals default to touch scrolling for coarse pointers, agent callers opt in, and fine pointers always disable the handler. Keep the toolbar's pointer-aware request and remove the mobile shell pane's unconditional override.
Extend the toolbar component test and add resolver coverage for shell defaults and fine-pointer protection. Capture the terminal prop and cover fine-pointer and coarse-pointer results.
Extend `mobile-terminal-scroll.spec.ts` with the reported `cli_passthrough` path. Use an 820-pixel coarse-pointer viewport and trusted browser touch input. Keep a separate 393-pixel phone flow for the shell path through `MobileTerminalPane`, also using trusted browser touch input.
The browser test must create more output than one xterm viewport. It must assert xterm movement and stable document scroll position.
## Tests
| Acceptance criterion | Evidence |
| --- | --- |
| `AC-UI-TERMINAL-TOUCH-SCROLLING-001.1` | The tablet TUI and phone shell Playwright flows move `viewportY` after trusted touch input. |
| `AC-UI-TERMINAL-TOUCH-SCROLLING-001.2` | Toolbar and `PassthroughTerminal` resolver tests cover pointer capability instead of viewport width. |
| `AC-UI-TERMINAL-TOUCH-SCROLLING-001.3` | The tablet TUI and phone shell Playwright flows keep document scroll position unchanged. |
| `AC-UI-TERMINAL-TOUCH-SCROLLING-001.4` | Existing `touch-scroll.test.ts` cases cover tap, multi-touch, and horizontal movement. |
| `AC-UI-TERMINAL-TOUCH-SCROLLING-001.5` | The toolbar component test keeps the handler disabled for a fine pointer. |
## E2E tests
`apps/web/e2e/tests/terminal/mobile-terminal-scroll.spec.ts` uses the `mobile-chrome` project. It contains two trusted-touch flows: an 820-pixel coarse-pointer `cli_passthrough` TUI flow and a 393-pixel phone shell flow through `MobileTerminalPane`.
Both flows create enough output to exceed one xterm viewport and send trusted touch input to the xterm canvas. Each asserts terminal movement and stable document scroll position.
## Files
- `apps/web/components/task/passthrough-terminal.tsx`
- `apps/web/components/task/use-passthrough-terminal.test.ts`
- `apps/web/components/task/mobile/mobile-terminal-pane.tsx`
- `apps/web/components/task/passthrough-toolbar.tsx`
- `apps/web/components/task/passthrough-toolbar.test.tsx`
- `apps/web/e2e/helpers/api-client.ts`
- `apps/web/e2e/helpers/api-client.test.ts`
- `apps/web/e2e/tests/terminal/mobile-terminal-scroll.spec.ts`
## Work orders
- [x] [Task 01: Enable coarse-pointer terminal scrolling](task-01-enable-coarse-pointer-terminal-scrolling.md) (`done`)
## Verification results
- RED unit evidence: the new coarse-pointer toolbar test failed before the production change because `enableTouchScroll` was `false`.
- RED browser evidence: the trusted-touch 820-pixel coarse-pointer flow failed before the production change because `viewportY` stayed at `172` after the downward swipe.
- `(cd apps/web && pnpm exec vitest run components/task/passthrough-toolbar.test.tsx lib/terminal/touch-scroll.test.ts)`: passed, 2 files and 41 tests.
- `(cd apps/web && pnpm exec eslint components/task/passthrough-toolbar.tsx components/task/passthrough-toolbar.test.tsx e2e/tests/terminal/mobile-terminal-scroll.spec.ts)`: passed.
- `(cd apps/web && pnpm run typecheck)`: passed.
- `(cd apps/web && pnpm e2e:run --project mobile-chrome tests/terminal/mobile-terminal-scroll.spec.ts -- --retries=0)`: passed, 1 test. The managed run built the production web bundle and verified trusted touch scrollback movement with stable document scroll.
- Review remediation browser coverage: `(cd apps/web && pnpm e2e:run --no-build --project mobile-chrome tests/terminal/mobile-terminal-scroll.spec.ts -- --retries=0)`: passed, 2 tests. This covers both the 820-pixel agent-TUI flow and the 393-pixel mobile shell flow through `MobileTerminalPane`.
- Review remediation shell stability: `(cd apps/web && pnpm e2e:run --no-build --project mobile-chrome tests/terminal/mobile-terminal-scroll.spec.ts -- --grep "keeps the mobile shell terminal scrollable" --retries=0)`: passed, 1 test.
- Fixup RED evidence: the new shell resolver tests failed before centralization because the resolver was missing, and the API helper test failed because `auto_approve` was omitted from the request body.
- Fixup GREEN evidence: `(cd apps/web && pnpm exec vitest run components/task/use-passthrough-terminal.test.ts e2e/helpers/api-client.test.ts components/task/passthrough-toolbar.test.tsx lib/terminal/touch-scroll.test.ts)`: passed, 4 files and 71 tests.
- Fixup lint: `(cd apps/web && pnpm exec eslint components/task/passthrough-terminal.tsx components/task/use-passthrough-terminal.test.ts components/task/mobile/mobile-terminal-pane.tsx e2e/helpers/api-client.ts e2e/helpers/api-client.test.ts e2e/tests/terminal/mobile-terminal-scroll.spec.ts components/task/passthrough-toolbar.tsx components/task/passthrough-toolbar.test.tsx)`: passed.
- Fixup typecheck: `(cd apps/web && pnpm run typecheck)`: passed.
- Fixup browser regression: `(cd apps/web && pnpm e2e:run --project mobile-chrome tests/terminal/mobile-terminal-scroll.spec.ts -- --retries=0)`: passed, 1 test after centralizing shell activation and preserving the agent flow.
- Specification lint: `python3 scripts/lint-spec-files.py --all`: passed.
- AC-UI-TERMINAL-TOUCH-SCROLLING-001.3 is verified for the Chromium trusted-touch flow. iOS Safari pull-to-refresh remains unverified in this Linux runner and is a device-level follow-up.
- CI stabilization provenance: commit `e140bbe29` is a test-only adjustment required by the current `main` transcript shape. Synthetic empty-turn status rows share a turn ID with the reply, so the existing assertion now scopes to the reply-containing row.
## Risks
- Synthetic DOM touch events can hide browser gesture behavior. The E2E flow must use browser input.
- A width-based assertion can pass while pointer gating stays wrong. The tests must assert `(pointer: coarse)`.
- Headless Chromium cannot prove iOS Safari pull-to-refresh behavior. The application overscroll rule remains a device-level risk.
@@ -0,0 +1,124 @@
---
id: "01-enable-coarse-pointer-terminal-scrolling"
title: "Enable coarse-pointer terminal scrolling"
status: done
wave: 1
depends_on: []
plan: "plan.md"
requirements:
- REQ-UI-TERMINAL-TOUCH-SCROLLING-001
acceptance_criteria:
- AC-UI-TERMINAL-TOUCH-SCROLLING-001.1
- AC-UI-TERMINAL-TOUCH-SCROLLING-001.2
- AC-UI-TERMINAL-TOUCH-SCROLLING-001.3
- AC-UI-TERMINAL-TOUCH-SCROLLING-001.4
- AC-UI-TERMINAL-TOUCH-SCROLLING-001.5
system_design:
- ../../specs/ui/system-design/terminal-touch-scrolling.md
---
# Task 01: Enable Coarse-Pointer Terminal Scrolling
## Summary
Add red evidence for the coarse-pointer TUI path. Then activate the current touch-scroll handler from pointer precision instead of viewport width.
## In scope
- Add component tests for touch-scroll activation in `PassthroughToolbar`.
- Add a trusted-touch TUI regression at an 820-pixel coarse-pointer viewport.
- Add a separate trusted-touch mobile shell regression through `MobileTerminalPane`.
- Keep document scroll position fixed during the terminal gesture.
- Resolve touch activation centrally in `PassthroughTerminal`: coarse-pointer shell callers default on, agent callers opt in, and fine pointers are always off.
- Keep the toolbar activation request pointer-aware and remove the mobile shell pane's unconditional override.
- Keep existing gesture, xterm, transport, and layout behavior.
## Out of scope
- Replace or redesign `attachTouchScroll`.
- Add touch inertia or terminal selection features.
- Change Quick Chat terminal behavior.
- Change application layout or terminal transport.
## Acceptance
- Before the correction, the new coarse-pointer E2E test fails because `viewportY` does not change.
- After the correction, trusted touch input moves TUI scrollback and does not move the document.
- The phone shell flow moves shell `viewportY` and keeps document scroll position fixed.
- Fine-pointer terminal paths do not activate the custom touch-scroll handler.
## Verification
The E2E command must fail for the documented reason before the production change.
```bash
cd apps/web && pnpm e2e:run --project mobile-chrome tests/terminal/mobile-terminal-scroll.spec.ts -- --retries=0
```
After the production change, run these commands.
```bash
cd apps/web && pnpm exec vitest run components/task/passthrough-toolbar.test.tsx lib/terminal/touch-scroll.test.ts
```
```bash
cd apps/web && pnpm exec eslint components/task/passthrough-toolbar.tsx components/task/passthrough-toolbar.test.tsx e2e/tests/terminal/mobile-terminal-scroll.spec.ts
```
```bash
cd apps/web && pnpm run typecheck
```
```bash
cd apps/web && pnpm e2e:run --project mobile-chrome tests/terminal/mobile-terminal-scroll.spec.ts -- --retries=0
```
## Files touched
- `apps/web/components/task/passthrough-terminal.tsx`
- `apps/web/components/task/use-passthrough-terminal.test.ts`
- `apps/web/components/task/mobile/mobile-terminal-pane.tsx`
- `apps/web/components/task/passthrough-toolbar.tsx`
- `apps/web/components/task/passthrough-toolbar.test.tsx`
- `apps/web/e2e/helpers/api-client.ts`
- `apps/web/e2e/helpers/api-client.test.ts`
- `apps/web/e2e/tests/terminal/mobile-terminal-scroll.spec.ts`
## Dependencies
None.
## Risks
- The mobile E2E file can contain both phone and tablet-width touch flows. Each flow must target its visible terminal.
- The browser cannot emulate iOS Safari pull-to-refresh. The test proves the shared handler and document-scroll contract in Chromium.
## Parallelism
`sequential`
## Inputs
- `REQ-UI-TERMINAL-TOUCH-SCROLLING-001`
- `docs/specs/ui/system-design/terminal-touch-scrolling.md`
- GitHub issues 2808 and 1035.
- PR 1046 and the current touch-scroll implementation.
## Results
- RED unit evidence: the coarse-pointer toolbar test failed before the production change because `enableTouchScroll` was `false`.
- RED browser evidence: the trusted-touch 820-pixel coarse-pointer flow failed before the production change because `viewportY` stayed at `172` after the downward swipe.
- GREEN focused tests: `(cd apps/web && pnpm exec vitest run components/task/passthrough-toolbar.test.tsx lib/terminal/touch-scroll.test.ts)` passed with 2 files and 41 tests.
- Focused lint: `(cd apps/web && pnpm exec eslint components/task/passthrough-toolbar.tsx components/task/passthrough-toolbar.test.tsx e2e/tests/terminal/mobile-terminal-scroll.spec.ts)` passed.
- Frontend typecheck: `(cd apps/web && pnpm run typecheck)` passed.
- GREEN browser regression: `(cd apps/web && pnpm e2e:run --project mobile-chrome tests/terminal/mobile-terminal-scroll.spec.ts -- --retries=0)` passed with 1 test. The managed run built the production web bundle and verified trusted touch scrollback movement with stable document scroll.
- Review remediation browser coverage: `(cd apps/web && pnpm e2e:run --no-build --project mobile-chrome tests/terminal/mobile-terminal-scroll.spec.ts -- --retries=0)` passed with 2 tests, covering the 820-pixel agent-TUI flow and the 393-pixel mobile shell flow through `MobileTerminalPane`.
- Review remediation shell stability: `(cd apps/web && pnpm e2e:run --no-build --project mobile-chrome tests/terminal/mobile-terminal-scroll.spec.ts -- --grep "keeps the mobile shell terminal scrollable" --retries=0)` passed with 1 test.
- Fixup RED evidence: the new shell resolver tests failed before centralization because the resolver was missing, and the API helper test failed because `auto_approve` was omitted from the request body.
- Fixup GREEN focused tests: `(cd apps/web && pnpm exec vitest run components/task/use-passthrough-terminal.test.ts e2e/helpers/api-client.test.ts components/task/passthrough-toolbar.test.tsx lib/terminal/touch-scroll.test.ts)` passed with 4 files and 71 tests.
- Fixup focused lint: `(cd apps/web && pnpm exec eslint components/task/passthrough-terminal.tsx components/task/use-passthrough-terminal.test.ts components/task/mobile/mobile-terminal-pane.tsx e2e/helpers/api-client.ts e2e/helpers/api-client.test.ts e2e/tests/terminal/mobile-terminal-scroll.spec.ts components/task/passthrough-toolbar.tsx components/task/passthrough-toolbar.test.tsx)` passed.
- Fixup frontend typecheck: `(cd apps/web && pnpm run typecheck)` passed.
- Fixup GREEN browser regression: `(cd apps/web && pnpm e2e:run --project mobile-chrome tests/terminal/mobile-terminal-scroll.spec.ts -- --retries=0)` passed with 1 test after centralizing shell activation and preserving the agent flow.
- Specification lint: `python3 scripts/lint-spec-files.py --all` passed.
- AC-UI-TERMINAL-TOUCH-SCROLLING-001.3 is verified for the Chromium trusted-touch flow. iOS Safari pull-to-refresh remains unverified in this Linux runner and is a device-level follow-up.
- CI stabilization provenance: commit `e140bbe29` is a test-only adjustment required by the current `main` transcript shape. Synthetic empty-turn status rows share a turn ID with the reply, so the existing assertion now scopes to the reply-containing row.
+3 -4
View File
@@ -11,10 +11,7 @@ owners:
## Scope
UI owns responsive behavior.
[Tasks](../tasks/README.md), [Agents](../agents/README.md),
[Plugins](../plugins/README.md), and [Integrations](../integrations/README.md) own
behavior/state.
UI owns responsive behavior; other systems own behavior/state.
## Map
@@ -130,6 +127,7 @@ behavior/state.
- [Task Workspace Content Search](requirements/task-workspace-content-search.md)
- [Terminal close feedback](requirements/terminal-close-feedback.md)
- [Terminal Rendering](requirements/terminal-rendering.md)
- [Terminal Touch Scrolling](requirements/terminal-touch-scrolling.md)
- [Thinking Message Preview](requirements/thinking-message-preview.md)
- [Transcript Auto-scroll Stability](requirements/transcript-auto-scroll.md)
- [Transcript Navigation Settings](requirements/transcript-navigation-settings.md)
@@ -181,6 +179,7 @@ behavior/state.
- [Command-panel Sidebar Task Reveal](system-design/command-panel-sidebar-task-reveal.md)
- [Command Panel Task Activity Icons](system-design/command-panel-task-activity-icons.md)
- [Terminal Rendering](system-design/terminal-rendering.md)
- [Terminal Touch Scrolling](system-design/terminal-touch-scrolling.md)
- [Thinking Message Preview](system-design/thinking-message-preview.md)
- [Task Transcript History Visibility](system-design/task-prompt-transcript-visibility.md)
- [Transcript Auto-scroll Stability](system-design/transcript-auto-scroll.md)
@@ -0,0 +1,44 @@
---
status: active
system: ui
created: 2026-09-02
owners:
- kandev
---
# Terminal Touch Scrolling Requirements
## Overview
Kandev terminal surfaces use xterm for task agents and shell sessions. The UI system owns this shared touch interaction contract.
The xterm canvas receives touch input, but xterm does not move its scrollback for a touch drag. Kandev must connect that gesture to scrollback.
## Terminology
- **Coarse pointer:** A primary pointer that the browser reports through `(pointer: coarse)`.
- **Terminal scrollback:** Output above the current xterm viewport.
- **Touch-scroll handler:** The Kandev handler that converts a touch drag to xterm row movement.
## Requirements
### REQ-UI-TERMINAL-TOUCH-SCROLLING-001: Reachable terminal scrollback
**Intent:** Users can inspect earlier terminal output from a touch device at each supported viewport width.
**User story:** As a touch user, I want to move through terminal scrollback, so that I can inspect earlier output.
#### Acceptance criteria
- **AC-UI-TERMINAL-TOUCH-SCROLLING-001.1:** When the primary pointer is coarse, a vertical single-touch drag shall move the connected terminal through its scrollback.
- **AC-UI-TERMINAL-TOUCH-SCROLLING-001.2:** The touch-scroll handler shall remain available at phone, tablet, and desktop viewport widths.
- **AC-UI-TERMINAL-TOUCH-SCROLLING-001.3:** A terminal touch drag shall not scroll or refresh the application document.
- **AC-UI-TERMINAL-TOUCH-SCROLLING-001.4:** A tap, multi-touch gesture, or horizontal-dominant drag shall not move terminal scrollback.
- **AC-UI-TERMINAL-TOUCH-SCROLLING-001.5:** Fine-pointer terminal selection and wheel scrolling shall not use the touch-scroll handler.
## Out of scope
- Native momentum or a custom inertia curve.
- Changes to terminal transport, buffering, persistence, fonts, or themes.
- An xterm fork or an upstream xterm contribution.
- Touch scrolling in terminal products that do not use `PassthroughTerminal`.
@@ -0,0 +1,86 @@
---
status: current
system: ui
requirements:
- REQ-UI-TERMINAL-TOUCH-SCROLLING-001
---
# Terminal Touch Scrolling System Design
## Purpose and boundaries
This design defines touch scrolling for `PassthroughTerminal`. The same renderer serves task-agent and task-shell terminal surfaces.
The design keeps the current xterm instance, WebSocket, buffer, and responsive layouts. It changes only touch-handler activation for coarse pointers.
## Requirement mapping
| Requirement | Design section |
| --- | --- |
| `REQ-UI-TERMINAL-TOUCH-SCROLLING-001` | [Pointer activation](#pointer-activation), [Gesture flow](#gesture-flow), [Responsive behavior](#responsive-behavior), [Test contract](#test-contract) |
## Confirmed fault
PR 1046 added the touch-scroll handler and enabled it for the phone layout. Current phone Chrome evidence shows that the handler moves TUI scrollback.
`PassthroughToolbar` enables the handler from `isMobile`. This value becomes false at 768 CSS pixels, including coarse-pointer tablet and landscape layouts.
A trusted browser touch at 820 CSS pixels leaves the TUI viewport unchanged. The handler is not active in that layout.
## Components and responsibilities
- `useResponsiveBreakpoint` reports pointer precision and the responsive layout.
- `PassthroughToolbar` opts agent passthrough terminals into touch scrolling for coarse pointers.
- `PassthroughTerminal` resolves the shared activation rule and passes the result to `useTouchScroll`.
- `useTouchScroll` attaches and removes one handler for the current xterm instance.
- `attachTouchScroll` converts vertical touch distance to xterm row movement.
- The application shell prevents document overscroll through its existing global style.
## Pointer activation
`PassthroughTerminal` resolves touch scrolling from pointer precision and terminal mode. Coarse-pointer shell terminals enable the handler by default. Agent terminals opt in through their caller, and fine pointers always disable the handler.
This rule is independent of viewport width. A pointer-mode change causes the responsive hook to render again and updates the effect for every shell caller.
Fine-pointer layouts do not install the custom handler. Their xterm selection and wheel behavior stay on the existing path.
## Gesture flow
1. A single touch starts on the xterm canvas and bubbles to the terminal host.
2. The handler waits until vertical movement crosses the current threshold.
3. The handler prevents the document action and converts whole-row distance to `scrollLines` calls.
4. The handler keeps fractional row distance for the next move in the same direction.
5. A touch end or cancellation clears the gesture state.
The handler yields before activation for a tap, a multi-touch gesture, or a horizontal-dominant drag.
## Responsive behavior
- **Desktop outcome:** Fine-pointer selection and wheel scrolling do not change.
- **Touch entry point:** The existing task Chat panel contains the passthrough terminal.
- **Nearest implementation:** `MobileTerminalPane` uses the same renderer and touch-scroll handler.
- **Presentation:** Phone, tablet, and desktop layouts keep their current composition.
- **Scroll owner:** Xterm owns terminal scrollback. The application document stays fixed to the viewport.
- **Shared behavior:** All layouts use the same handler, buffer, and connection. `PassthroughTerminal` enables shell scrolling for coarse pointers, while the agent toolbar opts in through its caller.
## Failure and recovery
If pointer detection is unavailable, the responsive hook keeps its current fine-pointer default. This behavior avoids a custom handler on an unknown desktop.
If xterm is not ready, `useTouchScroll` does not attach the handler. The readiness change attaches it to the initialized instance.
The cleanup path removes each listener. A remount or pointer-mode change cannot leave a duplicate listener.
## Test contract
Component tests prove that `PassthroughToolbar` enables touch scrolling for a coarse pointer. They also prove that a fine pointer disables it.
The current helper tests cover threshold, direction, accumulation, multi-touch, horizontal movement, and listener cleanup.
The mobile Playwright test uses a real `cli_passthrough` profile. It changes to an 820-pixel coarse-pointer viewport and creates enough output for scrollback.
Trusted browser touch input starts on the xterm canvas. The test proves that the xterm viewport moves while the document remains fixed.
## Related decisions
No architecture decision applies. This correction keeps the existing touch-scroll design from PR 1046.