Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions .plans/issue-163-181-182-mobile-terminal-scrolling.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# mobile terminal scrolling: issues #163, #181, #182

goal: reproduce each reported mobile scrolling failure and apply only evidence-backed fixes on one branch.

## ~~1. Create isolated branch and establish scope~~

- fresh worktree: `/private/tmp/wolfpack-mobile-terminal-scrolling`
- branch: `fix/mobile-terminal-scrolling`
- base: `main` at `ed009b3`
- issues: #163, #181, #182

## ~~2. Reproduce and diagnose #163~~

- historical fix `bedb118` covered switching between already-open sessions only
- reproduced unresolved path: fresh mobile load → first agent/session → 170px drag left `viewportY = 0`
- root cause: first-open used viewport prefill, which requests zero scrollback rows; switch-session already forced full prefill
- falsified touch/focus timing: the same first-open gesture worked when full history was supplied
- fix: first-session opening now uses the same full-prefill contract as switching
- focused regression passes on iPhone SE and iPhone 14 projects

## ~~3. Reproduce and diagnose #181~~

- reproduced: a 170px drag moved 6 rows while rendered row height required 11
- root cause: fixed 28px threshold ignored the actual ~15px terminal row height
- fix: derive touch-scroll threshold from renderer metrics with a fallback
- focused regression passes

## ~~4. Reproduce and diagnose #182~~

- reproduced: opening the keyboard left `viewportY` above zero
- root cause: keyboard activation did not release controller scroll lock or scroll to bottom
- active touch momentum could also move away from bottom after keyboard activation
- fix: cancel momentum and route bottom scrolling through the controller
- focused regression passes

## ~~5. Verify the combined branch~~

