Repository navigation
feat(shortcuts): bindable New tab, Split pane right and Split pane down - #940
Conversation
The palette already offers these actions; Settings → Shortcuts can now give each a Mod+Shift key. They have no default key, and their Default row reads Off. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Important Review skippedToo many files! This PR contains 674 files, which is 524 over the limit of 150. To get a review, reduce the PR to 150 files or fewer by splitting it into smaller PRs or changing its base branch. Upgrade to a paid plan to raise the limit. Usage-priced reviews support at most 300 files. ⚙️ Run configuration
⛔ Files ignored due to path filters: 3
📒 Files selected for processing: 674
You can disable this status message by setting the No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info
📝 Summary
Merge Risk: ⚪ Minimal · up to
|
…#915) ## Change First-device pairing previously persisted the closed access gate before the browser received its device cookie. A reverse proxy that discarded `Set-Cookie`, or a lost pairing response, could lock out a previously open browser despite successful proxy authentication. - Keep first-pair credentials bounded and temporary until the cookie returns; atomically save the confirmed device and gate before granting device access. - Discard other unconfirmed first-pair credentials after confirmation. Expiry/restart never closes an open gate; previously gated and corrupt registries remain gated, including after revoke-all. - Verify the current device before Settings reports success. Add cookie-failure guidance, explain app-level restrictions behind proxy authentication, and update all four translations and access documentation. - Return structured errors on confirmation-write failure without granting undurable access; allow retry after storage is repaired. - No new endpoints or wire shapes; demo transport is unchanged. ## Validation - `bun run check fast`: passed after review fixes; typecheck, build, generated types/workflow checks, 2,982 unit tests passed and 8 skipped. - `bun run check run bun test --timeout 15000 ./server/device-access.contract.test.ts ./server/api.contract.test.ts`: 122 passed on isolated herdr. Includes LAN-exposure warning transition and confirmation-write failure/retry. - Chromium with actual DevicesPanel and createServer behind a Basic-auth proxy: stripped Set-Cookie shows an error without locking access; lost response allows retry; normal retry confirms one device, survives reload, and denies another cookie-less proxy-authenticated browser. Repeated after review fixes. - Unit/HTTP coverage: missing cookie, confirmation, retries, restart, expiry/bounds, write failure, existing registries and revoke-all. - Independent security review found no auth bypass; its error-boundary and stale-contract findings were addressed. The browser failures were deliberately injected; this verifies the lockout mechanism, not the original reporter's exact proxy configuration. Full browser/integration and Windows CI are separate from these local checks.
## Change Closes devswha#876 On Windows, herdr's `pane.process_info` lists only the pane's launcher in `foreground_processes`, not what the launcher starts. A pane that runs a wrapper executable (for example a `pi.exe` that spawns `bun …/omo-ai/bin/cli.js`) was therefore never recognized as OmO, and its OmO chat and session were not found. When `process_info` has a `shell_pid`, `server/omo.ts` now adds the OmO processes found below that shell in the Windows process table (`recentProcessTable`, the shared 5 s cache) to the foreground list before detection: - `withOmoDescendants(info, rows)` is a pure function. It adds only processes that `isOmoProcess` accepts, so an ordinary `pi.exe` stays a non-OmO pane. - Only descendants of this pane's own `shell_pid` count, so another pane's OmO is not borrowed. - A process herdr already names (same pid) is not added twice. - The added entries keep their argv, so `--session-id` is still read from them. - Off Windows nothing changes: the process table is not read and the info is returned as it came. `server/windows-processes.ts` gets `descendantProcesses`, which returns the pid next to the argv. `descendantArgv` is now a thin wrapper over it, with the same result. ## Validation Unit tests are in `server/omo.test.ts` (launcher with an OmO child, ordinary pi not OmO, another pane's OmO not borrowed, no duplicate) and `server/shell-agent.test.ts` (`descendantProcesses`). RED, with the tests and no fix: `HERDR_TEST_MODE=unit bun test ./server/omo.test.ts ./server/shell-agent.test.ts` exit 1. Both files fail to load: `Export named 'withOmoDescendants' not found` and `Export named 'descendantProcesses' not found`. With only no-op stubs of the two functions, the tests that expect the OmO child to be found fail (`finds the OmO a Windows launcher started below the pane's shell` and `finds what runs under a pane's shell in the process table`). GREEN, with the fix: `HERDR_TEST_MODE=unit bun test ./server/shell-agent.test.ts` 6 pass, 0 fail, exit 0; `HERDR_TEST_MODE=unit bun test ./server/omo.test.ts -t "Windows launcher|ordinary pi|twice"` 3 pass, 0 fail, exit 0. - `HERDR_TEST_MODE=unit bun test ./server/omo.test.ts ./server/shell-agent.test.ts ./server/windows-native.test.ts ./server/gjc-runtime.test.ts`: 42 pass, 2 fail. The two failures, `finds omo's agent directory where the process's environment moved it` and `binds an omo pane whose launcher moved its agent directory out of ~/.omo`, assume POSIX paths. They fail the same on unmodified `main` on Windows. - `bun run typecheck`: exit 0. - `bun run build`: exit 0. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…d runner (devswha#873) ## Change Review of CI after the recent merges (devswha#795 browser lane parallelization, devswha#805 CI setup speedups), with two defects fixed and an opt-in self-hosted runner for Linux CI/CD. ### Fixes - **Release/Pages validation could skip heavy tests.** Under `workflow_call` (release.yml, pages.yml) and `workflow_dispatch`, Detect changes diffed `HEAD~1`; a docs-only last commit set `code=false` and the release was validated without integration and browser tests. Only a `pull_request` may now skip them. - **Docs-only regex.** `^(docs/|\.md$|...)` anchored `\.md$` to the path start, so no `*.md` outside docs/ ever counted as docs. Now `^docs/|\.(md|txt)$|^LICENSE$|^\.github/ISSUE_TEMPLATE/`. - `${{ }}` values in `run:` scripts go through `env:`. - The Bun/Node/herdr setup duplicated across 7 jobs is one composite action (`.github/actions/setup-ci`); the Bun version is set once (pages.yml and release.yml pinned it separately). ### Self-hosted runner (opt-in, default unchanged) - Repository variable `CI_SELF_HOSTED_LABELS` (JSON array containing `"self-hosted"`) routes Fast checks, Integration tests, both Browser regression jobs, Pages build/deploy and Release preflight/publish. Unset = `ubuntu-24.04` as today. ci.yml also takes a `self_hosted_labels` dispatch input for one manual run. - Fork PRs always get `ubuntu-24.04` (`scripts/ci-runner-route.sh`, tested in `scripts/ci-runner-route.test.ts`). Since a fork PR can edit `runs-on` itself, REVIEW.md now says not to approve such runs. - `scripts/self-hosted-runner/`: an ephemeral Docker runner (one job per container, `--cap-drop ALL`, `no-new-privileges`, no sudo, no host mounts, registration token on stdin) with Chromium libraries and ffmpeg baked in. The browser lane skips its sudo `install-deps` when `RUNNER_ENVIRONMENT=self-hosted`. - Note: GitHub-hosted minutes are free for this public repository, so the gain is speed (a 32-core host vs 4-core runners), not cost. ## Validation - `bun run check fast`: workflow syntax, generated types, typecheck, build, unit (2952 pass, 0 fail). - Route script cases (fork PR, variable, dispatch override, malformed labels) in `scripts/ci-runner-route.test.ts`. - Runner image: uid 1001, no sudo, Chromium launches without root under `--cap-drop ALL`; token absent from `/proc/1/environ`. - A workflow_dispatch run of this branch on the self-hosted runner: see the comment below.
## Change CHANGELOG.md's `## [Unreleased]` lists are the repo's most frequent merge conflict: every PR appends to the same Added/Changed/Fixed lists, and a conflict resolver once pushed the markers (devswha#838). A PR now adds one small file to `changelog.d/` instead, so two PRs never touch the same lines. - **Format** (`changelog.d/README.md`): a lowercase `name.md` holding exactly the lines it would have in CHANGELOG.md: `### Added|Changed|Removed|Fixed|Maintenance` headings, `- ` entries, two-space continuations. No PR link or credit needed. - **Fold** (`scripts/changelog-fold.ts`): the release PR runs `bun scripts/changelog-fold.ts X.Y.Z [YYYY-MM-DD]` (the date defaults to the local one). It turns the Unreleased notes into `## [X.Y.Z] - date` under an empty `## [Unreleased]` (the shape of devswha#853), with the existing Unreleased entries first, then the fragments sorted by file name, sections in release order (any other heading the old notes used is kept after them). It links each fragment's entries to the PR whose squash merge added the file (from `git log --follow --diff-filter=A`, so a later rename does not take the link) unless the entry already links that PR, moves the compare links on, deletes the fragments, and prints what still needs a link plus the reminder to add `by @login` credits. It refuses rather than drops a line it cannot place. - **Release gate**: `scripts/release-notes.ts`, which release.yml runs before tagging, refuses while `changelog.d/` still holds a fragment (one merged after the release PR folded them would otherwise ship without a line in the notes), and says to move its entries into the version by hand. - **Transition**: the ~30 open PRs that still append to Unreleased need no rework; their lines are folded together with the fragments. CHANGELOG.md content is unchanged in this PR. `scripts/release-notes.ts`, `server/release-notes.ts` and `scripts/update-browser-qa.ts` read the same shape as before. - **Guards** (`scripts/changelog-fold.test.ts`, unit suite): every fragment parses (known section, at least one non-empty entry, no stray text, lowercase `.md` name); CHANGELOG.md and the fragments hold no conflict markers; today's Unreleased notes plus fragments fold for the next release, so an old PR's malformed entry fails at its own PR rather than at release. Failure messages name the file and line and point at `changelog.d/README.md`. Not enforced yet: that a PR leaves CHANGELOG.md alone. A ratchet or a `changes`-job check would fail the ~30 open PRs that still append there (or turn main red when one of them merges on a stale green run), so it is a planned follow-up once they have drained; AGENTS.md says so. - **Docs**: CONTRIBUTING.md (changelog bullet), AGENTS.md maintainer workflow (one line, pointing at the test), docs/development.md Releasing step 1, the PR template comment. No CHANGELOG entry or fragment: not user-visible. ## Validation - `bun run check fast`: workflow syntax, generated types, typecheck (the fold script added to `tsconfig.tools.json`), build, unit (3032 pass, 0 fail, 8 skip). - `bun test scripts/changelog-fold.test.ts scripts/release-notes.test.ts`: 18 pass, including one where `releaseNotes()` reads a folded changelog. - `git log --follow --diff-filter=A` checked on a throwaway branch: after `x (devswha#1000)` adds a fragment and `chore: rename (devswha#1001)` renames it, it reports devswha#1000 (without `--follow`, devswha#1001). - Dry run on this branch with a probe fragment: `bun scripts/changelog-fold.ts 0.4.9 2026-10-13` changed CHANGELOG.md by only the new heading, the probe line at the end of Fixed and the two compare links (+5/-1), and deleted the fragment. Reverted afterwards. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…try key (devswha#948) ## Change Two release-time mistakes that keep coming back now fail CI instead of relying on memory: forgetting to raise the remote bundle version, and releasing before the telemetry Worker has been deployed. Both checks live in `scripts/release-guards.ts`, which runs in CI's **Fast checks** (after `check fast`, once a shallow fetch has pulled in the tags) and in the **Release** preflight (`--release X.Y.Z`). A tree counts as a release when its `package.json` version has no `v<version>` tag yet: a release PR, `main` between that PR's merge and the Release run, and the Release run itself. Every other tree passes at once, so feature PRs are not affected. ### Remote bundle version - The inputs are what `build-remote-bundle.ts` packs: `server/` and `shared/` (without `*.test.ts`, `*.md` and `server/fixtures/`), `bun.lock`, `patches/`, the builder itself, and the dependency fields of `package.json`. `dist/` is left out: the connection server serves the page, and the bridge never does. - Baseline: the `remote-vN` tag for the `REMOTE_BUNDLE_VERSION` in the tree. If that tag exists and the inputs differ from it, the check fails and says: raise N to N+1 in `shared/machines.ts`, then push `remote-v(N+1)` after the merge and before Release. If the tag does not exist yet (N was raised in this PR), the PR passes. The Release run fails until `remote-vN` is published from a tree with these same inputs. It also fails when N is lower than an existing `remote-vM`. - Tested on past releases: v0.4.5 to v0.4.8 pass. **v0.4.4 would have failed**: it shipped `server/index.ts`, `server/prompt.ts`, `server/conversation.ts`, `bun.lock` and others with no new bundle (remote-v23 was cut at v0.4.3). ### Telemetry Worker - Parity between client and Worker already holds at compile time: the Worker validates with `shared/telemetry.ts`, and `telemetrySettingsSnapshot` must cover every Settings field. A new unit test in `src/lib/settings.test.ts` adds a clear failure message on top: it lists each key the app sends that the Worker refuses, and the reverse. The message says to add the key to `TelemetrySettings` and `TELEMETRY_SETTINGS_VALIDATORS` and to the worker.test.ts fixture, then deploy the Worker before releasing. `TELEMETRY_SETTINGS_KEYS` is exported for this test. - What the code alone cannot show is whether the Worker was deployed. `telemetry/deployed.json` stores the sha256 of `telemetry/worker.ts` + `shared/telemetry.ts` as last deployed. A release whose sources differ fails and says to deploy (telemetry/README.md), run `bun scripts/release-guards.ts --worker-deployed` and commit the file. **Starting value:** the v0.4.8 sources. devswha#866 and devswha#863 changed the schema after that, so the next release PR will fail this check until the Worker is deployed and the file is updated. That deploy is already planned before the next release, so the failure is intended. ### Release metadata - `server/release-notes.test.ts` already requires patch notes in every language, but only for versions that have a CHANGELOG heading. `release.yml` checked the version itself only after merge. The guard now runs the same `releaseNotes()` check on the release PR, so a version bump without a CHANGELOG section, without a full `release-summaries.json` entry, or with a mismatched `herdr-plugin.toml` fails before merge. Docs: one paragraph in docs/development.md "Releasing" (the language list there now includes `tr` as well), one line in telemetry/README.md Deploy, one pointer in scripts/AGENTS.md. No CHANGELOG entry, since users see no change. ## Validation - `bun run check fast`: workflow syntax, generated types, typecheck, build, unit (3031 pass, 0 fail). - `scripts/release-guards.test.ts` builds throwaway git repos and checks each case: a tagged tree is left alone; a release PR that changed only the client passes; server, `bun.lock` and dependency changes under an already-published bundle fail; a raised bundle passes the PR but fails Release until `remote-vN` exists; a changed Worker without a recorded deploy fails; missing patch notes fail. - On this branch: `bun scripts/release-guards.ts` reports "Not a release" and exits 0. `--release 0.4.8` fails with both expected messages: 22 bundle inputs changed since remote-v27, and the Worker was changed since its deploy. - Simulated the CI step on a `--depth 1` clone: the tag fetch takes about 3 s and fetches 92 tags. With `package.json` bumped to 0.4.9, all three problems are reported. - Mutation check: adding a key to the snapshot that the Worker does not accept fails the new settings test with the "add it ... deploy the Worker before releasing" message. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…ha#949) ## Change Rules that AGENTS.md and src/AGENTS.md stated only in prose are now checked by bun tests. Each one passes on current main, and each failure message names the helper or file to change. The goal is that the codebase carries these rules itself, so an agent with little context gets them right without a reviewer catching it. ### `server/conventions.test.ts` - **PTY sidecar boundary:** only `server/pty/pty-host.mjs` and `server/pty/smoke.mjs` may load `node-pty` / `@lydell/node-pty`, whether by import, dynamic import, require or `createRequire(...)(...)`. The `Bun.resolveSync` in `server/pty/sidecar.ts` is not a load, and the test does not flag it. - **Web push:** nothing calls `sendNotification`. - **Error bodies:** outside `server/http.ts`, no code uses `Response.json(` or `new Response(JSON.stringify(`. Every `jsonResponse` / `reply` / `fail` body with a top-level `error:` key (first or not) must be `{ code, message }`, read as keys rather than words inside strings. The `jsonResponse({ error: { code, message } })` sites already in the code are valid, since `jsonResponse` is the http.ts helper. - **herdr socket:** a unix-socket connection (`Bun.connect` with `unix:` or with options it cannot read; node:net `connect`/`createConnection` through a named, default, namespace or require import, called with `path:` or a path) appears only in `server/herdr/client.ts`, plus `server/herdr-marker.ts` (a liveness probe) and `server/ssh-askpass.ts` (the askpass socket), each allowlisted with its reason. TCP connections (`{ host, port }`, `(port, host)`) are not flagged. `subscribeEvents` and the `"events.subscribe"` literal appear only in `server/collector.ts` and `server/herdr/client.ts`. ### `src/conventions.test.ts` - **Component CSS:** declaration values in `src/**/*.css` other than `styles.css` may not contain hex, color functions or any CSS named color, in any case, and no file may use `!important`. `color-mix()` of tokens is allowed. Selectors and comments are not checked. - **DESIGN.md vs `src/styles.css`:** each token-table row (`--token` followed by literal cells) is compared with `:root`, and a second literal column is compared with `[data-theme="light"]` or `[data-density="compact"]` depending on its header. An unknown column header fails the test, and fewer than 150 compared cells also fails it, so reformatting the tables cannot turn the test off. All 172 cells match today. AGENTS.md said that no test checked this. - **Icon cache:** the test hashes the icon images that browsers and `sw.js` cache (dotfiles such as `.DS_Store` are skipped) and compares the result with `ICON_VERSIONS[CURRENT_ICONS]`. Every icon URL in `index.html`, `manifest.webmanifest`, `sw.js` and `src/**/*.{ts,tsx}` (today `App.tsx`, `AccessGate.tsx`, `notifications.ts`) must carry `?v=` equal to `CURRENT_ICONS`; only `sw.js`'s precache list may name an icon bare. Each icon set records its own `CACHE_NAME`, and the live `CACHE_NAME` may never be an older set's; otherwise `CACHE_NAME` may move without the icons (fonts, shell assets). If the icons change without a bump, the message gives the new hash and lists the files to edit. - **UI wording:** "New session" may not appear in client or demo code outside comments. ### `scripts/conventions.test.ts` - **Imports:** relative specifiers must end in an explicit extension, and nothing may use `@shared/`. There are no violations across 719 files. - **Fixed sleeps (ratchet):** `scripts/fixed-sleeps.ts` classifies sleeps. These are not counted: pauses inside loops bounded by a clock, a deadline or a try count (`i < 40`, `i < MAX_TRIES`; a plain `i < list.length` is not a bound; loops that assert, throw or break on a deadline also count as bounded), timeouts inside `Promise.race`, and `setTimeout(r, 0)`. A sleep marked `// fixed wait: <why>` (a reason is required) is also skipped. A local sleep helper, a `Bun.sleep` alias and `node:timers/promises` `setTimeout` count at each call, not at the definition. `scripts/fixed-sleeps.json` records the current count per file (175 sleeps in 33 files). When `origin/main` is available locally, the test also fails if the JSON rose above the copy where the branch left main (any entry or the total); CI's shallow checkout skips that comparison, so `.github/REVIEW.md` asks reviewers to check it and the allowlists. A renamed file moves its entry with the same count. A file may not exceed its baseline. When a count drops, the test fails until the baseline is lowered, so the number cannot grow back. A file not listed may have no fixed sleeps. Media takes (`scripts/film/`, `scripts/readme-media/`) are excluded because their timing is set by hand. - **Demo transport:** every `/api/...` literal in shipped server code must have an answer in `site/demo/transport.ts` (an exact path or a `startsWith` prefix). Every `ClientMessage` frame type must have a `case` in the demo socket. Thirteen routes the demo leaves to its catch-all 404 are listed in `DEMO_404`, each with a reason; a new entry is only for a route the browser never asks the demo for. `pty-ack` is listed in `DEMO_IGNORES`. The test fails on an entry that is now answered or that no longer exists. Shared helpers are in `scripts/source-scan.ts`: glob, read, comment stripping, and `fail(offenders, instruction)`. ### Docs The AGENTS.md lines for these rules, and two lines in src/AGENTS.md, now name the test that enforces them. No rule was removed. The DESIGN.md line no longer says that no test checks it. ### Skipped - **`shared/herdr-api.generated.ts` hand edits:** `generate:types --check` already runs in `check fast` (`scripts/check.ts`). It regenerates the file from the committed `scripts/herdr-schema.json` and fails if the committed file differs. No CHANGELOG entry: these are test and process changes only. ### PRs this will break Branch protection has strict OFF, so these open PRs keep green runs made before the guards existed. Each fails a guard once merged onto main with this PR (checked with `git merge-tree` of origin/main and each of the open PRs, the guard files laid over it). After this PR merges, merge main into each and re-run CI before merging; do not merge them on a stale green run. - devswha#942: demo transport, `/api/pane/prompt/filter` has no answer in `site/demo/transport.ts`. - devswha#936: error bodies, `Response.json(...)` at `server/index.ts` (dictation endpoints). - devswha#859: fixed sleeps, `server/index.ts` 9 vs 8, `server/pending-input.contract.test.ts` 3 vs 2, `server/submit.contract.test.ts` 7 vs 6. ## Validation - `bun run check fast`: workflow syntax, generated types, typecheck, build, unit (3044 pass, 0 fail, 8 skip in 229 files). - Review follow-up mutations (reverted): an icon `?v=old0` in `src/App.tsx` fails; a `public/icons/.DS_Store` and a `CACHE_NAME` bump alone pass; `jsonResponse({ error: { message: "…" } })`, `jsonResponse({ ok: false, error: "nope" })`, `net.createConnection({ path })` via a default import, `Bun.connect(opts)` and `createRequire(import.meta.url)("@lydell/node-pty")` in `server/auth.ts` each fail. - Mutation check: I added a violation for each guard in turn (a CSS literal and `!important`, a changed `--bg`, `"New session"`, an extensionless import, an unbounded `Bun.sleep`, `jsonResponse({ error: "…" })`, `Response.json`, a unix `Bun.connect`, a `node-pty` import, `sendNotification`, `subscribeEvents` outside the collector). Each guard failed with its instruction, and I reverted the violations. - Each scanner also has inline self-tests for its classifier (CSS values, import specifiers, error-body shape, node-pty load forms, bounded poll vs fixed sleep). 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
devswha#951) ## Change This branch carries two things that were built on top of each other: 1. The large-file, diff and chat rendering base (the four earlier commits): React 19 with source line navigation, bounded virtualized source previews, a local and remote repository diff viewer, and a virtualized chat history with source search. 2. Ports from [t3code](https://github.com/pingdotgg/t3code) (MIT, revision `e82a1ce`), adapted to a client that only bridges herdr's pty. Each adaptation is listed under "T3 Code" in `THIRD_PARTY_NOTICES.md`. What a user sees from the ports: - **Chat**: day-aware timestamps, smooth live follow, animated work blocks, a full-screen image viewer with copy and save, Markdown and HTML on copy, skill chips, the files a turn edited, the scroll position kept per conversation, a turn minimap, long user messages folded to twelve lines. A work row reads as the agent's own summary or description of the call instead of the tool id. - **Composer**: the card's model group as divider-separated text with a working-folder strip under it, a prompt stash, a notice stack, Compact beside the context ring, a message at the length limit, terminal selections quoted into the draft. - **Files, diff, palette, sidebar**: Changes as a folder tree, file-type glyphs, path copy and a header skeleton in the diff viewer, one diff worker pool, Settings pages, aliases and folder browsing in the palette, a project's favicon in the sidebar. - **Settings**: separate Light and Dark palettes under System, contrast, font smoothing and panel motion, an installed-font picker, interface and code fonts, shortcuts recorded by pressing them. Not in this PR: the plan card (no real plan-mode transcript to confirm the payload), new-turn anchoring, inline HTML replies, prompt links, a toast stack, a tooltip primitive, the older-turn divider and the haptic cue. The composer strip shows the folder only, not the branch. HTTP additions: `GET /api/workspace/diff/files` and `GET /api/workspace/diff/file` for the repository diff viewer (types in `shared/protocol.ts`, proxied to a remote PC through `MACHINE_PROXY_PATH`, gated as read-only). No WS shape changes. `server/transcript-records.ts` changes only which text a tool call's `summary` carries. Toolchain: this PR carries the Vite 8 / TypeScript 7 / `bun --bun vite` move from devswha#830 by @todor-a (credited in the changelog fragment). It supersedes devswha#830, which should be closed when this merges so the toolchain entry is not folded twice. Known, left for follow-ups: folding a work block near the end of a conversation moves its header by 6px (10px on a phone); the review's nine notes (stale "today/yesterday" after midnight, unthrottled scroll-position reads, and others). ## Validation Run on macOS (Apple silicon, Bun 1.4.0). - `bun run typecheck`: exit 0, after merging `main`. - `bun run test:unit`, after merging `main`: 3277 pass and 5 fail; the one failure the merge caused (`server/repo-diff.test.ts`, first-cookie confirmation) is fixed in the merge commit and passes. The other four fail on `main` on this Mac too and were not checked on Linux: three in `scripts/check.test.ts` (the temp directory makes the socket path longer than 100 characters) and one in `server/voice.test.ts` (the uploaded file's name). - Browser scripts, run before merging `main` and not again after: `chat-virtual-browser-qa`, `repo-diff-browser-qa`, `virtual-source-browser-qa` and the `composer-fit`, `settings-pages`, `chat-greeting`, `font-swap`, `deep-link`, `palette-machines`, `keyboard-viewport` and `held-rows` demo regressions all exit 0. Three of them were changed to follow the new behavior: the fixture server answers `/pane/commands`, the greeting check reads which surface a touch reaches, and the font-swap check sets its fallback through the interface font setting. - Screens looked at in Chromium at 1440x900 and 390x844: the chat features, the composer, dialogs and Settings, and a walk of the whole demo app in dark, light and Korean. - `src/lib/i18n.test.ts`, `scripts/changelog-fold.test.ts` and `scripts/ci-browser.test.ts` pass after the merge. ### Review follow-up (Linux, this session) Fixes from a review of the branch, on top of the main merge: - File viewer: a large text file whose read hits the deadline shows what arrived as a cut prefix ("Only part of this file is shown") instead of an error; the request and the read share one deadline. - Composer: the saved-drafts popover opens toward the inside of the card, so it stays inside the window above 640px. - Chat Find: Enter on the find bar's buttons presses them (only the field's Enter steps matches), and Safari's IME commit Enter (keyCode 229) is ignored. Mod+F is Cmd on macOS and Ctrl elsewhere, so macOS Ctrl+F keeps moving the caret. - Image viewer: held by ChatView instead of the virtualized row, so it no longer closes when its row leaves the render window; its bars (and the diagram viewer's) clear the safe areas. - Smaller: a terminal quote names no line range when only the visible text was read; the highlight worker pool keeps 12 results per cache; smooth follow settles without `scrollend` on older Safari (a debounced scroll fallback, not exercised by Chromium); the developer-facing `openPalette(query)` changelog entry is dropped. Run on Linux (x64, Bun 1.4.3), at b66f80d: - `bun run check fast`: all steps ok (typecheck, build, 3345 unit tests pass, 0 fail). - browser-main as CI runs it (`ui-regression`, `ui-contexts-regression`, 2 workers): both pass. - browser-other as CI runs it (4 workers): 44 pass, 2 failed. `file-preview-text-browser-qa` expected the stalled body to fail; it now expects the cut prefix and passes. `narrow-pane-demo-regression` fails the same way on this PC at `origin/main` (5a4bfb3) and before these fixes (d648276): the font metrics here give 82 columns at 13px; it passed on CI's runner. - New browser assertions: the saved-drafts popover's box at 390, 800 and 1280px (`composer-fit-demo-regression`), and Enter on Previous match / Close search (`chat-virtual-browser-qa`); both pass. --------- Co-authored-by: Todor Andonov <todor.andonov@payhawk.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
## Problem On Android Chrome before 144, dragging down in a chat too short to scroll (a new or short conversation) starts Chrome's pull-to-refresh and reloads the whole app, dropping held drafts and restarting the terminal client. devswha#427 measured it 3 of 3 on Chrome 133. ## Cause devswha#413 put `overscroll-behavior: contain` on `.chat-view`, but Chrome before 144 ignores the property on a scroll container whose `scrollHeight == clientHeight`, so the drag chains to the page. `html` and `body` were `overscroll-behavior: auto`; nothing at the page root contained it. ## Fix `overscroll-behavior-y: contain` on both `html` and `body` in `src/styles.css` (option 2 in devswha#427). devswha#427's experiment on Chrome 133 found `html` alone still reloaded and `html` + `body` did not, so both are set. This only stops overscroll chaining / pull-to-refresh at the page root; it does not change what scrolls, and every existing scroll container keeps its own rule. ## Tests - `scripts/mobile-viewport-regression.ts` (`checkMobileViewport`, run by `keyboard-viewport-demo-regression.ts` and the `ui-contexts` checks) now asserts the computed `overscroll-behavior-y` of `html` and `body` is `contain`. - `bun run check run bun scripts/keyboard-viewport-demo-regression.ts`: passes with the fix; fails on the new assertion with `src/styles.css` reverted. - `bun run typecheck`, `bun run test:unit` (3069 pass, 0 fail), `changelog-fold.test.ts` and `conventions.test.ts` pass. ## Notes - Closes devswha#427. A real Chrome 133 drag was not re-run here: the browser test checks the computed style, the effect itself comes from devswha#427's emulator measurement. Untested, as in devswha#427: a real phone, the installed home-screen app, Samsung Internet. - Side effect by design: pull-to-refresh is off for the whole app on every browser, including newer Chrome. - No open PR touches the root `html, body` rule; devswha#943, devswha#909 and devswha#887 edit `src/styles.css` elsewhere. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…me layout reads (devswha#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: devswha#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>
…wha#966) ## 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, devswha#712). - If the masked input is pending but the socket is down, the reserved row stays empty instead of disappearing. - Overlap: - devswha#936 (dictation backend) edits `TerminalInput.tsx`/`.css`, but not the layout this changes. Expect a small textual merge in the CSS header. - devswha#945 and devswha#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>
…aging keys repeat (devswha#965) ## Problem Two small touch-input papercuts in the terminal lens: 1. **Autocorrect in the input line.** On iPhone the terminal input line (input-line mode) leaves iOS Auto-Correction and spell-check on. Typing a command with a non-dictionary token (a flag, path or branch name) and pressing Space can let the keyboard swap it for a dictionary word, and Send sends the rewritten command. Every other command or text field (composer, queue editor, key-bar settings, secret input, Portal panel) already turns autocorrect off. 2. **Held key-bar Backspace fires once.** Holding the key-bar ⌫ deletes one character; clearing a line takes one tap per character. The hold-to-repeat added in devswha#758 covers only the four arrows. Same for Del, Home, End, PgUp and PgDn. ## Cause 1. `src/components/TerminalInput.tsx`: the textarea sets only `autoCapitalize="off"`; no `autoCorrect="off"`, no `spellCheck={false}`. Its doc comment even listed autocorrect as a rewrite the line exists to absorb. 2. `src/lib/keyRepeat.ts`: `isRepeatableKeyBarItem` returns true only for `ArrowUp/Down/Left/Right`. `KeyBar` wires press-and-hold only for repeatable items, so every other key is a plain click. (devswha#718 left "should Backspace, Delete, PageUp, PageDown repeat too?" as an open question; devswha#758 shipped arrows only.) ## Fix Two commits: 1. `TerminalInput` textarea gets `autoCorrect="off"` and `spellCheck={false}`, matching `Composer.tsx` and the queue editor. The doc comment now says autocorrect stays off and why. 2. `isRepeatableKeyBarItem` uses a set: the arrows plus Backspace, Delete, Home, End, PageUp, PageDown, with or without saved modifiers (like the arrows). Escape, Tab, BackTab, Enter, Insert, ^C/^D/^Z, F-keys, printable keys and modifier buttons stay single-shot: a repeated Tab or ^C would act many times on what one press meant. Delay and rate are unchanged (400 ms / 50 ms). ## Tests - `src/lib/keyRepeat.test.ts`: the positive case now covers the six editing/paging keys (bare and with saved modifiers); the negative case keeps Escape, Tab, BackTab, Enter, Insert, ctrl-c, ctrl-d, F1, printable keys and modifiers. Fails without the fix (1 fail), passes with it. - `scripts/key-bar-hold-repeat-regression.ts`: new step 5b holds the key-bar ⌫ and checks it repeats (the DEL byte on the input path, 11 sends in the run) and sends nothing after release. Without the fix the step fails ("got 1"). `bun run check run bun scripts/key-bar-hold-repeat-regression.ts`: all steps pass. - `scripts/terminal-input-regression.ts` (run inside `ui-regression.ts`): asserts the input line has `autocorrect="off"`, `spellcheck="false"`, `autocapitalize="off"`. `bun run check run --build bun scripts/ui-regression.ts`: pass (exit 0), including "PASS terminal input". - `bun run typecheck`: pass. `bun run test:unit`: 3069 pass, 0 fail (including the devswha#949 conventions guard; the new post-release wait carries a `// fixed wait:` note). - Native iOS autocorrect was not reproduced on a device; the fix relies on WebKit honouring `autocorrect="off"`, as the composer already does. ## Notes - Refs devswha#862 (held backspace does not repeat). This fixes the key-bar ⌫ case. The issue does not say whether the reporter held the key-bar key or the system keyboard's backspace; the soft keyboard's own repeat goes through xterm's textarea, not this code, so I did not use "Closes". - `DEFAULT_KEY_BAR_ITEMS` is unchanged: ⌫ is still opt-in through key-bar customization. Neither devswha#862 nor the docs ask for it in the default bar, and changing defaults touches settings migration and telemetry customization flags. - devswha#935 (opt-in composer autocorrect, open) touches `Composer.tsx` / `PaneTerminal.tsx` / `compose.ts` only; no file overlap. Its body says "the terminal input line keeps its current attributes"; after this PR those attributes are off, matching the composer default. I suggest its toggle should not cover the terminal line, since shell tokens are the worst case for autocorrect. - devswha#943 and devswha#946 (open) touch `KeyBar.tsx` but not the repeat logic or `keyRepeat.ts`; this PR does not edit `KeyBar.tsx`. - Changelog: one fragment, `changelog.d/terminal-input-autocorrect-and-key-repeat.md` (two Fixed entries). ## Merge-session review Fixed (3468ec3): - `docs/terminal-input.md` still said only the arrows repeat when held. It now lists the arrows, ⌫, Del, Home, End, PgUp and PgDn (saved combinations of these repeat too) and says Esc, Tab, Enter, ^C, F-keys and modifier buttons send once. - `docs/development.md` now describes `key-bar-hold-repeat-regression.ts` as covering holds of arrows and editing keys. Docs only, so there is no new regression test; `src/lib/keyRepeat.test.ts` and browser step 5b already pin the behavior. `bun run check fast` passes. The changelog fragment was already accurate. Deferred: none. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
devswha#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: devswha#945 (phone terminal scrollbar) does not touch the drag translation. devswha#946 and devswha#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>
…swha#961) ## Problem A render error outside the few places that catch one (chat turns, the chat lens, the file viewer, the release notes) unmounted the whole React tree and left an empty page. In an installed home-screen app on iOS or Android there is no browser reload button and no pull-to-refresh on the terminal, so the only way back was to force-quit the app. Nothing was logged under a name a bug report could point to. ## Cause `src/main.tsx` rendered `<SettingsProvider><App/></SettingsProvider>` straight into `createRoot` with no boundary. `RenderBoundary` was used only around chat turns, the chat lens, FileViewer and the release notes. App, the sidebar, the tab strip, the pane canvas, the terminal's own JSX, Settings and the dialogs had no boundary, and React 18 unmounts the root on an uncaught render error. ## Fix - `main.tsx` wraps the whole tree, `SettingsProvider` included, in `RenderBoundary`. Its fallback, `AppCrash`, uses the module-level `t` and no context, because a context may be what threw. It offers **Reload app**, **Copy details** (via `lib/clipboard.ts` `copyText`, which falls back to selecting the text on a plain-http LAN address) and the error text in a fold. - The sidebar (`MachineSidebar`), each pane slot (tab strip plus canvas, reset when another pane is picked) and the Settings dialog get boundaries of their own, so one broken region leaves the rest usable. Settings' fallback is a small dialog with Close that keeps what Settings gives a modal: the focus moves to Close and Tab stays inside (`useFocusTrap`), Escape closes it, and on a phone the sheet's swipe down closes it (`useSheetSwipe`). - When the active pane slot falls back to its notice, the notice reports the connection lost (`RegionCrash` `onShown`), since the unmounted terminal cannot; the header no longer claims live with no terminal attached. - `RenderBoundary` now passes the caught error to its fallback as a second argument. Existing callers are unchanged. - `window` `error` and `unhandledrejection` are logged to the console with a `herdr-web-ui:` prefix. Console only, no telemetry. - Test hook: `src/lib/crashProbe.ts` (`?crash=root|sidebar|pane|settings`) throws while rendering only when the build-time constant `__HERDR_TEST_HOOKS__` is true. `vite.config.ts` sets it from `HERDR_TEST_HOOKS=1`, which only `scripts/demo-build.ts` passes. `bun run build` and the published demo (`build-site.ts`) build with it off, so the check compiles out of production. - New strings have entries in ko, ja, zh and tr. Changelog fragment `changelog.d/render-error-boundaries.md`. `src/AGENTS.md` and `scripts/AGENTS.md` each get one line about the boundaries and the hook. ## Tests - New browser check `scripts/render-crash-demo-regression.ts`, registered in `ci-browser.sh` (lane count 45 to 46 in `ci-browser.test.ts`). On the demo build it checks five cases: - Without `crash`, no notice appears and there are no page errors. - `root`: AppCrash shows; Copy details puts the error on the clipboard; Reload app reloads the page. - `sidebar` and `pane`: only that region shows its notice, the rest is drawn, and Try again recovers once the region stops throwing. - `settings`: the dialog shows its notice, the app is drawn behind it, the focus is inside it and Tab/Shift+Tab stay there, Escape dismisses it, and in a second opening Close does. - A pane that throws after its terminal was live (switched to with `crash=pane` set): the header goes from live to reconnecting. - On a 390px touch phone (`isMobile`, `hasTouch`): `root` with the clipboard API absent and the copy command refused, where Copy details opens the details with the error text selected and Reload app is on screen; `pane`, where the workspace drawer still opens and Try again recovers. - `bun run check run bun scripts/render-crash-demo-regression.ts`: PASS. - Negative runs: root boundary removed from `main.tsx` FAILS (timeout waiting for the AppCrash heading); the `App.tsx` connection report reverted FAILS (timeout waiting for `.conn-reconnecting`); the Settings fallback's focus trap/Escape reverted FAILS (focus never enters the dialog). - `bun run typecheck`: pass. The new script, which is outside `tsconfig.tools.json`, was also typechecked with a temporary config: pass. - `bun run test:unit`: 3069 pass, 0 fail, 8 skip (rerun after the review fixes). - `bun run build`, then grep: `crash probe` is absent from `dist/`. - Not run: `check full` and `check concurrent`. The phone sheet swipe on the Settings fallback uses the same `useSheetSwipe` hook as Settings and is not exercised by the script. ## Notes - Overlaps with open PRs: - devswha#951 touches App.tsx, SettingsDialog.tsx, src/AGENTS.md, scripts/AGENTS.md, ci-browser.sh, ci-browser.test.ts and demo-build.ts. It is not a duplicate: it adds `RenderBoundary` only inside chat and diff rendering. - devswha#917 and devswha#822 also add browser scripts, so the `toHaveLength` lane count in `ci-browser.test.ts` will conflict with whichever merges second. - devswha#909, devswha#890, devswha#917 and devswha#822 touch App.tsx near the sidebar and slot JSX. - The `crashProbe` check compiles out, but the `<CrashProbe>` elements stay in production as components that render null. - The pane boundary covers a split half (tab strip plus canvas), not each herdr pane, so a sibling in a herdr split inside the same half goes down with it. A per-pane boundary in `PaneCanvas.renderPane` would be the finer follow-up. - The browser console already prints uncaught errors. The listeners add a greppable `herdr-web-ui:` prefix. - The lane's shared demo build now has the inert probe compiled in, so every demo script runs on that build. - Left out: reporting crashes to telemetry, and other dialogs and the palette, which are still caught by the root boundary only. ## Merge-session review Fixed in 5ff418f: - The Settings crash fallback ignored `overPreview`: with a file preview open (Settings opened by its shortcut), the notice was drawn under the preview while its focus trap held the keyboard. `SettingsCrash` now takes `overPreview` and uses the `settings-over-preview` layer, as Settings does. - The fallback recorded no Settings entry in the history: if Settings threw on its first render, the system Back button left the page or the installed app; if it threw after a phone opened a page, the first Back did nothing visible. `SettingsCrash` now records one Settings level (`recordSettings([{ page: null, keyBar: false }])`), so one Back closes it. - `render-crash-demo-regression` adds three cases, each confirmed to fail without its fix: the hit test on Close over a file preview, Back on a desktop and a phone after a first-render crash, and one Back after a phone crash on an opened page. Deferred: none. Checks: `bun run check fast` passed; `render-crash-demo-regression` passed (run directly, since `check run` admission was busy). 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…input (devswha#956) ## Problem In direct typing, input the terminal cannot take yet (socket down, or the pane not input-ready after a phone resume) is held for review in the "Input held until the terminal is ready" banner. The held text did not match what was typed: - `lss`, Backspace, ` -la` was held as `lss -la`: the deleted character came back. - `ls`, Enter, `pwd` was held as `lspwd`. - A multi-line paste, raw (`a\rb`) or bracketed (`\x1b[200~a\nb\x1b[201~`), vanished whole with no notice. ## Cause `applyToDraft` (src/lib/draft.ts) kept a chunk only if it held no C0/C1 control at all and otherwise returned the draft unchanged with no flag. DEL, BS, CR and LF were all treated like arrows and xterm's automatic reports. ## Fix - A leading run of DEL / BS in a chunk deletes that many code points from the held text (`Array.from`, so an emoji or Hangul syllable is never split). Text after the deletions in the same chunk is folded in as usual. - A chunk (or the inside of a bracketed paste) with CR/LF keeps its text, with a single space where each run of line breaks was, and sets a new `leftOut` flag. The held text never contains a line break, so Send still never presses Enter. - The banner shows a new notice when `leftOut` is set: "An Enter or line break was left out: Send never presses Enter." (ko/ja/zh, and tr since that locale lists the neighbouring strings). `draftIsEmpty` counts the flag, and `restoreDraft` reads it back (anything but `true` reads as false, so older records restore unchanged). - An Enter or a run of line breaks with no text held changes nothing (no empty notice, nothing stored). - A tab inside a bracketed paste is held as a space (a typed Tab stays out: it is completion). A paste, or a multi-line chunk, that still has other controls sets `truncated` as well, so the notice says text was left out instead of implying it was kept. - Once `truncated` is set, Backspace no longer edits the held text: it may be meant for the chunk that was dropped. - Other typed controls (arrows, Tab, Ctrl keys, CPR/focus/mouse reports) stay silent, as before. ## Tests - `src/lib/draft.test.ts`: `ab` + `\x7f` -> `a`; `\b` likewise; empty + `\x7f` -> unchanged; surrogate/Hangul deletion; `\x7f\x7f\x7fthe` replaces; `ls`,`\r`,`pwd` -> `ls pwd` + `leftOut`; `a\rb` and `\x1b[200~a\nb\x1b[201~` -> `a b` + `leftOut`; restore of the flag; bare Enter / empty paste -> `EMPTY_DRAFT`; bracketed `a\tb` -> `a b`; raw `a\tb\nc` and bracketed `a\x1bb` -> `truncated`; Backspace after a loss keeps the held text. - `scripts/terminal-input-regression.ts` (via `ui-regression.ts`): in the not-ready state, a bare Enter shows no left-out notice, `b` + Backspace removes the `b`, Enter after text shows the notice, and after reload both the held text (`a `) and the notice are back; Send sends `a `. - Ran: `bun run typecheck` (pass), `HERDR_TEST_MODE=unit bun test ./src/lib/draft.test.ts` (23 pass; 5 fail on the first commit's draft.ts), `bun run test:unit` (3076 pass, 0 fail), `bun run build` + `bun run check run bun scripts/ui-regression.ts` (all PASS). ## Notes - Design choice: the space where a line break was is a character the user never typed. It keeps `ls` Enter `pwd` readable as two words instead of `lspwd`, and the notice says a break was left out. The alternative (keep `\n` in the draft and have Send deliver it as a bracketed paste) was not taken: it changes what Send writes and depends on the program's paste mode. - A lone Enter with nothing held shows nothing, as before. The flag is for Enter/line breaks only, not the "special keys dropped" count that was removed earlier (devswha#561). - Known limits, left out on purpose (the banner shows the held text before Send): Backspace right after a break deletes the space that stands for it (fixing it needs new stored state); arrows that move the cursor, Ctrl+U, Ctrl+W, Alt+Backspace and Cmd+Backspace are not followed; a chunk with DEL after text is dropped whole; a raw single-line chunk with a control is still dropped silently, since it cannot be told from typing. The changelog names only the Backspace key. - Overlap: devswha#951 reworks the held-input banner into an activity row and reads `draft.truncated` there. Whichever merges second needs to carry the `leftOut` notice into the new row. No open PR touches `src/lib/draft.ts`. - Source: mobile-terminal stability audit finding input-3. ## Merge-session review Fixed in ca92b54: - Medium: a typed ESC CR (physical Shift+Enter or Alt+Enter) went through the line-break branch and set both `truncated` and `leftOut`, so the draft showed the "too long" notice and later Backspace stopped editing it. A typed chunk containing ESC is now treated as a key sequence again: neither held nor told. Tests: `\x1b\r` / `\x1b\n` -> `EMPTY_DRAFT`; `ls`, `\x1b\r`, `\x7f`, `x` -> `lx` with no flags (both failed at f9b902c). - Low: Backspace that erased all held text kept `leftOut`, leaving an empty banner with only the line-break notice (and saving it). Erasing everything held now returns the empty draft. Test: `ls`, `\r`, three `\x7f` -> `EMPTY_DRAFT` (failed at f9b902c). - Low: the truncated notice blamed length even when a paste with control characters set it. The string is now "Some input could not be held and was left out." in en/ko/ja/zh/tr; `src/lib/i18n.test.ts` checks every locale has it. The changelog fragment was updated to match. Deferred: - Coordination with devswha#968 (stacked on devswha#967): on restack after this PR merges, devswha#968's `eraseFromDraft` expectations need `leftOut: false`, its Backspace erase should reuse this PR's ERASE path instead of a second implementation, and `heldInputAction` must check line breaks and ESC CR before its `applyToDraft(...) !== EMPTY_DRAFT` "hold" test if blocking stays the contract (update src/AGENTS.md to match). Its existing `"line1\nline2"` / `"\x1b\r"` -> `block` tests catch a naive merge. Not a defect in this PR alone. - devswha#951 overlap: the truncated notice's English key changed, so whichever of devswha#951 and this PR merges second must use the new key. Checks: `bun run check fast` (typecheck, build, 3078 unit pass), `CI=1 bun run check run --build bun scripts/terminal-input-regression.ts` (PASS). 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…PCs (devswha#958) ## Problem - On Android Chrome or a desktop browser, tapping an agent alert could bring forward a Markdown reader tab (`/?read=…`) or a raw file tab opened earlier from the file viewer. The pane named in the alert never opened. - An alert (still in the notification center) or a remembered selection (reload, new window) that names a PC removed since left an empty pane area with no explanation. An alert for a pane that has closed switched to herdr's focused pane without saying anything. - Review follow-ups (second commit): the worker's second delivery of one tap could strand a closed pane's lookup; a tab resumed with an old roster dropped a tap for a PC set up since; a just-added PC could be reported missing by a roster rebuilt from a pane status; shipped files such as `/social-preview.png` still counted as app windows; and a closed pane's tap always said its workspace had closed. ## Cause - `public/sw.js` `notificationclick` picked `windows.find(focused) || windows[0]` without checking `client.url`. `matchAll` lists the most recently focused window first, so the reader tab got the `select-pane` message. It renders `FileReader`, not `App`, so nothing consumed the message, and `focus()` succeeded, so `openWindow` never ran. - `src/App.tsx` handled "PC missing" and "pane closed" only for address links (`pendingLink`, `resolveLink`, `setHeaderNote`). Notification targets went straight to `selectTarget`, which also cleared `pendingLink`. That includes the worker's `selectLatest` repeat on a cold start, so a cold start's link could lose its machine-missing handling as well. A stored selection never set `pendingLink`. - Review follow-ups: the link resolver only re-ran on snapshot or selection changes, and dropped a lookup whose `pendingLink` object had been replaced, so the worker's second identical delivery stranded it. The tap handler decided "PC missing" from whatever roster the tab held, and the selection check ran on any `machines` change, including lists rebuilt from a pane status before `load()` brought a just-added PC. The alert carried no workspace, so `resolveLink` could only answer workspace-closed for a closed pane. ## Fix - `sw.js`: new `isAppWindow(url)`. Only the app shell's paths count: it rejects `?read=` readers, `/api/` responses (raw files and HTML pages), `/ws`, and any path whose last part has an extension other than `index.html` (icons, `/social-preview.png`, the manifest, `/sw.js`, notices). `notificationclick` only considers app windows, and opens the app when none is left. `CACHE_NAME` is unchanged because no icons changed. - Push payload: `PushPayload.workspace_id` (optional, additive). `server/push.ts` records each pane's workspace in `seed()` beside its title and sends it with status and ended alerts. `sw.js` keeps it in the notification data, posts it with `select-pane`, and puts it in the cold-start URL as `ws=`. - `src/lib/notificationTarget.ts`: `machineMissing`, `machineGone(roster, rosterSeq, since, id)` (a PC is gone only by a roster the server sent after it was named), `notificationLanding(roster, target)` (select, or a link carrying the alert's workspace, marked `stale` when the cached roster lacks the PC) and `sameLink`. - `App.tsx`, notification handler: - A pane the roster lists is selected as before. - Anything else is set as `pendingLink`, the same way popstate does, so it settles through `resolveLink` with its notes. With the alert's workspace, a closed pane opens its workspace's pane with the pane-closed note. - A second identical delivery keeps the pending link (and its lookup); a new link bumps `linkTick`, which the resolver effect watches, so it also settles when the pane is already selected. - A PC missing from the cached roster no longer settles at once: the link waits and `load()` asks for a fresh roster. - `App.tsx`, machine-missing effect: counts server rosters (`rosterSeq`, bumped in `load()` and the `machines` event, never for pane-status rebuilds) and decides only from one sent after the link or selection was made. A remembered selection (reload, new window) is checked against the first roster, and the selection keeps being checked against each newer one (a PC removed elsewhere while selected); "Open PC" before `load()` is checked against the roster `load()` brings. A `rosterTick` state re-runs the check when a roster comes in while a link waits, even if the list is unchanged (no re-render per poll otherwise). ## Tests - `src/pwa-notification.test.ts`: - `isAppWindow` cases, now including `/social-preview.png`, `/THIRD_PARTY_NOTICES.md`, the manifest, `/sw.js` and `/favicon.ico`. - The push handler keeps `workspace_id` in the notification data; a tap posts it and a cold start opens `/?machine=…&ws=…&pane=…&view=chat`. Both fail on the previous `sw.js`. - A reader tab used last never gets focused or messaged, and the app window takes the pane. - With only reader or raw tabs open, the app opens on the pane. - Existing tests now give clients a URL. All 17 fail without the `sw.js` change. - `src/lib/notificationTarget.test.ts`: `machineMissing`, `machineGone` (a roster no newer than the naming never settles; one after it does), `notificationLanding` (a PC the cached roster lacks waits as a stale link, known pane, unknown roster/panes/pane, the alert's workspace with `resolveLink` giving `pane-closed`), `sameLink`, and `workspace_id` parsing in `notificationTargets`. - `server/push.test.ts`: status and ended alerts carry the seeded `workspace_id`. - `scripts/notification-view-regression.ts`: an ended pane's alert delivered twice while the session lookup is held (`page.route` on `/api/session`, released after the second tap) lands on its workspace's pane with "That agent has closed: its workspace is open". Run alone through `bun run check run`: passes with the fix, times out on the note with the dedupe and `linkTick` taken out. - First commit: `bun run build` then `bun run check run bun scripts/ui-contexts-regression.ts`: pass. After the review follow-ups: `bun run build` then `bun run check run bun scripts/ui-contexts-regression.ts` (includes `checkNotificationStartup`, which pins the `select-pane` message shape, and `checkNotificationView`): pass. - `bun run check fast` (workflow syntax, generated types, typecheck, build, unit tests: 3080 pass, 0 fail): pass. ## Notes - devswha#957 (service worker cache per build) also edits `public/sw.js`, in the cache and fetch code; this PR edits the push and notificationclick handlers and no longer calls `isCacheFirst`, so the two should merge with at most a textual conflict. Otherwise no open PR touches `public/sw.js`, `src/lib/notificationTarget.ts` or `src/lib/deepLink.ts`. Several open PRs edit `src/App.tsx` (devswha#951, devswha#917, devswha#909, devswha#890, devswha#822), but none in the notification or machine-missing code. - Wording: the notes reuse the existing link strings, so no new i18n entries. A reload of a removed PC also shows "That link's PC is not set up here"; a separate non-link wording is left out. - An alert from a server before this change, or for a pane that alerts before the server's first `seed()` saw it, has no `workspace_id`: its closed pane still gets the workspace-closed note and herdr's fallback pane. - A tap for a PC the tab's roster lacks selects that PC while a fresh roster is fetched, so the pane area is empty until it arrives (the same state as a reload remembering a removed PC); a failed fetch is retried by the next poll. - Left out: the push handler's `watching` check still counts any visible window, a reader tab included; a split view's other half on a removed PC is not handled here. ## Merge-session review Fixed (2be0fd0, 3b87dfc): - **Ended pane still in the cached roster** (medium): a tap on a pane the resumed tab still listed selected it and dropped the alert's `workspace_id`; when the next roster removed the pane, the app fell back to herdr's focused pane with no note. The tap now keeps the alert's workspace as a hint with the selection (cleared by any other pick); once the selected pane is confirmed gone against `fetchSession`, the hint resolves through `resolveLink`: the workspace's pane opens with "That agent has closed: its workspace is open". Regression in `scripts/notification-view-regression.ts`: the pane is listed at the tap, closed through herdr afterwards, with herdr's focus in another workspace; it times out without the fix. - **Tab alerts without `workspace_id`** (medium): with push off and alerts on in this tab, `showPaneStatusNotification` / `showPaneEndedNotification` now put the pane's `workspace_id` in the notification data, and the worker-less fallback click goes through the same landing as a worker's tap. Unit test in `src/lib/notifications.test.ts` (fails without the fix). - **Remove PC on the selected PC** (low): the server tells the roster before it answers the DELETE, so the selection check showed "That link's PC is not set up here" for the user's own removal. `MachineSidebar` now selects this PC before the request and restores the selection if the request fails. `scripts/machine-conflict-regression.ts` gains an event stream and a DELETE that tells the roster first; it fails without the fix. - **Unbounded wait in the browser check** (low): the `/api/session` lookup wait races a 4 s timeout, and `release()` / `unroute` run in a `finally`. Deferred: - An old server's push (or a pane that alerts before the server's first `seed()`) with no `workspace_id`, whose pane alone is gone still shows "That link's workspace is closed"; settling such a tap silently is a wording/policy choice, left as in the Notes above. Checks: `bun run check fast` (3081 pass, 0 fail); `CI=1 bun run check run --build bun scripts/ui-contexts-regression.ts`: pass; `CI=1 bun run check run --build bun scripts/machine-conflict-regression.ts`: pass. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…evswha#962) ## Problem On a phone in input-line mode (the touch default), the whole key bar goes grey and ignores taps while a word is still composing in the input line. With Gboard that is most of the time, because every word composes until a space; with a Korean or Japanese IME it lasts until the syllable or phrase commits. Esc and ^C cannot stop an agent, an arrow cannot answer a menu, Ctrl/Alt/Shift cannot be armed, and the keyboard-mode toggle is dead. ## Cause `PaneTerminal` passed `onComposing={setComposing}` to `TerminalInput`, so the input line's `compositionstart`/`compositionend` wrote the same `composingRef` and `composing` state that guard xterm's deferred IME commit. That flag disables `<KeyBar>` and short-circuits `pressKey`, `toggleModifier` and `toggleDirect`. The guard exists so a key-bar key cannot overtake xterm's pending commit, but the input line's text never goes through xterm or `onData`: it is submitted separately with `socket.submit`. There was nothing to protect, only taps to drop. ## Fix - `PaneTerminal.tsx`: stop wiring `TerminalInput`'s composition into `setComposing`. The xterm host `compositionstart`/`compositionend` listeners (and the pane reset) stay the only writers of `composingRef`/`compositionCommitPendingRef`, so the xterm deferred-commit gate is unchanged. - `TerminalInput.tsx`: remove the now unused `onComposing` prop and its unmount cleanup. The line keeps its own local `composing` ref that guards its Send button and Enter. - `docs/terminal-input.md`: the key bar waits only for a direct-typing (xterm) composition; an input-line composition leaves it live. - Review follow-up (ca26dd6): the direct-typing toggle is the one key that still waits for an input-line composition. Switching mid-word would unmount the line with the IME composition open, and the word could be lost or typed into the pane. `TerminalInput` reports its composition through `onComposing` again. `PaneTerminal` tracks it apart from the xterm composition, `toggleDirect` returns early on it, and `KeyBar` gets `directDisabled`, which greys out only that key. ## Tests - New phone block in `scripts/terminal-input-regression.ts` (390x844, touch, line mode): fill the line, dispatch `compositionstart` on `.terminal-input-text`, assert Esc, ^C and Ctrl are enabled, tap Esc and ^C, expect exactly two `input` frames `\x1b` and `\x03`, and the line's value unchanged. - `bun run check run --build bun scripts/terminal-input-regression.ts`: PASS (all three blocks). With the two source files reverted the new block fails on "Esc stays live while the input line composes". - `bun run typecheck`: clean. `bun run test:unit`: 3069 pass, 0 fail, 8 skip. `scripts/changelog-fold.test.ts`, `scripts/pages-gate.test.ts`, `scripts/conventions.test.ts`: pass. - Not run: `scripts/ui-regression.ts` as a whole (it calls `checkTerminalInput`, so CI runs the new block there too), `check full`/`concurrent`. - The phone regression also checks that `[data-key="direct"]` is disabled during the composition and that a forced tap on it leaves the line, its text and the pane untouched. Before the follow-up fix, that check failed with "the direct-typing toggle waits for the line's composition". ## Notes - No open PR changes this gate (checked the diffs of devswha#936, devswha#946, devswha#943, devswha#822, devswha#812 for `onComposing`/`setComposing`). devswha#936 edits `TerminalInput.tsx` and devswha#943/devswha#946/devswha#950/devswha#951 edit `PaneTerminal.tsx`/`KeyBar.tsx`, so a textual conflict is possible depending on merge order, but none duplicates this fix. - Left out on purpose: no separate gate on the key-bar Enter while the line composes. It sends `\r` to the pane, not the line's text, just as it does after a commit. The direct-typing toggle is gated (see the follow-up in Fix). - The regression uses synthetic composition events, so it certifies the gate, not a physical IME. - Gate added in devswha#334 (c50c881) for both inputs; this keeps it for xterm only. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
devswha#952) ## Problem Viewing a pane on another PC (`/ws?machine_id=…`, through `MachineRelay`) from a phone on a slow or changing cellular link, a burst of output during a ~2 s stall ended the stream with "Terminal output stopped because this device could not keep up.", while the same pane on this PC would have survived. ## Cause `server/machine-relay.ts` started its stall clock on **any** non-zero `getBufferedAmount()` and closed with 4008 after 2 s unless a 100 ms sample saw exactly 0. A slow phone link keeps a few KB queued in userspace even while output moves. The local bridge (`reconcileOutput` in `server/index.ts`) counts a client's socket as blocked only while 256 KiB or more is buffered. ## Fix - New `RelayStallClock` in `machine-relay.ts` with this PC's rule: the clock runs only while `OUTPUT_HIGH_BYTES` (256 KiB) or more is queued, clears on the first sample below it, and fires after `OUTPUT_STALL_MS`. The end-to-end ACK window is still enforced by the remote bridge (ACK frames pass through the relay unchanged), so the relay only needs a memory backstop. - A relay stall still closes with the explicit 4008, like this PC's stall: an overload is never auto-replayed. - Buffering past the 1 MiB hard budget still closes with 4008 at once; an upstream 4008 passes through unchanged and other upstream ends map to 1012 (`upstreamCloseCode`, same behaviour as before). ## Tests - New `server/machine-relay.test.ts` (bun:test): a few KB and just under 256 KiB queued for 3 s do not close (fails on main, where any byte counts); 256 KiB queued for 2 s closes with 4008; one sample below 256 KiB clears the clock; hard overflow is 4008; upstream close-code mapping. - `bun run typecheck`: pass. `HERDR_TEST_MODE=unit bun test ./server/machine-relay.test.ts`: 5 pass. `bun run test:unit`: 3074 pass, 0 fail, 8 skip. - No browser script run (server-only change). ## Notes - From a mobile-terminal stability audit (finding connection-3). Related to devswha#781 (4008 family), not a duplicate. No open PR touches `server/machine-relay.ts`. - An earlier revision of this PR closed a relay stall with 1012 so the phone would reconnect. Review showed that loops (the client resets its backoff on every open, so a recurring stall reconnected every 250 ms) and appends the bounded replay tail to the live terminal, which the 4008 path exists to avoid. That part was dropped; safe automatic recovery needs an offset-based resume and is left out. - Left out: the relay's threshold is on JSON-framed bytes while the remote's ACK window counts payload bytes, so the relay can still stop a little before the remote would. Same code and same user-visible result either way. 🤖 Generated with [Claude Code](https://claude.com/claude-code) ## Merge-session review Fixed: - The new tests only exercised `RelayStallClock`/`upstreamCloseCode`; no test built a `MachineRelay`, so restoring the old any-byte timer body (for example in a conflict with devswha#960) would have stayed green. The timer body is now `MachineRelay.tick(now)`, and two tests drive a real relay (throwaway upstream WebSocket server, stub phone socket): 8 KiB queued for 3 s does not close; `OUTPUT_HIGH_BYTES` queued for `OUTPUT_STALL_MS` closes with 4008. Mutation-checked: reinstating the any-byte rule fails the first, skipping the stall sample fails the second. (4d89851) Deferred: none. No user-visible change, so the changelog fragment is unchanged. Checks: `bun run check fast` passes (typecheck, build, unit tests). --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
devswha#954) ## Problem On a phone, the app could stay disconnected until a reload after waking: - A connect attempt stuck in `CONNECTING` (waking on a dead network, a tunnel that drops the handshake) never timed out, so no new attempt started. - `wake()` (visibilitychange / pageshow / online) did nothing unless a reconnect timer was pending. A page frozen in the background can come back with a socket already `CLOSED` whose close event has not run yet, so no timer is set and the wake was ignored. ## Cause `HerdrSocket.connect()` in `src/lib/ws.ts` had no deadline for the handshake, and `wake()` returned early on `reconnectTimer === null`. ## Fix - `CONNECT_TIMEOUT_MS = 8_000`: an attempt still `CONNECTING` at the deadline is closed and ended on the client side through the same path as a close event (`closed()`), which settles submits, fires disconnect handlers and schedules the normal backoff retry. The abandoned socket's late close event is ignored by the existing stale-socket guard. - `wake()` no longer requires a pending timer. A socket that is `CLOSING`/`CLOSED` with its close event still pending is ended first (so its submits and listeners settle), then `connect()` runs at once. An open or connecting socket is left alone; a stopped output (4008) and a disposed client still do not reconnect. - The deadline is cleared on open, on close and on `close()`. - No protocol change. ## Tests - `src/lib/ws.test.ts` (fake WebSocket + fake timers): - a socket stuck in `CONNECTING` is closed at 8 s, a new attempt starts after the backoff, the late close event changes nothing, and an open socket gets no deadline; - wake with a `CLOSED` socket and no timer connects at once; wake while connecting or open does nothing. - Both fail without the fix (verified by stashing `ws.ts`). - `bun run typecheck`: pass. - `HERDR_TEST_MODE=unit bun test ./src/lib/ws.test.ts`: 32 pass. - `bun run test:unit`: 3075 pass, 0 fail, 8 skip (after the review fixes). - No browser script run: the change is confined to `HerdrSocket` and covered by the unit tests. ## Notes - From a mobile-terminal stability audit (findings moshi-5, and the wake part of connection-1 / history-1). - Open PR devswha#932 (steering) also edits `src/lib/ws.ts` and `ws.test.ts`, but only the submit path (`SubmitDelivery`, `canSteerMessages`, `steering_id`); it does not touch connect/wake/close. Textual merge conflicts are unlikely; whichever lands second may need a trivial rebase of the test file's tail. - Left out: detecting a socket that reports `OPEN` but is dead (heartbeat), and anything server-side. - `src/AGENTS.md`'s WS client row records the deadline and wake rule. ## Review follow-up (670a259) - The connect deadline now doubles with each attempt given up (8 s, 16 s, capped at 30 s) and starts over once an attempt opens. A remote PC's upgrade waits up to 15 s for the machine relay (`server/machine-relay.ts`), so a fixed 8 s cut every such handshake. Test: a 12 s handshake connects on the second attempt. - `wake()` leaves a `CLOSING` socket to its close event: a server close with `OUTPUT_STALLED_CLOSE_CODE` (4008) still stops output, and a `submit-result` arriving during the closing handshake is still delivered. Tests for both orderings. - A `CLOSED` socket that `wake()` ends early (defensive; per spec readyState turns CLOSED in the task that fires close) is remembered: a late 4008 close from it closes the replacement and stops output. Test included. ## Merge-session review (f928acf) Fixed: - `wake()` (visible, pageshow, online) now gives up an attempt still `CONNECTING` after an earlier deadline already fired (its deadline has grown to 16-30 s) and connects at once; `wake()` also resets the deadline so the fresh attempt starts at 8 s. A first attempt still connecting is left alone. Before, a 30 s attempt started on a network that then came back kept the page disconnected for the rest of its deadline. Test: an escalated attempt is replaced on wake and the new one times out at 8 s. - A pending backoff retry is cancelled when `connect()` creates a new socket and when output stops (`stopOutput()`), and the retry callback returns on a stopped output or disposed client. Before (also on main after a plain 1006), a pane mount connecting inside the backoff window, then a 4008 on that replacement, let the old timer reconnect and replay attaches behind "Terminal output stopped". Test: a retry pending at 4 s does not open a socket after the replacement closes with 4008. - Both tests fail on the previous head (670a259). - Changelog fragment and the `src/AGENTS.md` WS client row updated to match. Checks: `bun run check fast` passed (typecheck, build, 3077 unit tests); `scripts/ui-contexts-regression.ts` (includes the composer reconnect check) passed. Deferred: none. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…bar (devswha#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: devswha#472 (the chat-lens version) and devswha#712 (an attach holds the pane's size). Neither covers this path. - No open PR touches `reclaim`, `sharedGridRef`, `sendTerminalLine`, `pressEnter` or `pressKey`. devswha#951, devswha#950, devswha#946, devswha#945, devswha#936, devswha#935, devswha#932, devswha#910, devswha#894, devswha#859 and devswha#822 edit other parts of `PaneTerminal.tsx`. devswha#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>
…ild (devswha#957) ## Problem A phone tab still running build N after a managed update to N+1 asks for an N-hash lazy chunk (KaTeX on the first math expression, Mermaid on the first diagram). The server no longer has it, and: - `serveStatic` answers any missing path, `/assets/*` included, with the shell (200 `text/html`). - `public/sw.js` cache-first keeps any `ok` + `basic` answer, so that HTML is stored under the JS URL. - `CACHE_NAME` has been the constant `herdr-web-ui-v4-ram` since devswha#473, and `keepShell` retires only caches with other names, so every release's bundles (main, katex, mermaid, font chunks once fetched) pile up in one cache that is never trimmed. That contradicts the design comment in sw.js ("no hashed file of an old build outlives the first navigation"). - `Markdown.tsx` keeps the rejected import promise (`??=`) for the page's life, so the math or diagram stays as source text, and nothing nudges the user to reload until the next 30 s update poll. ## Cause The SPA fallback also covered asset paths, the worker never checked content type, and the cache name never changed per build, so the per-name retirement design never ran. ## Fix - `server/static.ts`: a missing `/assets/*` or `/icons/*` path (or a directory there) answers 404 with no body, `no-cache` and the CSP header. Every other path still gets the shell. - `public/sw.js`: cache-first never keeps a `text/html` answer, and never serves or copies forward one an older worker kept under a file's URL. - Per-build cache: `sw.js` gets `const BUILD = "";` and uses `CACHE = BUILD ? \`${CACHE_NAME}-${BUILD}\` : CACHE_NAME`. A new Vite plugin, `scripts/service-worker-build.ts`, stamps `dist/sw.js` in `writeBundle` with a 12-hex id hashed from the build's `assets/` file names. Each release therefore ships a byte-different worker under its own cache name, and the existing `keepShell` retirement (the old cache stays until the new one holds a shell, so offline starts keep working) drops the previous build's files. The first stamped worker also retires the unstamped `herdr-web-ui-v4-ram` cache every earlier release shared. `CACHE_NAME` stays the part a person moves by hand (icons, fonts), so the icon convention test is unchanged. Dev mode and the unbuilt source keep today's behavior (`BUILD` empty). - `keepShell` copies the new shell's entry script and stylesheet (read from the shell's `<script src>` / `<link href>` under `/assets/`) into the build's cache, from the retiring cache when it has them, else from the network. The new shell is stored as `/` and the old caches are retired only once every one of them is there; until then the old caches and their shell stay as a complete offline start, and the next navigation that reaches the server finishes the copy. - Typefaces (`/assets/*.woff2|woff|ttf`) and icons/favicons live in `herdr-web-ui-files-v1`, a cache no build retires. `keepShell` moves those entries out of the caches it retires (including the unstamped `herdr-web-ui-v4-ram`), so an update does not cost a phone the Pretendard chunks it had cached. - `src/lib/lazyChunk.ts`: KaTeX and Mermaid load through `lazyChunk(load)`. It shares one promise and keeps a failure for the page's life (no retry: Chrome keeps a failed import in its module map, and Vite's preload helper never asks for a stylesheet twice, so a retry drew KaTeX without its CSS). A failure notifies listeners and sets `lazyChunkFailed()`. - `useUpdates` tracks that failure as its own state. `reloadReason()` (`src/lib/updateNotes.ts`) returns `"updated"` when the server's revision differs, else `"incomplete"` after a failed chunk, so the reload line also shows on unmanaged servers, older bridges, non-Git builds and same-revision rebuilds. The incomplete case has its own wording ("Part of the app could not load. Save unsent drafts, then reload.", ko/ja/zh/tr added). The status is still polled at once on a failure (at most every 5 s) so a real update is named. ## Tests - `server/static-encoding.test.ts`: a missing asset, an asset directory and a missing icon get 404 with no HTML. `/`, `/settings` and `/assetsx/…` still get the shell. Fails without the fix. - `src/pwa.test.ts` runs sw.js against the stand-in CacheStorage. These cases fail without the fix: - an asset answered with the shell is not kept; - an HTML entry an older worker kept is not served; - a stamped build's worker drops the previous build's files after its first navigation; - the unstamped shared cache is retired. The 404 case also passes on the old worker; it is there as a guard. - `scripts/service-worker-build.test.ts`: `buildId` is stable across order and keyed only on `assets/` names. The stamp replaces exactly one line and refuses a missing line or a non-hex id. - `scripts/compressed-assets.test.ts`: the real production build writes a stamped `dist/sw.js`, which checks the plugin hook order. - `src/pwa.test.ts` (review follow-up, all fail on the previous worker): the shell's files are carried over from the retiring cache with the network gone after the page; the old cache and its shell stay (an offline start gets the old, complete shell) while a shell file can be had from nowhere, and the next online navigation finishes; fonts and icons survive an update; the unstamped cache's fonts move to the files cache. - `src/lib/lazyChunk.test.ts`: one load while pending; a failure is kept (the loader is not called again) and told once; listeners isolated and removable. - `src/lib/updateNotes.test.ts`: `reloadReason` offers a reload after a failed chunk with no revision, no app revision or equal revisions, names the update when revisions differ, and waits while busy. - `scripts/math-browser-qa.ts`: new case where only KaTeX's stylesheet fails, then later expressions mount new components. With the retrying loader Chrome typeset them unstyled (assertion failed); with the fix every expression keeps its source. - Ran after the review fixes: `bun run typecheck` OK; `bun run test:unit` 3088 pass, 0 fail, 8 skip; `check run` of `math-browser-qa.ts`, `notification-startup-regression.ts` and `ui-contexts-regression.ts` (includes the update notice) passed. Earlier run: `bun run test:unit` 3081 pass, 0 fail, 8 skip. `bun run build` produced a stamped `dist/sw.js`. `bun run check run bun scripts/math-browser-qa.ts` passed (KaTeX fetched once, typeset, offline keeps the source). `bun run check run bun scripts/notification-startup-regression.ts` passed. `check full` and `check concurrent` were not run. ## Notes - Behavior change: every release now installs a new service worker (it was installed once and never changed). Existing `skipWaiting` + `clients.claim` handle that. Lazy chunks of a release are fetched once per release; font chunks and icons are kept across releases. - On a build's first navigation where no older cache holds its entry files, `keepShell` and the page may both fetch `index-*.js`/`.css` (the HTTP cache usually answers the second). - The files cache is not pruned: a font chunk whose contents change (a Pretendard or KaTeX upgrade) leaves its old copy behind. That is rare and bounded by the chunks a device has drawn. - A failed lazy chunk now needs a reload in every case (also a phone that was only offline): no in-page retry can work in Chrome, so the app says so instead. - Overlap: devswha#951 also edits `src/components/Markdown.tsx` (not the loaders), and devswha#936 edits `server/static.ts` (CSP comments, `cspHeader` export and the unbuilt-client response, not the fallback). Either may need a small textual merge. No open PR changes `public/sw.js`. - Source: mobile-terminal stability audit finding lifecycle-5. ## Merge-session review Fixed (commits 46c77c3, 20e0e90, b623f4b): - **Failed Mermaid diagram chunks gave no reload line.** Mermaid loads each diagram type from its own chunk inside `render()`, outside `lazyChunk`, and turns a failed load into a failed render. Vite's preload helper (Vite 6, which also wraps Mermaid's internal imports in the built bundle) dispatches `vite:preloadError` for every failed dynamic import. `lazyChunk.ts` now listens for it on `window` (`watchPreloadErrors`, `reportLazyChunkFailure`) without cancelling it. A diagram with a syntax error still offers no reload. - **The `?read=` reader tab never showed the reload line.** It now mounts `ChunkFailureNotice` (in `UpdateControls.tsx`), which uses the same i18n string and polls nothing. `FileReader` wraps it and the document in `.file-reader-frame`. - **The browser case did not cover the notice wiring.** The shared browser fixture now mounts `UpdateNotice` with `useUpdates(false)`. `math-browser-qa.ts` checks that a KaTeX load failing offline brings up "Part of the app could not load" with no reload until **Reload app** is clicked. A new case loads Mermaid and the flowchart chunk, checks that a parse error offers no reload, then aborts the `sequenceDiagram-*.js` chunk and checks that the line appears. The diagram case fails with the window listener removed. - **An update could delete the new build's lazy chunks that the old worker cached.** The build stamps its `/assets/` names into `dist/sw.js` as `BUILD_FILES`, beside `BUILD`. Typefaces and `.br`/`.gz`/`.map` files are left out; a real build lists about 67 names (2.7 KB). `keepShell` moves listed files from the retiring caches into the new one. The cache-first path copies forward only listed files. An unstamped worker (no list) behaves as before. - **A tab started offline could go blank when another tab retired its cache.** `keepShell` now also keeps the retired cache's own shell entry script and stylesheet in the new cache. A page that started from that shell and has not asked for them yet still boots, then gets the reload line for any later chunk. The cost is one old entry bundle per build cache, which goes when that cache is retired. The `clients.matchAll` deferral was not used: with a second tab open, old caches would never be retired. - Tests: `src/pwa.test.ts` has 2 new cases (build files moved and only those; retired shell's entry files kept), and both fail without the fix. `scripts/service-worker-build.test.ts` covers the list and the stamp, including nested asset paths, `..`, and refusing a quote in a name. `src/lib/lazyChunk.test.ts` covers the preload-event path and the parse-error non-report. `src/components/ChunkFailureNotice.test.tsx` covers the reader line. - Ran: `bun run check fast` OK (3093 pass, 0 fail, 8 skip). `math-browser-qa.ts`, `chat-history-browser-qa.ts`, `pi-image-browser-qa.ts` and `skill-activity-browser-qa.ts` passed (the fixture changed). `check run --build` of `file-viewer-regression.ts` passed. Deferred: none. Note: a failed KaTeX entry may now reach listeners twice (its own `lazyChunk` catch and Vite's event). `useUpdates` already limits status refreshes to one per 5 s, and the line is idempotent. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…queueing (devswha#960) ## Problem A phone on a weak mobile link (about 1 Mbit/s) opens, or comes back to, a busy pane that a desktop tab already shows. It gets "Terminal output stopped because this device could not keep up." (4008) within about 2 s, while it is still receiving. Every pane of that PC drops on the phone, and the desktop tab's output freezes for up to 2 s, because the server pauses the shared pty for the phone meanwhile. This is stability-audit finding moshi-3. ## Cause - The replay a late joiner gets is capped at `MAX_REPLAY_BYTES` = 256 KiB, the same as `OUTPUT_HIGH_BYTES`. Once the attachment has streamed more than that, the replay frame alone blocks the joiner's window. - `reconcileOutput` starts the 2 s stall clock at the first blocked check, which is when the frame is queued. The client can only ACK after the whole frame has crossed the link and xterm has parsed it, and with JSON escaping the frame is 290 KB or more on the wire. A slow link cannot do that in 2 s. - The clock never takes ACK progress into account. A client that is still draining is treated the same as a client that is stuck. - Until that point the blocked joiner sets `paused`, so the pty everyone shares stops. ## Fix This change is flow control only, on this PC's bridge. - `OutputWindow.check(buffered, now)` returns `flow`, `pause` or `stop` for an ACK client. `reconcileOutput` uses it. Clients without ACKs keep the old transport-only path. - Stalls are measured by ACK progress. Every ACK that advances `acknowledged` restarts the stall clock. A duplicate or out-of-order ACK does not restart it. A blocked client whose ACKs have not moved for 2 s still gets 4008. - One client may hold the shared pty paused for at most `OUTPUT_PAUSE_MAX_MS` (10 s) in a row, however it acknowledges. A client that ACKs a byte at a time, or a phone slower than about 19 KiB/s, can no longer cap every peer at its own rate. Draining from the high to the low watermark (192 KiB) takes 6 s at 32 KiB/s, so an honest slow phone fits. - `OutputWindow.join(replay)` is called right before the replay frame is sent. Until the joiner's first ACK, it gets a budget sized to that frame as serialized (`wireBytes`, JSON escaping included, where an ESC costs six bytes) instead of the 2 s deadline: 2 s plus the frame at 32 KiB/s, at most 15 s. Within that budget the joiner never pauses the shared pty. Only its own window grows, and `OUTPUT_HARD_BYTES` still bounds it. Flow credit and offsets still count payload bytes. - A joiner whose budget runs out becomes an ordinary client: one blocked through its budget with nothing acknowledged is stopped, and one that only becomes blocked later (for example a phone tab suspended right after attaching to a quiet pane) gets the usual 2 s deadline instead of an immediate stop. - `MAX_REPLAY_BYTES` is now 128 KiB, half the high-water mark, so a join no longer blocks the joiner's window on its own. The changelog says so. - `server/AGENTS.md` and `docs/terminal-flow-control.md` are updated to describe the new rules. ## Tests - `server/output-window.test.ts` cases, which fail without the fix: - A joiner that ACKs a 200 KiB replay after 3 s is not closed and becomes an ordinary client afterwards. - A client whose ACKs make no progress for 2 s still gets `stop` (4008). - The stall clock restarts on an ACK that advances and not on a duplicate one. - A joiner is never a reason to `pause` the pty, its budget is capped at 15 s, and a full transport queue still stops it. - A joiner that never ACKs runs out of budget shortly after its small replay. - A client that ACKs 1 byte every 1.9 s is stopped at 10 s; a client that drains to the low watermark in time starts a fresh pause clock. - An ESC-dense 128 KiB replay gets a budget long enough for its serialized frame at 32 KiB/s. - A joiner never blocked during its budget gets the usual 2 s deadline when it becomes blocked later. - The last three were checked to fail against the previous revision's logic. - `bun run typecheck`: passed. - `HERDR_TEST_MODE=unit bun test ./server/output-window.test.ts`: 14 pass. - `bun run test:unit`: 3077 pass, 0 fail, 8 skip. - `bun run check run bun test ./server/output.contract.test.ts` (real herdr): 7 pass. - `bun run build` then `bun run check run bun scripts/output-browser-qa.ts`: passed, with the overload notice, reconnect action and manual recovery all working. - `bun run check fast/full` not run. ## Notes - **devswha#952** (`fix(relay)`) changes `server/machine-relay.ts`: remote-PC connections still close after 2 s of any queued bytes on main. That PR owns the relay's stall rule, so this PR leaves the relay alone (a progress-based relay clock was tried here and dropped to avoid duplicating it). devswha#952's rule (clock only while 256 KiB or more is queued) still does not count drain progress. A relayed ESC-dense replay frame of 256 KiB or more that drains slowly can therefore still trip it. That belongs to devswha#952 or a follow-up. - **devswha#939** (`fix/late-joiner-screen`) is complementary and does not duplicate this PR. devswha#939 changes what a late joiner is drawn from (herdr's `pane.read` screen instead of the tail). This PR changes only how the flow window treats the joiner. The two touch adjacent lines in the `attach` handler and in `output-window.ts`/its test, so whichever merges second has a small conflict. When resolving it, devswha#939's `joinFromScreen` should call `client.data.output.get(paneId)?.join(frame)` (the replay text, not a byte count) right before its `sendOutput(client, paneId, screen + held.join(""))`. devswha#939's fallback (`() => tail`) is then also bounded to 128 KiB. - The 128 KiB replay was kept, not restored to 256 KiB. The join budget already stops a joiner from pausing the pty. The smaller tail also keeps a join from blocking the joiner's own window and halves the frame a weak link must carry before its first ACK. The cost is less scroll-back context on reattach, and the changelog says so. - `wireBytes` stringifies the replay once more before `send` does. This happens once per join, at most 128 KiB. - Left out on purpose: suspending output on `presence:false`, the server-side grace window for an app-switcher peek (moshi-6). That is a follow-up. Also left out: WebSocket `perMessageDeflate`. ## Merge-session review Fixed (commits 61de841, 355e037): - **Idle pane stopped by another pane's backlog (medium).** `OutputWindow.check` now counts the shared WebSocket send queue against a window only while that window has more than `OUTPUT_LOW_BYTES` of its own outstanding. A quiet, fully ACKed split pane on the same socket as a busy joiner is no longer paused (freezing its pty for desktop peers) and then closed with 4008. `OUTPUT_HARD_BYTES` on the queue still stops every pane, and the pane that owns the backlog still pauses or stops. Regression test: idle window at 300 KiB buffered stays `flow` past 2 s and 10 s. - **Join budget ignored bytes queued ahead (low).** `join(replay, ahead, now)`; the attach handler passes `client.getBufferedAmount()`, so a reconnect that re-attaches several panes back to back gives each replay time for the frames ahead of it (still capped at 15 s). Regression test fails against the old budget. - **Frame still in flight after the replay ACK (low).** `write(bytes, wire)` records each frame's serialized size; the stall deadline is now `OUTPUT_STALL_MS` plus the oldest unACKed frame's crossing time at 32 KiB/s (capped so the total stays within 15 s). Regression test: 128 KiB ESC-dense replay plus two 64 KiB colored frames, replay ACK at 4 s, no stop at ~6.1 s, and a silent client is still stopped once that crossing time is up. - **Policy note:** this applies to every ACK client, not only joiners. A client that makes no ACK progress with a large frame in flight is now evicted at 2 s + that frame's crossing time instead of 2 s flat; the 10 s pause cap bounds how long it can hold peers paused (worst case: a dead client with a ~300 KiB-on-the-wire frame outstanding holds the pty up to 10 s, where it was 2 s). Docs item 4, `server/AGENTS.md` and the changelog fragment were updated together. Revert just this part of 61de841 if that trade is not wanted. - `wireBytes` now runs once per ACK frame in `sendOutput` (not only once per join), one extra `JSON.stringify` of the payload. - **index.ts wiring untested (low).** New `server/output.contract.test.ts` case: an operator floods a pane, a second ACK client attaches and does not ACK its replay for 3 s; it stays open and the operator keeps receiving (> 64 KiB in the 2 s window), then it ACKs and carries on. Commenting out the `join()` line makes it fail (operator got 12 KiB). Timing margin for a future flake: the joiner ACKs at ~3 s, and at the flood's ~180 KB/s ceiling (python `time.sleep(.01)`) the 1 MiB hard budget would stop it at ~5 s. - Two fixed waits in it are annotated `// fixed wait: ...` (they prove nothing stalls for a set time); `scripts/fixed-sleeps.json` was not raised. Mutation checks: reverting each of the three `output-window.ts` fixes fails its new unit test; removing the `join()` call fails the contract case. For devswha#939's conflict resolution: `join` now takes `(frame, ahead)`, so `joinFromScreen` should call `client.data.output.get(paneId)?.join(frame, client.getBufferedAmount())`. Checks: `bun run check fast` passed (unit 3080 pass, 0 fail); `bun run check run bun test ./server/output.contract.test.ts` 8 pass. Deferred: none from this review. The relay finding stays with devswha#952 as noted above. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
… ping (devswha#967) ## Problem A WebSocket can still report `OPEN` after its TCP path is gone, for example when a phone moves from Wi-Fi to cellular or wakes on a changed network. No close event arrives for tens of seconds to minutes. In that time: - the terminal stops updating while the header still says connected, and no reconnecting banner shows; - keys go to `rawSend` on the dead socket and `sendInput()` returns true, so PaneTerminal never keeps them as a held draft and they are lost; - a composer send waits the full 90 s submit timeout; - `wake()` (visibilitychange / pageshow / online) leaves an `OPEN` socket alone. ## Cause `HerdrSocket` treats `readyState === OPEN` as proof that the connection is alive. The protocol has no app-level ping. Bun's own pings and idle timeout only tell the server about a dead peer, and the browser never sees them. ## Fix - **Protocol** (`shared/protocol.ts`): new `ClientMessage` `ping {id}`, `ServerMessage` `pong {id}` and `ServerFeature` `"ping"`. The doc comments on both frames are new. - **Server** (`server/index.ts`): answers `ping` with `pong` for any role and lists `"ping"` in its snapshot. A malformed id gets an `invalid_ping` error frame, not a pong. - **Remote relay:** no code change. `MachineRelay` already forwards frames as they are, so the remote PC's own snapshot decides whether the client pings. The contract test proves the pass-through. - **Demo** (`site/demo/transport.ts`): lists `"ping"` and answers it the way the server does. - **Client** (`src/lib/ws.ts`): - Tracks `lastFrameAt`. On a bridge whose last snapshot listed `"ping"`, it probes an `OPEN` socket on wake (visible, pageshow, online) and after 20 s without a frame while the page is visible (checked every 5 s). The flag is remembered across reconnects, so a socket whose snapshot never arrives (a relay path that dies right after the upgrade, a failed snapshot) is probed too. - Only the `pong` of a ping in flight ends a probe. Any frame still postpones the idle probe, but a frame the browser buffered before a sleep, or a pong with another id, does not prove the path is alive. The probe ends before handlers run, so `canInput()` already reads true for them. - No pong within 3 s sends a second ping; no pong to that drops the socket on the client side: `socket = null`, the disconnect handlers run, submits settle as `disconnected`, `close()` is called, and `connect()` runs at once with retries reset. The open handler then replays the role and attaches. Each probe that ends in a drop doubles the per-ping deadline (cap 12 s) until a pong comes back, so a slow relayed link is not dropped over and over. - A dropped socket is kept in `endedEarly` (now a `WeakSet`, shared with `wake()`), so a 4008 close that arrives after the drop still stops output and closes the replacement. A socket that is `CLOSING` when the deadline hits is left to its close event (same rule as devswha#954's `wake()`). - Hiding the page cancels a probe; showing it starts a fresh one. A deadline that fires more than 2 s late (the page was frozen and the timer ran before the queued pong) pings again instead of dropping. - While a probe waits, `canInput()` refuses, so typing goes to the held draft. `submit()`, `pendingAction()` and `sendSecret()` wait the probe out after their snapshot wait and right before sending; if the socket is dropped they settle as `disconnected` having sent nothing (the secret is still cleared). The composer no longer reports offline during a routine probe. - New explicit `ConnectionState` (`idle | connecting | open | probing | waiting | stopped | closed`), exposed as `state`. `connected` still reads the socket itself, so the banner does not flash on every tab switch. - **Held draft** (`src/components/PaneTerminal.tsx`): once input is held for a pane, all later typing joins the draft until Send or Discard, also after the terminal is ready again. Before this, keys refused during a probe (or a disconnect) were held while the next keys after the pong went straight to the pty: `echo ` held and `rm -rf build⏎` run. Text, pastes, Backspace and anything with a line break join the draft; Enter is left out of it, a sticky-modifier chord is refused with a note (new string, translated in ko/ja/zh/tr), and the input line's Enter is refused while a draft is held. Other control keys (Ctrl+C, Escape from the Stop button, Tab, arrows) still reach the program, so an interrupt never waits on the draft. - `src/AGENTS.md`: the WS client row records the probe rules, and a new row records the held-draft rule. ## Tests - `src/lib/ws.test.ts` (fake socket and fake timers): - an `OPEN` socket with no pong gets a second ping at 3 s and is dropped at 6 s. The disconnect handler runs once, a pending submit settles as `disconnected`, a new socket starts with no backoff and its open replays role and attach at the saved geometry, and the late close of the old socket changes nothing; - during a probe, `sendInput` and `sendKeys` are refused; a buffered `pane-status` frame and a pong with another id leave it probing; the matching pong restores input; a second wake sends no second ping; - a socket that only delivers stale frames during a probe is dropped; - `submit`, `pendingAction(steer)` and `sendSecret` started during a probe send nothing until the pong, then each sends once; - a submit already waiting on its snapshot when `wake()` runs, and a secret, settle as `disconnected` with nothing sent when the probe drops the socket; - a 4008 close after a probe drop still stops output and closes the replacement; - a deadline that runs out while the page is frozen pings again, and the pre-freeze pong no longer counts; - hiding the page cancels the probe (no drop in 60 s), showing it starts a fresh one; - a reconnected socket with no snapshot is probed when the previous snapshot listed `ping`; - a probe drop doubles the next deadline, and a pong resets it; - plus the earlier idle-ping, no-flag and `CLOSING` tests. - The 10 new or changed tests fail with the previous `ws.ts` of this branch (checked by restoring it). - `scripts/terminal-input-regression.ts`: after the terminal becomes ready with `a` held, Enter and `b` are typed: the draft reads `ab` and nothing reaches the pane; Escape still reaches the pane with the draft kept; Send types `ab` once with no Enter. It fails without the PaneTerminal change (checked by restoring it and rebuilding). - `server/ping.contract.test.ts` (integration, real herdr): the snapshot lists `ping`; ping gets a pong with the same id, also for an observe role; a malformed id gets `invalid_ping`; ping and pong pass through `MachineRelay`. Not rerun for the review fixes (no server change). - `bun run typecheck`: pass. - `HERDR_TEST_MODE=unit bun test ./src/lib/ws.test.ts`: 49 pass. - `bun run test:unit`: 3088 pass, 0 fail, 8 skip (includes the devswha#949 convention guards). - `bun run check run --build bun scripts/terminal-input-regression.ts`: pass. - `scripts/ui-contexts-regression.ts` passed on the first revision; not rerun after the review fixes. - No browser test of a real half-open socket: Playwright cannot hold a socket half-open, so the unit tests cover that part. ## Notes - **Stacked on devswha#954; retarget to main after it merges.** It builds on devswha#954's connect deadline, `wake()` rules and `SOCKET_CLOSING` handling in `ws.ts`. - **Release:** `shared/protocol.ts` and `server/index.ts` are inputs to the remote bundle, so the release must raise `REMOTE_BUNDLE_VERSION`. The release guard from devswha#948 (`scripts/release-guards.ts`) refuses to tag otherwise. It is not bumped here. Until a remote PC updates, its snapshot does not list `ping`, so it is never probed and behaves as before. - **Trade-off:** a key typed within one round trip of a probe start lands in the held draft with Send/Discard rather than being sent, and later typing joins it until the user picks one. Sending it later on its own would break the never-queue rule. - **Left out on purpose:** - In-flight submits still settle as `disconnected` on a probe drop rather than waiting for the close event: a half-open socket may not close for minutes, and the server never types a message more than 45 s after it arrived. - No "checking connection" UI and no state-change event for probe start and end. `inputReady` drives the "Waiting for terminal input…" banner and the key bar, so a sub-second probe on a healthy link would flash both on every wake. The held-draft banner already shows the case the user can act on, and the composer no longer reports offline. The remaining gap: PaneTerminal recomputes `inputReady` only when a message arrives. - The `ping` flag survives a reconnect: if a PC is downgraded to a bridge without `ping`, the first probe after the reconnect costs one drop before the new snapshot clears the flag. - `resume()` in PaneTerminal still resets the screen before the attach answers (render-scroll-1's second half). - **Overlaps:** - devswha#956 edits the held-draft code (`applyToDraft` Backspace and line breaks) and the same lines of `scripts/terminal-input-regression.ts`: a textual conflict, and the test expectation changes with it: after devswha#956 an Enter in the draft becomes a space plus a notice, so the held and sent text become `a b` instead of `ab`. Never an Enter either way. - devswha#932 edits the same `submit()` guard area in `ws.ts`, plus `protocol.ts` and the demo transport. - devswha#951, devswha#936 and devswha#859 add to the protocol unions, the demo switch and `server/index.ts`'s message switch. These are textual merges with no conflict in meaning. - devswha#962, devswha#964 and devswha#966 touch PaneTerminal's key bar and input line, not the onData draft path. - devswha#952 owns `server/machine-relay.ts`. Nothing changed there. - From a mobile-terminal stability audit: connection-1, render-scroll-1, lifecycle-1, history-1, moshi-1. ## Merge-session review Fixed (each with a regression test that fails without the fix): - **Hiding the page answered the probe** (`ws.ts`): a submit, pending action or secret waiting on a probe was sent on an unchecked socket once the page hid. A hidden page now pauses the probe (`probePaused`): input stays refused, waiters keep waiting, and showing the page pings again. Test: `keeps a submit and a secret waiting when the page hides during a probe...`; the existing hide test now expects `probing` and refused input while hidden. - **Replacing a CLOSING socket left its probe unresolved** (`ws.ts`): `connect()` now ends a non-null previous socket (CLOSING, or CLOSED before its close event) first, so its probe and waiting submits settle, the disconnect handlers run, and a late 4008 from it still stops output (`endedEarly`). Test: `settles a submit waiting on a probe when a CLOSING socket is replaced...`. - **Ctrl+Enter under modifyOtherKeys got past the held-draft guard** (`PaneTerminal.tsx`): a modified Enter is decided on before its rewrite to `CSI 27;5;13~` and refused like a chord, with the held-input note. Test: `terminal-input-regression` turns on modifyOtherKeys and presses Ctrl+Enter beside a held draft. - **A paste with a tab went to the pty and a multi-line paste was dropped in silence** (`PaneTerminal.tsx`): while a draft is held, a paste the draft cannot take is refused with the held-input note. Test: paste `a\tb` and `x\ny` beside a held draft; nothing reaches the pane, and the multi-line paste is told or held, never dropped in silence (written so it still holds once devswha#956 folds line breaks into the draft). - **The input line said "disconnected" and its non-empty line got past the draft rule** (`TerminalInput.tsx`, `PaneTerminal.tsx`): `onEnter` returns `boolean | string` and the held-input reason is shown; `sendTerminalLine` refuses beside a held draft too and keeps the text. Test: empty Enter and `ls` + Enter on the input line beside a held draft show the held-input note and send nothing. - `src/AGENTS.md` rows and the changelog fragment updated to match. Deferred (unchanged from the builder's notes, including its review finding 8): the items under "Left out on purpose" above, such as PaneTerminal recomputing `inputReady` only when a message arrives. Notes for the merge step: - devswha#968 is stacked on this branch and does not carry these two commits; it picks them up when restacked onto main after this PR squash-merges. - devswha#956 overlap: the `terminal-input-regression` Send step now checks the draft's own text, so it holds whether devswha#956 shows `ab` or `a b`. Checks: `bun run check fast` pass; `scripts/ui-regression.ts` (terminal input, Ctrl+Enter, held-draft lanes) pass; `scripts/sticky-modifiers-regression.ts` pass. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…ped output (devswha#968) ## Problem A phone put away while its pane stays attached comes back to "Terminal output stopped because this device could not keep up." and a dead terminal for the whole PC. The pane stays attached in two cases: a message queued from this tab (`queueWaits`), or a mirrored pane (`fixedGridRef`). Its frozen page sends no `pty-ack`, so the server's 2 s stall check closes it with 4008. Unlocking the phone does not recover it. The only ways out are the banner's Reconnect link, which reloads the whole app, or picking another pane. This is stability-audit findings connection-2, history-2 and moshi-2. ## Cause - On a 4008 close, `HerdrSocket` sets its stopped flag and schedules no reconnect. `wake()` refuses while the output is stopped (intended: no automatic reconnect loop). - Only `attach()` clears that flag. `PaneTerminal.back()` calls `resume()`, which attaches, only for a released pane. A pane that kept its attach only gets `refit()`, which sends a resize into a closed socket. - A frozen page often reads the server's close only after it resumes, after `visibilitychange` and `back()` have already run. Also, the 2 s stall clock can still run out while the page catches up on the ACKs it held back. So checking visibility when the close arrives is not enough. ## Fix - `src/lib/ws.ts`: - New getters `outputStopped` and `stoppedWhileHidden`. The private field is renamed to `stopped`. - A 4008 counts as the hidden page's in three cases: it came while the page was hidden; it came while the page had been hidden and not yet shown; or it came within `SHOWN_GRACE_MS` (5 s) of the page being shown again. - A page that connects while hidden counts as put away until it is first shown. - The `attach()` that clears the stop also clears the mark and uses up the grace period. A second stall on the new connection while the page is in use is therefore final. - `wake()` still refuses a stopped output, as `src/AGENTS.md` requires. - `src/components/PaneTerminal.tsx`: - New `reattach()`. It clears `stalledRef`, the notice and `ended`, runs the `resume()` path (stream-order `\x1bc` reset, unwatch if watching, `socket.attach`), and restores `disableStdin`. The attach reconnects, and the connection's open replays every attach remembered for that PC. - It runs once when the page is back in use. It is called from `back()` when the close was read while the page was hidden. It is called from the `output_stalled` handler, in a microtask, when the close is read after the page was shown. - A 4008 while the page stays in use keeps today's behaviour: notice, no automatic reconnect. - The banner's Reconnect `<a href="?machine=&pane=">` (a full page reload) is now a `<button>` that runs the same in-place `reattach()`. The label is unchanged, so no i18n entries were added. - Docs: `docs/terminal-flow-control.md` step 5 and the QA paragraph, and the 4008 rule in `src/AGENTS.md`. - Changelog fragment `changelog.d/recover-hidden-output-stall.md`. ## Tests - `src/lib/ws.test.ts`: four new cases. Each one fails without the fix, checked by stashing `ws.ts`. - A 4008 while hidden is marked. Showing the page reconnects nothing by itself. An attach after showing clears `outputStopped`, reconnects, and replays both remembered attaches. - A 4008 read 1 s after the page is shown again is marked. After the re-attach, a second 4008 while in use is final (`stoppedWhileHidden` false). - A 4008 long after the page came back is final. A later `wake()` and a hide/show do not reconnect, even 60 s later. - A page that connected while hidden counts as put away until it is first shown. - `scripts/output-browser-qa.ts`, real herdr, xterm and Chromium, run with `bun run check run`: **passed**. - The existing visible-stall flow now clicks the Reconnect **button** and still asserts no automatic reconnect loop. - A new scenario fakes `visibilityState`/`hasFocus`, puts the page away with ACKs held and the pane still attached (desktop, release-when-away off), and waits for the 4008. It then shows the page and asserts that the pane attaches again with no click, the notice goes away, and exactly one reconnect happened. - With the two recovery branches disabled, the new scenario times out at "shown page attaches again by itself". - It covers only the order where the close is read while the page is hidden. The other order is covered by the unit tests. This script is not part of CI's lanes. - `bun run typecheck`: passed. `bun run test:unit`: 3091 pass, 0 fail, 8 skip; this ran before the small follow-up commit, after which `ws.test.ts` and typecheck were run again and passed. `check full`/`concurrent` were not run. ## Notes - Stacked on devswha#967: it builds on devswha#967's `ConnectionState`/`endedEarly` and the wake/probe code in `ws.ts`. Retarget to main after devswha#967 merges. - devswha#951 adds its own `?machine=&pane=` Reconnect link in a composer notice. It is a different element, so there is no conflict, but that link still reloads the page. A follow-up could send it through the same re-attach. - devswha#960 (stall clock driven by ACK progress, server side) and issue devswha#781 (stop hidden clients from reaching 4008 at all) are the prevention half. This PR is client recovery only and does not touch the server. The queued message that the server's `stopSlowClient` closes is still devswha#686/devswha#874. - The banner also shows for `attach_conflict`, so the button re-attaches in place for that code too. The server no longer sends it; only a test mentions it. - The re-attach redraws the pane in view from a reset screen. Other panes of the same PC come back through the replayed attach and their snapshot, as they do after a pane pick today. ## Review follow-up (cc86059, 0303ac0) These fix the probe and held-draft code this branch carries from devswha#967 (devswha#967 itself was not updated). Fix: 1. (high) Hiding the page during a probe released waiting sends on an unverified socket. `endProbe()` now records whether the probe was answered; `probeSettled()` lets a waiting submit, pending action or secret go only after a pong. A probe cancelled by hiding (or ended by the connection) settles them as `disconnected`, and the secret is cleared on that path. The secret still lives in the closure while it waits for the pong; that is inherent to waiting, and it is never sent unanswered. 2. (high) Ctrl+Enter under modifyOtherKeys (`ESC[27;5;13~`) passed the held-draft guard and committed the partial line. New pure `heldInputAction()` in `src/lib/draft.ts` decides by meaning: Enter itself (captured before the remap), any encoded Enter (`CSI 27;m;13~`, `CSI 13;m u`), any line break and chords are blocked with the existing "Not sent: send or discard the held input first." note. The input line (`sendTerminalLine`) and the composer (`submitComposerMessage`, new `held_input` code in `submitNote`) also refuse while a draft is held. 3. (medium) The pong returned before handlers ran, so a view that stored `inputReady=false` during the probe stayed disabled. The probe's pong now goes on to handlers after the state is updated, and PaneTerminal's 1 s poll also re-reads `canInput()` (covers the probe start, which has no frame). 4. (medium) Remembered ping support could drop an older bridge after a reconnect. A snapshot that leaves out `ping` now ends a probe in flight as answered (trusted as before) without counting a drop. A separate deadline for a snapshot that never comes was left out: on such a socket the probe's own two pings still decide. 5. (medium) Backspace now edits the held draft (`eraseFromDraft`, whole code points). A paste the draft cannot keep whole (line breaks) is refused with the same note instead of being lost silently. Line breaks are not held: Send types the draft with `sendInput`, so a held newline would run lines. 6. (low, not changed) Typing during a probe goes to the held draft. Buffering keys and flushing them on the pong would queue input typed into an unverified socket, against the never-queue rule in `src/AGENTS.md`; holding only after the first ping times out would send on a possibly dead socket for the first deadline, which defeats the probe. Left as a known UX trade-off for the maintainer to decide. Tests: - `src/lib/ws.test.ts`: three new cases (hidden page refuses waiting submit/pending action/secret, then a fresh probe's pong sends a new submit; the pong reaches handlers with `canInput()` true; a snapshot without `ping` ends a remembered probe and the connection is kept past 30 s). All three fail with `ws.ts` stashed. - `src/lib/draft.test.ts`: `heldInputAction` table (encoded Ctrl+Enter, CR/LF/Alt+Enter, Backspace, single- and multi-line pastes, pass-through controls) and `eraseFromDraft`. - `scripts/terminal-input-regression.ts`: with a held draft, the pane sends `ESC[>4;2m`, Ctrl+Enter shows the note and sends nothing, Backspace edits the draft. - `bun run typecheck` passed; `bun run test:unit` 3099 pass, 0 fail, 8 skip; `bun run build` then `bun run check run bun scripts/ui-regression.ts` passed (all PASS, exit 0). `check full`/`concurrent` not run. ## Merge-session review (d0b66cc..HEAD) Fixed: 1. (low) A probe the hidden page cancelled while a submit, pending action or secret still waited for the snapshot let the frame go out unverified (`probeSettled()` only counted a probe whose timer was still running). Requests now read a probe generation (`probeMark()`) before the snapshot wait; a probe in flight then, or started during the wait, counts even if it already ended, and only its pong lets the request go. Test: `src/lib/ws.test.ts` "refuses a submit, a pending action and a secret whose probe the hidden page cancelled while they waited for the snapshot" (fails without the fix). 2. (low) `held_input` was missing from `submitNotTyped()`, so Send now on a local held queue row refused for a held terminal draft turned the row "uncertain" (Copy only). Added, with a case in `src/lib/compose.test.ts`. 3. (medium, merge coordination with devswha#956) `heldInputAction()` now blocks line breaks, encoded Enter and pasted tabs before it asks `applyToDraft()`, so its answer no longer changes when devswha#956 makes `applyToDraft()` fold breaks into a space. `eraseFromDraft()` now follows devswha#956's Backspace rule: it leaves a truncated draft alone and returns `EMPTY_DRAFT` when erased to nothing. Checked with a throwaway no-commit merge of devswha#956 into this head: `src/lib/draft.test.ts` passes (30/30). Policy kept: Enter beside a held draft is refused with "Not sent: send or discard the held input first." (this PR), not folded into a space with the left-out notice (devswha#956). devswha#956's left-out path still applies to the chunk that starts a draft. For the restack onto main once devswha#956 is merged: - `scripts/terminal-input-regression.ts` conflicts textually. Take this PR's post-ready block. In devswha#956's not-ready step, the Enter pressed with "a" held is now refused: expect the "Not sent" note and draft text "a" (no left-out notice), and adjust the reload/Send expectations from "a " to "a" accordingly. - Keep one Backspace path: replace `setDraft(eraseFromDraft)` in PaneTerminal with `setDraft((prev) => applyToDraft(prev, input))` (devswha#956's ERASE) and drop `eraseFromDraft` and its tests. Keeping `eraseFromDraft` instead fails typecheck after devswha#956 (`InputDraft` gains a required `leftOut`, so its test fixtures need `leftOut: false`); the throwaway merge ran `bun test` only. Deferred: - Item 6 of the review follow-up above (typing during a probe goes to the held draft) is still a maintainer decision. Checks: `bun run check fast` passed (typecheck, build, unit 3101 pass / 0 fail / 8 skip). `CI=1 bun run check run --build bun scripts/ui-regression.ts` passed (terminal input, held input and Safari replay steps included). 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…evswha#969) ## Problem CI on main at eace9f0 (run 38429338201, job 115409436687, self-hosted runner, `HERDR_TEST_SHARDS=4`) failed `server/claude-session.contract.test.ts:200` "lists a Claude pane's subagents, counts the running ones and pushes the count without a status": expected `[{ id: "a1", status: "running" }]`, received `[]`, in 120 ms. The test after it in the file and every later test passed. ## Root cause: a race in the test, not a regression - The server's snapshot path starts `claudeAgents.refresh()` without waiting for it. If `claudePaneSession` answers null once for the pane (herdr's `agent.get` / `pane.process_info` busy or late), `ClaudeSubagentStatus.ensure` records `path: null`. It then skips a new lookup until `refreshMs` (5 s) has passed, and that includes the lookup `/api/pane/omo-tasks` makes when asked. So an ask inside that window answers `[]` fast, on purpose. The client retries. - The test asked once and treated that answer as final. Its comment ("before the server has had a snapshot") was already wrong: `beforeAll` waits for `statusReady`, and another test runs before this one. - Reproduced locally by making the first two `claudePaneSession` calls answer null. The result matched CI exactly: the same assertion, `[]`, 107 ms, and the other 9 tests green. With this change the same injection passes. - No file in the lookup or subagent path changed in devswha#952–devswha#968. A later main run with the same code (38430538375) passed integration. This is the only time this test has failed in the recent runs. The new factor is the self-hosted 4-shard runner (devswha#873). The log does not show which herdr call was late in CI. What is shown is that one null lookup gives exactly this failure. - 30 local runs (serial, and 6 in parallel on separate herdr sessions) and 3 full `HERDR_TEST_SHARDS=4` integration runs were all green before the fix. ## Change (test only) - Moved the bounded `until` poll (100 × 100 ms, allowed by the fixed-sleeps ratchet) to file scope. The plan test and this test both use it. - Poll the subagent list until `a1` reads running, then keep the existing `toMatchObject` assertions. - Wait for the `background_tasks === 1` frame starting from the socket's first frame, after the poll. Its 10 s window now starts once discovery is done, and a count pushed while the list was polled still counts. - Test timeout raised from 15 s to 25 s to cover the 5 s lookup cache on the slow path. Not changed: an explicit ask after a failed background lookup still waits out the 5 s cache. That is the intended product behaviour. If an explicit ask should look again at once, that is a separate follow-up. ## Checks - `bun run check fast`: ok - `bun test server/claude-session.contract.test.ts` on an isolated herdr session: 10 pass, with and without the injected lookup failure 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…appers (devswha#970) ## Problem `server/herdr/client.ts` exported an untyped `herdrRpc(method: string, params: Record<string, unknown>)`. Eleven server files called it directly for four methods (`pane.process_info` x10, `agent.get` x6, `pane.send_text` x2, `pane.send_keys` x1), each with its own inline result type. Nothing stopped `{ target }` reaching `pane.process_info`, which herdr then silently answers for the focused pane (the AGENTS.md rule was prose only). ## Change - `server/herdr/methods.ts`: `HerdrMethods`, a `{ [method]: { params; result } }` map for those four methods. Params and result bodies come from the generated schema types; `PaneProcessInfoParams`, `AgentTarget` and `PaneProcessInfo` are now generator roots (`scripts/generate-protocol-types.ts`, regenerated `shared/herdr-api.generated.ts`). The map itself is hand-written because herdr's schema links a method to its params (`request.oneOf`) but not to its answer: answers are one `ResponseResult.oneOf` told apart only by a `type` tag (`pane_process_info`, `agent_info`, `ok`). - `herdrRpc<M extends keyof HerdrMethods>(method, params: HerdrMethods[M]["params"], …): Promise<HerdrMethods[M]["result"]>` is the first overload. An untyped fallback overload stays for methods the map does not list yet. It refuses a typed method name (`UntypedHerdrMethod<M>` is `never` for one), so `herdrRpc("pane.process_info", { target })` is a compile error. `server/herdr/seam.test.ts` pins that with `@ts-expect-error` and checks the wire params of the new wrappers against a stand-in socket. - New wrappers: `paneProcessInfo(paneId)` returns one shared `ProcessInfo` (= generated `PaneProcessInfo`: shell_pid, foreground_processes with pid/name/argv0/argv) and `agentGet(paneId)` returns `AgentInfo`. The existing `paneSendText` / `paneSendKeys` now go through the typed overload. The wrappers call the exported `herdrRpc` binding, so tests that `spyOn(herdr, "herdrRpc")` keep working. - Migrated (no open PR touches them): `server/pi.ts`, `server/omp.ts`, `server/grok-store.ts`, `server/agy-store.ts`, `server/codex.ts` (x3). Behaviour is unchanged: the same params go on the wire, with the same default timeout and the same `.catch` / `try` at each call site. `codex.ts` keeps its old `argv?: string[]` reading through a commented cast, because the schema allows `argv: null` and that path has never handled null. Changing that would change behaviour, so it is left for a separate fix. - Guard: `server/conventions.test.ts` ("herdr socket") now fails when server code outside `server/herdr/` uses `herdrRpc`. The message names the fix ("add a typed wrapper in server/herdr/client.ts; for pane status use the server/collector.ts listeners") and the allowlist `HERDR_RPC_ALLOWED`. A stale allowlist entry also fails. The `events.subscribe` / `subscribeEvents` rules from devswha#949 were already there; their message now also says where `events.subscribe` lives. - AGENTS.md: the `pane.process_info` line points at `paneProcessInfo` / `HerdrMethods` and names the enforcing test. Only that one line changes; devswha#812, devswha#888 and devswha#894 also edit AGENTS.md. ## Allowlist (`HERDR_RPC_ALLOWED`, migrated in PR B) | File | Open PRs touching it | |------|----------------------| | `server/conversation.ts` | devswha#869 devswha#912 devswha#923 | | `server/gjc-runtime.ts` | devswha#912 | | `server/hermes.ts` | devswha#910 | | `server/omo.ts` | devswha#923 | | `server/shell-agent.ts` | devswha#885 devswha#923 | Built from `gh pr list --state open --limit 100 --json number,files` on 2026-10-14. ## Known limits (closed in PR B) - The export is still named `herdrRpc`. Renaming it to `herdrRpcUnsafe` would touch about 33 test files and 30 `scripts/` harnesses, several of them in open PRs (api.contract, gjc.contract, omo.contract, ui-regression, pending-input…). Tests and `scripts/` QA harnesses are outside this guard's scope: they drive herdr (`pane.report_agent`, `workspace.create`, …) and are not part of the bridge seam. - A call with an explicit result type, `herdrRpc<Inline>("pane.process_info", …)`, still reaches the untyped fallback. That is the form the allowlisted files use, and they may not change here. In server code the source guard closes this gap. The fallback goes away once the allowlist is empty. ## Validation - `bun run check fast`: green. A first run had one failure in `server/portal.test.ts` ("does not treat an arbitrary interpreter as a Portal script": the process argv read raced and returned undefined). This change does not touch that file, it passed alone, and the rerun was fully green. - `bun run test:integration`: 455 pass, 0 fail, 14 skip in 38 files (includes the omp, codex, codex-binding, pi, gjc, omo and hermes contract tests). - I checked by hand that the guard fails on a new `herdr.herdrRpc` use in `server/pi.ts`. ## Follow-up: PR B Once devswha#869, devswha#885, devswha#910, devswha#912 and devswha#923 land: migrate the five allowlisted files to `paneProcessInfo` / `agentGet` / `paneSendText` / `paneSendKeys`, empty `HERDR_RPC_ALLOWED`, drop the untyped fallback overload (or rename it `herdrRpcUnsafe` for tests and scripts), and consider handling `argv: null` in `codex.ts`. ## Review Fixed in 290d5f1: - **Union of typed methods (medium, part of finding 1).** The typed overload takes `SingleHerdrMethod<M>`, so `herdrRpc(cond ? "pane.process_info" as const : "agent.get" as const, { target })` no longer compiles. `seam.test.ts` pins this with a `@ts-expect-error` line. Checked with tsc: the inferred `herdrRpc("pane.process_info", { pane_id })` still returns the typed result. - **Allowlist missed devswha#928 and devswha#859 (medium).** `conventions.test.ts` adds `HERDR_RPC_PENDING` with `server/gjc-status.ts` (devswha#928) and `server/index.ts` (devswha#859). Those PRs add a `pane.process_info` call, but neither file calls herdrRpc on main yet, so the "no longer calls" check skips them. It would be better for both PRs to switch to `paneProcessInfo(paneId)`, which is a one-line change in each. After that, these entries come out. - **Docs claimed more than the PR delivers (low).** AGENTS.md now says the guard enforces the rule, not the compiler, and that the allowlisted and pending files still call herdrRpc. The changelog fragment now says "no longer compiles without an explicit result type". "PR B" is replaced with "a follow-up PR that moves these files to the wrappers once their open PRs land". The fallback docblock in `client.ts` and the `UntypedHerdrMethod` comment in `methods.ts` list the holes. Deferred to the follow-up PR that drains `HERDR_RPC_ALLOWED`: - **Explicit result type and widened string (rest of finding 1).** `herdrRpc<T>("pane.process_info", { target })` and `herdrRpc(m as string, { target })` still compile. With an explicit T, the fallback's `M` stays at `string`, and that cannot be closed while the fallback shares the name. Closing it means splitting the fallback out as `herdrRpcUntyped` (or `herdrRpcUnsafe`) and moving about 400 test and script call sites onto it. It also means converting the `herdrRpc<T>("pane.process_info" | "agent.get", …)` calls in the five allowlisted files, which conflicts with their open PRs. `seam.test.ts` keeps both forms as compiling lines with a comment, so the follow-up can turn them into `@ts-expect-error`. - **Error message.** Two overloads are still present, so tsc reports a bad inferred call such as `{ target }` to pane.process_info as `not assignable to parameter of type 'never'` instead of naming the wrong param. That message only gets better with the split. - **Stale pending entries.** Nothing removes a `HERDR_RPC_PENDING` entry on its own. If devswha#928 or devswha#859 switches to `paneProcessInfo` or closes, its entry stays until someone takes it out by hand. - **Tell the open PRs.** devswha#912, devswha#923, devswha#928, devswha#859 and devswha#910 call `herdrRpc<T>("pane.process_info", …)`. They should hear about the switch to `paneProcessInfo` before the split lands. I have not commented on those PRs. Checks: `bun run check fast` passed (typecheck, build, 3452 unit tests). `bun run test:integration` passed (455 pass, 0 fail). 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…wha#972) ## Summary Four review fixes from cc86059 and 66599c7 never reached main when devswha#967 merged. I checked each claim against origin/main (a338f8b) and found no equivalent handling for any of them. All four are restored here on main's current probe design (`pauseProbe`/`probePaused`). The original `endProbe(answered)`/`probeAnswered` scheme is not brought back: main handles the hidden-page case differently on purpose, by keeping submits waiting (ws.test.ts "keeps a submit and a secret waiting when the page hides during a probe"). | Dropped fix | Gap on main | Restored as | |---|---|---| | Composer send refused beside a held terminal draft (cc86059) | `submitComposerMessage` never checked `draftHeldForRef`. The message box, quick sends and a queued "Send now" typed text and pressed Enter after a partial line that had already reached the shell. | `submitComposerMessage` answers `held_input`. `submitNote` gives the held-input note. | | `held_input` counts as nothing typed (66599c7) | `submitNotTyped` did not list it, so a refused queued row would be marked uncertain. | Added to `submitNotTyped`. A refused "Send now" now stays held. | | Probe pong goes on to handlers, and the poll re-reads `canInput()` (cc86059) | `ws.ts` returned early on every pong. If a frame arrived during the probe, the view stored `inputReady=false`. On a quiet pane, the "Waiting for terminal input…" banner and the disabled key bar then stayed until some later frame arrived. | The pong reaches the handlers after `endProbe()`. PaneTerminal's 1 s poll calls `setInputReady(socket.canInput(pane))`. | | A snapshot without `ping` ends a probe (cc86059) | A probe sent before the snapshot, on ping support remembered from the last connection, dropped a healthy older bridge after 3 s + 3 s. If the page hid before the snapshot, the paused probe was never cleared, because `probe()` returns early on `!pingKnown`. `canInput()` then stayed false for the whole connection. | The snapshot handler calls `endProbe()` when `!pingKnown && this.probing`. This covers both the running and the paused case. | ## Tests - `ws.test.ts`: three tests from cc86059, adapted to main. They cover the pong reaching the handlers, the snapshot without ping ending a running probe while the connection and a waiting submit carry on, and the paused-probe variant. I ran all three against main's unfixed `ws.ts` and they failed. - `compose.test.ts`: checks the `held_input` entries for `submitNotTyped` and `submitNote`. - `bun run check fast`: typecheck, build and lint pass. `server/portal.test.ts` failed once in the parallel unit run. It passes on its own, and this PR does not touch that file. - Browser runs with `CI=1 bun run check run`: `ui-regression.ts` (held draft, composer and terminal input checks), `ui-contexts-regression.ts`, `terminal-input-regression.ts`, `held-rows-demo-regression.ts` and `pending-pane-switch-regression.ts` all pass. Docs: I updated `src/AGENTS.md` (WS client and Held terminal draft rows) and extended `changelog.d/ws-liveness-probe.md`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) ## Review Fixes from the review, in a8752f8: - **Medium: Send now on a server-owned queued row skipped the held-input guard.** `actOnPending` sent `pending-action` straight away, and the server pastes the text and presses Enter for non-Claude agents, so a partial line left in the pty would run with the message. The guard now runs in `actOnPending` for every Send now, before `begin`. It refuses with `held_input`, does not mark the row uncertain, and leaves Send now (once the draft is settled) and Discard working. The changelog fragment and the `src/AGENTS.md` row are now accurate as written. - **Low: no test covered the composer guard or the poll's `canInput` re-read.** `scripts/pending-pane-switch-regression.ts` now holds a terminal draft and checks three things: Send now on a server-owned row sends no `pending-action` and shows the held note, a message-box send sends no `submit` and keeps its text, and Send now delivers exactly once after Discard. `scripts/terminal-input-regression.ts` holds the probe's pings on a quiet pane. It checks that "Waiting for terminal input…" shows within about a second and clears after the pong. I confirmed each test fails without its fix. - **Low: a refused Send now on a row showed the raw English reason.** `PendingMessages` now sends `held_input` through `submitNote` along with `input_draft`. A new unit test, `src/components/PendingMessages.test.tsx`, renders it in Korean. --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
## Problem
On a phone in input-line mode, tapping Send while the keyboard is still
composing the last word does nothing: no send, no note, and tapping
again does nothing either. It works only after typing a space or the
keyboard's own Return. Gboard composes every word (any language), and
Korean/Japanese/Chinese IMEs keep the last syllable open, so this hits
the default touch input path.
## Cause
`TerminalInput.send()` returned early on `composing.current` with no
feedback and no deferred send. The Send button's `onPointerDown`
`preventDefault` (which keeps the soft keyboard up) also keeps focus and
the selection in the textarea, so nothing in the tap ends the
composition and the guard never clears. Gboard commits on a space,
Return, or a focus/selection change, and this tap causes none of those.
The synthetic regression pinned the no-op ("unfinished composition must
not submit via button"), and the native Gboard regression always typed a
trailing space, so the real case was untested.
## Fix
- A Send during composition marks a pending send, shows "Finishing the
word…", and then **finishes the word itself**: `blur()` + `focus({
preventScroll: true })` on the line inside the click. Chrome ends the
composition on the focus change and fires `compositionend`
synchronously; the refocus inside the same gesture keeps the keyboard
up. Probed in headless Chrome with a real CDP composition
(`Input.imeSetComposition`): without the blur no `compositionend` ever
fires; with it, `compositionend` fires before `blur`, and focus is back
on the line afterwards.
- On `compositionend` with a pending send, `send()` runs on the next
tick (`setTimeout(0)`, so WebKit's committing input event has landed).
- `send()` reads the line from the draft store
(`readTerminalDraft(owner)`) instead of the last render's `text`, so a
commit that has not re-rendered yet (the forced commit, or WebKit's late
input) is not cut short.
- Fallback: if no commit arrives within 300 ms, the tap is withdrawn
with "Finish the word, then send.", so the tap is never silent and a
later commit never sends on its own. The timer is cleared on unmount.
- A deferred send that finds the terminal disconnected says "Not sent:
the terminal is disconnected." instead of leaving "Finishing the word…"
up; if another send is already in flight it clears the note.
- The Return key path is unchanged (IME Enter still never sends).
- New strings translated in ko/ja/zh (and tr); docs/terminal-input.md
updated; changelog fragment added.
## Tests
`scripts/terminal-input-regression.ts` (also run by
`scripts/ui-regression.ts`):
- Real composition: CDP `Input.imeSetComposition` on the line, click
Send, expect the committed line sent at once and focus still on the
line. Fails without the forced commit (`actual: 0, expected: 1`, checked
by running the case against the previous `TerminalInput.tsx`).
- Synthetic commit within the window: `compositionend` followed in the
same task by the committing input; the committed line is sent once.
- Disconnect during the wait: the socket is closed and refused, then
`compositionend`; the note reads "Not sent: the terminal is
disconnected.". Fails without the deferred-note change (the note stays
"Finishing the word…").
- Withdrawn tap: after "Finish the word, then send.", a `compositionend`
and then a 400 ms wait (past the deferred tick and a whole commit
window) before asserting nothing was sent; `compositionstart` is
dispatched only after that check.
Runs:
- `bun run typecheck`: pass. `bun run test:unit`: 3069 pass, 0 fail.
`bun run build`: pass.
- `bun run check run bun scripts/terminal-input-regression.ts`: pass 3
times in a row.
- `bun run check run bun scripts/ui-regression.ts`: pass.
- `scripts/android-ime-regression.ts` (native Gboard step: types
`gksrmf` with no trailing space, taps Send, expects `한글\r` in the PTY),
on an Android 16 emulator with Chrome 133.0.6943.137 and Gboard
15.1.08.726012951-preload-x86_64 (Korean 2-bulsik):
- PR head (21eb156): **pass 4/4**. After the send, focus is still on
the input line and the keyboard stays up (`lineFocused: true`, visual
viewport 471 px, screenshot taken).
- main (control): **fail 2/2** at the send-during-composition step (`PTY
did not receive expected input`: the tap sends nothing).
- Merged head after bringing main in (16bd27f): **pass 2/2**.
- Merged head: `bun run check fast` pass (3451 unit tests, 0 fail);
`scripts/terminal-input-regression.ts` and `scripts/ui-regression.ts`
pass.
## Notes
- **Device behaviour observed on an emulator.** The forced commit
(Chrome/Blink finishing the composition on a focus change) holds on
Chrome Android with native Gboard: see the emulator runs above. The
Android step still taps Send through Playwright `tap()`; the commit is
forced inside the click handler whatever started the tap, so a native
tap takes the same path. On iOS the blur/refocus inside the tap also
ends a WebKit composition, but it may make the keyboard flicker. That is
not observed yet.
- Caveat for future adb-coordinate taps: with a Chrome command-line flag
file on a non-rooted device, Chrome shows the `CommandLineOnNonRooted`
("unsupported command-line flag") banner at the top, which shifts the
page down. Screen coordinates mapped from CSS positions must account for
it (or dismiss it first). The script here does not tap by adb
coordinates, so these runs are unaffected.
- Overlap: devswha#937 (chat Send keeps focus) adds the same `onPointerDown
preventDefault` to the chat Composer, and `Composer.send()` bails on
`composingRef` the same way, so chat Send would get the same silent
no-op on Gboard. Not changed here. It needs the same treatment as a
follow-up or within devswha#937.
- Overlap: devswha#936 (Speaches dictation) rewrites `TerminalInput.send()` and
adds its own `sendRef`. A textual merge conflict is likely, and
whichever merges second must keep both behaviours.
- Overlap: devswha#822 and devswha#812 edit `scripts/terminal-input-regression.ts`
near the changed block.
## Review
Five low findings from review, all fixed in 6e1b494 (test tightened in
the next commit):
- **Empty commit pressed a bare Enter.** A deferred text Send whose word
committed empty (preedit deleted or cancelled within the wait) took the
empty-line branch and sent a lone CR. The tap now records whether the
line held text; such a Send sends nothing. Emptying the line also
withdraws a waiting Send.
- **Transient `role="alert"` on every mid-word Send.** "Finishing the
word…" was set before the blur, so it mounted for one macrotask even
when Gboard committed inside the tap. The note and the timer are now
armed only if the composition is still open after the blur and refocus.
- **Docs contradiction.** `docs/terminal-input.md` said both Send
buttons preserve an active composition; now only the chat Send does, and
the input-line behaviour (fixed deadline, drop, empty commit) is
described.
- **Deadline was not absolute.** A deferred Send that found a new
composition armed a fresh 300 ms wait. The deadline is now fixed at the
tap; a later re-check waits only for what is left, and past it the tap
is withdrawn with "Finish the word, then send."
- **Pending Send survived a quick reconnect.** The first reconnect (250
ms) fits in the window. A drop now withdraws a waiting Send at once with
"Not sent: the terminal is disconnected.", and a deferred send runs only
while its attempt is still current.
Regression tests in `scripts/terminal-input-regression.ts`: no
`.terminal-input-note` node is ever added when a real CDP composition
commits inside the tap (MutationObserver; fails on 16bd27f), an empty
commit after a text Send sends nothing, a chain of compositions past the
deadline sends nothing and leaves "Finish the word, then send.", and a
drop plus reconnect withdraws the tap. Each of these fails when the
component is reverted to 16bd27f (checked one at a time, earlier
assertions neutralized).
Verified on 6e1b494 and its follow-up test commit: `bun run check fast`
pass (3451 unit, 0 fail); `scripts/ui-regression.ts` (includes the
terminal-input regression) pass; native
`scripts/android-ime-regression.ts` on the Android 16 emulator (Chrome
133.0.6943.137, Gboard 15.1.08.726012951) **pass 1/1**, send during
composition delivers `한글\r`.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
## Change While an agent streams output, a populated draft in the composer could briefly collapse or shift as the status row measured its hidden labels. Placeholder-only updates no longer feed textarea sizing. The status row now refits only when its visible content or width changes, and its grid tracks are kept as they were while it measures. Typography settings also trigger a refit once their CSS applies, including changes that leave the card width the same. A focused draft keeps its position, scroll and caret while output streams. ## Validation - Browser DOM regressions at 390 and 1440px check that the draft value, selection, scrollTop, height, top and offsetTop stay the same across streamed transcript, context and reasoning updates and prompt suggestions. On unchanged main they fail: the textarea top moves by 14px while the status row fits, and a placeholder update collapses the textarea from 240px to 44px. - A Settings regression changes Interface font size from 14px to 20px and back at a fixed 723px card width, and the labels refit without a viewport resize. - The full composer-fit demo matrix and the pane-paging demo regression pass. - Typecheck and 104 targeted composer, convention and changelog tests pass. - Checked in Chromium, with the client code that Safari and installed web apps share. I have not reproduced this on a physical iPhone. --------- Co-authored-by: Hako <devswha@gmail.com>
## 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 devswha#945.
## Change Selecting part of a chat reply and copying it dropped inline file paths, because those paths render as buttons. The existing Markdown/HTML serializer now keeps their text, and touch screens can select it natively. The chat looks the same, and the per-message Copy buttons are unchanged. This does not fix the separately reported loss of line breaks. ## Validation - `bun run typecheck` passes. - Targeted Markdown, file-path and convention tests: 92 pass. - `bun scripts/chat-selection-copy-regression.ts`, also with `COPY_WEBKIT=1`: copying in Chromium and WebKit and pasting into a native textarea pass at 1440px and 390px. The cases cover partial selections of a file path, line breaks, whitespace in fenced code and both Copy buttons. - On upstream/main `38d6863` without the fix, both browsers drop the inline file path. --------- Co-authored-by: Hako <devswha@gmail.com>
CodeRabbit's open-source plan limits PR reviews per developer per hour
(1-10 for OSS, per repository), and every push to an open PR counts as
one review. Agent sessions push several times per PR, so the
maintainer's allowance runs out on our own PRs. Outside contributors'
PRs use their own allowance, so they are unaffected.
- `.coderabbit.yaml`: `reviews.auto_review.auto_incremental_review:
false` (schema: "Re-run the review on each push", default true). A PR is
still reviewed once when it opens (non-draft); later pushes are not
re-reviewed automatically.
- `docs/development.md`: before merging, comment `@coderabbitai review`
on the final head and wait for it.
No app change; no changelog fragment. YAML checked with `Bun.YAML.parse`
(`auto_review` = `{enabled: true, drafts: false,
auto_incremental_review: false}`).
…ut viewport (devswha#1087) Refs devswha#1045 Follow-up to devswha#1072. On Android Chrome (`interactive-widget=resizes-content`), the soft keyboard shrinks the visual viewport, the layout viewport (`innerHeight`) and the `100lvh` ruler together. `src/lib/viewport.ts` read that as a window resize and dropped the remembered unobstructed height. As a result `data-keyboard` was never set, and the terminal refit the shared pty to the strip above the keyboard (47x38 -> 47x20 -> 47x38). ## Change - `keyboardViewportReference` (`src/lib/keyboard.ts`) now computes the reference height. While a typing field is focused or the keyboard is already up, a visual viewport that fills the shortened layout viewport no longer counts as a window resize, so the unobstructed height is kept. A split view that still has a separate visual inset, a rotation (a new width has its own baseline), and an unfocused shrink still replace the baseline, as before. - `src/lib/viewport.test.ts`: classifier cases shaped like Android (lvh shrinks) and iOS (only the visual viewport shrinks), plus controls. Against main's original algorithm the Android case fails (533 instead of 790), as does the case for a window resize with no visual inset. Both pass here. ## Device evidence (MacBook emulators, own herdr session, `trap WINCH` + `stty size` loop in the pane) | Device | Build | stty rest / keyboard up / closed | outgoing resize frames | |---|---|---|---| | Android 16 emulator, Chrome 133, Gboard | main 694b753 (before) | 38 47 / **20 47** / 38 47 | 2 (47x20, 47x38) | | Android 16 emulator, Chrome 133, Gboard | this branch | 38 47 / 38 47 / 38 47 | **0** | | iPhone 17 Pro simulator, iOS 27 Safari | this branch | 30 46 / 30 46 / 30 46 | not countable (no CDP); stty unchanged | The Android frames were counted over CDP with a `WebSocket.prototype.send` hook. `innerHeight` went 790 -> 477 -> 790, so the layout viewport really shrank. With the keyboard up the pane is cropped, not refitted. ## Checks - `bun run typecheck`: pass - `bun test ./src/lib/viewport.test.ts ./src/lib/keyboard.test.ts`: 22 pass - `bun run test:unit`: 3588 pass, 0 fail - `bun scripts/keyboard-viewport-regression.ts`: pass (browser replay: `data-keyboard` and `--app-height: 477px` are set while the keyboard is up and cleared on close)
…vswha#984) ## Problem Three CI flakes: 1. `scripts/ui-contexts-regression.ts`: `split-view-regression.ts:81` timed out after 30 s waiting for two `.pane-slot`s. The guide had shown "right", the mouse was released, and no split opened. Seen at `splitRight` lines 467 and 488 (runs 38426853901 and 38443069041). 2. `scripts/sticky-modifiers-regression.ts`: in the kitty pass, the dropped "z" arrived as `\x1b[200~z\x1b[201~` (run 38443069041). 3. Native Windows install, "starts after its launcher exits, stops its owned tree and restarts on Windows": "could not verify the recorded herdr web ui process" (run 38423731292, attempt 1). ## Root cause 1. **A product bug.** The pane area accepted a sidebar drag only on `dragover`. A drop runs one more hit test. If the node under the pointer was replaced since the last dragover (xterm redraws its rows, the chat transcript renders again), Blink sends the new node a `dragenter`. Nobody accepted that event, so Chromium refuses the drop. The page then gets `dragenter → dragleave → dragend` and no `drop`. I reproduced this by replacing the node under the pointer before the drop and recording the events. 2. **A test race.** Herdr's first attach screen carries `\x1b[?2004h`. The test waits for the key bar to be enabled (input-ready) and then injects `\x1b[?2004l`. Input-ready can come before the first screen: in the kitty pass, the probe measured the stream's `2004h` 4 ms before the inject. When the first screen arrives later, it turns bracketed paste back on, and xterm wraps the drop. 3. **Not identifiable from the log.** The stack is `windows-plugin.test.ts:62 ← :147`, and 147 is the `finally` block's cleanup `run("stop")`. So an earlier `expect` had already failed, and the cleanup stop's error replaced it. The log does not show which earlier check failed or which verification condition in `plugin.ts stop()` tripped. ## Change - `src/App.tsx`: the pane area also handles `onDragEnterCapture` with the same accept logic as dragover. It still returns early for drags that are not pane drags. - `scripts/split-view-regression.ts`: new case 9b. A test-owned cover node over the pane area is replaced between the last dragover and the drop. The drop must still open the half. - `scripts/sticky-modifiers-regression.ts`: the test's WebSocket route strips `\x1b[?2004h` from herdr's `pty-data`, so bracketed paste stays off for the whole run, which every assertion assumes. Flow-control offsets come from the server, so editing `data` is safe. The strip does not catch a combined form such as `\x1b[?2004;1049h`. Herdr does not emit that today. - `scripts/windows-plugin.test.ts`: after an earlier failure, a failing cleanup stop no longer replaces the original error. The next time this flakes, the log will show the real failure. This is not a root-cause fix for the Windows flake. - `changelog.d/split-drop-on-redrawn-node.md`: Fixed + Maintenance. ## Validation - New case 9b **fails without the App fix** with the same 30 s `waitForFunction` timeout at line 81. With the fix it passed in 3 out of 3 split-view-only runs. - A forced swap of the node under the pointer (temporary instrumentation) reproduced `dragenter, dragleave, dragend` with no drop before the fix, and passed after it. - The full `scripts/ui-contexts-regression.ts` run passed every check, checkSplitView included, under shared-machine load (nice 10). The `check` wrapper exited 1 only because a changelog file was added while it ran ("source changed during the check"). - `scripts/sticky-modifiers-regression.ts` passed 5 out of 5 runs (nice 10). - `bun run check fast` passes. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…on the bottom bar (devswha#943) ## Problem On an iPad with split panes (seen with two panes stacked on the right and one on the left): - The key bar's first key sat against the pane's left border (`padding: 0`), out of line with the message line above it. - Every key bar took the whole bottom safe area (`height: calc(var(--keybar-h) + env(safe-area-inset-bottom))`). A pane stacked over another one has the other pane under its bar, not the home indicator, so it showed an empty band under its keys. The bar at the bottom of the screen also kept the whole inset on top of its own padding. ## Change - The bar has `padding: 0 var(--space-2)`, so its keys line up with the message line. - `KeyBar` marks the bar that reaches the bottom of the screen (`is-at-screen-bottom`, measured on layout and on resize). Only that bar keeps clear of the home indicator, by `max(0px, env(safe-area-inset-bottom) - var(--space-2))`, the same allowance the composer makes (`Composer.css`). - `.terminal-host` keeps leaving its bottom inset to the key bar, as before. ## Validation - Two panes stacked (`pane.split` down) at 1100×900 in Chrome with `isMobile`/`hasTouch` (coarse pointer), on a build of this branch: the upper bar has no `is-at-screen-bottom` and the lower one does. The first key is 8 px from the bar's edge in both; on `main` it was 0 px and neither bar was told apart. - `bun run typecheck`, `bun run build`: pass. `bun test src/lib` (unit mode): 1255 pass. The 4 failures ("a phone's sheet in the history") fail the same way on `main` on that machine. - The real inset needs an iPad; desktop browsers report `env(safe-area-inset-bottom)` as 0, so the size of the lower bar's allowance was not measured. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Noobear <tmshdnaos45@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Hako <devswha@gmail.com>
Carries devswha#1035 by @polaroidkidd onto current main: the org fork (dle-labs) does not accept maintainer pushes, so the branch could not be updated in place. Their commit is kept as written, with them as author. ## Change (from devswha#1035) Chat Find no longer sits under a pane's menu button: the Find field is placed clear of the pane menu on desktop (comfortable and compact density) and of the phone header. ## Added here - Merged current main: kept main's pointer/focus visibility rules in `src/components/PaneCanvas.css` (since devswha#1101 the desktop pane menu shows only on keyboard focus) and added the PR's spacing. - The Find placement check now reveals the pane menu by keyboard focus instead of hover, keeping its placement assertions. ## Validation - The regression fails on main (Find overlaps the keyboard-revealed menu) and passes with this branch; `bun run typecheck` passes. Closes devswha#1035 when merged. --------- Co-authored-by: Daniel Einars <contact@dle.dev>
…a#937) ## Change On a phone, pressing **Send** in the chat while the keyboard is up closes the keyboard but does not send. Pressing the button moves focus off the message box, so the keyboard collapses and the layout shifts under the finger before the tap lands as a click. `TerminalInput`'s send button already stops the pointer-down default for this reason (`// the soft keyboard stays up for the next line`). The chat composer's send button did not. This gives it the same `onPointerDown={(event) => event.preventDefault()}`, so focus stays in the message box and the click goes through. Seen on an iPhone (Home Screen app) against v0.4.8; the code is unchanged on `main` at 5a9fe39. ## Validation - `bun run typecheck`: passes. - `bun scripts/composer-fit-demo-regression.ts` (Chrome, `HERDR_TEST_MODE=unit`): passes with a new block, `PASS pressing Send keeps focus in the message box`, at 390px and 1440px. - The new block presses the Send button and releases away from it, so nothing is sent, and asserts the message box is still the active element. With the `onPointerDown` guard removed it fails with `AssertionError: 390px: pressing Send keeps the message box focused`. - Not checked: the real iOS soft keyboard. The harness has none, so it checks that focus stays in the message box, which is what keeps the keyboard up. I also did not run `test:integration` or `test:ui`. - No new `t("…")` strings, so no translation entries. The change is not in the HTTP/WS contract. --------- Co-authored-by: Ben Fox <ben@nullfox.com> Co-authored-by: Hako <devswha@gmail.com>
## Change A custom Bun launcher can run the OMO engine normally while herdr reports it as `pi` and its argv contains no standard OMO executable. The chat lens then misses the native conversation. Recognize custom runtimes from their OMO store and native holder records, validating the live PID, process start and cwd. Keep each holder's store/session identity through pending, malformed and ambiguous transcript cases. Reuse the same identity in snapshot discovery, chat routing and shell-start confirmation; verified OMO ownership takes precedence over a child agent's label. Preserve standard and hoisted-engine launch compatibility without matching custom filenames, profiles or model names. Custom recognition needs readable process metadata and a native holder in an explicit OMO store or the default OMO store. Insufficient evidence fails closed. This does not change launchers, model mappings or authentication. ## Validation - CI Bun 1.4.2: `bun run check fast` — generated types, both TypeScript checks, production build, and unit suite pass (2,996 passed, 8 skipped). - Isolated native contract: 10 tests, including custom/default stores, pending sessions, negative identity, background logs and Codex/Devin/GJC child labels. - Real Chromium: six scenario groups covering store isolation, arbitrary model metadata, legacy launch, no-evidence rejection, `/new`, live first-message rendering and resume. - Independent review reproductions for missing holder headers and changed process cwd (including stale pane paths without process-level cwd) are covered by focused regressions. A running custom OMO pane was inspected read-only: the original PID remained active, runtime detection succeeded, and the native transcript parser returned its conversation. All reproduction inputs and browser captures use isolated synthetic panes. No deployment or merge was performed. The related GJC status PR devswha#928 was also checked in an isolated combined worktree: native OMO ownership survives a raw GJC label at initial snapshot and after periodic refresh. --------- Co-authored-by: Hako <devswha@gmail.com>
Closes devswha#875 ## Change On Windows, the New workspace and New tab menus offered gjc but not omo, although both are agents that the shell starts in the pane. The Windows allow-list `WINDOWS_SHELL_AGENTS` in `server/shell-agent.ts` contained only `gjc`, so `shellAgentExecutable("omo", "win32")` returned `null` and omo was never offered there. This PR: - adds `omo` to `WINDOWS_SHELL_AGENTS` (`new Set(["omo", "gjc"])`). The doc comment above it already says that the set lists the shell-started agents known to start on Windows, so it is unchanged; - replaces the test that pinned omo as not offered on Windows with a test that resolves omo through `Bun.which` when it is on PATH, and returns `null` when it is not; - adds a CHANGELOG line under `[Unreleased]` → `Fixed`. The fix is one line of behaviour. Nothing else in the server or the client changes. ## Validation Focused unit test, RED on the old code and GREEN on the new code: ``` $ HERDR_TEST_MODE=unit bun test ./server/shell-agent.test.ts # before the fix (fail) offers OmO on Windows when its launcher is on PATH 5 pass 1 fail exit 1 $ HERDR_TEST_MODE=unit bun test ./server/shell-agent.test.ts # after the fix 6 pass 0 fail exit 0 ``` The RED failure is `expect(received).toBe(expected)`, with `Expected: "C:\\tools\\omo.exe"` and `Received: null` at the first assertion of the new test. Other checks, on the branch after the fix: ``` $ bun install exit 0 $ bun run typecheck exit 0 (tsc --noEmit and tsc --noEmit -p tsconfig.tools.json) $ bun run build exit 0 ``` Not run: the full unit suite and the browser scripts. The full suite has known Windows-only failures on unmodified `main`, and this change does not reach the browser. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com> Co-authored-by: Hako <devswha@gmail.com>
…of sending each jamo (devswha#938) ## Change On an iPad with a hardware keyboard (Magic Keyboard), Korean typed in the terminal reached the PTY one jamo at a time: 한 arrived as ㅎㅏㄴ. Typing in Chat, or in an ordinary text field, was fine. I recorded the native event order with `scripts/terminal-ime-diagnostic.ts` on the iPad (iPadOS Safari 27.2 in its default desktop mode, so `navigator.platform` is `MacIntel`). It is different from both the macOS Safari path in devswha#524 and ordinary composition: - Each jamo arrives as a printable `keydown` with its own keyCode, not 229 (`key: "ㅎ"`, `keyCode: 71`, `isComposing: false`), followed by a `keypress`. - The editor then composes with plain input events. A new syllable is `insertText`. A changed one is `deleteContentBackward` and then `insertText` of the whole result (ㅎ → 하 → 한). There are no composition events. xterm sends a single-character `key` on keydown and cancels the event (the `keyCode >= 48 && key.length === 1` branch of `evaluateKeyboardEvent`). The editor never gets to compose, so every jamo is sent on its own. This change leaves such a key to the editor: - `CompositionHelper.passHangulKey()` matches Safari on macOS or iPad, a Hangul jamo or syllable `key`, keyCode other than 229, no modifiers, and no screen reader mode. For such a key, `Terminal._keyDown` returns without sending or cancelling it, and marks the keydown handled so `keypress` doesn't send it either. - The editor's `insertText` then starts the existing Safari replacement preedit from devswha#524. That path already commits the syllable once, on the next other key, a paste, a blur or a pane reset. It now also accepts: - a replacement of more than one syllable. iPadOS puts back 마아 when the final ㅇ of 망 moves on to a following ㅏ. - the `iPad` platform as well as `MacIntel`. English, shortcuts, Enter, Backspace and the macOS Safari path are unchanged. ### The pending syllable's size (second commit) With composing working, the syllable being typed looked smaller than the ones before it. On Apple fonts `terminalGlyphs.ts` (devswha#272) draws a Hangul glyph in the rows up to 1.2x larger so it fills its two cells. The pending syllable is not in a row: xterm draws it in its composition view at the plain font size. On the iPad it measured 16 device px tall against 20 for the committed ones. `adjustTerminalGlyphs` now watches the composition view and gives it the fit its row will give it: the same `glyphFit`, with the spacing taken from xterm's own measure of the glyph (its `WidthCache` element), as the row's is. A canvas measures Hangul differently on macOS WebKit (11.2px against xterm's 13px at 13px), so the canvas alone gave the view 15.6px instead of the row's 14.45px. ## Validation - **New replay:** `scripts/terminal-ipad-ime-regression.ts` (also run from `ui-regression.ts`) replays the recorded iPad order for `navigator.platform` `MacIntel` and `iPad`. It covers: - English then Hangul, with no jamo sent while composing - a two-syllable replacement - commit before Enter, Ctrl+C and Tab - commit on blur - no leak into another pane on a pane switch - the pending syllable drawn at the size of a committed one It **fails on `main`** at the first Hangul keydown (the keydown is cancelled) and passes with this change. Run on macOS 27 with Chrome and with Playwright's WebKit. Without the second commit it fails in WebKit at the size check (`13px against 14.452889px`). In Chrome the rows need no fit for Hangul, so both are 13px there. - **Existing replay:** `scripts/terminal-safari-ime-regression.ts` (macOS Safari, devswha#524) still passes. - **Real device:** iPad with Magic Keyboard, Korean 2-set, iPadOS Safari 27.2, using `terminal-ime-diagnostic.ts`. Typing `abc`, switching to Korean, `한글`, `?`, then Backspace several times delivered exactly `abc` `한` `글` `?` and one `\x7f` per Backspace to the PTY (`received.bin`), each syllable once. - `bun run generate:types --check` and `bun run typecheck`: pass. `bun run test:unit` on macOS: 2937 pass, 0 fail, 50 skip for the first commit (with `TMPDIR=/private/tmp`; the default macOS temp path is too long for `check.test.ts`'s sockets, as in devswha#524). With both commits, on another Mac, the only failure is `scripts/phone-setup.test.ts` ("runs the active release's setup…"), which fails the same way on `main` on that machine. Refs devswha#432. That issue is the macOS Safari English-to-Korean transition and stays separate; this is the iPad hardware-keyboard path next to it. Thank you for devswha#524. Its replacement path made this a small change. I'm happy to adjust the key check or the test shape if you would rather keep the iPad case separate. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Noobear <tmshdnaos45@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Hako <devswha@gmail.com>
## Change While a chat message waits for acknowledgement, the Send button looks the same as it does with an empty draft. Following the maintainer's [suggestion in devswha#319](devswha#319 (comment)), the button now shows a small stepped progress ring, with an accessible "sending" name and a busy state. With reduced motion the ring stays static. Draft retention and the handling of acknowledgements and refusals work as before. ## Validation - `bun run typecheck` passes. - Targeted draft, i18n, CSS/motion, script convention and changelog tests: 62 pass, 0 fail. - `bun scripts/composer-fit-demo-regression.ts` passes, and its held-receipt assertion failed before the fix. It covers success and refusal, unchanged button bounds, all six palettes, reduced motion, Claude and Codex, desktop and phone, and dark and light themes. - I reviewed eight screenshots made from fictional demo data. --------- Co-authored-by: Hako <devswha@gmail.com>
## Problem GitHub-hosted fork CI repeatedly failed `checkChatKeepsTerminalSize` with `no size-5 answer from the pane` (runs 38618468326 and 38624998171). The pane snapshot showed the `size-5` command and the next shell prompt, but not the command's output. Main's self-hosted runs and local runs passed. ## Root cause The send RPCs were not lost or queued: the exact command reached the shell and the prompt returned. Probes also cannot overlap because each `size()` call is awaited until its unique marker resolves or throws. The failure was the observation channel: `pane.read(source: "recent")` reads a screen/history snapshot, and this helper already documents that a concurrent resize can reflow the screen and lose a completed output line. devswha#1095 added a real-PTY restoration probe during the resize but used the screen-backed mode. On slower hosted runners, reflow could erase the marker even though the shell executed it. ## Change Use the helper's existing file-backed mode for `checkChatKeepsTerminalSize`. Each probe remains sequential and still runs `stty size` in the real pane shell, but reads its answer from the fixture directory instead of a reflowing screen snapshot. The two one-second restoration assertions are unchanged; no timeout or fixed sleep was added. Test-only change, so no changelog fragment. ## Validation - `bun run check run --build bun scripts/chat-size-regression.ts` — exit 0 (focused real browser/PTY regression) - `taskset -c 0 bun run check run --build bun scripts/ui-contexts-regression.ts` — exit 0; report verified, source unchanged (build 10.2 s, run 304.7 s) - `taskset -c 0-7 bun run check run --build bun scripts/ui-contexts-regression.ts` — exit 0; report verified, source unchanged (build 4.1 s, run 179.0 s) - `bun run typecheck` — exit 0 on final main base - `HERDR_TEST_MODE=unit bun test ./scripts/conventions.test.ts ./scripts/ci-browser.test.ts` — exit 0 on final main base Two intermediate full-suite attempts exited 1 before reaching chat-size in the pre-existing `checkTabMenu` locator timeout also present in the supplied hosted-CI artifact; no unrelated code was changed.
## Change GJC can finish its parent turn while herdr still reports `working` from a provider child. The Web UI now derives GJC turn status from the transcript bound to its native runtime, independently of the selected provider or preset. The shared transcript tracker keeps OMO defaults and adds GJC-specific lifecycle rules: native work and runtime notifications produce RUN, final answers settle the turn, `ask` waits for input, and errors or interrupted processes remain READY. Missing evidence falls back to herdr, and restored bindings publish their current status over WebSocket. Transcript discovery uses the foreground cwd and native process start information, including the Windows process lookup. Native OMO ownership takes precedence over a provider-child GJC label in snapshot reads, periodic discovery, callbacks and status events. Native ownership disappearing allows standalone GJC to take over again. This corrects parent-turn status. It does not infer live background-job counts from historical records; GJC maintains that live job state in memory. No HTTP/WS wire fields, launchers, authentication or model presets change. ## Validation - Bun 1.4.2, herdr 0.9.3, Linux. - `bun run check fast`: 3,019 passed, 0 failed, 8 skipped; workflow syntax, generated types, application/tool typechecks and build passed. - Combined with PR devswha#923 in a separate worktree, the raw-GJC/native-OMO ownership fixture failed before the fix and passed afterward (13 assertions, including a periodic refresh). - Isolated native GJC + OMO binding contracts: 17 passed, 62 assertions. - `bun run check run --build` (native contracts followed by `bun scripts/gjc-status-browser-regression.ts`): six real Chromium scenarios passed using owned panes, a real server, HTTP snapshots and WebSocket status frames: completed history, native work, final completion despite stale raw reports, background-result wake, user questions, and delayed transcript readiness. - Read-only verification distinguished a real GJC session's completed historical turn from its subsequently resumed live work. Existing user sessions were not changed. - Windows process discovery is covered by injected tests; a live Windows browser was not exercised. Synthetic browser completion evidence:  --------- Co-authored-by: Hako <devswha@gmail.com>
devswha#895) ## Change A bridge install that never reached its `trap` (SSH link lost mid-upload, the web server restarted while it ran) left `~/.local/share/herdr-web-ui/install.lock` behind. Seen on a Linux PC: the remote `sh` sat for over an hour with `cat > bundle.tgz` waiting for an EOF that never came, holding the lock, so every later Update failed with `Another installation is in progress; retry shortly` until the directory was removed by hand. The lock had no owner or age, unlike the bridge lock in `remote-entry.ts`. - `install.lock` records its owner's PID. A lock whose owner is gone, or that is older than any install can run (35 min; the local SSH timeout is 30), is reclaimed instead of blocking the retry. - The `trap` releases the lock only while it still owns it, so a late install cannot delete a successor's lock. - The upload `cat` runs under `timeout` (25 min) where the PC has one, so a link that never delivers EOF ends the install and its own cleanup runs. PCs without `timeout` (stock macOS) still get the age-based reclaim. The native Windows script has the same lock shape and is left as it is. ## Validation - `bun test server/install-lock.test.ts` (new, runs the lock snippet in a real `sh`): free lock taken and owner recorded, live owner refused, dead owner reclaimed, 40-minute-old lock reclaimed with a live owner. - Reproduced the hang: with stdin never closing, `timeout` ends the upload (exit 124) and the trap removes both the lock and the staging dir. - `bun run typecheck`, `bun run build`, `server/machines.test.ts`, `server/machine-update.test.ts` pass; the built server starts and serves `/`. - `bun run test:unit`: 3 failures in `scripts/check.test.ts`, all "socket path too long (120 > 100)" from macOS's long `$TMPDIR`, unrelated to this change. - Not run: a full bundle install against a real PC. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com> Co-authored-by: Hako <devswha@gmail.com>
…a#986) ## Change On a phone keyboard you can't add a line break in the composer unless you know to turn off "Enter sends". The setting's description only mentions Mod+Enter, which a touch keyboard doesn't have, so it's easy to miss that it also turns Return into a newline. On touch screens the same per-device setting now reads "Return inserts a new line", with a note that Send or a hardware keyboard's Mod+Enter sends the message. Defaults, the desktop setting and what gets sent are unchanged. Translations for ko/ja/zh, the docs and a changelog fragment are included. Pasting multiline text keeps its line breaks, Markdown and indentation in the browser checks. Trailing newlines are still trimmed on Send by the existing submission rule; I left that for a separate change. Related: whitespace work in [devswha#710](devswha#710), and multiline submission from the mirror in [devswha#269](devswha#269). ## Validation - `bun run typecheck` - Compose, settings, i18n, client/script conventions and changelog tests: 142 pass, 0 fail. - `scripts/composer-fit-demo-regression.ts` covers the Return default on phone and desktop, the saved choice, native clipboard paste, Send and hardware Mod+Enter, plus the existing composer checks. - `scripts/settings-pages-demo-regression.ts` covers the phone settings layout and navigation. - I looked at dark and light phone captures made from demo data. The browser checks use Chromium's touch viewport and native clipboard. I haven't tried physical iOS or Android keyboards. --------- Co-authored-by: Hako <devswha@gmail.com>
…evswha#1119) ## CI evidence and root cause [CI run 38631061842, attempt 1](https://github.com/devswha/herdr-web-ui/actions/runs/38631061842/attempts/1) failed in `hold()` at `server/omo.contract.test.ts:117`, called by the senpi-holder setup at line 273. The env-named-store sibling passed; the rerun passed. `launch()` already waits until the launcher appears. `hold()` then independently rereads `pane.process_info` and non-null-asserts the response, foreground list, and matching process before reading its PID. That later observation need not contain the launcher even though the earlier one did. The client contract explicitly allows missing process info. The CI error does not distinguish which optional value was missing, so this change handles absent info, absent lists, and absent matches rather than assuming a specific missing field. The unchanged local baseline passed both custom-launcher tests; this was not a deterministic local reproduction. ## Change Extract the existing launcher readiness loop into `waitForProcess`, bounded by the existing five-second readiness budget. Return the matching process and its observed foreground list, so consumers use the successful snapshot. Use it for `launch()`, `hold()` (including both custom-launcher tests), and the env-store sibling's later process snapshot. On timeout, name the expected launcher, pane, and last process-info response. No fixed sleep or timeout inflation, no production changes, and no changelog fragment. Tests are excluded from the raw-RPC convention, so this retains the neighboring tests' API style. ## Validation Linux, Bun 1.4.3, Node 24.18.0, herdr 0.9.3. All herdr runs used isolated `check run` sessions with `HERDR_TEST_MODE` unset. - `CHECK_WAIT_SECONDS=7200 bun run check run bun test --timeout 60000 ./server/omo.contract.test.ts -t "custom launcher"`: five consecutive post-change runs, exits **0, 0, 0, 0, 0**, two tests passed each. - `CHECK_WAIT_SECONDS=7200 bun run check run bun test --timeout 60000 ./server/omo.contract.test.ts`: exit **0**, 10 passed. - `bun run typecheck`: exit **0**. - `HERDR_TEST_MODE=unit bun test ./server/conventions.test.ts ./scripts/conventions.test.ts`: exit **0**, 19 passed, including the fixed-sleep ratchet. - LSP: no errors. `git diff --check`: exit **0**. - Check reports verified unchanged source for every herdr run. The baseline check itself exited 0 (confirmed in its report); its outer logging wrapper had an exit-reporting typo and exited 2. That wrapper was corrected before all five counted runs. AI-assisted implementation and verification. Co-authored-by: e2e <e2e@example.com>
…uiet opened finishes (devswha#1120) ## Change Three Appearance settings added surface without much use, and pinning hid behind one of them. This removes them and makes pinning an ordinary row action. - **Pin from the row's menu.** Each Agents row gets a `⋯`, shown like a workspace row's (on hover, focus, selection, an open menu, and always on touch). It and a right-click (or the context-menu key) open one menu: **Pin agent** / **Unpin agent**. A right-click opens at the pointer and leaves the focus where it was. Pins always apply; pins saved before stay. The star button is gone. - **Pinned head.** Pinned agents stand first under a quiet **Pinned** head (`--text-dim`, `--fs-xs`, aligned with the titles), the rest follow `--space-2` below with no head; no pins, no head. Modeled on Paseo's and Codex's pinned sections. - **Same order as workspace rows.** The state dot now sits at the row's right edge after the `⋯`, as on workspace rows, so both lists line up on a phone. - **Removed:** Settings → Appearance → **Workspace cards**, **Pin agents** and **Quiet opened finishes**, with the card UI and CSS, `groupRows` / `agentContext`'s `grouped`, the per-browser "seen" records (`herdr-web-ui:seen:*`) and `SeqMemory.promoted` / `restarts`, which only they read. The Agents list is always flat, and a finished agent keeps herdr's DONE. - `shared/preferences.ts` stops sharing `agentCards` / `agentPins`; a stored file or an older browser that still sends them is filtered by the existing key allowlist. - DESIGN.md, docs/development.md and a `changelog.d` fragment are updated; the three settings' strings leave ko/ja/zh, and `Pinned` is added. **Telemetry Worker redeploy needed before the next release.** `shared/telemetry.ts` drops `agentCards`, `agentPins` and `quietOpenedDone` from the snapshot. No released version sends a snapshot yet (0.4.8 still sends `changed_settings`), so nothing in the field breaks, but `release-guards` will refuse the release until the Worker is redeployed and `telemetry/deployed.json` recorded (`telemetry/README.md`). Known gap: iOS Safari reports no context menu for a long press, so on an iPhone the `⋯` is the way to pin (it is always visible on touch). ## Validation - `bun run typecheck` and `bun run build`: pass. - `bun run test:unit`: 3667 pass, 0 fail (8 skip). - `bun scripts/sidebar-activity-demo-regression.ts`: 7/7 pass, rewritten for the new behavior. Pin from a right-click (focus stays on the prior element), survives a reload; Unpin with Shift+F10 (focus back on the row); pin/unpin from the `⋯` (Pinned head appears and goes, focus back on the `⋯`); a phone pins from the `⋯` sheet, and a tap on a row selects without a menu; Activity order unchanged; an opened DONE keeps its dot. - `scripts/ui-regression.ts`: the Quiet opened finishes toggle steps are removed; the BG checks stay. Not run locally (needs the test herdr session); CI's Integration and browser lane covers it. - Demo screenshots checked at 1280 and 390 px, dark and light: the menu at the `⋯`, the Pinned head, the phone sheet, and Settings → Appearance without the three toggles.
…a#1122) ## Change Wait for the phone tab menu to reflect the unzoomed layout before selecting its sibling pane in `checkTabMenu`. The reported `tab-menu-regression.ts:49` timeout is `currentPane()`, not the desktop right-click menu wait. Two focused runs constrained to one CPU reproduced the timeout after the phone's Unzoom action. Temporary event diagnostics showed both pointerdown and pointerup reaching `canvas-second`, but `canvas-first` remained current. At the tap, the reopened menu still contained: ```text Tab 1canvas-secondcanvas-firstRename paneClear pane nameUnzoom paneClose paneRename tabClose tabCancel ``` The Unzoom HTTP 200 can precede the layout snapshot reaching the browser. `App.tsx` deliberately selects the native focused pane while the tab is zoomed, so the premature sibling selection is redirected. The test now waits for the reopened sheet and for its existing “Unzoom pane” action to disappear before making the next selection. All desktop right-clicks succeeded in both diagnostic runs. Hit-testing `{x:32,y:32}` found xterm row content inside the intended pane, not a covering overlay. No product changes, retries, increased timeouts, fixed sleeps, or changes to `scripts/fixed-sleeps.json`. Temporary diagnostics were removed. ## Validation - Baseline full `CHECK_WAIT_SECONDS=7200 bun run check run --build bun scripts/ui-contexts-regression.ts`: exit 0. - Before the fix, two instrumented `taskset -c 0 bun run check run bun scripts/tab-menu-regression.ts` runs: exit **1**, both at the phone post-Unzoom `currentPane()` wait. - After the fix, the same focused command on one CPU: **five consecutive exits 0** (45.535s, 44.698s, 47.907s, 48.189s, 50.537s including the check wrapper). - Full `CHECK_WAIT_SECONDS=7200 bun run check run --build bun scripts/ui-contexts-regression.ts`, with `HERDR_TEST_MODE` unset: **two exits 0** (191.497s, 193.207s). Reports confirm unchanged source throughout every run. - `bun run typecheck`: exit **0**. - `HERDR_TEST_MODE=unit bun test ./scripts/conventions.test.ts ./scripts/ci-browser.test.ts ./src/conventions.test.ts`: exit **0**, **19 pass, 0 fail**. - Manual omowright check against a separately owned fixture at 390px: Unzoom, reopen menu, wait for Unzoom to disappear, select `canvas-second`; observed `current: "w2:p2"` and captured screenshot. Desktop and 390px/320px phone screenshots from the focused suite were also inspected. - `git diff --check`: exit **0**. LSP fresh diagnostics timed out; the compiler check above passed. Local evidence is retained under `/tmp/ulw-flaky/tab/evidence/`: baseline and two failing diagnostic reports, `focused-{1..5}-report.json`, `full-{1..2}-report.json`, their command logs/screenshots, and `manual-after-unzoom.png`. These local paths are not hosted attachments. The manual fixture's Ctrl+C teardown returned 1 because its workspace-close RPC raced herdr shutdown; independent read-back confirmed no owned processes and its HTTP port refused connections. The standard regression runners all cleaned up successfully.
…chema (devswha#1123) The Turkish UI language was removed in devswha#1081, and `sanitizeSettings` loads a stored `"tr"` as `"system"`, so no snapshot can carry it. No release has sent a settings snapshot yet (devswha#866 is in no `v*` tag), so dropping the value refuses nothing a shipped sender sends. - `shared/telemetry.ts`: `language` no longer accepts `"tr"` (type and validator). - `telemetry/worker.test.ts`: the Worker refuses a snapshot with `language: "tr"`. Every other setting is already reported (devswha#1105), so the snapshot needs nothing else. **Before the next release:** `shared/telemetry.ts` is a Worker source, so redeploy the Worker and record it (`bun scripts/release-guards.ts --worker-deployed`, commit `telemetry/deployed.json`), per telemetry/README.md#deploy. The release guard fails until then. No changelog fragment: nothing a user sees changes. Tested: `bun run typecheck`; `HERDR_TEST_MODE=unit bun test` on telemetry/worker, src/lib/settings, src/lib/api, server/telemetry and scripts/release-guards tests (101 pass).
## Change Suppress the exposure notice, `/api/health` `lan_exposure`, and no-token startup warning for binds in `100.64.0.0/10` or `fd7a:115c:a1e0::/48`: these addresses are reachable only within the tailnet. Reuse the existing address ranges only for the notice. LAN/wildcard warnings and access/auth decisions stay unchanged. The existing UI notice is not dismissible; no dismiss control was added. ## Validation - `bun run typecheck`: passed. - `HERDR_TEST_MODE=unit bun test ./server/access.test.ts`: 39 pass, 0 fail. - `bun run check fast`: passed, source verified unchanged (291 unit test files). - Isolated-herdr live HTTP/startup checks: all 8 passed for Tailscale IPv4, LAN, wildcard, and loopback binds. IPv6 range boundaries covered by unit tests; this PC has no assigned Tailscale IPv6 address. - LSP diagnostics were attempted, but the external-worktree language server could not resolve Bun/Node types; the project's actual typecheck passed.
…#1127) ## Change 폰에서는 메시지 입력창 아래의 폴더·브랜치 스트립을 그리지 않습니다. 채팅 화면이 좁은데 스트립이 대화를 가리던 문제입니다. 데스크톱은 그대로입니다. - `Composer.tsx`: 사용량 표시가 이미 쓰는 `mobile` 판정(`(max-width: 640px), (pointer: coarse)`)이 참이면 `strip`을 null로 둡니다. 스트립이 없을 때의 레이아웃(`PromptCard.css`의 `:has(~ .composer > .composer-strip)`)은 원래 있던 분기라 따로 손대지 않았습니다. - 설정은 다시 만들지 않았습니다. #1099가 "Folder and branch" 토글을 없앴기 때문에, 폰에서는 항상 끄는 쪽으로 했습니다. - `DESIGN.md`와 `docs/guide.md`에 폰에서는 스트립이 없다는 문장을 넣었고, `changelog.d/composer-strip-off-on-phones.md`를 추가했습니다. ## Validation - `bun run check fast`: 생성 타입, typecheck, build, 유닛 테스트 통과(3725 pass, 0 fail). - 브라우저에서 화면을 직접 확인하지는 않았습니다. CI의 browser lane에서 확인됩니다.
## Change The in-app alert (`Droplet`) could only be put away with a flick up or by waiting about four seconds; there was no close button. - `Droplet.tsx`: a close button (lucide `X`) beside the card, labelled "Dismiss" (already translated, e.g. 닫기). It runs the same `leave()` exit as a flick and opens nothing. It is a sibling of the card because the card is itself a button. - `Droplet.css`: the button sits over the card's right end, with the card's right padding widened so text never runs under it; it reveals, hides and drags with the card, and follows reduced motion. Tokens only. - `scripts/droplet-regression.ts`: a step that taps the close button and checks the alert leaves without opening a pane. ## Validation - `bun run typecheck`: pass. - `HERDR_TEST_MODE=unit bun test ./scripts/changelog-fold.test.ts ./src/conventions.test.ts ./src/motion.test.ts ./src/lib/i18n.test.ts ./scripts/conventions.test.ts`: 45 pass. - Manual: rendered the Droplet alone in Vite at 1200px and 390px; the button sits inside the card's right end, vertically centred, and a click dismisses it without calling `onOpen`. - The new regression step was not run locally (needs the herdr test session); it runs in the browser lane.
…swha#1130) ## Change Closes devswha#902. **Problem.** A fresh `keep_size` attach still spawns the PTY sidecar at the layout rectangle, or 80x24 when no rectangle is available. That spawn resizes the pane before the client can leave it alone. The layout is not the pane's current geometry, and herdr exposes no authoritative current-size API. **Behavior.** A tab that is not visible or lacks document focus watches the pane read-only before acquiring a new attachment, independently of `releasePaneAway`. This covers loads/reloads, background reconnects, and replacement panes. `PaneTerminal` reuses the released/watch lifecycle; the socket lets it decide before attach replay. Focus, pointerdown, or eligible terminal key input resumes attachment. No server or wire-protocol change. Exceptions and ownership: - `queueWaits` preserves the attachment for retained, in-flight, queued, or sending messages; one pane's queue does not confer ownership on its replacement. - Mirrored panes keep their existing attach/mirror path. An away pane waits for machine identity when health arrives after the roster, so it does not briefly start a watch on a mirrored PC. - Embedded pages, including the demo, retain their attachment behavior. - A server without the `watch` feature retains the existing `keep_size` attach behavior after capability discovery. - Joining an existing attachment also watches conservatively while away: the client cannot atomically distinguish a safe join from a fresh spawn if the last holder disconnects. Existing active attachments are not released solely by this fix. **The setting remains necessary.** `releasePaneAway` still releases a previously active, existing desktop attachment one second after blur/hidden and watches instead. This fix only avoids acquiring a new attachment while away. devswha#699 made release opt-in because unfocused windows, including visible second-monitor terminals, stopped updating and appeared frozen. devswha#700 added read-only watching, but older bridges still pause and reattachment still changes the stream and geometry. Keep the setting and its default off in this fix; evaluating default-on is a separate usability decision, not grounds to remove it here. The obsolete canvas-wide inherited-release flag is removed because each fresh pane now makes its own decision. A changelog fragment is included. `scripts/fixed-sleeps.json` decreases from 6 to 5 for `chat-size-regression.ts`: the background-reload assertion now waits for observable watch state rather than a fixed delay. No new fixed sleep was added. ## Validation - `bun run typecheck`: exit 0. - `HERDR_TEST_MODE=unit bun test ./src/lib/ws.test.ts ./src/lib/machineSession.test.ts ./src/lib/settings.test.ts`: **132 pass, 0 fail**. - `bun run test:unit`: **3,718 pass, 0 fail, 8 skip**, across 291 files. Rerun after correcting the required fixed-sleep ratchet. - `HERDR_TEST_MODE=unit bun test ./scripts/conventions.test.ts ./scripts/changelog-fold.test.ts`: **21 pass, 0 fail** on the final fixture; includes the no-new-fixed-sleep and shrinking-baseline checks. - `bun run check run --build bun scripts/chat-size-regression.ts`: **34 pass, 0 fail, exit 0**. Its report records unchanged source and an unchanged build produced by the run. - `bun x tsc --ignoreConfig --noEmit --strict --noUncheckedIndexedAccess --noUnusedLocals --target esnext --module esnext --moduleResolution bundler --allowImportingTsExtensions --skipLibCheck --jsx react-jsx --types bun scripts/chat-size-regression.ts`: exit 0. - `git diff --check`: exit 0. The new mount assertion failed before the fix by observing `attach` with `keep_size: true`. Browser coverage verifies preservation of a real 140x40 pane across background loads/reloads and a 132x38 replacement pane, plus reconnects, focus/pointer/key resume, queue ownership, delayed mirror identity, legacy capability fallback, cache transitions, existing-attachment joins, and an embedded iframe. Hidden-load and iframe-policy fixture failures were corrected; the iframe's framing override and loopback permission are test-context-only, with production security headers unchanged. Verified locally on Linux/Chromium with Bun 1.4.3, Node 24.18.0, and herdr 0.9.3. **Not verified on native Windows, macOS, or Safari.** Mirror behavior uses controlled identity/geometry fixtures; the deployed demo was not exercised. The build still emits its existing large-chunk warning.
Removes the "Files edited in this turn · Working tree" list that chat work blocks drew under their rows. It repeated paths the edit rows already show and added noise to every turn that touched files. - Deletes `TurnChangedFiles.tsx`/`.css` and `lib/turnChangedFiles.ts` with its test. - Drops the `cwd`/`onOpenDiff` props and `ChatDiffContext` that only fed it (App → PaneTerminal → ChatView). Files → Changes still opens diffs through `useRepoDiffViewer`. - Removes its three now-unused i18n entries (ko/ja/zh). - The feature is unreleased (devswha#951), so its line is dropped from `changelog.d/t3code-ports-and-chat-rendering.md` instead of adding a Removed entry. Verified: `bun run typecheck`; `i18n`, `src/conventions`, `scripts/conventions` and `changelog-fold` unit tests pass. No browser check run.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Move the entry from CHANGELOG.md into changelog.d/, and ignore key repeat for New tab and the two splits so a held chord makes one tab or pane. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Thanks @sergiorademacher! I pushed two commits on top:
Typecheck and the shortcut, settings, i18n and convention tests pass; CI is running. |
Change
New workspace has a shortcut, but New tab and the two splits only run from the command palette. This adds
new-tab,split-rightandsplit-downtoSHORTCUTSandCUSTOM_SHORTCUT_IDS, with the existing Mod+Shift convention:shortcut, so the hint shows the chosen key and disappears when there is none.splitPane("right" | "down")without focus, as the palette rows and herdr's prefix+v / prefix+- do. With no pane selected, the shortcut does nothing, like the palette.Validation
bun run check fast: generated types, typecheck and build pass. Unit tests: 2979 pass, 1 fail inserver/portal.test.ts, which passes 3 of 3 times on its own, with and without this change (load-related under 8 workers).src/lib/shortcuts.test.ts: no key by default, override, match, display hint, conflict. It fails whennew-tabis removed fromCUSTOM_SHORTCUT_IDS.scripts/ui-regression.ts(not committed) passed: the New tab row shows Off with no second Off, Mod+Shift+L after binding splits the pane in herdr, the palette shows the Y hint, and Mod+Shift+Y opens the New tab dialog.scripts/ui-regression.ts:311(palette resting-pointer selection) fails on untouchedmain5a9fe39 on this machine as well, so the full script was not green here.🤖 Generated with Claude Code