Skip to content

feat(appearance): add a phone-only terminal scrollbar toggle - #945

Closed
ThinMaPros wants to merge 2 commits into
devswha:mainfrom
ThinMaPros:feat/mobile-terminal-scrollbar
Closed

ThinMaPros wants to merge 2 commits into
devswha:mainfrom
ThinMaPros:feat/mobile-terminal-scrollbar

Conversation

@ThinMaPros

Copy link
Copy Markdown

Change

On an iPhone, the terminal's shared-history scrollbar reserves a 24px column. There is currently no way to hide it and recover that space.

Add Settings → Appearance → Show terminal scrollbar:

  • Visible only on touch layouts at up to 768px ((max-width: 768px) and (pointer: coarse)). A narrow desktop window does not expose the control.
  • On by default, preserving existing behavior.
  • Off: unmount the history scrollbar and remove its reserved column, while keeping touch scrolling, native paging and search highlights active. xterm still has zero scrollback; herdr remains the history owner.
  • Persist the boolean per browser/device, never sync it to other devices or collect it in telemetry. Desktop and wider touch layouts ignore the phone choice and keep their existing bar.
  • React to media-query changes without remounting the terminal.
  • Include ko/ja/zh/tr translations, settings/sync tests, design documentation and changelog entries.

This PR has two commits. The first fixes a pre-existing mobile search bug uncovered by the new regression: FindBar and the terminal input line reused the same sibling React key, causing duplicate search forms to consume the terminal's height. The second adds the scrollbar preference and the combined regression coverage. The key fix is needed to verify that mobile search still works with the history bar hidden.

This does not change the swipe algorithm or add momentum scrolling.

Validation

On final commit 6017eb9341e784ac601d007b14a2a627af77fd34 (macOS arm64, Bun 1.4.2, herdr 0.9.3):

  • TMPDIR=/private/tmp bun run check fast: passed generated-type freshness, typecheck, build and the unit suite. Workflow syntax is skipped locally because actionlint is pinned to Linux x64; CI checks it.
  • TMPDIR=/private/tmp CHROME_PATH="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" bun run check run --build bun scripts/terminal-viewport-regression.ts: passed against an isolated herdr with temporary config/state, not the user's running panes.
  • Browser coverage verifies the 24px width recovery and rendered-grid refit, default and persisted toggle state, real CDP touch swipe, paging/search with no bar, re-enabling the bar, live narrow/wide touch transitions, and no control or behavior change on desktop (including a narrow desktop window). Existing shared-history/search and older-bridge checks still pass.
  • The operator tested the switch on their physical iPhone and confirmed that it works. The automated tests use Chromium touch emulation, not an automated iOS keyboard/Safari test.
  • git diff --check: clean.

For both final check runs, a temporary process-local Git excludes file ignored only .DS_Store so Finder metadata changes could not invalidate the source fingerprint. No ignore file, generated build output or macOS metadata is included in this PR. Both final reports recorded unchanged source and exit 0.

Full integration/browser lanes and independent maintainer review remain for CI/review.

@coderabbitai

coderabbitai Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository: devswha/herdr-web-ui/.coderabbit.yaml
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: e84fb681-b7ca-4beb-ad4d-52ce395259fe
📥 Commits

Reviewing files that changed from the base of the PR and between b76aebe and 6017eb9.

📒 Files selected for processing (15)
  • CHANGELOG.md
  • DESIGN.md
  • scripts/terminal-viewport-regression.ts
  • src/components/PaneTerminal.css
  • src/components/PaneTerminal.tsx
  • src/components/SettingsDialog.tsx
  • src/components/TerminalViewportTools.tsx
  • src/lib/i18n.ja.ts
  • src/lib/i18n.ko.ts
  • src/lib/i18n.tr.ts
  • src/lib/i18n.zh.ts
  • src/lib/preferencesSync.test.ts
  • src/lib/settings.test.ts
  • src/lib/settings.ts
  • src/lib/terminalViewport.ts

Included review availability: This review used your included allowance. Your plan provides up to 4 included reviews per hour; 3 remain after this review.

📝 Summary

