Skip to content
Merged
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
4 changes: 4 additions & 0 deletions changelog.d/touch-scroll-by-finger-travel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
### Fixed
- Dragging the terminal on a phone now scrolls by how far the finger travels: a quick swipe goes as
far as a slow drag over the same distance, one scroll step per three rows of travel, and a short
flick of at least half a step still scrolls once when the finger lifts.
47 changes: 41 additions & 6 deletions scripts/terminal-pinch-regression.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,17 @@ export async function checkTerminalPinch(browser: Browser, origin: string, paneI
// Keep the size saved by the gesture when this same page reloads.
if (!localStorage.getItem("herdr-web-ui:settings")) localStorage.setItem("herdr-web-ui:settings", JSON.stringify({ language: "en" }));
localStorage.setItem(`herdr-web-ui:view:${paneId}`, "terminal");
const touchWindow = window as Window & { wheels_: number; remaining_: number[] };
const touchWindow = window as Window & { wheels_: number; wheelModes_: number[]; remaining_: number[] };
touchWindow.wheels_ = 0;
touchWindow.wheelModes_ = [];
touchWindow.remaining_ = [];
document.addEventListener("touchend", (event) => {
touchWindow.remaining_ = Array.from(event.touches, (touch) => touch.identifier);
}, { capture: true });
document.addEventListener("wheel", () => touchWindow.wheels_++, { capture: true });
document.addEventListener("wheel", (event) => {
touchWindow.wheels_++;
touchWindow.wheelModes_.push(event.deltaMode);
}, { capture: true });
}, { paneId });
try {
const page = await context.newPage();
Expand Down Expand Up @@ -59,6 +63,9 @@ export async function checkTerminalPinch(browser: Browser, origin: string, paneI
transform: (document.querySelector(".pane-terminal .xterm") as HTMLElement).style.transform,
computedTransform: getComputedStyle(document.querySelector(".pane-terminal .xterm")!).transform,
wheels: (window as Window & { wheels_: number }).wheels_,
wheelModes: (window as Window & { wheelModes_: number[] }).wheelModes_.slice(),
cell: document.querySelector(".pane-terminal .xterm-screen")!.getBoundingClientRect().height
/ document.querySelectorAll(".pane-terminal .xterm-rows > div").length,
remaining: (window as Window & { remaining_: number[] }).remaining_,
scale: visualViewport!.scale,
focused: document.hasFocus(),
Expand All @@ -83,18 +90,44 @@ export async function checkTerminalPinch(browser: Browser, origin: string, paneI
}
};
const release = () => cdp.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
// a wheel report per three rows of travel: 150 px is more than that even at the largest cell the 22 px font makes;
// the drag ends at cy, where a following pinch puts this finger, so that jump is no vertical travel
const dragOne = async (): Promise<void> => {
await cdp.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x: cx, y: cy - 60, id: 1 }] });
await cdp.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x: cx, y: cy - 150, id: 1 }] });
for (let step = 1; step <= 5; step++) {
await cdp.send("Input.dispatchTouchEvent", { type: "touchMove", touchPoints: [{ x: cx, y: cy - 60 + 60 * step / 5, id: 1 }] });
await cdp.send("Input.dispatchTouchEvent", { type: "touchMove", touchPoints: [{ x: cx, y: cy - 150 + 150 * step / 5, id: 1 }] });
}
};

/** one finger drags `distance` px down in `moves` touchmoves; resolves to the wheels it sent, each line mode (1) */
const swipe = async (distance: number, moves: number): Promise<number> => {
const before = await probe();
await cdp.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x: cx, y: cy - distance / 2, id: 1 }] });
for (let step = 1; step <= moves; step++) {
await cdp.send("Input.dispatchTouchEvent", { type: "touchMove", touchPoints: [{ x: cx, y: cy - distance / 2 + distance * step / moves, id: 1 }] });
}
await release();
const after = await probe();
assert(after.wheelModes.slice(before.wheelModes.length).every((mode) => mode === 1),
`a ${moves}-move swipe sends line-mode wheels only`);
return after.wheels - before.wheels;
};

