Skip to content

feat(shortcuts): bindable New tab, Split pane right and Split pane down - #940

Merged
devswha merged 150 commits into
devswha:mainfrom
sergiorademacher:feat/shortcuts-new-tab-split
Oct 14, 2026
Merged

devswha merged 150 commits into
devswha:mainfrom
sergiorademacher:feat/shortcuts-new-tab-split

Conversation

@sergiorademacher

Copy link
Copy Markdown
Contributor

Change

New workspace has a shortcut, but New tab and the two splits only run from the command palette. This adds new-tab, split-right and split-down to SHORTCUTS and CUSTOM_SHORTCUT_IDS, with the existing Mod+Shift convention:

  • No default key. The free Mod+Shift letters are few and several are browser-reserved (Mod+Shift+T reopens a closed tab), so each person picks one in Settings → Shortcuts.
  • For a binding without a default key, the Default option reads Off, and the separate Off option is hidden.
  • The palette rows carry shortcut, so the hint shows the chosen key and disappears when there is none.
  • The splits run 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.
  • Labels already have translations. The guide and CHANGELOG are updated.

Validation

  • bun run check fast: generated types, typecheck and build pass. Unit tests: 2979 pass, 1 fail in server/portal.test.ts, which passes 3 of 3 times on its own, with and without this change (load-related under 8 workers).
  • New unit test in src/lib/shortcuts.test.ts: no key by default, override, match, display hint, conflict. It fails when new-tab is removed from CUSTOM_SHORTCUT_IDS.
  • Browser: a temporary block in 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 untouched main 5a9fe39 on this machine as well, so the full script was not green here.

🤖 Generated with Claude Code

sergiorademacher and others added 2 commits October 12, 2026 22:14
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>
@coderabbitai

coderabbitai Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Important

Review skipped

Too 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
  • Configuration used: Repository: devswha/herdr-web-ui/.coderabbit.yaml
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 050ceb93-9313-403a-9804-e0e1a8605d40
📥 Commits

Reviewing files that changed from the base of the PR and between 4cf5fb1 and 3c7b5e8.

⛔ Files ignored due to path filters: 3
  • bun.lock is excluded by !**/*.lock, !bun.lock
  • docs/screenshots/gjc-status-completion.png is excluded by !**/*.png, !docs/screenshots/**
  • shared/herdr-api.generated.ts is excluded by !**/*.generated.*, !shared/herdr-api.generated.ts
📒 Files selected for processing: 674
  • .coderabbit.yaml
  • .github/REVIEW.md
  • .github/actions/setup-ci/action.yml
  • .github/pull_request_template.md
  • .github/workflows/ci.yml
  • .github/workflows/pages.yml
  • .github/workflows/release.yml
  • .gitignore
  • AGENTS.md
  • CHANGELOG.md
  • CONTRIBUTING.md
  • DESIGN.md
  • README.md
  • README.tr.md
  • THIRD_PARTY_NOTICES.md
  • changelog.d/README.md
  • changelog.d/agent-list-supported-only.md
  • changelog.d/agent-picker-unrelated-scroll.md
  • changelog.d/agents-pin-menu-drop-cards-quiet.md
  • changelog.d/alerts-menu-check.md
  • changelog.d/android-keyboard-keeps-pty-size.md
  • changelog.d/attach-held-split-teardown.md
  • changelog.d/attach-missing-herdr.md
  • changelog.d/attachment-limit-32-mb.md
  • changelog.d/auth-shared-failures-cap.md
  • changelog.d/browser-checks-wait-for-what-they-read.md
  • changelog.d/browser-lane-reruns-a-failed-script.md
  • changelog.d/chat-blank-under-last-message.md
  • changelog.d/chat-composer-autocorrect.md
  • changelog.d/chat-find-menu-placement.md
  • changelog.d/chat-pageup-stale-follow.md
  • changelog.d/chat-read-http-timeout.md
  • changelog.d/chat-selection-copy.md
  • changelog.d/chat-send-input-box-check.md
  • changelog.d/chat-send-keeps-focus.md
  • changelog.d/ci-health-after-the-retry.md
  • changelog.d/ci-names-the-failing-check.md
  • changelog.d/ci-runners-share-check-slots.md
  • changelog.d/claude-completed-turns.md
  • changelog.d/claude-nocolor-tip-not-a-draft.md
  • changelog.d/claude-resume-background-session.md
  • changelog.d/claude-thinking-updates.md
  • changelog.d/close-workspace-singular.md
  • changelog.d/closed-workspace-leaves-at-once.md
  • changelog.d/codex-binding-short-answers-and-resume-picker.md
  • changelog.d/codex-row-title-without-folder.md
  • changelog.d/composer-no-room.md
  • changelog.d/composer-send-progress.md
  • changelog.d/composer-stable-while-working.md
  • changelog.d/composer-strip-branch.md
  • changelog.d/composer-strip-off-on-phones.md
  • changelog.d/composer-strip-repo-and-setting.md
  • changelog.d/desktop-password-prompt-typing.md
  • changelog.d/dev-proxy-origin.md
  • changelog.d/docs-t3-features.md
  • changelog.d/drawer-closes-on-view-switch.md
  • changelog.d/drawer-escape-closes.md
  • changelog.d/drop-turkish.md
  • changelog.d/droplet-close-button.md
  • changelog.d/first-chat-of-a-started-agent.md
  • changelog.d/gjc-conversation-single-flight.md
  • changelog.d/gjc-native-turn-status.md
  • changelog.d/gjc-question-ids-stable.md
  • changelog.d/gjc-sdk-session-binding.md
  • changelog.d/held-input-backspace-and-line-breaks.md
  • changelog.d/herdr-rpc-seam.md
  • changelog.d/input-line-overlays-grid.md
  • changelog.d/install-lock-stale-recovery.md
  • changelog.d/install-ps1-gh-job-object.md
  • changelog.d/ipad-hangul-composition.md
  • changelog.d/keep-grid-unfocused-desktop.md
  • changelog.d/keybar-live-while-composing.md
  • changelog.d/keybar-off-the-pane-edge.md
  • changelog.d/late-joiner-stall-by-ack-progress.md
  • changelog.d/lazy-chat-timeline.md
  • changelog.d/macos-local-test-runs.md
  • changelog.d/math-check-late-request.md
  • changelog.d/mirror-ctrl-enter.md
  • changelog.d/mirror-secret-authority.md
  • changelog.d/no-pull-to-refresh.md
  • changelog.d/notification-tap-target.md
  • changelog.d/omo-pending-question-card.md
  • changelog.d/omo-runtime-session-binding.md
  • changelog.d/out-of-use-watch.md
  • changelog.d/pane-paging-demo-turn-flake.md
  • changelog.d/pending-held-copy-cross-tab.md
  • changelog.d/pending-not-delivered-after-leaving-page.md
  • changelog.d/pending-process-identity.md
  • changelog.d/phone-input-reclaims-terminal-size.md
  • changelog.d/phone-keyboard-keeps-pty-size.md
  • changelog.d/push-retired-endpoint-restart.md
  • changelog.d/push-retired-endpoint.md
  • changelog.d/queued-pane-navigation.md
  • changelog.d/recover-hidden-output-stall.md
  • changelog.d/relay-stall-threshold.md
  • changelog.d/release-grid-hidden-clients.md
  • changelog.d/release-grid-on-chat-return.md
  • changelog.d/remove-terminal-scrollbar.md
  • changelog.d/render-error-boundaries.md
  • changelog.d/repo-diff-windowed-plain-rows.md
  • changelog.d/return-newline.md
  • changelog.d/settings-history-entries.md
  • changelog.d/sheet-reopened-during-step-back.md
  • changelog.d/shortcuts-new-tab-split.md
  • changelog.d/split-drop-on-redrawn-node.md
  • changelog.d/stale-asset-404-and-sw-cache.md
  • changelog.d/stop-before-next-message.md
  • changelog.d/symlinked-skills.md
  • changelog.d/t3code-ports-and-chat-rendering.md
  • changelog.d/tab-switch-keeps-panes.md
  • changelog.d/tailnet-exposure-notice.md
  • changelog.d/tailscale-forwarded-login.md
  • changelog.d/telemetry-all-settings.md
  • changelog.d/telemetry-notice-one-line.md
  • changelog.d/terminal-input-autocorrect-and-key-repeat.md
  • changelog.d/terminal-input-send-during-composition.md
  • changelog.d/terminal-input-waits-for-the-snapshot.md
  • changelog.d/three-palettes.md
  • changelog.d/touch-scroll-by-finger-travel.md
  • changelog.d/trim-pane-and-header-menus.md
  • changelog.d/trim-settings-2.md
  • changelog.d/trim-settings.md
  • changelog.d/turn-time-midnight.md
  • changelog.d/ui-regression-phone-plan-and-split-drag.md
  • changelog.d/viewport-tools-idle-backoff.md
  • changelog.d/windows-closed-pipe-write.md
  • changelog.d/windows-omo-catalog.md
  • changelog.d/windows-plugin-stop-waits-tree.md
  • changelog.d/windows-remote-xdg-config-home.md
  • changelog.d/windows-test-session-socket.md
  • changelog.d/workspace-tab-name-limits.md
  • changelog.d/worktree-menu-on-repository-workspace.md
  • changelog.d/worktree-without-commits.md
  • changelog.d/ws-connect-deadline.md
  • changelog.d/ws-liveness-probe.md
  • docs/chat-mode-audit.md
  • docs/development.md
  • docs/guide.md
  • docs/terminal-flow-control.md
  • docs/terminal-input.md
  • install.ps1
  • package.json
  • patches/@legendapp%2Flist@3.3.5.patch
  • patches/@xterm%2Fxterm@5.5.0.patch
  • public/sw.js
  • release-summaries.json
  • scripts/AGENTS.md
  • scripts/alert-sound-regression.ts
  • scripts/android-ime-regression.ts
  • scripts/application-paging-regression.ts
  • scripts/build-site.ts
  • scripts/build-xterm.ts
  • scripts/changelog-fold.test.ts
  • scripts/changelog-fold.ts
  • scripts/chat-browser-qa.ts
  • scripts/chat-fallback-browser-qa.ts
  • scripts/chat-greeting-demo-regression.ts
  • scripts/chat-history-browser-qa.ts
  • scripts/chat-history-fixture.tsx
  • scripts/chat-scroll-padding-fixture.tsx
  • scripts/chat-scroll-padding-regression.ts
  • scripts/chat-selection-copy-regression.ts
  • scripts/chat-size-regression.ts
  • scripts/chat-virtual-browser-qa.ts
  • scripts/chat-virtual-fixture.tsx
  • scripts/check.test.ts
  • scripts/check.ts
  • scripts/chrome-path.ts
  • scripts/ci-browser-parallel.ts
  • scripts/ci-browser.sh
  • scripts/ci-browser.test.ts
  • scripts/ci-chromium.sh
  • scripts/ci-chromium.test.ts
  • scripts/ci-flakes.test.ts
  • scripts/ci-flakes.ts
  • scripts/ci-preinstalled-tools.sh
  • scripts/ci-runner-route.sh
  • scripts/ci-runner-route.test.ts
  • scripts/ci-setup.test.ts
  • scripts/composer-fit-demo-regression.ts
  • scripts/compressed-assets.test.ts
  • scripts/conventions.test.ts
  • scripts/deep-link-demo-regression.ts
  • scripts/demo-build.ts
  • scripts/demo-pane-layout.test.ts
  • scripts/demo-pane-move.test.ts
  • scripts/demo-submit.test.ts
  • scripts/droplet-demo-regression.ts
  • scripts/droplet-regression.ts
  • scripts/file-preview-text-browser-qa.ts
  • scripts/file-viewer-mobile-regression.ts
  • scripts/file-viewer-regression.ts
  • scripts/fixed-sleeps.json
  • scripts/fixed-sleeps.ts
  • scripts/font-swap-demo-regression.ts
  • scripts/generate-protocol-types.ts
  • scripts/gjc-status-browser-regression.ts
  • scripts/header-more.ts
  • scripts/held-rows-demo-regression.ts
  • scripts/key-bar-customization-demo-regression.ts
  • scripts/key-bar-hold-repeat-regression.ts
  • scripts/keyboard-viewport-demo-regression.ts
  • scripts/keyboard-viewport-regression.ts
  • scripts/machine-conflict-regression.ts
  • scripts/machines-browser-qa.ts
  • scripts/math-browser-qa.ts
  • scripts/mobile-tabs-regression.ts
  • scripts/mobile-viewport-regression.ts
  • scripts/narrow-pane-demo-regression.ts
  • scripts/notification-view-regression.ts
  • scripts/omo-chat-browser-qa.ts
  • scripts/output-browser-qa.ts
  • scripts/pages-gate.test.ts
  • scripts/palette-machines-demo-regression.ts
  • scripts/pane-find-regression.ts
  • scripts/pane-paging-demo-regression.ts
  • scripts/pending-pane-switch-regression.ts
  • scripts/phone-split-grid-demo-regression.ts
  • scripts/pi-image-browser-qa.ts
  • scripts/plugin-actions-demo-regression.ts
  • scripts/plugin.ts
  • scripts/preferences-sync-regression.ts
  • scripts/prompt-dock-demo-regression.ts
  • scripts/pwa-update-browser-qa.ts
  • scripts/readme-media/capture.ts
  • scripts/readme-media/compose.ts
  • scripts/recent-folders-regression.ts
  • scripts/release-guards.test.ts
  • scripts/release-guards.ts
  • scripts/release-notes.ts
  • scripts/render-crash-demo-regression.ts
  • scripts/repo-diff-browser-qa.ts
  • scripts/repo-diff-fixture.tsx
  • scripts/restored-draft-demo-regression.ts
  • scripts/secret-input-regression.ts
  • scripts/self-hosted-runner.test.ts
  • scripts/self-hosted-runner/Dockerfile
  • scripts/self-hosted-runner/README.md
  • scripts/self-hosted-runner/entrypoint.sh
  • scripts/self-hosted-runner/run.sh
  • scripts/service-worker-build.test.ts
  • scripts/service-worker-build.ts
  • scripts/settings-pages-demo-regression.ts
  • scripts/sidebar-activity-demo-regression.ts
  • scripts/skill-activity-browser-qa.ts
  • scripts/source-scan.ts
  • scripts/split-view-regression.ts
  • scripts/ssh-password-docker.ts
  • scripts/sticky-modifiers-regression.ts
  • scripts/tab-menu-regression.ts
  • scripts/telemetry-regression.ts
  • scripts/terminal-arrows-clicks-regression.ts
  • scripts/terminal-command-arrows-regression.ts
  • scripts/terminal-ctrl-enter-regression.ts
  • scripts/terminal-dispose-browser-qa.ts
  • scripts/terminal-draft-regression.ts
  • scripts/terminal-file-input-regression.ts
  • scripts/terminal-file-links-regression.ts
  • scripts/terminal-input-regression.ts
  • scripts/terminal-ipad-ime-regression.ts
  • scripts/terminal-pinch-regression.ts
  • scripts/terminal-safari-ime-evidence.ts
  • scripts/terminal-safari-ime-regression.ts
  • scripts/terminal-viewport-regression.ts
  • scripts/test-herdr.test.ts
  • scripts/test-herdr.ts
  • scripts/touch-long-press-demo-regression.ts
  • scripts/ui-contexts-checks.ts
  • scripts/ui-contexts-regression.ts
  • scripts/ui-regression.ts
  • scripts/update-browser-qa.ts
  • scripts/update-notice-regression.ts
  • scripts/usage-regression.ts
  • scripts/virtual-source-browser-qa.ts
  • scripts/virtual-source-fixture.tsx
  • scripts/windows-install.test.ps1
  • scripts/windows-lens-browser-qa.ts
  • scripts/windows-plugin.test.ts
  • scripts/workspace-rows-regression.ts
  • scripts/workspace-touch-reorder-demo-regression.ts
  • server/AGENTS.md
  • server/access.test.ts
  • server/access.ts
  • server/agy-store.ts
  • server/api.contract.test.ts
  • server/attach-held.contract.test.ts
  • server/auth.test.ts
  • server/auth.ts
  • server/chat-read-timeout.test.ts
  • server/claude-session.contract.test.ts
  • server/claude-store.test.ts
  • server/claude-store.ts
  • server/claude-turn-boundaries.test.ts
  • server/codex-binding.contract.test.ts
  • server/codex-processes.test.ts
  • server/codex.test.ts
  • server/codex.ts
  • server/commands.test.ts
  • server/commands.ts
  • server/conventions.test.ts
  • server/conversation.test.ts
  • server/conversation.ts
  • server/device-access.contract.test.ts
  • server/devices.test.ts
  • server/devices.ts
  • server/find.contract.test.ts
  • server/fixtures/omo-pending-5.1.30-extension-tail.txt
  • server/fixtures/omo-pending-5.1.30-one-narrow.txt
  • server/fixtures/omo-pending-5.1.30-one-wide.txt
  • server/fixtures/omo-pending-5.1.30-one.jsonl
  • server/fixtures/omo-pending-5.1.30-two-narrow.txt
  • server/fixtures/omo-pending-5.1.30-two-wide.txt
  • server/fixtures/omo-pending-5.1.30-two.jsonl
  • server/gjc-runtime.test.ts
  • server/gjc-runtime.ts
  • server/gjc-sdk-live.contract.test.ts
  • server/gjc-sdk.test.ts
  • server/gjc-sdk.ts
  • server/gjc-status.test.ts
  • server/gjc-status.ts
  • server/gjc.contract.test.ts
  • server/grok-store.ts
  • server/herdr/client.ts
  • server/herdr/methods.ts
  • server/herdr/seam.test.ts
  • server/hermes.test.ts
  • server/http.test.ts
  • server/index.ts
  • server/install-lock.test.ts
  • server/large-history-agents.test.ts
  • server/launched-agents.test.ts
  • server/launched-agents.ts
  • server/machine-api.ts
  • server/machine-proxy.test.ts
  • server/machine-relay.test.ts
  • server/machine-relay.ts
  • server/machines-refresh.test.ts
  • server/machines.test.ts
  • server/omo-identity.test.ts
  • server/omo-status.ts
  • server/omo.contract.test.ts
  • server/omo.test.ts
  • server/omo.ts
  • server/omp.test.ts
  • server/omp.ts
  • server/open-access.test.ts
  • server/opencode.contract.test.ts
  • server/output-window.test.ts
  • server/output-window.ts
  • server/output.contract.test.ts
  • server/pane-move.contract.test.ts
  • server/pane-move.ts
  • server/paste.ts
  • server/pending-input.contract.test.ts
  • server/pending-input.test.ts
  • server/pending-input.ts
  • server/pi-large-history.test.ts
  • server/pi-tree.ts
  • server/pi.test.ts
  • server/pi.ts
  • server/ping.contract.test.ts
  • server/preferences.test.ts
  • server/prompt.test.ts
  • server/prompt.ts
  • server/pty/session.test.ts
  • server/pty/session.ts
  • server/push.test.ts
  • server/push.ts
  • server/remote-entry.test.ts
  • server/remote-entry.ts
  • server/remote-host.ts
  • server/repo-diff.test.ts
  • server/repo-diff.ts
  • server/retention-budget.ts
  • server/shell-agent.test.ts
  • server/shell-agent.ts
  • server/ssh.ts
  • server/start-screen.test.ts
  • server/start-screen.ts
  • server/static-encoding.test.ts
  • server/static.ts
  • server/submit.contract.test.ts
  • server/transcript-records.ts
  • server/windows-processes.ts
  • shared/AGENTS.md
  • shared/attachments.ts
  • shared/labels.ts
  • shared/preferences.ts
  • shared/protocol.ts
  • shared/supported-agents.ts
  • shared/telemetry.ts
  • shared/update.ts
  • site/demo/history.ts
  • site/demo/transport.ts
  • src/AGENTS.md
  • src/App.tsx
  • src/components/AgentPicker.tsx
  • src/components/AgentSidebar.css
  • src/components/AgentSidebar.tsx
  • src/components/AnimatedHeight.css
  • src/components/AnimatedHeight.tsx
  • src/components/AppCrash.css
  • src/components/AppCrash.tsx
  • src/components/ChatFind.css
  • src/components/ChatFind.tsx
  • src/components/ChatMinimap.css
  • src/components/ChatMinimap.tsx
  • src/components/ChatTimeline.css
  • src/components/ChatTimeline.tsx
  • src/components/ChatView.css
  • src/components/ChatView.tsx
  • src/components/ChunkFailureNotice.test.tsx
  • src/components/CommandPalette.css
  • src/components/CommandPalette.tsx
  • src/components/Composer.css
  • src/components/Composer.tsx
  • src/components/ComposerNotices.css
  • src/components/ComposerNotices.tsx
  • src/components/DevicesPanel.tsx
  • src/components/DiffWorkerPool.tsx
  • src/components/DirectoryBrowser.tsx
  • src/components/Droplet.css
  • src/components/Droplet.tsx
  • src/components/FileGlyph.tsx
  • src/components/FileReader.tsx
  • src/components/FileViewer.css
  • src/components/FileViewer.tsx
  • src/components/FilesDialog.css
  • src/components/FilesDialog.tsx
  • src/components/HighlightedCode.css
  • src/components/HighlightedCode.tsx
  • src/components/ImageLightbox.css
  • src/components/ImageLightbox.tsx
  • src/components/KeyBar.css
  • src/components/KeyBar.tsx
  • src/components/MachineSidebar.tsx
  • src/components/Markdown.tsx
  • src/components/MermaidLightbox.css
  • src/components/MermaidLightbox.tsx
  • src/components/MovePaneMenu.css
  • src/components/MovePaneMenu.tsx
  • src/components/NewSessionDialog.tsx
  • src/components/PaneCanvas.css
  • src/components/PaneCanvas.tsx
  • src/components/PaneTerminal.css
  • src/components/PaneTerminal.tsx
  • src/components/PendingMessages.test.tsx
  • src/components/PendingMessages.tsx
  • src/components/PromptCard.css
  • src/components/PushTestControls.tsx
  • src/components/RenderBoundary.tsx
  • src/components/RepoChanges.css
  • src/components/RepoChanges.tsx
  • src/components/RepoDiff.css
  • src/components/RepoDiff.tsx
  • src/components/RepoDiffViewer.css
  • src/components/RepoDiffViewer.tsx
  • src/components/RowMenu.tsx
  • src/components/SecretInput.css
  • src/components/SettingsDialog.css
  • src/components/SettingsDialog.tsx
  • src/components/Sidebar.css
  • src/components/Sidebar.tsx
  • src/components/Skeleton.css
  • src/components/Skeleton.tsx
  • src/components/TabStrip.tsx
  • src/components/TelemetryControls.tsx
  • src/components/TerminalInput.css
  • src/components/TerminalInput.tsx
  • src/components/TerminalViewportTools.css
  • src/components/TerminalViewportTools.tsx
  • src/components/TextFileView.tsx
  • src/components/TurnTime.test.tsx
  • src/components/TurnTime.tsx
  • src/components/UpdateControls.tsx
  • src/components/UsageMeters.css
  • src/components/UsageMeters.tsx
  • src/components/VirtualSource.css
  • src/components/VirtualSource.tsx
  • src/components/WorkspaceFavicon.tsx
  • src/components/WorktreeDialog.tsx
  • src/components/chatTimelineLoader.ts
  • src/conventions.test.ts
  • src/lib/actions.ts
  • src/lib/animatedHeight.test.ts
  • src/lib/animatedHeight.ts
  • src/lib/api.test.ts
  • src/lib/api.ts
  • src/lib/changeTree.test.ts
  • src/lib/changeTree.ts
  • src/lib/chatMinimap.test.ts
  • src/lib/chatMinimap.ts
  • src/lib/chatRowState.tsx
  • src/lib/chatRows.test.ts
  • src/lib/chatRows.ts
  • src/lib/chatScrollMemory.test.ts
  • src/lib/chatScrollMemory.ts
  • src/lib/chatSearch.test.ts
  • src/lib/chatSearch.ts
  • src/lib/chatTimestamp.test.ts
  • src/lib/chatTimestamp.ts
  • src/lib/clipboard.ts
  • src/lib/compactContext.test.ts
  • src/lib/compactContext.ts
  • src/lib/compose.test.ts
  • src/lib/compose.ts
  • src/lib/composerDraft.test.ts
  • src/lib/composerImageMime.test.ts
  • src/lib/composerImageMime.ts
  • src/lib/composerLabelFit.test.ts
  • src/lib/composerLabelFit.ts
  • src/lib/composerLength.test.ts
  • src/lib/composerLength.ts
  • src/lib/composerMenuHighlight.test.ts
  • src/lib/composerMenuHighlight.ts
  • src/lib/composerNotices.test.ts
  • src/lib/composerNotices.ts
  • src/lib/composerStrip.test.ts
  • src/lib/composerStrip.ts
  • src/lib/conversationFetch.test.ts
  • src/lib/conversationRefresh.ts
  • src/lib/crashProbe.ts
  • src/lib/deepLink.test.ts
  • src/lib/deepLink.ts
  • src/lib/draft.test.ts
  • src/lib/draft.ts
  • src/lib/fileIcons.test.ts
  • src/lib/fileIcons.ts
  • src/lib/filePaths.test.ts
  • src/lib/filePaths.ts
  • src/lib/filePreviewText.test.ts
  • src/lib/filePreviewText.ts
  • src/lib/followScroll.test.ts
  • src/lib/followScroll.ts
  • src/lib/fontFamily.ts
  • src/lib/i18n.ja.ts
  • src/lib/i18n.ko.ts
  • src/lib/i18n.test.ts
  • src/lib/i18n.tr.ts
  • src/lib/i18n.ts
  • src/lib/i18n.zh.ts
  • src/lib/idleLease.test.ts
  • src/lib/idleLease.ts
  • src/lib/imageBox.test.ts
  • src/lib/imageBox.ts
  • src/lib/imageViewer.test.ts
  • src/lib/imageViewer.ts
  • src/lib/installedFonts.test.ts
  • src/lib/installedFonts.ts
  • src/lib/keyRepeat.test.ts
  • src/lib/keyRepeat.ts
  • src/lib/keyboard.test.ts
  • src/lib/keyboard.ts
  • src/lib/keys.test.ts
  • src/lib/keys.ts
  • src/lib/lazyChunk.test.ts
  • src/lib/lazyChunk.ts
  • src/lib/localDay.test.ts
  • src/lib/localDay.ts
  • src/lib/machineContext.tsx
  • src/lib/markdown.test.ts
  • src/lib/markdown.ts
  • src/lib/markdownClipboard.test.ts
  • src/lib/markdownClipboard.ts
  • src/lib/notificationTarget.test.ts
  • src/lib/notificationTarget.ts
  • src/lib/notifications.test.ts
  • src/lib/notifications.ts
  • src/lib/pageScroll.ts
  • src/lib/paletteFolders.test.ts
  • src/lib/paletteFolders.ts
  • src/lib/paletteFor.test.ts
  • src/lib/paletteFor.ts
  • src/lib/paletteSeed.test.ts
  • src/lib/paletteSeed.ts
  • src/lib/paletteSettings.test.ts
  • src/lib/paletteSettings.ts
  • src/lib/paneAwayContext.ts
  • src/lib/paneBranch.test.ts
  • src/lib/paneBranch.ts
  • src/lib/paneCommandCache.ts
  • src/lib/paneLens.test.ts
  • src/lib/paneLens.ts
  • src/lib/paneMove.test.ts
  • src/lib/paneMove.ts
  • src/lib/paneName.test.ts
  • src/lib/paneName.ts
  • src/lib/paneSubmitContext.ts
  • src/lib/paneSubmitRetention.ts
  • src/lib/paneTabCache.test.ts
  • src/lib/paneTabCache.ts
  • src/lib/pendingMessages.test.ts
  • src/lib/pendingMessages.ts
  • src/lib/preferencesSync.test.ts
  • src/lib/projectFavicon.test.ts
  • src/lib/projectFavicon.ts
  • src/lib/push.test.ts
  • src/lib/push.ts
  • src/lib/repoDiff.test.ts
  • src/lib/repoDiff.ts
  • src/lib/settings.test.ts
  • src/lib/settings.ts
  • src/lib/settingsHistory.test.ts
  • src/lib/settingsHistory.ts
  • src/lib/sheetHistory.test.ts
  • src/lib/sheetHistory.ts
  • src/lib/shortcutRecorder.test.ts
  • src/lib/shortcutRecorder.ts
  • src/lib/shortcuts.ts
  • src/lib/sidebarActivity.tsx
  • src/lib/sidebarAgents.test.ts
  • src/lib/sidebarAgents.ts
  • src/lib/sidebarOrder.test.ts
  • src/lib/sidebarOrder.ts
  • src/lib/sidebarPins.test.ts
  • src/lib/sidebarPins.ts
  • src/lib/skeleton.test.ts
  • src/lib/skeleton.ts
  • src/lib/skillMentions.test.ts
  • src/lib/skillMentions.ts
  • src/lib/sourcePreview.test.ts
  • src/lib/sourcePreview.ts
  • src/lib/sourceTheme.ts
  • src/lib/terminalFileLinks.test.ts
  • src/lib/terminalFileLinks.ts
  • src/lib/terminalGlyphs.ts
  • src/lib/terminalQuote.test.ts
  • src/lib/terminalQuote.ts
  • src/lib/terminalViewport.test.ts
  • src/lib/terminalViewport.ts
  • src/lib/textPreview.test.ts
  • src/lib/textPreview.ts
  • src/lib/toolVerbs.test.ts
  • src/lib/toolVerbs.ts
  • src/lib/typography.test.ts
  • src/lib/typography.ts
  • src/lib/uncaughtErrors.test.ts
  • src/lib/uncaughtErrors.ts
  • src/lib/updateNotes.test.ts
  • src/lib/updateNotes.ts
  • src/lib/updates.ts
  • src/lib/usage.test.ts
  • src/lib/usage.ts
  • src/lib/useFileViewer.ts
  • src/lib/useRepoDiffViewer.ts
  • src/lib/useSkillMatcher.ts
  • src/lib/useTabReorder.ts
  • src/lib/useWorktreeBranches.ts
  • src/lib/userMessageFold.test.ts
  • src/lib/userMessageFold.ts
  • src/lib/viewport.test.ts
  • src/lib/viewport.ts
  • src/lib/viewportResume.test.ts
  • src/lib/viewportResume.ts
  • src/lib/wheelSteps.test.ts
  • src/lib/wheelSteps.ts
  • src/lib/workFoldHeight.test.ts
  • src/lib/workFoldHeight.ts
  • src/lib/workRowLabel.test.ts
  • src/lib/workRowLabel.ts
  • src/lib/workspacePath.test.ts
  • src/lib/workspacePath.ts
  • src/lib/ws.test.ts
  • src/lib/ws.ts
  • src/main.tsx
  • src/pwa-notification.test.ts
  • src/pwa.test.ts
  • src/styles.css
  • telemetry/README.md
  • telemetry/deployed.json
  • telemetry/worker.test.ts
  • tsconfig.json
  • tsconfig.tools.json
  • vite.config.ts

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository: devswha/herdr-web-ui/.coderabbit.yaml
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 61cd4a77-2bda-4047-aa0b-0d415776e03a
📥 Commits

Reviewing files that changed from the base of the PR and between 5a9fe39 and 4cf5fb1.

📒 Files selected for processing (7)
  • CHANGELOG.md
  • docs/guide.md
  • src/components/CommandPalette.tsx
  • src/components/SettingsDialog.tsx
  • src/lib/shortcutBindings.ts
  • src/lib/shortcuts.test.ts
  • src/lib/shortcuts.ts

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

📝 Summary

Summary by CodeRabbit

  • New Features
    • New tab, Split pane right, and Split pane down can now be assigned shortcuts in Settings → Shortcuts. They have no default bindings, and assigned shortcuts appear in the command palette.
    • Split-pane actions appear in the command palette when split-pane support is available.

The shortcut registry adds New tab, Split pane right, and Split pane down without default bindings. Users can assign shortcuts in Settings, and the command palette and documentation show the configured shortcuts.

Changes: Configurable tab and pane shortcuts

Layer / File(s) Summary
Register and dispatch shortcut actions
src/lib/shortcutBindings.ts, src/lib/shortcuts.ts, src/lib/shortcuts.test.ts
The registry adds three actions without default keys. Dispatch handles New tab and optional right or down pane splits. Tests cover configured bindings, display formatting, and conflicts.
Show and configure shortcut bindings
src/components/SettingsDialog.tsx, src/components/CommandPalette.tsx, docs/guide.md, CHANGELOG.md
Settings distinguish unbound shortcuts from shortcuts with default keys. The New tab palette action advertises its shortcut. The guide and changelog describe the configurable shortcuts.

Priority: ⬇️ Low

Estimated code review effort: 2 (Simple) | ~10 minutes

Change: Feature

Suggested reviewers: devswha

Merge Risk: ⚪ Minimal · up to 4cf5f

The configurable actions are wired to their handlers, and no concrete merge-blocking risk is established.

Pre-merge checks: Passed 4 · Failed 1

Failed Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 3 functions across 5 files. (2 skipped: 2 … Write docstrings for the functions missing them to satisfy the coverage threshold.
Passed Passed checks (4 passed)
Check name Status Explanation
Title check Passed The title clearly and concisely describes the main change: configurable shortcuts for New tab, Split pane right, and Split pane down.
Description check Passed The description includes the required Change and Validation sections. It explains the behavior, configuration details, test coverage, known flaky test, and browser validation results.
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Full details: Docstring Coverage

Explanation

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

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

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

❤️ Share

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

devswha and others added 27 commits October 12, 2026 21:43
…#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>
demrom and others added 28 commits October 14, 2026 11:58
## 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:

![GJC completion status in the Agents
list](https://raw.githubusercontent.com/Haeminway1/herdr-web-ui/fix/gjc-completion-state/docs/screenshots/gjc-status-completion.png)

---------

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>
@devswha

devswha commented Oct 14, 2026

Copy link
Copy Markdown
Owner

Thanks @sergiorademacher! I pushed two commits on top:

  • a merge of main (Settings → Shortcuts gained Record and search meanwhile; your unbound Default/Off handling is kept on main's Record row, and the guide paragraph sits before main's new Record paragraph);
  • the CHANGELOG.md entry moved to changelog.d/shortcuts-new-tab-split.md (PRs no longer edit CHANGELOG.md, see changelog.d/README.md), and key repeat is ignored for New tab and the two splits, so holding a bound chord makes one tab or pane instead of one per repeat.

Typecheck and the shortcut, settings, i18n and convention tests pass; CI is running.

@devswha
devswha merged commit ea8c4b1 into devswha:main Oct 14, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

8 participants