Summary by CodeRabbit

  • New Features
    • Added a phone-only Appearance setting to show or hide the terminal history scrollbar. It is on by default and saved on the device.
    • When hidden, the terminal reclaims the scrollbar’s space while swipe scrolling, paging, and search highlighting remain available. Wider layouts are unaffected.
  • Bug Fixes
    • Fixed search controls being misidentified when the terminal input line is visible on a phone.

A device-local preference controls the terminal history scrollbar on phone layouts. The scrollbar’s layout space is reclaimed when hidden, while paging and search remain available. Terminal search controls also receive distinct React keys.

Changes: Phone terminal scrollbar

Layer / File(s) Summary
Preference and phone detection
src/lib/terminalViewport.ts, src/lib/settings.ts, src/components/SettingsDialog.tsx, src/lib/i18n.*.ts, src/lib/settings.test.ts, src/lib/preferencesSync.test.ts
Adds a device-local, default-on preference and a phone media query. The Appearance page shows the toggle on matching layouts. Tests cover defaulting, sanitization, persistence, and scope.
Scrollbar visibility and layout
src/components/PaneTerminal.tsx, src/components/TerminalViewportTools.tsx, src/components/PaneTerminal.css
The phone layout and preference control scrollbar rendering and its reserved column. Hiding the bar does not disable history metrics, paging, or search.
Regression coverage and documentation
scripts/terminal-viewport-regression.ts, DESIGN.md, CHANGELOG.md
Adds checks for phone, wider touch, and desktop behavior. Documents the preference and its effects.

Terminal search control keys

Layer / File(s) Summary
FindBar key update
src/components/PaneTerminal.tsx, CHANGELOG.md
Adds a find prefix to the pane-specific FindBar key. The changelog records the distinct-key update for terminal search controls.

Priority: ⬇️ Low

Estimated code review effort: 3 (Moderate) | ~25 minutes

Change: Feature

Suggested reviewers: devswha

Merge Risk: ⚪ Minimal · up to 6017e

This adds a phone-only toggle for the terminal scrollbar and fixes duplicate search control keys. No actionable merge-blocking risk was identified from the supplied evidence.

Pre-merge checks · Passed 4 · Failed 1

