From dfa9b4af5ad17ce1db67f658dab31dd9b59b346a Mon Sep 17 00:00:00 2001 From: Hako Date: Tue, 13 Oct 2026 21:43:05 +0900 Subject: [PATCH 1/5] fix(terminal): scroll a phone drag by finger travel, not per touchmove xterm sends herdr one wheel report per wheel event whatever its delta, and the touch handler dispatched one pixel wheel per touchmove, so a quick swipe sent a few reports while a slow drag over the same distance sent one per row. Gather the gesture's travel and send one line-mode wheel per cell height times herdr's default mouse_scroll_lines (3), keeping the rest for the next move. The pixel-to-step arithmetic is shared with the selecting drag's wheel. Co-Authored-By: Claude Opus 5.5 --- changelog.d/touch-scroll-by-finger-travel.md | 4 ++ scripts/terminal-pinch-regression.ts | 3 +- src/AGENTS.md | 2 +- src/components/PaneTerminal.tsx | 29 +++++++++---- src/lib/wheelSteps.test.ts | 44 ++++++++++++++++++++ src/lib/wheelSteps.ts | 16 +++++++ 6 files changed, 89 insertions(+), 9 deletions(-) create mode 100644 changelog.d/touch-scroll-by-finger-travel.md create mode 100644 src/lib/wheelSteps.test.ts create mode 100644 src/lib/wheelSteps.ts diff --git a/changelog.d/touch-scroll-by-finger-travel.md b/changelog.d/touch-scroll-by-finger-travel.md new file mode 100644 index 0000000000..76c780bc05 --- /dev/null +++ b/changelog.d/touch-scroll-by-finger-travel.md @@ -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, and the text moves with the finger instead of a few + lines per touch event. diff --git a/scripts/terminal-pinch-regression.ts b/scripts/terminal-pinch-regression.ts index 1a26ed7f71..2672d7692b 100644 --- a/scripts/terminal-pinch-regression.ts +++ b/scripts/terminal-pinch-regression.ts @@ -83,10 +83,11 @@ 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 22 px maximum const dragOne = async (): Promise => { await cdp.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x: cx, y: cy - 60, 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 - 60 + 150 * step / 5, id: 1 }] }); } }; diff --git a/src/AGENTS.md b/src/AGENTS.md index dd4d8f419d..ff47474176 100644 --- a/src/AGENTS.md +++ b/src/AGENTS.md @@ -51,7 +51,7 @@ React 18 + 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 (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 | diff --git a/src/components/PaneTerminal.tsx b/src/components/PaneTerminal.tsx index 121c5d1fcc..4d7189ee39 100644 --- a/src/components/PaneTerminal.tsx +++ b/src/components/PaneTerminal.tsx @@ -53,6 +53,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 { 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; @@ -765,6 +766,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; @@ -779,11 +783,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 => { @@ -1326,6 +1330,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 @@ -1394,6 +1400,7 @@ export function PaneTerminal({ return; } tracking = event.touches.length === 1; + touchPixels = 0; const first = event.touches[0]; if (tracking && first) { touchX = first.clientX; @@ -1440,9 +1447,17 @@ 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; + 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: first.clientX, clientY: first.clientY, + })); } }; const onTouchEnd = (event: TouchEvent): void => { diff --git a/src/lib/wheelSteps.test.ts b/src/lib/wheelSteps.test.ts new file mode 100644 index 0000000000..43507ff928 --- /dev/null +++ b/src/lib/wheelSteps.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, test } from "bun:test"; +import { HERDR_MOUSE_SCROLL_LINES, wheelSteps } from "./wheelSteps.ts"; + +/** wheel reports a gesture of these moves sends, as the touch handler gathers them */ +function reports(moves: number[], cellHeight: number): number { + let pixels = 0; + let sent = 0; + for (const move of moves) { + const { steps, rest } = wheelSteps(pixels + move, cellHeight * HERDR_MOUSE_SCROLL_LINES); + pixels = rest; + sent += steps; + } + return sent; +} + +describe("wheelSteps", () => { + test("a fast swipe sends as many reports as a slow drag over the same distance", () => { + const fast = reports(Array(5).fill(60), 20); + const slow = reports(Array(30).fill(10), 20); + expect(fast).toBe(slow); + expect(fast).toBe(5); + }); + + test("the report count follows finger travel, not the number of moves", () => { + expect(reports([300], 20)).toBe(5); + expect(reports(Array(300).fill(1), 20)).toBe(5); + }); + + test("steps keep the direction and the rest carries to the next move", () => { + expect(wheelSteps(130, 60)).toEqual({ steps: 2, rest: 10 }); + expect(wheelSteps(-130, 60)).toEqual({ steps: -2, rest: -10 }); + expect(wheelSteps(59, 60)).toEqual({ steps: 0, rest: 59 }); + }); + + test("a reversal pays back the gathered travel before scrolling the other way", () => { + expect(reports([50, -50, -50], 20)).toBe(0); + expect(reports([50, -50, -130], 20)).toBe(-2); + }); + + test("an unlaid-out terminal makes no steps", () => { + expect(wheelSteps(500, 0)).toEqual({ steps: 0, rest: 0 }); + expect(wheelSteps(500, Number.NaN)).toEqual({ steps: 0, rest: 0 }); + }); +}); diff --git a/src/lib/wheelSteps.ts b/src/lib/wheelSteps.ts new file mode 100644 index 0000000000..ec1a29312b --- /dev/null +++ b/src/lib/wheelSteps.ts @@ -0,0 +1,16 @@ +/** + * Lines herdr scrolls a pane per wheel report: its `ui.mouse_scroll_lines` default. The browser + * cannot read the PC's herdr config, so a changed value only changes how far one report goes. + */ +export const HERDR_MOUSE_SCROLL_LINES = 3; + +/** + * Whole steps of `stepPixels` in the pixels gathered so far, and the pixels left for the next + * move. Steps keep the sign of the travel; the rest keeps it too, so a reversal first pays back + * what was gathered the other way. No step size (an unlaid-out terminal) makes no steps. + */ +export function wheelSteps(pixels: number, stepPixels: number): { steps: number; rest: number } { + if (!(stepPixels > 0) || !Number.isFinite(pixels)) return { steps: 0, rest: 0 }; + const steps = Math.trunc(pixels / stepPixels); + return { steps, rest: pixels - steps * stepPixels }; +} From 41039f25fd5bed6753be51d189938ef057818dfe Mon Sep 17 00:00:00 2001 From: Hako Date: Tue, 13 Oct 2026 21:45:01 +0900 Subject: [PATCH 2/5] test(pinch): word the drag-distance comment by cell height Co-Authored-By: Claude Opus 5.5 --- scripts/terminal-pinch-regression.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/terminal-pinch-regression.ts b/scripts/terminal-pinch-regression.ts index 2672d7692b..e94ac709de 100644 --- a/scripts/terminal-pinch-regression.ts +++ b/scripts/terminal-pinch-regression.ts @@ -83,7 +83,7 @@ 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 22 px maximum + // a wheel report per three rows of travel: 150 px is more than that even at the largest cell the 22 px font makes const dragOne = async (): Promise => { await cdp.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x: cx, y: cy - 60, id: 1 }] }); for (let step = 1; step <= 5; step++) { From 2172bcb10e45f52098ce7038f755a3491790d542 Mon Sep 17 00:00:00 2001 From: Hako Date: Tue, 13 Oct 2026 21:52:22 +0900 Subject: [PATCH 3/5] test(pinch): compare a swipe with a slow drag and size the leftover-finger move by cell height The pinch check now drags 300 px once in 2 touchmoves and once in 30 and asserts both send the same count of line-mode wheels, about one per three rows. On the old per-touchmove dispatch this fails. The finger left after a partial pinch release now moves three wheel steps at the 20 px cell, so a broken pinch-stroke guard would send wheels there. Co-Authored-By: Claude Opus 5.5 --- scripts/terminal-pinch-regression.ts | 37 +++++++++++++++++++++++++--- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/scripts/terminal-pinch-regression.ts b/scripts/terminal-pinch-regression.ts index e94ac709de..de62567ceb 100644 --- a/scripts/terminal-pinch-regression.ts +++ b/scripts/terminal-pinch-regression.ts @@ -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(); @@ -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(), @@ -91,11 +98,31 @@ export async function checkTerminalPinch(browser: Browser, origin: string, paneI } }; + /** 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 => { + 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"); 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"); @@ -171,8 +198,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(); From b462b5777b0dccd7910fa271d0d68d8088bff365 Mon Sep 17 00:00:00 2001 From: Hako Date: Tue, 13 Oct 2026 23:12:56 +0900 Subject: [PATCH 4/5] fix(terminal): send the leftover of a short touch drag when the finger lifts Gathering finger travel into one wheel report per three rows dropped any flick shorter than a step: the remainder reset at each touchstart and touchend never sent it, so 30-40 px nudges at the default font scrolled nothing. On touchend of a one-finger drag (not a pinch, not an adopted grid, not a cancel), send one more report when at least half a step is left (finalStep in lib/wheelSteps.ts). Unit cases for finalStep and a browser case in terminal-pinch-regression: a 0.7-step flick sends one wheel, a 0.3-step flick sends none. Co-Authored-By: Claude Opus 5.5 --- changelog.d/touch-scroll-by-finger-travel.md | 4 ++-- scripts/terminal-pinch-regression.ts | 4 ++++ src/AGENTS.md | 2 +- src/components/PaneTerminal.tsx | 14 +++++++++++-- src/lib/wheelSteps.test.ts | 21 +++++++++++++++++++- src/lib/wheelSteps.ts | 10 ++++++++++ 6 files changed, 49 insertions(+), 6 deletions(-) diff --git a/changelog.d/touch-scroll-by-finger-travel.md b/changelog.d/touch-scroll-by-finger-travel.md index 76c780bc05..7dcd47cba1 100644 --- a/changelog.d/touch-scroll-by-finger-travel.md +++ b/changelog.d/touch-scroll-by-finger-travel.md @@ -1,4 +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, and the text moves with the finger instead of a few - lines per touch event. + 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. diff --git a/scripts/terminal-pinch-regression.ts b/scripts/terminal-pinch-regression.ts index de62567ceb..fd426ceae0 100644 --- a/scripts/terminal-pinch-regression.ts +++ b/scripts/terminal-pinch-regression.ts @@ -121,6 +121,10 @@ export async function checkTerminalPinch(browser: Browser, origin: string, paneI 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 = (await probe()).wheels; await spreadTo(100, 154); diff --git a/src/AGENTS.md b/src/AGENTS.md index ff47474176..2d301d4db2 100644 --- a/src/AGENTS.md +++ b/src/AGENTS.md @@ -51,7 +51,7 @@ React 18 + 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 line-mode wheel events, one per `HERDR_MOUSE_SCROLL_LINES` rows of finger travel gathered over the gesture (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) | +| 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 | diff --git a/src/components/PaneTerminal.tsx b/src/components/PaneTerminal.tsx index 4d7189ee39..178921ab98 100644 --- a/src/components/PaneTerminal.tsx +++ b/src/components/PaneTerminal.tsx @@ -53,7 +53,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 { HERDR_MOUSE_SCROLL_LINES, wheelSteps } from "../lib/wheelSteps.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; @@ -1452,15 +1452,25 @@ export function PaneTerminal({ // 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: first.clientX, clientY: first.clientY, + 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); diff --git a/src/lib/wheelSteps.test.ts b/src/lib/wheelSteps.test.ts index 43507ff928..f577f8da95 100644 --- a/src/lib/wheelSteps.test.ts +++ b/src/lib/wheelSteps.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { HERDR_MOUSE_SCROLL_LINES, wheelSteps } from "./wheelSteps.ts"; +import { finalStep, HERDR_MOUSE_SCROLL_LINES, wheelSteps } from "./wheelSteps.ts"; /** wheel reports a gesture of these moves sends, as the touch handler gathers them */ function reports(moves: number[], cellHeight: number): number { @@ -42,3 +42,22 @@ describe("wheelSteps", () => { expect(wheelSteps(500, Number.NaN)).toEqual({ steps: 0, rest: 0 }); }); }); + +describe("finalStep", () => { + test("half a step or more left at the lift sends one more report in its direction", () => { + expect(finalStep(36, 60)).toBe(1); + expect(finalStep(30, 60)).toBe(1); + expect(finalStep(-36, 60)).toBe(-1); + }); + + test("less than half a step left sends nothing", () => { + expect(finalStep(24, 60)).toBe(0); + expect(finalStep(-24, 60)).toBe(0); + expect(finalStep(0, 60)).toBe(0); + }); + + test("an unlaid-out terminal makes no step", () => { + expect(finalStep(36, 0)).toBe(0); + expect(finalStep(Number.NaN, 60)).toBe(0); + }); +}); diff --git a/src/lib/wheelSteps.ts b/src/lib/wheelSteps.ts index ec1a29312b..26243be6db 100644 --- a/src/lib/wheelSteps.ts +++ b/src/lib/wheelSteps.ts @@ -14,3 +14,13 @@ export function wheelSteps(pixels: number, stepPixels: number): { steps: number; const steps = Math.trunc(pixels / stepPixels); return { steps, rest: pixels - steps * stepPixels }; } + +/** + * The last step a gesture sends when the finger lifts: one more in the travel's direction when + * what is left reaches half a step, so a short flick still scrolls and a long drag loses no more + * than half a step at its end. No step size makes no step. + */ +export function finalStep(rest: number, stepPixels: number): -1 | 0 | 1 { + if (!(stepPixels > 0) || !Number.isFinite(rest)) return 0; + return Math.abs(rest) >= stepPixels / 2 ? (Math.sign(rest) as -1 | 1) : 0; +} From d069e1ffa14de3785b833a414fecaba7c6108021 Mon Sep 17 00:00:00 2001 From: Hako Date: Wed, 14 Oct 2026 00:38:45 +0900 Subject: [PATCH 5/5] test(pinch): end the one-finger drag where the pinch puts that finger The pinch's touchStart moved finger 1 back 90 px, which Chrome reports as a one-finger touchmove first; with the drag's leftover that crossed a wheel step at CI's cell height. Co-Authored-By: Claude Opus 5.5 --- scripts/terminal-pinch-regression.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/scripts/terminal-pinch-regression.ts b/scripts/terminal-pinch-regression.ts index fd426ceae0..9ca341fce2 100644 --- a/scripts/terminal-pinch-regression.ts +++ b/scripts/terminal-pinch-regression.ts @@ -90,11 +90,12 @@ 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 + // 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 => { - 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 + 150 * step / 5, id: 1 }] }); + await cdp.send("Input.dispatchTouchEvent", { type: "touchMove", touchPoints: [{ x: cx, y: cy - 150 + 150 * step / 5, id: 1 }] }); } };