- focused iPhone SE: 5 passed (#163 first-open/switch, #181, #182, cached restore)
- focused iPhone 14: 5 passed (#163 first-open/switch, #181, #182, cached restore)
- typecheck: passed
- full Bun suite: 1,683 passed, 20 broker-dependent skips, 0 failed
- full Playwright rerun: 78 passed, 117 project/broker-dependent skips, 0 failed
- one prior Playwright run hit the unchanged fake-clock prefill-timeout test; isolated rerun and full rerun passed
- generated browser assets refreshed with `scripts/gen-assets.ts`
11 changes: 8 additions & 3 deletions public/app-touch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export function setupTouchScrollHandler(container, term, sendInput, canAcceptInp
let velocityY = 0;
let momentumId = null;
let tracking = false;
const SCROLL_THRESHOLD = 28;
const DEFAULT_SCROLL_THRESHOLD_PX = 17;
const FRICTION = 0.95;
const MIN_VELOCITY = 0.5;
const MAX_LINES_PER_EVENT = 5;
Expand Down Expand Up @@ -146,10 +146,12 @@ export function setupTouchScrollHandler(container, term, sendInput, canAcceptInp
function sendScroll(deltaY) {
let hasMouse = false;
try { hasMouse = term.getMode(1000) || term.getMode(1002) || term.getMode(1003); } catch {}
const metrics = term.renderer?.getMetrics?.();
const scrollThreshold = metrics?.height > 0 ? metrics.height : DEFAULT_SCROLL_THRESHOLD_PX;
scrollAccum += deltaY;
const lines = Math.trunc(scrollAccum / SCROLL_THRESHOLD);
const lines = Math.trunc(scrollAccum / scrollThreshold);
if (lines === 0) return;
scrollAccum -= lines * SCROLL_THRESHOLD;
scrollAccum -= lines * scrollThreshold;
if (hasMouse) {
const btn = lines > 0 ? 65 : 64;
const seq = encoder.encode(`\x1b[<${btn};1;1M`);
Expand Down Expand Up @@ -273,10 +275,12 @@ export function setupTouchScrollHandler(container, term, sendInput, canAcceptInp
if (Math.abs(velocityY) > MIN_VELOCITY) { momentumId = requestAnimationFrame(momentumTick); }
}

const keyboardButton = document.getElementById("kb-open-btn");
container.addEventListener("touchstart", onTouchStart, { passive: true });
container.addEventListener("touchmove", onTouchMove, { passive: false });
container.addEventListener("touchend", onTouchEnd, { passive: true });
container.addEventListener("touchcancel", onTouchEnd, { passive: true });
keyboardButton?.addEventListener("click", cancelMomentum, true);

return function cleanup() {
cancelMomentum();
Expand All @@ -286,5 +290,6 @@ export function setupTouchScrollHandler(container, term, sendInput, canAcceptInp
container.removeEventListener("touchmove", onTouchMove);
container.removeEventListener("touchend", onTouchEnd);
container.removeEventListener("touchcancel", onTouchEnd);
keyboardButton?.removeEventListener("click", cancelMomentum, true);
};
}
12 changes: 11 additions & 1 deletion public/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1271,6 +1271,7 @@ interface PtyTerminalController {
mount(container: HTMLElement, mountOpts?: { readonly cached?: string | null }): Promise<void>;
connect(connectOpts?: { readonly takeControl?: boolean }): void;
focus(): void;
scrollToBottom(): void;
resize(): void;
dispose(): void;
scheduleReconnect(): void;
Expand Down Expand Up @@ -1907,6 +1908,13 @@ function createPtyTerminalController(opts: PtyTerminalControllerOpts): PtyTermin
if (_term) _term.focus();
}

function scrollToBottom() {
_userScrolledUp = false;
_userRequestedScrollback = false;
_lastScrollbackLength = -1;
if (_term) _term.scrollToBottom();
}

function resize() {
syncLayout({ forceSend: true, repaint: true, reason: "resize" });
}
Expand Down Expand Up @@ -1946,6 +1954,7 @@ function createPtyTerminalController(opts: PtyTerminalControllerOpts): PtyTermin
mount,
connect,
focus,
scrollToBottom,
resize,
dispose,
// Delegation to pty client
Expand Down Expand Up @@ -2787,7 +2796,7 @@ async function openSession(name, machineUrl) {
const cached = loadSnapshot(state.currentMachine, name);
showView("terminal");
__wfTraceEvent(trace, "dom.view.created", { cached: !!cached });
initTerminal(cached);
void initTerminal(cached, "full");
renderSidebar();
}

Expand Down Expand Up @@ -4218,6 +4227,7 @@ function insertMessageInputNewline(): void {
if (proxy.style.display === "none") return;
const opening = document.activeElement !== proxy;
if (opening) {
state.terminalController?.scrollToBottom();
proxy.removeAttribute("readonly");
proxy.setAttribute("inputmode", "text");
proxy.focus({ preventScroll: true });
Expand Down
2 changes: 1 addition & 1 deletion src/public-assets.ts

Large diffs are not rendered by default.

156 changes: 156 additions & 0 deletions tests/e2e/mobile-scrolling.e2e.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import { expect, test, type Page } from "@playwright/test";
import { startTestServer, type TestServer } from "./helpers.ts";

let srv: TestServer;

const HISTORY_LINE_COUNT = 160;

interface MobileTerminalState {
readonly viewportY: number;
readonly rowHeight: number;
}

async function openMobileTerminalWithHistory(page: Page): Promise<void> {
await page.addInitScript(() => {
localStorage.setItem("wp-effects", JSON.stringify({ soloPrefillMode: "full" }));
});
await page.routeWebSocket(/\/ws\/pty/, (ws) => {
ws.onMessage((message) => {
if (typeof message !== "string") return;
let parsed: { readonly type?: string };
try { parsed = JSON.parse(message); } catch { return; }
if (parsed.type !== "attach") return;
ws.send(JSON.stringify({ type: "attach_ack" }));
ws.send(Buffer.from(Array.from(
{ length: HISTORY_LINE_COUNT },
(_, index) => `history-${index}\r\n`,
).join("")));
ws.send(JSON.stringify({ type: "prefill_done" }));
ws.send(JSON.stringify({ type: "pty_ready" }));
});
});

await page.goto(srv.baseUrl);
await page.waitForSelector(".card", { timeout: 5000 });
await page.locator(".card", { hasText: "test-project" }).first().click();
await expect(page.locator("#desktop-terminal-container")).toHaveAttribute(
"data-terminal-load-state",
"live",
{ timeout: 5000 },
);
await expect.poll(() => page.evaluate(() => {
const terminal = (window as unknown as {
state: { terminalController?: { term?: { getScrollbackLength?: () => number } } };
}).state.terminalController?.term;
return terminal?.getScrollbackLength?.() ?? 0;
})).toBeGreaterThan(0);
}

async function dragTerminal(page: Page, startY: number, endY: number): Promise<MobileTerminalState> {
return page.evaluate(({ startY, endY }) => {
const container = document.getElementById("desktop-terminal-container");
const canvas = container?.querySelector("canvas");
const terminal = (window as unknown as {
state: {
terminalController?: {
term?: {
readonly viewportY: number;
readonly renderer?: { getMetrics?: () => { readonly height: number } };
};
};
};
}).state.terminalController?.term;
if (!canvas || !terminal) throw new Error("missing mobile terminal");

const dispatchTouch = (type: string, clientY: number): void => {
const event = new Event(type, { bubbles: true, cancelable: true });
Object.defineProperty(event, "touches", {
value: type === "touchend" ? [] : [{ clientX: 100, clientY }],
});
canvas.dispatchEvent(event);
};

dispatchTouch("touchstart", startY);
dispatchTouch("touchmove", endY);
const state = {
viewportY: terminal.viewportY,
rowHeight: terminal.renderer?.getMetrics?.().height ?? 17,
};
dispatchTouch("touchend", endY);
return state;
}, { startY, endY });
}

test.beforeAll(async () => {
srv = await startTestServer();
});

test.afterAll(async () => {
srv?.close();
});

test("first mobile session opens with touch-scrollable history", async ({ page }, testInfo) => {
test.skip(testInfo.project.name === "desktop", "mobile-only first-open path");

await page.routeWebSocket(/\/ws\/pty/, (ws) => {
ws.onMessage((message) => {
if (typeof message !== "string") return;
let parsed: { readonly type?: string; readonly prefillMode?: string; readonly rows?: number };
try { parsed = JSON.parse(message); } catch { return; }
if (parsed.type !== "attach") return;
const prefillMode = parsed.prefillMode ?? "full";
const viewportRows = Math.max(1, (parsed.rows ?? 24) - 1);
const lineCount = prefillMode === "full" ? HISTORY_LINE_COUNT : viewportRows;
ws.send(JSON.stringify({ type: "attach_ack" }));
ws.send(Buffer.from(Array.from(
{ length: lineCount },
(_, index) => `first-open-history-${index}\r\n`,
).join("")));
if (prefillMode === "viewport") {
ws.send(JSON.stringify({ type: "prefill_viewport" }));
}
ws.send(JSON.stringify({ type: "prefill_done" }));
ws.send(JSON.stringify({ type: "pty_ready" }));
});
});

await page.goto(srv.baseUrl);
await page.waitForSelector(".card", { timeout: 5000 });
await page.locator(".card", { hasText: "test-project" }).first().click();
await expect(page.locator("#desktop-terminal-container")).toHaveAttribute(
"data-terminal-load-state",
"live",
{ timeout: 5000 },
);

const terminalState = await dragTerminal(page, 200, 370);
expect(terminalState.viewportY).toBeGreaterThan(0);
});

test("mobile touch drag scrolls at least one history row per rendered row", async ({ page }, testInfo) => {
test.skip(testInfo.project.name === "desktop", "mobile-only touch path");
await openMobileTerminalWithHistory(page);

const dragDistance = 170;
const terminalState = await dragTerminal(page, 200, 200 + dragDistance);
const expectedRows = Math.trunc(dragDistance / terminalState.rowHeight);

expect(terminalState.viewportY).toBeGreaterThanOrEqual(expectedRows);
});

test("opening the mobile keyboard returns the terminal to latest output", async ({ page }, testInfo) => {
test.skip(testInfo.project.name === "desktop", "mobile-only keyboard behavior");
await openMobileTerminalWithHistory(page);

const scrolledState = await dragTerminal(page, 200, 370);
expect(scrolledState.viewportY).toBeGreaterThan(0);

await page.locator("#kb-open-btn").click();
await expect(page.locator("#mobile-kb-proxy")).toBeFocused();
await expect.poll(() => page.evaluate(() => {
const terminal = (window as unknown as {
state: { terminalController?: { term?: { readonly viewportY: number } } };
}).state.terminalController?.term;
return terminal?.viewportY ?? -1;
})).toBe(0);
});
6 changes: 3 additions & 3 deletions tests/e2e/session-switch.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ test("mobile touch scrolling works immediately after switching sessions", async
switchSession("another-project");
});
await expect(page.locator("#desktop-terminal-container")).toHaveAttribute("data-terminal-load-state", "live", { timeout: 5000 });
await expect.poll(() => attachModes).toEqual(["viewport", "full"]);
await expect.poll(() => attachModes).toEqual(["full", "full"]);
await expect.poll(() => page.evaluate(() => {
const terminal = (window as unknown as { state: { terminalController?: { term?: { getScrollbackLength?: () => number } } } }).state.terminalController?.term;
return terminal?.getScrollbackLength?.() ?? 0;
Expand Down Expand Up @@ -427,7 +427,7 @@ test("desktop solo saved fast uses full prefill and clears cached prose", async
expect(tail).not.toContain("CACHED-HISTORY-MUST-NOT-MIX");
});

test("mobile fast restore with cached snapshot requests viewport prefill without showing cached placeholder", async ({ page }, testInfo) => {
test("mobile first-session restore overrides fast mode with full prefill without showing cached placeholder", async ({ page }, testInfo) => {
test.skip(testInfo.project.name === "desktop", "mobile-only restore path");

await page.goto(srv.baseUrl);
Expand All @@ -454,7 +454,7 @@ test("mobile fast restore with cached snapshot requests viewport prefill without
expect(immediateState.className).not.toContain("cached-visible");
expect(immediateState.placeholder).toBe("");
expect(immediateState.loadState).toBe("prefill-loading");
await expectSoloAttachPrefillMode(page, "viewport");
await expectSoloAttachPrefillMode(page, "full");
});

test("desktop solo terminal defaults to full prefill", async ({ page }, testInfo) => {
Expand Down
Loading