const baseline = await probe();
assert.equal(baseline.fontPx, 13, "baseline font is 13 px");
assert(!(await badge.isVisible()), "badge is hidden before pinching");
// a quick swipe scrolls as far as a slow drag over the same travel: one wheel report per three rows
const fast = await swipe(300, 2);
const slow = await swipe(300, 30);
const expected = 300 / (3 * baseline.cell);
assert(fast >= Math.floor(expected) - 1 && fast <= Math.ceil(expected), `a 300 px swipe sends about ${expected.toFixed(1)} wheels (sent ${fast})`);
assert.equal(fast, slow, "a 2-move swipe and a 30-move drag over 300 px send the same wheel count");
// a flick shorter than one step still scrolls once it reaches half a step when the finger lifts
const step = 3 * baseline.cell;
assert.equal(await swipe(Math.round(step * 0.7), 4), 1, `a flick of 0.7 step (${Math.round(step * 0.7)} px) sends one wheel`);
assert.equal(await swipe(Math.round(step * 0.3), 4), 0, `a flick of 0.3 step (${Math.round(step * 0.3)} px) sends none`);
const r0 = resizes;
const w0 = baseline.wheels;
const w0 = (await probe()).wheels;
await spreadTo(100, 154);
await until(async () => (await probe()).fontPx === 20, "pinch out renders 20 px before release");
await until(async () => (await probe()).rows < baseline.rows, "pinch out refits fewer local rows before release");
Expand Down Expand Up @@ -170,8 +203,10 @@ export async function checkTerminalPinch(browser: Browser, origin: string, paneI
assert.equal((await probe()).fontPx, 20, "partial release keeps the live 20 px font");
assert.equal((await probe()).saved, 20, "partial release saves 20 px");
assert(!(await badge.isVisible()), "the first lifted finger hides the size badge");
// three wheel steps of travel at the 20 px cell: a broken pinch-stroke guard would send wheels here
const leftover = 3 * 3 * (await probe()).cell;
for (let step = 1; step <= 5; step++) {
await cdp.send("Input.dispatchTouchEvent", { type: "touchMove", touchPoints: [{ x: cx - 77, y: cy + 60 * step / 5, id: 1 }] });
await cdp.send("Input.dispatchTouchEvent", { type: "touchMove", touchPoints: [{ x: cx - 77, y: cy + leftover * step / 5, id: 1 }] });
}
assert.equal((await probe()).wheels, w1, "the finger remaining after a pinch does not scroll");
await release();
Expand Down
2 changes: 1 addition & 1 deletion src/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ React 19 + xterm.js browser client. The render-state machine, xterm lifecycle an
| Terminal view/lifecycle | components/PaneTerminal.tsx + TerminalViewportTools.tsx + lib/terminalViewport.ts | one xterm per pane mount; `term.reset()` + re-attach on paneId change; `active` gates every automatic focus (including async uploads and prompt answers), and inactive siblings never take focus; theme + font size applied through `term.options` (no remount) and re-fit; the grid re-fits when the lens returns to terminal; ended / reconnecting pills only under the terminal lens (ChatView says them inline); viewport tools draw only native ranges validated against xterm's current cells, never reconstructed history. Automatic `jump:false` searches require an explicit response carrying `matches` and `scroll`, and no read-only/legacy bridge gets background search POSTs. The separate scrollbar calls herdr's shared `pane.scroll`; xterm remains at zero history. |
| Roles / observe mode | components/PaneTerminal.tsx | NO UI control — App connects as `interact` and never asks for another role; `role-ack` still applies the consequences if one arrives: stdin gate (`disableStdin`), `pane-geometry` adoption, force-refit + resize when returning to interact |
| Held-input draft | components/PaneTerminal.tsx + lib/draft.ts | input during a disconnect accumulates in a draft (printable only); the review banner offers Send/Discard after reconnect |
| Geometry/fit | components/PaneTerminal.tsx | fit on attach, ResizeObserver on the host, `focus`/`visibilitychange` refit, and a click or key in the terminal while another device's grid holds the pty (`reclaim`: a window that kept its focus gets no focus event) — all SKIPPED while observing; only a tab in use (`inUse`: visible AND `document.hasFocus()`) resizes the pty from the observer, the refit, `role-ack`, a font load or the lens return; out of use, a pane attach (a reload, the next pane after one closed) goes with `keep_size`, and blur or hidden marks `keepSize` so a background reconnect adopts the grid: a window left behind another app must not take the pane from a phone or herdr's own TUI; with `settings.releasePaneAway` on (off by default), after `RELEASE_AFTER_MS` out of use (from a blur, hidden, or a pane attach made out of use) it also detaches (`released`), and a touch screen (`coarse`) that goes hidden does so at once whatever the setting, since a phone's page can freeze before the timer runs, because herdr holds a pane at an attach's size until that attach ends and its TUI cannot take it back, and watches the pane instead (`socket.watch`, feature "watch": a read-only view at this grid that leaves the pane's size alone, written as it comes; a grid change starts it again; banner "View only…"); a server without the feature, or a `watch-end`, leaves it paused on its last screen. It attaches again on focus or a pointerdown (unwatch first); a released tab moving to another pane watches that one; it keeps the pane while a message it queued waits (`queueWaits`), and never releases a mirrored pane or an embedded page (the site's demo). `HerdrSocket.resize` sends nothing for a pane it is not attached to: the server would resize the holder's attach; single-finger touch drag becomes synthetic wheel events (listeners `passive: false` only there) |
| Geometry/fit | components/PaneTerminal.tsx | fit on attach, ResizeObserver on the host, `focus`/`visibilitychange` refit, and a click or key in the terminal while another device's grid holds the pty (`reclaim`: a window that kept its focus gets no focus event) — all SKIPPED while observing; only a tab in use (`inUse`: visible AND `document.hasFocus()`) resizes the pty from the observer, the refit, `role-ack`, a font load or the lens return; out of use, a pane attach (a reload, the next pane after one closed) goes with `keep_size`, and blur or hidden marks `keepSize` so a background reconnect adopts the grid: a window left behind another app must not take the pane from a phone or herdr's own TUI; with `settings.releasePaneAway` on (off by default), after `RELEASE_AFTER_MS` out of use (from a blur, hidden, or a pane attach made out of use) it also detaches (`released`), and a touch screen (`coarse`) that goes hidden does so at once whatever the setting, since a phone's page can freeze before the timer runs, because herdr holds a pane at an attach's size until that attach ends and its TUI cannot take it back, and watches the pane instead (`socket.watch`, feature "watch": a read-only view at this grid that leaves the pane's size alone, written as it comes; a grid change starts it again; banner "View only…"); a server without the feature, or a `watch-end`, leaves it paused on its last screen. It attaches again on focus or a pointerdown (unwatch first); a released tab moving to another pane watches that one; it keeps the pane while a message it queued waits (`queueWaits`), and never releases a mirrored pane or an embedded page (the site's demo). `HerdrSocket.resize` sends nothing for a pane it is not attached to: the server would resize the holder's attach; single-finger touch drag becomes synthetic line-mode wheel events, one per `HERDR_MOUSE_SCROLL_LINES` rows of finger travel gathered over the gesture, plus one at touchend when at least half a step is left (`finalStep`) (lib/wheelSteps.ts, shared with a selecting drag's wheel; xterm sends one report per wheel event whatever its delta) (listeners `passive: false` only there) |
| Key bar | components/KeyBar.tsx + KeyBarSettings.tsx + lib/keyBar.ts + lib/keys.ts | `settings.keyBarItems` owns the complete ordered row; absent legacy records migrate from `keyBarExtras`, explicit empty lists stay empty. Ordinary keys inherit held modifiers; configured combinations are exact. Taps go through `term.input()` into the guarded onData path. PaneTerminal owns sticky Ctrl/Alt/Shift, clearing state on pane/lens change, disconnect or modifier removal; hidden while observing |
| Chat composer | components/Composer.tsx + lib/compose.ts + lib/mentions.ts + lib/modelName.ts + PendingMessages.tsx | the model pill shows a name only for an id `modelLabel` matches whole, else the id as received in mono: never add a guessed name; Enter or Mod+Enter sends per `settings.enterSends`, IME Enter never sends; `/` commands cache 60s per pane, `@` files debounce 150ms; one Send/Stop control for every agent, no Queue button/menu; working Send uses `socket.submit` queue delivery only with `pending-input`, otherwise keeps the draft; pending rows offer an explicit Send now action on server IDs, retained uncertain text never retries; Stop is Escape via `onAbort`, also from the box's own Escape while a draft shows Send in its place |
| Chat lens | components/ChatView.tsx + lib/conversationRefresh.ts + Markdown.tsx (lib/markdown.ts) + lib/workBlocks.ts + lib/toolVerbs.ts + PromptCard.tsx | `splitTurn` folds tools/thinking/narration into one `.work-block` and honors explicit Codex commentary/final phases; a row is verb + object by exact tool id (`toolVerb`), the id kept in its title and opened detail; duration = `end_ts − ts`; only mounted visible Chat subscribes to `conversation-watch` on its attached machine-bound socket; `conversation-changed` uses a serial coordinator with push reads at least 2s apart, a visible-only 10s backstop (2s while `conversationPushes` is false: an older bridge or a released pane) and immediate initial/send/visibility/reconnect refresh; a visible scrollback stand-in is invalidated by the pane's `pty-data` through a ref listener (`onTerminalOutput`), never by React state per frame; newest and older pages share the coordinator; a send refresh preserves history/scroll, a pane change resets; ChatView polls `/api/pane/prompt` every 2s whatever the pane's status (a menu can wait while herdr reports idle: Codex's trust check, pi's dialogs, Claude Code's `/model` list) and owns the prompt; PromptCard answers through `/api/pane/prompt/answer` (409 → re-read) and is drawn through a portal into PaneTerminal's `.prompt-dock`, over the input card and outside the transcript |
Expand Down
39 changes: 32 additions & 7 deletions src/components/PaneTerminal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import { useMediaQuery } from "../lib/useMediaQuery.ts";
import { modalOpen } from "../lib/focus.ts";
import { pinchFontSize } from "../lib/pinchFontSize.ts";
import { narrowPaneFontSize } from "../lib/narrowPaneFont.ts";
import { finalStep, HERDR_MOUSE_SCROLL_LINES, wheelSteps } from "../lib/wheelSteps.ts";

/** How long a resize must rest before the grid refits and the pty follows it. */
const RESIZE_SETTLE_MS = 120;
Expand Down Expand Up @@ -776,6 +777,9 @@ export function PaneTerminal({
sendScroll(d);
});
};
/** one row's height on screen, in CSS pixels (a selecting drag's wheel and a finger's travel) */
const cellHeight = (): number =>
(term.element?.querySelector(".xterm-screen")?.getBoundingClientRect().height ?? term.rows) / term.rows;
/** positive lines show older text */
const scrollDrag = (d: Drag, lines: number): void => {
if (d.top === null) return;
Expand All @@ -790,11 +794,11 @@ export function PaneTerminal({
const dragWheel = (event: WheelEvent): void => {
const d = drag;
if (!d || d.top === null) return;
const lineHeight = (term.element?.querySelector(".xterm-screen")?.getBoundingClientRect().height ?? term.rows) / term.rows;
d.wheelPixels += event.deltaMode === WheelEvent.DOM_DELTA_LINE ? event.deltaY * lineHeight : event.deltaY;
const lines = Math.trunc(d.wheelPixels / lineHeight);
const lineHeight = cellHeight();
const { steps: lines, rest } = wheelSteps(
d.wheelPixels + (event.deltaMode === WheelEvent.DOM_DELTA_LINE ? event.deltaY * lineHeight : event.deltaY), lineHeight);
d.wheelPixels = rest;
if (lines === 0) return;
d.wheelPixels -= lines * lineHeight;
scrollDrag(d, -lines);
};
const stopEdge = (): void => {
Expand Down Expand Up @@ -1345,6 +1349,8 @@ export function PaneTerminal({
// Two fingers pinch the font size: the font follows them locally, and the shared pty resizes once when one lifts.
let touchX = 0;
let touchY = 0;
/** finger travel of this gesture not yet sent as a wheel report */
let touchPixels = 0;
let tracking = false;
let pinch: { first: number; second: number; startSize: number; startDistance: number; size: number } | null = null;
// from a second finger until every finger lifts: the stroke is a pinch, never a scroll
Expand Down Expand Up @@ -1413,6 +1419,7 @@ export function PaneTerminal({
return;
}
tracking = event.touches.length === 1;
touchPixels = 0;
const first = event.touches[0];
if (tracking && first) {
touchX = first.clientX;
Expand Down Expand Up @@ -1459,12 +1466,30 @@ export function PaneTerminal({
host.scrollBy(across, delta);
return;
}
if (delta !== 0) {
const target = term.element ?? host;
target.dispatchEvent(new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaY: delta, clientX: first.clientX, clientY: first.clientY }));
// xterm sends herdr one report per wheel event however far it went, and herdr scrolls a
// few lines per report: one line-mode wheel per that many rows of finger travel keeps a
// swipe as far as a slow drag, and the text under the finger
const { steps, rest } = wheelSteps(touchPixels + delta, cellHeight() * HERDR_MOUSE_SCROLL_LINES);
touchPixels = rest;
sendTouchWheels(steps);
};
/** `steps` line-mode wheel reports at the finger, each one herdr scroll step in the sign's direction */
const sendTouchWheels = (steps: number): void => {
const target = term.element ?? host;
for (let sent = 0; sent < Math.abs(steps); sent += 1) {
target.dispatchEvent(new WheelEvent("wheel", {
bubbles: true, cancelable: true, deltaY: Math.sign(steps), deltaMode: WheelEvent.DOM_DELTA_LINE,
clientX: touchX, clientY: touchY,
}));
}
};
const onTouchEnd = (event: TouchEvent): void => {
// a lifted one-finger drag sends what is left once it reaches half a step: a flick shorter
// than one step still scrolls, and a long drag loses at most half a step at its end
if (tracking && event.type === "touchend" && !pinchStroke && !adopted()) {
sendTouchWheels(finalStep(touchPixels, cellHeight() * HERDR_MOUSE_SCROLL_LINES));
}
touchPixels = 0;
tracking = false;
if (!pinchStroke) return;
claimPinch(event);
Expand Down
Loading
Loading