Failed Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage Warning Docstring coverage is 40.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 5 functions across 12 files. (3 skipped: … Write docstrings for the functions missing them to satisfy the coverage threshold.
Passed Passed checks (4 passed)
Check name Status Explanation
Title check Passed The title is concise, specific, and accurately describes the main change: a phone-only terminal scrollbar toggle in Appearance settings.
Description check Passed The description includes the required Change and Validation sections. It clearly explains the problem, resulting behavior, scope, persistence, testing, limitations, and remaining CI review work.
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Full details: Docstring Coverage

Explanation

Docstring coverage is 40.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 5 functions across 12 files. (3 skipped: 3 unsupported.)

  • Fix all pre-merge checks with AI
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

devswha added a commit that referenced this pull request Oct 13, 2026
…me layout reads (#955)

## Problem

`TerminalViewportTools` (the native-history scrollbar and find-highlight
overlay) keeps a visible, idle pane asking herdr for its scroll metrics
once a second (`GET /api/pane/scroll`, one `paneScrollInfo` RPC each)
for as long as the pane stays open. On a phone over cellular or
Tailscale this keeps the radio awake. It also runs `paint()` on every
xterm `onRender`. `paint()` calls `getClientRects`, `getComputedStyle`
and two `getBoundingClientRect`s right after the DOM renderer changed
the rows, so each frame forces a synchronous layout during heavy output,
even when no search is active.

## Cause

- `read()`'s `finally` always re-armed `schedule(dirty ? 250 : 1000)`,
so with no output at all it still polled every second.
- `paint()` measured both the box geometry and the find rectangles, and
was bound to `terminal.onRender`. The box only moves on a resize or a
mount scroll, and the ResizeObserver, `onResize` and the scroll listener
already handled those.

## Fix

- New pure helper `idleScrollPollDelay(idleReads)` in
`src/lib/terminalViewport.ts`. Reads in a row with nothing between them
wait 1 s, 2 s, 4 s, 8 s, then 15 s (the cap). While output is arriving,
polling stays at 250 ms (`SCROLL_POLL_BUSY_MS`).
- The count resets on `wake()` (output, a resize, the end of a scrollbar
write, the tab becoming visible), on an explicit refresh (a search
result), and while the pane is not showing. A scroll made with no output
(another client, or herdr itself) is still picked up within 15 s.
- `paint()` is split:
- `paintBox()` measures the geometry. It runs on resize, on mount
scroll, after each metrics read, and on refresh.
- `paintHits()` runs on `onRender`. It returns before any layout read
unless a search has a result to place.
- The ResizeObserver now also watches `.xterm-screen`, so a change in
cell metrics (a font load, a zoom) still updates the box now that
rendered frames no longer do.

## Tests

- `src/lib/terminalViewport.test.ts`: new `scroll metrics poll` cases
for the backoff schedule, the cap, the reset and nonsense inputs, and
the busy cadence being shorter than the first idle step.
- `bun run typecheck`: pass.
- `bun run test:unit`: 3072 pass, 0 fail.
- `bun run check run --build bun
scripts/terminal-viewport-regression.ts`: PASS (scrollbar, search
highlights, wide cells, non-jumping refresh, old bridge).
- `bun run check run --build bun
scripts/application-paging-regression.ts`: PASS (this includes the held
page on stale metrics after a program quits, which relies on the read
that follows output).
- `scripts/terminal-copy-regression.ts`: started under `check run` but
printed no result, so it was not actually exercised here.

## Notes

- Source: mobile-terminal stability audit, finding render-scroll-3 (low
severity: battery, radio and herdr RPC load, plus per-frame layout jank;
nothing desyncs).
- Overlap: #945 (phone scrollbar toggle) also edits
`TerminalViewportTools.tsx` (a `showScrollbar` prop and the render
condition) and `src/lib/terminalViewport.ts` (a new constant at the
top). The hunks are separate from this PR's, but whichever merges second
may need a small rebase. Neither PR duplicates the other.
- Left out:
- Pushing `offset_from_bottom` / `max_offset_from_bottom` on the attach
stream, which would replace the poll entirely. That is a protocol change
for a separate PR.
  - A slower busy cadence on coarse pointers.

### Review follow-up (3b53d5b)
- Scrolled-up view: confirmed in the test herdr that a view scrolled up
into history is held still while output grows the history (offset and
max both grew 155 -> 165) and no bytes reach the client, so with the
backoff alone the scrollbar stayed stale for 7.3 s. The poll now keeps
its 1 s read while `offset_from_bottom > 0` (`scrollPollDelay`), and a
read that returns moved metrics resets the backoff.
- Wiring test: `scripts/terminal-viewport-regression.ts` now counts `GET
/api/pane/scroll`: a quiet pane at the bottom reads at most 4 times in 8
s (a fixed 1 s poll read 7 and fails), output brings the next read back
within 1.5 s, a scrolled-up view reads at least 4 times in 6 s (the
backoff alone read 3 and fails), and history grown under it reaches
`aria-valuemax` within 3 s.

🤖 Generated with [Claude Code](https://claude.com/claude-code)



## Merge-session review

Fixed (a8c2a68):
- A failed metrics read (for example a 502 or timeout on `GET
/api/pane/scroll`) hid the scrollbar, and the retry followed the idle
backoff, up to 15 s on a quiet pane. The failure path now resets the
backoff, so the next read comes after 1 s as before this PR. While no
metrics are held, a page key (Mod+Shift+PageUp/Down or the key-bar
button) now asks for a fresh read right away.
`scripts/terminal-viewport-regression.ts` fails one backed-off read and
checks that the retry comes within about 1.5 s and the scrollbar is back
within 2 s. It fails without the fix.

Deferred: none. A server that stays down is retried every 1 s, the same
as before this PR.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
devswha added a commit that referenced this pull request Oct 13, 2026
## Problem
On a phone in the terminal lens (line mode), the input line grows from
one row up to four as a message wraps or gets line breaks. Each new row
took one line of height from the grid. After about 120 ms the grid's
ResizeObserver fit one row fewer and sent a pty resize, so a three-line
message caused 2-3 resizes while typing and one more when the line
collapsed after Send. Each resize makes herdr reflow the pane and the
program in it (Claude Code) repaint, and any desktop sharing the pane
sees its grid shift. A note under the line (`.terminal-input-note`) and
the masked input (SecretInput) taking the line's place changed the
height in the same way.

## Cause
`TerminalInput` was an in-flow sibling of `.terminal-surface` in the
`.terminal-stack` flex column. The grid host (`.pane-terminal`, `flex: 1
1 auto`) got whatever height was left. The ResizeObserver in
`PaneTerminal.tsx` (fit, then `socket.resize`) cannot tell a change in
the input chrome from a real viewport change.

## Fix
- `PaneTerminal.tsx`: in the terminal lens, the input line and the
masked input render inside a `.terminal-input-slot`. The slot keeps
exactly one row of the input line in flow. The masked input renders from
the same slot in the chat lens too, where the slot is box-less
(`display: contents`) and the card stays in flow as before. One tree
position in both lenses means a lens switch does not remount it, so a
typed value or a send in flight survives the switch.
- `PaneTerminal.tsx` also measures the room from the slot's bottom to
the grid's top (ResizeObserver on the stack and `.terminal-surface`)
into `--terminal-input-room`. The grown line (`max-height`, textarea
`max-height: 100%` of its row) and the masked input (`max-height`,
`overflow-y: auto`) stop there and scroll. In a short pane they are no
longer clipped by the pane frame or drawn over the find bar.
- `TerminalInput.css`: the slot's height is computed from the same
tokens as a one-row line (top border and padding, then the taller of the
send button and a one-row textarea). `.terminal-input` hangs from the
slot's bottom edge (`position: absolute; bottom: 0`, `z-index:
var(--z-banner)`), so extra rows and the note grow upward over the
grid's last rows. On a pane where you type straight into the grid
(`.is-empty`), no row is reserved.
- `SecretInput.css`: inside the slot, the masked input floats the same
way.
- The CSS uses tokens only. There are no new strings and no protocol
change.

## Tests
- `scripts/terminal-input-regression.ts` has a new block. It types three
lines (Shift+Enter) into the input line, sends, and acknowledges. It
then asserts:
- the `.pane-terminal` height is unchanged after the line grows and
after it collapses;
  - an in-page ResizeObserver on the grid never fired;
  - no `resize` frame was sent;
- at one row, the line covers no grid row (the line's height equals the
slot's).
- Fails without the fix: `a growing input line keeps the grid's height`
(621 !== 669). Passes with it.
- Added after review, in the same script: a refused send
(`agent_blocked`) shows the note and then clears it, and the grid height
and observer stay unchanged; the one-row parity is checked again under
`data-density="compact"`; in a 160px-high viewport a four-line line
stays under the grid's top and its text scrolls. The last check fails on
the previous commit (`37 < 80`).
- `scripts/secret-input-regression.ts`: the field keeps `fixture-secret`
across the switch to the chat lens (fails on the previous commit). A
desktop context (no key bar) then brings up the masked prompt in the
terminal lens with direct typing and with the input line, and asserts
the grid height and observer stay unchanged. This part was not run
against main without the fix.
- `bun run check run --build bun scripts/terminal-input-regression.ts`:
pass (run several times). After review, `checkTerminalInput` was run
through a small driver under `bun run check run`: pass.
- `bun run check run --build bun scripts/secret-input-regression.ts`:
pass. A screenshot of the masked input over the grid in the terminal
lens was checked by eye.
- `bun run typecheck`: pass. `bun run test:unit`: 3069 pass, 0 fail, 8
skip.
- Not run: `ui-regression.ts` (the touch input-line block), `check
full`/`concurrent`.

## Notes
- The overlay covers the grid's last rows while the line has more than
one row, or while a note or the masked input is shown. That is the
design: the grid underneath keeps its size, and the rows come back when
the line collapses. No scroll hint or bottom inset was added for the
covered cursor row. Either one would be a policy change of its own.
- Out of scope:
- The KeyBar still unmounts while the masked input is shown, which
changes the host height on a phone.
- The soft keyboard opening and closing is still a resize. That is a
separate policy question (audit item 2-10, #712).
- If the masked input is pending but the socket is down, the reserved
row stays empty instead of disappearing.
- Overlap:
- #936 (dictation backend) edits `TerminalInput.tsx`/`.css`, but not the
layout this changes. Expect a small textual merge in the CSS header.
- #945 and #951 touch `PaneTerminal.css` only, which this PR does not
edit.
- Sibling stability PRs from the same audit also touch
`PaneTerminal.tsx`/`TerminalInput.tsx` (key bar while composing,
reclaiming the pty size from phone inputs).
- Source: mobile-terminal stability audit, finding viewport-resize-3
(report 2-6).

🤖 Generated with [Claude Code](https://claude.com/claude-code)



## Merge-session review

Fixed (908110d):
- Low: in a short pane a line grown to its cap (or a tall masked input)
painted over the top-right banners, since both sat at `--z-banner` and
the slot comes later in the DOM; the banners' buttons (held input
Send/Discard, Dismiss) lost their taps and "reconnecting" was hidden.
`.terminal-banners` now sits at `calc(var(--z-banner) + 1)`; the column
keeps `pointer-events: none`, so the line still takes taps outside the
chips. `scripts/terminal-input-regression.ts` raises a banner over the
capped line in the 160px-tall pane and checks with `elementFromPoint`
that its button is on top (it fails without the fix).

Deferred: none.

Checks: `bun run check fast` passed; `CI=1 bun run check run --build bun
scripts/ui-regression.ts` passed.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
devswha added a commit that referenced this pull request Oct 13, 2026
#963)

## Problem

On a phone, a slow drag scrolls herdr's history steadily, but a quick
swipe over the same distance moves only a few notches. The text does not
follow the finger, and flicking through a long agent transcript takes
many repeated drags.

## Cause

`onTouchMove` in `PaneTerminal.tsx` dispatched one synthetic pixel-mode
`WheelEvent` per touchmove. With mouse reporting on (herdr attach),
xterm 5.5 sends exactly one wheel report per wheel event:
`getLinesScrolled` is used only as a zero check, and its
`_wheelPartialScroll %= 1` drops every whole row beyond the first. The
wheel-speed replay only covers trusted events, so touch got no
compensation. A 40-60 px touchmove (a flick) crossed 2-3 rows but sent
one report; a slow drag sent about one per row.

## Fix

- New pure helper `src/lib/wheelSteps.ts`: `wheelSteps(pixels,
stepPixels)` returns the whole signed steps and the pixels left over.
`HERDR_MOUSE_SCROLL_LINES = 3` is herdr's default
`ui.mouse_scroll_lines`.
- The touch handler gathers the gesture's travel (`touchPixels`, reset
on touchstart), divides it by cell height x 3, and dispatches that many
`DOM_DELTA_LINE` wheels of `deltaY` +-1 (each makes xterm send exactly
one report), keeping the rest for the next move. One report then moves
herdr about as far as the finger moved.
- The selecting drag's wheel (`dragWheel`) uses the same helper and the
same `cellHeight()` so the two conversions cannot drift.
- `src/AGENTS.md` geometry row updated.

No momentum in this PR: a flick still stops where the finger lifts
(`onTouchEnd` sends at most one leftover step, see the merge-session
review below). render-scroll-2 also proposed a decaying momentum phase;
that is deferred to a follow-up and this PR closes only the
per-touchmove report loss.

## Tests

- `scripts/terminal-pinch-regression.ts` (browser, runs in
`ui-contexts-regression`): at the 13 px baseline it drags 300 px once in
2 touchmoves and once in 30, and asserts both send the same wheel count
(about 300 / (3 x cell height)) and that every wheel is
`DOM_DELTA_LINE`. Verified to fail on main's `PaneTerminal.tsx` ("a
2-move swipe sends line-mode wheels only"). The finger left after a
partial pinch release now moves three wheel steps of the 20 px cell (it
moved 60 px, under one step, so that negative check could not fail).
- `src/lib/wheelSteps.test.ts` (new): five 60 px moves send the same
report count as thirty 10 px moves (and 300 x 1 px equals one 300 px
move); sign and remainder carry; a reversal pays back gathered travel
first; a zero/NaN cell height makes no steps.
- `bun run typecheck`: pass. `bun run test:unit`: 0 fail.
- `bun run build`, then `bun run check run bun
scripts/ui-contexts-regression.ts`: pass, including `checkTerminalPinch`
(earlier commit); after the test update, `bun run check run bun
scripts/terminal-pinch-regression.ts`: pass. Its one-finger drag went
from 60 px to 150 px: at a 20 px font, 60 px is less than one three-row
step, so it would no longer send a wheel. That script only asserts that
synthetic wheel events are dispatched; it does not check how far herdr
scrolls.
- `scripts/windows-lens-browser-qa.ts` (manual QA, not a CI lane) is the
only script that drags a finger into real herdr history. It failed twice
at an earlier step ("the command's output reaches the mirrored
terminal", in the mirror-only part), before the touch-scroll step ran.
So the herdr-side scroll distance was **not** verified end to end here.

## Notes

- The browser cannot read the PC's herdr config. On a PC where
`mouse_scroll_lines` was changed, one step scrolls that many lines
instead of 3, so the text moves proportionally faster or slower than the
finger.
- `terminalWheelSpeed` still does not apply to touch (unchanged: the
replay is for trusted wheels only).
- A slow drag now sends about a third of the reports it sent before.
Before, it scrolled about 3 lines per row of finger travel; now it
scrolls about 1.
- Overlap: #945 (phone terminal scrollbar) does not touch the drag
translation. #946 and #951 edit `PaneTerminal.tsx` elsewhere: textual
merge-conflict risk only, no semantic overlap.
- Source: mobile-terminal stability audit, finding render-scroll-2.

🤖 Generated with [Claude Code](https://claude.com/claude-code)



## Merge-session review

Fixed (b462b57):
- Short drags no longer get lost. Before this, a one-finger flick
shorter than one step (3 rows, so 51 px at the default 13 px font) sent
no wheel at all, and every longer drag dropped its last partial step.
Now, on `touchend` of a one-finger drag (not a pinch, not an adopted
grid, not a `touchcancel`), one more line-mode wheel is sent if at least
half a step is left (`finalStep` in `src/lib/wheelSteps.ts`). Unit cases
cover `finalStep`. `scripts/terminal-pinch-regression.ts` now checks
that a 0.7-step flick sends exactly one wheel and a 0.3-step flick sends
none. On the previous head this case failed: "a flick of 0.7 step (40
px) sends one wheel". The changelog fragment and the `src/AGENTS.md` row
now describe the touchend step.

Deferred:
- Momentum/inertia scrolling after a fling (already listed above). This
is still out of scope.

Checks: `bun run check fast` passes, and `CI=1 bun run check run --build
bun scripts/terminal-pinch-regression.ts` passes.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
devswha added a commit that referenced this pull request Oct 13, 2026
…bar (#964)

## Problem

A phone shows a pane in the terminal lens while a desktop tab of the
same pane takes the pty back to its own size (a click or key in the
desktop terminal, or the desktop window coming back to the front). The
phone's xterm stays at phone width while herdr streams rows drawn for
the desktop's width, so lines wrap in the wrong place and agent TUIs
look scrambled. Typing in the input line, Send, Enter and key-bar taps
did not fix it. Only a touch on the grid itself, a focus change or a
reload did.

## Cause

In the terminal lens, an interact client stores `pane-geometry` in
`sharedGridRef` and then ignores it, because it drives the grid itself.
The only thing that took the size back while the window kept its focus
was `reclaim`, and that runs only for a pointerdown or keydown whose
target is inside `term.element`. The phone's usual input paths are all
outside that element:
- `sendTerminalLine` submits from the `TerminalInput` textarea.
- `pressEnter` sends `\r`/keys from the same line.
- `pressKey` calls `term.input()` from the key bar, with no DOM event on
the grid.

## Fix

- The size check moves out of `reclaim` into `reassertIfStale()`. When
the shared grid differs from this grid, the tab is in use and has not
released its pane, it calls `refit()` (force resize).
- `reclaim` keeps its event filters (target inside the grid, no
IME/repeat key, primary button) and then calls the helper.
- `sendTerminalLine`, `pressEnter` and `pressKey` call it before they
send, so the resize frame goes out before the input on the same socket.
- `TerminalInput` gets an optional `onFocus`, wired to the same helper.
Tapping into the input line counts as the user being back, the same way
a tap on the grid does.
- No resize was added for the keyboard opening or closing. That is a
separate policy question (viewport-resize-2).

## Tests

- `scripts/chat-size-regression.ts`
`checkFocusedWindowReclaimsTerminalSize` (already in
`ui-contexts-checks.ts`, so it runs in the browser lane) now continues
with two clients after its existing steps. A desktop at 1280x800 and a
phone at 390x844 in line mode take turns:
  - a tap into the input line;
  - a line sent with Enter;
  - a key-bar Ctrl+C;
  - Enter on the empty line.

Each phone step comes after a desktop key has taken the pty back. Each
step asserts that the phone's own-size `resize` frame goes out before
its `submit`/`input` frame, and that the pane's shell (`stty size`)
reports the phone's size again.
- `CHAT_SIZE_CASE=reclaim bun scripts/chat-size-regression.ts` now runs
that check on its own.
- Without the `src/` change the new step fails ("a tap into the input
line takes the pane back to the phone's grid"). With it, `bun run check
run --build bun scripts/chat-size-regression.ts` (with
`CHAT_SIZE_CASE=reclaim`) passes.
- `bun run typecheck` and `bun run test:unit` (3069 pass, 0 fail) pass.
- `check full` / `concurrent` was not run.

## Notes

- Source: a mobile-terminal stability audit, finding viewport-resize-1.
Related: #472 (the chat-lens version) and #712 (an attach holds the
pane's size). Neither covers this path.
- No open PR touches `reclaim`, `sharedGridRef`, `sendTerminalLine`,
`pressEnter` or `pressKey`. #951, #950, #946, #945, #936, #935, #932,
#910, #894, #859 and #822 edit other parts of `PaneTerminal.tsx`. #936
also edits `TerminalInput.tsx` (dictation), so a small textual conflict
is possible there.
- The size tug-of-war between two devices that both type into the same
pane remains by design: whichever device the user is typing on takes the
pty. A "sized by another device" notice and passive adoption of the
foreign grid were left out.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
@devswha

devswha commented Oct 14, 2026

Copy link
Copy Markdown
Owner

Thank you for this. Closing: the app is reducing its settings (#1099, #1104) and is not taking new appearance toggles, so a phone-only scrollbar switch will not be added.

@devswha

devswha commented Oct 14, 2026

Copy link
Copy Markdown
Owner

Follow-up: we looked at this again. Rather than a toggle, we are removing the terminal history scrollbar everywhere in #1113, since the wheel, phone drag, page keys and Find already scroll the shared history (Paseo and Moshi show no terminal scrollbar either). Your point about the column it costs on an iPhone is what started this. Thank you, @ThinMaPros.

devswha added a commit that referenced this pull request Oct 14, 2026
## Why
Owner decision (2026-10-15): delete the terminal history scrollbar
outright, with no setting. Paseo and Moshi show no terminal scrollbar
either, and on phones the bar reserved a 24px column beside the grid.
History stays reachable: the wheel, a finger drag,
Mod+Shift+PageUp/PageDown and Find all still scroll herdr's shared
history.

## Removed
- The `terminal-history-scrollbar` track and thumb in
`TerminalViewportTools.tsx`, with its drag, click and keyboard (arrows,
Page Up/Down, Home/End) handlers, its preview state, the `viewportId`
prop, and the `scrollThumb`/`offsetAtThumb` helpers (and their unit
test).
- The reserved column: the `.pane-terminal.has-viewport-tools` width
rule, the `--terminal-history-gutter` variable, and the single-pane
header offsets in `PaneCanvas.css`. The grid now fits the full mount
width (the fit addon reads the mount; no cols math subtracted the
column).
- Its CSS, the `Terminal scrollback` i18n entry (ko/ja/zh), and the
scrollbar passages in DESIGN.md, docs/guide.md and the demo log line.

## Kept
- The scroll metrics poll (`GET /api/pane/scroll`) the page keys rely
on, Mod+Shift+PageUp/PageDown paging with pending-page accumulation,
find highlights and jumps (and Find cancelling queued pages), phone drag
and wheel scrolling. No `server/` or `shared/` changes; `pane.scroll`
and `/api/pane/scroll` are untouched.

## Tests
`scripts/terminal-viewport-regression.ts` now asserts that no scrollbar
is drawn and that the grid spans the whole terminal surface. Steps that
used the scrollbar now page with the shortcut. The paging,
rebound-shortcut, poll back-off, Find ordering and old-bridge checks are
kept.

- `bun run typecheck`: exit 0 (also after merging origin/main)
- `bun run test:unit`: exit 0 (3645 pass, 0 fail, 8 skip)
- `bun run build`: exit 0
- `bun run check run sh -c "bun scripts/terminal-viewport-regression.ts
&& bun scripts/terminal-pinch-regression.ts && bun
scripts/ui-regression.ts"`: exit 0 (`terminal-pinch-regression` covers
one-finger drag scrolling)

Supersedes the idea in #945.
devswha added a commit that referenced this pull request Oct 14, 2026
…ory (#1036)

<!-- See CONTRIBUTING.md for checks, translations, contract tests and
the changelog (a file in changelog.d/, never CHANGELOG.md). -->

## Change

Closes #1034

In the Terminal lens, scrolling herdr's shared history up leaves the
pane with no one-tap way back to the live bottom: the history
scrollbar's End key is the only shortcut, and it is out of reach on a
phone. Chat already has a round "Jump to latest" button for this.

While herdr reports `offset_from_bottom > 0` and the pane is
interactive, a round ArrowDown "Jump to bottom" button now floats over
the grid's bottom-right corner, left of the history gutter. Tapping it
sends the same `pane.scroll` offset 0 as the scrollbar's End key,
through the existing coalesced `writeRef` path in
`TerminalViewportTools`. A failed write falls back to the existing
`Scroll failed` error. The button:

- is hidden at the bottom, in the chat lens, in observe and read-only
views, and while Find owns the viewport (it renders only when `enabled
&& interactive`);
- keeps focus in the terminal (and the soft keyboard up) via
`onPointerDown` `preventDefault`, the same pattern as the input row's
buttons;
- reuses the Chat button's round form with tokens only (`--control-h`,
`--touch-target` on coarse pointers, `--radius-pill`, `--shadow-pop`,
`--icon-size`), no color literals, no new animation;
- is anchored to the grid's bottom-right corner clipped to the mount's
client area, so an adopted grid (a mirrored PC, an observer) that is
wider than the mount keeps the button inside the overlay instead of
under the gutter.

No new state, polling, endpoint, WS frame or protocol type;
`site/demo/transport.ts` already answers `pane/scroll`. New string `Jump
to bottom` is translated in ko, ja, zh and tr. `DESIGN.md` (terminal
history section) and a `changelog.d` fragment are updated.

Heads-up: #945 (phone scrollbar toggle) also edits
`TerminalViewportTools.tsx` and `DESIGN.md`. There is no functional
dependency, only a possible textual conflict.

## Validation

- `bun run check fast`: generated types fresh, typecheck, build and unit
tests (3470 pass, 0 fail, includes `i18n.test.ts`,
`conventions.test.ts`, `motion.test.ts`).
- `scripts/terminal-viewport-regression.ts` gains a step: no button at
the native bottom, button visible after Home and left of the gutter,
with the terminal textarea focused a click returns herdr to offset 0,
the button detaches, focus stays on the textarea and no input frame is
sent.
- On my macOS machine (herdr 0.9.3) the script already stops at the
existing step "repeat paging accumulates against the pending preview",
identically on untouched `main`, so I could not run it end to end here.
With that one block removed in a throwaway copy the whole script,
including the new step, prints `PASS native history scrollbar, ...`.
Please rely on the CI browser lane for the full script.
- One-off Playwright check (not committed): 390x844 viewport with a
coarse pointer, on an attached pane and on a mirror pane
(`createServer({ terminalAttach: false })`, adopted grid). In both the
button is 40x40, inside the mount's client area and left of the gutter.
- Not verified: a real iOS or Android soft keyboard (only Playwright
mouse and touch emulation), and an adopted grid that is actually wider
than the mount (covered by code reading and the clip logic only).

Co-authored-by: Hako <devswha@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants