diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index d9f19dcc1b8..2f91c946bc7 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -69,6 +69,14 @@ jobs: - name: Check reliability gate manifest run: pnpm run check:reliability-gates + # Why: oxlint fails any file over max-lines that is NOT suppressed, so this + # ratchet forbids ADDING a new suppression (inline disable or mobile max + # bump). Existing oversized files are grandfathered in + # config/max-lines-baseline.txt, which may only shrink — new bypasses fail + # here with a clear message instead of silently growing the debt. + - name: Enforce max-lines ratchet (no new bypasses) + run: pnpm run check:max-lines-ratchet + # Why: project-owned type declarations must live in .ts so tsc # actually checks them. TypeScript's skipLibCheck: true (inherited # from @electron-toolkit/tsconfig) silently widens unresolved names diff --git a/AGENTS.md b/AGENTS.md index 647c598a25e..b10035e9b17 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,7 +12,9 @@ Keep comments short — one or two lines. Capture only the non-obvious reason (s ## Lint Rules: Do Not Disable Max Lines -Never add a `max-lines` disable (`eslint-disable max-lines`, `oxlint-disable max-lines`, or line-specific variants). Split the file, extract focused modules, move fixtures/builders into named files, or otherwise reduce the counted lines instead. +Never add a `max-lines` disable (`eslint-disable max-lines`, `oxlint-disable max-lines`, or line-specific variants), and never add a per-file `max-lines` bump in `mobile/.oxlintrc.json`. Split the file, extract focused modules, move fixtures/builders into named files, or otherwise reduce the counted lines instead. + +This is enforced in CI by `pnpm check:max-lines-ratchet` (`config/scripts/check-max-lines-ratchet.mjs`). Files already over the limit are grandfathered in `config/max-lines-baseline.txt`; that list may only **shrink**. Adding a new suppression fails the build. If you remove a suppression (great — split it!), run `pnpm check:max-lines-ratchet --prune` to drop its baseline line. ## File and Module Naming diff --git a/README.md b/README.md index 1043bc9d670..8e9bc05d3f3 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ Monitor and steer your agents from your phone — get notified when an agent finishes and send follow-ups from anywhere. -[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.22](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) +[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.24](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.24/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) @@ -230,7 +230,7 @@ yay -S stably-orca-bin Pair with your desktop app to monitor and steer your agents from your phone. - **iOS:** [Download on the App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) or [join TestFlight](https://testflight.apple.com/join/YjeGMQBA) -- **Android:** [Download APK 0.0.22](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk) +- **Android:** [Download APK 0.0.24](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.24/app-release.apk) --- diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt new file mode 100644 index 00000000000..543ff2e6a60 --- /dev/null +++ b/config/max-lines-baseline.txt @@ -0,0 +1,359 @@ +# Files/globs currently allowed to exceed the oxlint `max-lines` budget. +# This is a RATCHET: the list may only SHRINK. Do NOT add entries to get CI green — +# split the oversized file instead (AGENTS.md → "Do Not Disable Max Lines"). +# Regenerate/prune: pnpm check:max-lines-ratchet --prune (removes stale entries only) +inline mobile/src/constants/marine-creatures.ts +inline src/cli/handlers/automations.ts +inline src/cli/handlers/orchestration.ts +inline src/cli/help.ts +inline src/cli/index.test.ts +inline src/main/agent-hooks/server.test.ts +inline src/main/agent-hooks/server.ts +inline src/main/amp/hook-service.ts +inline src/main/antigravity/hook-service.ts +inline src/main/attribution/terminal-attribution.ts +inline src/main/automations/external-manager.ts +inline src/main/automations/hermes-cron-output.ts +inline src/main/browser/agent-browser-bridge.test.ts +inline src/main/browser/agent-browser-bridge.ts +inline src/main/browser/browser-cookie-import.ts +inline src/main/browser/browser-guest-ui.ts +inline src/main/browser/browser-manager-grab.test.ts +inline src/main/browser/browser-manager.test.ts +inline src/main/browser/browser-manager.ts +inline src/main/browser/browser-screencast-stream.ts +inline src/main/browser/browser-session-registry.ts +inline src/main/browser/cdp-bridge.ts +inline src/main/browser/cdp-ws-proxy.ts +inline src/main/browser/grab-guest-script.ts +inline src/main/browser/snapshot-engine.ts +inline src/main/claude-accounts/runtime-auth-service.test.ts +inline src/main/claude-accounts/runtime-auth-service.ts +inline src/main/claude-accounts/service.test.ts +inline src/main/claude-accounts/service.ts +inline src/main/claude-usage/scanner.ts +inline src/main/claude-usage/store.ts +inline src/main/cli/cli-installer.test.ts +inline src/main/cli/cli-installer.ts +inline src/main/cli/wsl-cli-installer.ts +inline src/main/codex-accounts/runtime-home-service.test.ts +inline src/main/codex-accounts/runtime-home-service.ts +inline src/main/codex-accounts/service.test.ts +inline src/main/codex-accounts/service.ts +inline src/main/codex-usage/scanner.ts +inline src/main/codex-usage/store.test.ts +inline src/main/codex-usage/store.ts +inline src/main/codex/config-toml-trust.test.ts +inline src/main/codex/config-toml-trust.ts +inline src/main/codex/hook-service.test.ts +inline src/main/codex/hook-service.ts +inline src/main/copilot/hook-service.ts +inline src/main/daemon/client.ts +inline src/main/daemon/daemon-health.ts +inline src/main/daemon/daemon-init.test.ts +inline src/main/daemon/daemon-init.ts +inline src/main/daemon/daemon-pty-adapter.test.ts +inline src/main/daemon/daemon-pty-adapter.ts +inline src/main/daemon/daemon-server.ts +inline src/main/daemon/pty-subprocess.test.ts +inline src/main/daemon/pty-subprocess.ts +inline src/main/daemon/session.ts +inline src/main/daemon/shell-ready.ts +inline src/main/git/remove-worktree.test.ts +inline src/main/git/repo.ts +inline src/main/git/runner.ts +inline src/main/git/status.test.ts +inline src/main/git/status.ts +inline src/main/git/worktree.test.ts +inline src/main/git/worktree.ts +inline src/main/github/client.test.ts +inline src/main/github/client.ts +inline src/main/github/issues.ts +inline src/main/github/pr-refresh-coordinator.test.ts +inline src/main/github/pr-refresh-coordinator.ts +inline src/main/github/project-view.ts +inline src/main/github/project-view/mutations.ts +inline src/main/github/work-item-details.ts +inline src/main/gitlab/client-mr.test.ts +inline src/main/gitlab/client.ts +inline src/main/gitlab/issues.ts +inline src/main/gitlab/work-item-details.ts +inline src/main/hermes/hook-service.ts +inline src/main/hooks.test.ts +inline src/main/hooks.ts +inline src/main/index.ts +inline src/main/ipc/browser.ts +inline src/main/ipc/crash-reporting.ts +inline src/main/ipc/filesystem-auth.ts +inline src/main/ipc/filesystem-mutations.ts +inline src/main/ipc/filesystem-watcher.ts +inline src/main/ipc/filesystem.test.ts +inline src/main/ipc/filesystem.ts +inline src/main/ipc/github.test.ts +inline src/main/ipc/github.ts +inline src/main/ipc/gitlab.ts +inline src/main/ipc/linear.ts +inline src/main/ipc/notifications.test.ts +inline src/main/ipc/notifications.ts +inline src/main/ipc/pet.ts +inline src/main/ipc/preflight.test.ts +inline src/main/ipc/pty.test.ts +inline src/main/ipc/pty.ts +inline src/main/ipc/remote-workspace.ts +inline src/main/ipc/repos-remote.test.ts +inline src/main/ipc/repos.ts +inline src/main/ipc/runtime-environments.test.ts +inline src/main/ipc/ssh.test.ts +inline src/main/ipc/ssh.ts +inline src/main/ipc/worktree-remote.ts +inline src/main/ipc/worktrees.test.ts +inline src/main/ipc/worktrees.ts +inline src/main/jira/client.ts +inline src/main/jira/issues.ts +inline src/main/keybindings/keybinding-file.ts +inline src/main/linear/client.ts +inline src/main/linear/issues.ts +inline src/main/linear/projects.ts +inline src/main/memory/collector.ts +inline src/main/opencode-usage/scanner.ts +inline src/main/opencode-usage/store.ts +inline src/main/opencode/hook-service.ts +inline src/main/persistence.test.ts +inline src/main/persistence.ts +inline src/main/ports/advertised-url-watcher.ts +inline src/main/ports/local-workspace-port-scanner.ts +inline src/main/project-groups/nested-repo-discovery.ts +inline src/main/providers/local-pty-provider.test.ts +inline src/main/providers/local-pty-provider.ts +inline src/main/providers/local-pty-shell-ready.test.ts +inline src/main/providers/local-pty-shell-ready.ts +inline src/main/providers/ssh-git-provider.test.ts +inline src/main/providers/ssh-git-provider.ts +inline src/main/rate-limits/claude-fetcher.test.ts +inline src/main/rate-limits/claude-fetcher.ts +inline src/main/rate-limits/claude-pty.ts +inline src/main/rate-limits/codex-fetcher.ts +inline src/main/rate-limits/service.test.ts +inline src/main/rate-limits/service.ts +inline src/main/runtime/orca-runtime-browser.ts +inline src/main/runtime/orca-runtime-files.test.ts +inline src/main/runtime/orca-runtime-files.ts +inline src/main/runtime/orca-runtime-git.ts +inline src/main/runtime/orca-runtime.test.ts +inline src/main/runtime/orca-runtime.ts +inline src/main/runtime/orchestration/coordinator.ts +inline src/main/runtime/orchestration/db.ts +inline src/main/runtime/rpc/methods/files.ts +inline src/main/runtime/rpc/methods/git.ts +inline src/main/runtime/rpc/methods/github.ts +inline src/main/runtime/rpc/methods/orchestration.test.ts +inline src/main/runtime/rpc/methods/orchestration.ts +inline src/main/runtime/rpc/methods/terminal.ts +inline src/main/runtime/rpc/terminal-multiplex.test.ts +inline src/main/runtime/runtime-rpc.test.ts +inline src/main/runtime/runtime-rpc.ts +inline src/main/source-control/hosted-review-creation.ts +inline src/main/speech/model-manager.ts +inline src/main/speech/stt-service.ts +inline src/main/ssh/ssh-channel-multiplexer.ts +inline src/main/ssh/ssh-connection.test.ts +inline src/main/ssh/ssh-connection.ts +inline src/main/ssh/ssh-relay-deploy.ts +inline src/main/ssh/ssh-relay-session.ts +inline src/main/star-nag/service.test.ts +inline src/main/text-generation/commit-message-text-generation.test.ts +inline src/main/text-generation/commit-message-text-generation.ts +inline src/main/updater.test.ts +inline src/main/updater.ts +inline src/main/window/attach-main-window-services.ts +inline src/main/window/createMainWindow.test.ts +inline src/main/window/createMainWindow.ts +inline src/main/workspace-space-analysis.ts +inline src/preload/api-types.ts +inline src/preload/index.ts +inline src/relay/agent-hook-server.ts +inline src/relay/dispatcher.ts +inline src/relay/external-automations-handler.ts +inline src/relay/fs-handler.ts +inline src/relay/git-handler.test.ts +inline src/relay/git-handler.ts +inline src/relay/pty-handler.test.ts +inline src/relay/pty-handler.ts +inline src/relay/relay.ts +inline src/relay/workspace-space-scan.ts +inline src/renderer/src/App.tsx +inline src/renderer/src/components/GitHubItemDialog.tsx +inline src/renderer/src/components/GitLabItemDialog.tsx +inline src/renderer/src/components/JiraIssueWorkspace.tsx +inline src/renderer/src/components/LinearIssueWorkspace.tsx +inline src/renderer/src/components/LinearItemDrawer.tsx +inline src/renderer/src/components/NewWorkspaceComposerCard.tsx +inline src/renderer/src/components/PullRequestPage.tsx +inline src/renderer/src/components/TaskPage.tsx +inline src/renderer/src/components/Terminal.tsx +inline src/renderer/src/components/UpdateCard.tsx +inline src/renderer/src/components/WorktreeJumpPalette.tsx +inline src/renderer/src/components/activity/ActivityPrototypePage.test.ts +inline src/renderer/src/components/activity/ActivityPrototypePage.tsx +inline src/renderer/src/components/automations/AutomationsPage.tsx +inline src/renderer/src/components/browser-pane/BrowserPane.tsx +inline src/renderer/src/components/diff-comments/useDiffCommentDecorator.tsx +inline src/renderer/src/components/editor/CombinedDiffViewer.tsx +inline src/renderer/src/components/editor/EditorContent.tsx +inline src/renderer/src/components/editor/IpynbViewer.tsx +inline src/renderer/src/components/editor/MarkdownPreview.tsx +inline src/renderer/src/components/editor/MonacoEditor.tsx +inline src/renderer/src/components/editor/editor-autosave-controller.ts +inline src/renderer/src/components/editor/ipynb-parse.ts +inline src/renderer/src/components/editor/useEditorPanelContentState.ts +inline src/renderer/src/components/feature-wall/BrowserAnimatedVisual.tsx +inline src/renderer/src/components/feature-wall/EditorAnimatedVisual.tsx +inline src/renderer/src/components/feature-wall/WorkbenchAnimatedVisual.tsx +inline src/renderer/src/components/floating-terminal/FloatingTerminalPanel.test.tsx +inline src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx +inline src/renderer/src/components/github-project/ProjectCell.tsx +inline src/renderer/src/components/github-project/ProjectPicker.tsx +inline src/renderer/src/components/github-project/ProjectViewWrapper.tsx +inline src/renderer/src/components/linear-project-view-surfaces.tsx +inline src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx +inline src/renderer/src/components/onboarding/use-onboarding-flow.ts +inline src/renderer/src/components/right-sidebar/ChecksPanel.tsx +inline src/renderer/src/components/right-sidebar/FileExplorer.test.tsx +inline src/renderer/src/components/right-sidebar/FileExplorer.tsx +inline src/renderer/src/components/right-sidebar/FileExplorerRow.tsx +inline src/renderer/src/components/right-sidebar/PortsPanel.tsx +inline src/renderer/src/components/right-sidebar/SourceControl.tsx +inline src/renderer/src/components/right-sidebar/checks-panel-content.tsx +inline src/renderer/src/components/right-sidebar/index.tsx +inline src/renderer/src/components/right-sidebar/source-control-dropdown-items.ts +inline src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts +inline src/renderer/src/components/right-sidebar/useFileExplorerDragDrop.ts +inline src/renderer/src/components/settings/AccountsPane.tsx +inline src/renderer/src/components/settings/AgentsPane.tsx +inline src/renderer/src/components/settings/RepositoryHooksSection.tsx +inline src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx +inline src/renderer/src/components/settings/Settings.tsx +inline src/renderer/src/components/settings/SettingsFormControls.tsx +inline src/renderer/src/components/sidebar/RemoteFileBrowser.tsx +inline src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx +inline src/renderer/src/components/sidebar/WorktreeCard.tsx +inline src/renderer/src/components/sidebar/WorktreeContextMenu.tsx +inline src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts +inline src/renderer/src/components/sidebar/WorktreeList.tsx +inline src/renderer/src/components/sidebar/use-workspace-kanban-area-selection.ts +inline src/renderer/src/components/sidebar/worktree-list-groups.test.ts +inline src/renderer/src/components/sidebar/worktree-list-groups.ts +inline src/renderer/src/components/stats/usage-overview-model.ts +inline src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx +inline src/renderer/src/components/status-bar/StatusBar.tsx +inline src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx +inline src/renderer/src/components/tab-bar/TabBar.tsx +inline src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts +inline src/renderer/src/components/tab-group/useTabDragSplit.ts +inline src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts +inline src/renderer/src/components/task-page-cache-selectors.ts +inline src/renderer/src/components/terminal-pane/TerminalPane.tsx +inline src/renderer/src/components/terminal-pane/agent-completion-coordinator.test.ts +inline src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts +inline src/renderer/src/components/terminal-pane/keyboard-handlers.ts +inline src/renderer/src/components/terminal-pane/pty-connection.test.ts +inline src/renderer/src/components/terminal-pane/pty-connection.ts +inline src/renderer/src/components/terminal-pane/pty-transport.test.ts +inline src/renderer/src/components/terminal-pane/pty-transport.ts +inline src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts +inline src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts +inline src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts +inline src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts +inline src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts +inline src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +inline src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx +inline src/renderer/src/hooks/useAutomationDispatchEvents.ts +inline src/renderer/src/hooks/useComposerState.ts +inline src/renderer/src/hooks/useEditorExternalWatch.ts +inline src/renderer/src/hooks/useIpcEvents.test.ts +inline src/renderer/src/hooks/useIpcEvents.ts +inline src/renderer/src/hooks/useIssueMetadata.ts +inline src/renderer/src/hooks/useSettingsNavigationMetadata.ts +inline src/renderer/src/lib/active-agent-note-send.test.ts +inline src/renderer/src/lib/file-type-icons.ts +inline src/renderer/src/lib/pane-manager/pane-manager.ts +inline src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts +inline src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts +inline src/renderer/src/lib/pane-manager/pane-tree-ops.ts +inline src/renderer/src/lib/terminal-links.ts +inline src/renderer/src/lib/worktree-activation.test.ts +inline src/renderer/src/lib/worktree-activation.ts +inline src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts +inline src/renderer/src/runtime/runtime-file-client.test.ts +inline src/renderer/src/runtime/runtime-file-client.ts +inline src/renderer/src/runtime/runtime-git-client.ts +inline src/renderer/src/runtime/runtime-linear-client.ts +inline src/renderer/src/runtime/sync-runtime-graph.test.ts +inline src/renderer/src/runtime/sync-runtime-graph.ts +inline src/renderer/src/runtime/web-runtime-session.test.ts +inline src/renderer/src/runtime/web-runtime-session.ts +inline src/renderer/src/runtime/web-session-tabs-sync.test.ts +inline src/renderer/src/runtime/web-session-tabs-sync.ts +inline src/renderer/src/store/slices/agent-status.test.ts +inline src/renderer/src/store/slices/agent-status.ts +inline src/renderer/src/store/slices/browser.test.ts +inline src/renderer/src/store/slices/browser.ts +inline src/renderer/src/store/slices/diffComments.ts +inline src/renderer/src/store/slices/editor.test.ts +inline src/renderer/src/store/slices/editor.ts +inline src/renderer/src/store/slices/github.test.ts +inline src/renderer/src/store/slices/github.ts +inline src/renderer/src/store/slices/hosted-review.ts +inline src/renderer/src/store/slices/jira.ts +inline src/renderer/src/store/slices/linear.test.ts +inline src/renderer/src/store/slices/linear.ts +inline src/renderer/src/store/slices/repos.ts +inline src/renderer/src/store/slices/store-cascades.test.ts +inline src/renderer/src/store/slices/store-session-cascades.test.ts +inline src/renderer/src/store/slices/tabs.test.ts +inline src/renderer/src/store/slices/tabs.ts +inline src/renderer/src/store/slices/terminals.ts +inline src/renderer/src/store/slices/ui.test.ts +inline src/renderer/src/store/slices/ui.ts +inline src/renderer/src/store/slices/workspace-cleanup.ts +inline src/renderer/src/store/slices/worktrees.test.ts +inline src/renderer/src/store/slices/worktrees.ts +inline src/renderer/src/web/web-preload-api.test.ts +inline src/renderer/src/web/web-preload-api.ts +inline src/renderer/src/web/web-runtime-client.ts +inline src/shared/agent-hook-listener.test.ts +inline src/shared/agent-hook-listener.ts +inline src/shared/automation-schedules.ts +inline src/shared/commit-message-agent-spec.ts +inline src/shared/constants.ts +inline src/shared/github-project-types.ts +inline src/shared/keybindings.test.ts +inline src/shared/keybindings.ts +inline src/shared/marine-creatures.ts +inline src/shared/remote-runtime-client.ts +inline src/shared/runtime-types.ts +inline src/shared/source-control-ai.ts +inline src/shared/telemetry-events.ts +inline src/shared/text-search.ts +inline src/shared/types.ts +inline tests/e2e/helpers/terminal.ts +inline tests/e2e/terminal-panes.spec.ts +mobile-config app/h/*/files/*.tsx +mobile-config app/h/*/index.tsx +mobile-config app/h/*/session/*.tsx +mobile-config app/h/*/source-control/*.tsx +mobile-config app/h/*/tasks.tsx +mobile-config app/index.tsx +mobile-config app/pair-scan.tsx +mobile-config app/terminal-settings.tsx +mobile-config app/troubleshoot.tsx +mobile-config scripts/mock-server.ts +mobile-config scripts/repro-terminal-colors.ts +mobile-config scripts/repro-worktree-startup-stream.ts +mobile-config src/browser/MobileBrowserPane.tsx +mobile-config src/components/CustomKeyModal.tsx +mobile-config src/components/NewWorktreeModal.tsx +mobile-config src/components/mobile-rich-markdown-editor-html.ts +mobile-config src/terminal/terminal-accessory-keys.ts +mobile-config src/terminal/terminal-webview-html.ts +mobile-config src/transport/rpc-client.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index bdabd2a7b79..1975f41c7f9 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "updatedAt": "2026-07-03", + "updatedAt": "2026-07-07", "policy": { "maturityLevels": [ "experimental", @@ -1273,7 +1273,7 @@ "macos" ], "coveredProviders": [], - "coverageNotes": "Local macOS evidence on main@1282f5c2d; identical files to the pending stack. Real OS IME automation, Windows ConPTY post-agent reset, and the CJK/Vietnamese/Arabic matrix remain registered gaps.", + "coverageNotes": "Local macOS evidence on main@1282f5c2d, deterministic renderer-unit coverage for the Linux/Sogou candidate-key policy, and a local Electron/CDP live-PTY Sogou-style repro. Real Linux/Sogou OS IME automation, Windows ConPTY post-agent reset, and the CJK/Vietnamese/Arabic matrix remain registered gaps.", "motivatingLinks": [ "https://github.com/stablyai/orca/pull/6699", "https://github.com/stablyai/orca/pull/6682", @@ -1281,14 +1281,20 @@ "https://github.com/stablyai/orca/pull/6999" ], "invariant": "Composition, native text forwarding, synthetic input, paste, and platform keyboard bypass paths must not send preedit/control bytes before commit and must commit text exactly once to the intended PTY.", - "oracle": "The current renderer-unit slice asserts native text commits route to the intended PTY once, composition/preedit bookkeeping does not leak premature text, input-source classification handles synthetic/native paths, and paste/runtime forwarding avoids duplicate terminal payloads for covered fixtures. Real IME paths and the full CJK/Vietnamese/Arabic/JIS-yen matrix run in follow-up platform soak where automation is possible.", + "oracle": "The current renderer-unit slice asserts native text commits route to the intended PTY once, composition/preedit bookkeeping does not leak premature text, input-source classification handles synthetic/native paths, paste/runtime forwarding avoids duplicate terminal payloads for covered fixtures, and Linux/Sogou candidate Space/digit selectors do not leak keydown/keypress/keyup while ordinary post-IME typing remains available. The Electron/CDP live-PTY repro verifies Sogou-style Space and digit candidate selectors submit only the committed Chinese text. Real IME paths and the full CJK/Vietnamese/Arabic/JIS-yen matrix run in follow-up platform soak where automation is possible.", "commands": [ - "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts src/renderer/src/components/terminal-pane/terminal-ime-input-source.test.ts src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts" + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts src/renderer/src/components/terminal-pane/terminal-ime-input-source.test.ts src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts", + "pnpm run test:e2e -- tests/e2e/chinese-ime-chat-input-repro.spec.ts" ], "testFiles": [ "src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts", "src/renderer/src/components/terminal-pane/terminal-ime-input-source.test.ts", - "src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts" + "src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts", + "src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts", + "src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts", + "src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts", + "src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts", + "tests/e2e/chinese-ime-chat-input-repro.spec.ts" ], "assertionRefs": [ { @@ -1309,34 +1315,80 @@ "assertions": [ "paste/runtime forwarding avoids duplicate terminal payloads" ] + }, + { + "file": "src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts", + "assertions": [ + "empty Sogou-style compositionupdate keeps composition active", + "stale composition state expires so editing keys cannot stay suppressed indefinitely", + "post-composition candidate guard is armed only by Sogou-style empty updates and clears after real typing" + ] + }, + { + "file": "src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts", + "assertions": [ + "suppressed candidate keydown arms only the matching keypress/keyup release guard", + "held-key repeat keydowns stay guarded until their keyup, even past expiry", + "fresh keydowns, other keys, modified keys, and expired keypresses are not guarded, and a fresh press drops a stale pending release" + ] + }, + { + "file": "src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts", + "assertions": [ + "standalone Linux 229 keydowns reach xterm while Windows 229 keydowns stay suppressed", + "candidate Space/digit selectors are suppressed only while candidate guards are active" + ] + }, + { + "file": "src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts", + "assertions": [ + "macOS standalone Process key behavior and composition-owned key suppression stay intact" + ] + }, + { + "file": "tests/e2e/chinese-ime-chat-input-repro.spec.ts", + "assertions": [ + "Sogou-style Space candidate selection submits only the committed Chinese character", + "Sogou-style digit candidate selection submits only the committed Chinese phrase", + "Post-composition Sogou-style digit selection stays out of the PTY after compositionend" + ] } ], "evidenceRuns": [ { - "date": "2026-07-03", + "date": "2026-07-07", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts src/renderer/src/components/terminal-pane/terminal-ime-input-source.test.ts src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts src/renderer/src/components/terminal-pane/terminal-ime-input-source.test.ts src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts", "result": "passed", - "durationSeconds": 0.9, - "summary": "3 test file(s) passed, 63 tests passed on main@1282f5c2d in a clean checkout." + "durationSeconds": 0.7, + "summary": "7 test file(s) passed, 144 tests passed on the Linux/Sogou candidate-key branch (includes held-key repeat guard coverage)." + }, + { + "date": "2026-07-07", + "runner": "local", + "platform": "macos", + "command": "pnpm run test:e2e -- tests/e2e/chinese-ime-chat-input-repro.spec.ts", + "result": "passed", + "durationSeconds": 72.0, + "summary": "Electron/CDP IME repro passed: Sogou candidate selection, post-composition candidate selection, and existing Chinese IME harness passed; real Codex IME test was skipped behind ORCA_E2E_REAL_CODEX_IME." } ], "runtimeBudget": { - "p95Seconds": 30, - "scope": "renderer unit plus platform soak" + "p95Seconds": 90, + "scope": "renderer unit plus focused Electron/CDP IME repro" }, "flakeHistory": { "status": "unknown", - "evidence": "Focused renderer input tests are now registered; needs soak history and platform IME evidence before promotion." + "evidence": "Focused renderer input tests and the Electron/CDP Sogou-style repro are now registered; needs soak history and true platform IME evidence before promotion." }, "redGreenEvidence": { "status": "partial", - "evidence": "Focused tests cover existing native-text, input-source, and paste/runtime forwarding contracts. Needs intentional-break proof for duplicate native text forwarding and composition preedit leakage, plus the broader language/platform matrix." + "evidence": "Focused tests cover existing native-text, input-source, paste/runtime forwarding, Linux/Sogou candidate selector, post-composition guard contracts, and live-PTY Sogou-style candidate commits. Needs intentional-break proof for duplicate native text forwarding and composition preedit leakage, plus the broader language/platform matrix." }, "performanceBudget": { - "required": false, - "evidence": "Required if input forwarding adds polling, repeated event listeners, or renderer hot-path work." + "required": true, + "evidence": "Terminal IME hot-path audit clean on 2026-07-07: candidate guard adds O(1) boolean checks plus a bounded Space/digit release map; composition listeners are disposed with pane lifecycle; no polling, IPC fan-out, subprocess work, or SSH/remote transport cost added." }, "promotionCriteria": [ "Cover deterministic byte/cell oracles first.", @@ -1344,7 +1396,7 @@ "Pair with Windows ConPTY keyboard reset for Windows standard-key behavior." ], "knownGaps": [ - "Current command is a renderer-unit slice; real OS IME automation may need manual or soak evidence.", + "Current commands include renderer-unit coverage and a CDP-driven Electron repro; real OS IME automation may need manual or soak evidence.", "Backspace/Enter during composition, JIS yen, Arabic/RTL, paste edge cases, and Windows ConPTY post-agent key reset still need representative gate coverage." ], "demotionRule": "Cannot promote if success is based only on DOM text without PTY byte/cell evidence." diff --git a/config/scripts/check-max-lines-ratchet.mjs b/config/scripts/check-max-lines-ratchet.mjs new file mode 100644 index 00000000000..d4e8b9fa072 --- /dev/null +++ b/config/scripts/check-max-lines-ratchet.mjs @@ -0,0 +1,254 @@ +import { execFileSync } from 'node:child_process' +import fs from 'node:fs' +import path from 'node:path' +import process from 'node:process' +import { pathToFileURL } from 'node:url' + +// Ratchet gate for the oxlint `max-lines` rule. +// +// oxlint already fails any file that exceeds max-lines WITHOUT a suppression, so +// the only way a file grows past the budget is by adding an `eslint/oxlint-disable +// max-lines` comment or a per-file `max-lines` bump in mobile/.oxlintrc.json. This +// check freezes the set of files currently allowed to do that (the baseline) and +// fails CI when a NEW bypass appears — the existing over-limit files are +// grandfathered; new ones must split instead. The baseline may only shrink. + +const BASELINE_PATH = 'config/max-lines-baseline.txt' +const MOBILE_CONFIG_PATH = 'mobile/.oxlintrc.json' +// These two files legitimately contain the directive text as data (regex, fixtures), +// so scanning them would self-flag. The ratchet does not police itself. +const SELF_FILES = new Set([ + 'config/scripts/check-max-lines-ratchet.mjs', + 'config/scripts/check-max-lines-ratchet.test.mjs' +]) + +// Default max-lines budgets from .oxlintrc.json (counted lines). +export function defaultLimitForPath(p) { + if (/\.(test|spec)\.(ts|tsx)$/.test(p)) { + return 800 + } + if (p.endsWith('.tsx')) { + return 400 + } + if (p.endsWith('.mjs')) { + return 600 + } + return 300 +} + +// True if the source contains an eslint/oxlint disable directive listing `max-lines` +// (block or line, bare or compound, with or without a `-- Why:` reason). +export function hasMaxLinesDisable(sourceText) { + const re = /(?:eslint|oxlint)-disable(?:-next-line|-line)?\b([^\n]*)/g + let m + while ((m = re.exec(sourceText)) !== null) { + let rules = m[1] + rules = rules.split('--')[0] // strip the reason + const close = rules.indexOf('*/') + if (close !== -1) { + rules = rules.slice(0, close) // strip block-comment tail + } + if (/\bmax-lines\b/.test(rules)) { + return true + } + } + return false +} + +// Per-file `max-lines` bumps in mobile/.oxlintrc.json whose `max` exceeds the +// default for that glob (a lower `max` is stricter, not a bypass). +export function collectMobileBumps(configText) { + const cfg = JSON.parse(configText) + const bumps = [] + for (const override of cfg.overrides ?? []) { + const rule = override.rules?.['max-lines'] + if (!Array.isArray(rule) || typeof rule[1]?.max !== 'number') { + continue + } + for (const glob of override.files ?? []) { + if (rule[1].max > defaultLimitForPath(glob)) { + bumps.push(`mobile-config ${glob}`) + } + } + } + return bumps +} + +export function parseBaseline(text) { + return new Set( + text + .split('\n') + .map((l) => l.trim()) + .filter((l) => l && !l.startsWith('#')) + ) +} + +export function diffBaseline(current, baseline) { + const cur = new Set(current) + const base = baseline instanceof Set ? baseline : new Set(baseline) + const added = [...cur].filter((e) => !base.has(e)).sort() + const stale = [...base].filter((e) => !cur.has(e)).sort() + return { added, stale } +} + +// Collect every current suppression entry from the tracked tree. +export function collectCurrentSuppressions(root = process.cwd()) { + const tracked = execFileSync('git', ['ls-files', '*.ts', '*.tsx', '*.mjs'], { + cwd: root, + encoding: 'utf8', + maxBuffer: 64 * 1024 * 1024 + }) + .split('\n') + .filter(Boolean) + .filter((f) => !SELF_FILES.has(f)) + + const entries = [] + for (const rel of tracked) { + let src + try { + src = fs.readFileSync(path.join(root, rel), 'utf8') + } catch { + continue + } + if (hasMaxLinesDisable(src)) { + entries.push(`inline ${rel}`) + } + } + + const mobileCfgPath = path.join(root, MOBILE_CONFIG_PATH) + if (fs.existsSync(mobileCfgPath)) { + entries.push(...collectMobileBumps(fs.readFileSync(mobileCfgPath, 'utf8'))) + } + + return entries.sort() +} + +function printAddedFailure(added) { + for (const entry of added) { + console.error(`::error::New max-lines bypass not allowed: ${entry}`) + } + console.error('') + console.error('╭────────────────────────────────────────────────────────────────────────────╮') + console.error('│ ❌ max-lines ratchet failed — a NEW file is trying to exceed the line cap. │') + console.error('╰────────────────────────────────────────────────────────────────────────────╯') + console.error('') + console.error(` ${added.length} file(s)/glob(s) newly bypass the oxlint \`max-lines\` rule:`) + console.error('') + for (const entry of added) { + const [kind, ...rest] = entry.split(' ') + const target = rest.join(' ') + const how = + kind === 'inline' + ? 'added an eslint/oxlint-disable max-lines comment' + : 'added a per-file max-lines bump in mobile/.oxlintrc.json' + console.error(` • ${target}\n ↳ ${how}`) + } + console.error('') + console.error(' Orca caps file size (300 .ts / 400 .tsx / 600 .mjs / 800 test — non-blank,') + console.error( + ' non-comment lines). Existing oversized files are grandfathered; NEW ones are not.' + ) + console.error('') + console.error(' ✅ Fix it: SPLIT the file into focused modules — do NOT suppress the rule.') + console.error(' See AGENTS.md → "Lint Rules: Do Not Disable Max Lines".') + console.error('') + console.error(' (If you are intentionally, with reviewer sign-off, adding an unavoidable') + console.error(` exception, add the exact line(s) above to ${BASELINE_PATH}.)`) + console.error('') +} + +function printStaleFailure(stale) { + for (const entry of stale) { + console.error(`::error::Stale max-lines baseline entry (prune it): ${entry}`) + } + console.error('') + console.error('╭────────────────────────────────────────────────────────────────────────────╮') + console.error('│ ⚠️ max-lines baseline is out of date — nice work removing a bypass! │') + console.error('╰────────────────────────────────────────────────────────────────────────────╯') + console.error('') + console.error(` ${stale.length} baseline entr(y/ies) no longer have a max-lines suppression.`) + console.error( + ' The baseline may only shrink, so these must be removed to keep re-adding blocked:' + ) + console.error('') + for (const entry of stale) { + console.error(` • ${entry}`) + } + console.error('') + console.error(` ✅ Fix it (one command): pnpm check:max-lines-ratchet --prune`) + console.error('') +} + +export function main(root = process.cwd()) { + const baselineFile = path.join(root, BASELINE_PATH) + if (!fs.existsSync(baselineFile)) { + console.error( + `::error::Missing ${BASELINE_PATH}. Generate it with: node config/scripts/check-max-lines-ratchet.mjs --init` + ) + return 1 + } + const baseline = parseBaseline(fs.readFileSync(baselineFile, 'utf8')) + const current = collectCurrentSuppressions(root) + const { added, stale } = diffBaseline(current, baseline) + + if (added.length > 0) { + printAddedFailure(added) + if (stale.length > 0) { + console.error( + ` (Also: ${stale.length} stale baseline entr(y/ies) can be pruned — see below.)` + ) + printStaleFailure(stale) + } + return 1 + } + if (stale.length > 0) { + printStaleFailure(stale) + return 1 + } + console.log( + `max-lines ratchet OK — ${current.length} grandfathered suppression(s), no new bypasses.` + ) + return 0 +} + +function writeBaseline(root, entries) { + const header = [ + '# Files/globs currently allowed to exceed the oxlint `max-lines` budget.', + '# This is a RATCHET: the list may only SHRINK. Do NOT add entries to get CI green —', + '# split the oversized file instead (AGENTS.md → "Do Not Disable Max Lines").', + '# Regenerate/prune: pnpm check:max-lines-ratchet --prune (removes stale entries only)', + '' + ].join('\n') + fs.writeFileSync(path.join(root, BASELINE_PATH), `${header}${entries.join('\n')}\n`) +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const root = process.cwd() + const arg = process.argv[2] + if (arg === '--init') { + // One-time bootstrap: capture the current suppression set as the baseline. + const entries = collectCurrentSuppressions(root) + writeBaseline(root, entries) + console.log(`Wrote ${BASELINE_PATH} with ${entries.length} entries.`) + process.exit(0) + } + if (arg === '--prune') { + // Remove baseline entries whose suppression is gone (shrink only; never adds). + const current = new Set(collectCurrentSuppressions(root)) + const baseline = parseBaseline(fs.readFileSync(path.join(root, BASELINE_PATH), 'utf8')) + const kept = [...baseline].filter((e) => current.has(e)).sort() + const newlyAdded = [...current].filter((e) => !baseline.has(e)) + writeBaseline(root, kept) + console.log( + `Pruned baseline to ${kept.length} entries (removed ${baseline.size - kept.length}).` + ) + if (newlyAdded.length > 0) { + console.error( + `::error::--prune does not add entries; ${newlyAdded.length} new bypass(es) remain — split those files.` + ) + process.exit(1) + } + process.exit(0) + } + process.exit(main(root)) +} diff --git a/config/scripts/check-max-lines-ratchet.test.mjs b/config/scripts/check-max-lines-ratchet.test.mjs new file mode 100644 index 00000000000..f1eb4183454 --- /dev/null +++ b/config/scripts/check-max-lines-ratchet.test.mjs @@ -0,0 +1,115 @@ +import { describe, expect, it } from 'vitest' + +import { + collectMobileBumps, + defaultLimitForPath, + diffBaseline, + hasMaxLinesDisable, + parseBaseline +} from './check-max-lines-ratchet.mjs' + +describe('hasMaxLinesDisable', () => { + it('detects a bare block disable', () => { + expect(hasMaxLinesDisable('/* eslint-disable max-lines */\nexport const a = 1\n')).toBe(true) + }) + + it('detects the oxlint spelling', () => { + expect(hasMaxLinesDisable('/* oxlint-disable max-lines */\n')).toBe(true) + }) + + it('detects a disable with a -- Why reason', () => { + expect(hasMaxLinesDisable('/* eslint-disable max-lines -- Why: one owner. */\n')).toBe(true) + }) + + it('detects a multi-line block where the reason wraps', () => { + const src = + '/* eslint-disable max-lines -- Why: this contract is\n * intentionally centralized. */\nimport x from "y"\n' + expect(hasMaxLinesDisable(src)).toBe(true) + }) + + it('detects max-lines inside a compound rule list', () => { + expect(hasMaxLinesDisable('/* eslint-disable no-control-regex, max-lines -- Why: x */\n')).toBe( + true + ) + expect(hasMaxLinesDisable('/* eslint-disable max-lines, no-control-regex */\n')).toBe(true) + }) + + it('detects a line-scoped disable', () => { + expect(hasMaxLinesDisable('const a = 1 // eslint-disable-line max-lines\n')).toBe(true) + }) + + it('ignores a disable for an unrelated rule', () => { + expect(hasMaxLinesDisable('/* eslint-disable no-console */\n')).toBe(false) + }) + + it('does not treat "max-lines" appearing only in the reason text as a suppression', () => { + // max-lines is after the `--`, so it is prose, not a suppressed rule. + expect( + hasMaxLinesDisable('/* eslint-disable no-console -- we could hit max-lines later */\n') + ).toBe(false) + }) + + it('returns false for ordinary source', () => { + expect(hasMaxLinesDisable('export function f() {\n return 42\n}\n')).toBe(false) + }) +}) + +describe('defaultLimitForPath', () => { + it('uses 800 for tests, 400 for tsx, 600 for mjs, 300 otherwise', () => { + expect(defaultLimitForPath('a/b.test.ts')).toBe(800) + expect(defaultLimitForPath('a/b.spec.tsx')).toBe(800) + expect(defaultLimitForPath('a/b.tsx')).toBe(400) + expect(defaultLimitForPath('a/b.mjs')).toBe(600) + expect(defaultLimitForPath('a/b.ts')).toBe(300) + }) +}) + +describe('collectMobileBumps', () => { + it('captures only overrides whose max exceeds the default for the glob', () => { + const cfg = JSON.stringify({ + overrides: [ + { files: ['app/h/*/tasks.tsx'], rules: { 'max-lines': ['error', { max: 14682 }] } }, // bump (>400) + { + files: ['src/terminal/TerminalWebView.tsx'], + rules: { 'max-lines': ['error', { max: 379 }] } + }, // stricter (<400), skip + { files: ['scripts/mock-server.ts'], rules: { 'max-lines': ['error', { max: 407 }] } } // bump (>300) + ] + }) + expect(collectMobileBumps(cfg)).toEqual([ + 'mobile-config app/h/*/tasks.tsx', + 'mobile-config scripts/mock-server.ts' + ]) + }) + + it('ignores overrides without a max-lines rule', () => { + const cfg = JSON.stringify({ + overrides: [{ files: ['a.tsx'], rules: { 'no-console': 'off' } }] + }) + expect(collectMobileBumps(cfg)).toEqual([]) + }) +}) + +describe('parseBaseline', () => { + it('drops comments and blank lines', () => { + const b = parseBaseline('# header\n\ninline a.ts\nmobile-config x/*.tsx\n') + expect(b).toEqual(new Set(['inline a.ts', 'mobile-config x/*.tsx'])) + }) +}) + +describe('diffBaseline', () => { + it('reports added and stale entries', () => { + const { added, stale } = diffBaseline( + ['inline b.ts', 'inline c.ts'], + new Set(['inline a.ts', 'inline b.ts']) + ) + expect(added).toEqual(['inline c.ts']) // new bypass + expect(stale).toEqual(['inline a.ts']) // suppression removed + }) + + it('is clean when current matches baseline', () => { + const { added, stale } = diffBaseline(['inline a.ts'], new Set(['inline a.ts'])) + expect(added).toEqual([]) + expect(stale).toEqual([]) + }) +}) diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index 3f124029e89..4afaca9293c 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 3.7m + + downloads: 3.9m @@ -15,7 +15,7 @@ downloads downloads - 3.7m - 3.7m + 3.9m + 3.9m diff --git a/docs/readme/README.pt.md b/docs/readme/README.pt.md index 6c3fc52354c..3ba8d351bf9 100644 --- a/docs/readme/README.pt.md +++ b/docs/readme/README.pt.md @@ -238,10 +238,6 @@ Conecte ao app desktop para monitorar e conduzir seus agentes pelo celular. - **Discord:** Entre na comunidade no **[Discord](https://discord.gg/fzjDKHxv8Q)**. - **Twitter / X:** Siga **[@orca_build](https://x.com/orca_build)** para atualizações e anúncios. -- **WeChat:** Escaneie o QR code para entrar na comunidade. - - QR code do WeChat para a comunidade Orca - - **Feedback e ideias:** Lançamos rápido. Sentiu falta de algo? [Peça um novo recurso](https://github.com/stablyai/orca/issues). - **Privacidade:** Veja a [documentação de privacidade e telemetria](https://www.onorca.dev/docs/telemetry) para saber quais dados anônimos de uso o Orca coleta e como desativar. - **Mostre apoio:** Dê uma [estrela](https://github.com/stablyai/orca) neste repositório para acompanhar nossos lançamentos diários. diff --git a/docs/readme/README.zh-CN.md b/docs/readme/README.zh-CN.md index 4c538b08f22..d17ae0a8df8 100644 --- a/docs/readme/README.zh-CN.md +++ b/docs/readme/README.zh-CN.md @@ -235,6 +235,11 @@ yay -S stably-orca-bin - **Discord:** 加入 **[Discord](https://discord.gg/fzjDKHxv8Q)** 社区。 - **Twitter / X:** 关注 **[@orca_build](https://x.com/orca_build)** 获取更新和公告。 +- **微信:** 扫描二维码加入社区。如果第一个群已满,请使用备用微信群。 + + Orca 社区微信群二维码 + Orca 社区备用微信群二维码 + - **反馈与想法:** 我们发布很快。缺少什么功能?[提交功能请求](https://github.com/stablyai/orca/issues)。 - **隐私:** 查看[隐私与遥测文档](https://www.onorca.dev/docs/telemetry),了解 Orca 收集哪些匿名使用数据以及如何退出。 - **支持我们:** 给这个仓库点 [Star](https://github.com/stablyai/orca),关注我们的日常发布。 diff --git a/docs/reference/ssh-typing-latency-under-relay-load.md b/docs/reference/ssh-typing-latency-under-relay-load.md new file mode 100644 index 00000000000..f7ae8e0b3a7 --- /dev/null +++ b/docs/reference/ssh-typing-latency-under-relay-load.md @@ -0,0 +1,115 @@ +# SSH Typing Latency Under Relay Load + +Date: 2026-07-06 + +## Symptom + +Typing in an SSH-host terminal can become extremely slow (hundreds of ms to +seconds per echoed keystroke) while other SSH work is active — file previews, +source-control refresh, search. A quiet SSH shell stays snappy, which made the +reports look unreproducible. + +This is distinct from the local warm-switch lag documented in +`terminal-switch-typing-lag-investigation.md` (daemon `listSessions()` +snapshot cost — already fixed; `TerminalHost.listSessions()` is now +metadata-only via `getAppliedSize()`). + +## Root Cause + +The relay and the Electron client share ONE ordered SSH channel for all +JSON-RPC traffic: PTY input/output, file streams, git responses, search, port +scans. Two mechanisms turned bulk traffic into typing latency: + +1. **Relay-side head-of-line blocking (primary).** The `fs.readFileStream` + pump (`src/relay/fs-handler-file-read.ts`) wrote every 256KB chunk (~340KB + framed) into the relay's stdout as fast as local disk reads completed, + ignoring the `write() === false` backpressure signal. A 10MB preview + enqueued ~13.6MB into the pipe at once; a `pty.data` echo emitted + mid-stream queued behind ALL of it. At 2MB/s WAN that is multiple seconds + of echo delay per open file. The reproduced measurement: 4,195,592 bytes + (the entire remaining 3MB test file, framed) queued ahead of one echo + frame. + +2. **Client-side O(n²) frame buffering (secondary).** `FrameDecoder.feed()` + in both `src/main/ssh/relay-protocol.ts` and `src/relay/protocol.ts` did + `Buffer.concat([buffered, chunk])` per data event, re-copying the whole + backlog for every ~32KB TCP chunk — ~2MB of memcpy per 340KB frame, + ~80MB per 10MB file — on the Electron main thread, delaying `pty:write` + IPC dispatch and echo delivery to the renderer. + +## Fix + +- **Bulk lane with sink backpressure** (`RelayDispatcher.notifyBulk`): + `fs.streamChunk` frames are serialized per client and each send waits for + the sink's `drain` when `write()` returns `false`. Interactive frames + (`pty.data`) still use plain `notify()` and are admitted immediately — + because the bulk lane keeps the outbound buffer at ~1 frame, an echo jumps + ahead of every not-yet-admitted chunk. Sinks (`relay.ts` stdout + Unix + socket clients) surface `write()`'s boolean and a one-shot + `waitWriteDrain`; every death path (EPIPE, stdin end, detach, dispose) + flushes parked waiters so a pump can never hang on a dead pipe. + +- **Credit-based flow control** (`fs.streamAck`): the client requests + streams with `flowControl: 'ack'` and acks each processed chunk; the relay + caps unacked chunks at `STREAM_ACK_WINDOW_CHUNKS = 4` (~1MB raw). This + bounds in-flight bulk bytes even past the relay's own pipe (sshd/TCP + buffers) and paces the relay's base64/JSON encode loop so incoming + keystroke frames get event-loop turns. A parked pump wakes on ack, cancel, + release, client detach, and a 1s staleness recheck. + +- **Cross-version compatibility**: old client + new relay → no + `flowControl` param → legacy unpaced pump (still drain-bounded, which is + transparent). New client + old relay → `fs.streamAck` is an unknown + notification and is silently ignored. `STREAM_CHUNK_SIZE` (256KB) is + intentionally unchanged — both sides bake it into chunk offset math, so + changing it would corrupt cross-version streams. + +- **FrameDecoder rewrite** (both mirrored protocol files): chunk-list + buffering; each frame is assembled exactly once (one copy) instead of + re-concatenating the backlog per feed. + +## What Stays Fast / Unchanged + +- Relay PTY output batching (interactive echo fast path in + `src/relay/pty-handler.ts`) is untouched. +- No `listSessions()` / provider inventory was added anywhere near typing, + focus, switch, resume, resize, or render paths. +- Remote file streaming correctness: per-chunk length checks, chunk-count and + byte-count invariants, cancel paths, and the 12MB round-trip integration + test all pass unchanged. + +## Regression Coverage + +- `src/relay/fs-stream-pty-echo-backpressure.integration.test.ts` — real + mux ↔ dispatcher ↔ FsHandler over a congestible in-memory pipe; asserts + deterministic BYTE bounds (not wall-clock): echo queues behind < 2 framed + chunks when congested (pre-fix: whole file), ack window caps in-flight + chunks, legacy no-ack clients still get complete streams. +- `src/relay/fs-handler-stream.test.ts` — pump parks at the ack window, + resumes per ack, and releases its file handle when cancelled while parked. +- `src/relay/dispatcher.test.ts` — notifyBulk semantics: drain gating, + per-client targeting, dispose releases parked senders, interactive + notify() not gated behind a stalled bulk lane. +- `src/main/ssh/relay-protocol.test.ts` — decoder byte-at-a-time boundary + straddling, oversized-frame resync across odd chunk sizes, and a + no-`Buffer.concat`-during-feed guard that locks out the O(n²) shape. +- `tests/e2e/ssh-docker-relay-perf.spec.ts` — new "busy relay" scenario: + types while two 8MB remote file-read loops and a git.status loop run; + budgets median < 500ms, worst < 2000ms. (On loopback Docker the pre-fix + HOL is only tens of ms — the in-process byte-bound test is the + authoritative red/green; the e2e guards the end-to-end wiring.) + +## Residual Gaps + +- **Large single-frame responses**: `git.*` responses (stdout capped at + `MAX_GIT_BUFFER` = 10MB → ~13MB framed) and other request/response results + are one atomic frame on the wire; a huge diff can still delay an echo by + its own transfer time. Fixing this requires response chunking (a protocol + change); bounded by the 16MB `MAX_MESSAGE_SIZE`. +- **Outbound direction**: a large `fs.writeFile` request frame (client → + relay) can queue ahead of keystroke `pty.data` frames on `channel.stdin`. + Same shape, opposite direction, rarer trigger (saving a large remote file + while typing). +- `pty.ackData` flow control for PTY output remains unenforced + (`src/relay/pty-handler.ts` — "not yet enforced"); PTY output floods are + already paced by the relay's 8ms batch/16KB slice scheduler. diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx index ad94114bd66..f47ec591057 100644 --- a/mobile/app/_layout.tsx +++ b/mobile/app/_layout.tsx @@ -102,6 +102,14 @@ export default function RootLayout() { return } handledNotificationIdsRef.current.add(notificationId) + // Why: RootLayout never unmounts, so cap this tap-dedup set (FIFO) rather + // than letting it grow one id per notification tapped for the app's life. + if (handledNotificationIdsRef.current.size > 256) { + const oldest = handledNotificationIdsRef.current.values().next().value + if (oldest !== undefined) { + handledNotificationIdsRef.current.delete(oldest) + } + } const path = await getNavigationPath(response.notification.request.content.data) clearLastNotificationResponse() diff --git a/mobile/scripts/mock-server-file-preview-data.ts b/mobile/scripts/mock-server-file-preview-data.ts index a64c76eda3b..9bf43471df6 100644 --- a/mobile/scripts/mock-server-file-preview-data.ts +++ b/mobile/scripts/mock-server-file-preview-data.ts @@ -21,6 +21,31 @@ const MOCK_FILE_LIST = [ { relativePath: 'dist/archive.zip', basename: 'archive.zip', kind: 'binary' } ] +function readMockDirectory(relativePath: string): Array<{ name: string; isDirectory: boolean }> { + const prefix = relativePath ? `${relativePath}/` : '' + const children = new Map() + for (const file of MOCK_FILE_LIST) { + if (!file.relativePath.startsWith(prefix)) { + continue + } + const rest = file.relativePath.slice(prefix.length) + if (!rest) { + continue + } + const [name, ...descendants] = rest.split('/') + if (!name) { + continue + } + children.set(name, children.get(name) === true || descendants.length > 0) + } + return Array.from(children, ([name, isDirectory]) => ({ name, isDirectory })).sort((a, b) => { + if (a.isDirectory !== b.isDirectory) { + return a.isDirectory ? -1 : 1 + } + return a.name.localeCompare(b.name) + }) +} + export function handleMockFilePreviewRequest( request: RpcRequest, respond: Respond, @@ -40,6 +65,10 @@ export function handleMockFilePreviewRequest( ) return true + case 'files.readDir': + respond(success(request.id, readMockDirectory(String(request.params?.relativePath ?? '')))) + return true + case 'files.read': { const relativePath = String(request.params?.relativePath ?? '') const content = MOCK_FILE_CONTENT[relativePath] diff --git a/mobile/src/files/MobileFileExplorerPanel.test.ts b/mobile/src/files/MobileFileExplorerPanel.test.ts new file mode 100644 index 00000000000..0d003e9f456 --- /dev/null +++ b/mobile/src/files/MobileFileExplorerPanel.test.ts @@ -0,0 +1,398 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { MobileFileExplorerPanel } from './MobileFileExplorerPanel' +import type { MobileDirEntry } from './file-tree' +import type { RpcResponse } from '../transport/types' + +type MockClient = { + sendRequest: ReturnType +} + +const mockTransport = vi.hoisted(() => ({ + client: null as MockClient | null, + connectionState: 'connected', + forceReconnect: vi.fn() +})) + +vi.mock('react-native', async () => { + const React = await import('react') + return { + ActivityIndicator: 'ActivityIndicator', + FlatList: (props: { + data: unknown[] + keyExtractor: (item: unknown, index: number) => string + renderItem: (info: { + item: unknown + index: number + separators: Record + }) => unknown + }) => + React.createElement( + 'FlatList', + props, + props.data.map((item, index) => + React.createElement( + 'FlatListItem', + { key: props.keyExtractor(item, index) }, + props.renderItem({ item, index, separators: {} }) + ) + ) + ), + Pressable: 'Pressable', + StyleSheet: { + create: (styles: unknown) => styles, + hairlineWidth: 1 + }, + Text: 'Text', + View: 'View' + } +}) + +vi.mock('react-native-safe-area-context', () => ({ + SafeAreaView: 'SafeAreaView' +})) + +vi.mock('expo-router', () => ({ + useRouter: () => ({ + back: vi.fn(), + push: vi.fn() + }) +})) + +vi.mock('lucide-react-native', () => ({ + ChevronDown: 'ChevronDown', + ChevronLeft: 'ChevronLeft', + ChevronRight: 'ChevronRight', + File: 'File', + FileText: 'FileText', + Folder: 'Folder', + Image: 'Image', + X: 'X' +})) + +vi.mock('../platform/haptics', () => ({ + triggerSelection: vi.fn() +})) + +vi.mock('../transport/client-context', () => ({ + useForceReconnect: () => mockTransport.forceReconnect, + useHostClient: () => ({ + client: mockTransport.client, + state: mockTransport.connectionState + }) +})) + +function suppressReactTestRendererDeprecationWarning(): () => void { + const originalConsoleError = console.error + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { + const firstArg = args[0] + if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) { + return + } + originalConsoleError(...args) + }) + return () => consoleErrorSpy.mockRestore() +} + +function entry(name: string, isDirectory = false): MobileDirEntry { + return { name, isDirectory } +} + +function ok(result: MobileDirEntry[]): RpcResponse { + return { id: 'response-id', ok: true, result, _meta: { runtimeId: 'runtime-id' } } +} + +function createMockClient(entriesByPath: Record): MockClient { + return { + sendRequest: vi.fn(async (_method: string, params: { relativePath: string }) => { + return ok(entriesByPath[params.relativePath] ?? []) + }) + } +} + +async function renderExplorer(): Promise { + let renderer: ReactTestRenderer | null = null + const restoreConsoleError = suppressReactTestRendererDeprecationWarning() + try { + await act(async () => { + renderer = create( + createElement(MobileFileExplorerPanel, { + hostId: 'host-a', + worktreeId: 'worktree-a', + name: 'Example Worktree', + embedded: true + }) + ) + }) + } finally { + restoreConsoleError() + } + if (!renderer) { + throw new Error('MobileFileExplorerPanel did not render') + } + return renderer +} + +async function updateExplorer(renderer: ReactTestRenderer): Promise { + await act(async () => { + renderer.update( + createElement(MobileFileExplorerPanel, { + hostId: 'host-a', + worktreeId: 'worktree-a', + name: 'Example Worktree', + embedded: true + }) + ) + }) +} + +async function pressByLabel( + renderer: ReactTestRenderer, + accessibilityLabel: string +): Promise { + const pressable = renderer.root + .findAllByType('Pressable') + .find((node) => node.props.accessibilityLabel === accessibilityLabel) + if (!pressable) { + throw new Error(`Unable to find pressable: ${accessibilityLabel}`) + } + await act(async () => { + pressable.props.onPress() + }) +} + +function renderedText(renderer: ReactTestRenderer): string { + return renderer.root + .findAllByType('Text') + .flatMap((node) => node.props.children) + .join(' ') +} + +describe('MobileFileExplorerPanel', () => { + afterEach(() => { + vi.restoreAllMocks() + mockTransport.client = null + mockTransport.connectionState = 'connected' + mockTransport.forceReconnect = vi.fn() + }) + + it('loads directories lazily and reuses cached children when reopened', async () => { + const client = createMockClient({ + '': [entry('src', true), entry('README.md')], + src: [entry('app.ts'), entry('components', true)] + }) + mockTransport.client = client + + const renderer = await renderExplorer() + + expect(client.sendRequest).toHaveBeenCalledTimes(1) + expect(client.sendRequest).toHaveBeenLastCalledWith('files.readDir', { + worktree: 'id:worktree-a', + relativePath: '' + }) + + await pressByLabel(renderer, 'Open folder src') + await vi.waitFor(() => expect(client.sendRequest).toHaveBeenCalledTimes(2)) + expect(client.sendRequest).toHaveBeenLastCalledWith('files.readDir', { + worktree: 'id:worktree-a', + relativePath: 'src' + }) + expect(renderedText(renderer)).toContain('app.ts') + + await pressByLabel(renderer, 'Open folder src') + await pressByLabel(renderer, 'Open folder src') + + expect(client.sendRequest).toHaveBeenCalledTimes(2) + }) + + it('keeps the loaded tree visible during a transient disconnect', async () => { + const client = createMockClient({ + '': [entry('src', true), entry('README.md')] + }) + mockTransport.client = client + + const renderer = await renderExplorer() + expect(renderedText(renderer)).toContain('src') + + mockTransport.client = null + mockTransport.connectionState = 'disconnected' + await updateExplorer(renderer) + + expect(client.sendRequest).toHaveBeenCalledTimes(1) + expect(renderedText(renderer)).toContain('src') + expect(renderedText(renderer)).toContain('README.md') + expect(renderedText(renderer)).not.toContain('Waiting for desktop...') + }) + + it('refreshes the root in the background after reconnect without blanking the tree', async () => { + const client = createMockClient({ + '': [entry('src', true), entry('README.md')] + }) + mockTransport.client = client + + const renderer = await renderExplorer() + expect(renderedText(renderer)).toContain('README.md') + + mockTransport.client = null + mockTransport.connectionState = 'disconnected' + await updateExplorer(renderer) + + let resolveReload: ((response: RpcResponse) => void) | undefined + const reconnectedClient: MockClient = { + sendRequest: vi.fn( + () => + new Promise((resolve) => { + resolveReload = resolve + }) + ) + } + mockTransport.client = reconnectedClient + mockTransport.connectionState = 'connected' + await updateExplorer(renderer) + + expect(reconnectedClient.sendRequest).toHaveBeenCalledTimes(1) + expect(renderedText(renderer)).toContain('src') + expect(renderedText(renderer)).toContain('README.md') + + await act(async () => { + resolveReload?.(ok([entry('src', true), entry('README.md'), entry('CHANGELOG.md')])) + }) + expect(renderedText(renderer)).toContain('CHANGELOG.md') + }) + + it('keeps the cached tree when a post-reconnect root refresh fails', async () => { + const client = createMockClient({ + '': [entry('src', true), entry('README.md')] + }) + mockTransport.client = client + + const renderer = await renderExplorer() + expect(renderedText(renderer)).toContain('README.md') + + mockTransport.client = null + mockTransport.connectionState = 'disconnected' + await updateExplorer(renderer) + + const failingClient: MockClient = { + sendRequest: vi.fn(async () => { + throw new Error('refresh failed') + }) + } + mockTransport.client = failingClient + mockTransport.connectionState = 'connected' + await updateExplorer(renderer) + + expect(failingClient.sendRequest).toHaveBeenCalledTimes(1) + expect(renderedText(renderer)).toContain('src') + expect(renderedText(renderer)).toContain('README.md') + expect(renderedText(renderer)).not.toContain('refresh failed') + }) + + it('falls back to the capped files.list against desktops without files.readDir', async () => { + const legacyClient: MockClient = { + sendRequest: vi.fn(async (method: string): Promise => { + if (method === 'files.readDir') { + return { + id: 'response-id', + ok: false, + error: { + code: 'forbidden', + message: "Method 'files.readDir' is not available to mobile clients" + }, + _meta: { runtimeId: 'runtime-id' } + } + } + return { + id: 'response-id', + ok: true, + result: { + files: [ + { relativePath: 'src/app.ts', basename: 'app.ts', kind: 'text' }, + { relativePath: 'README.md', basename: 'README.md', kind: 'text' } + ], + totalCount: 2, + truncated: false + }, + _meta: { runtimeId: 'runtime-id' } + } + }) + } + mockTransport.client = legacyClient + + const renderer = await renderExplorer() + + expect(legacyClient.sendRequest).toHaveBeenCalledWith('files.list', { + worktree: 'id:worktree-a' + }) + expect(renderedText(renderer)).toContain('src') + expect(renderedText(renderer)).toContain('README.md') + + await pressByLabel(renderer, 'Open folder src') + expect(renderedText(renderer)).toContain('app.ts') + // Every directory comes from the synthesized cache: one readDir attempt + // plus one files.list call total, no per-directory RPCs afterwards. + expect(legacyClient.sendRequest).toHaveBeenCalledTimes(2) + }) + + it('surfaces the legacy cap note when the files.list fallback is truncated', async () => { + const legacyClient: MockClient = { + sendRequest: vi.fn(async (method: string): Promise => { + if (method === 'files.readDir') { + return { + id: 'response-id', + ok: false, + error: { code: 'method_not_found', message: 'Unknown method' }, + _meta: { runtimeId: 'runtime-id' } + } + } + return { + id: 'response-id', + ok: true, + result: { + files: [{ relativePath: 'README.md', basename: 'README.md', kind: 'text' }], + totalCount: 6000, + truncated: true + }, + _meta: { runtimeId: 'runtime-id' } + } + }) + } + mockTransport.client = legacyClient + + const renderer = await renderExplorer() + + expect(renderedText(renderer)).toContain('README.md') + expect(renderedText(renderer)).toContain('Showing first 5000') + }) + + it('reports the files.list failure when the fallback itself fails', async () => { + const legacyClient: MockClient = { + sendRequest: vi.fn(async (method: string): Promise => { + if (method === 'files.readDir') { + return { + id: 'response-id', + ok: false, + error: { + code: 'forbidden', + message: "Method 'files.readDir' is not available to mobile clients" + }, + _meta: { runtimeId: 'runtime-id' } + } + } + return { + id: 'response-id', + ok: false, + error: { code: 'internal', message: 'legacy list failed' }, + _meta: { runtimeId: 'runtime-id' } + } + }) + } + mockTransport.client = legacyClient + + const renderer = await renderExplorer() + + expect(renderedText(renderer)).toContain('legacy list failed') + expect(renderedText(renderer)).not.toContain('not available to mobile clients') + }) +}) diff --git a/mobile/src/files/MobileFileExplorerPanel.tsx b/mobile/src/files/MobileFileExplorerPanel.tsx index 8192eb90020..7e67bf81b5f 100644 --- a/mobile/src/files/MobileFileExplorerPanel.tsx +++ b/mobile/src/files/MobileFileExplorerPanel.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { ActivityIndicator, FlatList, @@ -9,34 +9,33 @@ import { } from 'react-native' import { SafeAreaView } from 'react-native-safe-area-context' import { useRouter } from 'expo-router' -import { - ChevronDown, - ChevronLeft, - ChevronRight, - File, - FileText, - Folder, - Image as ImageIcon, - X -} from 'lucide-react-native' +import { ChevronLeft, X } from 'lucide-react-native' import { useHostClient, useForceReconnect } from '../transport/client-context' import { getWorktreeLabel } from '../session/worktree-label' import { - buildTree, - flattenTree, - isMarkdownPath, - type FilesListResult, - type MobileFileEntry, - type TreeNode + flattenDirectoryCache, + getDirectoryCacheState, + type DirectoryCache, + type FileExplorerRow, + type MobileDirEntry } from './file-tree' import type { RpcSuccess } from '../transport/types' -import { triggerSelection } from '../platform/haptics' -import { colors, spacing } from '../theme/mobile-theme' -import { fileExplorerStyles as styles } from './mobile-file-explorer-styles' +import { colors } from '../theme/mobile-theme' import { - canPreviewMobileFileRow, - navigateToMobileFilePreview -} from './mobile-file-preview-navigation' + beginDirectoryLoad, + createDirectoryLoadRevisions, + isCurrentDirectoryLoad, + resetDirectoryLoadRevisions, + type DirectoryLoadRevisions +} from './directory-load-revisions' +import { + directoryCacheFromFileList, + isMobileMethodUnavailableError, + type LegacyFilesListResult +} from './file-list-fallback' +import { fileExplorerStyles as styles } from './mobile-file-explorer-styles' +import { MobileFileExplorerRow } from './mobile-file-explorer-row' +import { navigateToMobileFilePreview } from './mobile-file-preview-navigation' export function MobileFileExplorerPanel(props: { hostId: string @@ -49,58 +48,217 @@ export function MobileFileExplorerPanel(props: { const router = useRouter() const { client, state: connState } = useHostClient(hostId) const forceReconnect = useForceReconnect() - const [files, setFiles] = useState([]) + const scopeRef = useRef('') + const scope = `${hostId}:${worktreeId}` + scopeRef.current = scope + const directoryLoadRevisionsRef = useRef(createDirectoryLoadRevisions()) + const pendingDirectoryRetriesRef = useRef>(new Set()) + const directoryCacheRef = useRef({}) + const [directoryCache, setDirectoryCache] = useState({}) const [expanded, setExpanded] = useState>(() => new Set()) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) - const [truncated, setTruncated] = useState(false) + const [legacyListTruncated, setLegacyListTruncated] = useState(false) const worktreeLabel = getWorktreeLabel(name, worktreeId) - const loadFiles = useCallback(async () => { - if (!client || connState !== 'connected') { - setLoading(false) - setError(connState === 'connected' ? 'Connecting to desktop...' : 'Waiting for desktop...') - return - } - setLoading(true) - setError(null) - try { - const response = await client.sendRequest('files.list', { worktree: `id:${worktreeId}` }) - if (!response.ok) { - throw new Error(response.error?.message || 'Unable to load files') + const loadDirectory = useCallback( + async (relativePath: string) => { + const scope = scopeRef.current + const loadToken = beginDirectoryLoad(directoryLoadRevisionsRef.current, scope, relativePath) + const rootLoad = relativePath === '' + + if (!client || connState !== 'connected') { + const message = + connState === 'connected' ? 'Connecting to desktop...' : 'Waiting for desktop...' + if (rootLoad) { + const hasLoadedRoot = + (getDirectoryCacheState(directoryCacheRef.current, '')?.entries.length ?? 0) > 0 + setLoading(false) + // Why: transient reconnects should not blank an already browsable tree. + setError(hasLoadedRoot ? null : message) + } else { + setDirectoryCache((prev) => ({ + ...prev, + [relativePath]: { + entries: getDirectoryCacheState(prev, relativePath)?.entries ?? [], + error: message + } + })) + } + return } - const result = (response as RpcSuccess).result as FilesListResult - setFiles(result.files) - setTruncated(result.truncated) - } catch (err) { - setError(err instanceof Error ? err.message : 'Unable to load files') - } finally { - setLoading(false) - } - }, [client, connState, worktreeId]) + + const hadLoadedRoot = + rootLoad && (getDirectoryCacheState(directoryCacheRef.current, '')?.entries.length ?? 0) > 0 + if (rootLoad) { + // Why: a reconnect refresh must not blank an already browsable tree — + // the full-screen spinner unmounts the list and resets scroll. + if (!hadLoadedRoot) { + setLoading(true) + } + setError(null) + } + setDirectoryCache((prev) => ({ + ...prev, + [relativePath]: { + entries: getDirectoryCacheState(prev, relativePath)?.entries ?? [], + loading: true + } + })) + + try { + const response = await client.sendRequest('files.readDir', { + worktree: `id:${worktreeId}`, + relativePath + }) + if (!response.ok) { + // Why: desktops that predate the files.readDir mobile allowlist + // entry still serve the capped files.list; fall back so the Files + // tab keeps working until the desktop updates. + if ( + rootLoad && + isMobileMethodUnavailableError(response.error?.code, response.error?.message) + ) { + const legacy = await client.sendRequest('files.list', { + worktree: `id:${worktreeId}` + }) + if (legacy.ok) { + if ( + !isCurrentDirectoryLoad( + directoryLoadRevisionsRef.current, + scopeRef.current, + loadToken + ) + ) { + return + } + const legacyResult = (legacy as RpcSuccess).result as LegacyFilesListResult + setDirectoryCache(directoryCacheFromFileList(legacyResult.files)) + // Why: the capped list silently omits files past the cap — keep + // the legacy explorer's "Showing first 5000" note. + setLegacyListTruncated(legacyResult.truncated) + return + } + throw new Error( + legacy.error?.message || response.error?.message || 'Unable to load files' + ) + } + throw new Error(response.error?.message || 'Unable to load files') + } + if ( + !isCurrentDirectoryLoad(directoryLoadRevisionsRef.current, scopeRef.current, loadToken) + ) { + return + } + const entries = (response as RpcSuccess).result as MobileDirEntry[] + if (rootLoad) { + setLegacyListTruncated(false) + } + setDirectoryCache((prev) => ({ + ...prev, + [relativePath]: { entries } + })) + } catch (err) { + if ( + !isCurrentDirectoryLoad(directoryLoadRevisionsRef.current, scopeRef.current, loadToken) + ) { + return + } + const message = err instanceof Error ? err.message : 'Unable to load files' + if (rootLoad) { + // Why: a failed background refresh keeps the cached tree browsable; + // only a cold load surfaces the full-screen error. + setError(hadLoadedRoot ? null : message) + } else { + setDirectoryCache((prev) => ({ + ...prev, + [relativePath]: { + entries: getDirectoryCacheState(prev, relativePath)?.entries ?? [], + error: message + } + })) + } + } finally { + if ( + rootLoad && + isCurrentDirectoryLoad(directoryLoadRevisionsRef.current, scopeRef.current, loadToken) + ) { + setLoading(false) + } + } + }, + [client, connState, worktreeId] + ) useEffect(() => { - void loadFiles() - }, [loadFiles]) + scopeRef.current = scope + resetDirectoryLoadRevisions(directoryLoadRevisionsRef.current) + pendingDirectoryRetriesRef.current.clear() + directoryCacheRef.current = {} + setDirectoryCache({}) + setExpanded(new Set()) + setLoading(true) + setError(null) + setLegacyListTruncated(false) + }, [scope]) - const rows = useMemo(() => flattenTree(buildTree(files), expanded), [expanded, files]) + useEffect(() => { + directoryCacheRef.current = directoryCache + }, [directoryCache]) - const toggleDirectory = useCallback((relativePath: string) => { - triggerSelection() - setExpanded((prev) => { - const next = new Set(prev) - if (next.has(relativePath)) { - next.delete(relativePath) - } else { - next.add(relativePath) + useEffect(() => { + void loadDirectory('') + }, [hostId, loadDirectory]) + + useEffect(() => { + if (connState !== 'connected' || pendingDirectoryRetriesRef.current.size === 0) { + return + } + const pending = [...pendingDirectoryRetriesRef.current] + pendingDirectoryRetriesRef.current.clear() + for (const relativePath of pending) { + void loadDirectory(relativePath) + } + }, [connState, loadDirectory]) + + const rows = useMemo( + () => flattenDirectoryCache(directoryCache, expanded), + [directoryCache, expanded] + ) + + const toggleDirectory = useCallback( + (relativePath: string) => { + setExpanded((prev) => { + const next = new Set(prev) + if (next.has(relativePath)) { + next.delete(relativePath) + } else { + next.add(relativePath) + } + return next + }) + const state = getDirectoryCacheState(directoryCache, relativePath) + if (!expanded.has(relativePath) && !state?.loading && (!state?.entries || state.error)) { + void loadDirectory(relativePath) } - return next - }) - }, []) + }, + [directoryCache, expanded, loadDirectory] + ) + + const retryDirectory = useCallback( + (relativePath: string) => { + if (connState !== 'connected' && hostId) { + pendingDirectoryRetriesRef.current.add(relativePath) + void forceReconnect(hostId) + return + } + void loadDirectory(relativePath) + }, + [connState, forceReconnect, hostId, loadDirectory] + ) const previewFile = useCallback( (relativePath: string, displayName: string) => { - triggerSelection() navigateToMobileFilePreview( router, { @@ -116,66 +274,15 @@ export function MobileFileExplorerPanel(props: { [embedded, hostId, name, onRequestClose, router, worktreeId] ) - const renderItem: ListRenderItem = ({ item }) => { - const isDirectory = item.kind === 'directory' - const isExpanded = expanded.has(item.relativePath) - // Images render in the mobile viewer (via files.readPreview), so a binary - // image is openable; only non-previewable binaries are unavailable. - const previewable = - item.kind !== 'directory' && - canPreviewMobileFileRow({ kind: item.kind, relativePath: item.relativePath }) - const isImage = item.kind === 'binary' && previewable - const disabled = item.kind === 'binary' && !previewable - const markdown = item.kind === 'text' && isMarkdownPath(item.relativePath) + const renderItem: ListRenderItem = ({ item }) => { return ( - [ - styles.row, - { paddingLeft: spacing.lg + item.depth * 18 }, - pressed && !disabled && styles.rowPressed, - disabled && styles.rowDisabled - ]} - disabled={disabled} - onPress={() => { - if (isDirectory) { - toggleDirectory(item.relativePath) - } else if (!disabled) { - previewFile(item.relativePath, item.name) - } - }} - accessibilityLabel={ - isDirectory - ? `Open folder ${item.name}` - : disabled - ? `${item.name} unavailable on mobile` - : `Preview file ${item.name}` - } - > - {isDirectory ? ( - isExpanded ? ( - - ) : ( - - ) - ) : ( - - )} - {isDirectory ? ( - - ) : markdown ? ( - - ) : isImage ? ( - - ) : ( - - )} - - - {item.name} - - {disabled ? Unavailable on mobile : null} - - + ) } @@ -206,7 +313,7 @@ export function MobileFileExplorerPanel(props: { {worktreeLabel} - {truncated ? ' - Showing first 5000' : ''} + {legacyListTruncated ? ' - Showing first 5000' : ''} @@ -220,12 +327,12 @@ export function MobileFileExplorerPanel(props: { {error} {/* Why: while disconnected, re-sending the request is useless — revive - the parked transport instead (issue #5049); loadFiles re-runs via + the parked transport instead (issue #5049); loadDirectory re-runs via its effect once the new client connects. */} - connState !== 'connected' && hostId ? void forceReconnect(hostId) : void loadFiles() + connState !== 'connected' && hostId ? void forceReconnect(hostId) : void loadDirectory('') } > Retry diff --git a/mobile/src/files/directory-load-revisions.test.ts b/mobile/src/files/directory-load-revisions.test.ts new file mode 100644 index 00000000000..86ebfc3c0e8 --- /dev/null +++ b/mobile/src/files/directory-load-revisions.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest' +import { + beginDirectoryLoad, + createDirectoryLoadRevisions, + isCurrentDirectoryLoad, + resetDirectoryLoadRevisions, + type DirectoryLoadRevisions +} from './directory-load-revisions' + +describe('directory-load-revisions', () => { + it('ignores stale duplicate loads for the same directory and scope', () => { + const revisions: DirectoryLoadRevisions = createDirectoryLoadRevisions() + const older = beginDirectoryLoad(revisions, 'host-a:worktree-a', 'src') + const newer = beginDirectoryLoad(revisions, 'host-a:worktree-a', 'src') + + expect(isCurrentDirectoryLoad(revisions, 'host-a:worktree-a', newer)).toBe(true) + expect(isCurrentDirectoryLoad(revisions, 'host-a:worktree-a', older)).toBe(false) + }) + + it('ignores loads from an old host/worktree scope', () => { + const revisions: DirectoryLoadRevisions = createDirectoryLoadRevisions() + const oldScopeLoad = beginDirectoryLoad(revisions, 'host-a:worktree-a', '') + + expect(isCurrentDirectoryLoad(revisions, 'host-b:worktree-b', oldScopeLoad)).toBe(false) + }) + + it('ignores loads from an old reset generation in the same scope', () => { + const revisions: DirectoryLoadRevisions = createDirectoryLoadRevisions() + const oldLoad = beginDirectoryLoad(revisions, 'host-a:worktree-a', '') + + resetDirectoryLoadRevisions(revisions) + const newLoad = beginDirectoryLoad(revisions, 'host-a:worktree-a', '') + + expect(isCurrentDirectoryLoad(revisions, 'host-a:worktree-a', newLoad)).toBe(true) + expect(isCurrentDirectoryLoad(revisions, 'host-a:worktree-a', oldLoad)).toBe(false) + }) + + it('tracks directory names that overlap object prototype keys', () => { + const revisions: DirectoryLoadRevisions = createDirectoryLoadRevisions() + const load = beginDirectoryLoad(revisions, 'host-a:worktree-a', '__proto__') + + expect(isCurrentDirectoryLoad(revisions, 'host-a:worktree-a', load)).toBe(true) + }) +}) diff --git a/mobile/src/files/directory-load-revisions.ts b/mobile/src/files/directory-load-revisions.ts new file mode 100644 index 00000000000..04153092bbe --- /dev/null +++ b/mobile/src/files/directory-load-revisions.ts @@ -0,0 +1,42 @@ +export type DirectoryLoadRevisions = { + generation: number + revisionsByPath: Map +} + +export type DirectoryLoadToken = { + generation: number + relativePath: string + revision: number + scope: string +} + +export function createDirectoryLoadRevisions(): DirectoryLoadRevisions { + return { generation: 0, revisionsByPath: new Map() } +} + +export function resetDirectoryLoadRevisions(revisions: DirectoryLoadRevisions): void { + revisions.generation += 1 + revisions.revisionsByPath.clear() +} + +export function beginDirectoryLoad( + revisions: DirectoryLoadRevisions, + scope: string, + relativePath: string +): DirectoryLoadToken { + const revision = (revisions.revisionsByPath.get(relativePath) ?? 0) + 1 + revisions.revisionsByPath.set(relativePath, revision) + return { generation: revisions.generation, relativePath, revision, scope } +} + +export function isCurrentDirectoryLoad( + revisions: DirectoryLoadRevisions, + currentScope: string, + token: DirectoryLoadToken +): boolean { + return ( + currentScope === token.scope && + revisions.generation === token.generation && + revisions.revisionsByPath.get(token.relativePath) === token.revision + ) +} diff --git a/mobile/src/files/file-list-fallback.test.ts b/mobile/src/files/file-list-fallback.test.ts new file mode 100644 index 00000000000..a69affc5fe8 --- /dev/null +++ b/mobile/src/files/file-list-fallback.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest' +import { directoryCacheFromFileList, isMobileMethodUnavailableError } from './file-list-fallback' +import { getDirectoryCacheState } from './file-tree' + +describe('isMobileMethodUnavailableError', () => { + it('detects old-desktop allowlist and missing-method failures', () => { + expect(isMobileMethodUnavailableError('forbidden', undefined)).toBe(true) + expect(isMobileMethodUnavailableError('method_not_found', undefined)).toBe(true) + expect( + isMobileMethodUnavailableError( + 'some_code', + "Method 'files.readDir' is not available to mobile clients" + ) + ).toBe(true) + expect(isMobileMethodUnavailableError('internal', 'boom')).toBe(false) + expect(isMobileMethodUnavailableError(undefined, undefined)).toBe(false) + }) +}) + +describe('directoryCacheFromFileList', () => { + it('synthesizes every ancestor directory from flat paths', () => { + const cache = directoryCacheFromFileList([ + { relativePath: 'src/lib/util.ts', basename: 'util.ts', kind: 'text' }, + { relativePath: 'src/app.ts', basename: 'app.ts', kind: 'text' }, + { relativePath: 'README.md', basename: 'README.md', kind: 'text' } + ]) + expect(cache['']?.entries).toEqual( + expect.arrayContaining([ + { name: 'src', isDirectory: true }, + { name: 'README.md', isDirectory: false } + ]) + ) + expect(cache['src']?.entries).toEqual( + expect.arrayContaining([ + { name: 'lib', isDirectory: true }, + { name: 'app.ts', isDirectory: false } + ]) + ) + expect(cache['src/lib']?.entries).toEqual([{ name: 'util.ts', isDirectory: false }]) + }) + + it('keeps a name a directory when it appears as both file and dir prefix', () => { + const cache = directoryCacheFromFileList([ + { relativePath: 'src', basename: 'src', kind: 'text' }, + { relativePath: 'src/app.ts', basename: 'app.ts', kind: 'text' } + ]) + expect(cache['']?.entries).toEqual([{ name: 'src', isDirectory: true }]) + }) + + it('returns an empty root for an empty list', () => { + const cache = directoryCacheFromFileList([]) + expect(cache['']?.entries).toEqual([]) + }) + + it('stores a __proto__ directory as an own key instead of mutating the prototype', () => { + const cache = directoryCacheFromFileList([ + { relativePath: '__proto__/pollute.js', basename: 'pollute.js', kind: 'text' } + ]) + expect(Object.getPrototypeOf(cache)).toBe(Object.prototype) + expect(cache['']?.entries).toEqual([{ name: '__proto__', isDirectory: true }]) + expect(getDirectoryCacheState(cache, '__proto__')?.entries).toEqual([ + { name: 'pollute.js', isDirectory: false } + ]) + }) +}) diff --git a/mobile/src/files/file-list-fallback.ts b/mobile/src/files/file-list-fallback.ts new file mode 100644 index 00000000000..ef3041029af --- /dev/null +++ b/mobile/src/files/file-list-fallback.ts @@ -0,0 +1,73 @@ +// Fallback for desktops that predate files.readDir in the mobile RPC +// allowlist: synthesize the lazy directory cache from the flat, capped +// files.list result so the Files tab stays browsable against old desktops. +import type { DirectoryCache, MobileDirEntry } from './file-tree' + +export type LegacyMobileFileEntry = { + relativePath: string + basename: string + kind: 'text' | 'binary' +} + +export type LegacyFilesListResult = { + files: LegacyMobileFileEntry[] + totalCount: number + truncated: boolean +} + +// Same detection shape as isMobileGitUnavailable in mobile-git-status.ts: +// 'forbidden' = method exists but is not mobile-allowlisted on the old +// desktop; 'method_not_found' = desktop predates the method entirely. +export function isMobileMethodUnavailableError( + code: string | undefined, + message: string | undefined +): boolean { + return ( + code === 'forbidden' || + code === 'method_not_found' || + message?.includes('not available to mobile clients') === true + ) +} + +export function directoryCacheFromFileList(files: LegacyMobileFileEntry[]): DirectoryCache { + const childrenByDir = new Map>() + const ensureDir = (path: string): Map => { + let children = childrenByDir.get(path) + if (!children) { + children = new Map() + childrenByDir.set(path, children) + } + return children + } + ensureDir('') + for (const file of files) { + const parts = file.relativePath.split('/').filter(Boolean) + let parentPath = '' + parts.forEach((name, index) => { + const isDirectory = index < parts.length - 1 + const children = ensureDir(parentPath) + children.set(name, children.get(name) === true || isDirectory) + parentPath = parentPath ? `${parentPath}/${name}` : name + if (isDirectory) { + ensureDir(parentPath) + } + }) + } + // Why: plain `cache[path] = ...` with a '__proto__' path segment mutates the + // object's prototype instead of storing the directory; fromEntries always + // creates own keys. + return Object.fromEntries( + Array.from(childrenByDir, ([path, children]) => [ + path, + { + entries: Array.from( + children, + ([name, isDirectory]): MobileDirEntry => ({ + name, + isDirectory + }) + ) + } + ]) + ) +} diff --git a/mobile/src/files/file-tree.test.ts b/mobile/src/files/file-tree.test.ts index c4777ca2188..bdd9e9225cb 100644 --- a/mobile/src/files/file-tree.test.ts +++ b/mobile/src/files/file-tree.test.ts @@ -1,32 +1,109 @@ import { describe, expect, it } from 'vitest' -import { buildTree, flattenTree, isMarkdownPath, type MobileFileEntry } from './file-tree' +import { + flattenDirectoryCache, + getMobileFileKind, + isMarkdownPath, + shouldIncludeMobileFileExplorerEntry, + type DirectoryCache, + type MobileDirEntry +} from './file-tree' -function entry(relativePath: string, kind: 'text' | 'binary' = 'text'): MobileFileEntry { - return { relativePath, basename: relativePath.split('/').pop() ?? relativePath, kind } +function entry(name: string, isDirectory = false, isSymlink = false): MobileDirEntry { + return { name, isDirectory, isSymlink } } describe('file-tree', () => { - it('nests files under their directories', () => { - const root = buildTree([entry('src/app.ts'), entry('src/lib/util.ts'), entry('readme.md')]) - expect(root.files.map((f) => f.relativePath)).toEqual(['readme.md']) - expect(root.directories.get('src')?.directories.get('lib')?.files[0]?.relativePath).toBe( - 'src/lib/util.ts' - ) + it('flattens cached directories lazily with directories before files', () => { + const cache: DirectoryCache = { + '': { entries: [entry('zeta.txt'), entry('src', true), entry('readme.md')] }, + src: { entries: [entry('app.ts'), entry('lib', true)] }, + 'src/lib': { entries: [entry('util.ts')] } + } + + const collapsed = flattenDirectoryCache(cache, new Set()) + expect(collapsed.map((row) => row.id)).toEqual(['dir:src', 'file:readme.md', 'file:zeta.txt']) + + const expanded = flattenDirectoryCache(cache, new Set(['src', 'src/lib'])) + expect(expanded.map((row) => row.id)).toEqual([ + 'dir:src', + 'dir:src/lib', + 'file:src/lib/util.ts', + 'file:src/app.ts', + 'file:readme.md', + 'file:zeta.txt' + ]) }) - it('flattens with directories before files and only expands open dirs', () => { - const root = buildTree([entry('src/app.ts'), entry('zeta.txt')]) - const collapsed = flattenTree(root, new Set()) - expect(collapsed.map((r) => r.id)).toEqual(['dir:src', 'file:zeta.txt']) + it('mirrors desktop default browse exclusions while keeping dotfiles visible', () => { + const cache: DirectoryCache = { + '': { + entries: [ + entry('.git', true), + entry('.env'), + entry('node_modules', true), + entry('src', true) + ] + } + } - const expanded = flattenTree(root, new Set(['src'])) - expect(expanded.map((r) => r.id)).toEqual(['dir:src', 'file:src/app.ts', 'file:zeta.txt']) + expect(flattenDirectoryCache(cache, new Set()).map((row) => row.id)).toEqual([ + 'dir:src', + 'file:.env' + ]) + expect(shouldIncludeMobileFileExplorerEntry(entry('.config', true))).toBe(true) }) - it('preserves the binary kind on flattened rows', () => { - const root = buildTree([entry('assets/logo.png', 'binary')]) - const rows = flattenTree(root, new Set(['assets'])) - expect(rows.find((r) => r.id === 'file:assets/logo.png')?.kind).toBe('binary') + it('renders inline loading and error rows under expanded directories', () => { + const cache: DirectoryCache = { + '': { entries: [entry('loading-dir', true), entry('error-dir', true)] }, + 'loading-dir': { entries: [], loading: true }, + 'error-dir': { entries: [], error: 'permission denied' } + } + + const rows = flattenDirectoryCache(cache, new Set(['loading-dir', 'error-dir'])) + expect(rows.map((row) => row.id)).toEqual([ + 'dir:error-dir', + 'error:error-dir', + 'dir:loading-dir', + 'loading:loading-dir' + ]) + expect(rows.find((row) => row.id === 'error:error-dir')).toMatchObject({ + kind: 'error', + message: 'permission denied' + }) + }) + + it('preserves symlink metadata for user-initiated activation', () => { + const cache: DirectoryCache = { + '': { entries: [entry('linked-docs', false, true)] } + } + + expect(flattenDirectoryCache(cache, new Set())).toEqual([ + { + id: 'file:linked-docs', + name: 'linked-docs', + relativePath: 'linked-docs', + depth: 0, + kind: 'text', + isSymlink: true + } + ]) + }) + + it('does not treat inherited object keys as loaded directories', () => { + const cache: DirectoryCache = { + '': { entries: [entry('constructor', true), entry('__proto__', true)] }, + ['__proto__']: { entries: [entry('file.txt')] } + } + + expect( + flattenDirectoryCache(cache, new Set(['constructor', '__proto__'])).map((row) => row.id) + ).toEqual(['dir:__proto__', 'file:__proto__/file.txt', 'dir:constructor']) + }) + + it('classifies binary file paths for existing mobile preview behavior', () => { + expect(getMobileFileKind('assets/logo.png')).toBe('binary') + expect(getMobileFileKind('src/app.ts')).toBe('text') }) it('detects markdown paths', () => { diff --git a/mobile/src/files/file-tree.ts b/mobile/src/files/file-tree.ts index e453cc152e4..a9283cfa457 100644 --- a/mobile/src/files/file-tree.ts +++ b/mobile/src/files/file-tree.ts @@ -1,91 +1,152 @@ -// Pure tree model for the mobile file explorer: turns the flat files.list -// result into a nested directory structure and flattens it into renderable -// rows. Kept out of the screen component so the screen stays under its line cap. +// Pure tree projection for the mobile file explorer. Mobile mirrors desktop +// browse semantics by flattening cached files.readDir results as folders open. -export type MobileFileEntry = { - relativePath: string - basename: string - kind: 'text' | 'binary' +export type MobileDirEntry = { + name: string + isDirectory: boolean + isSymlink?: boolean } -export type FilesListResult = { - files: MobileFileEntry[] - totalCount: number - truncated: boolean -} +export type MobileFileKind = 'text' | 'binary' export type TreeNode = { id: string name: string relativePath: string depth: number - kind: 'directory' | 'text' | 'binary' + kind: 'directory' | MobileFileKind + isSymlink?: boolean } -export type DirectoryNode = { - name: string +export type DirectoryState = { + entries: MobileDirEntry[] + loading?: boolean + error?: string +} + +export type DirectoryCache = Record + +export type InlineStatusNode = { + id: string relativePath: string - directories: Map - files: MobileFileEntry[] + depth: number + kind: 'loading' | 'error' + message?: string } -function createDirectoryNode(name: string, relativePath: string): DirectoryNode { - return { name, relativePath, directories: new Map(), files: [] } -} +export type FileExplorerRow = TreeNode | InlineStatusNode -export function buildTree(files: MobileFileEntry[]): DirectoryNode { - const root = createDirectoryNode('', '') - for (const file of files) { - const parts = file.relativePath.split('/').filter(Boolean) - let current = root - for (let index = 0; index < parts.length - 1; index += 1) { - const name = parts[index]! - const relativePath = parts.slice(0, index + 1).join('/') - let child = current.directories.get(name) - if (!child) { - child = createDirectoryNode(name, relativePath) - current.directories.set(name, child) - } - current = child - } - current.files.push(file) - } - return root -} +const DESKTOP_EXCLUDED_NAMES = new Set(['.git', 'node_modules']) +const BINARY_EXTENSIONS = new Set([ + '.avif', + '.bmp', + '.gif', + '.heic', + '.ico', + '.jpeg', + '.jpg', + '.mov', + '.mp3', + '.mp4', + '.pdf', + '.png', + '.webp', + '.zip' +]) -export function flattenTree(root: DirectoryNode, expanded: ReadonlySet): TreeNode[] { - const rows: TreeNode[] = [] - const visit = (directory: DirectoryNode, depth: number): void => { - const dirs = Array.from(directory.directories.values()).sort((a, b) => - a.name.localeCompare(b.name) - ) - for (const child of dirs) { - rows.push({ - id: `dir:${child.relativePath}`, - name: child.name, - relativePath: child.relativePath, - depth, - kind: 'directory' - }) - if (expanded.has(child.relativePath)) { - visit(child, depth + 1) - } - } - const files = [...directory.files].sort((a, b) => a.basename.localeCompare(b.basename)) - for (const file of files) { - rows.push({ - id: `file:${file.relativePath}`, - name: file.basename, - relativePath: file.relativePath, - depth, - kind: file.kind - }) - } - } - visit(root, 0) +export function flattenDirectoryCache( + cache: DirectoryCache, + expanded: ReadonlySet +): FileExplorerRow[] { + const rows: FileExplorerRow[] = [] + visitDirectory('', 0, cache, expanded, rows) return rows } +function visitDirectory( + relativePath: string, + depth: number, + cache: DirectoryCache, + expanded: ReadonlySet, + rows: FileExplorerRow[] +): void { + const state = getDirectoryCacheState(cache, relativePath) + const entries = state?.entries ?? [] + const visibleEntries = entries + .filter(shouldIncludeMobileFileExplorerEntry) + .sort(compareDirectoryEntries) + + for (const entry of visibleEntries) { + const childPath = joinRelativePath(relativePath, entry.name) + rows.push(toTreeNode(entry, childPath, depth)) + if (entry.isDirectory && expanded.has(childPath)) { + const childState = getDirectoryCacheState(cache, childPath) + if (childState?.loading) { + rows.push({ + id: `loading:${childPath}`, + relativePath: childPath, + depth: depth + 1, + kind: 'loading' + }) + } else if (childState?.error) { + rows.push({ + id: `error:${childPath}`, + relativePath: childPath, + depth: depth + 1, + kind: 'error', + message: childState.error + }) + } else { + visitDirectory(childPath, depth + 1, cache, expanded, rows) + } + } + } +} + +function toTreeNode(entry: MobileDirEntry, relativePath: string, depth: number): TreeNode { + return { + id: `${entry.isDirectory ? 'dir' : 'file'}:${relativePath}`, + name: entry.name, + relativePath, + depth, + kind: entry.isDirectory ? 'directory' : getMobileFileKind(relativePath), + isSymlink: entry.isSymlink + } +} + +function compareDirectoryEntries(a: MobileDirEntry, b: MobileDirEntry): number { + if (a.isDirectory !== b.isDirectory) { + return a.isDirectory ? -1 : 1 + } + return a.name.localeCompare(b.name) +} + +export function shouldIncludeMobileFileExplorerEntry(entry: MobileDirEntry): boolean { + return !DESKTOP_EXCLUDED_NAMES.has(entry.name) +} + +export function getDirectoryCacheState( + cache: DirectoryCache, + relativePath: string +): DirectoryState | undefined { + // Why: repository paths are arbitrary object keys; inherited keys like + // "constructor" must not masquerade as loaded directory state. + return Object.prototype.hasOwnProperty.call(cache, relativePath) ? cache[relativePath] : undefined +} + +export function joinRelativePath(parentPath: string, name: string): string { + return parentPath ? `${parentPath}/${name}` : name +} + +export function getMobileFileKind(relativePath: string): MobileFileKind { + const basename = relativePath.split('/').pop() ?? relativePath + const dotIndex = basename.lastIndexOf('.') + if (dotIndex <= 0) { + return 'text' + } + return BINARY_EXTENSIONS.has(basename.slice(dotIndex).toLowerCase()) ? 'binary' : 'text' +} + export function isMarkdownPath(relativePath: string): boolean { return /\.(md|mdx|markdown)$/i.test(relativePath) } diff --git a/mobile/src/files/mobile-file-explorer-row.tsx b/mobile/src/files/mobile-file-explorer-row.tsx new file mode 100644 index 00000000000..36f74ad3b53 --- /dev/null +++ b/mobile/src/files/mobile-file-explorer-row.tsx @@ -0,0 +1,145 @@ +import { ActivityIndicator, Pressable, Text, View } from 'react-native' +import { + ChevronDown, + ChevronRight, + File, + FileText, + Folder, + Image as ImageIcon +} from 'lucide-react-native' +import { triggerSelection } from '../platform/haptics' +import { colors, spacing } from '../theme/mobile-theme' +import { type FileExplorerRow, isMarkdownPath, type TreeNode } from './file-tree' +import { fileExplorerStyles as styles } from './mobile-file-explorer-styles' +import { canPreviewMobileFileRow } from './mobile-file-preview-navigation' + +type Props = { + item: FileExplorerRow + expanded: ReadonlySet + onPreviewFile: (relativePath: string, displayName: string) => void + onRetryDirectory: (relativePath: string) => void + onToggleDirectory: (relativePath: string) => void +} + +export function MobileFileExplorerRow(props: Props) { + const { item, expanded, onPreviewFile, onRetryDirectory, onToggleDirectory } = props + + if (item.kind === 'loading') { + return ( + + + + Loading... + + ) + } + + if (item.kind === 'error') { + return ( + + + + {item.message || 'Unable to load folder'} + + [styles.inlineRetryButton, pressed && styles.rowPressed]} + onPress={() => { + triggerSelection() + onRetryDirectory(item.relativePath) + }} + accessibilityLabel={`Retry loading ${item.relativePath}`} + > + Retry + + + ) + } + + if (isTreeNode(item)) { + return ( + + ) + } + + return null +} + +function isTreeNode(item: FileExplorerRow): item is TreeNode { + return item.kind === 'directory' || item.kind === 'text' || item.kind === 'binary' +} + +function TreeRow(props: { + item: TreeNode + expanded: ReadonlySet + onPreviewFile: (relativePath: string, displayName: string) => void + onToggleDirectory: (relativePath: string) => void +}) { + const { item, expanded, onPreviewFile, onToggleDirectory } = props + const isDirectory = item.kind === 'directory' + const isExpanded = expanded.has(item.relativePath) + // Images render in the mobile viewer (via files.readPreview), so a binary + // image is openable; only non-previewable binaries are unavailable. + const previewable = + item.kind !== 'directory' && + canPreviewMobileFileRow({ kind: item.kind, relativePath: item.relativePath }) + const isImage = item.kind === 'binary' && previewable + const disabled = item.kind === 'binary' && !previewable + const markdown = item.kind === 'text' && isMarkdownPath(item.relativePath) + + return ( + [ + styles.row, + { paddingLeft: spacing.lg + item.depth * 18 }, + pressed && !disabled && styles.rowPressed, + disabled && styles.rowDisabled + ]} + disabled={disabled} + onPress={() => { + triggerSelection() + if (isDirectory) { + onToggleDirectory(item.relativePath) + } else if (!disabled) { + onPreviewFile(item.relativePath, item.name) + } + }} + accessibilityLabel={ + isDirectory + ? `Open folder ${item.name}` + : disabled + ? `${item.name} unavailable on mobile` + : `Preview file ${item.name}` + } + > + {isDirectory ? ( + isExpanded ? ( + + ) : ( + + ) + ) : ( + + )} + {isDirectory ? ( + + ) : markdown ? ( + + ) : isImage ? ( + + ) : ( + + )} + + + {item.name} + + {disabled ? Unavailable on mobile : null} + + + ) +} diff --git a/mobile/src/files/mobile-file-explorer-styles.ts b/mobile/src/files/mobile-file-explorer-styles.ts index 339dd5519de..5df9710be10 100644 --- a/mobile/src/files/mobile-file-explorer-styles.ts +++ b/mobile/src/files/mobile-file-explorer-styles.ts @@ -78,6 +78,37 @@ export const fileExplorerStyles = StyleSheet.create({ color: colors.textMuted, fontSize: 11 }, + inlineStatusRow: { + minHeight: 36, + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + paddingRight: spacing.md + }, + inlineStatusText: { + color: colors.textSecondary, + fontSize: typography.metaSize + }, + inlineErrorText: { + flex: 1, + minWidth: 0, + color: colors.statusRed, + fontSize: typography.metaSize + }, + inlineRetryButton: { + minHeight: 28, + alignItems: 'center', + justifyContent: 'center', + borderRadius: radii.button, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.borderSubtle, + paddingHorizontal: spacing.md + }, + inlineRetryText: { + color: colors.textPrimary, + fontSize: typography.metaSize, + fontWeight: '600' + }, state: { flex: 1, alignItems: 'center', diff --git a/mobile/src/notifications/mobile-notifications.test.ts b/mobile/src/notifications/mobile-notifications.test.ts index 46eab52ec92..7ae4315086b 100644 --- a/mobile/src/notifications/mobile-notifications.test.ts +++ b/mobile/src/notifications/mobile-notifications.test.ts @@ -1,6 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import * as Notifications from 'expo-notifications' -import { subscribeToDesktopNotifications } from './mobile-notifications' +import { + setScheduledNotificationsMaxForTests, + subscribeToDesktopNotifications +} from './mobile-notifications' import type { RpcClient } from '../transport/rpc-client' import { loadPushNotificationsEnabled } from '../storage/preferences' @@ -268,4 +271,48 @@ describe('subscribeToDesktopNotifications', () => { expect(Notifications.dismissNotificationAsync).not.toHaveBeenCalled() }) + + // Why: notificationId is unique per completion, so the map grew unbounded when + // the desktop never sent a dismiss (the remote-mobile case). It is now capped. + it('evicts the oldest scheduled entry once the cap is exceeded', async () => { + setScheduledNotificationsMaxForTests(1) + try { + vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true) + vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({ + status: 'granted', + canAskAgain: true + } as never) + vi.mocked(Notifications.scheduleNotificationAsync) + .mockResolvedValueOnce('scheduled-old') + .mockResolvedValueOnce('scheduled-new') + vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined) + let onEvent: ((data: unknown) => void) | null = null + const client = { + subscribe: vi.fn((_method, _params, callback: (data: unknown) => void) => { + onEvent = callback + return vi.fn() + }), + getState: vi.fn(() => 'connected'), + sendRequest: vi.fn() + } as unknown as RpcClient + + subscribeToDesktopNotifications(client, 'host-1') + onEvent?.({ type: 'notification', title: 't', body: 'b', notificationId: 'agent:old' }) + await flushAsync() + onEvent?.({ type: 'notification', title: 't', body: 'b', notificationId: 'agent:new' }) + await flushAsync() + + // The older entry was evicted by the cap: dismissing it is a no-op... + onEvent?.({ type: 'dismiss', notificationId: 'agent:old' }) + await flushAsync() + expect(Notifications.dismissNotificationAsync).not.toHaveBeenCalledWith('scheduled-old') + + // ...while the most-recent entry is retained and still dismissable. + onEvent?.({ type: 'dismiss', notificationId: 'agent:new' }) + await flushAsync() + expect(Notifications.dismissNotificationAsync).toHaveBeenCalledWith('scheduled-new') + } finally { + setScheduledNotificationsMaxForTests() + } + }) }) diff --git a/mobile/src/notifications/mobile-notifications.ts b/mobile/src/notifications/mobile-notifications.ts index 83abea6fb8a..9ab936f75c6 100644 --- a/mobile/src/notifications/mobile-notifications.ts +++ b/mobile/src/notifications/mobile-notifications.ts @@ -31,10 +31,42 @@ type ScheduledNotificationState = { const scheduledNotificationsByHostAndNotificationId = new Map() +// Why: notificationId embeds a per-completion timestamp (buildAgentNotificationId), +// so every agent-task-complete inserts a new, never-reused key. Entries are only +// removed when the desktop sends a matching dismiss — which a remote mobile user +// (not at the desktop) frequently never gets — so the map grew for the app's whole +// life. Bound it; a settled entry only retains a small identifier used for later +// programmatic dismissal, unnecessary for long-past completions. +const MAX_SCHEDULED_NOTIFICATIONS = 256 +let maxScheduledNotifications = MAX_SCHEDULED_NOTIFICATIONS + function getStoredNotificationKey(hostId: string, notificationId: string): string { return `${encodeURIComponent(hostId)}:${encodeURIComponent(notificationId)}` } +// Evict the oldest SETTLED entries (never one mid-schedule) until within the cap. +// Map iteration is insertion order, so the first match is the oldest. +function boundScheduledNotifications(): void { + while (scheduledNotificationsByHostAndNotificationId.size > maxScheduledNotifications) { + let evicted = false + for (const [key, state] of scheduledNotificationsByHostAndNotificationId) { + if (!state.pending) { + scheduledNotificationsByHostAndNotificationId.delete(key) + evicted = true + break + } + } + if (!evicted) { + break + } + } +} + +/** Test-only: override the cap (pass no arg to restore the default). */ +export function setScheduledNotificationsMaxForTests(max?: number): void { + maxScheduledNotifications = max ?? MAX_SCHEDULED_NOTIFICATIONS +} + export type NotificationPermissionState = { granted: boolean status: string @@ -155,6 +187,7 @@ async function showLocalNotification(event: NotificationEvent, hostId: string): return } notificationState.identifier = scheduledIdentifier + boundScheduledNotifications() } finally { if (notificationState.pending === pending) { notificationState.pending = undefined diff --git a/package.json b/package.json index 27f7d72598a..524ccf7b2b5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.126-rc.0", + "version": "1.4.127-rc.0", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", @@ -11,7 +11,7 @@ "main": "./out/main/index.js", "scripts": { "format": "oxfmt --write .", - "lint": "oxlint && pnpm run lint:switch-exhaustiveness && node config/scripts/check-styled-scrollbars.mjs && pnpm run check:reliability-gates && pnpm run verify:localization-catalog && pnpm run verify:localization-coverage", + "lint": "oxlint && pnpm run lint:switch-exhaustiveness && node config/scripts/check-styled-scrollbars.mjs && pnpm run check:reliability-gates && pnpm run check:max-lines-ratchet && pnpm run verify:localization-catalog && pnpm run verify:localization-coverage", "lint:react-doctor": "oxlint --config config/oxlint-react-doctor.json", "lint:react-doctor:changed": "node config/scripts/lint-react-doctor-changed.mjs", "lint:switch-exhaustiveness": "oxlint --type-aware --config config/oxlint-switch-exhaustiveness.json src/main src/preload src/shared src/relay src/cli src/renderer/src config tests --quiet", @@ -19,6 +19,7 @@ "test": "node config/scripts/ensure-native-runtime.mjs --runtime=node && vitest run --config config/vitest.config.ts", "check:styled-scrollbars": "node config/scripts/check-styled-scrollbars.mjs", "check:reliability-gates": "node config/scripts/check-reliability-gates.mjs", + "check:max-lines-ratchet": "node config/scripts/check-max-lines-ratchet.mjs", "check:feature-wall-assets": "node config/scripts/check-feature-wall-assets.mjs", "verify:macos-entitlements": "node config/scripts/verify-macos-entitlements.mjs", "vendor:feature-wall-assets": "node config/scripts/vendor-feature-wall-assets.mjs", diff --git a/src/main/agent-hooks/server.test.ts b/src/main/agent-hooks/server.test.ts index 46ff181a73d..91b0dc66051 100644 --- a/src/main/agent-hooks/server.test.ts +++ b/src/main/agent-hooks/server.test.ts @@ -13,7 +13,12 @@ import { } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { AgentHookServer, agentHookServer, _internals } from './server' +import { + AgentHookServer, + agentHookServer, + CLOSED_AGENT_STATUS_TAB_IDS_MAX, + _internals +} from './server' import { AGENT_STATUS_MAX_FIELD_LENGTH, AGENT_STATUS_STALE_AFTER_MS, @@ -6848,3 +6853,23 @@ describe('AgentHookServer ingestTerminalStatus', () => { expect(server.getStatusSnapshot()).toEqual([]) }) }) + +describe('AgentHookServer closed-tab suppression bound', () => { + it('bounds closedAgentStatusTabIds with LRU eviction as tabs close', () => { + const server = new AgentHookServer() + const internals = server as unknown as { + markTabClosedForAgentStatus: (tabId: string) => void + closedAgentStatusTabIds: Set + } + + const total = CLOSED_AGENT_STATUS_TAB_IDS_MAX + 200 + for (let i = 0; i < total; i += 1) { + internals.markTabClosedForAgentStatus(`closed-tab-${i}`) + } + + // Set stays bounded; oldest ids are evicted, most-recent are retained. + expect(internals.closedAgentStatusTabIds.size).toBe(CLOSED_AGENT_STATUS_TAB_IDS_MAX) + expect(internals.closedAgentStatusTabIds.has('closed-tab-0')).toBe(false) + expect(internals.closedAgentStatusTabIds.has(`closed-tab-${total - 1}`)).toBe(true) + }) +}) diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index d4cc443d8ab..167331765cf 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -119,6 +119,12 @@ const AGENT_PROMPT_SENT_AGENT_KINDS = new Set(AGENT_KIND_VALUES) // not resurrect on hydrate. const HYDRATE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000 +// Why: closed-tab suppression only needs to cover recently closed tabs — a +// status event for a long-closed tab cannot arrive once its process/hooks are +// gone. Bound the set so it can't grow one entry per tab close for the whole +// session (it is otherwise only cleared at app quit). +export const CLOSED_AGENT_STATUS_TAB_IDS_MAX = 1024 + type LastStatusFile = { version: number entries: Record @@ -596,7 +602,17 @@ export class AgentHookServer { } private markTabClosedForAgentStatus(tabId: string): void { + // Delete-then-add keeps recently closed tabs most-recent so eviction only + // sheds the oldest ids, which can no longer receive status events. + this.closedAgentStatusTabIds.delete(tabId) this.closedAgentStatusTabIds.add(tabId) + while (this.closedAgentStatusTabIds.size > CLOSED_AGENT_STATUS_TAB_IDS_MAX) { + const oldest = this.closedAgentStatusTabIds.keys().next().value + if (oldest === undefined) { + break + } + this.closedAgentStatusTabIds.delete(oldest) + } } private shouldSuppressClosedTabStatus(paneKey: string): boolean { diff --git a/src/main/ai-vault/remote-session-scanner-sources.ts b/src/main/ai-vault/remote-session-scanner-sources.ts index f232b0adf81..e8d43ae1f08 100644 --- a/src/main/ai-vault/remote-session-scanner-sources.ts +++ b/src/main/ai-vault/remote-session-scanner-sources.ts @@ -15,7 +15,7 @@ import { parseHermesSessionContent } from './session-scanner-secondary-parsers' import type { FileWithMtime } from './session-scanner-types' -import { normalizePiSessionsDir } from './session-scanner-values' +import { normalizeAgentSessionsDir } from './session-scanner-values' import { remoteCodexIndexTitles } from './remote-session-scanner-codex-index' import type { RemoteParserOptions, @@ -83,6 +83,7 @@ export function remoteSessionSources( parseDevinSessionContent ), jsonlSource('pi', remoteHome, hostPlatform, remotePiSessionsSegments(), piParser), + jsonlSource('omp', remoteHome, hostPlatform, remoteOmpSessionsSegments(), ompParser), jsonlSource( 'droid', remoteHome, @@ -203,6 +204,15 @@ function piParser( return parseMessageGraphSessionContent('pi', file, content, platform, options) } +function ompParser( + file: FileWithMtime, + content: string, + platform: NodeJS.Platform, + options: RemoteParserOptions +): Promise { + return parseMessageGraphSessionContent('omp', file, content, platform, options) +} + function openClawParser( file: FileWithMtime, content: string, @@ -217,5 +227,9 @@ function remotePathSegments(path: string): string[] { } function remotePiSessionsSegments(): string[] { - return normalizePiSessionsDir('/.pi/agent/sessions').split('/').filter(Boolean) + return normalizeAgentSessionsDir('/.pi/agent/sessions', '.pi').split('/').filter(Boolean) +} + +function remoteOmpSessionsSegments(): string[] { + return normalizeAgentSessionsDir('/.omp/agent/sessions', '.omp').split('/').filter(Boolean) } diff --git a/src/main/ai-vault/session-scanner-accumulator.ts b/src/main/ai-vault/session-scanner-accumulator.ts index c31408a8496..8e44e30c60b 100644 --- a/src/main/ai-vault/session-scanner-accumulator.ts +++ b/src/main/ai-vault/session-scanner-accumulator.ts @@ -113,6 +113,7 @@ export function finalizeSession( resumeCommand: buildAiVaultResumeCommand({ agent: accumulator.agent, sessionId, + resumeFilePath: accumulator.filePath, cwd: accumulator.cwd, platform, codexHome: options.codexHome diff --git a/src/main/ai-vault/session-scanner-agent-parser.ts b/src/main/ai-vault/session-scanner-agent-parser.ts index 1114c1b8bb2..5a81ff260d0 100644 --- a/src/main/ai-vault/session-scanner-agent-parser.ts +++ b/src/main/ai-vault/session-scanner-agent-parser.ts @@ -64,6 +64,8 @@ export async function parseAgentSessionFile( return parseMessageGraphSessionFile('openclaw', candidate.file, platform) case 'pi': return parseMessageGraphSessionFile('pi', candidate.file, platform) + case 'omp': + return parseMessageGraphSessionFile('omp', candidate.file, platform) case 'droid': return parseDroidSessionFile(candidate.file, platform) case 'devin': diff --git a/src/main/ai-vault/session-scanner-codex-workers.test.ts b/src/main/ai-vault/session-scanner-codex-workers.test.ts index 585e37b0203..8a6fb183f62 100644 --- a/src/main/ai-vault/session-scanner-codex-workers.test.ts +++ b/src/main/ai-vault/session-scanner-codex-workers.test.ts @@ -117,6 +117,7 @@ describe('scanAiVaultSessions Codex worker sessions', () => { openclawStateDir: join(root, 'openclaw-state'), openclawLegacyStateDir: join(root, 'openclaw-legacy-state'), piSessionsDir: join(root, 'pi-sessions'), + ompSessionsDir: join(root, 'omp-sessions'), droidSessionsDir: join(root, 'droid-sessions'), droidProjectsDir: join(root, 'droid-projects'), kimiSessionsDir: join(root, 'kimi-sessions'), diff --git a/src/main/ai-vault/session-scanner-graph-parsers.ts b/src/main/ai-vault/session-scanner-graph-parsers.ts index 01d84ddfac0..04601eacde5 100644 --- a/src/main/ai-vault/session-scanner-graph-parsers.ts +++ b/src/main/ai-vault/session-scanner-graph-parsers.ts @@ -163,8 +163,12 @@ export function rovoPartsText(parts: unknown[], role: 'user' | 'assistant'): str return extractContentText(textParts) } +// Agents whose transcripts are append-only message-graph JSONL (session + +// model_change + message records). OMP is a Pi fork and shares the format. +export type MessageGraphAgent = 'openclaw' | 'pi' | 'omp' + export async function parseMessageGraphSessionFile( - agent: 'openclaw' | 'pi', + agent: MessageGraphAgent, file: FileWithMtime, platform: NodeJS.Platform = process.platform ): Promise { @@ -176,7 +180,7 @@ export async function parseMessageGraphSessionFile( } export async function parseMessageGraphSessionContent( - agent: 'openclaw' | 'pi', + agent: MessageGraphAgent, file: FileWithMtime, content: string, platform: NodeJS.Platform = process.platform, @@ -206,7 +210,10 @@ function consumeMessageGraphRecordLine(accumulator: SessionAccumulator, line: st return } if (record.type === 'model_change') { - accumulator.model = extractString(record.modelId) ?? accumulator.model + // Pi writes `modelId`; OMP writes `model`. Prefer either so an in-progress + // session shows its model before the first assistant reply lands. + accumulator.model = + extractString(record.modelId) ?? extractString(record.model) ?? accumulator.model return } if (record.type !== 'message') { @@ -227,7 +234,7 @@ function consumeMessageGraphRecordLine(accumulator: SessionAccumulator, line: st } export function createMessageGraphSessionResumeState( - agent: 'openclaw' | 'pi', + agent: MessageGraphAgent, file: FileWithMtime ): ResumableSessionParseState { return accumulatorFoldResumeState( @@ -237,7 +244,7 @@ export function createMessageGraphSessionResumeState( } async function parseMessageGraphSessionLines(args: { - agent: 'openclaw' | 'pi' + agent: MessageGraphAgent file: FileWithMtime lines: AsyncIterable | Iterable platform: NodeJS.Platform diff --git a/src/main/ai-vault/session-scanner-incremental-fixtures.ts b/src/main/ai-vault/session-scanner-incremental-fixtures.ts index 01219836a58..1945193e6c7 100644 --- a/src/main/ai-vault/session-scanner-incremental-fixtures.ts +++ b/src/main/ai-vault/session-scanner-incremental-fixtures.ts @@ -188,6 +188,56 @@ export function piFixture(): IncrementalAgentFixture { } } +// OMP is a Pi fork sharing the message-graph format, but keys the model on +// `model` (not Pi's `modelId`); its own fixture exercises the registry's 'omp' +// branch and that model-key difference. (The session `title` is included for +// realism only — the parser derives the title from the first user message.) +export function ompFixture(): IncrementalAgentFixture { + return { + agent: 'omp', + fileName: '2026-05-01T10-00-00-000Z_cccccccc-dddd-4eee-8fff-000000000000.jsonl', + seedLines: [ + JSON.stringify({ + type: 'session', + version: 3, + id: 'cccccccc-dddd-4eee-8fff-000000000000', + cwd: '/repo/app', + title: 'OMP session title', + timestamp: '2026-05-01T10:00:00.000Z' + }), + JSON.stringify({ + type: 'model_change', + model: 'gpt-5.4-mini', + timestamp: '2026-05-01T10:00:01.000Z' + }), + JSON.stringify({ + type: 'message', + message: { role: 'user', content: [{ type: 'text', text: 'omp seed question' }] }, + timestamp: '2026-05-01T10:00:05.000Z' + }) + ], + appendLines: [ + JSON.stringify({ + type: 'message', + message: { + role: 'assistant', + content: [{ type: 'text', text: 'omp incremental answer' }], + model: 'gpt-5.4-mini', + usage: { input: 40, output: 20, totalTokens: 60 } + }, + timestamp: '2026-05-01T10:01:00.000Z' + }) + ], + truncatedLines: [ + JSON.stringify({ + type: 'session', + id: 'cccccccc-dddd-4eee-8fff-000000000000', + timestamp: '2026-05-01T10:00:00.000Z' + }) + ] + } +} + export function geminiJsonlFixture(): IncrementalAgentFixture { return { agent: 'gemini', @@ -235,6 +285,7 @@ export function allIncrementalAgentFixtures(): IncrementalAgentFixture[] { droidFixture(), openclawFixture(), piFixture(), + ompFixture(), geminiJsonlFixture() ] } diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-coexistence.test.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-coexistence.test.ts index d0eaa649818..2fb3df21613 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-coexistence.test.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-coexistence.test.ts @@ -36,7 +36,8 @@ function isolatedScanRoots(root: string) { piSessionsDir: join(root, 'pi-sessions'), droidSessionsDir: join(root, 'droid-sessions'), droidProjectsDir: join(root, 'droid-projects'), - kimiSessionsDir: join(root, 'kimi-sessions') + kimiSessionsDir: join(root, 'kimi-sessions'), + ompSessionsDir: join(root, 'omp-sessions') } } diff --git a/src/main/ai-vault/session-scanner-parse-cache.ts b/src/main/ai-vault/session-scanner-parse-cache.ts index dcc7fc649aa..20650f105a9 100644 --- a/src/main/ai-vault/session-scanner-parse-cache.ts +++ b/src/main/ai-vault/session-scanner-parse-cache.ts @@ -59,7 +59,8 @@ function resumableStateFactoryFor( case 'droid': return () => createDroidSessionResumeState(candidate.file) case 'openclaw': - case 'pi': { + case 'pi': + case 'omp': { const agent = candidate.agent return () => createMessageGraphSessionResumeState(agent, candidate.file) } @@ -67,7 +68,12 @@ function resumableStateFactoryFor( return candidate.file.path.endsWith('.jsonl') ? () => createGeminiJsonlSessionResumeState(candidate.file) : null - default: + case 'devin': + case 'grok': + case 'hermes': + case 'kimi': + case 'opencode': + case 'rovo': return null } } @@ -103,7 +109,7 @@ function storeEntry(path: string, entry: SessionParseCacheEntry): void { /** * Parse a session file, reusing prior work where the file is provably * unchanged (mtime+size) and, for append-only JSONL transcripts (Claude, - * Codex, Cursor, Copilot, Droid, OpenClaw/Pi, Gemini-JSONL), resuming the + * Codex, Cursor, Copilot, Droid, OpenClaw/Pi/OMP, Gemini-JSONL), resuming the * parse from the last consumed byte when the file only grew. This is what * keeps the renderer's ~5s forced rescans from re-reading gigabytes of * transcripts (STA-1278/STA-1417: main process pegging one core during diff --git a/src/main/ai-vault/session-scanner-source-discovery.ts b/src/main/ai-vault/session-scanner-source-discovery.ts index 12acdeab188..8c03ec72e42 100644 --- a/src/main/ai-vault/session-scanner-source-discovery.ts +++ b/src/main/ai-vault/session-scanner-source-discovery.ts @@ -6,7 +6,7 @@ import { discoverFiles, discoverOpenClawFiles } from './session-scanner-discover import { droidDiscoveries, kimiDiscoveries } from './session-scanner-droid-kimi-sources' import { opencodeDiscoveries } from './session-scanner-opencode-sources' import type { AiVaultScanOptions, SessionFileDiscovery } from './session-scanner-types' -import { normalizePiSessionsDir } from './session-scanner-values' +import { normalizeAgentSessionsDir } from './session-scanner-values' const CLAUDE_PROJECTS_DIR = join(homedir(), '.claude', 'projects') export const DEFAULT_CODEX_HOME_DIR = join(homedir(), '.codex') @@ -25,8 +25,13 @@ const GROK_SESSIONS_DIR = join( const HERMES_SESSIONS_DIR = join(homedir(), '.hermes', 'sessions') const ROVO_SESSIONS_DIR = join(homedir(), '.rovodev', 'sessions') const OPENCLAW_STATE_DIR = process.env.OPENCLAW_STATE_DIR?.trim() || join(homedir(), '.openclaw') -const PI_SESSIONS_DIR = normalizePiSessionsDir( - process.env.PI_CODING_AGENT_DIR?.trim() || join(homedir(), '.pi', 'agent', 'sessions') +const PI_SESSIONS_DIR = normalizeAgentSessionsDir( + process.env.PI_CODING_AGENT_DIR?.trim() || join(homedir(), '.pi', 'agent', 'sessions'), + '.pi' +) +const OMP_SESSIONS_DIR = normalizeAgentSessionsDir( + process.env.OMP_CODING_AGENT_DIR?.trim() || join(homedir(), '.omp', 'agent', 'sessions'), + '.omp' ) // Why: Devin ATIF transcripts are stored under /transcripts. const DEVIN_TRANSCRIPTS_DIR = join( @@ -124,7 +129,8 @@ function standardDiscoveries( ...devinDiscoveries(options, wslHomeDirs, limit, issues), ...hermesDiscoveries(options, wslHomeDirs, limit, issues), ...rovoDiscoveries(options, wslHomeDirs, limit, issues), - ...piDiscoveries(options, wslHomeDirs, limit, issues) + ...piDiscoveries(options, wslHomeDirs, limit, issues), + ...ompDiscoveries(options, wslHomeDirs, limit, issues) ] } @@ -244,6 +250,21 @@ function piDiscoveries( ) } +function ompDiscoveries( + options: AiVaultScanOptions, + wslHomeDirs: readonly string[], + limit: number, + issues: AiVaultScanIssue[] +): Promise[] { + return sessionRootDirs(options.ompSessionsDir ?? OMP_SESSIONS_DIR, wslHomeDirs, [ + '.omp', + 'agent', + 'sessions' + ]).map((rootDir) => + discoverFiles({ rootDir, limit, agent: 'omp', issues, extensions: ['.jsonl'] }) + ) +} + function openClawDiscovery( options: AiVaultScanOptions, wslHomeDirs: readonly string[], diff --git a/src/main/ai-vault/session-scanner-test-fixtures.ts b/src/main/ai-vault/session-scanner-test-fixtures.ts index b7c4dfd05cf..0a3b6c7c1a8 100644 --- a/src/main/ai-vault/session-scanner-test-fixtures.ts +++ b/src/main/ai-vault/session-scanner-test-fixtures.ts @@ -19,6 +19,7 @@ export function isolatedScanRoots(root: string) { openclawStateDir: join(root, 'openclaw-state'), openclawLegacyStateDir: join(root, 'openclaw-legacy-state'), piSessionsDir: join(root, 'pi-sessions'), + ompSessionsDir: join(root, 'omp-sessions'), droidSessionsDir: join(root, 'droid-sessions'), droidProjectsDir: join(root, 'droid-projects'), kimiSessionsDir: join(root, 'kimi-sessions') diff --git a/src/main/ai-vault/session-scanner-types.ts b/src/main/ai-vault/session-scanner-types.ts index 7c6c38a0be3..5364e29e21e 100644 --- a/src/main/ai-vault/session-scanner-types.ts +++ b/src/main/ai-vault/session-scanner-types.ts @@ -25,6 +25,7 @@ export type AiVaultScanOptions = { openclawStateDir?: string openclawLegacyStateDir?: string piSessionsDir?: string + ompSessionsDir?: string droidSessionsDir?: string droidProjectsDir?: string kimiSessionsDir?: string diff --git a/src/main/ai-vault/session-scanner-values.test.ts b/src/main/ai-vault/session-scanner-values.test.ts index 9c1de341d28..a9954ae8d26 100644 --- a/src/main/ai-vault/session-scanner-values.test.ts +++ b/src/main/ai-vault/session-scanner-values.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { extractPreviewContentText, + normalizeAgentSessionsDir, normalizePreviewText, normalizeTitleText } from './session-scanner-values' @@ -51,4 +52,20 @@ describe('AI Vault session scanner text values', () => { expect(result).toBe(`${'a'.repeat(216)}...`) }) + + it('expands Pi and OMP agent homes to their session directories', () => { + expect(normalizeAgentSessionsDir('/agents/.pi', '.pi')).toBe('/agents/.pi/agent/sessions') + expect(normalizeAgentSessionsDir('/agents/.pi/agent', '.pi')).toBe('/agents/.pi/agent/sessions') + expect(normalizeAgentSessionsDir('/agents/.pi/agent/sessions', '.pi')).toBe( + '/agents/.pi/agent/sessions' + ) + + expect(normalizeAgentSessionsDir('/agents/.omp', '.omp')).toBe('/agents/.omp/agent/sessions') + expect(normalizeAgentSessionsDir('/agents/.omp/agent', '.omp')).toBe( + '/agents/.omp/agent/sessions' + ) + expect(normalizeAgentSessionsDir('/agents/.omp/agent/sessions', '.omp')).toBe( + '/agents/.omp/agent/sessions' + ) + }) }) diff --git a/src/main/ai-vault/session-scanner-values.ts b/src/main/ai-vault/session-scanner-values.ts index 9733175e360..39006bd68a4 100644 --- a/src/main/ai-vault/session-scanner-values.ts +++ b/src/main/ai-vault/session-scanner-values.ts @@ -126,10 +126,15 @@ export function findOpenCodeStorageRoot(filePath: string): string | null { return dirname(sessionRoot) } -export function normalizePiSessionsDir(rawValue: string): string { +// Pi and OMP (a Pi fork) both store transcripts under +// //agent/sessions; accept any prefix of that path. +export function normalizeAgentSessionsDir( + rawValue: string, + agentHomeDirName: '.pi' | '.omp' +): string { const trimmed = rawValue.trim() if (!trimmed) { - return join(homedir(), '.pi', 'agent', 'sessions') + return join(homedir(), agentHomeDirName, 'agent', 'sessions') } const normalized = trimmed.replace(/[\\/]+$/, '') const leaf = basename(normalized) @@ -139,7 +144,7 @@ export function normalizePiSessionsDir(rawValue: string): string { if (leaf === 'agent') { return join(normalized, 'sessions') } - if (leaf === '.pi') { + if (leaf === agentHomeDirName) { return join(normalized, 'agent', 'sessions') } return normalized diff --git a/src/main/ai-vault/session-scanner.test.ts b/src/main/ai-vault/session-scanner.test.ts index 41b1b2b05ab..d4404341fc1 100644 --- a/src/main/ai-vault/session-scanner.test.ts +++ b/src/main/ai-vault/session-scanner.test.ts @@ -2,7 +2,7 @@ import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' -import { AI_VAULT_AGENTS, buildAiVaultResumeCommand } from '../../shared/ai-vault-types' +import { AI_VAULT_AGENTS } from '../../shared/ai-vault-types' import { scanAiVaultSessions } from './session-scanner' import { isolatedScanRoots, jsonLines } from './session-scanner-test-fixtures' @@ -584,6 +584,44 @@ describe('scanAiVaultSessions', () => { ]) ) + const ompSessionFile = join(roots.ompSessionsDir, 'omp-session.jsonl') + await mkdir(roots.ompSessionsDir, { recursive: true }) + await writeFile( + ompSessionFile, + jsonLines([ + { + type: 'session', + version: 3, + id: 'omp-session', + title: 'OMP session title', + timestamp: '2026-05-01T10:08:30.000Z', + cwd: '/tmp/omp' + }, + { + type: 'model_change', + model: 'gpt-5.4-mini', + timestamp: '2026-05-01T10:08:30.500Z' + }, + { + type: 'message', + timestamp: '2026-05-01T10:08:31.000Z', + message: { role: 'user', content: [{ type: 'text', text: 'OMP title' }] } + }, + { + type: 'message', + timestamp: '2026-05-01T10:08:32.000Z', + message: { + role: 'assistant', + content: [{ type: 'text', text: 'OMP answer' }], + model: 'gpt-5.4-mini', + // totalTokens deliberately != input+output so the assertion proves + // the explicit-total field is read, not an input/output sum. + usage: { input: 10, output: 5, totalTokens: 160 } + } + } + ]) + ) + await mkdir(roots.devinTranscriptsDir, { recursive: true }) await writeFile( join(roots.devinTranscriptsDir, 'devin-session.json'), @@ -719,11 +757,48 @@ describe('scanAiVaultSessions', () => { "cd '/tmp/openclaw' && openclaw --resume 'openclaw-session'" ) expect(commandByAgent.get('pi')).toBe("cd '/tmp/pi' && pi --session 'pi-session'") + // OMP resumes by absolute transcript path, not by internal session id. + expect(commandByAgent.get('omp')).toBe(`cd '/tmp/omp' && omp --resume '${ompSessionFile}'`) expect(commandByAgent.get('devin')).toBe("cd '/tmp/devin' && devin --resume 'devin-session'") expect(commandByAgent.get('droid')).toBe("cd '/tmp/droid' && droid --resume 'droid-session'") expect(commandByAgent.get('kimi')).toBe( "cd '/tmp/kimi' && kimi --session 'session_kimi-session'" ) + + const ompSession = result.sessions.find((session) => session.agent === 'omp') + expect(ompSession?.model).toBe('gpt-5.4-mini') + expect(ompSession?.totalTokens).toBe(160) + }) + + it('captures an in-progress OMP model from model_change before any assistant reply', async () => { + // OMP writes the model on `model_change.model` (not Pi's `modelId`). With no + // assistant message yet, the model must still come through — proving the + // model_change fallback rather than assistant-message capture. + const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-omp-mc-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + await mkdir(roots.ompSessionsDir, { recursive: true }) + await writeFile( + join(roots.ompSessionsDir, 'omp-in-progress.jsonl'), + jsonLines([ + { + type: 'session', + id: 'omp-in-progress', + timestamp: '2026-05-01T10:00:00.000Z', + cwd: '/tmp/omp' + }, + { type: 'model_change', model: 'omp-mc-only-model', timestamp: '2026-05-01T10:00:01.000Z' }, + { + type: 'message', + timestamp: '2026-05-01T10:00:02.000Z', + message: { role: 'user', content: [{ type: 'text', text: 'first prompt' }] } + } + ]) + ) + + const result = await scanAiVaultSessions({ ...roots, platform: 'darwin', limit: 5 }) + const session = result.sessions.find((s) => s.agent === 'omp') + expect(session?.model).toBe('omp-mc-only-model') }) it('strips newline-heavy Grok user_query envelopes without regex matching', async () => { @@ -769,42 +844,3 @@ describe('scanAiVaultSessions', () => { expect(usedGrokWrapperMatch).toBe(false) }) }) - -describe('buildAiVaultResumeCommand', () => { - it('wraps Windows cwd changes in cmd so PowerShell and cmd launch the same resume command', () => { - expect( - buildAiVaultResumeCommand({ - agent: 'codex', - sessionId: 'session-1', - cwd: 'C:\\Users\\Ada Lovelace\\repo', - platform: 'win32' - }) - ).toBe('cmd /d /s /c "cd /d ""C:\\Users\\Ada Lovelace\\repo"" && codex resume ""session-1"""') - }) - - it('carries non-default Codex homes in copied resume commands', () => { - expect( - buildAiVaultResumeCommand({ - agent: 'codex', - sessionId: 'session-1', - cwd: '/repo/app', - platform: 'darwin', - codexHome: '/Users/ada/Library/Application Support/Orca/codex-runtime-home/home' - }) - ).toBe( - "cd '/repo/app' && CODEX_HOME='/Users/ada/Library/Application Support/Orca/codex-runtime-home/home' codex resume 'session-1'" - ) - - expect( - buildAiVaultResumeCommand({ - agent: 'codex', - sessionId: 'session-1', - cwd: 'C:\\Users\\Ada Lovelace\\repo', - platform: 'win32', - codexHome: 'C:\\Users\\Ada\\AppData\\Roaming\\Orca\\codex-runtime-home\\home' - }) - ).toBe( - 'cmd /d /s /c "cd /d ""C:\\Users\\Ada Lovelace\\repo"" && set ""CODEX_HOME=C:\\Users\\Ada\\AppData\\Roaming\\Orca\\codex-runtime-home\\home"" && codex resume ""session-1"""' - ) - }) -}) diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index 9a6b8566f57..7a1facf1420 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -533,6 +533,56 @@ describe('CodexRuntimeHomeService', () => { } }) + it('bridges WSL history from a configured per-distro source-home override', async () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const startWslCodexSessionBridgeInBackground = vi.fn(() => Promise.resolve()) + vi.doMock('../codex/wsl-codex-session-bridge', () => ({ + startWslCodexSessionBridgeInBackground + })) + const wslHome = join(testState.userDataDir, 'wsl-home') + vi.doMock('../wsl', () => ({ + getDefaultWslDistro: () => 'Ubuntu', + getWslHome: () => wslHome + })) + const store = createStore( + createSettings({ + activeCodexManagedAccountId: null, + activeCodexManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: null } }, + // Why: the override is a Linux path inside the distro, not /.codex. + codexSessionSourceHome: { wsl: { Ubuntu: '/home/me/.config/codex' } } + }) + ) + + try { + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + const wslRuntimeHomePath = join( + wslHome, + '.local', + 'share', + 'orca', + 'codex-runtime-home', + 'home' + ) + + service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + + expect(startWslCodexSessionBridgeInBackground).toHaveBeenCalledTimes(1) + expect(startWslCodexSessionBridgeInBackground).toHaveBeenCalledWith({ + distro: 'Ubuntu', + systemCodexHomePath: '/home/me/.config/codex', + managedCodexHomePath: wslRuntimeHomePath + }) + } finally { + vi.doUnmock('../codex/wsl-codex-session-bridge') + vi.doUnmock('../wsl') + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + } + }) + it('starts WSL session bridging for the distro used by the materialized runtime home', async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts index 3d2b0670354..67120f15e53 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -40,6 +40,10 @@ import { syncSystemCodexResourcesIntoManagedHome } from '../codex/codex-home-paths' import { startSystemCodexSessionBridgeInBackground } from '../codex/codex-session-bridge' +import { + resolveHostCodexSessionSourceHome, + resolveWslCodexSessionSourceHome +} from '../codex/codex-session-source-home' import { startWslCodexSessionBridgeInBackground } from '../codex/wsl-codex-session-bridge' import { prepareSystemConfigForFreshRuntimeMirror, @@ -146,7 +150,10 @@ export class CodexRuntimeHomeService { syncSystemConfigIntoManagedCodexHome() // Why: historical Codex sessions can be large; bridge them after launch // setup so starting a fresh Codex TUI never waits on a full tree walk. - void startSystemCodexSessionBridgeInBackground() + void startSystemCodexSessionBridgeInBackground( + {}, + resolveHostCodexSessionSourceHome(this.store.getSettings()) + ) return this.getRuntimeHomePath() } @@ -162,10 +169,11 @@ export class CodexRuntimeHomeService { if (!distro) { return } - const systemCodexHomePath = this.getWslSystemCodexHomePath({ - runtime: 'wsl', - wslDistro: distro - }) + // Why: history-only override lets custom-CODEX_HOME users bridge from their + // real home; falls back to /.codex, which auth/config still use. + const systemCodexHomePath = + resolveWslCodexSessionSourceHome(this.store.getSettings(), distro) ?? + this.getWslSystemCodexHomePath({ runtime: 'wsl', wslDistro: distro }) if (!systemCodexHomePath || systemCodexHomePath === runtimeHomePath) { return } diff --git a/src/main/codex/codex-session-bridge.test.ts b/src/main/codex/codex-session-bridge.test.ts index ae4e17be693..ed3c838ea7c 100644 --- a/src/main/codex/codex-session-bridge.test.ts +++ b/src/main/codex/codex-session-bridge.test.ts @@ -215,6 +215,51 @@ describe('syncSystemCodexSessionsIntoManagedHome', () => { ).toBe(false) }) + it('bridges from a custom source home override instead of ~/.codex', () => { + // Why: users with a custom CODEX_HOME point history discovery at that + // folder; the default ~/.codex must be ignored when an override is given. + const customSourceHome = join(fakeHomeDir, 'custom-codex') + const customSessionPath = join( + customSourceHome, + 'sessions', + '2026', + '05', + '26', + 'rollout-custom.jsonl' + ) + mkdirSync(dirname(customSessionPath), { recursive: true }) + writeFileSync(customSessionPath, '{"id":"custom"}\n', 'utf-8') + + // A session under the default ~/.codex should NOT be bridged when overridden. + const defaultSessionPath = join( + getSystemCodexHomePath(), + 'sessions', + '2026', + '05', + '26', + 'rollout-default.jsonl' + ) + mkdirSync(dirname(defaultSessionPath), { recursive: true }) + writeFileSync(defaultSessionPath, '{"id":"default"}\n', 'utf-8') + + syncSystemCodexSessionsIntoManagedHome(customSourceHome) + + const bridgedCustomPath = join( + getRuntimeCodexHomePath(), + 'sessions', + '2026', + '05', + '26', + 'rollout-custom.jsonl' + ) + expect(readFileSync(bridgedCustomPath, 'utf-8')).toBe('{"id":"custom"}\n') + expect( + existsSync( + join(getRuntimeCodexHomePath(), 'sessions', '2026', '05', '26', 'rollout-default.jsonl') + ) + ).toBe(false) + }) + it('falls back to symlinks when hardlinks are unavailable', () => { fsMockState.failLink = true const systemSessionPath = join( diff --git a/src/main/codex/codex-session-bridge.ts b/src/main/codex/codex-session-bridge.ts index a574228953e..e546ce9d8f8 100644 --- a/src/main/codex/codex-session-bridge.ts +++ b/src/main/codex/codex-session-bridge.ts @@ -42,9 +42,12 @@ let backgroundSessionBridgeTask: Promise | null = null /** * Synchronously mirrors system session files into the managed runtime home. + * + * `sourceCodexHomePath` overrides the default ~/.codex history source for users + * who run Codex with a custom CODEX_HOME; it only affects history discovery. */ -export function syncSystemCodexSessionsIntoManagedHome(): void { - const systemSessionsRoot = join(getSystemCodexHomePath(), 'sessions') +export function syncSystemCodexSessionsIntoManagedHome(sourceCodexHomePath?: string): void { + const systemSessionsRoot = join(sourceCodexHomePath || getSystemCodexHomePath(), 'sessions') if (!existsSync(systemSessionsRoot)) { return } @@ -62,12 +65,13 @@ export function syncSystemCodexSessionsIntoManagedHome(): void { * background bridging without starting duplicate directory walks. */ export function startSystemCodexSessionBridgeInBackground( - options: CodexSessionBridgeIncrementalOptions = {} + options: CodexSessionBridgeIncrementalOptions = {}, + sourceCodexHomePath?: string ): Promise { if (backgroundSessionBridgeTask) { return backgroundSessionBridgeTask } - const task = syncSystemCodexSessionsIntoManagedHomeIncrementally(options) + const task = syncSystemCodexSessionsIntoManagedHomeIncrementally(options, sourceCodexHomePath) .catch((error: unknown) => { console.warn('[codex-session-bridge] Background session bridge failed:', error) }) @@ -88,9 +92,10 @@ export function startSystemCodexSessionBridgeInBackground( * bridge operation equivalent to the synchronous path. */ export async function syncSystemCodexSessionsIntoManagedHomeIncrementally( - options: CodexSessionBridgeIncrementalOptions = {} + options: CodexSessionBridgeIncrementalOptions = {}, + sourceCodexHomePath?: string ): Promise { - const systemSessionsRoot = join(getSystemCodexHomePath(), 'sessions') + const systemSessionsRoot = join(sourceCodexHomePath || getSystemCodexHomePath(), 'sessions') if (!existsSync(systemSessionsRoot)) { return { scannedFiles: 0, linkedFiles: 0 } } diff --git a/src/main/codex/codex-session-source-home.test.ts b/src/main/codex/codex-session-source-home.test.ts new file mode 100644 index 00000000000..fa29650c495 --- /dev/null +++ b/src/main/codex/codex-session-source-home.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest' +import { + resolveHostCodexSessionSourceHome, + resolveWslCodexSessionSourceHome +} from './codex-session-source-home' + +describe('resolveHostCodexSessionSourceHome', () => { + it('returns undefined when no override is configured', () => { + expect(resolveHostCodexSessionSourceHome({})).toBeUndefined() + expect(resolveHostCodexSessionSourceHome({ codexSessionSourceHome: {} })).toBeUndefined() + }) + + it('returns undefined for blank/whitespace overrides so the default is kept', () => { + expect( + resolveHostCodexSessionSourceHome({ codexSessionSourceHome: { host: ' ' } }) + ).toBeUndefined() + }) + + it('returns the trimmed host override path', () => { + expect( + resolveHostCodexSessionSourceHome({ codexSessionSourceHome: { host: ' /custom/codex ' } }) + ).toBe('/custom/codex') + }) +}) + +describe('resolveWslCodexSessionSourceHome', () => { + it('returns undefined when no per-distro override exists', () => { + expect(resolveWslCodexSessionSourceHome({}, 'Ubuntu')).toBeUndefined() + expect( + resolveWslCodexSessionSourceHome( + { codexSessionSourceHome: { wsl: { Debian: '/home/me/.codex' } } }, + 'Ubuntu' + ) + ).toBeUndefined() + }) + + it('resolves a per-distro override for the matching distro', () => { + expect( + resolveWslCodexSessionSourceHome( + { codexSessionSourceHome: { wsl: { Ubuntu: '/home/me/.config/codex' } } }, + 'Ubuntu' + ) + ).toBe('/home/me/.config/codex') + }) + + it('matches distro names case-insensitively, mirroring WSL', () => { + expect( + resolveWslCodexSessionSourceHome( + { codexSessionSourceHome: { wsl: { Ubuntu: '/home/me/.config/codex' } } }, + 'ubuntu' + ) + ).toBe('/home/me/.config/codex') + }) + + it('ignores blank per-distro overrides so the default is kept', () => { + expect( + resolveWslCodexSessionSourceHome( + { codexSessionSourceHome: { wsl: { Ubuntu: ' ' } } }, + 'Ubuntu' + ) + ).toBeUndefined() + }) + + it('does not leak the host override into WSL resolution', () => { + expect( + resolveWslCodexSessionSourceHome( + { codexSessionSourceHome: { host: '/custom/codex' } }, + 'Ubuntu' + ) + ).toBeUndefined() + }) +}) diff --git a/src/main/codex/codex-session-source-home.ts b/src/main/codex/codex-session-source-home.ts new file mode 100644 index 00000000000..0060051e924 --- /dev/null +++ b/src/main/codex/codex-session-source-home.ts @@ -0,0 +1,43 @@ +import type { GlobalSettings } from '../../shared/types' + +/** + * Resolves the user-configured Codex *session history* source home, if any. + * + * Why: Orca relocates CODEX_HOME to a managed home, then bridges history from + * the user's real Codex home so /resume finds it. That source defaults to + * ~/.codex, but users who run Codex with a custom CODEX_HOME need to point + * history discovery at that folder. This override affects history only; auth, + * config, and hooks continue to read from ~/.codex. + */ + +/** Host override; returns undefined to keep the default ~/.codex source. */ +export function resolveHostCodexSessionSourceHome( + settings: Pick +): string | undefined { + return normalizeSourceHome(settings.codexSessionSourceHome?.host) +} + +/** Per-distro WSL override; returns undefined to keep the default /.codex source. */ +export function resolveWslCodexSessionSourceHome( + settings: Pick, + distro: string +): string | undefined { + const perDistro = settings.codexSessionSourceHome?.wsl + if (!perDistro) { + return undefined + } + // Why: distro keys are matched case-insensitively so "Ubuntu" and "ubuntu" + // resolve the same override, mirroring how WSL treats distro names. + const normalizedDistro = distro.trim().toLowerCase() + for (const [key, value] of Object.entries(perDistro)) { + if (key.trim().toLowerCase() === normalizedDistro) { + return normalizeSourceHome(value) + } + } + return undefined +} + +function normalizeSourceHome(value: string | undefined): string | undefined { + const trimmed = value?.trim() + return trimmed ? trimmed : undefined +} diff --git a/src/main/github/client.test.ts b/src/main/github/client.test.ts index 2138ba0aad0..5363cb56c60 100644 --- a/src/main/github/client.test.ts +++ b/src/main/github/client.test.ts @@ -122,7 +122,7 @@ import { _resetMergeQueueCacheForTests, __resetTrackedUpstreamBranchCacheForTests } from './client' -import { __resetPRConflictSummaryGitCapabilityCacheForTests } from './conflict-summary' +import { __resetPRConflictSummaryCachesForTests } from './conflict-summary' import { resetMergedPRCommitMembershipCacheForTest } from './merged-pr-commit-membership' describe('checkOrcaStarred', () => { @@ -194,7 +194,7 @@ describe('getPRForBranch', () => { _resetOwnerRepoCache() _resetMergeQueueCacheForTests() __resetTrackedUpstreamBranchCacheForTests() - __resetPRConflictSummaryGitCapabilityCacheForTests() + __resetPRConflictSummaryCachesForTests() resetMergedPRCommitMembershipCacheForTest() }) @@ -2987,11 +2987,16 @@ describe('getPRForBranch', () => { baseRefOid: 'base-oid', headRefOid: 'head-oid' } + // Why a second head OID: identical inputs now hit the summary result + // cache outright; a pushed head re-derives and must still skip the + // unsupported --merge-base retry via the capability cache. + const pushedBranchLookup = { ...branchLookup, head: { ref: 'feature/test', sha: 'head-oid-2' } } + const pushedExactLookup = { ...exactLookup, headRefOid: 'head-oid-2' } ghExecFileAsyncMock .mockResolvedValueOnce({ stdout: JSON.stringify([branchLookup]) }) .mockResolvedValueOnce({ stdout: JSON.stringify(exactLookup) }) - .mockResolvedValueOnce({ stdout: JSON.stringify([branchLookup]) }) - .mockResolvedValueOnce({ stdout: JSON.stringify(exactLookup) }) + .mockResolvedValueOnce({ stdout: JSON.stringify([pushedBranchLookup]) }) + .mockResolvedValueOnce({ stdout: JSON.stringify(pushedExactLookup) }) gitExecFileAsyncMock .mockResolvedValueOnce({ stdout: '' }) .mockResolvedValueOnce({ stdout: 'latest-base-oid\n' }) @@ -2999,8 +3004,6 @@ describe('getPRForBranch', () => { .mockResolvedValueOnce({ stdout: '2\n' }) .mockRejectedValueOnce({ stderr: "error: unknown option `merge-base'" }) .mockRejectedValueOnce({ stdout: 'result-tree-oid\u0000src/conflict.ts\u0000' }) - .mockResolvedValueOnce({ stdout: '' }) - .mockResolvedValueOnce({ stdout: 'latest-base-oid\n' }) .mockResolvedValueOnce({ stdout: 'merge-base-oid\n' }) .mockResolvedValueOnce({ stdout: '2\n' }) .mockRejectedValueOnce({ stdout: 'result-tree-oid\u0000src/conflict.ts\u0000' }) @@ -3567,6 +3570,7 @@ describe('GitHub GraphQL rate-limit guard', () => { acquireMock.mockResolvedValue(undefined) _resetOwnerRepoCache() _resetMergeQueueCacheForTests() + __resetPRConflictSummaryCachesForTests() }) it('skips PR review-thread GraphQL fetch while preserving REST comments', async () => { diff --git a/src/main/github/conflict-summary-cache.ts b/src/main/github/conflict-summary-cache.ts new file mode 100644 index 00000000000..99a2715ce6d --- /dev/null +++ b/src/main/github/conflict-summary-cache.ts @@ -0,0 +1,150 @@ +import type { PRConflictSummary } from '../../shared/types' + +// Why 60s: the hottest coordinator cadences that re-derive a CONFLICTING PR +// (10s mergeability-pending, 2.5s manual-pending) previously each ran a +// network fetch; one fetch per base branch per minute matches the 60s minimum +// background refresh cadence, so the tracked base tip is never staler than the +// PR data around it. Manual refresh intentionally shares the window: GitHub's +// own mergeability recompute is async too, and the card self-corrects within +// a minute. +export const CONFLICT_SUMMARY_BASE_FETCH_WINDOW_MS = 60_000 + +// Why bounded: the main process is long-lived; cap the maps so repos and PRs +// that stop refreshing can't accumulate entries forever. +const BASE_OID_CACHE_MAX = 64 +const SUMMARY_CACHE_MAX = 128 + +export type FreshBaseTipResolution = + | { kind: 'resolved'; oid: string } + | { kind: 'fallback-unresolved' } + +type CachedBaseTipResolution = { + oid: string | null + resolvedAt: number +} + +type CachedSummary = { + value: PRConflictSummary | undefined + // Why: a successful summary is a pure function of two immutable commit OIDs + // and never goes stale; a failed derivation depends on which objects exist + // locally, which the next fetch window can change, so it carries an expiry. + staleAt: number | null +} + +const baseOidCache = new Map() +const summaryCache = new Map() +const inFlightBaseOidResolves = new Map>() +const inFlightSummaryDerivations = new Map>() + +// Why: WSL distros have their own git binary, filesystem view, and remote +// access, so cached state must never leak across the host/distro boundary. +export function getConflictSummaryGitRuntimeKey(wslDistro: string | undefined): string { + return wslDistro ? `wsl:${wslDistro}` : 'local:host' +} + +// Why JSON: repo paths and git ref names may contain any printable joiner +// character (git allows `|` in branch names), so a delimiter-joined key could +// alias distinct identities onto one cache entry. +export function buildConflictSummaryCacheKey(...parts: string[]): string { + return JSON.stringify(parts) +} + +export function readFreshBaseTipResolution(baseKey: string): FreshBaseTipResolution | null { + const entry = baseOidCache.get(baseKey) + if (!entry) { + return null + } + if (Date.now() - entry.resolvedAt >= CONFLICT_SUMMARY_BASE_FETCH_WINDOW_MS) { + baseOidCache.delete(baseKey) + return null + } + return entry.oid ? { kind: 'resolved', oid: entry.oid } : { kind: 'fallback-unresolved' } +} + +export function storeResolvedBaseTip(baseKey: string, oid: string): void { + setBoundedMapEntry(baseOidCache, baseKey, { oid, resolvedAt: Date.now() }, BASE_OID_CACHE_MAX) +} + +export function rememberUnresolvedBaseTip(baseKey: string): void { + setBoundedMapEntry( + baseOidCache, + baseKey, + { oid: null, resolvedAt: Date.now() }, + BASE_OID_CACHE_MAX + ) +} + +export function readCachedSummary(summaryKey: string): CachedSummary | null { + const entry = summaryCache.get(summaryKey) + if (!entry) { + return null + } + if (entry.staleAt !== null && Date.now() >= entry.staleAt) { + summaryCache.delete(summaryKey) + return null + } + return entry +} + +export function storeCachedSummary(summaryKey: string, value: PRConflictSummary | undefined): void { + setBoundedMapEntry( + summaryCache, + summaryKey, + { + value, + staleAt: value === undefined ? Date.now() + CONFLICT_SUMMARY_BASE_FETCH_WINDOW_MS : null + }, + SUMMARY_CACHE_MAX + ) +} + +export function dedupeBaseOidResolve( + key: string, + factory: () => Promise +): Promise { + return dedupeInFlight(inFlightBaseOidResolves, key, factory) +} + +export function dedupeSummaryDerivation( + key: string, + factory: () => Promise +): Promise { + return dedupeInFlight(inFlightSummaryDerivations, key, factory) +} + +function dedupeInFlight( + map: Map>, + key: string, + factory: () => Promise +): Promise { + const existing = map.get(key) + if (existing) { + return existing + } + const promise = factory().finally(() => { + map.delete(key) + }) + map.set(key, promise) + return promise +} + +function setBoundedMapEntry(map: Map, key: K, value: V, maxEntries: number): void { + if (map.has(key)) { + map.delete(key) + } + map.set(key, value) + while (map.size > maxEntries) { + const oldest = map.keys().next() + if (oldest.done) { + return + } + map.delete(oldest.value) + } +} + +export function __resetPRConflictSummaryDerivationCachesForTests(): void { + baseOidCache.clear() + summaryCache.clear() + inFlightBaseOidResolves.clear() + inFlightSummaryDerivations.clear() +} diff --git a/src/main/github/conflict-summary.test.ts b/src/main/github/conflict-summary.test.ts new file mode 100644 index 00000000000..a9b5e9aee9a --- /dev/null +++ b/src/main/github/conflict-summary.test.ts @@ -0,0 +1,331 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +// Why these tests exist: the conflict-summary derivation used to re-run a +// network `git fetch` plus a four-subprocess chain on every PR refresh tick +// (measured ~490 full derivations in 2.1h on one machine). They pin the +// regression contract by counting subprocess spawns: unchanged inputs must +// cost zero subprocesses, and the base fetch must run at most once per +// throttle window. + +const gitExecFileAsyncMock = vi.hoisted(() => vi.fn()) + +vi.mock('../git/runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock })) + +import { CONFLICT_SUMMARY_BASE_FETCH_WINDOW_MS } from './conflict-summary-cache' +import { __resetPRConflictSummaryCachesForTests, getPRConflictSummary } from './conflict-summary' + +type GitResult = { stdout: string } +type GitHandler = (argv: string[]) => Promise + +const defaultHandlers: Record = { + fetch: async () => ({ stdout: '' }), + 'rev-parse': async () => ({ stdout: 'base-tip-1\n' }), + 'merge-base': async () => ({ stdout: 'merge-base-1\n' }), + 'rev-list': async () => ({ stdout: '3\n' }), + 'merge-tree': async () => ({ stdout: 'tree-oid\u0000src/conflict.ts\u0000' }) +} + +function mockGitDispatch(overrides: Record = {}): void { + gitExecFileAsyncMock.mockImplementation((argv: string[]) => { + const handler = overrides[argv[0]] ?? defaultHandlers[argv[0]] + if (!handler) { + return Promise.reject(new Error(`unexpected git command: ${argv.join(' ')}`)) + } + return handler(argv) + }) +} + +function spawnCount(command?: string): number { + const calls = gitExecFileAsyncMock.mock.calls + if (!command) { + return calls.length + } + return calls.filter(([argv]) => Array.isArray(argv) && argv[0] === command).length +} + +const expectedSummary = { + baseRef: 'main', + baseCommit: 'base-ti', + commitsBehind: 3, + files: ['src/conflict.ts'] +} + +function deriveSummary(headRefOid = 'head-oid-1', wslDistro?: string) { + return getPRConflictSummary( + '/repo-root', + 'main', + 'github-base-oid', + headRefOid, + wslDistro ? { wslDistro } : {} + ) +} + +describe('getPRConflictSummary caching', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(1_750_000_000_000) + gitExecFileAsyncMock.mockReset() + __resetPRConflictSummaryCachesForTests() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('runs exactly one fetch and one derivation chain, then zero subprocesses on repeat calls', async () => { + mockGitDispatch() + + const first = await deriveSummary() + expect(first).toEqual(expectedSummary) + expect(spawnCount('fetch')).toBe(1) + expect(spawnCount()).toBe(5) + + const second = await deriveSummary() + const third = await deriveSummary() + expect(second).toEqual(expectedSummary) + expect(third).toEqual(expectedSummary) + expect(spawnCount()).toBe(5) + }) + + it('re-derives when headRefOid changes without re-fetching inside the throttle window', async () => { + mockGitDispatch() + + await deriveSummary('head-oid-1') + const afterPush = await deriveSummary('head-oid-2') + + expect(afterPush).toEqual(expectedSummary) + expect(spawnCount('fetch')).toBe(1) + expect(spawnCount('rev-parse')).toBe(1) + expect(spawnCount('merge-base')).toBe(2) + expect(spawnCount('rev-list')).toBe(2) + expect(spawnCount('merge-tree')).toBe(2) + }) + + it('fetches again after the throttle window and skips derivation when the base tip is unchanged', async () => { + mockGitDispatch() + + await deriveSummary() + vi.setSystemTime(1_750_000_000_000 + CONFLICT_SUMMARY_BASE_FETCH_WINDOW_MS + 1_000) + const afterExpiry = await deriveSummary() + + expect(afterExpiry).toEqual(expectedSummary) + expect(spawnCount('fetch')).toBe(2) + expect(spawnCount('rev-parse')).toBe(2) + // Why: same (headRefOid, latestBaseOid) pair — the summary cache still hits. + expect(spawnCount('merge-base')).toBe(1) + expect(spawnCount('rev-list')).toBe(1) + expect(spawnCount('merge-tree')).toBe(1) + }) + + it('re-derives after the throttle window when the base tip moved', async () => { + mockGitDispatch() + await deriveSummary() + + vi.setSystemTime(1_750_000_000_000 + CONFLICT_SUMMARY_BASE_FETCH_WINDOW_MS + 1_000) + mockGitDispatch({ + 'rev-parse': async () => ({ stdout: 'base-tip-2\n' }), + 'rev-list': async () => ({ stdout: '5\n' }) + }) + const afterBaseMove = await deriveSummary() + + expect(afterBaseMove).toEqual({ ...expectedSummary, baseCommit: 'base-ti', commitsBehind: 5 }) + expect(spawnCount('fetch')).toBe(2) + expect(spawnCount('merge-base')).toBe(2) + expect(spawnCount('rev-list')).toBe(2) + expect(spawnCount('merge-tree')).toBe(2) + }) + + it('dedupes concurrent identical calls onto one in-flight subprocess chain', async () => { + let releaseFetch: ((value: GitResult) => void) | undefined + mockGitDispatch({ + fetch: () => + new Promise((resolve) => { + releaseFetch = resolve + }) + }) + + const firstCall = deriveSummary() + const secondCall = deriveSummary() + releaseFetch?.({ stdout: '' }) + const [first, second] = await Promise.all([firstCall, secondCall]) + + expect(first).toEqual(expectedSummary) + expect(second).toEqual(expectedSummary) + expect(spawnCount()).toBe(5) + }) + + it('dedupes overlapping derivations after resolving the same live base tip', async () => { + let releaseFetch: ((value: GitResult) => void) | undefined + mockGitDispatch({ + fetch: () => + new Promise((resolve) => { + releaseFetch = resolve + }) + }) + + const backgroundRefresh = getPRConflictSummary( + '/repo-root', + 'main', + 'older-github-base-oid', + 'head-oid-1', + {} + ) + const manualRefresh = getPRConflictSummary( + '/repo-root', + 'main', + 'newer-github-base-oid', + 'head-oid-1', + {} + ) + releaseFetch?.({ stdout: '' }) + const [backgroundSummary, manualSummary] = await Promise.all([backgroundRefresh, manualRefresh]) + + expect(backgroundSummary).toEqual(expectedSummary) + expect(manualSummary).toEqual(expectedSummary) + expect(spawnCount('fetch')).toBe(1) + expect(spawnCount('rev-parse')).toBe(1) + expect(spawnCount('merge-base')).toBe(1) + expect(spawnCount('rev-list')).toBe(1) + expect(spawnCount('merge-tree')).toBe(1) + }) + + it('shares one base fetch across concurrent PRs on the same base branch', async () => { + let releaseFetch: ((value: GitResult) => void) | undefined + mockGitDispatch({ + fetch: () => + new Promise((resolve) => { + releaseFetch = resolve + }) + }) + + const prA = deriveSummary('head-oid-a') + const prB = deriveSummary('head-oid-b') + releaseFetch?.({ stdout: '' }) + const [summaryA, summaryB] = await Promise.all([prA, prB]) + + expect(summaryA).toEqual(expectedSummary) + expect(summaryB).toEqual(expectedSummary) + expect(spawnCount('fetch')).toBe(1) + expect(spawnCount('rev-parse')).toBe(1) + expect(spawnCount('merge-base')).toBe(2) + }) + + it('keeps concurrent fallback base OIDs isolated per PR when the base ref is unavailable', async () => { + let releaseFetch: ((value: GitResult) => void) | undefined + mockGitDispatch({ + fetch: () => + new Promise((resolve) => { + releaseFetch = resolve + }), + 'rev-parse': () => Promise.reject(new Error('missing remote-tracking ref')), + 'rev-list': async (argv) => ({ + stdout: argv[2]?.includes('bbbb2222-base') ? '7\n' : '4\n' + }), + 'merge-tree': async (argv) => ({ + stdout: argv.includes('bbbb2222-base') + ? 'tree-oid\u0000src/b.ts\u0000' + : 'tree-oid\u0000src/a.ts\u0000' + }) + }) + + const prA = getPRConflictSummary('/repo-root', 'main', 'aaaa1111-base', 'head-oid-a', {}) + const prB = getPRConflictSummary('/repo-root', 'main', 'bbbb2222-base', 'head-oid-b', {}) + releaseFetch?.({ stdout: '' }) + const [summaryA, summaryB] = await Promise.all([prA, prB]) + + expect(summaryA).toEqual({ + baseRef: 'main', + baseCommit: 'aaaa111', + commitsBehind: 4, + files: ['src/a.ts'] + }) + expect(summaryB).toEqual({ + baseRef: 'main', + baseCommit: 'bbbb222', + commitsBehind: 7, + files: ['src/b.ts'] + }) + expect(spawnCount('fetch')).toBe(1) + expect(spawnCount('merge-base')).toBe(2) + }) + + it('falls back to the local remote-tracking ref when fetch fails', async () => { + mockGitDispatch({ + fetch: () => Promise.reject(new Error('offline')) + }) + + const summary = await deriveSummary() + + expect(summary).toEqual(expectedSummary) + expect(spawnCount('fetch')).toBe(1) + + // Why: a failed fetch attempt must also be throttled — offline machines + // were the worst case, paying the 10s fetch timeout on every tick. + const repeat = await deriveSummary() + expect(repeat).toEqual(expectedSummary) + expect(spawnCount('fetch')).toBe(1) + expect(spawnCount()).toBe(5) + }) + + it("falls back to GitHub's baseRefOid when fetch and remote-tracking refs are unavailable", async () => { + mockGitDispatch({ + fetch: () => Promise.reject(new Error('offline')), + 'rev-parse': () => Promise.reject(new Error('unknown revision')) + }) + + const summary = await deriveSummary() + + expect(summary).toEqual({ ...expectedSummary, baseCommit: 'github-' }) + expect( + gitExecFileAsyncMock.mock.calls.some( + ([argv]) => argv[0] === 'merge-base' && argv.includes('github-base-oid') + ) + ).toBe(true) + }) + + it('keeps WSL-distro derivations isolated from host derivations', async () => { + mockGitDispatch() + + await deriveSummary() + await deriveSummary('head-oid-1', 'Ubuntu') + + expect(spawnCount('fetch')).toBe(2) + expect( + gitExecFileAsyncMock.mock.calls.filter( + ([argv, options]) => + argv[0] === 'fetch' && (options as { wslDistro?: string })?.wslDistro === 'Ubuntu' + ) + ).toHaveLength(1) + }) + + it('keeps identities distinct when paths or ref names contain a joiner character', async () => { + mockGitDispatch() + + // Why: these two calls alias under naive pipe-joined keys — the segment + // boundary shifts between repoPath and baseRefName. + await getPRConflictSummary('/repo|x', 'main', 'github-base-oid', 'head-oid-1', {}) + await getPRConflictSummary('/repo', 'x|main', 'github-base-oid', 'head-oid-1', {}) + + expect(spawnCount('fetch')).toBe(2) + expect(spawnCount('merge-base')).toBe(2) + }) + + it('caches failed derivations only until the throttle window expires', async () => { + mockGitDispatch({ + 'merge-base': () => Promise.reject(new Error('bad object')) + }) + + await expect(deriveSummary()).resolves.toBeUndefined() + const failureSpawns = spawnCount() + + // Within the window the failure is negative-cached: zero subprocesses. + await expect(deriveSummary()).resolves.toBeUndefined() + expect(spawnCount()).toBe(failureSpawns) + + // After expiry the derivation retries (a fetch may have brought objects in). + vi.setSystemTime(1_750_000_000_000 + CONFLICT_SUMMARY_BASE_FETCH_WINDOW_MS + 1_000) + mockGitDispatch() + await expect(deriveSummary()).resolves.toEqual(expectedSummary) + expect(spawnCount('merge-base')).toBe(2) + }) +}) diff --git a/src/main/github/conflict-summary.ts b/src/main/github/conflict-summary.ts index ce0c4b361b7..9644555048a 100644 --- a/src/main/github/conflict-summary.ts +++ b/src/main/github/conflict-summary.ts @@ -1,5 +1,17 @@ import type { PRConflictSummary } from '../../shared/types' import { gitExecFileAsync } from '../git/runner' +import { + __resetPRConflictSummaryDerivationCachesForTests, + buildConflictSummaryCacheKey, + dedupeBaseOidResolve, + dedupeSummaryDerivation, + getConflictSummaryGitRuntimeKey, + readCachedSummary, + readFreshBaseTipResolution, + rememberUnresolvedBaseTip, + storeResolvedBaseTip, + storeCachedSummary +} from './conflict-summary-cache' type LocalGitExecOptions = { wslDistro?: string @@ -7,8 +19,9 @@ type LocalGitExecOptions = { const mergeTreeMergeBaseUnsupportedRuntimes = new Set() -export function __resetPRConflictSummaryGitCapabilityCacheForTests(): void { +export function __resetPRConflictSummaryCachesForTests(): void { mergeTreeMergeBaseUnsupportedRuntimes.clear() + __resetPRConflictSummaryDerivationCachesForTests() } export async function getPRConflictSummary( @@ -18,45 +31,122 @@ export async function getPRConflictSummary( headRefOid: string, localGitOptions: LocalGitExecOptions = {} ): Promise { - try { - // Why: the renderer only needs a read-only merge-conflict snapshot. We - // derive it from local git state so the PR card can show GitHub-style - // detail without spending additional gh API calls on every refresh. We use - // GitHub's head OID directly because the registered repo path may not have - // a matching local branch name for the PR head. For the base side, prefer a - // freshly-fetched remote-tracking ref so Orca matches GitHub's portal, - // which compares against the latest base branch tip rather than the PR's - // older pinned baseRefOid snapshot. - const latestBaseOid = await resolveLatestBaseOid( + // Why: the renderer only needs a read-only merge-conflict snapshot. We + // derive it from local git state so the PR card can show GitHub-style + // detail without spending additional gh API calls on every refresh. We use + // GitHub's head OID directly because the registered repo path may not have + // a matching local branch name for the PR head. For the base side, prefer a + // freshly-fetched remote-tracking ref so Orca matches GitHub's portal, + // which compares against the latest base branch tip rather than the PR's + // older pinned baseRefOid snapshot. + const latestBaseOid = await resolveLatestBaseOidThrottled( + repoPath, + baseRefName, + baseRefOid, + localGitOptions + ) + // Why: the summary is a pure function of the two commit OIDs, so a key hit + // can skip the whole merge-base/rev-list/merge-tree subprocess chain. + const runtimeKey = getConflictSummaryGitRuntimeKey(localGitOptions.wslDistro) + const summaryKey = buildConflictSummaryCacheKey( + runtimeKey, + repoPath, + baseRefName, + headRefOid, + latestBaseOid + ) + const cached = readCachedSummary(summaryKey) + if (cached) { + return cached.value + } + + // Why: different GitHub reads can report different pinned baseRefOid values + // while still resolving to the same live base tip; dedupe the expensive + // local derivation on the actual summary identity. + return dedupeSummaryDerivation(summaryKey, () => + derivePRConflictSummary( repoPath, baseRefName, - baseRefOid, + headRefOid, + latestBaseOid, + summaryKey, localGitOptions ) + ) +} + +async function derivePRConflictSummary( + repoPath: string, + baseRefName: string, + headRefOid: string, + latestBaseOid: string, + summaryKey: string, + localGitOptions: LocalGitExecOptions +): Promise { + const cached = readCachedSummary(summaryKey) + if (cached) { + return cached.value + } + + try { const mergeBase = await resolveMergeBase(repoPath, headRefOid, latestBaseOid, localGitOptions) const [commitsBehind, files] = await Promise.all([ countCommits(repoPath, `${headRefOid}..${latestBaseOid}`, localGitOptions), loadConflictingFiles(repoPath, mergeBase, headRefOid, latestBaseOid, localGitOptions) ]) - return { + const summary = { baseRef: baseRefName, baseCommit: latestBaseOid.slice(0, 7), commitsBehind, files, ...(files.length === 0 ? { localMergeState: 'clean' as const } : {}) } + storeCachedSummary(summaryKey, summary) + return summary } catch { + storeCachedSummary(summaryKey, undefined) return undefined } } -async function resolveLatestBaseOid( +async function resolveLatestBaseOidThrottled( repoPath: string, baseRefName: string, fallbackBaseOid: string, localGitOptions: LocalGitExecOptions ): Promise { + const runtimeKey = getConflictSummaryGitRuntimeKey(localGitOptions.wslDistro) + const baseKey = buildConflictSummaryCacheKey(runtimeKey, repoPath, baseRefName) + const cachedResolution = readFreshBaseTipResolution(baseKey) + if (cachedResolution) { + return cachedResolution.kind === 'resolved' ? cachedResolution.oid : fallbackBaseOid + } + return dedupeBaseOidResolve(baseKey, async () => { + // Why re-check inside the dedupe slot: a sibling caller may have finished + // resolving between our cache read and this factory starting. + const freshResolution = readFreshBaseTipResolution(baseKey) + if (freshResolution) { + return freshResolution + } + const oid = await resolveLatestBaseOid(repoPath, baseRefName, localGitOptions) + if (oid) { + storeResolvedBaseTip(baseKey, oid) + return { kind: 'resolved', oid } + } + // Why cache the unresolved probe, not the caller fallback: the fetch + // attempt is branch-wide expensive work, but GitHub's baseRefOid is + // PR-specific and must not leak to sibling PRs on the same base branch. + rememberUnresolvedBaseTip(baseKey) + return { kind: 'fallback-unresolved' } + }).then((resolution) => (resolution.kind === 'resolved' ? resolution.oid : fallbackBaseOid)) +} + +async function resolveLatestBaseOid( + repoPath: string, + baseRefName: string, + localGitOptions: LocalGitExecOptions +): Promise { const remoteName = 'origin' try { @@ -88,7 +178,7 @@ async function resolveLatestBaseOid( } } - return fallbackBaseOid + return null } async function resolveMergeBase( @@ -123,7 +213,7 @@ async function loadConflictingFiles( baseOid: string, localGitOptions: LocalGitExecOptions ): Promise { - const capabilityKey = getMergeTreeCapabilityKey(localGitOptions) + const capabilityKey = getConflictSummaryGitRuntimeKey(localGitOptions.wslDistro) const modernArgs = [ 'merge-tree', '--write-tree', @@ -193,10 +283,6 @@ async function loadConflictingFilesWithLegacyMergeTree( } } -function getMergeTreeCapabilityKey(localGitOptions: LocalGitExecOptions): string { - return localGitOptions.wslDistro ? `wsl:${localGitOptions.wslDistro}` : 'local:host' -} - function parseMergeTreeNameOnlyOutput(stdout: string): string[] { const entries = stdout.split('\0').filter(Boolean) if (entries.length === 0) { diff --git a/src/main/github/pr-refresh-coordinator.test.ts b/src/main/github/pr-refresh-coordinator.test.ts index 76bd2460307..3e53d0116d1 100644 --- a/src/main/github/pr-refresh-coordinator.test.ts +++ b/src/main/github/pr-refresh-coordinator.test.ts @@ -3,14 +3,19 @@ request timestamps, and follow-up scheduling against shared module state. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { GitHubPRRefreshCandidate, PRInfo } from '../../shared/types' -const { sendMock, getAllWebContentsMock, getPRForBranchOutcomeMock, getRateLimitMock } = vi.hoisted( - () => ({ - sendMock: vi.fn(), - getAllWebContentsMock: vi.fn(), - getPRForBranchOutcomeMock: vi.fn(), - getRateLimitMock: vi.fn() - }) -) +const { + sendMock, + getAllWebContentsMock, + getPRForBranchOutcomeMock, + getRateLimitMock, + rateLimitGuardMock +} = vi.hoisted(() => ({ + sendMock: vi.fn(), + getAllWebContentsMock: vi.fn(), + getPRForBranchOutcomeMock: vi.fn(), + getRateLimitMock: vi.fn(), + rateLimitGuardMock: vi.fn() +})) vi.mock('electron', () => ({ webContents: { @@ -25,7 +30,7 @@ vi.mock('./client', () => ({ vi.mock('./rate-limit', () => ({ getRateLimit: getRateLimitMock, noteRateLimitSpend: vi.fn(), - rateLimitGuard: vi.fn(() => ({ blocked: false })) + rateLimitGuard: rateLimitGuardMock })) function makeCandidate( @@ -80,6 +85,8 @@ describe('pr-refresh-coordinator', () => { getAllWebContentsMock.mockReset() getPRForBranchOutcomeMock.mockReset() getRateLimitMock.mockReset() + rateLimitGuardMock.mockReset() + rateLimitGuardMock.mockReturnValue({ blocked: false }) getAllWebContentsMock.mockReturnValue([ { id: 1, @@ -505,13 +512,45 @@ describe('pr-refresh-coordinator', () => { expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(5) }) + it('proceeds with background refreshes when the budget probe fails (fail open)', async () => { + const { enqueuePRRefresh } = await import('./pr-refresh-coordinator') + // Why: regression for #7553 — GHES with rate limiting disabled 404s every + // probe; an unreadable budget must not pause refreshes. + getRateLimitMock.mockResolvedValue({ + ok: false, + error: 'HTTP 404: Rate limiting is not enabled.' + }) + getPRForBranchOutcomeMock.mockResolvedValue({ + kind: 'no-pr', + fetchedAt: Date.now() + }) + + enqueuePRRefresh(makeCandidate(), 'active', 80, 1) + + await vi.advanceTimersByTimeAsync(0) + + expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(1) + const pausedEvents = sendMock.mock.calls + .map(([, event]) => event) + .filter((event) => event.status === 'paused' && event.skippedReason === 'rate-limit') + expect(pausedEvents).toHaveLength(0) + }) + it('does not consume active burst slots for rate-limit pauses', async () => { const { enqueuePRRefresh } = await import('./pr-refresh-coordinator') - getRateLimitMock - .mockResolvedValueOnce({ ok: false }) - .mockResolvedValueOnce({ ok: false }) - .mockResolvedValueOnce({ ok: false }) - .mockResolvedValue({ ok: true }) + // Why: keyed on drained items (one getRateLimit call each) rather than + // guard-call counts, so the guard's per-bucket evaluation order stays free + // to change without breaking the slot-accounting assertion. + let drainedItems = 0 + getRateLimitMock.mockImplementation(async () => { + drainedItems += 1 + return { ok: true } + }) + rateLimitGuardMock.mockImplementation(() => + drainedItems <= 3 + ? { blocked: true, remaining: 0, limit: 5000, resetAt: 61 } + : { blocked: false } + ) getPRForBranchOutcomeMock.mockResolvedValue({ kind: 'upstream-error', errorType: 'unknown', diff --git a/src/main/github/pr-refresh-coordinator.ts b/src/main/github/pr-refresh-coordinator.ts index a2576e4805e..8065fbb8145 100644 --- a/src/main/github/pr-refresh-coordinator.ts +++ b/src/main/github/pr-refresh-coordinator.ts @@ -708,20 +708,11 @@ async function drainQueue(): Promise { ) if (isBackground(next.reason)) { - const rateLimit = await getRateLimit() - if (!rateLimit.ok) { - const retryAt = Date.now() + 30_000 - queue.set(next.key, { ...next, dueAt: retryAt }) - broadcast({ - aliases, - reason: next.reason, - status: 'paused', - pausedUntil: retryAt, - skippedReason: 'rate-limit' - }) - scheduleDrain(30_000) - continue - } + // Why: the probe only warms rateLimitGuard's cached snapshot, so a + // failed probe must fail open — GHES with rate limiting disabled 404s + // every probe (#7553). A genuinely broken gh surfaces per-key as typed + // fetch outcomes instead (visible keys additionally back off). + await getRateLimit() const buckets = backgroundRefreshBuckets() const blockedGuard = buckets .map((bucket) => rateLimitGuard(bucket)) diff --git a/src/main/github/rate-limit.test.ts b/src/main/github/rate-limit.test.ts new file mode 100644 index 00000000000..ab1373a70f0 --- /dev/null +++ b/src/main/github/rate-limit.test.ts @@ -0,0 +1,138 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { ghExecFileAsyncMock } = vi.hoisted(() => ({ + ghExecFileAsyncMock: vi.fn() +})) + +vi.mock('../git/runner', () => ({ + ghExecFileAsync: ghExecFileAsyncMock +})) + +vi.mock('./gh-utils', () => ({ + acquire: vi.fn(), + release: vi.fn() +})) + +import { getRateLimit, rateLimitGuard, _resetRateLimitCache } from './rate-limit' + +const PAYLOAD = JSON.stringify({ + resources: { + core: { limit: 5000, remaining: 4200, reset: 1_700_000_000 }, + search: { limit: 30, remaining: 28, reset: 1_700_000_000 }, + graphql: { limit: 5000, remaining: 4900, reset: 1_700_000_000 } + } +}) + +describe('getRateLimit', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + ghExecFileAsyncMock.mockReset() + _resetRateLimitCache() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('parses bucket counts from gh api rate_limit', async () => { + ghExecFileAsyncMock.mockResolvedValue({ stdout: PAYLOAD }) + + const result = await getRateLimit() + + expect(result).toEqual({ + ok: true, + snapshot: { + core: { limit: 5000, remaining: 4200, resetAt: 1_700_000_000 }, + search: { limit: 30, remaining: 28, resetAt: 1_700_000_000 }, + graphql: { limit: 5000, remaining: 4900, resetAt: 1_700_000_000 }, + fetchedAt: 1_000 + } + }) + }) + + it('caches a failed probe for the TTL instead of re-spawning gh', async () => { + // Why: regression for #7553 — GHES with rate limiting disabled fails every + // probe. Refreshes fail open past the failure, so without a negative cache + // each queued refresh would spawn its own doomed gh subprocess. + ghExecFileAsyncMock.mockRejectedValue(new Error('HTTP 404: Rate limiting is not enabled.')) + + const first = await getRateLimit() + const second = await getRateLimit() + + expect(first).toEqual({ ok: false, error: 'HTTP 404: Rate limiting is not enabled.' }) + expect(second).toEqual(first) + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) + }) + + it('re-probes after the failure TTL elapses and recovers on success', async () => { + ghExecFileAsyncMock.mockRejectedValueOnce(new Error('boom')) + ghExecFileAsyncMock.mockResolvedValue({ stdout: PAYLOAD }) + + expect((await getRateLimit()).ok).toBe(false) + expect((await getRateLimit()).ok).toBe(false) + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) + + vi.setSystemTime(1_000 + 30_000) + + expect((await getRateLimit()).ok).toBe(true) + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) + }) + + it('bypasses the failure cache when forced', async () => { + ghExecFileAsyncMock.mockRejectedValueOnce(new Error('boom')) + ghExecFileAsyncMock.mockResolvedValue({ stdout: PAYLOAD }) + + expect((await getRateLimit()).ok).toBe(false) + expect((await getRateLimit({ force: true })).ok).toBe(true) + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) + }) +}) + +describe('rateLimitGuard', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + ghExecFileAsyncMock.mockReset() + _resetRateLimitCache() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + async function cacheExhaustedSnapshot(resetEpochSeconds: number): Promise { + ghExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify({ + resources: { + core: { limit: 5000, remaining: 3, reset: resetEpochSeconds }, + search: { limit: 30, remaining: 28, reset: resetEpochSeconds }, + graphql: { limit: 5000, remaining: 4900, reset: resetEpochSeconds } + } + }) + }) + expect((await getRateLimit()).ok).toBe(true) + } + + it('blocks below the floor while resetAt is in the future', async () => { + await cacheExhaustedSnapshot(61) + + expect(rateLimitGuard('core')).toEqual({ + blocked: true, + remaining: 3, + limit: 5000, + resetAt: 61 + }) + }) + + it('fails open once the blocking bucket resetAt has elapsed', async () => { + // Why: probes can fail indefinitely (sleep past resetAt, network blip), so + // a stale exhausted snapshot must not block once GitHub has already reset + // the budget — a past-due pause would spin the coordinator drain loop. + await cacheExhaustedSnapshot(61) + + vi.setSystemTime(61_000) + + expect(rateLimitGuard('core')).toEqual({ blocked: false }) + }) +}) diff --git a/src/main/github/rate-limit.ts b/src/main/github/rate-limit.ts index ad140422488..f4a1ba56248 100644 --- a/src/main/github/rate-limit.ts +++ b/src/main/github/rate-limit.ts @@ -35,6 +35,11 @@ import { // 1-per-second "is it safe now?" polling pattern UIs tend to fall into. const RATE_LIMIT_CACHE_TTL_MS = 30_000 let cached: GitHubRateLimitSnapshot | null = null +// Why: failed probes are cached for the same TTL as successes. Refreshes fail +// open past a failed probe, so on a host that can never report a budget (GHES +// with rate limiting disabled 404s every probe) an uncached failure would cost +// a gh subprocess per queued refresh. +let probeFailure: { at: number; error: string } | null = null type GhRateLimitPayload = { resources?: { @@ -66,6 +71,7 @@ function parseBucket( /** @internal — test-only */ export function _resetRateLimitCache(): void { cached = null + probeFailure = null } // Why: hard-stop thresholds for the circuit breaker. We refuse to issue a new @@ -196,6 +202,9 @@ export async function getRateLimit(options?: { force?: boolean }): Promise { fetchedAt: Date.now() } cached = snapshot + probeFailure = null return { ok: true, snapshot } } catch (err) { const message = err instanceof Error ? err.message : String(err) + probeFailure = { at: Date.now(), error: message } return { ok: false, error: message } } finally { release() diff --git a/src/main/ipc/filesystem-auth.test.ts b/src/main/ipc/filesystem-auth.test.ts index 979d6b5611f..c6fa78b126d 100644 --- a/src/main/ipc/filesystem-auth.test.ts +++ b/src/main/ipc/filesystem-auth.test.ts @@ -8,8 +8,11 @@ import type * as RepoWorktrees from '../repo-worktrees' import { listRepoWorktrees } from '../repo-worktrees' import type { FolderWorkspace, GitWorktreeInfo, ProjectGroup, Repo } from '../../shared/types' import { + AUTHORIZED_EXTERNAL_PATHS_MAX, + authorizeExternalPath, invalidateAuthorizedRootsCache, isDescendantOrEqual, + isPathAllowed, rebuildAuthorizedRootsCache, resolveAuthorizedPath, resolveRegisteredWorktreePath, @@ -336,3 +339,42 @@ describe('filesystem-auth path containment', () => { } }) }) + +describe('filesystem-auth authorized external path bound', () => { + // Empty allow-list store, so a path is allowed only if it (or an ancestor) is + // in the session-authorized external-path set. + const emptyStore = makeStore([]) + const flood = (n: number): string => + resolve(`/leak-audit-ext/flood-${String(n).padStart(6, '0')}`) + + it('bounds the authorized external path set with LRU eviction', () => { + const keep = resolve('/leak-audit-ext/keep') + authorizeExternalPath(keep) + + // Flood past the cap with distinct external paths, re-authorizing `keep` + // periodically so LRU keeps it hot. + const total = AUTHORIZED_EXTERNAL_PATHS_MAX + 200 + for (let i = 0; i < total; i += 1) { + authorizeExternalPath(flood(i)) + if (i % 250 === 0) { + authorizeExternalPath(keep) + } + } + + // The oldest never-re-touched entries fell out of the bounded set... + expect(isPathAllowed(flood(0), emptyStore)).toBe(false) + // ...while the periodically re-authorized path and the most recent survive. + expect(isPathAllowed(keep, emptyStore)).toBe(true) + expect(isPathAllowed(flood(total - 1), emptyStore)).toBe(true) + }) + + it('re-authorizes an evicted path on next use (self-healing)', () => { + const path = resolve('/leak-audit-ext/evicted-then-reused') + for (let i = 0; i < AUTHORIZED_EXTERNAL_PATHS_MAX + 50; i += 1) { + authorizeExternalPath(flood(100_000 + i)) + } + expect(isPathAllowed(path, emptyStore)).toBe(false) + authorizeExternalPath(path) + expect(isPathAllowed(path, emptyStore)).toBe(true) + }) +}) diff --git a/src/main/ipc/filesystem-auth.ts b/src/main/ipc/filesystem-auth.ts index f931ebe119b..7fee4ca1990 100644 --- a/src/main/ipc/filesystem-auth.ts +++ b/src/main/ipc/filesystem-auth.ts @@ -13,6 +13,14 @@ import type { FolderWorkspace, ProjectGroup, Repo } from '../../shared/types' export const PATH_ACCESS_DENIED_MESSAGE = 'Access denied: path resolves outside allowed directories. If this blocks a legitimate workflow, please file a GitHub issue.' +// Why: authorized external paths accumulate for the whole session (file drops, +// terminal link opens, editor/composer/notebook opens). Bound the set with LRU +// eviction — mirrors rememberUnwatchableRoot in filesystem-watcher.ts — so a long +// session touching many distinct external files cannot grow it (or the O(n) auth +// scan in isPathAllowed) without limit. Every caller re-authorizes a path right +// before operating on it, so evicting a stale entry is self-healing and does not +// weaken the security boundary. +export const AUTHORIZED_EXTERNAL_PATHS_MAX = 4096 const authorizedExternalPaths = new Set() const registeredWorktreeRoots = new Set() const registeredWorktreeRootsByRepo = new Map>() @@ -23,12 +31,26 @@ const AUTHORIZED_ROOTS_REBUILD_CONCURRENCY = 8 type FolderScopeStore = Pick & Partial> +function rememberAuthorizedExternalPath(path: string): void { + // Delete-then-add keeps re-authorized (actively used) paths most-recent so + // eviction only sheds the oldest never-re-touched entries. + authorizedExternalPaths.delete(path) + authorizedExternalPaths.add(path) + while (authorizedExternalPaths.size > AUTHORIZED_EXTERNAL_PATHS_MAX) { + const oldest = authorizedExternalPaths.keys().next().value + if (oldest === undefined) { + break + } + authorizedExternalPaths.delete(oldest) + } +} + export function authorizeExternalPath(targetPath: string): void { const resolvedTarget = resolve(targetPath) - authorizedExternalPaths.add(resolvedTarget) + rememberAuthorizedExternalPath(resolvedTarget) try { // Why: macOS canonicalizes /tmp to /private/tmp during read authorization. - authorizedExternalPaths.add(realpathSync(resolvedTarget)) + rememberAuthorizedExternalPath(realpathSync(resolvedTarget)) } catch {} } diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 00d244e3031..cffdf6cb668 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -3381,6 +3381,54 @@ describe('registerPtyHandlers', () => { } }) + // Why: the cap and its flag must never fire in the common case (renderer keeps + // up), so ordinary small output carries no droppedBacklog. + it('does not flag droppedBacklog for ordinary small output under the cap', async () => { + vi.useFakeTimers() + const runtime = { + setPtyController: vi.fn(), + registerPty: vi.fn(), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn(() => 12), + createPreAllocatedTerminalHandle: vi.fn(() => 'terminal-handle-small'), + registerPreAllocatedHandleForPty: vi.fn() + } + try { + registerPtyHandlers( + mainWindow as never, + runtime as never, + undefined, + undefined, + undefined, + undefined, + { awaitLocalPtyStartup: () => Promise.resolve() } + ) + const pendingSpawn = handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + sessionId: 'small-output-session' + }) as Promise<{ id: string }> + await Promise.resolve() + const daemon = installObservableDaemonTestProvider() + rebindLocalProviderListeners() + const result = await pendingSpawn + + daemon.emitData(result.id, 'small output') + await vi.advanceTimersByTimeAsync(50) + + const dataSends = mainWindow.webContents.send.mock.calls.filter( + (call) => call[0] === 'pty:data' && (call[1] as { id: string }).id === result.id + ) + expect(dataSends.length).toBeGreaterThan(0) + for (const call of dataSends) { + expect((call[1] as { droppedBacklog?: boolean }).droppedBacklog).toBeUndefined() + } + } finally { + vi.useRealTimers() + } + }) + it('waits for the desktop startup barrier before runtime local spawns resolve the provider', async () => { const barrier = makeDeferred() const runtime = { @@ -8380,6 +8428,7 @@ describe('registerPtyHandlers', () => { onPtyExit: vi.fn(), onPtyData: vi.fn(() => 42), getPtyOutputSequence: vi.fn(() => 42), + hasRemoteTerminalViewSubscriber: vi.fn(() => false), createPreAllocatedTerminalHandle: vi.fn(() => 'terminal-handle-1'), registerPreAllocatedHandleForPty: vi.fn() } @@ -8797,6 +8846,7 @@ describe('registerPtyHandlers', () => { onPtyExit: vi.fn(), onPtyData: vi.fn(() => 42), getPtyOutputSequence: vi.fn(() => 42), + hasRemoteTerminalViewSubscriber: vi.fn(() => false), createPreAllocatedTerminalHandle: vi.fn(() => 'terminal-handle-1'), registerPreAllocatedHandleForPty: vi.fn() } @@ -8853,6 +8903,7 @@ describe('registerPtyHandlers', () => { onPtyExit: vi.fn(), onPtyData: vi.fn(() => 42), getPtyOutputSequence: vi.fn(() => 42), + hasRemoteTerminalViewSubscriber: vi.fn(() => false), createPreAllocatedTerminalHandle: vi.fn(() => 'terminal-handle-1'), registerPreAllocatedHandleForPty: vi.fn() } @@ -8916,6 +8967,7 @@ describe('registerPtyHandlers', () => { onPtyExit: vi.fn(), onPtyData: vi.fn(() => 42), getPtyOutputSequence: vi.fn(() => 42), + hasRemoteTerminalViewSubscriber: vi.fn(() => false), createPreAllocatedTerminalHandle: vi.fn(() => 'terminal-handle-1'), registerPreAllocatedHandleForPty: vi.fn() } @@ -10332,6 +10384,7 @@ describe('registerPtyHandlers', () => { onPtyData: vi.fn(), preAllocateHandleForPty: vi.fn(() => null), getPtyOutputSequence: vi.fn(() => 2_472), + hasRemoteTerminalViewSubscriber: vi.fn(() => false), serializeHiddenOutputRecoveryBuffer: vi.fn().mockResolvedValue({ data: 'snapshot\r\n', cols: 100, diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 8d14ac8830f..8be80723582 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -1504,6 +1504,11 @@ export function registerPtyHandlers( data: string startSeq?: number containsBackgroundOutput?: boolean + // Why droppedOutput (not main's droppedBacklog trim): this branch bounds + // the unsent backlog with the O(1) drop-to-sentinel + query-salvage + + // snapshot-restore mechanism below, which strictly supersedes main's + // #7630 keep-2MB-tail trim — carrying both would race two cap policies + // over the same buffer. droppedOutput?: true } @@ -1552,7 +1557,9 @@ export function registerPtyHandlers( const PTY_RENDERER_TOTAL_IN_FLIGHT_HIGH_WATER_CHARS = 8 * 1024 * 1024 // Why: while the renderer cannot receive (frozen, starved, mid-reload), a // chatty PTY used to grow its pendingData string without bound — main-process - // heap ballooning that a renderer reload cannot clear. Beyond this cap the + // heap ballooning that a renderer reload cannot clear (main's #7630 fixed the + // same Win/Linux background-throttled-renderer leak with a 2MB tail trim; + // this branch's sentinel mechanism supersedes it). Beyond this cap the // buffered bytes are dropped and the pane heals from the main-owned buffer // snapshot via the droppedOutput sentinel (renderer hidden-output restore). // Why read settings live: the cap scales with the user's scrollback setting @@ -2339,15 +2346,25 @@ export function registerPtyHandlers( // tears down the terminal on pty:exit before the batch timer fires. const remaining = pendingData.get(payload.id) if (remaining) { - sendPtyDataToRenderer( - payload.id, - makePtyDataPayload( + if (remaining.droppedOutput === true) { + // Sentinel entry: only salvaged query bytes remain; keep the flag so + // the renderer knows the span was dropped (same as the flush loop). + sendPtyDataToRenderer(payload.id, { + id: payload.id, + data: remaining.data, + droppedOutput: true + }) + } else { + sendPtyDataToRenderer( payload.id, - remaining.data, - remaining.startSeq, - remaining.containsBackgroundOutput + makePtyDataPayload( + payload.id, + remaining.data, + remaining.startSeq, + remaining.containsBackgroundOutput + ) ) - ) + } pendingData.delete(payload.id) } // Why: exit drops this PTY's bookkeeping; resume (no-op on a dead PTY) @@ -2514,7 +2531,8 @@ export function registerPtyHandlers( ...(typeof pending.startSeq === 'number' ? { seq: pending.startSeq + nextData.length, rawLength: nextData.length } : {}), - ...(pending.containsBackgroundOutput === true ? { background: true } : {}) + ...(pending.containsBackgroundOutput === true ? { background: true } : {}), + ...(pending.droppedOutput === true ? { droppedOutput: true } : {}) }) return } diff --git a/src/main/ipc/ssh-browse.test.ts b/src/main/ipc/ssh-browse.test.ts index 4ca47c3aa63..6d04e8c7b0f 100644 --- a/src/main/ipc/ssh-browse.test.ts +++ b/src/main/ipc/ssh-browse.test.ts @@ -25,6 +25,16 @@ function createMockChannel(): EventEmitter & { stderr: EventEmitter } { }) } +// Recover the PowerShell script from a `powershell.exe ... -EncodedCommand ` +// command so tests can assert on the actual (UTF-16LE) payload sent to the host. +function decodeEncodedCommand(command: string): string { + const match = /-EncodedCommand (\S+)/.exec(command) + if (!match) { + throw new Error(`no -EncodedCommand in: ${command}`) + } + return Buffer.from(match[1], 'base64').toString('utf16le') +} + describe('registerSshBrowseHandler', () => { let handler: BrowseHandler @@ -88,6 +98,275 @@ describe('registerSshBrowseHandler', () => { expect(exec).toHaveBeenCalledWith("cd '/tmp/it'\\''s here' && pwd && command ls -1Ap") }) + it('falls back to PowerShell when a Windows SSH shell rejects POSIX exec', async () => { + const posixChannel = createMockChannel() + const windowsChannel = createMockChannel() + const exec = vi.fn().mockResolvedValueOnce(posixChannel).mockResolvedValueOnce(windowsChannel) + const getConnectionManager = () => ({ + getConnection: () => ({ exec }) + }) + registerSshBrowseHandler(getConnectionManager as never) + + const resultPromise = handler(null, { targetId: 'ssh-1', dirPath: 'C:/Users/alice' }) + await Promise.resolve() + posixChannel.stderr.emit( + 'data', + Buffer.from('"exec" no se reconoce como un comando interno o externo') + ) + posixChannel.emit('exit', 1) + posixChannel.emit('close') + await vi.waitFor(() => { + expect(windowsChannel.listenerCount('close')).toBe(1) + }) + // Windows OpenSSH exec emits CRLF; the parser must strip \r so directories + // aren't misclassified as files with a stray carriage return in the name. + // The script emits a forward-slash resolvedPath (the -replace '\\','/' line) + // so the renderer's parentPath/joinPath, which only split on `/`, still work. + windowsChannel.emit('data', Buffer.from('C:/Users/alice\r\nDesktop/\r\nnotes.txt\r\n')) + windowsChannel.emit('exit', 0) + windowsChannel.emit('close') + + await expect(resultPromise).resolves.toEqual({ + resolvedPath: 'C:/Users/alice', + entries: [ + { name: 'Desktop', isDirectory: true }, + { name: 'notes.txt', isDirectory: false } + ] + }) + expect(exec).toHaveBeenCalledTimes(2) + expect(exec).toHaveBeenNthCalledWith(1, "cd 'C:/Users/alice' && pwd && command ls -1Ap") + expect(exec.mock.calls[1]?.[0]).toMatch(/^powershell\.exe /) + expect(exec.mock.calls[1]?.[1]).toEqual({ wrapCommand: false }) + + // Decode the -EncodedCommand payload so an accidental switch from the + // single-quote-escaped PowerShell literal to raw interpolation (an injection + // regression) is caught, and to lock in the UTF-8 output pin. + const script = decodeEncodedCommand(exec.mock.calls[1]?.[0] ?? '') + expect(script).toContain('[Console]::OutputEncoding = [System.Text.Encoding]::UTF8') + expect(script).toContain("$dir = 'C:/Users/alice'") + expect(script).toContain('Get-ChildItem -LiteralPath $resolved -Force') + // resolvedPath must be emitted with forward slashes so the renderer's + // parentPath/joinPath (which only split on `/`) keep working on Windows. + expect(script).toContain("Write-Output ($resolved -replace '\\\\', '/')") + }) + + it('falls back for a non-English cmd.exe reject (exit 1, localized stderr)', async () => { + // Regression: real Windows OpenSSH + cmd.exe forwards exit 1 (not 9009) with + // localized stderr. The old 9009/English-string trigger silently missed this, + // so German/Japanese/etc. hosts never fell back. Keying off the non-zero exit + // of a RemoteBrowseError fixes it regardless of OS display language. + const posixChannel = createMockChannel() + const windowsChannel = createMockChannel() + const exec = vi.fn().mockResolvedValueOnce(posixChannel).mockResolvedValueOnce(windowsChannel) + const getConnectionManager = () => ({ + getConnection: () => ({ exec }) + }) + registerSshBrowseHandler(getConnectionManager as never) + + const resultPromise = handler(null, { targetId: 'ssh-1', dirPath: 'C:/Users' }) + await Promise.resolve() + // Japanese cmd.exe "not recognized" text — matches none of the removed English + // /Spanish substrings, and exit 1 is not the removed 9009 sentinel. + posixChannel.stderr.emit( + 'data', + Buffer.from("'exec' は、内部コマンドとして認識されていません。") + ) + posixChannel.emit('exit', 1) + posixChannel.emit('close') + await vi.waitFor(() => { + expect(windowsChannel.listenerCount('close')).toBe(1) + }) + windowsChannel.emit('data', Buffer.from('C:/Users\r\nAdmin/\r\n')) + windowsChannel.emit('exit', 0) + windowsChannel.emit('close') + + await expect(resultPromise).resolves.toEqual({ + resolvedPath: 'C:/Users', + entries: [{ name: 'Admin', isDirectory: true }] + }) + expect(exec).toHaveBeenCalledTimes(2) + }) + + it('escapes single quotes in the PowerShell literal path', async () => { + const posixChannel = createMockChannel() + const windowsChannel = createMockChannel() + const exec = vi.fn().mockResolvedValueOnce(posixChannel).mockResolvedValueOnce(windowsChannel) + const getConnectionManager = () => ({ + getConnection: () => ({ exec }) + }) + registerSshBrowseHandler(getConnectionManager as never) + + const resultPromise = handler(null, { targetId: 'ssh-1', dirPath: "C:/O'Brien" }) + await Promise.resolve() + // A cmd.exe reject exits 1 over SSH (its 9009 ERRORLEVEL never crosses the + // process boundary) with localized German stderr. The fallback must still fire, + // since it keys off the non-zero exit, not the (localized, unmatchable) text. + posixChannel.stderr.emit('data', Buffer.from('Der Befehl "exec" ist falsch geschrieben')) + posixChannel.emit('exit', 1) + posixChannel.emit('close') + await vi.waitFor(() => { + expect(windowsChannel.listenerCount('close')).toBe(1) + }) + windowsChannel.emit('data', Buffer.from("C:/O'Brien\r\n")) + windowsChannel.emit('exit', 0) + windowsChannel.emit('close') + + await expect(resultPromise).resolves.toEqual({ + resolvedPath: "C:/O'Brien", + entries: [] + }) + const script = decodeEncodedCommand(exec.mock.calls[1]?.[0] ?? '') + // Single quote must be doubled inside the PowerShell literal, not passed raw. + expect(script).toContain("$dir = 'C:/O''Brien'") + }) + + it('expands ~ to $HOME in the PowerShell fallback (the default browse path)', async () => { + const posixChannel = createMockChannel() + const windowsChannel = createMockChannel() + const exec = vi.fn().mockResolvedValueOnce(posixChannel).mockResolvedValueOnce(windowsChannel) + const getConnectionManager = () => ({ + getConnection: () => ({ exec }) + }) + registerSshBrowseHandler(getConnectionManager as never) + + const resultPromise = handler(null, { targetId: 'ssh-1', dirPath: '~' }) + await Promise.resolve() + posixChannel.stderr.emit('data', Buffer.from('"exec" is not recognized')) + posixChannel.emit('exit', 1) + posixChannel.emit('close') + await vi.waitFor(() => { + expect(windowsChannel.listenerCount('close')).toBe(1) + }) + windowsChannel.emit('data', Buffer.from('C:/Users/alice\r\n')) + windowsChannel.emit('exit', 0) + windowsChannel.emit('close') + + await expect(resultPromise).resolves.toEqual({ resolvedPath: 'C:/Users/alice', entries: [] }) + const script = decodeEncodedCommand(exec.mock.calls[1]?.[0] ?? '') + // ~ must expand to $HOME, not be passed literally to Set-Location. + expect(script).toContain('$dir = $HOME') + }) + + // The renderer rebuilds forward-slash Windows paths with POSIX helpers: the + // breadcrumb prepends a spurious leading '/' before the drive, and "Up" from a + // first-level dir yields a bare drive letter. Both must be rooted for + // Set-Location, or navigation lands in the drive-relative cwd / errors. + it.each([ + { dirPath: '/C:/Users', expected: "$dir = 'C:/Users'" }, + { dirPath: 'C:', expected: "$dir = 'C:/'" }, + // Combined strip + root, so a future refactor can't break the ordering. + { dirPath: '/C:', expected: "$dir = 'C:/'" } + ])( + 'roots the Windows drive path $dirPath in the PowerShell fallback', + async ({ dirPath, expected }) => { + const posixChannel = createMockChannel() + const windowsChannel = createMockChannel() + const exec = vi.fn().mockResolvedValueOnce(posixChannel).mockResolvedValueOnce(windowsChannel) + const getConnectionManager = () => ({ + getConnection: () => ({ exec }) + }) + registerSshBrowseHandler(getConnectionManager as never) + + const resultPromise = handler(null, { targetId: 'ssh-1', dirPath }) + await Promise.resolve() + posixChannel.stderr.emit('data', Buffer.from('"exec" is not recognized')) + posixChannel.emit('exit', 1) + posixChannel.emit('close') + await vi.waitFor(() => { + expect(windowsChannel.listenerCount('close')).toBe(1) + }) + windowsChannel.emit('data', Buffer.from('C:/Users\r\n')) + windowsChannel.emit('exit', 0) + windowsChannel.emit('close') + + await expect(resultPromise).resolves.toEqual({ resolvedPath: 'C:/Users', entries: [] }) + const script = decodeEncodedCommand(exec.mock.calls[1]?.[0] ?? '') + expect(script).toContain(expected) + } + ) + + it('surfaces the original POSIX failure when the PowerShell retry shows the host is not Windows', async () => { + const posixChannel = createMockChannel() + const windowsChannel = createMockChannel() + const exec = vi.fn().mockResolvedValueOnce(posixChannel).mockResolvedValueOnce(windowsChannel) + const getConnectionManager = () => ({ + getConnection: () => ({ exec }) + }) + registerSshBrowseHandler(getConnectionManager as never) + + const resultPromise = handler(null, { targetId: 'ssh-1', dirPath: '/root/secret' }) + await Promise.resolve() + // A genuine POSIX permission failure exits non-zero; it's indistinguishable + // from a Windows shell reject without probing, so the fallback is attempted... + posixChannel.stderr.emit('data', Buffer.from('ls: /root/secret: Permission denied')) + posixChannel.emit('exit', 1) + posixChannel.emit('close') + await vi.waitFor(() => { + expect(windowsChannel.listenerCount('close')).toBe(1) + }) + // ...but on a POSIX host the login shell can't find powershell.exe (exit 127), + // so the original permission error is surfaced, never masked by the retry. + windowsChannel.stderr.emit('data', Buffer.from('bash: powershell.exe: command not found')) + windowsChannel.emit('exit', 127) + windowsChannel.emit('close') + + await expect(resultPromise).rejects.toThrow('Permission denied') + expect(exec).toHaveBeenCalledTimes(2) + }) + + it('surfaces the PowerShell error when the Windows fallback runs but fails', async () => { + const posixChannel = createMockChannel() + const windowsChannel = createMockChannel() + const exec = vi.fn().mockResolvedValueOnce(posixChannel).mockResolvedValueOnce(windowsChannel) + const getConnectionManager = () => ({ + getConnection: () => ({ exec }) + }) + registerSshBrowseHandler(getConnectionManager as never) + + const resultPromise = handler(null, { targetId: 'ssh-1', dirPath: 'C:/missing' }) + await Promise.resolve() + posixChannel.stderr.emit('data', Buffer.from('"exec" is not recognized')) + posixChannel.emit('exit', 1) + posixChannel.emit('close') + await vi.waitFor(() => { + expect(windowsChannel.listenerCount('close')).toBe(1) + }) + windowsChannel.stderr.emit('data', Buffer.from('Cannot find path C:/missing')) + windowsChannel.emit('exit', 1) + windowsChannel.emit('close') + + // PowerShell exited non-127, so it genuinely ran on a Windows host — its error + // is the real cause. Surface it rather than the cmd.exe "not recognized" prose. + await expect(resultPromise).rejects.toThrow('Cannot find path') + }) + + it('surfaces the original POSIX error when the fallback shows powershell.exe is missing', async () => { + const posixChannel = createMockChannel() + const windowsChannel = createMockChannel() + const exec = vi.fn().mockResolvedValueOnce(posixChannel).mockResolvedValueOnce(windowsChannel) + const getConnectionManager = () => ({ + getConnection: () => ({ exec }) + }) + registerSshBrowseHandler(getConnectionManager as never) + + const resultPromise = handler(null, { targetId: 'ssh-1', dirPath: '/opt/exec' }) + await Promise.resolve() + // A non-zero POSIX failure triggers the fallback probe on any host... + posixChannel.stderr.emit('data', Buffer.from('exec: command not found')) + posixChannel.emit('exit', 127) + posixChannel.emit('close') + await vi.waitFor(() => { + expect(windowsChannel.listenerCount('close')).toBe(1) + }) + windowsChannel.stderr.emit('data', Buffer.from('sh: powershell.exe: not found')) + windowsChannel.emit('exit', 127) + windowsChannel.emit('close') + + // The original POSIX failure is the real one — don't mask it with the + // misleading "powershell.exe: not found" from the doomed retry. + await expect(resultPromise).rejects.toThrow('exec: command not found') + }) + it('rejects and detaches listeners when the browse channel errors', async () => { const channel = createMockChannel() const exec = vi.fn().mockResolvedValue(channel) @@ -101,6 +380,9 @@ describe('registerSshBrowseHandler', () => { channel.emit('error', new Error('remote disconnected')) await expect(resultPromise).rejects.toThrow('remote disconnected') + // A transport failure is not a RemoteBrowseError, so it must not trigger a + // pointless PowerShell retry. + expect(exec).toHaveBeenCalledTimes(1) expect(channel.listenerCount('data')).toBe(0) expect(channel.listenerCount('exit')).toBe(0) expect(channel.listenerCount('close')).toBe(0) diff --git a/src/main/ipc/ssh-browse.ts b/src/main/ipc/ssh-browse.ts index 83afbd9de01..2729cd59ab7 100644 --- a/src/main/ipc/ssh-browse.ts +++ b/src/main/ipc/ssh-browse.ts @@ -1,5 +1,7 @@ import { ipcMain } from 'electron' import type { SshConnectionManager } from '../ssh/ssh-connection' +import type { SshExecOptions } from '../ssh/ssh-connection-utils' +import { powerShellCommand, powerShellLiteral } from '../ssh/ssh-remote-powershell' export type RemoteDirEntry = { name: string @@ -8,6 +10,33 @@ export type RemoteDirEntry = { const SSH_BROWSE_TIMEOUT_MS = 15_000 +// Why: a POSIX login shell that can't find powershell.exe exits 127 (the POSIX +// "command not found" convention, identical across sh/bash/zsh and locales). It's +// the locale-independent signal that the Windows fallback never actually ran, so +// the original POSIX failure — not the doomed retry — is the real error. +// +// Note: cmd.exe's ERRORLEVEL for an unrecognized command is 9009, but that value +// never crosses cmd.exe's process boundary. sshd forwards cmd.exe's *process* exit +// code, which is 1 — verified on real Windows OpenSSH + cmd.exe over both the ssh2 +// and system-ssh transports. So a Windows host rejecting Orca's POSIX `exec` +// wrapper is detected by "the remote command ran and exited non-zero" +// (RemoteBrowseError), not by a magic exit code or localized stderr text. +const POSIX_COMMAND_NOT_FOUND_EXIT = 127 + +// Carries the raw exit code so the fallback can (a) recognize that the remote +// command actually ran and failed — the locale-independent trigger for the +// Windows retry — and (b) tell a POSIX "powershell.exe not found" (127) apart +// from a genuine PowerShell error, without parsing localized shell prose. +class RemoteBrowseError extends Error { + constructor( + message: string, + readonly exitCode: number | null + ) { + super(message) + this.name = 'RemoteBrowseError' + } +} + // Why: the relay's fs.readDir enforces workspace root ACLs, which aren't // registered until a repo is added. This handler uses a raw SSH exec channel // to list directories, allowing the user to browse the remote filesystem @@ -32,156 +61,229 @@ export function registerSshBrowseHandler( throw new Error(`SSH connection "${args.targetId}" not found`) } - // Why: using one line per entry preserves filenames containing spaces. - // `command ls` bypasses user aliases/functions like `ls='eza ...'`. - // The -1 flag outputs one entry per line. The -p flag appends / to directories. - // We resolve ~ and get the absolute path via `cd && pwd`. - // `cd` and `ls` are chained with `&&` so a failing `ls` (e.g. permission - // denied after a readable `cd ... && pwd`) propagates as a non-zero exit - // code rather than being indistinguishable from an empty directory. - const command = `cd ${shellEscape(args.dirPath)} && pwd && command ls -1Ap` - const channel = await conn.exec(command) - - return new Promise((resolve, reject) => { - let stdout = '' - let stderr = '' - let exitCode: number | null = null - let settled = false - let timeout: ReturnType | null = null - - const cleanup = (): void => { - if (timeout) { - clearTimeout(timeout) - timeout = null - } - channel.off('data', onStdoutData) - channel.stderr.off('data', onStderrData) - channel.off('exit', onExit) - channel.off('close', onClose) - channel.off('error', onError) - channel.stderr.off('error', onError) + try { + return await browseWithPosixShell(conn, args.dirPath) + } catch (posixError) { + // Why: a Windows login shell (cmd.exe/PowerShell) rejects Orca's POSIX + // `exec` wrapper, and the only locale-independent signal for that is "the + // remote command executed and exited non-zero" (RemoteBrowseError). Its + // stderr prose is localized, and cmd.exe's 9009 ERRORLEVEL never reaches + // us (sshd forwards process exit 1). Transport errors/timeouts aren't + // RemoteBrowseErrors, so a dropped connection is never retried as Windows. + if (!(posixError instanceof RemoteBrowseError)) { + throw posixError } - const rejectOnce = (error: Error): void => { - if (settled) { - return - } - settled = true - cleanup() - reject(error) + try { + return await browseWithWindowsPowerShell(conn, args.dirPath) + } catch (fallbackError) { + // Why: if the login shell couldn't find powershell.exe (exit 127) the + // host isn't Windows — surface the original POSIX failure rather than a + // misleading "powershell.exe: not found". Otherwise PowerShell genuinely + // ran and its error (e.g. "Cannot find path") is the real cause. + throw isPosixCommandNotFound(fallbackError) ? posixError : fallbackError } - const closeChannel = (): void => { - const closable = channel as { close?: () => void; destroy?: () => void } - try { - if (typeof closable.close === 'function') { - closable.close() - } else if (typeof closable.destroy === 'function') { - closable.destroy() - } - } catch { - /* best effort */ - } - } - const onTimeout = (): void => { - // Why: remote browsing runs before a relay workspace root exists, so - // it cannot rely on relay request deadlines. Bound this raw exec - // channel directly to keep Add Remote Project from hanging forever. - rejectOnce(new Error('Remote directory listing timed out')) - closeChannel() - } - const resolveOnce = (result: { entries: RemoteDirEntry[]; resolvedPath: string }): void => { - if (settled) { - return - } - settled = true - cleanup() - resolve(result) - } - - const onStdoutData = (data: Buffer): void => { - stdout += data.toString() - } - const onStderrData = (data: Buffer): void => { - stderr += data.toString() - } - // `exit` fires before `close`; capture the code so we can distinguish - // a failed `ls` that still produced `pwd` output from an empty listing. - const onExit = (code: number | null): void => { - exitCode = code - } - const onError = (error: Error): void => { - rejectOnce(error) - } - const onClose = (): void => { - // A null exitCode means the server closed the channel without - // sending an exit-status message (or signalled termination). We - // can't assume success — falling back to "empty stdout = empty - // directory" is exactly the bug the exit-code branch was added to - // fix. Treat any non-zero OR null exit as a failure when stderr - // has content, and otherwise require stdout to contain at least - // the resolved `pwd` line before accepting the result. - if (exitCode !== 0) { - const msg = - stderr.trim() || - (exitCode === null - ? 'Remote listing failed (channel closed without exit status)' - : `Remote listing failed (exit ${exitCode})`) - rejectOnce(new Error(msg)) - return - } - if (stderr.trim() && !stdout.trim()) { - rejectOnce(new Error(stderr.trim())) - return - } - - const lines = stdout.trim().split('\n') - if (lines.length === 0) { - rejectOnce(new Error('Empty response from remote')) - return - } - - const resolvedPath = lines[0] - const entries: RemoteDirEntry[] = [] - - for (let i = 1; i < lines.length; i++) { - const line = lines[i] - if (!line || line === './' || line === '../') { - continue - } - if (line.endsWith('/')) { - entries.push({ name: line.slice(0, -1), isDirectory: true }) - } else { - entries.push({ name: line, isDirectory: false }) - } - } - - // Sort: directories first, then alphabetical - entries.sort((a, b) => { - if (a.isDirectory !== b.isDirectory) { - return a.isDirectory ? -1 : 1 - } - return a.name.localeCompare(b.name) - }) - - resolveOnce({ entries, resolvedPath }) - } - - channel.on('data', onStdoutData) - channel.stderr.on('data', onStderrData) - channel.on('exit', onExit) - channel.on('close', onClose) - // Why: SSH exec streams emit `error` on transport loss; without a - // scoped listener, a disappearing remote can become process-fatal. - channel.on('error', onError) - channel.stderr.on('error', onError) - timeout = setTimeout(onTimeout, SSH_BROWSE_TIMEOUT_MS) - if (typeof timeout.unref === 'function') { - timeout.unref() - } - }) + } } ) } +type SshBrowseConnection = NonNullable> + +function browseWithPosixShell( + conn: SshBrowseConnection, + dirPath: string +): Promise<{ entries: RemoteDirEntry[]; resolvedPath: string }> { + // Why: using one line per entry preserves filenames containing spaces. + // `command ls` bypasses user aliases/functions like `ls='eza ...'`. + // The -1 flag outputs one entry per line. The -p flag appends / to directories. + // We resolve ~ and get the absolute path via `cd && pwd`. + // `cd` and `ls` are chained with `&&` so a failing `ls` (e.g. permission + // denied after a readable `cd ... && pwd`) propagates as a non-zero exit + // code rather than being indistinguishable from an empty directory. + return runBrowseCommand(conn, `cd ${shellEscape(dirPath)} && pwd && command ls -1Ap`) +} + +function browseWithWindowsPowerShell( + conn: SshBrowseConnection, + dirPath: string +): Promise<{ entries: RemoteDirEntry[]; resolvedPath: string }> { + const script = [ + "$ErrorActionPreference = 'Stop'", + // Why: Windows PowerShell 5.1 writes redirected stdout in the legacy OEM + // code page, but runBrowseCommand decodes as UTF-8; pin UTF-8 output so + // non-ASCII names (e.g. C:\Users\José, CJK, Cyrillic) don't come back mojibake. + '[Console]::OutputEncoding = [System.Text.Encoding]::UTF8', + `$dir = ${powerShellPathExpression(dirPath)}`, + 'Set-Location -LiteralPath $dir', + '$resolved = (Get-Location).ProviderPath', + // Why: the renderer's parentPath/joinPath only split on `/`, so a native + // backslash path (C:\Users\alice) breaks "Up" and mixes separators. Emit a + // forward-slash resolvedPath (matching the POSIX branch) while keeping the + // native $resolved for Get-ChildItem -LiteralPath. + "Write-Output ($resolved -replace '\\\\', '/')", + 'Get-ChildItem -LiteralPath $resolved -Force | ForEach-Object {', + " if ($_.PSIsContainer) { Write-Output ($_.Name + '/') } else { Write-Output $_.Name }", + '}' + ].join('; ') + + return runBrowseCommand(conn, powerShellCommand(script), { wrapCommand: false }) +} + +async function runBrowseCommand( + conn: SshBrowseConnection, + command: string, + options?: SshExecOptions +): Promise<{ entries: RemoteDirEntry[]; resolvedPath: string }> { + const channel = options ? await conn.exec(command, options) : await conn.exec(command) + + return new Promise((resolve, reject) => { + let stdout = '' + let stderr = '' + let exitCode: number | null = null + let settled = false + let timeout: ReturnType | null = null + + const cleanup = (): void => { + if (timeout) { + clearTimeout(timeout) + timeout = null + } + channel.off('data', onStdoutData) + channel.stderr.off('data', onStderrData) + channel.off('exit', onExit) + channel.off('close', onClose) + channel.off('error', onError) + channel.stderr.off('error', onError) + } + const rejectOnce = (error: Error): void => { + if (settled) { + return + } + settled = true + cleanup() + reject(error) + } + const closeChannel = (): void => { + const closable = channel as { close?: () => void; destroy?: () => void } + try { + if (typeof closable.close === 'function') { + closable.close() + } else if (typeof closable.destroy === 'function') { + closable.destroy() + } + } catch { + /* best effort */ + } + } + const onTimeout = (): void => { + // Why: remote browsing runs before a relay workspace root exists, so + // it cannot rely on relay request deadlines. Bound this raw exec + // channel directly to keep Add Remote Project from hanging forever. + rejectOnce(new Error('Remote directory listing timed out')) + closeChannel() + } + const resolveOnce = (result: { entries: RemoteDirEntry[]; resolvedPath: string }): void => { + if (settled) { + return + } + settled = true + cleanup() + resolve(result) + } + + const onStdoutData = (data: Buffer): void => { + stdout += data.toString() + } + const onStderrData = (data: Buffer): void => { + stderr += data.toString() + } + // `exit` fires before `close`; capture the code so we can distinguish + // a failed `ls` that still produced `pwd` output from an empty listing. + const onExit = (code: number | null): void => { + exitCode = code + } + const onError = (error: Error): void => { + rejectOnce(error) + } + const onClose = (): void => { + // A null exitCode means the server closed the channel without + // sending an exit-status message (or signalled termination). We + // can't assume success — falling back to "empty stdout = empty + // directory" is exactly the bug the exit-code branch was added to + // fix. Treat any non-zero OR null exit as a failure when stderr + // has content, and otherwise require stdout to contain at least + // the resolved `pwd` line before accepting the result. + if (exitCode !== 0) { + const msg = + stderr.trim() || + (exitCode === null + ? 'Remote listing failed (channel closed without exit status)' + : `Remote listing failed (exit ${exitCode})`) + rejectOnce(new RemoteBrowseError(msg, exitCode)) + return + } + if (stderr.trim() && !stdout.trim()) { + rejectOnce(new Error(stderr.trim())) + return + } + + // Why: Windows OpenSSH exec emits CRLF, so split on \r?\n — otherwise a + // trailing \r defeats the endsWith('/') dir check and leaves a stray CR + // in every name. + const lines = stdout.trim().split(/\r?\n/) + if (lines.length === 0) { + rejectOnce(new Error('Empty response from remote')) + return + } + + const resolvedPath = lines[0] + const entries: RemoteDirEntry[] = [] + + for (let i = 1; i < lines.length; i++) { + const line = lines[i] + if (!line || line === './' || line === '../') { + continue + } + if (line.endsWith('/')) { + entries.push({ name: line.slice(0, -1), isDirectory: true }) + } else { + entries.push({ name: line, isDirectory: false }) + } + } + + // Sort: directories first, then alphabetical + entries.sort((a, b) => { + if (a.isDirectory !== b.isDirectory) { + return a.isDirectory ? -1 : 1 + } + return a.name.localeCompare(b.name) + }) + + resolveOnce({ entries, resolvedPath }) + } + + channel.on('data', onStdoutData) + channel.stderr.on('data', onStderrData) + channel.on('exit', onExit) + channel.on('close', onClose) + // Why: SSH exec streams emit `error` on transport loss; without a + // scoped listener, a disappearing remote can become process-fatal. + channel.on('error', onError) + channel.stderr.on('error', onError) + timeout = setTimeout(onTimeout, SSH_BROWSE_TIMEOUT_MS) + if (typeof timeout.unref === 'function') { + timeout.unref() + } + }) +} + +// Why: a POSIX login shell that can't find powershell.exe exits 127, marking the +// Windows fallback as "never ran" — the host isn't Windows, so the original POSIX +// failure, not the doomed retry, is the error worth surfacing. +function isPosixCommandNotFound(error: unknown): boolean { + return error instanceof RemoteBrowseError && error.exitCode === POSIX_COMMAND_NOT_FOUND_EXIT +} + // Why: prevent shell injection in the directory path. Single-quote wrapping // with escaped internal single quotes is the safest approach for sh/bash. // Tilde must be expanded by the shell, so paths starting with ~ use $HOME @@ -199,3 +301,24 @@ function shellEscape(s: string): string { function shellEscapeRaw(s: string): string { return `'${s.replace(/'/g, "'\\''")}'` } + +function powerShellPathExpression(s: string): string { + if (s === '~') { + return '$HOME' + } + if (s.startsWith('~/') || s.startsWith('~\\')) { + return `Join-Path $HOME ${powerShellLiteral(s.slice(2))}` + } + return powerShellLiteral(normalizeWindowsDrivePath(s)) +} + +// Why: browse emits forward-slash Windows paths, so the renderer rebuilds them +// with POSIX helpers — the breadcrumb prepends a spurious leading '/' before the +// drive (/C:/Users) and "Up" from a first-level dir yields a bare drive letter +// (C:). Both are wrong for Set-Location: a leading '/' means the current drive's +// root, and 'C:' is drive-relative (the process cwd), not 'C:\'. Normalize both +// back to a rooted drive path here so navigation lands where the user clicked. +function normalizeWindowsDrivePath(s: string): string { + const stripped = s.replace(/^\/(?=[A-Za-z]:(?:[/\\]|$))/, '') + return /^[A-Za-z]:$/.test(stripped) ? `${stripped}/` : stripped +} diff --git a/src/main/ipc/ssh.ts b/src/main/ipc/ssh.ts index ff676112218..0ac0c5e0db0 100644 --- a/src/main/ipc/ssh.ts +++ b/src/main/ipc/ssh.ts @@ -714,8 +714,8 @@ export function registerSshHandlers( await removeRegisteredSshTarget(args.id) }) - ipcMain.handle('ssh:importConfig', () => { - return sshStore!.importFromSshConfig() + ipcMain.handle('ssh:importConfig', (_event, args?: { reAdopt?: boolean }) => { + return sshStore!.importFromSshConfig(args) }) // ── Connection lifecycle ─────────────────────────────────────────── diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index c7ff8f45c82..8b966e2b537 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -103,6 +103,7 @@ type CreateWorktreeArgsWithSystemProvenance = CreateWorktreeArgs & { } import { classifyWorkspaceCreateError } from './workspace-create-error-classifier' import { advertisedUrlWatcher } from '../ports/advertised-url-watcher' +import { localhostWorktreeLabelProxy } from '../localhost-worktree-label-proxy' import { assertWorktreeDoesNotContainRegisteredWorktree, canCleanupUnregisteredOrcaLeftoverDirectory, @@ -161,6 +162,9 @@ function removeWorktreeMetadataAndTransientState(store: Store, worktreeId: strin // drop process-local caches before the same ID can point at a new workspace. store.removeWorktreeMeta(worktreeId) advertisedUrlWatcher.forgetWorktree(worktreeId) + // Why: drop this worktree's localhost label routes so they don't accumulate + // in the proxy's route maps for the rest of the session. + localhostWorktreeLabelProxy.unregisterWorktree(worktreeId) deleteWorktreeHistoryDir(worktreeId) // Why: release the removed worktree's PR-refresh aliases so coalesced queue // entries do not retain it for the rest of the session (memory creep). diff --git a/src/main/localhost-worktree-label-proxy.test.ts b/src/main/localhost-worktree-label-proxy.test.ts index 49a5e866ec1..7d499209847 100644 --- a/src/main/localhost-worktree-label-proxy.test.ts +++ b/src/main/localhost-worktree-label-proxy.test.ts @@ -124,4 +124,33 @@ describe('localhost worktree label proxy', () => { expect(result.status).toBe(404) }) + + it('drops a worktree label routes on unregisterWorktree, leaving others intact', async () => { + const port = await startUpstream((_request, response) => { + response.writeHead(200, { 'content-type': 'text/plain' }) + response.end('ok') + }) + const proxy = new LocalhostWorktreeLabelProxy() + const a = await proxy.registerRoute({ + targetUrl: `http://localhost:${port}/`, + projectName: 'Snap Studio', + worktreeName: 'feature-a', + worktreeId: 'wt-a' + }) + const b = await proxy.registerRoute({ + targetUrl: `http://localhost:${port}/`, + projectName: 'Snap Studio', + worktreeName: 'feature-b', + worktreeId: 'wt-b' + }) + + expect((await fetchThroughProxy(a.url)).status).toBe(200) + expect((await fetchThroughProxy(b.url)).status).toBe(200) + + proxy.unregisterWorktree('wt-a') + + // The removed worktree's label no longer routes; the other worktree is unaffected. + expect((await fetchThroughProxy(a.url)).status).toBe(404) + expect((await fetchThroughProxy(b.url)).status).toBe(200) + }) }) diff --git a/src/main/localhost-worktree-label-proxy.ts b/src/main/localhost-worktree-label-proxy.ts index b2f8152707b..acc1164d1ee 100644 --- a/src/main/localhost-worktree-label-proxy.ts +++ b/src/main/localhost-worktree-label-proxy.ts @@ -49,6 +49,18 @@ export class LocalhostWorktreeLabelProxy { } } + // Why: routes are added per (worktreeId, targetUrl) but were never removed, so + // labels for deleted worktrees accumulated in both maps for the whole session. + // Drop them when the worktree is torn down. The shared http.Server stays up. + unregisterWorktree(worktreeId: string): void { + for (const [label, route] of this.routes) { + if (route.worktreeId === worktreeId) { + this.routes.delete(label) + this.routeKeys.delete(route.routeKey) + } + } + } + private async ensureServer(): Promise { if (this.server && this.listenPort !== null) { return diff --git a/src/main/native-chat/transcript-read-cache.test.ts b/src/main/native-chat/transcript-read-cache.test.ts index df5b310fcef..6f0fc39acb6 100644 --- a/src/main/native-chat/transcript-read-cache.test.ts +++ b/src/main/native-chat/transcript-read-cache.test.ts @@ -20,7 +20,8 @@ vi.mock('./transcript-reader', async (importOriginal) => { import { isTextBlock } from '../../shared/native-chat-types' import { clearNativeChatTranscriptCache, - readNativeChatTranscriptCached + readNativeChatTranscriptCached, + setNativeChatTranscriptCacheMaxBytesForTests } from './transcript-read-cache' let tempRoots: string[] = [] @@ -46,8 +47,26 @@ async function seedSession(sessionId: string, turns: number): Promise { return filePath } +// Writes a transcript file at an explicit path whose on-disk size is ~`bytes` +// (padded via one big user message), returning the path. Read it back by passing +// the path as `transcriptPath` so resolution doesn't depend on process.env.HOME. +async function seedBigFile(name: string, bytes: number): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-cache-bytes-')) + tempRoots.push(root) + const filePath = join(root, `${name}.jsonl`) + const record = { + type: 'user', + uuid: `u-${name}`, + timestamp: '2026-06-01T10:00:00.000Z', + message: { role: 'user', content: 'x'.repeat(Math.max(1, bytes)) } + } + await writeFile(filePath, jsonLines([record])) + return filePath +} + beforeEach(() => { clearNativeChatTranscriptCache() + setNativeChatTranscriptCacheMaxBytesForTests() readSpy.mockClear() }) @@ -148,4 +167,59 @@ describe('readNativeChatTranscriptCached', () => { // Distinct files must not share a cached parse object. expect(c).not.toBe(a) }) + + // Why: each cached entry is a FULL unwindowed transcript parse; heavy agent + // sessions are tens of MB, so the count-only cap let 50 entries retain multiple + // GB in the one process serving desktop + every paired client. The byte budget + // evicts the oldest large entries while always keeping the most-recent. + it('evicts the oldest entry once total cached bytes exceed the budget', async () => { + // ~4 KB per file, budget 9 KB → holds 2, a third read evicts the oldest. + setNativeChatTranscriptCacheMaxBytesForTests(9 * 1024) + const fileA = await seedBigFile('big-a', 4 * 1024) + const fileB = await seedBigFile('big-b', 4 * 1024) + const fileC = await seedBigFile('big-c', 4 * 1024) + + await readNativeChatTranscriptCached('claude', 'sa', fileA) // spy 1 + await readNativeChatTranscriptCached('claude', 'sb', fileB) // spy 2 + await readNativeChatTranscriptCached('claude', 'sc', fileC) // spy 3, evicts A + expect(readSpy).toHaveBeenCalledTimes(3) + + // C is the most-recent → still cached (no re-read). + await readNativeChatTranscriptCached('claude', 'sc', fileC) + expect(readSpy).toHaveBeenCalledTimes(3) + + // A was evicted by the byte cap → this re-reads (and evicts B in turn). + await readNativeChatTranscriptCached('claude', 'sa', fileA) + expect(readSpy).toHaveBeenCalledTimes(4) + + // B is now evicted → also re-reads. + await readNativeChatTranscriptCached('claude', 'sb', fileB) + expect(readSpy).toHaveBeenCalledTimes(5) + }) + + it('keeps a single active transcript larger than the whole budget cached', async () => { + // A lone entry over budget must NOT be dropped, else every read re-parses. + setNativeChatTranscriptCacheMaxBytesForTests(1024) + const big = await seedBigFile('huge', 8 * 1024) + await readNativeChatTranscriptCached('claude', 'huge', big) + await readNativeChatTranscriptCached('claude', 'huge', big) + expect(readSpy).toHaveBeenCalledTimes(1) + }) + + it('does not evict small entries under the default budget (no regression for typical use)', async () => { + const files = await Promise.all([ + seedBigFile('s1', 512), + seedBigFile('s2', 512), + seedBigFile('s3', 512) + ]) + for (const [i, file] of files.entries()) { + await readNativeChatTranscriptCached('claude', `sess-${i}`, file) + } + expect(readSpy).toHaveBeenCalledTimes(3) + // Re-read all three: every one is still cached (byte budget never triggered). + for (const [i, file] of files.entries()) { + await readNativeChatTranscriptCached('claude', `sess-${i}`, file) + } + expect(readSpy).toHaveBeenCalledTimes(3) + }) }) diff --git a/src/main/native-chat/transcript-read-cache.ts b/src/main/native-chat/transcript-read-cache.ts index b62a1a1142a..9f4df5bde87 100644 --- a/src/main/native-chat/transcript-read-cache.ts +++ b/src/main/native-chat/transcript-read-cache.ts @@ -21,6 +21,9 @@ type CachedTranscript = { result: ReadTranscriptResult /** mtime of the resolved file when cached; a newer mtime invalidates it. */ mtimeMs: number + /** On-disk byte size of the resolved file — a cheap, monotonic proxy for this + * entry's parsed memory footprint, used to bound the cache by total bytes. */ + bytes: number } const cache = new Map() @@ -28,19 +31,36 @@ const cache = new Map() // Why: cap the cache so a long-lived process browsing many sessions can't grow // it unbounded. Map preserves insertion order, so evicting the first key drops // the oldest entry (a simple LRU once re-inserts bump recency; see setCached). -// Entry-count cap is fine for v1; a byte-aware cap is the follow-up if profiling -// shows RSS pressure now that one process serves many remote clients. const MAX_CACHE_ENTRIES = 50 +// Why: a heavy Claude/Codex coding session's JSONL is routinely tens of MB (tool +// results embed whole file contents, command output, and diffs), and each cached +// entry is the full unwindowed parse. The count cap alone let 50 such entries +// retain multiple GB in the one process that now serves desktop + every paired +// web/mobile client. Bound total cached file bytes too; we always keep the most- +// recent entry (see setCached) so an active transcript is never re-parsed on +// every read, which caps the regression to extra re-parses only past this budget. +const MAX_CACHE_BYTES = 128 * 1024 * 1024 +// Overridable only from tests so the byte-eviction path can be exercised without +// writing hundreds of MB of fixtures; production always uses MAX_CACHE_BYTES. +let maxCacheBytes = MAX_CACHE_BYTES function setCached(key: string, value: CachedTranscript): void { // Re-insert moves the key to the most-recent position for LRU eviction. cache.delete(key) cache.set(key, value) - while (cache.size > MAX_CACHE_ENTRIES) { + let totalBytes = 0 + for (const entry of cache.values()) { + totalBytes += entry.bytes + } + // Evict oldest until within BOTH caps, but never drop the most-recent entry + // (cache.size > 1): a single active transcript larger than the whole budget + // must stay cached or every read would re-parse the full file. + while (cache.size > 1 && (cache.size > MAX_CACHE_ENTRIES || totalBytes > maxCacheBytes)) { const oldest = cache.keys().next().value if (oldest === undefined) { break } + totalBytes -= cache.get(oldest)?.bytes ?? 0 cache.delete(oldest) } } @@ -49,11 +69,12 @@ function cacheKey(agent: AgentType, filePath: string): string { return `${agent}:${filePath}` } -async function fileMtimeMs(filePath: string): Promise { +async function fileStat(filePath: string): Promise<{ mtimeMs: number; bytes: number }> { try { - return (await stat(filePath)).mtimeMs + const stats = await stat(filePath) + return { mtimeMs: stats.mtimeMs, bytes: stats.size } } catch { - return Number.NaN + return { mtimeMs: Number.NaN, bytes: 0 } } } @@ -74,7 +95,7 @@ export async function readNativeChatTranscriptCached( } const key = cacheKey(agent, filePath) - const mtimeMs = await fileMtimeMs(filePath) + const { mtimeMs, bytes } = await fileStat(filePath) const cached = cache.get(key) if (cached && Number.isFinite(mtimeMs) && cached.mtimeMs === mtimeMs) { // Bump recency so a frequently-read session survives eviction. @@ -84,7 +105,7 @@ export async function readNativeChatTranscriptCached( const result = await readNativeChatTranscript(agent, sessionId, { filePath }) if (Number.isFinite(mtimeMs)) { - setCached(key, { result, mtimeMs }) + setCached(key, { result, mtimeMs, bytes }) } return result } @@ -93,3 +114,8 @@ export async function readNativeChatTranscriptCached( export function clearNativeChatTranscriptCache(): void { cache.clear() } + +/** Test-only: override the byte budget (pass no arg to restore the default). */ +export function setNativeChatTranscriptCacheMaxBytesForTests(bytes?: number): void { + maxCacheBytes = bytes ?? MAX_CACHE_BYTES +} diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 157ab31f011..5bad9510e65 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -3339,6 +3339,11 @@ export class Store { defaults.workspaceSession ), sshTargets: (parsed.sshTargets ?? []).map(normalizeSshTarget), + deletedSshConfigAliases: Array.isArray(parsed.deletedSshConfigAliases) + ? parsed.deletedSshConfigAliases.filter( + (alias): alias is string => typeof alias === 'string' + ) + : [], sshRemotePtyLeases: (parsed.sshRemotePtyLeases ?? []) .map(normalizeSshRemotePtyLease) .filter((lease): lease is SshRemotePtyLease => lease !== null), @@ -5973,6 +5978,34 @@ export class Store { this.scheduleSave() } + getDeletedSshConfigAliases(): string[] { + return [...(this.state.deletedSshConfigAliases ?? [])] + } + + addDeletedSshConfigAlias(alias: string): void { + this.state.deletedSshConfigAliases ??= [] + if (!this.state.deletedSshConfigAliases.includes(alias)) { + this.state.deletedSshConfigAliases.push(alias) + this.scheduleSave() + } + } + + removeDeletedSshConfigAlias(alias: string): void { + const current = this.state.deletedSshConfigAliases + if (!current || !current.includes(alias)) { + return + } + this.state.deletedSshConfigAliases = current.filter((entry) => entry !== alias) + this.scheduleSave() + } + + clearDeletedSshConfigAliases(): void { + if (this.state.deletedSshConfigAliases && this.state.deletedSshConfigAliases.length > 0) { + this.state.deletedSshConfigAliases = [] + this.scheduleSave() + } + } + // ── SSH Remote PTY Leases ────────────────────────────────────────── getSshRemotePtyLeases(targetId?: string): SshRemotePtyLease[] { diff --git a/src/main/providers/ssh-filesystem-provider-stream.test.ts b/src/main/providers/ssh-filesystem-provider-stream.test.ts index 29daf5af532..b66b5633503 100644 --- a/src/main/providers/ssh-filesystem-provider-stream.test.ts +++ b/src/main/providers/ssh-filesystem-provider-stream.test.ts @@ -80,7 +80,8 @@ describe('SshFilesystemProvider readFile streaming', () => { const result = await provider.readFile('/home/user/file.txt') expect(mux.request).toHaveBeenCalledWith('fs.readFileStream', { - filePath: '/home/user/file.txt' + filePath: '/home/user/file.txt', + flowControl: 'ack' }) expect(result).toEqual({ content: text, isBinary: false }) }) diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index 59efa6abfa2..a5418500c1d 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -153,6 +153,7 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'files.openDiff', 'files.read', 'files.readChunk', + 'files.readDir', 'files.readPreview', 'files.readTerminalArtifact', 'files.readTerminalArtifactPreview', diff --git a/src/main/ssh/relay-protocol.test.ts b/src/main/ssh/relay-protocol.test.ts index 8369c3c02cd..c4e9f089743 100644 --- a/src/main/ssh/relay-protocol.test.ts +++ b/src/main/ssh/relay-protocol.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { HEADER_LENGTH, MessageType, @@ -160,6 +160,74 @@ describe('FrameDecoder', () => { expect(frames).toHaveLength(1) expect(frames[0].id).toBe(2) }) + + it('decodes frames fed one byte at a time (worst-case boundary straddling)', () => { + const frames: DecodedFrame[] = [] + const decoder = new FrameDecoder((f) => frames.push(f)) + + const frame1 = encodeFrame(MessageType.Regular, 1, 0, Buffer.from('first payload')) + const frame2 = encodeFrame(MessageType.Regular, 2, 1, Buffer.from('second')) + const combined = Buffer.concat([frame1, frame2]) + + for (let i = 0; i < combined.length; i += 1) { + decoder.feed(combined.subarray(i, i + 1)) + } + + expect(frames).toHaveLength(2) + expect(frames[0].payload.toString()).toBe('first payload') + expect(frames[1].id).toBe(2) + expect(frames[1].payload.toString()).toBe('second') + }) + + it('skips an oversized frame fed in odd-sized chunks and resynchronizes', () => { + const errors: Error[] = [] + const frames: DecodedFrame[] = [] + const decoder = new FrameDecoder( + (f) => frames.push(f), + (err) => errors.push(err) + ) + + const oversizedLength = 17 * 1024 * 1024 + const header = Buffer.alloc(HEADER_LENGTH) + header[0] = MessageType.Regular + header.writeUInt32BE(1, 1) + header.writeUInt32BE(0, 5) + header.writeUInt32BE(oversizedLength, 9) + const oversized = Buffer.concat([header, Buffer.alloc(oversizedLength)]) + const valid = encodeFrame(MessageType.Regular, 2, 0, Buffer.from('after')) + const combined = Buffer.concat([oversized, valid]) + + const chunkSize = 1024 * 1024 - 7 + for (let i = 0; i < combined.length; i += chunkSize) { + decoder.feed(combined.subarray(i, i + chunkSize)) + } + + expect(errors).toHaveLength(1) + expect(frames).toHaveLength(1) + expect(frames[0].payload.toString()).toBe('after') + }) + + it('never rebuilds the buffered stream per feed while assembling a large frame', () => { + // Regression: feed() used Buffer.concat([buffered, chunk]) per data event, + // re-copying the whole backlog for every TCP chunk — O(n²) memcpy on the + // Electron main thread while fs.streamChunk frames arrive (SSH typing lag). + const frames: DecodedFrame[] = [] + const decoder = new FrameDecoder((f) => frames.push(f)) + const frame = encodeFrame(MessageType.Regular, 1, 0, Buffer.alloc(512 * 1024, 0x61)) + + const concatSpy = vi.spyOn(Buffer, 'concat') + try { + for (let i = 0; i < frame.length; i += 32 * 1024) { + decoder.feed(frame.subarray(i, i + 32 * 1024)) + } + } finally { + concatSpy.mockRestore() + } + + expect(frames).toHaveLength(1) + expect(frames[0].payload.length).toBe(512 * 1024) + expect(concatSpy).not.toHaveBeenCalled() + }) }) describe('parseJsonRpcMessage', () => { diff --git a/src/main/ssh/relay-protocol.ts b/src/main/ssh/relay-protocol.ts index 3b8b63af159..db57c4d19aa 100644 --- a/src/main/ssh/relay-protocol.ts +++ b/src/main/ssh/relay-protocol.ts @@ -127,7 +127,13 @@ export type DecodedFrame = { * Incremental frame parser. Feed it chunks of data; it emits complete frames. */ export class FrameDecoder { - private buffer = Buffer.alloc(0) + // Why: feed() runs on the Electron main thread for every SSH channel data + // event. Rebuilding one contiguous buffer per feed (Buffer.concat) re-copies + // every already-buffered byte for each incoming ~32KB TCP chunk — O(n²) per + // large frame (a 340KB fs.streamChunk frame cost ~2MB of memcpy). A chunk + // list assembles each frame exactly once instead. + private chunks: Buffer[] = [] + private bufferedLength = 0 private onFrame: (frame: DecodedFrame) => void private onError: ((err: Error) => void) | null @@ -137,21 +143,31 @@ export class FrameDecoder { } feed(chunk: Buffer | Uint8Array): void { - this.buffer = Buffer.concat([this.buffer, chunk]) + const buf = Buffer.isBuffer(chunk) + ? chunk + : Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength) + if (buf.length > 0) { + this.chunks.push(buf) + this.bufferedLength += buf.length + } - while (this.buffer.length >= HEADER_LENGTH) { - const length = this.buffer.readUInt32BE(9) + while (this.bufferedLength >= HEADER_LENGTH) { + const header = this.peekBytes(HEADER_LENGTH) + const length = header.readUInt32BE(9) const totalLength = HEADER_LENGTH + length + if (this.bufferedLength < totalLength) { + // Not fully received yet (also holds oversized frames until they can + // be skipped whole, keeping the decoder synchronized). + break + } + // Why: throwing here would leave the buffer in a partially consumed // state — subsequent feed() calls would try to parse leftover payload // bytes as a new header, corrupting every future frame. Instead we // skip the entire oversized frame so the decoder stays synchronized. if (length > MAX_MESSAGE_SIZE) { - if (this.buffer.length < totalLength) { - break - } - this.buffer = this.buffer.subarray(totalLength) + this.discardBytes(totalLength) const err = new Error(`Frame payload too large: ${length} bytes — discarded`) if (this.onError) { this.onError(err) @@ -159,24 +175,83 @@ export class FrameDecoder { continue } - if (this.buffer.length < totalLength) { - break - } - + const framed = this.takeBytes(totalLength) const frame: DecodedFrame = { - type: this.buffer[0], - id: this.buffer.readUInt32BE(1), - ack: this.buffer.readUInt32BE(5), - payload: this.buffer.subarray(HEADER_LENGTH, totalLength) + type: framed[0], + id: framed.readUInt32BE(1), + ack: framed.readUInt32BE(5), + payload: framed.subarray(HEADER_LENGTH, totalLength) } - - this.buffer = this.buffer.subarray(totalLength) this.onFrame(frame) } } reset(): void { - this.buffer = Buffer.alloc(0) + this.chunks = [] + this.bufferedLength = 0 + } + + /** View of the first `count` buffered bytes without consuming them. */ + private peekBytes(count: number): Buffer { + const first = this.chunks[0] + if (first.length >= count) { + return first + } + const out = Buffer.allocUnsafe(count) + let copied = 0 + for (const part of this.chunks) { + copied += part.copy(out, copied, 0, Math.min(part.length, count - copied)) + if (copied >= count) { + break + } + } + return out + } + + /** Consume and return the first `count` buffered bytes (single copy). */ + private takeBytes(count: number): Buffer { + const first = this.chunks[0] + if (first.length === count) { + this.chunks.shift() + this.bufferedLength -= count + return first + } + if (first.length > count) { + this.chunks[0] = first.subarray(count) + this.bufferedLength -= count + return first.subarray(0, count) + } + const out = Buffer.allocUnsafe(count) + let copied = 0 + while (copied < count) { + const part = this.chunks[0] + const take = Math.min(part.length, count - copied) + part.copy(out, copied, 0, take) + copied += take + if (take === part.length) { + this.chunks.shift() + } else { + this.chunks[0] = part.subarray(take) + } + } + this.bufferedLength -= count + return out + } + + /** Consume the first `count` buffered bytes without assembling them. */ + private discardBytes(count: number): void { + let remaining = count + while (remaining > 0) { + const part = this.chunks[0] + if (part.length <= remaining) { + this.chunks.shift() + remaining -= part.length + } else { + this.chunks[0] = part.subarray(remaining) + remaining = 0 + } + } + this.bufferedLength -= count } } diff --git a/src/main/ssh/ssh-connection-store.test.ts b/src/main/ssh/ssh-connection-store.test.ts index 8e3e347d709..f1bf80d17aa 100644 --- a/src/main/ssh/ssh-connection-store.test.ts +++ b/src/main/ssh/ssh-connection-store.test.ts @@ -14,6 +14,7 @@ vi.mock('./ssh-config-parser', () => ({ function createMockStore() { const targets: SshTarget[] = [] + let deletedAliases: string[] = [] return { getSshTargets: vi.fn(() => [...targets]), @@ -32,6 +33,18 @@ function createMockStore() { if (idx !== -1) { targets.splice(idx, 1) } + }), + getDeletedSshConfigAliases: vi.fn(() => [...deletedAliases]), + addDeletedSshConfigAlias: vi.fn((alias: string) => { + if (!deletedAliases.includes(alias)) { + deletedAliases.push(alias) + } + }), + removeDeletedSshConfigAlias: vi.fn((alias: string) => { + deletedAliases = deletedAliases.filter((entry) => entry !== alias) + }), + clearDeletedSshConfigAliases: vi.fn(() => { + deletedAliases = [] }) } } @@ -324,4 +337,106 @@ describe('SshConnectionStore', () => { expect(result).toEqual([]) }) }) + + describe('deleted config host tombstones', () => { + function candidate(overrides: Partial & { configHost: string }): SshTarget { + return { + id: `tmp-${overrides.configHost}`, + label: overrides.configHost, + host: `${overrides.configHost}.example.com`, + port: 22, + username: '', + ...overrides + } + } + + // PRIMARY regression: deleting a config-sourced host must not let the next + // ~/.ssh/config sync resurrect it. + it('tombstones a deleted config-sourced host so re-import skips it', () => { + mockStore.addSshTarget({ + id: 'ssh-1', + label: 'mini', + configHost: 'mini', + host: 'mini.example.com', + port: 22, + username: 'ping', + source: 'ssh-config' + }) + + sshStore.removeTarget('ssh-1') + expect(mockStore.addDeletedSshConfigAlias).toHaveBeenCalledWith('mini') + + loadUserSshConfigMock.mockReturnValue([{ host: 'mini' }]) + sshConfigHostsToTargetsMock.mockReturnValue([candidate({ configHost: 'mini' })]) + + const result = sshStore.importFromSshConfig() + + expect(mockStore.addSshTarget).toHaveBeenCalledTimes(1) // only the seed insert + expect(result).toEqual([]) + }) + + it('does not tombstone a manual target on delete', () => { + mockStore.addSshTarget({ + id: 'ssh-1', + label: 'mini', + configHost: 'mini', + host: 'mini.example.com', + port: 22, + username: 'ping', + source: 'manual' + }) + + sshStore.removeTarget('ssh-1') + expect(mockStore.addDeletedSshConfigAlias).not.toHaveBeenCalled() + }) + + it('re-adding a deleted host reclaims its alias so sync stops suppressing it', () => { + mockStore.addDeletedSshConfigAlias('mini') + + sshStore.addTarget({ + label: 'mini', + configHost: 'mini', + host: '10.0.0.2', + port: 22, + username: 'ping' + }) + expect(mockStore.removeDeletedSshConfigAlias).toHaveBeenCalledWith('mini') + + loadUserSshConfigMock.mockReturnValue([{ host: 'mini' }]) + sshConfigHostsToTargetsMock.mockReturnValue([candidate({ configHost: 'mini' })]) + // Alias reclaimed, but it is now a manual target — still not re-inserted. + const result = sshStore.importFromSshConfig() + expect(result).toEqual([]) + }) + + it('editing a target reclaims its alias from the deleted set', () => { + mockStore.addSshTarget({ + id: 'ssh-1', + label: 'mini', + configHost: 'mini', + host: 'mini.example.com', + port: 22, + username: 'ping', + source: 'ssh-config' + }) + mockStore.addDeletedSshConfigAlias('mini') + + sshStore.updateTarget('ssh-1', { port: 2222, source: 'manual' }) + expect(mockStore.removeDeletedSshConfigAlias).toHaveBeenCalledWith('mini') + }) + + it('reAdopt clears all tombstones and re-imports the deleted host', () => { + mockStore.addDeletedSshConfigAlias('mini') + loadUserSshConfigMock.mockReturnValue([{ host: 'mini' }]) + sshConfigHostsToTargetsMock.mockReturnValue([candidate({ configHost: 'mini' })]) + + const result = sshStore.importFromSshConfig({ reAdopt: true }) + + expect(mockStore.clearDeletedSshConfigAliases).toHaveBeenCalled() + expect(mockStore.addSshTarget).toHaveBeenCalledWith( + expect.objectContaining({ configHost: 'mini', source: 'ssh-config' }) + ) + expect(result).toHaveLength(1) + }) + }) }) diff --git a/src/main/ssh/ssh-connection-store.ts b/src/main/ssh/ssh-connection-store.ts index d65bd42b9f0..975da8cdbc2 100644 --- a/src/main/ssh/ssh-connection-store.ts +++ b/src/main/ssh/ssh-connection-store.ts @@ -23,6 +23,9 @@ export class SshConnectionStore { source: target.source ?? 'manual', id: `ssh-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` } + // Why: re-adding a host the user previously deleted is an explicit intent to + // keep it — lift any tombstone so config sync stops suppressing this alias. + this.reclaimAlias(full.configHost ?? full.label) this.store.addSshTarget(full) return full } @@ -51,19 +54,49 @@ export class SshConnectionStore { } updateTarget(id: string, updates: Partial>): SshTarget | null { - return this.store.updateSshTarget(id, updates) + const updated = this.store.updateSshTarget(id, updates) + if (updated) { + // Why: actively editing a target reclaims its alias from the deleted set, + // so an edit can never leave the host tombstoned. + this.reclaimAlias(updated.configHost ?? updated.label) + } + return updated } removeTarget(id: string): void { + const target = this.store.getSshTarget(id) + // Why: deleting a config-managed target must record a tombstone; otherwise + // the next ~/.ssh/config sync re-inserts it verbatim (the config entry still + // exists on disk) and the host reappears. Manual targets need no tombstone — + // sync never re-adds them. + if (target && isConfigManagedTarget(target)) { + const alias = target.configHost ?? target.label + if (alias) { + this.store.addDeletedSshConfigAlias(alias) + } + } this.store.removeSshTarget(id) } + private reclaimAlias(alias: string | undefined): void { + if (alias) { + this.store.removeDeletedSshConfigAlias(alias) + } + } + /** * Sync targets from ~/.ssh/config: insert new hosts, update existing * config-sourced ones in place (so a rotated port takes effect), never touch * manual targets. Returns the inserted and updated targets. */ - importFromSshConfig(): SshTarget[] { + importFromSshConfig(options?: { reAdopt?: boolean }): SshTarget[] { + // Why: the explicit Import action re-adopts every config host, so it clears + // all tombstones first. The passive on-open sync passes no flag and keeps + // deleted hosts suppressed. + if (options?.reAdopt) { + this.store.clearDeletedSshConfigAliases() + } + const deletedAliases = new Set(this.store.getDeletedSshConfigAliases()) const configHosts = loadUserSshConfig() const existingTargets = this.store.getSshTargets() // Map config-managed targets (and legacy targets that strongly look like @@ -102,6 +135,11 @@ export class SshConnectionStore { // A manual target owns this alias — never clobber it. continue } + if (deletedAliases.has(alias)) { + // The user deleted this config host — stay deleted until they re-add it + // or re-adopt config explicitly. + continue + } if (processedAliases.has(alias)) { continue } @@ -156,6 +194,16 @@ export function isRuntimeOwnedSshTarget(target: SshTarget): boolean { return target.owner?.type === 'on-demand-runtime' } +function isConfigManagedTarget(target: SshTarget): boolean { + // Why: a target is subject to config sync (and therefore needs a tombstone on + // delete) when it is explicitly config-sourced, or a legacy import that sync + // still adopts. Manual targets are excluded — sync never re-adds them. + return ( + target.source === 'ssh-config' || + (target.source === undefined && isLegacyConfigImportTarget(target)) + ) +} + function isLegacyConfigImportTarget(target: SshTarget): boolean { const alias = target.configHost ?? target.label // Why: legacy manual and imported targets both lack `source`. Only adopt the diff --git a/src/main/ssh/ssh-filesystem-stream-reader.ts b/src/main/ssh/ssh-filesystem-stream-reader.ts index cda490c8580..2147ed0ef4f 100644 --- a/src/main/ssh/ssh-filesystem-stream-reader.ts +++ b/src/main/ssh/ssh-filesystem-stream-reader.ts @@ -148,6 +148,10 @@ export async function readFileViaStream( expectedSeq += 1 receivedChunks += 1 bytesReceived += decoded.length + // Why: credit-based flow control — the relay caps unacked chunks so bulk + // stream frames cannot queue unbounded ahead of interactive pty.data + // frames on the shared SSH channel. Old relays ignore this notification. + mux.notify('fs.streamAck', { streamId: id, seq }) } const handleEnd = (params: Record): void => { @@ -260,7 +264,9 @@ export async function readFileViaStream( unsubscribers.push(onDispose) void mux - .request('fs.readFileStream', { filePath }) + // Why: flowControl declares this client acks each chunk, letting a new + // relay pace the pump. Old relays ignore the extra param and flood. + .request('fs.readFileStream', { filePath, flowControl: 'ack' }) .then((rawMetadata) => { if (settled) { return diff --git a/src/main/ssh/ssh-port-forward.test.ts b/src/main/ssh/ssh-port-forward.test.ts index 31e26a5bc40..b49c627b8b3 100644 --- a/src/main/ssh/ssh-port-forward.test.ts +++ b/src/main/ssh/ssh-port-forward.test.ts @@ -267,6 +267,27 @@ describe('SshPortForwardManager', () => { ) }) + it('keeps only a bounded tail of a chatty forward stderr (memory-leak regression)', async () => { + const onForwardClosed = vi.fn() + manager.setCallbacks({ onForwardClosed }) + const forward = createFakeSystemSshForward() + startSystemSshPortForwardProcessMock.mockReturnValue(forward) + const conn = createSystemSshConn() + + await manager.addForward('conn-1', conn as never, 3000, '127.0.0.1', 8080) + // A long-lived forward against a chatty remote sshd: emit >64 KB of stderr. + forward.process.stderr.emit('data', Buffer.from(`HEAD_MARKER${'x'.repeat(70 * 1024)}`)) + forward.process.stderr.emit('data', Buffer.from('TAIL_MARKER')) + forward.process.emit('exit', 255) + + const detail = onForwardClosed.mock.calls[0]?.[1]?.detail as string + // The oldest bytes are trimmed; the most-recent tail is retained. + expect(detail).toContain('TAIL_MARKER') + expect(detail).not.toContain('HEAD_MARKER') + // Bounded well below the ~70 KB produced. + expect(detail.length).toBeLessThan(66 * 1024) + }) + it('lists forwards filtered by connectionId', async () => { const conn = createMockConn() await manager.addForward('conn-1', conn as never, 3000, 'localhost', 8080) diff --git a/src/main/ssh/system-ssh-port-forward-provider.ts b/src/main/ssh/system-ssh-port-forward-provider.ts index 74227f67cfe..e2b53abab7b 100644 --- a/src/main/ssh/system-ssh-port-forward-provider.ts +++ b/src/main/ssh/system-ssh-port-forward-provider.ts @@ -33,9 +33,17 @@ export class SystemSshPortForwardProvider implements SshPortForwardProvider { ) await forward.waitForStartup() + // Why: this stderr stays attached for the forward's whole lifetime but is + // only used to build the exit-error detail, so keep a bounded tail — a chatty + // remote sshd could otherwise grow it unbounded on a long-lived forward + // (mirrors MAX_RELAY_STARTUP_BUFFER_BYTES in ssh-relay-deploy-helpers). + const MAX_STDERR_TAIL_BYTES = 64 * 1024 let stderr = '' const onStderr = (chunk: Buffer): void => { stderr += chunk.toString('utf-8') + if (stderr.length > MAX_STDERR_TAIL_BYTES) { + stderr = stderr.slice(-MAX_STDERR_TAIL_BYTES) + } } forward.process.stderr?.on('data', onStderr) diff --git a/src/main/system-fonts.test.ts b/src/main/system-fonts.test.ts index 3f63d8930f0..c6c5e0e42c4 100644 --- a/src/main/system-fonts.test.ts +++ b/src/main/system-fonts.test.ts @@ -9,16 +9,56 @@ vi.mock('child_process', () => ({ execFile: execFileMock })) -function expectedFallbackFont(): string { - if (process.platform === 'darwin') { +function expectedFallbackFont(platform = process.platform): string { + if (platform === 'darwin') { return 'SF Mono' } - if (process.platform === 'win32') { + if (platform === 'win32') { return 'Cascadia Mono' } return 'JetBrains Mono' } +async function withPlatform(platform: NodeJS.Platform, fn: () => Promise): Promise { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: platform }) + try { + return await fn() + } finally { + Object.defineProperty(process, 'platform', { + configurable: true, + value: originalPlatform + }) + } +} + +async function expectFontCommandTimeout( + platform: NodeJS.Platform, + timeoutMs: number +): Promise { + await withPlatform(platform, async () => { + vi.useFakeTimers() + execFileMock.mockReturnValue({ kill: killMock }) + + const { listSystemFontFamilies } = await import('./system-fonts') + const fontsPromise = listSystemFontFamilies() + let resolvedFonts: string[] | null = null + fontsPromise.then((fonts) => { + resolvedFonts = fonts + }) + + await vi.advanceTimersByTimeAsync(timeoutMs - 1) + + expect(resolvedFonts).toBeNull() + expect(killMock).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(1) + + await expect(fontsPromise).resolves.toContain(expectedFallbackFont(platform)) + expect(killMock).toHaveBeenCalledOnce() + }) +} + describe('listSystemFontFamilies', () => { afterEach(() => { vi.useRealTimers() @@ -44,4 +84,15 @@ describe('listSystemFontFamilies', () => { expect(resolvedFonts).toContain(expectedFallbackFont()) expect(killMock).toHaveBeenCalledOnce() }) + + it('uses the longer timeout for macOS profiler scans', async () => { + await expectFontCommandTimeout('darwin', 45_000) + }) + + it.each([ + ['linux' as NodeJS.Platform, 15_000], + ['win32' as NodeJS.Platform, 15_000] + ])('keeps the %s font command timeout short', async (platform, timeoutMs) => { + await expectFontCommandTimeout(platform, timeoutMs) + }) }) diff --git a/src/main/system-fonts.ts b/src/main/system-fonts.ts index afe142562d9..15ff9c28bde 100644 --- a/src/main/system-fonts.ts +++ b/src/main/system-fonts.ts @@ -3,6 +3,9 @@ import { execFile } from 'node:child_process' let cachedFonts: string[] | null = null let fontsPromise: Promise | null = null const SYSTEM_FONT_LIST_TIMEOUT_MS = 15_000 +// Why: large macOS font catalogs can make system_profiler exceed 15s even +// when it is healthy; keep the longer wait scoped to that slow command. +const MAC_SYSTEM_FONT_LIST_TIMEOUT_MS = 45_000 export async function listSystemFontFamilies(): Promise { if (cachedFonts) { @@ -43,23 +46,26 @@ function loadSystemFontFamilies(): Promise { } function listMacFonts(): Promise { - return execFileText('system_profiler', ['SPFontsDataType', '-json'], 32 * 1024 * 1024).then( - (output) => { - const parsed = JSON.parse(output) as { - SPFontsDataType?: { - typefaces?: { - family?: string - }[] + return execFileText( + 'system_profiler', + ['SPFontsDataType', '-json'], + 32 * 1024 * 1024, + MAC_SYSTEM_FONT_LIST_TIMEOUT_MS + ).then((output) => { + const parsed = JSON.parse(output) as { + SPFontsDataType?: { + typefaces?: { + family?: string }[] - } - - return uniqueSorted( - (parsed.SPFontsDataType ?? []).flatMap((font) => - (font.typefaces ?? []).map((typeface) => typeface.family) - ) - ) + }[] } - ) + + return uniqueSorted( + (parsed.SPFontsDataType ?? []).flatMap((font) => + (font.typefaces ?? []).map((typeface) => typeface.family) + ) + ) + }) } function listLinuxFonts(): Promise { @@ -95,7 +101,12 @@ $fonts.Families | ForEach-Object { $_.Name } ) } -function execFileText(command: string, args: string[], maxBuffer: number): Promise { +function execFileText( + command: string, + args: string[], + maxBuffer: number, + timeoutMs = SYSTEM_FONT_LIST_TIMEOUT_MS +): Promise { return new Promise((resolve, reject) => { let settled = false let timer: ReturnType | undefined @@ -123,7 +134,7 @@ function execFileText(command: string, args: string[], maxBuffer: number): Promi // should fall back instead of keeping settings IPC pending forever. child.kill() reject(new Error(`Timed out listing system fonts with ${command}`)) - }, SYSTEM_FONT_LIST_TIMEOUT_MS) + }, timeoutMs) if (typeof timer === 'object' && 'unref' in timer) { timer.unref() } diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 4201cfd351a..2e9f0c7272c 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -2879,7 +2879,7 @@ export type PreloadApi = { updates: Partial> }) => Promise removeTarget: (args: { id: string }) => Promise - importConfig: () => Promise + importConfig: (args?: { reAdopt?: boolean }) => Promise connect: (args: { targetId: string }) => Promise disconnect: (args: { targetId: string }) => Promise terminateSessions: (args: { targetId: string }) => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index 04b8217620f..d9272ec40f9 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -3947,7 +3947,8 @@ const api = { removeTarget: (args: { id: string }): Promise => ipcRenderer.invoke('ssh:removeTarget', args), - importConfig: (): Promise => ipcRenderer.invoke('ssh:importConfig'), + importConfig: (args?: { reAdopt?: boolean }): Promise => + ipcRenderer.invoke('ssh:importConfig', args), connect: (args: { targetId: string }): Promise => ipcRenderer.invoke('ssh:connect', args), diff --git a/src/relay/dispatcher.test.ts b/src/relay/dispatcher.test.ts index a87e37c6078..5ab997c9d21 100644 --- a/src/relay/dispatcher.test.ts +++ b/src/relay/dispatcher.test.ts @@ -395,4 +395,119 @@ describe('RelayDispatcher', () => { expect(listener).toHaveBeenCalledWith(1) }) + + describe('notifyBulk (bulk lane backpressure)', () => { + it('resolves immediately when the sink accepts the frame', async () => { + const frames: Buffer[] = [] + const bulkDispatcher = new RelayDispatcher((data) => { + frames.push(Buffer.from(data)) + return true + }) + try { + await bulkDispatcher.notifyBulk('bulk.event', { seq: 0 }) + expect(frames).toHaveLength(1) + const frame = decodeFirstFrame(frames[0]) + const msg = JSON.parse(frame.payload.toString()) as JsonRpcNotification + expect(msg.method).toBe('bulk.event') + } finally { + bulkDispatcher.dispose() + } + }) + + it('holds the next bulk frame until the saturated sink drains', async () => { + const frames: Buffer[] = [] + const drainWaiters = new Set<() => void>() + const bulkDispatcher = new RelayDispatcher( + (data) => { + frames.push(Buffer.from(data)) + return false + }, + { waitWriteDrain: (cb) => drainWaiters.add(cb) } + ) + try { + let firstSettled = false + const first = bulkDispatcher.notifyBulk('bulk.event', { seq: 0 }).then(() => { + firstSettled = true + }) + void bulkDispatcher.notifyBulk('bulk.event', { seq: 1 }) + await vi.advanceTimersByTimeAsync(0) + + // First frame written, but its send has not settled and the second + // frame is not admitted while the sink stays saturated. + expect(frames).toHaveLength(1) + expect(firstSettled).toBe(false) + + for (const cb of Array.from(drainWaiters)) { + drainWaiters.delete(cb) + cb() + } + await first + await vi.advanceTimersByTimeAsync(0) + expect(frames).toHaveLength(2) + } finally { + bulkDispatcher.dispose() + } + }) + + it('interactive notify() frames are not gated behind a stalled bulk lane', async () => { + const frames: Buffer[] = [] + const bulkDispatcher = new RelayDispatcher( + (data) => { + frames.push(Buffer.from(data)) + return false + }, + { waitWriteDrain: () => {} } + ) + try { + void bulkDispatcher.notifyBulk('bulk.event', { seq: 0 }) + void bulkDispatcher.notifyBulk('bulk.event', { seq: 1 }) + await vi.advanceTimersByTimeAsync(0) + expect(frames).toHaveLength(1) + + bulkDispatcher.notify('pty.data', { id: 'pty-1', data: 'x' }) + expect(frames).toHaveLength(2) + const msg = JSON.parse( + decodeFirstFrame(frames[1]).payload.toString() + ) as JsonRpcNotification + expect(msg.method).toBe('pty.data') + } finally { + bulkDispatcher.dispose() + } + }) + + it('releases a parked bulk send when the dispatcher is disposed', async () => { + const bulkDispatcher = new RelayDispatcher(() => false, { waitWriteDrain: () => {} }) + const pending = bulkDispatcher.notifyBulk('bulk.event', { seq: 0 }) + await vi.advanceTimersByTimeAsync(0) + bulkDispatcher.dispose() + await expect(pending).resolves.toBeUndefined() + }) + + it('targets only the requested client and resolves for missing clients', async () => { + const primaryFrames: Buffer[] = [] + const secondaryFrames: Buffer[] = [] + const bulkDispatcher = new RelayDispatcher((data) => { + primaryFrames.push(Buffer.from(data)) + return true + }) + try { + const secondaryId = bulkDispatcher.attachClient((data) => { + secondaryFrames.push(Buffer.from(data)) + return true + }) + + await bulkDispatcher.notifyBulk('bulk.event', { seq: 0 }, { clientId: secondaryId }) + expect(primaryFrames).toHaveLength(0) + expect(secondaryFrames).toHaveLength(1) + + await expect( + bulkDispatcher.notifyBulk('bulk.event', { seq: 1 }, { clientId: 999 }) + ).resolves.toBeUndefined() + expect(primaryFrames).toHaveLength(0) + expect(secondaryFrames).toHaveLength(1) + } finally { + bulkDispatcher.dispose() + } + }) + }) }) diff --git a/src/relay/dispatcher.ts b/src/relay/dispatcher.ts index 41809a4d749..427d90e6ac0 100644 --- a/src/relay/dispatcher.ts +++ b/src/relay/dispatcher.ts @@ -27,10 +27,28 @@ export type MethodHandler = ( export type NotificationHandler = (params: Record, context: RequestContext) => void +/** Sink write. Returning literal `false` signals saturation (Node stream + * semantics); `void`/`true` mean the frame was accepted. */ +export type RelayClientWrite = (data: Buffer) => boolean | void + +export type RelayClientSinkOptions = { + /** One-shot: invoke `cb` once when the sink can accept more data again + * (stream 'drain'), or when the sink is permanently dead (error/close) so + * bulk senders waiting on it never hang. */ + waitWriteDrain?: (cb: () => void) => void +} + type RelayClient = { id: number decoder: FrameDecoder - write: (data: Buffer) => void + write: RelayClientWrite + waitWriteDrain?: (cb: () => void) => void + /** Pending resolvers for bulk sends stalled on sink saturation. Flushed on + * drain, write failure, detach, setWrite, and dispose so no pump hangs. */ + drainWaiters: Set<() => void> + /** Serializes bulk-lane sends per client so at most one bulk frame is + * admitted past the sink's high-water mark at a time. */ + bulkChain: Promise nextOutgoingSeq: number highestReceivedSeq: number generation: number @@ -58,8 +76,8 @@ export class RelayDispatcher { private nextClientId = 1 private nextRequestId = 1 - constructor(write: (data: Buffer) => void) { - this.primaryClient = this.createClient(write) + constructor(write: RelayClientWrite, sinkOptions?: RelayClientSinkOptions) { + this.primaryClient = this.createClient(write, sinkOptions) this.clients.set(this.primaryClient.id, this.primaryClient) this.startKeepalive() } @@ -75,10 +93,14 @@ export class RelayDispatcher { // never acks the new client's frames until the new client's seq catches // up - causing the client's unacked-timeout checker to accumulate stale // timestamps that could eventually fire a false connection-dead signal. - setWrite(write: (data: Buffer) => void): void { + setWrite(write: RelayClientWrite, sinkOptions?: RelayClientSinkOptions): void { this.requestAborts.abortClient(this.primaryClient.id) this.primaryClient.write = write + this.primaryClient.waitWriteDrain = sinkOptions?.waitWriteDrain this.primaryClient.closed = false + // Why: the saturated sink the waiters were parked on no longer exists; + // wake stalled bulk senders so they re-evaluate against the new sink. + this.flushDrainWaiters(this.primaryClient) this.resetClient(this.primaryClient) } @@ -89,14 +111,15 @@ export class RelayDispatcher { this.requestAborts.abortClient(this.primaryClient.id) this.primaryClient.generation++ this.primaryClient.closed = true + this.flushDrainWaiters(this.primaryClient) this.notifyClientDetached(this.primaryClient.id) } // Why: synced remote workspaces can have more than one Orca client attached // to the same relay. Frame sequence numbers and JSON-RPC request ids are per // SSH channel, so each socket client needs independent protocol state. - attachClient(write: (data: Buffer) => void): number { - const client = this.createClient(write) + attachClient(write: RelayClientWrite, sinkOptions?: RelayClientSinkOptions): number { + const client = this.createClient(write, sinkOptions) this.clients.set(client.id, client) return client.id } @@ -109,6 +132,7 @@ export class RelayDispatcher { this.requestAborts.abortClient(clientId) client.generation++ client.closed = true + this.flushDrainWaiters(client) this.clients.delete(clientId) this.notifyClientDetached(clientId) } @@ -165,6 +189,90 @@ export class RelayDispatcher { } } + /** + * Bulk-lane notification. Sends are serialized per client and the returned + * promise resolves only after the sink accepted the frame without reporting + * saturation (or the client went away). Bulk producers (file streams) await + * this between frames so interactive frames (pty.data echo) never queue + * behind an unbounded backlog on the shared SSH channel. + * + * With `clientId`, the frame goes only to that client — stream chunks have + * exactly one consumer, and broadcasting them would let one slow secondary + * client stall everyone. A missing/closed target resolves immediately; the + * caller's staleness check owns aborting the stream. + */ + notifyBulk( + method: string, + params?: Record, + opts?: { clientId?: number } + ): Promise { + if (this.disposed) { + return Promise.resolve() + } + const msg: JsonRpcNotification = { + jsonrpc: '2.0', + method, + ...(params !== undefined ? { params } : {}) + } + const targets = + opts?.clientId !== undefined + ? [this.clients.get(opts.clientId)].filter((c): c is RelayClient => c !== undefined) + : Array.from(this.clients.values()) + const waits: Promise[] = [] + for (const client of targets) { + if (client.closed) { + continue + } + // Why: the frame is encoded inside the chain step, not at call time — + // sequence numbers must be assigned in actual write order. + const step = client.bulkChain.then(() => { + if (this.disposed || client.closed) { + return + } + const accepted = this.sendFrame(client, msg) + if (accepted === false) { + return this.waitForClientDrain(client) + } + return undefined + }) + client.bulkChain = step.catch(() => {}) + waits.push(step) + } + if (waits.length === 0) { + return Promise.resolve() + } + return Promise.all(waits).then(() => {}) + } + + private waitForClientDrain(client: RelayClient): Promise { + if (this.disposed || client.closed || !client.waitWriteDrain) { + return Promise.resolve() + } + return new Promise((resolve) => { + let settled = false + const finish = (): void => { + if (settled) { + return + } + settled = true + client.drainWaiters.delete(finish) + resolve() + } + client.drainWaiters.add(finish) + try { + client.waitWriteDrain!(finish) + } catch { + finish() + } + }) + } + + private flushDrainWaiters(client: RelayClient): void { + for (const waiter of Array.from(client.drainWaiters)) { + waiter() + } + } + requestPrimary( method: string, params?: Record, @@ -236,14 +344,20 @@ export class RelayDispatcher { // Why: dispose means this relay instance cannot send responses anymore; // abort in-flight request work so stale SSH-side scans/watchers release. this.requestAborts.abortAll() + for (const client of this.clients.values()) { + this.flushDrainWaiters(client) + } } - private createClient(write: (data: Buffer) => void): RelayClient { + private createClient(write: RelayClientWrite, sinkOptions?: RelayClientSinkOptions): RelayClient { const id = this.nextClientId++ const client: RelayClient = { id, decoder: new FrameDecoder((frame) => this.handleFrame(client, frame)), write, + waitWriteDrain: sinkOptions?.waitWriteDrain, + drainWaiters: new Set(), + bulkChain: Promise.resolve(), nextOutgoingSeq: 1, highestReceivedSeq: 0, generation: 0, @@ -387,13 +501,13 @@ export class RelayDispatcher { private sendFrame( client: RelayClient, msg: JsonRpcRequest | JsonRpcResponse | JsonRpcNotification - ): void { + ): boolean | void { if (this.disposed || client.closed) { return } const seq = client.nextOutgoingSeq++ const frame = encodeJsonRpcFrame(msg, seq, client.highestReceivedSeq) - this.writeFrame(client, frame) + return this.writeFrame(client, frame) } private startKeepalive(): void { @@ -416,12 +530,13 @@ export class RelayDispatcher { this.keepaliveTimer.unref() } - private writeFrame(client: RelayClient, frame: Buffer): void { + private writeFrame(client: RelayClient, frame: Buffer): boolean | void { try { - client.write(frame) + return client.write(frame) } catch (err) { client.closed = true client.generation++ + this.flushDrainWaiters(client) if (client !== this.primaryClient) { this.clients.delete(client.id) this.notifyClientDetached(client.id) diff --git a/src/relay/fs-handler-file-read.ts b/src/relay/fs-handler-file-read.ts index 45a0685b8ac..003172c08fd 100644 --- a/src/relay/fs-handler-file-read.ts +++ b/src/relay/fs-handler-file-read.ts @@ -1,7 +1,7 @@ import { open, readFile, stat } from 'node:fs/promises' import { extname } from 'node:path' import type { RelayDispatcher, RequestContext } from './dispatcher' -import { STREAM_CHUNK_SIZE, RelayErrorCode } from './protocol' +import { STREAM_ACK_WINDOW_CHUNKS, STREAM_CHUNK_SIZE, RelayErrorCode } from './protocol' import type { RelayStreamRegistry, TooManyStreamsError } from './fs-stream-registry' import { BINARY_PROBE_BYTES, @@ -61,11 +61,21 @@ type StreamChunkReader = { ): Promise<{ bytesRead: number }> } +export type StreamPumpOptions = { + /** Client that requested the stream. Chunks go only to it — broadcasting + * bulk frames would let one slow secondary client stall the requester. */ + clientId?: number + /** True when the client declared `flowControl: 'ack'` — it sends + * fs.streamAck per processed chunk and the pump caps unacked chunks. */ + paceWithAcks: boolean +} + export async function readRelayFileStreamMetadata( filePath: string, dispatcher: RelayDispatcher, registry: RelayStreamRegistry, - context: RequestContext + context: RequestContext, + pumpOptions?: StreamPumpOptions ): Promise { const stats = await stat(filePath) const mimeType = IMAGE_MIME_TYPES[extname(filePath).toLowerCase()] @@ -106,8 +116,9 @@ export async function readRelayFileStreamMetadata( // Why: pumpChunks owns its own try/finally for handle release; the outer // setImmediate kicks the pump off the metadata-response task so the client // sees the response before the first chunk frame. + const resolvedPumpOptions = pumpOptions ?? { paceWithAcks: false } setImmediate(() => { - void pumpChunks(streamId, stats.size, dispatcher, registry, context) + void pumpChunks(streamId, stats.size, dispatcher, registry, context, resolvedPumpOptions) }) return { @@ -126,7 +137,8 @@ async function pumpChunks( totalSize: number, dispatcher: RelayDispatcher, registry: RelayStreamRegistry, - context: RequestContext + context: RequestContext, + pumpOptions: StreamPumpOptions ): Promise { const entry = registry.get(streamId) if (!entry) { @@ -150,6 +162,27 @@ async function pumpChunks( endReason = 'aborted' break } + // Why: credit window — bulk chunks share one ordered SSH channel with + // interactive pty.data frames. Waiting for client acks bounds how many + // stream bytes a keystroke echo can queue behind, and yields the relay + // event loop so incoming keystrokes are handled between chunks. + if (pumpOptions.paceWithAcks) { + while ( + seq - registry.ackedThroughSeq(streamId) > STREAM_ACK_WINDOW_CHUNKS && + !context.isStale() && + !registry.isAborted(streamId) + ) { + await registry.waitForAck(streamId) + } + if (context.isStale()) { + endReason = 'stale' + break + } + if (registry.isAborted(streamId)) { + endReason = 'aborted' + break + } + } const want = Math.min(STREAM_CHUNK_SIZE, totalSize - offset) const bytesRead = await readFullStreamChunk(entry.handle, buffer, want, offset) if (bytesRead !== want) { @@ -167,7 +200,14 @@ async function pumpChunks( break } const data = buffer.subarray(0, bytesRead).toString('base64') - dispatcher.notify('fs.streamChunk', { streamId, seq, data }) + // Why: the bulk lane waits out sink saturation, so a flood of chunk + // frames cannot pile up in the outbound pipe ahead of interactive + // pty.data frames written via plain notify(). + await dispatcher.notifyBulk( + 'fs.streamChunk', + { streamId, seq, data }, + pumpOptions.clientId !== undefined ? { clientId: pumpOptions.clientId } : undefined + ) offset += bytesRead seq += 1 } diff --git a/src/relay/fs-handler-stream.test.ts b/src/relay/fs-handler-stream.test.ts index d71dbf8c076..c7624afce65 100644 --- a/src/relay/fs-handler-stream.test.ts +++ b/src/relay/fs-handler-stream.test.ts @@ -34,6 +34,9 @@ function createMockDispatcher() { notify: vi.fn((method: string, params?: Record) => { notifications.push({ method, params }) }), + notifyBulk: vi.fn(async (method: string, params?: Record): Promise => { + notifications.push({ method, params }) + }), _notifications: notifications, callRequest( method: string, @@ -278,6 +281,61 @@ describe('FsHandler readFileStream', () => { expect(err).toBeNull() }) + it('parks the pump at the ack credit window and resumes on fs.streamAck', async () => { + const filePath = path.join(tmpDir, 'paced.png') + const content = Buffer.alloc(1536 * 1024, 0x42) // 6 chunks + writeFileSync(filePath, content) + + const meta = (await dispatcher.callRequest( + 'fs.readFileStream', + { filePath, flowControl: 'ack' }, + { isStale: () => false } + )) as { streamId: number } + + // Window of 4 admits seqs 0..3; seq 4 must wait for an ack. + await waitFor(() => collectStream(dispatcher).chunks.length === 4) + await flush(10) + expect(collectStream(dispatcher).chunks.length).toBe(4) + expect(collectStream(dispatcher).end).toBeNull() + + // Ack one chunk → exactly one more is admitted. + dispatcher.callNotification('fs.streamAck', { streamId: meta.streamId, seq: 0 }) + await waitFor(() => collectStream(dispatcher).chunks.length === 5) + await flush(10) + expect(collectStream(dispatcher).chunks.length).toBe(5) + + for (let seq = 1; seq < 6; seq += 1) { + dispatcher.callNotification('fs.streamAck', { streamId: meta.streamId, seq }) + } + await waitFor(() => collectStream(dispatcher).end !== null) + + const { chunks, err } = collectStream(dispatcher) + expect(err).toBeNull() + const reassembled = Buffer.concat(chunks.map((c) => Buffer.from(c.data, 'base64'))) + expect(reassembled.equals(content)).toBe(true) + }) + + it('releases a pump parked on the ack window when the stream is cancelled', async () => { + const filePath = path.join(tmpDir, 'parked-cancel.png') + writeFileSync(filePath, Buffer.alloc(4 * 1024 * 1024, 0x42)) // 16 chunks + + const meta = (await dispatcher.callRequest( + 'fs.readFileStream', + { filePath, flowControl: 'ack' }, + { isStale: () => false } + )) as { streamId: number } + + await waitFor(() => collectStream(dispatcher).chunks.length === 4) + dispatcher.callNotification('fs.cancelStream', { streamId: meta.streamId }) + + const registry = (handler as unknown as { streamRegistry: { size(): number } }).streamRegistry + await waitFor(() => registry.size() === 0) + + const { end, err } = collectStream(dispatcher) + expect(end).toBeNull() + expect(err).toBeNull() + }) + it('rejects the 17th concurrent stream with TooManyStreams', async () => { const paths: string[] = [] for (let i = 0; i < 17; i++) { diff --git a/src/relay/fs-handler.ts b/src/relay/fs-handler.ts index 0c4a263f13e..c6564c51fbe 100644 --- a/src/relay/fs-handler.ts +++ b/src/relay/fs-handler.ts @@ -86,7 +86,13 @@ export class FsHandler { constructor(dispatcher: RelayDispatcher, _context: RelayContext) { this.dispatcher = dispatcher this.registerHandlers() - this.dispatcher.onClientDetached?.((clientId) => this.releaseClientWatches(clientId)) + this.dispatcher.onClientDetached?.((clientId) => { + this.releaseClientWatches(clientId) + // Why: a detached client's fs.streamAck frames will never arrive; wake + // any pump parked on the ack window so it re-checks staleness and exits + // instead of stranding its open file handle. + this.streamRegistry.wakeAllAckWaiters() + }) } private registerHandlers(): void { @@ -113,6 +119,7 @@ export class FsHandler { this.dispatcher.onRequest('fs.watch', (p, context) => this.watch(p, context)) this.dispatcher.onNotification('fs.unwatch', (p, context) => this.unwatch(p, context)) this.dispatcher.onNotification('fs.cancelStream', (p) => this.cancelStream(p)) + this.dispatcher.onNotification('fs.streamAck', (p) => this.streamAck(p)) } private async readDir(params: Record) { @@ -148,7 +155,13 @@ export class FsHandler { private async readFileStream(params: Record, context?: RequestContext) { const filePath = expandTilde(params.filePath as string) const ctx = context ?? { clientId: 0, isStale: () => false } - return readRelayFileStreamMetadata(filePath, this.dispatcher, this.streamRegistry, ctx) + return readRelayFileStreamMetadata(filePath, this.dispatcher, this.streamRegistry, ctx, { + // Why: only target the requesting client when the dispatcher actually + // routed this request (context present) — direct-call tests and legacy + // paths keep broadcast semantics. + ...(context ? { clientId: context.clientId } : {}), + paceWithAcks: params.flowControl === 'ack' + }) } private async tempDir(): Promise { @@ -162,6 +175,14 @@ export class FsHandler { } } + private streamAck(params: Record): void { + const streamId = params.streamId as number | undefined + const seq = params.seq as number | undefined + if (typeof streamId === 'number' && typeof seq === 'number') { + this.streamRegistry.recordAck(streamId, seq) + } + } + private async writeFile(params: Record) { const filePath = expandTilde(params.filePath as string) const content = params.content as string diff --git a/src/relay/fs-stream-pty-echo-backpressure.integration.test.ts b/src/relay/fs-stream-pty-echo-backpressure.integration.test.ts new file mode 100644 index 00000000000..8cca2787891 --- /dev/null +++ b/src/relay/fs-stream-pty-echo-backpressure.integration.test.ts @@ -0,0 +1,295 @@ +/** + * Regression: SSH typing latency under bulk file-stream load. + * + * The relay and the client share ONE ordered SSH channel. If the relay + * enqueues an entire file's fs.streamChunk frames into the outbound pipe + * at once, an interactive pty.data echo emitted mid-stream queues behind + * megabytes of bulk data and typing feels seconds-slow. + * + * These tests model the SSH channel as a congestible in-memory pipe and + * assert deterministic byte bounds instead of wall-clock latency: + * - with a saturated sink, the relay stalls the pump on write backpressure + * so at most ~1 chunk frame sits ahead of a pty echo; + * - with a fast sink but an unresponsive client, the fs.streamAck credit + * window bounds the in-flight backlog; + * - legacy clients that never ack still receive the full stream. + */ +import { describe, expect, it, beforeEach, afterEach } from 'vitest' +import { mkdtempSync, writeFileSync } from 'node:fs' +import { rm } from 'node:fs/promises' +import * as path from 'node:path' +import { tmpdir } from 'node:os' +import { randomBytes } from 'node:crypto' + +import { + SshChannelMultiplexer, + type MultiplexerTransport +} from '../main/ssh/ssh-channel-multiplexer' +import { readFileViaStream } from '../main/ssh/ssh-filesystem-stream-reader' + +import { RelayDispatcher } from './dispatcher' +import { RelayContext } from './context' +import { FsHandler } from './fs-handler' +import { STREAM_CHUNK_SIZE } from './protocol' + +// One framed fs.streamChunk: 256KB raw → base64 (4/3) + JSON envelope + header. +const FRAMED_CHUNK_BYTES = Math.ceil((STREAM_CHUNK_SIZE * 4) / 3) + 512 +// Node pipe/socket sinks report saturation via write() === false past the HWM. +const SINK_HIGH_WATER_MARK = 64 * 1024 + +async function waitUntil( + predicate: () => boolean, + what: string, + timeoutMs = 10_000 +): Promise { + const deadline = Date.now() + timeoutMs + while (!predicate()) { + if (Date.now() > deadline) { + throw new Error(`waitUntil timed out: ${what}`) + } + await new Promise((r) => setImmediate(r)) + } +} + +/** Waits until `read()` stops changing for `stableTurns` macrotask turns. */ +async function waitUntilSettled(read: () => number, stableTurns = 25): Promise { + let last = read() + let stable = 0 + while (stable < stableTurns) { + await new Promise((r) => setImmediate(r)) + const current = read() + if (current === last) { + stable += 1 + } else { + stable = 0 + last = current + } + } +} + +type Harness = { + mux: SshChannelMultiplexer + dispatcher: RelayDispatcher + fsHandler: FsHandler + queuedBytes: () => number + /** Deliver every queued relay→client buffer to the client mux. */ + deliverAll: () => void + /** Start delivering continuously on each macrotask turn. */ + startAutoDeliver: () => void + dispose: () => void +} + +function createHarness(opts: { congested: boolean }): Harness { + let relayFeed: ((data: Buffer) => void) | null = null + const clientDataCallbacks: ((data: Buffer) => void)[] = [] + + const clientTransport: MultiplexerTransport = { + write: (data: Buffer) => { + // Client → relay: keystrokes and acks flow on the opposite direction of + // the duplex channel; they are not blocked by relay→client congestion. + setImmediate(() => relayFeed?.(data)) + }, + onData: (cb) => { + clientDataCallbacks.push(cb) + }, + onClose: () => {} + } + + const outQueue: Buffer[] = [] + let queuedBytes = 0 + const drainWaiters = new Set<() => void>() + const fireDrainIfIdle = (): void => { + if (queuedBytes > 0) { + return + } + for (const cb of Array.from(drainWaiters)) { + drainWaiters.delete(cb) + cb() + } + } + + const dispatcher = new RelayDispatcher( + (data: Buffer) => { + outQueue.push(data) + queuedBytes += data.length + if (!opts.congested) { + return true + } + return queuedBytes < SINK_HIGH_WATER_MARK + }, + { + waitWriteDrain: (cb: () => void) => { + drainWaiters.add(cb) + fireDrainIfIdle() + } + } + ) + relayFeed = (data: Buffer) => dispatcher.feed(data) + + const deliverAll = (): void => { + while (outQueue.length > 0) { + const buf = outQueue.shift()! + queuedBytes -= buf.length + for (const cb of clientDataCallbacks) { + cb(buf) + } + } + fireDrainIfIdle() + } + + let autoDeliverTimer: ReturnType | null = null + const startAutoDeliver = (): void => { + if (autoDeliverTimer) { + return + } + autoDeliverTimer = setInterval(deliverAll, 1) + } + + const context = new RelayContext() + const fsHandler = new FsHandler(dispatcher, context) + const mux = new SshChannelMultiplexer(clientTransport) + + return { + mux, + dispatcher, + fsHandler, + queuedBytes: () => queuedBytes, + deliverAll, + startAutoDeliver, + dispose: () => { + if (autoDeliverTimer) { + clearInterval(autoDeliverTimer) + } + mux.dispose() + dispatcher.dispose() + fsHandler.dispose() + } + } +} + +describe('fs.readFileStream vs pty.data echo head-of-line blocking', () => { + let tmpDir: string + + beforeEach(() => { + tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-stream-hol-')) + }) + + afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }) + }) + + it('bounds bulk bytes queued ahead of a pty echo when the channel is congested', async () => { + const harness = createHarness({ congested: true }) + try { + const filePath = path.join(tmpDir, 'big.png') + const original = randomBytes(3 * 1024 * 1024) // 12 chunks + writeFileSync(filePath, original) + + // Relay-side fake PTY: echoes input back immediately, mirroring + // PtyHandler's interactive fast path (dispatcher.notify on echo). + let queuedBytesAheadOfEcho = -1 + harness.dispatcher.onNotification('pty.data', (params) => { + queuedBytesAheadOfEcho = harness.queuedBytes() + harness.dispatcher.notify('pty.data', { id: params.id, data: params.data }) + }) + + const readPromise = readFileViaStream(harness.mux, filePath) + // Deliver until the client has the stream metadata, then congest fully. + await waitUntil(() => harness.queuedBytes() > 0, 'metadata response queued') + harness.deliverAll() + // Let the pump run to whatever bound it enforces (pre-fix: whole file). + await waitUntil(() => harness.queuedBytes() > 0, 'first chunk queued') + await waitUntilSettled(() => harness.queuedBytes()) + + // Type one key while the stream is congested. + harness.mux.notify('pty.data', { id: 'pty-1', data: 'x' }) + await waitUntil(() => queuedBytesAheadOfEcho >= 0, 'echo emitted by relay') + + // The echo must not sit behind an unbounded chunk backlog: at most one + // in-flight bulk frame (the write that saturated the sink) plus slack. + expect(queuedBytesAheadOfEcho).toBeLessThan(2 * FRAMED_CHUNK_BYTES) + + // Un-congest: the stream must still complete with intact content. + harness.startAutoDeliver() + const result = await readPromise + expect(Buffer.from(result.content, 'base64').equals(original)).toBe(true) + } finally { + harness.dispose() + } + }, 30_000) + + it('caps in-flight chunks via the fs.streamAck credit window when the client stalls', async () => { + const harness = createHarness({ congested: false }) + try { + const filePath = path.join(tmpDir, 'big.png') + writeFileSync(filePath, randomBytes(3 * 1024 * 1024)) // 12 chunks + + const receivedSeqs: number[] = [] + harness.mux.onNotificationByMethod('fs.streamChunk', (params) => { + receivedSeqs.push(params.seq as number) + }) + let streamEnded = false + harness.mux.onNotificationByMethod('fs.streamEnd', () => { + streamEnded = true + }) + + harness.startAutoDeliver() + // Raw ack-capable request without sending any acks: models a client + // whose main thread is too busy to process chunks. + const metadata = (await harness.mux.request('fs.readFileStream', { + filePath, + flowControl: 'ack' + })) as { streamId: number } + + await waitUntil(() => receivedSeqs.length > 0, 'first chunk received') + await waitUntilSettled(() => receivedSeqs.length) + + // Without acks the relay must stop at the credit window, not flood + // the remaining chunks. + expect(receivedSeqs.length).toBeLessThanOrEqual(5) + expect(streamEnded).toBe(false) + + // Acking releases the window and the stream completes. + const totalChunks = 12 + for (let seq = 0; seq < totalChunks; seq += 1) { + harness.mux.notify('fs.streamAck', { streamId: metadata.streamId, seq }) + } + await waitUntil(() => streamEnded, 'stream completed after acks') + expect(receivedSeqs).toEqual(Array.from({ length: totalChunks }, (_, i) => i)) + } finally { + harness.dispose() + } + }, 30_000) + + it('still delivers the full stream to legacy clients that never ack', async () => { + const harness = createHarness({ congested: false }) + try { + const filePath = path.join(tmpDir, 'legacy.png') + const original = randomBytes(1024 * 1024 + 12345) + writeFileSync(filePath, original) + + const chunks = new Map() + let streamEnded = false + harness.mux.onNotificationByMethod('fs.streamChunk', (params) => { + chunks.set(params.seq as number, Buffer.from(params.data as string, 'base64')) + }) + harness.mux.onNotificationByMethod('fs.streamEnd', () => { + streamEnded = true + }) + + harness.startAutoDeliver() + // Legacy request shape: no flowControl param, and no acks ever sent. + await harness.mux.request('fs.readFileStream', { filePath }) + await waitUntil(() => streamEnded, 'legacy stream completed') + + const reassembled = Buffer.concat( + Array.from(chunks.entries()) + .sort(([a], [b]) => a - b) + .map(([, buf]) => buf) + ) + expect(reassembled.equals(original)).toBe(true) + } finally { + harness.dispose() + } + }, 30_000) +}) diff --git a/src/relay/fs-stream-registry.ts b/src/relay/fs-stream-registry.ts index e0bf2a5ed6f..2bca76c9495 100644 --- a/src/relay/fs-stream-registry.ts +++ b/src/relay/fs-stream-registry.ts @@ -1,9 +1,14 @@ import type { FileHandle } from 'node:fs/promises' -import { MAX_CONCURRENT_STREAMS, RelayErrorCode } from './protocol' +import { MAX_CONCURRENT_STREAMS, RelayErrorCode, STREAM_ACK_STALL_RECHECK_MS } from './protocol' type StreamEntry = { handle: FileHandle aborted: boolean + /** Highest chunk seq the client acknowledged (in-order; -1 = none yet). */ + ackedThroughSeq: number + /** Pumps parked on the ack credit window. Woken by acks, abort, release, + * and a periodic stall recheck so a vanished client cannot strand a pump. */ + ackWaiters: Set<() => void> } export class TooManyStreamsError extends Error { @@ -22,7 +27,12 @@ export class RelayStreamRegistry { throw new TooManyStreamsError() } const streamId = this.nextId++ - this.streams.set(streamId, { handle, aborted: false }) + this.streams.set(streamId, { + handle, + aborted: false, + ackedThroughSeq: -1, + ackWaiters: new Set() + }) return streamId } @@ -30,6 +40,7 @@ export class RelayStreamRegistry { const entry = this.streams.get(streamId) if (entry) { entry.aborted = true + this.wakeAckWaiters(entry) } } @@ -41,11 +52,65 @@ export class RelayStreamRegistry { return this.streams.get(streamId) } + recordAck(streamId: number, seq: number): void { + const entry = this.streams.get(streamId) + if (!entry || typeof seq !== 'number' || !Number.isFinite(seq)) { + return + } + if (seq > entry.ackedThroughSeq) { + entry.ackedThroughSeq = seq + } + this.wakeAckWaiters(entry) + } + + ackedThroughSeq(streamId: number): number { + return this.streams.get(streamId)?.ackedThroughSeq ?? Number.MAX_SAFE_INTEGER + } + + /** Resolves on the next ack/abort/release for this stream, or after the + * stall-recheck interval so callers can re-evaluate staleness. */ + waitForAck(streamId: number): Promise { + const entry = this.streams.get(streamId) + if (!entry || entry.aborted) { + return Promise.resolve() + } + return new Promise((resolve) => { + let settled = false + const timer = setTimeout(() => finish(), STREAM_ACK_STALL_RECHECK_MS) + timer.unref?.() + const finish = (): void => { + if (settled) { + return + } + settled = true + clearTimeout(timer) + entry.ackWaiters.delete(finish) + resolve() + } + entry.ackWaiters.add(finish) + }) + } + + /** Wake every parked pump (all streams) so it re-checks staleness — used + * when a client detaches and its acks will never arrive. */ + wakeAllAckWaiters(): void { + for (const entry of this.streams.values()) { + this.wakeAckWaiters(entry) + } + } + + private wakeAckWaiters(entry: StreamEntry): void { + for (const waiter of Array.from(entry.ackWaiters)) { + waiter() + } + } + async release(streamId: number): Promise { const entry = this.streams.get(streamId) if (!entry) { return } + this.wakeAckWaiters(entry) this.streams.delete(streamId) try { await entry.handle.close() diff --git a/src/relay/protocol.ts b/src/relay/protocol.ts index 66818efaf61..267f6ef480f 100644 --- a/src/relay/protocol.ts +++ b/src/relay/protocol.ts @@ -48,6 +48,17 @@ export const TIMEOUT_MS = 20_000 export const STREAM_CHUNK_SIZE = 256 * 1024 export const MAX_CONCURRENT_STREAMS = 16 +/** Max unacked fs.streamChunk frames in flight per stream when the client + * requested `flowControl: 'ack'`. Bounds how many bulk bytes an interactive + * pty.data frame can queue behind on the shared SSH channel (~1MB raw) while + * keeping the pipe full across one ack round-trip on fast links. */ +export const STREAM_ACK_WINDOW_CHUNKS = 4 + +/** Safety-valve poll interval for a pump stalled on acks: re-checks stream + * abort/staleness so a client that vanished mid-stream cannot park the pump + * (and its open file handle) forever. */ +export const STREAM_ACK_STALL_RECHECK_MS = 1_000 + export const RelayErrorCode = { TooManyStreams: -33006, StreamProtocolError: -33007 @@ -109,7 +120,12 @@ export function encodeKeepAliveFrame(id: number, ack: number): Buffer { } export class FrameDecoder { - private buffer = Buffer.alloc(0) + // Why: feed() sits on the hot receive path. Rebuilding one contiguous + // buffer per feed (Buffer.concat) re-copies every already-buffered byte for + // each incoming chunk — O(n²) per large frame. A chunk list assembles each + // frame exactly once instead. + private chunks: Buffer[] = [] + private bufferedLength = 0 private onFrame: (frame: DecodedFrame) => void private onError: ((err: Error) => void) | null @@ -119,23 +135,32 @@ export class FrameDecoder { } feed(chunk: Buffer | Uint8Array): void { - this.buffer = Buffer.concat([this.buffer, chunk]) + const buf = Buffer.isBuffer(chunk) + ? chunk + : Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength) + if (buf.length > 0) { + this.chunks.push(buf) + this.bufferedLength += buf.length + } - while (this.buffer.length >= HEADER_LENGTH) { - const length = this.buffer.readUInt32BE(9) + while (this.bufferedLength >= HEADER_LENGTH) { + const header = this.peekBytes(HEADER_LENGTH) + const length = header.readUInt32BE(9) const totalLength = HEADER_LENGTH + length + if (this.bufferedLength < totalLength) { + // Not fully received yet (also holds oversized frames until they can + // be skipped whole, keeping the decoder synchronized). + break + } + if (length > MAX_MESSAGE_SIZE) { // Why: Throwing here would leave the buffer in a partially consumed // state — subsequent feed() calls would try to parse the leftover // payload bytes as a new header, corrupting every future frame. // Instead we skip the entire oversized frame so the decoder stays // synchronized with the stream. - if (this.buffer.length < totalLength) { - // Haven't received the full oversized payload yet; wait for more data. - break - } - this.buffer = this.buffer.subarray(totalLength) + this.discardBytes(totalLength) const err = new Error(`Frame payload too large: ${length} bytes — discarded`) if (this.onError) { this.onError(err) @@ -145,23 +170,20 @@ export class FrameDecoder { continue } - if (this.buffer.length < totalLength) { - break - } - + const framed = this.takeBytes(totalLength) const frame: DecodedFrame = { - type: this.buffer[0], - id: this.buffer.readUInt32BE(1), - ack: this.buffer.readUInt32BE(5), - payload: this.buffer.subarray(HEADER_LENGTH, totalLength) + type: framed[0], + id: framed.readUInt32BE(1), + ack: framed.readUInt32BE(5), + payload: framed.subarray(HEADER_LENGTH, totalLength) } - this.buffer = this.buffer.subarray(totalLength) this.onFrame(frame) } } reset(): void { - this.buffer = Buffer.alloc(0) + this.chunks = [] + this.bufferedLength = 0 } // Why: at the handshake → dispatcher transition, the next consumer must @@ -169,10 +191,74 @@ export class FrameDecoder { // frame. This returns and clears the decoder's internal residue so the // caller can hand it to the dispatcher (or stdout pipe) without loss. drain(): Buffer { - const out = this.buffer - this.buffer = Buffer.alloc(0) + const out = + this.chunks.length === 1 ? this.chunks[0] : Buffer.concat(this.chunks, this.bufferedLength) + this.reset() return out } + + /** View of the first `count` buffered bytes without consuming them. */ + private peekBytes(count: number): Buffer { + const first = this.chunks[0] + if (first.length >= count) { + return first + } + const out = Buffer.allocUnsafe(count) + let copied = 0 + for (const part of this.chunks) { + copied += part.copy(out, copied, 0, Math.min(part.length, count - copied)) + if (copied >= count) { + break + } + } + return out + } + + /** Consume and return the first `count` buffered bytes (single copy). */ + private takeBytes(count: number): Buffer { + const first = this.chunks[0] + if (first.length === count) { + this.chunks.shift() + this.bufferedLength -= count + return first + } + if (first.length > count) { + this.chunks[0] = first.subarray(count) + this.bufferedLength -= count + return first.subarray(0, count) + } + const out = Buffer.allocUnsafe(count) + let copied = 0 + while (copied < count) { + const part = this.chunks[0] + const take = Math.min(part.length, count - copied) + part.copy(out, copied, 0, take) + copied += take + if (take === part.length) { + this.chunks.shift() + } else { + this.chunks[0] = part.subarray(take) + } + } + this.bufferedLength -= count + return out + } + + /** Consume the first `count` buffered bytes without assembling them. */ + private discardBytes(count: number): void { + let remaining = count + while (remaining > 0) { + const part = this.chunks[0] + if (part.length <= remaining) { + this.chunks.shift() + remaining -= part.length + } else { + this.chunks[0] = part.subarray(remaining) + remaining = 0 + } + } + this.bufferedLength -= count + } } export function parseJsonRpcMessage(payload: Buffer): JsonRpcMessage { diff --git a/src/relay/pty-handler.test.ts b/src/relay/pty-handler.test.ts index aa35e99571a..7d5198e87a9 100644 --- a/src/relay/pty-handler.test.ts +++ b/src/relay/pty-handler.test.ts @@ -14,7 +14,10 @@ import { const { mockPtySpawn, mockPtyInstance } = vi.hoisted(() => ({ mockPtySpawn: vi.fn(), mockPtyInstance: { - pid: 12345, + // Why: attach now proves the backing pid is alive before replaying, so the + // default managed PTY must report a live pid. Reuse the test runner's own + // pid — always alive — so unrelated attach tests are not seen as dead. + pid: process.pid, onData: vi.fn(), onExit: vi.fn(), write: vi.fn(), @@ -609,6 +612,71 @@ describe('PtyHandler', () => { } ) + it('reports not-found and reaps a reattach whose backing shell is dead', async () => { + // Why: a lingering managed entry whose child died without an onExit would + // otherwise attach-succeed with an empty replay and strand the pane on a + // black shell. A provably-dead pid must surface as not-found so the SSH + // provider maps it to SSH_SESSION_EXPIRED and the pane respawns fresh. + let onExitCb: ((evt: { exitCode: number }) => void) | undefined + mockPtySpawn.mockReturnValue({ + ...mockPtyInstance, + onData: vi.fn(), + onExit: vi.fn((cb: (evt: { exitCode: number }) => void) => { + onExitCb = cb + }) + }) + const exits: { id: string; paneKey?: string }[] = [] + handler.setExitListener((evt) => exits.push(evt)) + + await dispatcher.callRequest('pty.spawn', { env: { ORCA_PANE_KEY: 'tab-dead:0' } }) + expect(handler.activePtyCount).toBe(1) + expect(onExitCb).toBeDefined() + + const aliveSpy = vi.spyOn(ptyShellUtils, 'isProcessAlive').mockReturnValue(false) + try { + await expect( + dispatcher.callRequest('pty.attach', { id: 'pty-1', suppressReplayNotification: true }) + ).rejects.toThrow('PTY "pty-1" not found') + } finally { + aliveSpy.mockRestore() + } + + // The stale entry is reaped: cache-eviction observers fire and the map slot + // is freed so a later attach also cleanly reports not-found. + expect(exits).toEqual([{ id: 'pty-1', paneKey: 'tab-dead:0' }]) + expect(handler.activePtyCount).toBe(0) + await expect( + dispatcher.callRequest('pty.attach', { id: 'pty-1', suppressReplayNotification: true }) + ).rejects.toThrow('PTY "pty-1" not found') + }) + + it('replays for a reattach whose backing shell is still alive', async () => { + // Guard the other direction: a live pid must never be reaped, so a quiet + // but live session still returns its buffered replay on reattach. + let dataCallback: ((data: string) => void) | undefined + mockPtySpawn.mockReturnValue({ + ...mockPtyInstance, + onData: vi.fn((cb: (data: string) => void) => { + dataCallback = cb + }), + onExit: vi.fn() + }) + await dispatcher.callRequest('pty.spawn', {}) + dataCallback?.('prompt$ ') + + const aliveSpy = vi.spyOn(ptyShellUtils, 'isProcessAlive').mockReturnValue(true) + try { + const result = await dispatcher.callRequest('pty.attach', { + id: 'pty-1', + suppressReplayNotification: true + }) + expect(result).toEqual({ replay: 'prompt$ ' }) + } finally { + aliveSpy.mockRestore() + } + expect(handler.activePtyCount).toBe(1) + }) + it('terminates spawned PTY when request becomes stale before response', async () => { const killSpy = vi.fn() const term = { ...mockPtyInstance, kill: killSpy, onData: vi.fn(), onExit: vi.fn() } diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index 7e94eb9a74f..3a812415909 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -10,6 +10,7 @@ import { resolveProcessCwd, processHasChildren, getForegroundProcessName, + isProcessAlive, listShellProfiles } from './pty-shell-utils' import { getRelayShellLaunchConfig } from './pty-shell-launch' @@ -692,6 +693,22 @@ export class PtyHandler { throw new Error(`PTY "${id}" not found`) } + // Why: a reattach can arrive for a relay PTY whose backing shell already + // died without node-pty delivering onExit (e.g. the child was reaped out of + // band while the SSH channel was down). The map entry lingers, so attach + // would otherwise "succeed" with an empty replay and strand the reattached + // pane on a black, unresponsive shell. Prove liveness here; if the pid is + // provably gone, reap the stale entry and report not-found so the caller + // drops the dead lease and spawns fresh — the same recovery path an expired + // grace window already takes. + if (managed.pty.pid && !isProcessAlive(managed.pty.pid)) { + this.notifyExitListener(managed) + disposeManagedPty(managed) + this.ptys.delete(id) + this.clearPtyFlowState(id) + throw new Error(`PTY "${id}" not found`) + } + // Why: PTY ids are a per-relay-process counter (pty-1, pty-2, …). When the // relay changes generation — an app update deploys a new content-hashed // relay dir, or a grace-expired relay restarts — the counter resets, so an diff --git a/src/relay/pty-shell-utils.test.ts b/src/relay/pty-shell-utils.test.ts index e2b7adcadd0..544e1509c12 100644 --- a/src/relay/pty-shell-utils.test.ts +++ b/src/relay/pty-shell-utils.test.ts @@ -11,6 +11,7 @@ vi.mock('child_process', () => ({ import { resetProcessTableSnapshotForTests } from '../shared/process-table-snapshot' import { getForegroundProcessName, + isProcessAlive, resolveDefaultCwd, resolveWindowsDefaultShell } from './pty-shell-utils' @@ -51,6 +52,40 @@ beforeEach(() => { resetProcessTableSnapshotForTests() }) +describe('isProcessAlive', () => { + it('reports the test runner process as alive', () => { + expect(isProcessAlive(process.pid)).toBe(true) + }) + + it('reports a process as dead ONLY on ESRCH', () => { + // Why: attach() reaps a lingering managed PTY based on this, so a false + // positive would kill a live remote shell. Only "no such process" counts. + const spy = vi.spyOn(process, 'kill').mockImplementation(() => { + const err = new Error('no such process') as NodeJS.ErrnoException + err.code = 'ESRCH' + throw err + }) + try { + expect(isProcessAlive(2147483646)).toBe(false) + } finally { + spy.mockRestore() + } + }) + + it('treats an unsignalable process (EPERM) as alive', () => { + const spy = vi.spyOn(process, 'kill').mockImplementation(() => { + const err = new Error('operation not permitted') as NodeJS.ErrnoException + err.code = 'EPERM' + throw err + }) + try { + expect(isProcessAlive(1)).toBe(true) + } finally { + spy.mockRestore() + } + }) +}) + describe('resolveWindowsDefaultShell', () => { it('uses an existing SHELL override when one is provided', () => { expect( diff --git a/src/relay/pty-shell-utils.ts b/src/relay/pty-shell-utils.ts index 6a9c4507372..87035c9510d 100644 --- a/src/relay/pty-shell-utils.ts +++ b/src/relay/pty-shell-utils.ts @@ -147,6 +147,19 @@ export async function processHasChildren(pid: number): Promise { } } +// Why: signal 0 probes existence without delivering a signal. Only ESRCH ("no +// such process") proves the pid is gone; EPERM means it exists but is +// unsignalable, so treat every non-ESRCH outcome as alive. Kept conservative so +// a liveness check can only ever declare a *provably* dead process dead. +export function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch (err) { + return (err as NodeJS.ErrnoException).code !== 'ESRCH' + } +} + function collectDescendants( rows: ProcessTableRow[], rootPid: number diff --git a/src/relay/relay.ts b/src/relay/relay.ts index c930a244b65..99225227903 100644 --- a/src/relay/relay.ts +++ b/src/relay/relay.ts @@ -373,15 +373,44 @@ async function main(): Promise { // write to a dead pipe, silently failing or throwing EPIPE. When a // socket client reconnects, setWrite swaps the callback to the socket. let stdoutAlive = true - const dispatcher = new RelayDispatcher((data) => { - if (stdoutAlive) { + // Why: one-shot waiters parked by the dispatcher's bulk lane when stdout + // reports saturation (write() === false). Flushed on 'drain' and on every + // stdout-death path so a stalled file-stream pump never outlives the pipe + // it was waiting on. + const stdoutDrainWaiters = new Set<() => void>() + const flushStdoutDrainWaiters = (): void => { + for (const cb of Array.from(stdoutDrainWaiters)) { + stdoutDrainWaiters.delete(cb) + cb() + } + } + process.stdout.on('drain', flushStdoutDrainWaiters) + const dispatcher = new RelayDispatcher( + (data) => { + if (!stdoutAlive) { + return + } try { - process.stdout.write(data) + // Why: surface Node's backpressure signal to the dispatcher so bulk + // frames (fs.streamChunk) wait for drain instead of queueing megabytes + // ahead of interactive pty.data frames on the SSH channel. + return process.stdout.write(data) } catch { stdoutAlive = false + flushStdoutDrainWaiters() + return undefined + } + }, + { + waitWriteDrain: (cb) => { + if (!stdoutAlive) { + cb() + return + } + stdoutDrainWaiters.add(cb) } } - }) + ) const context = new RelayContext() @@ -680,11 +709,35 @@ async function main(): Promise { ) cancelGrace('socket client accepted') - const clientId = dispatcher.attachClient((data) => { - if (!sock.destroyed) { - sock.write(data) + // Why: same backpressure surface as the stdout sink — bulk frames wait + // for the socket to drain so they cannot bury interactive PTY frames. + const sockDrainWaiters = new Set<() => void>() + const flushSockDrainWaiters = (): void => { + for (const cb of Array.from(sockDrainWaiters)) { + sockDrainWaiters.delete(cb) + cb() } - }) + } + sock.on('drain', flushSockDrainWaiters) + sock.on('close', flushSockDrainWaiters) + sock.on('error', flushSockDrainWaiters) + const clientId = dispatcher.attachClient( + (data) => { + if (!sock.destroyed) { + return sock.write(data) + } + return undefined + }, + { + waitWriteDrain: (cb) => { + if (sock.destroyed) { + cb() + return + } + sockDrainWaiters.add(cb) + } + } + ) socketClients.set(sock, clientId) // Why: bytes that arrived in the same TCP send as the handshake frame @@ -889,6 +942,7 @@ async function main(): Promise { // before the grace period starts. process.stdout.on('error', () => { stdoutAlive = false + flushStdoutDrainWaiters() dispatcher.invalidateClient() }) @@ -934,6 +988,7 @@ async function main(): Promise { // dead so the primary client write callback becomes a no-op while // socket clients, if any, keep their own live transports. stdoutAlive = false + flushStdoutDrainWaiters() dispatcher.invalidateClient() if (socketClients.size === 0) { startGrace('stdin ended') @@ -942,6 +997,7 @@ async function main(): Promise { process.stdin.on('error', () => { stdoutAlive = false + flushStdoutDrainWaiters() dispatcher.invalidateClient() if (socketClients.size === 0) { startGrace('stdin error') diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 08f39a8b61c..b77b58a5c1a 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -101,6 +101,8 @@ --color-status-success: var(--status-success); --color-status-success-background: var(--status-success-background); --color-status-success-border: var(--status-success-border); + --color-tab-group-split-divider: var(--tab-group-split-divider); + --color-tab-group-split-divider-strong: var(--tab-group-split-divider-strong); --color-git-graph-ref: var(--git-graph-ref); --color-git-graph-remote-ref: var(--git-graph-remote-ref); --color-git-graph-base-ref: var(--git-graph-base-ref); @@ -202,6 +204,11 @@ --git-graph-lane-4: #40b0a6; --git-graph-lane-5: #b66dff; --annotation-highlight: #f59e0b; + /* Why: tab-group splits use a dedicated divider — `--border` is too faint on + card/editor surfaces when panes are side-by-side or stacked. Default must + meet >=3:1 against `--card`, not only the hover/drag strong state. */ + --tab-group-split-divider: #868690; + --tab-group-split-divider-strong: #71717a; } /* ── Dark Mode ───────────────────────────────────────── */ @@ -285,6 +292,9 @@ --git-graph-lane-4: #40b0a6; --git-graph-lane-5: #b66dff; --annotation-highlight: #fbbf24; + /* Brighter than terminal defaults so the always-visible line clears 3:1 on `--card`. */ + --tab-group-split-divider: #71717a; + --tab-group-split-divider-strong: #a1a1aa; } .linear-priority-bars { @@ -618,6 +628,52 @@ overflow: hidden; } +/* Tab-group split resize handle — wide hit area, visible center line. */ +.tab-group-split-resize-handle { + flex-shrink: 0; + position: relative; + background: transparent; +} + +.tab-group-split-resize-handle.is-vertical { + width: 6px; + cursor: col-resize; +} + +.tab-group-split-resize-handle.is-horizontal { + height: 6px; + cursor: row-resize; +} + +.tab-group-split-resize-handle::after { + content: ''; + position: absolute; + border-radius: 1px; + background: var(--tab-group-split-divider); + transition: background 100ms ease; +} + +.tab-group-split-resize-handle.is-vertical::after { + top: 0; + bottom: 0; + left: 50%; + width: 3px; + transform: translateX(-50%); +} + +.tab-group-split-resize-handle.is-horizontal::after { + left: 0; + right: 0; + top: 50%; + height: 3px; + transform: translateY(-50%); +} + +.tab-group-split-resize-handle:hover::after, +.tab-group-split-resize-handle.is-dragging::after { + background: var(--tab-group-split-divider-strong); +} + /* ── Titlebar ────────────────────────────────────────── */ /* Why: Radix portals (dropdowns, popovers, dialogs, sheets) render at the @@ -1904,13 +1960,14 @@ text-underline-offset: 2px; } -/* Why: Monaco owns Peek References scrollables and auto-hides faded needed - vertical thumbs while the widget stays open; revalidate these classes on upgrade. */ +/* Why: Monaco's faded scrollbar uses the class `invisible`, which collides with + Tailwind's `.invisible` utility (visibility: hidden) — so also restore visibility. */ .monaco-editor .reference-zone-widget .monaco-scrollable-element > .scrollbar.vertical.invisible.fade { opacity: 1; + visibility: visible; pointer-events: auto; } diff --git a/src/renderer/src/components/CodexRestartChip.tsx b/src/renderer/src/components/CodexRestartChip.tsx index fa644f79df5..960703e8759 100644 --- a/src/renderer/src/components/CodexRestartChip.tsx +++ b/src/renderer/src/components/CodexRestartChip.tsx @@ -1,7 +1,9 @@ import { useEffect, useId, useMemo, useRef, useState } from 'react' +import { useShallow } from 'zustand/react/shallow' import { RefreshCw } from 'lucide-react' import { Button } from '@/components/ui/button' import { useAppStore } from '../store' +import { selectCodexRestartInputs } from './codex-restart-chip-inputs' import { translate } from '@/i18n/i18n' import { shouldFocusMobileDriverAction } from './terminal-pane/mobile-driver-overlay-focus' import { @@ -73,8 +75,14 @@ export default function CodexRestartChip({ worktreeId: string }): React.JSX.Element | null { const tabs = useAppStore((s) => s.tabsByWorktree[worktreeId] ?? EMPTY_TABS) - const ptyIdsByTabId = useAppStore((s) => s.ptyIdsByTabId) - const codexRestartNoticeByPtyId = useAppStore((s) => s.codexRestartNoticeByPtyId) + // Why: both of these maps churn on unrelated pty lifecycle events (ptyIdsByTabId + // on attach/detach; codexRestartNoticeByPtyId is re-spread even when empty on + // pty teardown), so subscribe to them only while a restart notice actually + // exists. Otherwise this per-worktree chip re-rendered on every pty event to + // compute "no notice → render nothing". See codex-restart-chip-inputs. + const { ptyIdsByTabId, codexRestartNoticeByPtyId } = useAppStore( + useShallow(selectCodexRestartInputs) + ) const staleWorktreePtyIds = useMemo( () => collectStalePtyIdsForTabs({ diff --git a/src/renderer/src/components/GitHubItemDialog.tsx b/src/renderer/src/components/GitHubItemDialog.tsx index 863928204b0..5d45f1f78ea 100644 --- a/src/renderer/src/components/GitHubItemDialog.tsx +++ b/src/renderer/src/components/GitHubItemDialog.tsx @@ -3848,7 +3848,21 @@ function PRActionsPanel({ const actionItem = { ...item, state: localState } const mergePresentation = presentGitHubPRMergeState(actionItem) const mergeMethods = resolveGitHubPRMergeMethods(actionItem.mergeMethodSettings) - const sourceSettings = getTaskSourceRuntimeSettings(sourceContext) + const repoOwnerSettings = useAppStore( + useShallow((s) => getSettingsForRepoRuntimeOwner(s, repoId ?? item.repoId ?? null)) + ) + const sourceSettings = useMemo(() => { + if (sourceContext?.provider !== 'github') { + return repoOwnerSettings + } + const sourceRuntimeSettings = getTaskSourceRuntimeSettings(sourceContext) + return sourceRuntimeSettings.activeRuntimeEnvironmentId + ? ({ + ...repoOwnerSettings, + ...sourceRuntimeSettings + } as typeof repoOwnerSettings) + : repoOwnerSettings + }, [repoOwnerSettings, sourceContext]) const mergeTarget = getActiveRuntimeTarget(sourceSettings) const canMutateWithRepoContext = !!repoPath || !!projectOrigin || mergeTarget.kind === 'environment' diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index 1bb4108e8b1..a88abafdae7 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -57,7 +57,7 @@ import { import TabGroupSplitLayout from './tab-group/TabGroupSplitLayout' import AiVaultSessionDropLayer from './tab-group/AiVaultSessionDropLayer' import { shouldAutoCreateInitialTerminal } from './terminal/initial-terminal' -import { shouldRepairActiveTerminalTab } from './terminal/active-terminal-repair' +import { resolveRepairedActiveTerminalTabId } from './terminal/active-terminal-repair' import { scheduleBackgroundTerminalWorktreeMeasure } from './terminal/background-terminal-worktree-visibility' import { getEffectiveLayoutForWorktree as getEffectiveLayout, @@ -252,6 +252,7 @@ function Terminal(): React.JSX.Element | null { const pendingStartupByTabId = useAppStore((s) => s.pendingStartupByTabId) const terminalParkingEnabled = useAppStore((s) => s.settings?.terminalHiddenViewParking !== false) const activeTabId = useAppStore((s) => s.activeTabId) + const activeTabIdByWorktree = useAppStore((s) => s.activeTabIdByWorktree) const createTab = useAppStore((s) => s.createTab) const closeTab = useAppStore((s) => s.closeTab) const setActiveTab = useAppStore((s) => s.setActiveTab) @@ -720,19 +721,38 @@ function Terminal(): React.JSX.Element | null { }, [queueEditorCloseRequests]) useEffect(() => { - if (!shouldRepairActiveTerminalTab({ activeTabType, activeTabId, tabs })) { + const rememberedTabId = renderedActiveWorktreeId + ? (activeTabIdByWorktree[renderedActiveWorktreeId] ?? null) + : null + // Why: prefer the worktree's remembered active tab over the first tab so a + // repair firing on a transient worktree-switch render restores the tab the + // user left on instead of permanently resetting the selection to Terminal 1. + const repairedTabId = resolveRepairedActiveTerminalTabId({ + activeTabType, + activeTabId, + rememberedTabId, + tabs + }) + if (!repairedTabId) { return } // Why: mutating Zustand during render trips React's "Cannot update a // component while rendering a different component" warning. Keep the repair // terminal-only so inactive CLI-created tabs cannot steal editor/browser focus. - setActiveTab(tabs[0].id) + setActiveTab(repairedTabId) // Why: `tabs` is intentionally the dependency here because the repair must // react to tab-order/content changes, not just scalar IDs. The list comes // from Zustand selectors and is small in practice, so this explicit repair // effect is preferred over duplicating reconciliation state. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [activeTabId, activeTabType, setActiveTab, tabs]) + }, [ + activeTabId, + activeTabType, + setActiveTab, + tabs, + activeTabIdByWorktree, + renderedActiveWorktreeId + ]) // Track which worktrees have been activated during this app session. // Only mount TerminalPanes for visited worktrees to prevent mass PTY diff --git a/src/renderer/src/components/codex-restart-chip-inputs.test.ts b/src/renderer/src/components/codex-restart-chip-inputs.test.ts new file mode 100644 index 00000000000..ad6412efd16 --- /dev/null +++ b/src/renderer/src/components/codex-restart-chip-inputs.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest' +import { shallow } from 'zustand/shallow' +import { + EMPTY_CODEX_RESTART_INPUTS, + selectCodexRestartInputs, + type CodexRestartInputsState +} from './codex-restart-chip-inputs' + +describe('selectCodexRestartInputs', () => { + it('returns the frozen empty bundle while no restart notice exists', () => { + const state: CodexRestartInputsState = { + ptyIdsByTabId: { 'tab-1': ['pty-1'] }, + codexRestartNoticeByPtyId: {} + } + expect(selectCodexRestartInputs(state)).toBe(EMPTY_CODEX_RESTART_INPUTS) + + // Churning EITHER map while no notice exists must NOT change the selected + // reference, so a useShallow subscription skips the re-render. This covers + // the pty-teardown path that re-spreads codexRestartNoticeByPtyId even empty. + const churnedPty: CodexRestartInputsState = { + ...state, + ptyIdsByTabId: { 'tab-1': ['pty-2'], 'tab-2': ['pty-3'] } + } + const churnedNotice: CodexRestartInputsState = { + ptyIdsByTabId: state.ptyIdsByTabId, + codexRestartNoticeByPtyId: {} // fresh empty object, same as a teardown re-spread + } + expect(selectCodexRestartInputs(churnedPty)).toBe(EMPTY_CODEX_RESTART_INPUTS) + expect(selectCodexRestartInputs(churnedNotice)).toBe(EMPTY_CODEX_RESTART_INPUTS) + expect(shallow(selectCodexRestartInputs(state), selectCodexRestartInputs(churnedNotice))).toBe( + true + ) + }) + + it('exposes both live maps the instant a restart notice exists', () => { + const ptyIdsByTabId = { 'tab-1': ['pty-1'] } + const codexRestartNoticeByPtyId = { 'pty-1': {} as never } + const state: CodexRestartInputsState = { ptyIdsByTabId, codexRestartNoticeByPtyId } + const selected = selectCodexRestartInputs(state) + // Live references pass straight through so the stale-pty memo + notice lookup derive fully. + expect(selected.ptyIdsByTabId).toBe(ptyIdsByTabId) + expect(selected.codexRestartNoticeByPtyId).toBe(codexRestartNoticeByPtyId) + expect(selected).not.toBe(EMPTY_CODEX_RESTART_INPUTS) + }) + + it('shallow-changes only when a live map reference changes while a notice exists', () => { + const ptyIdsByTabId = { 'tab-1': ['pty-1'] } + const s1: CodexRestartInputsState = { + ptyIdsByTabId, + codexRestartNoticeByPtyId: { 'pty-1': {} as never } + } + const r1 = selectCodexRestartInputs(s1) + expect(shallow(r1, selectCodexRestartInputs(s1))).toBe(true) + + const s2: CodexRestartInputsState = { ...s1, ptyIdsByTabId: { 'tab-1': ['pty-9'] } } + expect(shallow(r1, selectCodexRestartInputs(s2))).toBe(false) + }) +}) diff --git a/src/renderer/src/components/codex-restart-chip-inputs.ts b/src/renderer/src/components/codex-restart-chip-inputs.ts new file mode 100644 index 00000000000..eec46f6a8e7 --- /dev/null +++ b/src/renderer/src/components/codex-restart-chip-inputs.ts @@ -0,0 +1,37 @@ +import type { AppState } from '@/store/types' + +export type CodexRestartInputsState = Pick + +export type CodexRestartInputs = CodexRestartInputsState + +// Why: shared frozen bundle returned while no Codex restart notice exists — the +// overwhelmingly common case. CodexRestartChip is mounted once per worktree +// Terminal (visible AND hidden-measurable) and once per split group, and reads +// these two maps only to find panes carrying a restart notice, which appear only +// on a Codex account switch. BOTH churn on unrelated pty lifecycle: +// ptyIdsByTabId gets a fresh identity on every pty register/attach/detach, and +// codexRestartNoticeByPtyId is re-spread into a new object even when empty on the +// pty exit/teardown path (terminals.ts clearTabPtyId). Subscribing to either +// re-rendered every mounted chip on that churn. Gate both behind notice-existence +// so idle chips keep the same reference and stop reacting; behavior is identical +// (no notice -> [] stale ids -> null render). Frozen so the singleton can't be +// mutated. +export const EMPTY_CODEX_RESTART_INPUTS: CodexRestartInputs = Object.freeze({ + ptyIdsByTabId: {}, + codexRestartNoticeByPtyId: {} +}) + +/** + * Expose the pty and restart-notice maps to the chip only while at least one + * Codex restart notice is live; otherwise return a stable frozen bundle so a + * `useShallow` subscription skips re-renders on unrelated pty-lifecycle churn. + */ +export function selectCodexRestartInputs(s: CodexRestartInputsState): CodexRestartInputs { + if (Object.keys(s.codexRestartNoticeByPtyId).length === 0) { + return EMPTY_CODEX_RESTART_INPUTS + } + return { + ptyIdsByTabId: s.ptyIdsByTabId, + codexRestartNoticeByPtyId: s.codexRestartNoticeByPtyId + } +} diff --git a/src/renderer/src/components/github-item-dialog-merge-routing.test.ts b/src/renderer/src/components/github-item-dialog-merge-routing.test.ts new file mode 100644 index 00000000000..8374fe94ba9 --- /dev/null +++ b/src/renderer/src/components/github-item-dialog-merge-routing.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest' +import { + getSettingsForRepoRuntimeOwner, + type RepoRuntimeOwnerState +} from '../lib/repo-runtime-owner' +import { getActiveRuntimeTarget } from '../runtime/runtime-rpc-client' +import type { TaskSourceContext } from '../../../shared/task-source-context' +import { getTaskSourceRuntimeSettings } from '../../../shared/task-source-context' + +// Why: mirrors the merge-routing decision inside GitHubItemDialog's +// PRActionsPanel so a revert to source-only routing (issue #6957) fails here. +// A runtime-owned repo whose GitHub source view is local must still merge on +// the owner runtime, not fall back to local `gh:mergePR`. +function resolveMergeTarget( + state: RepoRuntimeOwnerState, + repoId: string | null, + sourceContext: TaskSourceContext | null +): ReturnType { + const repoOwnerSettings = getSettingsForRepoRuntimeOwner(state, repoId) + const sourceSettings = + sourceContext?.provider !== 'github' + ? repoOwnerSettings + : (() => { + const sourceRuntimeSettings = getTaskSourceRuntimeSettings(sourceContext) + return sourceRuntimeSettings.activeRuntimeEnvironmentId + ? { ...repoOwnerSettings, ...sourceRuntimeSettings } + : repoOwnerSettings + })() + return getActiveRuntimeTarget(sourceSettings) +} + +function githubSource(hostId: TaskSourceContext['hostId']): TaskSourceContext { + return { kind: 'task-source', provider: 'github', projectId: 'p', hostId, repoId: 'repo-1' } +} + +describe('GitHubItemDialog PR merge routing', () => { + const runtimeOwnedRepo: RepoRuntimeOwnerState = { + settings: { activeRuntimeEnvironmentId: null }, + repos: [{ id: 'repo-1', connectionId: null, executionHostId: 'runtime:owner-runtime' }] + } + + it('routes a runtime-owned repo to its owner runtime when the source view is local (#6957)', () => { + expect(resolveMergeTarget(runtimeOwnedRepo, 'repo-1', githubSource('local'))).toEqual({ + kind: 'environment', + environmentId: 'owner-runtime' + }) + }) + + it('routes a runtime-owned repo to its owner runtime when there is no source context', () => { + expect(resolveMergeTarget(runtimeOwnedRepo, 'repo-1', null)).toEqual({ + kind: 'environment', + environmentId: 'owner-runtime' + }) + }) + + it('lets a runtime source override the repo owner when both are runtimes', () => { + expect( + resolveMergeTarget(runtimeOwnedRepo, 'repo-1', githubSource('runtime:source-runtime')) + ).toEqual({ kind: 'environment', environmentId: 'source-runtime' }) + }) + + it('keeps an explicitly-local repo on local IPC even while a runtime is focused', () => { + const localRepo: RepoRuntimeOwnerState = { + settings: { activeRuntimeEnvironmentId: 'focused-runtime' }, + repos: [{ id: 'repo-1', connectionId: null, executionHostId: 'local' }] + } + expect(resolveMergeTarget(localRepo, 'repo-1', githubSource('local'))).toEqual({ + kind: 'local' + }) + }) +}) diff --git a/src/renderer/src/components/github-item-dialog-source-boundary.test.ts b/src/renderer/src/components/github-item-dialog-source-boundary.test.ts index deb287ca2d3..740d65f67cf 100644 --- a/src/renderer/src/components/github-item-dialog-source-boundary.test.ts +++ b/src/renderer/src/components/github-item-dialog-source-boundary.test.ts @@ -91,8 +91,8 @@ describe('GitHubItemDialog source host boundaries', () => { expect(source).toContain('sourceContext,') expect(cacheKeySection).toContain('sourceCacheScope') expect(source).toContain('getTaskSourceCacheScope(sourceContext)') - expect(matchInvalidationSection).toContain( - 'if (removed) {\n workItemDetailsCacheGeneration += 1' + expect(matchInvalidationSection).toMatch( + /if \(removed\) {\s+workItemDetailsCacheGeneration \+= 1/ ) }) @@ -190,7 +190,13 @@ describe('GitHubItemDialog source host boundaries', () => { 'function CommentReactions' ) - expect(actionsSection).toContain('getTaskSourceRuntimeSettings(sourceContext)') + expect(actionsSection).toContain('getSettingsForRepoRuntimeOwner(s, repoId ?? item.repoId') + expect(actionsSection).toContain('...repoOwnerSettings') + expect(actionsSection).toContain( + 'const sourceRuntimeSettings = getTaskSourceRuntimeSettings(sourceContext)' + ) + expect(actionsSection).toContain('sourceRuntimeSettings.activeRuntimeEnvironmentId') + expect(actionsSection).toContain('...sourceRuntimeSettings') expect(actionsSection).toContain('getActiveRuntimeTarget(sourceSettings)') expect(actionsSection).toContain( 'const canMergeWithRepoContext = !!repoPath || mergeTarget.kind ===' diff --git a/src/renderer/src/components/native-chat/native-chat-composer-scope-cache.test.ts b/src/renderer/src/components/native-chat/native-chat-composer-scope-cache.test.ts new file mode 100644 index 00000000000..07867e8a5c4 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-composer-scope-cache.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from 'vitest' +import { + NATIVE_CHAT_COMPOSER_SCOPE_CACHE_MAX, + setBoundedScopeCacheEntry +} from './native-chat-composer-scope-cache' + +describe('setBoundedScopeCacheEntry', () => { + it('bounds the cache with LRU eviction, keeping re-set keys', () => { + const cache = new Map() + setBoundedScopeCacheEntry(cache, 'keep', 1) + + const total = NATIVE_CHAT_COMPOSER_SCOPE_CACHE_MAX + 20 + for (let i = 0; i < total; i += 1) { + setBoundedScopeCacheEntry(cache, `scope-${i}`, i) + if (i % 10 === 0) { + setBoundedScopeCacheEntry(cache, 'keep', 1) + } + } + + expect(cache.size).toBe(NATIVE_CHAT_COMPOSER_SCOPE_CACHE_MAX) + expect(cache.has('scope-0')).toBe(false) // oldest untouched entry evicted + expect(cache.has('keep')).toBe(true) // periodically re-set → retained + expect(cache.has(`scope-${total - 1}`)).toBe(true) // most recent retained + }) + + it('moves a re-set key to most-recent and updates its value', () => { + const cache = new Map() + setBoundedScopeCacheEntry(cache, 'a', 1) + setBoundedScopeCacheEntry(cache, 'b', 2) + setBoundedScopeCacheEntry(cache, 'a', 3) + + expect([...cache.keys()]).toEqual(['b', 'a']) + expect(cache.get('a')).toBe(3) + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-composer-scope-cache.ts b/src/renderer/src/components/native-chat/native-chat-composer-scope-cache.ts new file mode 100644 index 00000000000..7a63f16f9fe --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-composer-scope-cache.ts @@ -0,0 +1,24 @@ +// Shared LRU bound for the native-chat composer's per-scope caches (draft text +// and image attachments), both keyed by `targetPtyId ?? terminalTabId`. The +// caches exist so an in-progress message survives the composer unmounting on a +// TUI/GUI toggle, but a scope key for a permanently-removed pane is never +// revisited, so without a bound its unsent entry would linger for the renderer's +// whole session. delete-then-set keeps the actively-edited scope most-recent so +// eviction only sheds the oldest untouched scopes. +export const NATIVE_CHAT_COMPOSER_SCOPE_CACHE_MAX = 128 + +export function setBoundedScopeCacheEntry( + cache: Map, + scopeKey: string, + value: T +): void { + cache.delete(scopeKey) + cache.set(scopeKey, value) + while (cache.size > NATIVE_CHAT_COMPOSER_SCOPE_CACHE_MAX) { + const oldest = cache.keys().next().value + if (oldest === undefined) { + break + } + cache.delete(oldest) + } +} diff --git a/src/renderer/src/components/native-chat/native-chat-draft-cache.test.ts b/src/renderer/src/components/native-chat/native-chat-draft-cache.test.ts index c12d661e39d..7aa0f658517 100644 --- a/src/renderer/src/components/native-chat/native-chat-draft-cache.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-draft-cache.test.ts @@ -4,6 +4,7 @@ import { readNativeChatDraftCache, writeNativeChatDraftCache } from './native-chat-draft-cache' +import { NATIVE_CHAT_COMPOSER_SCOPE_CACHE_MAX } from './native-chat-composer-scope-cache' afterEach(() => { clearNativeChatDraftCacheForTests() @@ -26,4 +27,21 @@ describe('native-chat draft cache', () => { writeNativeChatDraftCache('pty-1', '') expect(readNativeChatDraftCache('pty-1')).toBe('') }) + + it('bounds the cache so unsent drafts for removed panes cannot accumulate', () => { + writeNativeChatDraftCache('keep', 'hot') + + const total = NATIVE_CHAT_COMPOSER_SCOPE_CACHE_MAX + 40 + for (let i = 0; i < total; i += 1) { + writeNativeChatDraftCache(`scope-${i}`, `draft-${i}`) + if (i % 20 === 0) { + writeNativeChatDraftCache('keep', 'hot') + } + } + + // Oldest untouched draft evicted; the actively-edited and most-recent survive. + expect(readNativeChatDraftCache('scope-0')).toBe('') + expect(readNativeChatDraftCache('keep')).toBe('hot') + expect(readNativeChatDraftCache(`scope-${total - 1}`)).toBe(`draft-${total - 1}`) + }) }) diff --git a/src/renderer/src/components/native-chat/native-chat-draft-cache.ts b/src/renderer/src/components/native-chat/native-chat-draft-cache.ts index 2c230c0a4eb..2a753744458 100644 --- a/src/renderer/src/components/native-chat/native-chat-draft-cache.ts +++ b/src/renderer/src/components/native-chat/native-chat-draft-cache.ts @@ -4,6 +4,8 @@ // the typed-but-unsent draft would be lost on every TUI/GUI round-trip. Mirrors // the attachment cache so both halves of an unsent message survive the toggle. +import { setBoundedScopeCacheEntry } from './native-chat-composer-scope-cache' + const draftCache = new Map() export function readNativeChatDraftCache(scopeKey: string): string { @@ -17,7 +19,8 @@ export function writeNativeChatDraftCache(scopeKey: string, draft: string): void draftCache.delete(scopeKey) return } - draftCache.set(scopeKey, draft) + // LRU-bounded so unsent drafts for permanently-removed panes can't accumulate. + setBoundedScopeCacheEntry(draftCache, scopeKey, draft) } export function clearNativeChatDraftCacheForTests(): void { diff --git a/src/renderer/src/components/native-chat/native-chat-pending.test.ts b/src/renderer/src/components/native-chat/native-chat-pending.test.ts index 5083ab0c518..58393460467 100644 --- a/src/renderer/src/components/native-chat/native-chat-pending.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-pending.test.ts @@ -347,3 +347,42 @@ describe('applyCommandMarkerBoundaries', () => { ).toEqual(['new']) }) }) + +describe('scope-cache key counts stay bounded (memory-leak regression)', () => { + // The per-key arrays were capped at 8, but the KEY count (paneKey/agent/session, + // all ephemeral) was unbounded, so distinct panes/sessions accumulated forever. + // Both caches now LRU-bound the key count at 128 (shared helper, #7566). + const CAP = 128 + + it('appendCommandMarkerCache evicts the oldest scope key past the cap', () => { + clearCommandMarkerCacheForTests() + for (let i = 0; i < CAP + 5; i++) { + appendCommandMarkerCache( + { paneKey: 'tab:leaf', agent: 'claude', sessionId: `s${i}` }, + '/clear' + ) + } + // Oldest sessions evicted; the most-recent CAP survive. + expect( + readCommandMarkerCache({ paneKey: 'tab:leaf', agent: 'claude', sessionId: 's0' }) + ).toEqual([]) + expect( + readCommandMarkerCache({ paneKey: 'tab:leaf', agent: 'claude', sessionId: 's4' }) + ).toEqual([]) + expect( + readCommandMarkerCache({ paneKey: 'tab:leaf', agent: 'claude', sessionId: `s${CAP + 4}` }) + ).toHaveLength(1) + }) + + it('writePendingSendCache evicts the oldest scope key past the cap', () => { + clearPendingSendCacheForTests() + const send = (id: string): NativeChatPendingSend => ({ id, text: id, sentAt: 1 }) + for (let i = 0; i < CAP + 5; i++) { + writePendingSendCache({ paneKey: `tab-${i}:leaf`, agent: 'claude' }, [send(`m${i}`)]) + } + expect(readPendingSendCache({ paneKey: 'tab-0:leaf', agent: 'claude' })).toEqual([]) + expect(readPendingSendCache({ paneKey: `tab-${CAP + 4}:leaf`, agent: 'claude' })).toHaveLength( + 1 + ) + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-pending.ts b/src/renderer/src/components/native-chat/native-chat-pending.ts index 3021dc7bba4..c03f4c0b96a 100644 --- a/src/renderer/src/components/native-chat/native-chat-pending.ts +++ b/src/renderer/src/components/native-chat/native-chat-pending.ts @@ -5,6 +5,7 @@ import { isTextBlock, type NativeChatMessage } from '../../../../shared/native-chat-types' import { stripImagePromptMarker } from './native-chat-image-transcript-markers' +import { setBoundedScopeCacheEntry } from './native-chat-composer-scope-cache' import type { NativeChatLaunchPrompt } from '@/lib/native-chat-launch-prompt' /** An optimistic, not-yet-confirmed composer send. */ @@ -44,7 +45,10 @@ export function writePendingSendCache( if (next.length === 0) { pendingSendCache.delete(key) } else { - pendingSendCache.set(key, next) + // Why: the empty-drain path above clears keys on the normal confirm flow, + // but a pane closed with an unconfirmed send (agent crash / early close) + // would strand its entry forever. LRU-bound the key count too. + setBoundedScopeCacheEntry(pendingSendCache, key, next) } return [...next] } @@ -233,7 +237,11 @@ export function appendCommandMarkerCache( ...(commandMarkerCache.get(key) ?? []), { id: `${sentAt}-${commandMarkerCounter}`, command, sentAt } ].slice(-COMMAND_MARKER_LIMIT) - commandMarkerCache.set(key, next) + // Why: the per-key array is capped at 8, but the KEY (paneKey\0agent\0sessionId, + // sessionId changes on every /clear) is ephemeral and was never evicted, so it + // grew one entry per (pane, session) for the renderer's whole life. LRU-bound + // the key count (mirrors the #7566 draft/attachment caches in this folder). + setBoundedScopeCacheEntry(commandMarkerCache, key, next) return [...next] } diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts index 926e9a2ea4d..e64512fc98d 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts @@ -7,6 +7,7 @@ import { type NativeChatResolvedTarget } from './native-chat-composer-target' import type { NativeChatComposerImageAttachment } from './NativeChatComposerField' +import { setBoundedScopeCacheEntry } from './native-chat-composer-scope-cache' export type UseNativeChatComposerAttachmentsArgs = { attachmentScopeKey: string @@ -153,7 +154,8 @@ function writeNativeChatAttachmentCache( attachmentCache.delete(scopeKey) return } - attachmentCache.set(scopeKey, [...attachments]) + // LRU-bounded so pending attachments for permanently-removed panes can't accumulate. + setBoundedScopeCacheEntry(attachmentCache, scopeKey, [...attachments]) } export function clearNativeChatAttachmentCacheForTests(): void { diff --git a/src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.ts b/src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.ts index cb5a1f7a33e..5ae84dde522 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.ts @@ -2,7 +2,7 @@ import { useEffect } from 'react' import { useAppStore } from '../../store' import type { AgentType } from '../../../../shared/agent-status-types' import type { TerminalPaneLayoutNode } from '../../../../shared/types' -import { resolveTabAgentFromTitle } from '@/lib/use-tab-agent' +import { resolveCommittedTitleAgentType } from '@/lib/pane-agent-evidence' import { canToggleNativeChat } from './native-chat-availability' import { isMacPlatform, matchesNativeChatToggleShortcut } from './native-chat-shortcut' @@ -72,8 +72,8 @@ export function useNativeChatToggleShortcut(worktreeId: string, isWorktreeActive agentStatusByPaneKey: state.agentStatusByPaneKey }) const titleFallbackAgent = isNativeChatShortcutTitleFallbackSafe(terminalLayout?.root) - ? (resolveTabAgentFromTitle(tab.label ?? '') ?? - (terminalTab ? resolveTabAgentFromTitle(terminalTab.title) : null)) + ? (resolveCommittedTitleAgentType(tab.label ?? '') ?? + (terminalTab ? resolveCommittedTitleAgentType(terminalTab.title) : null)) : null if ( !canToggleNativeChat({ diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx index a8de4d84313..545b4095544 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx @@ -154,7 +154,10 @@ export function AiVaultSessionVirtualList({ }) return ( -
+
{loading && sessionsCount === 0 ? : null} {!loading && sessionsCount === 0 && !error ? ( diff --git a/src/renderer/src/components/right-sidebar/checks-panel-content.test.tsx b/src/renderer/src/components/right-sidebar/checks-panel-content.test.tsx index 6ce6dc4f886..1bc4c291821 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel-content.test.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel-content.test.tsx @@ -250,6 +250,32 @@ describe('isMutablePRConversationComment', () => { }) describe('PRCommentsList', () => { + it('pins the comments header while scrolling the sidebar', () => { + const comments: PRComment[] = [ + { + id: 1, + author: 'AmethystLiang', + authorAvatarUrl: '', + body: 'Existing review context', + createdAt: '2026-05-14T00:00:00Z', + url: 'https://github.com/acme/widgets/pull/42#issuecomment-1' + } + ] + + const markup = renderWithTooltips( + React.createElement(PRCommentsList, { + comments, + commentsLoading: false, + onAddComment: () => Promise.resolve({ ok: true as const }) + }) + ) + + const commentsHeaderIndex = markup.indexOf('Comments') + const stickyIndex = markup.indexOf('sticky top-0 z-10 bg-sidebar/95 backdrop-blur-sm') + expect(stickyIndex).toBeGreaterThan(-1) + expect(stickyIndex).toBeLessThan(commentsHeaderIndex) + }) + it('places the collapsed add-comment action in the comments header', () => { const comments: PRComment[] = [ { diff --git a/src/renderer/src/components/right-sidebar/checks-panel-content.tsx b/src/renderer/src/components/right-sidebar/checks-panel-content.tsx index 43ca52db9ea..70b53d8f9eb 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel-content.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel-content.tsx @@ -2358,7 +2358,14 @@ export function PRCommentsList({ return (
{/* Header */} -
+
diff --git a/src/renderer/src/components/settings/AgentsPane.tsx b/src/renderer/src/components/settings/AgentsPane.tsx index fb02a5b58a9..99718de46fe 100644 --- a/src/renderer/src/components/settings/AgentsPane.tsx +++ b/src/renderer/src/components/settings/AgentsPane.tsx @@ -13,6 +13,11 @@ import { cn } from '@/lib/utils' import { AgentAwakeSetting } from './AgentAwakeSetting' import { AgentCacheTimerSection } from './AgentCacheTimerSection' import { AgentRuntimeSetting } from './AgentRuntimeSetting' +import { + AgentSessionSourceHomeInput, + buildCodexSessionSourceHomeControl, + type AgentSessionSourceHomeControl +} from './codex-session-source-home-control' import { getAgentGeneratedTabTitlesDescription, getAgentGeneratedTabTitlesTitle @@ -81,6 +86,8 @@ type AgentRowProps = { onSaveOverride: (value: string) => void onSaveArgs: (value: string) => void onSaveEnv: (value: Record) => void + /** Codex-only: current runtime scope label + persisted history-source override. */ + sessionSourceHome?: AgentSessionSourceHomeControl } type AgentCommandOverrideInputProps = { @@ -277,42 +284,44 @@ function AgentCommandOverrideInput({ } return ( -
- +
+ {translate('auto.components.settings.AgentsPane.2e45ca29b6', 'Command')} - setCmdDraft(e.target.value)} - onBlur={commitCmd} - onKeyDown={(e) => { - if (e.key === 'Enter') { - commitCmd() - e.currentTarget.blur() - } - if (e.key === 'Escape') { - setCmdDraft(draftSeed) - e.currentTarget.blur() - } - }} - placeholder={defaultCmd} - spellCheck={false} - className="h-7 flex-1 font-mono text-xs" - /> - {cmdOverride && ( - - )} + placeholder={defaultCmd} + spellCheck={false} + className="h-7 flex-1 font-mono text-xs" + /> + {cmdOverride && ( + + )} +
) } @@ -330,45 +339,47 @@ function AgentDefaultArgsInput({ } return ( -
- +
+ {translate('auto.components.settings.AgentsPane.cfb3f35775', 'Arguments')} - setArgsDraft(e.target.value)} - onBlur={commitArgs} - onKeyDown={(e) => { - if (e.key === 'Enter') { - commitArgs() - e.currentTarget.blur() - } - if (e.key === 'Escape') { - setArgsDraft(draftSeed) - e.currentTarget.blur() - } - }} - placeholder={ - defaultArgs || - translate('auto.components.settings.AgentsPane.6f99bf5dd0', 'No default arguments') - } - spellCheck={false} - className="h-7 flex-1 font-mono text-xs" - /> - {argsOverride !== defaultArgs && ( - - )} + placeholder={ + defaultArgs || + translate('auto.components.settings.AgentsPane.6f99bf5dd0', 'No default arguments') + } + spellCheck={false} + className="h-7 flex-1 font-mono text-xs" + /> + {argsOverride !== defaultArgs && ( + + )} +
) } @@ -394,11 +405,11 @@ function AgentDefaultEnvInput({ } return ( -
+
+ + {translate('auto.components.settings.AgentsPane.8fbe1f37c1', 'Environment')} +
- - {translate('auto.components.settings.AgentsPane.8fbe1f37c1', 'Environment')} - { @@ -476,7 +487,8 @@ function AgentRow({ onSetEnabled, onSaveOverride, onSaveArgs, - onSaveEnv + onSaveEnv, + sessionSourceHome }: AgentRowProps): React.JSX.Element { const envSummary = stringifyAgentDefaultEnvDraft(envOverride) const defaultEnvSummary = stringifyAgentDefaultEnvDraft(defaultEnv) @@ -494,15 +506,6 @@ function AgentRow({
{label} - {isDetected ? ( - - {translate('auto.components.settings.AgentsPane.c8794e622e', 'Detected')} - - ) : ( - - {translate('auto.components.settings.AgentsPane.df123171d1', 'Not installed')} - - )} {!isEnabled && ( {translate('auto.components.settings.AgentsPane.8dc0192e48', 'Disabled')} @@ -523,7 +526,7 @@ function AgentRow({
-
+
-
- {isDetected && ( - - )} -
-
)} -

+ {sessionSourceHome && ( +

+ +
+ )} +

{translate( 'auto.components.settings.AgentsPane.f9f127d664', 'Override the binary path or name, and edit the default launch arguments or environment for this agent.' @@ -902,6 +893,11 @@ export function AgentsPane({ onSaveOverride={(v) => saveOverride(agent.id, v)} onSaveArgs={(v) => saveAgentArgs(agent.id, v)} onSaveEnv={(v) => saveAgentEnv(agent.id, v)} + sessionSourceHome={ + agent.id === 'codex' + ? buildCodexSessionSourceHomeControl(settings, updateSettings) + : undefined + } /> ))}

diff --git a/src/renderer/src/components/settings/AppearanceInterfaceSection.tsx b/src/renderer/src/components/settings/AppearanceInterfaceSection.tsx index 0f691a072dd..31afc7d6843 100644 --- a/src/renderer/src/components/settings/AppearanceInterfaceSection.tsx +++ b/src/renderer/src/components/settings/AppearanceInterfaceSection.tsx @@ -38,6 +38,7 @@ type AppearanceInterfaceSectionProps = { applyTheme: (theme: 'system' | 'dark' | 'light') => void fontSuggestions: string[] isDesktopWindows: boolean + onRequestFontSuggestions?: () => void forceVisiblePrimary?: boolean } @@ -47,6 +48,7 @@ export function AppearanceInterfaceSection({ applyTheme, fontSuggestions, isDesktopWindows, + onRequestFontSuggestions, forceVisiblePrimary = false }: AppearanceInterfaceSectionProps): React.JSX.Element { const searchQuery = useAppStore((state) => state.settingsSearchQuery) @@ -142,6 +144,7 @@ export function AppearanceInterfaceSection({ value={settings.appFontFamily} suggestions={fontSuggestions} placeholder={DEFAULT_APP_FONT_FAMILY} + onRequestSuggestions={onRequestFontSuggestions} onChange={(value) => updateSettings({ appFontFamily: value.trim() || DEFAULT_APP_FONT_FAMILY }) } diff --git a/src/renderer/src/components/settings/AppearancePane.test.tsx b/src/renderer/src/components/settings/AppearancePane.test.tsx index 714844932f1..5fe949cea39 100644 --- a/src/renderer/src/components/settings/AppearancePane.test.tsx +++ b/src/renderer/src/components/settings/AppearancePane.test.tsx @@ -129,7 +129,10 @@ function createWarpThemesStub() { async function renderAppearancePane( settings: GlobalSettings, - updateSettings: (updates: Partial) => void = vi.fn() + updateSettings: (updates: Partial) => void = vi.fn(), + options: { + onRequestFontSuggestions?: () => void + } = {} ): Promise { const container = document.createElement('div') document.body.appendChild(container) @@ -146,6 +149,7 @@ async function renderAppearancePane( applyTheme={vi.fn()} fontSuggestions={[]} terminalFontSuggestions={[]} + onRequestFontSuggestions={options.onRequestFontSuggestions} systemPrefersDark={false} ghostty={createGhosttyStub() as never} warpThemes={createWarpThemesStub() as never} @@ -296,6 +300,25 @@ describe('AppearancePane', () => { expect(container.textContent).not.toContain('Code & Markdown') }) + it('requests installed font suggestions only after the IDE font picker is used', async () => { + mocks.state.settingsSearchQuery = '' + const requestFontSuggestions = vi.fn() + const container = await renderAppearancePane(getDefaultSettings('/tmp'), vi.fn(), { + onRequestFontSuggestions: requestFontSuggestions + }) + + expect(requestFontSuggestions).not.toHaveBeenCalled() + + const input = container.querySelector('input[role="combobox"]') + expect(input).not.toBeNull() + + await act(async () => { + input?.focus() + }) + + expect(requestFontSuggestions).toHaveBeenCalledOnce() + }) + it('keeps the app icon control at the bottom of the pane, after the section rows', async () => { mocks.state.settingsSearchQuery = '' const container = await renderAppearancePane(getDefaultSettings('/tmp')) diff --git a/src/renderer/src/components/settings/AppearancePane.tsx b/src/renderer/src/components/settings/AppearancePane.tsx index f7a2dd52c13..92792621533 100644 --- a/src/renderer/src/components/settings/AppearancePane.tsx +++ b/src/renderer/src/components/settings/AppearancePane.tsx @@ -45,6 +45,7 @@ type AppearancePaneProps = { applyTheme: (theme: 'system' | 'dark' | 'light') => void fontSuggestions: string[] terminalFontSuggestions: string[] + onRequestFontSuggestions?: () => void systemPrefersDark: boolean ghostty: UseGhosttyImportReturn warpThemes: UseWarpThemeImportReturn @@ -68,6 +69,7 @@ export function AppearancePane({ applyTheme, fontSuggestions, terminalFontSuggestions, + onRequestFontSuggestions, systemPrefersDark, ghostty, warpThemes @@ -179,6 +181,7 @@ export function AppearancePane({ updateSettings={updateSettings} applyTheme={applyTheme} fontSuggestions={fontSuggestions} + onRequestFontSuggestions={onRequestFontSuggestions} isDesktopWindows={isDesktopWindows} forceVisiblePrimary={interfaceLabelMatches} /> @@ -205,6 +208,7 @@ export function AppearancePane({ updateSettings={updateSettings} systemPrefersDark={systemPrefersDark} terminalFontSuggestions={terminalFontSuggestions} + onRequestFontSuggestions={onRequestFontSuggestions} ghostty={ghostty} warpThemes={warpThemes} forceVisiblePrimary={terminalLabelMatches} diff --git a/src/renderer/src/components/settings/Settings.tsx b/src/renderer/src/components/settings/Settings.tsx index 92c4a35b2c4..bfe82c9d783 100644 --- a/src/renderer/src/components/settings/Settings.tsx +++ b/src/renderer/src/components/settings/Settings.tsx @@ -23,7 +23,11 @@ import { useSystemPrefersDark } from '@/components/terminal-pane/use-system-pref import { isMacUserAgent, isWindowsUserAgent } from '@/components/terminal-pane/pane-helpers' import { applyDocumentTheme } from '@/lib/document-theme' import { useConfirmationDialog } from '@/components/confirmation-dialog' -import { SCROLLBACK_PRESETS_ROWS, getFallbackTerminalFonts } from './SettingsConstants' +import { + SCROLLBACK_PRESETS_ROWS, + getFallbackTerminalFonts, + mergeFontSuggestions +} from './SettingsConstants' import { DEFAULT_APP_FONT_FAMILY, getDefaultVoiceSettings } from '../../../../shared/constants' import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' import { GeneralPane } from './GeneralPane' @@ -313,7 +317,7 @@ function Settings(): React.JSX.Element { const ghostty = useGhosttyImport(updateSettings, settings) const warpThemes = useWarpThemeImport(updateSettings, settings) const [fontSuggestions, setFontSuggestions] = useState( - Array.from(new Set([DEFAULT_APP_FONT_FAMILY, ...getFallbackTerminalFonts()])) + mergeFontSuggestions([], getFallbackTerminalFonts()) ) const terminalFontSuggestions = useMemo( () => fontSuggestions.filter((font) => font !== DEFAULT_APP_FONT_FAMILY), @@ -336,7 +340,9 @@ function Settings(): React.JSX.Element { const [hiddenExperimentalUnlocked, setHiddenExperimentalUnlocked] = useState(false) const contentScrollRef = useRef(null) const searchInputRef = useRef(null) - const terminalFontsLoadedRef = useRef(false) + const installedFontsLoadedRef = useRef(false) + const installedFontsLoadPromiseRef = useRef | null>(null) + const settingsMountedRef = useRef(true) const pendingNavSectionRef = useRef(null) const pendingScrollTargetRef = useRef(null) const pendingSubsectionScrollFrameRef = useRef(null) @@ -394,6 +400,42 @@ function Settings(): React.JSX.Element { cancelPendingSettingsSubsectionScrollFrame(pendingSubsectionScrollFrameRef) }, []) + useEffect(() => { + // Why: React dev StrictMode replays mount effects; async font requests + // should still commit while the Settings view is actually mounted. + settingsMountedRef.current = true + return () => { + settingsMountedRef.current = false + } + }, []) + + const requestFontSuggestions = useCallback((): void => { + if (installedFontsLoadedRef.current || installedFontsLoadPromiseRef.current) { + return + } + + installedFontsLoadPromiseRef.current = window.api.settings + .listFonts() + .then((fonts) => { + if (!settingsMountedRef.current) { + return + } + // Latch after the first successful attempt even when empty, so a font-less + // system doesn't reissue listFonts() on every picker interaction. + installedFontsLoadedRef.current = true + if (fonts.length === 0) { + return + } + setFontSuggestions((prev) => mergeFontSuggestions(fonts, prev)) + }) + .catch(() => { + // Fall back to curated cross-platform suggestions. + }) + .finally(() => { + installedFontsLoadPromiseRef.current = null + }) + }, []) + // Pure "discard and leave?" prompt — no side effects. Why separate from the // discard helper below: the window-close guard must ask without clearing the // drafts, since a later guard/handler can still cancel the close. @@ -754,35 +796,6 @@ function Settings(): React.JSX.Element { setMountedSectionIds(neededSectionIds) } - useEffect(() => { - if (!neededSectionIds.has('appearance') && !neededSectionIds.has('terminal')) { - return - } - if (terminalFontsLoadedRef.current) { - return - } - - let stale = false - const loadFontSuggestions = async (): Promise => { - try { - const fonts = await window.api.settings.listFonts() - if (stale || fonts.length === 0) { - return - } - terminalFontsLoadedRef.current = true - setFontSuggestions((prev) => - Array.from(new Set([DEFAULT_APP_FONT_FAMILY, ...fonts, ...prev])).slice(0, 320) - ) - } catch { - // Fall back to curated cross-platform suggestions. - } - } - void loadFontSuggestions() - return () => { - stale = true - } - }, [neededSectionIds]) - const neededRepoIds = useMemo( () => deriveNeededRepoIds(repos, neededSectionIds), [neededSectionIds, repos] @@ -1410,6 +1423,7 @@ function Settings(): React.JSX.Element { applyTheme={applyTheme} fontSuggestions={fontSuggestions} terminalFontSuggestions={terminalFontSuggestions} + onRequestFontSuggestions={requestFontSuggestions} systemPrefersDark={systemPrefersDark} ghostty={ghostty} warpThemes={warpThemes} diff --git a/src/renderer/src/components/settings/SettingsConstants.test.ts b/src/renderer/src/components/settings/SettingsConstants.test.ts new file mode 100644 index 00000000000..46876796784 --- /dev/null +++ b/src/renderer/src/components/settings/SettingsConstants.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest' +import { DEFAULT_APP_FONT_FAMILY } from '../../../../shared/constants' +import { mergeFontSuggestions } from './SettingsConstants' + +describe('mergeFontSuggestions', () => { + it('keeps installed fonts beyond the old hydration cutoff', () => { + const systemFonts = Array.from({ length: 350 }, (_value, index) => `System Font ${index}`) + const suggestions = mergeFontSuggestions(systemFonts, ['JetBrains Mono']) + + expect(suggestions).toContain(DEFAULT_APP_FONT_FAMILY) + expect(suggestions).toContain('System Font 349') + expect(suggestions).toContain('JetBrains Mono') + }) +}) diff --git a/src/renderer/src/components/settings/SettingsConstants.ts b/src/renderer/src/components/settings/SettingsConstants.ts index 47fb0ba281a..2c49121d31d 100644 --- a/src/renderer/src/components/settings/SettingsConstants.ts +++ b/src/renderer/src/components/settings/SettingsConstants.ts @@ -1,5 +1,5 @@ import type { OrcaHooks } from '../../../../shared/types' -import { getDefaultRepoHookSettings } from '../../../../shared/constants' +import { DEFAULT_APP_FONT_FAMILY, getDefaultRepoHookSettings } from '../../../../shared/constants' import { DESKTOP_TERMINAL_SCROLLBACK_ROW_PRESETS } from '../../../../shared/terminal-scrollback-policy' export type HookName = keyof OrcaHooks['scripts'] @@ -14,6 +14,15 @@ export function zoomLevelToPercent(level: number): number { return Math.round(100 * Math.pow(1.2, level)) } +export function mergeFontSuggestions( + systemFonts: readonly string[], + previousFonts: readonly string[] +): string[] { + // Why: picker rendering can be capped later, but the source list must keep + // every installed font searchable/selectable. + return Array.from(new Set([DEFAULT_APP_FONT_FAMILY, ...systemFonts, ...previousFonts])) +} + export function getFallbackTerminalFonts(): string[] { const nav = typeof navigator !== 'undefined' diff --git a/src/renderer/src/components/settings/SettingsFormControls.font-autocomplete.test.tsx b/src/renderer/src/components/settings/SettingsFormControls.font-autocomplete.test.tsx index 62412f2e258..4e5e3a8f235 100644 --- a/src/renderer/src/components/settings/SettingsFormControls.font-autocomplete.test.tsx +++ b/src/renderer/src/components/settings/SettingsFormControls.font-autocomplete.test.tsx @@ -4,6 +4,7 @@ import { act, useState, type ReactNode } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { FontAutocomplete } from './SettingsFormControls' +import { FONT_SUGGESTION_RENDER_LIMIT } from './settings-form-option-filter' vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, defaultValue: string) => defaultValue @@ -186,4 +187,85 @@ describe('FontAutocomplete', () => { expect(getOptionLabels()).toEqual(['JetBrains Mono']) }) + + it('requests installed suggestions only after the picker is used', async () => { + const requestSuggestions = vi.fn() + + function Harness(): ReactNode { + const [value, setValue] = useState('Geist') + return ( + + ) + } + + await act(async () => { + root.render() + }) + + expect(requestSuggestions).not.toHaveBeenCalled() + + await act(async () => { + getInput().focus() + }) + + expect(requestSuggestions).toHaveBeenCalledOnce() + }) + + it('searches suggestions past the previous 320 font cutoff', async () => { + const suggestions = [ + ...Array.from({ length: 350 }, (_value, index) => `System Font ${index}`), + 'Zed Mono' + ] + + function Harness(): ReactNode { + const [value, setValue] = useState('System Font 0') + return + } + + await act(async () => { + root.render() + }) + + const input = getInput() + + await act(async () => { + input.focus() + }) + expect(getOptionLabels()).toHaveLength(FONT_SUGGESTION_RENDER_LIMIT) + expect(getOptionLabels()).not.toContain('Zed Mono') + + await typeIntoInput(input, 'Zed') + + expect(getOptionLabels()).toEqual(['Zed Mono']) + }) + + it('renders the selected late-list font even when the full list is capped', async () => { + const suggestions = [ + ...Array.from({ length: FONT_SUGGESTION_RENDER_LIMIT + 30 }, (_value, index) => { + return `System Font ${index}` + }), + 'Zed Mono' + ] + + function Harness(): ReactNode { + const [value, setValue] = useState('Zed Mono') + return + } + + await act(async () => { + root.render() + }) + + await act(async () => { + getInput().focus() + }) + + expect(getOptionLabels()).toHaveLength(FONT_SUGGESTION_RENDER_LIMIT) + expect(getOptionLabels().at(-1)).toBe('Zed Mono') + }) }) diff --git a/src/renderer/src/components/settings/SettingsFormControls.tsx b/src/renderer/src/components/settings/SettingsFormControls.tsx index a8b5bbdef05..146e0000f62 100644 --- a/src/renderer/src/components/settings/SettingsFormControls.tsx +++ b/src/renderer/src/components/settings/SettingsFormControls.tsx @@ -13,6 +13,7 @@ import { MAX_THEME_RESULTS } from './SettingsConstants' import { filterFontSuggestions, filterTerminalThemeOptions, + getRenderedFontSuggestions, isSettingsFormOptionQueryTooLarge } from './settings-form-option-filter' import { cn } from '@/lib/utils' @@ -286,6 +287,7 @@ type FontAutocompleteProps = { suggestions: string[] onChange: (value: string) => void placeholder?: string + onRequestSuggestions?: () => void /** Fires with whichever option the user is currently highlighting in the * dropdown (via mouse hover or keyboard arrow), or null when nothing is * highlighted / the dropdown is closed. Lets a consumer show a live @@ -588,6 +590,7 @@ export function FontAutocomplete({ suggestions, onChange, placeholder = 'SF Mono', + onRequestSuggestions, onPreviewFontFamily }: FontAutocompleteProps): React.JSX.Element { const [query, setQuery] = useState(value) @@ -619,8 +622,15 @@ export function FontAutocomplete({ } } + const requestSuggestions = useCallback((): void => { + onRequestSuggestions?.() + }, [onRequestSuggestions]) + const handleOpenChange = (nextOpen: boolean): void => { setOpen(nextOpen) + if (nextOpen) { + requestSuggestions() + } if (!nextOpen) { setIsFilteringQuery(false) } @@ -636,6 +646,10 @@ export function FontAutocomplete({ // still reveal every installed font instead of only fonts sharing that name. const visibleSuggestions = !isFilteringQuery && normalizedQuery === normalizedValue ? suggestions : filteredSuggestions + const renderedSuggestions = useMemo( + () => getRenderedFontSuggestions(visibleSuggestions, highlightedIndex), + [visibleSuggestions, highlightedIndex] + ) // Why: sync the highlighted index during render rather than via useEffect so // the correct item is highlighted on the very first paint after open/filter @@ -699,12 +713,14 @@ export function FontAutocomplete({ value={query} onChange={(e) => { const next = e.target.value + requestSuggestions() setQuery(next) setIsFilteringQuery(true) onChange(next) setOpen(true) }} onFocus={() => { + requestSuggestions() setIsFilteringQuery(false) setOpen(true) }} @@ -795,6 +811,7 @@ export function FontAutocomplete({ setIsFilteringQuery(false) } if (nextOpen) { + requestSuggestions() focusInput() } }} @@ -831,29 +848,29 @@ export function FontAutocomplete({ }} > 8 ? 'h-64' : undefined} + className={renderedSuggestions.length > 8 ? 'h-64' : undefined} style={popoverAvailableHeightStyle} viewportProps={{ style: popoverAvailableHeightStyle }} >
{visibleSuggestions.length > 0 ? ( - visibleSuggestions.map((font, index) => ( + renderedSuggestions.map(({ font, sourceIndex }) => ( + + + {translate( + 'auto.components.settings.AgentsPane.codexSessionSourceTooltip', + 'Orca runs Codex in an isolated home. Point this at your existing Codex home to import that session history. Empty uses ~/.codex.' + )} + + + +
+ setDraft(e.target.value)} + onBlur={commit} + onKeyDown={(e) => { + if (e.key === 'Enter') { + commit() + e.currentTarget.blur() + } + if (e.key === 'Escape') { + setDraft(value) + e.currentTarget.blur() + } + }} + placeholder={runtimeLabel} + spellCheck={false} + className="h-7 flex-1 font-mono text-xs" + /> + {value.trim() && ( + + )} +
+
+ ) +} diff --git a/src/renderer/src/components/settings/settings-form-option-filter.test.ts b/src/renderer/src/components/settings/settings-form-option-filter.test.ts index 489490f828d..d24a9fe9a1f 100644 --- a/src/renderer/src/components/settings/settings-form-option-filter.test.ts +++ b/src/renderer/src/components/settings/settings-form-option-filter.test.ts @@ -1,9 +1,11 @@ import { describe, expect, it } from 'vitest' import type { TerminalThemeOption } from '@/lib/terminal-theme' import { + FONT_SUGGESTION_RENDER_LIMIT, SETTINGS_FORM_OPTION_QUERY_MAX_BYTES, filterFontSuggestions, filterTerminalThemeOptions, + getRenderedFontSuggestions, isSettingsFormOptionQueryTooLarge } from './settings-form-option-filter' @@ -86,4 +88,37 @@ describe('settings-form-option-filter', () => { expect(filterTerminalThemeOptions([createThemeOption('Solarized Dark')], query)).toEqual([]) expect(filterFontSuggestions(['Fira Code'], query)).toEqual([]) }) + + it('caps rendered font suggestions without changing the searchable source list', () => { + const suggestions = Array.from( + { length: FONT_SUGGESTION_RENDER_LIMIT + 40 }, + (_value, index) => `System Font ${index}` + ) + + const rendered = getRenderedFontSuggestions(suggestions, 0) + + expect(rendered).toHaveLength(FONT_SUGGESTION_RENDER_LIMIT) + expect(rendered.map((option) => option.font)).not.toContain( + `System Font ${FONT_SUGGESTION_RENDER_LIMIT + 39}` + ) + expect( + filterFontSuggestions(suggestions, `System Font ${FONT_SUGGESTION_RENDER_LIMIT + 39}`) + ).toEqual([`System Font ${FONT_SUGGESTION_RENDER_LIMIT + 39}`]) + }) + + it('keeps a highlighted late font visible inside the render cap', () => { + const lateFontIndex = FONT_SUGGESTION_RENDER_LIMIT + 12 + const suggestions = Array.from( + { length: lateFontIndex + 1 }, + (_value, index) => `System Font ${index}` + ) + + const rendered = getRenderedFontSuggestions(suggestions, lateFontIndex) + + expect(rendered).toHaveLength(FONT_SUGGESTION_RENDER_LIMIT) + expect(rendered.at(-1)).toEqual({ + font: `System Font ${lateFontIndex}`, + sourceIndex: lateFontIndex + }) + }) }) diff --git a/src/renderer/src/components/settings/settings-form-option-filter.ts b/src/renderer/src/components/settings/settings-form-option-filter.ts index 976bacf6a9e..c321739722e 100644 --- a/src/renderer/src/components/settings/settings-form-option-filter.ts +++ b/src/renderer/src/components/settings/settings-form-option-filter.ts @@ -2,6 +2,12 @@ import { isClipboardTextByteLengthOverLimit } from '../../../../shared/clipboard import type { TerminalThemeOption } from '@/lib/terminal-theme' export const SETTINGS_FORM_OPTION_QUERY_MAX_BYTES = 2 * 1024 +export const FONT_SUGGESTION_RENDER_LIMIT = 320 + +export type RenderedFontSuggestion = { + font: string + sourceIndex: number +} export function isSettingsFormOptionQueryTooLarge( query: string, @@ -55,3 +61,20 @@ export function filterFontSuggestions(suggestions: readonly string[], query: str } return [...startsWith, ...includes] } + +export function getRenderedFontSuggestions( + suggestions: readonly string[], + highlightedIndex: number, + limit = FONT_SUGGESTION_RENDER_LIMIT +): RenderedFontSuggestion[] { + const cappedLength = Math.min(suggestions.length, limit) + if (cappedLength <= 0) { + return [] + } + + const sourceIndexes = Array.from({ length: cappedLength }, (_value, index) => index) + if (highlightedIndex >= cappedLength && highlightedIndex < suggestions.length) { + sourceIndexes[cappedLength - 1] = highlightedIndex + } + return sourceIndexes.map((sourceIndex) => ({ font: suggestions[sourceIndex] ?? '', sourceIndex })) +} diff --git a/src/renderer/src/components/sidebar/WorktreeCard.test.ts b/src/renderer/src/components/sidebar/WorktreeCard.test.ts index 36ac370dbed..abc03a3c4fd 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.test.ts +++ b/src/renderer/src/components/sidebar/WorktreeCard.test.ts @@ -20,7 +20,6 @@ vi.mock('@/lib/agent-status', () => ({ import { getWorktreeStatus } from '@/lib/worktree-status' import { shouldBeginWorktreeRename } from './WorktreeCard' -import { deriveWorktreeCardStatus } from './worktree-card-status' function makeTerminalTab(title: string): TerminalTab { return { @@ -35,22 +34,6 @@ function makeTerminalTab(title: string): TerminalTab { } } -function makeAgentStatusEntry(args: { - paneKey: string - state: AgentStatusEntry['state'] - updatedAt?: number -}): AgentStatusEntry { - const updatedAt = args.updatedAt ?? 1_000 - return { - paneKey: args.paneKey, - state: args.state, - prompt: '', - updatedAt, - stateStartedAt: updatedAt, - stateHistory: [] - } -} - describe('getWorktreeStatus', () => { it('treats browser-only worktrees as active', () => { expect(getWorktreeStatus([], [{ id: 'browser-1' }], {})).toBe('active') @@ -59,67 +42,22 @@ describe('getWorktreeStatus', () => { it('keeps terminal agent states higher priority than browser presence', () => { // Why: liveness gate now requires ptyIdsByTabId, not tab.ptyId. Pass a // populated live-pty map so this assertion exercises the live-tab branch. + // Titles are real classifiable shapes: getWorktreeStatus reads the shared + // classifier through pane-agent-evidence, which this file does not mock. const livePtyIds = { 'tab-1': ['pty-1'] } expect( - getWorktreeStatus([makeTerminalTab('permission needed')], [{ id: 'browser-1' }], livePtyIds) + getWorktreeStatus( + [makeTerminalTab('Claude - action required')], + [{ id: 'browser-1' }], + livePtyIds + ) ).toBe('permission') expect( - getWorktreeStatus([makeTerminalTab('working hard')], [{ id: 'browser-1' }], livePtyIds) + getWorktreeStatus([makeTerminalTab('mimo working')], [{ id: 'browser-1' }], livePtyIds) ).toBe('working') }) }) -describe('deriveWorktreeCardStatus', () => { - it('keeps split-pane heuristics for panes without fresh explicit status', () => { - const status = deriveWorktreeCardStatus({ - tabs: [makeTerminalTab('claude [done]')], - browserTabs: [], - worktreeAgentEntries: [makeAgentStatusEntry({ paneKey: 'tab-1:1', state: 'done' })], - runtimePaneTitlesByTabId: { - 'tab-1': { - 1: 'claude [done]', - 2: 'codex [working]' - } - }, - now: 1_000 - }) - - expect(status).toBe('working') - }) - - it('lets fresh explicit status win over the matching pane title heuristic', () => { - const status = deriveWorktreeCardStatus({ - tabs: [makeTerminalTab('codex [working]')], - browserTabs: [], - worktreeAgentEntries: [makeAgentStatusEntry({ paneKey: 'tab-1:2', state: 'done' })], - runtimePaneTitlesByTabId: { - 'tab-1': { - 2: 'codex [working]' - } - }, - now: 1_000 - }) - - expect(status).toBe('done') - }) - - it('stays active when the only live terminal signal is the Claude agents screen', () => { - const status = deriveWorktreeCardStatus({ - tabs: [makeTerminalTab('claude agents')], - browserTabs: [], - worktreeAgentEntries: [], - runtimePaneTitlesByTabId: { - 'tab-1': { - 1: 'claude agents' - } - }, - now: 1_000 - }) - - expect(status).toBe('active') - }) -}) - describe('shouldBeginWorktreeRename', () => { it('matches unscoped legacy rename requests by worktree id', () => { expect(shouldBeginWorktreeRename({ worktreeId: 'wt-1' }, 'wt-1', 'all:wt-1')).toBe(true) diff --git a/src/renderer/src/components/sidebar/smart-attention.ts b/src/renderer/src/components/sidebar/smart-attention.ts index 4ca27a7c47d..ae0d7542da3 100644 --- a/src/renderer/src/components/sidebar/smart-attention.ts +++ b/src/renderer/src/components/sidebar/smart-attention.ts @@ -1,4 +1,4 @@ -import { detectAgentStatusFromTitle, isExplicitAgentStatusFresh } from '@/lib/agent-status' +import { classifyTitleActivity, isExplicitAgentStatusFresh } from '@/lib/pane-agent-evidence' import { migrationUnsupportedToAgentStatusEntry } from '@/lib/migration-unsupported-agent-entry' import { tabHasLivePty } from '@/lib/tab-has-live-pty' import { resolveRuntimePaneTitleLeafId } from '@/lib/runtime-pane-title-leaf-id' @@ -308,7 +308,7 @@ export function buildAttentionByWorktree( } panes.push({ kind: 'title', - status: detectAgentStatusFromTitle(title), + status: classifyTitleActivity(title), worktreeLastActivityAt: worktree.lastActivityAt }) } @@ -318,7 +318,7 @@ export function buildAttentionByWorktree( // titles or hook entries exist for this tab. panes.push({ kind: 'title', - status: detectAgentStatusFromTitle(tab.title), + status: classifyTitleActivity(tab.title), worktreeLastActivityAt: worktree.lastActivityAt }) } diff --git a/src/renderer/src/components/sidebar/worktree-card-status.ts b/src/renderer/src/components/sidebar/worktree-card-status.ts deleted file mode 100644 index 61daeb74c9a..00000000000 --- a/src/renderer/src/components/sidebar/worktree-card-status.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { detectAgentStatusFromTitle, isExplicitAgentStatusFresh } from '@/lib/agent-status' -import type { WorktreeStatus } from '@/lib/worktree-status' -import { - AGENT_STATUS_STALE_AFTER_MS, - type AgentStatusEntry -} from '../../../../shared/agent-status-types' -import type { TerminalTab } from '../../../../shared/types' - -type WorktreeCardStatusInput = { - tabs: Pick[] - browserTabs: { id: string }[] - worktreeAgentEntries: AgentStatusEntry[] - runtimePaneTitlesByTabId: Record> - now?: number -} - -export function deriveWorktreeCardStatus({ - tabs, - browserTabs, - worktreeAgentEntries, - runtimePaneTitlesByTabId, - now = Date.now() -}: WorktreeCardStatusInput): WorktreeStatus { - const liveTabs = tabs.filter((tab) => tab.ptyId) - // Why: browser-only worktrees are still active from the user's point of - // view even when they have no PTY-backed terminal. The sidebar filter - // already treats them as active, so every navigation surface must reuse - // that rule instead of showing a misleading inactive dot. - const hasTerminals = liveTabs.length > 0 || browserTabs.length > 0 - if (!hasTerminals) { - return 'inactive' - } - - const freshByTabId = new Map() - const explicitPaneIdsByTabId = new Map>() - for (const entry of worktreeAgentEntries) { - if (!isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS)) { - continue - } - const colonIdx = entry.paneKey.indexOf(':') - // Why: paneKey must be `${tabId}:${paneId}`. Skip malformed entries (no - // colon or leading colon) rather than bucketing under "". - if (colonIdx <= 0) { - continue - } - const tabId = entry.paneKey.slice(0, colonIdx) - const paneId = entry.paneKey.slice(colonIdx + 1) - const bucket = freshByTabId.get(tabId) - if (bucket) { - bucket.push(entry) - } else { - freshByTabId.set(tabId, [entry]) - } - const explicitPaneIds = explicitPaneIdsByTabId.get(tabId) ?? new Set() - explicitPaneIds.add(paneId) - explicitPaneIdsByTabId.set(tabId, explicitPaneIds) - } - - let hasPermission = false - let hasWorking = false - let hasDone = false - for (const tab of liveTabs) { - const fresh = freshByTabId.get(tab.id) - if (fresh && fresh.length > 0) { - if (fresh.some((e) => e.state === 'blocked' || e.state === 'waiting')) { - hasPermission = true - } else if (fresh.some((e) => e.state === 'working')) { - hasWorking = true - } else if (fresh.some((e) => e.state === 'done')) { - hasDone = true - } - } - - const explicitPaneIds = explicitPaneIdsByTabId.get(tab.id) - const paneTitles = runtimePaneTitlesByTabId[tab.id] - if (paneTitles && Object.keys(paneTitles).length > 0) { - for (const [paneId, title] of Object.entries(paneTitles)) { - // Why: explicit hook status only supersedes the matching pane. Other - // split panes still need the title heuristic when hooks are absent. - if (explicitPaneIds?.has(paneId)) { - continue - } - const heuristic = detectAgentStatusFromTitle(title) - if (heuristic === 'permission') { - hasPermission = true - break - } - if (heuristic === 'working') { - hasWorking = true - } - } - continue - } - - if (fresh && fresh.length > 0) { - continue - } - - const heuristic = detectAgentStatusFromTitle(tab.title) - if (heuristic === 'permission') { - hasPermission = true - } else if (heuristic === 'working') { - hasWorking = true - } - } - - if (hasPermission) { - return 'permission' - } - if (hasWorking) { - return 'working' - } - // Why: surface 'done' as its own status so the sidebar dot turns blue - // (sky-500/80) — matching the dashboard's done color. A completed agent - // still has a live terminal, so 'inactive' would be misleading; calling - // it 'done' keeps the two surfaces in agreement on what the agent is. - if (hasDone) { - return 'done' - } - return 'active' -} diff --git a/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts b/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts index 2b5efddebee..1c862b70505 100644 --- a/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts +++ b/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts @@ -1,9 +1,6 @@ import type { DashboardAgentRow } from '@/components/dashboard/useDashboardData' -import { - detectAgentStatusFromTitle, - getAgentLabel, - isClaudeManagementTitle -} from '@/lib/agent-status' +import { isClaudeManagementTitle } from '@/lib/agent-status' +import { classifyTitleActivity, resolveTitleActivityLabel } from '@/lib/pane-agent-evidence' import { tabHasLivePty } from '@/lib/tab-has-live-pty' import type { AgentStatusEntry, @@ -135,8 +132,8 @@ function buildTitleDerivedAgentRow(args: { // Why: `claude agents` is a live Claude Code Agent Teams surface, but the // shared detector keeps it neutral so runtime liveness probes do not treat // the management/list screen as active work. - const status = isClaudeAgentsTitle ? 'idle' : detectAgentStatusFromTitle(title) - const label = isClaudeAgentsTitle ? 'Claude Code' : getAgentLabel(title) + const status = isClaudeAgentsTitle ? 'idle' : classifyTitleActivity(title) + const label = isClaudeAgentsTitle ? 'Claude Code' : resolveTitleActivityLabel(title) if (!status || !label) { return null } @@ -199,7 +196,7 @@ export function resolveAgentTypeFromTerminalTitle( return null } const normalizedTitle = normalizeCompatibleAgentTitleForOwner(title, ownerAgentType) - const label = getAgentLabel(normalizedTitle) + const label = resolveTitleActivityLabel(normalizedTitle) return label ? (resolveCompatibleAgentTypeForOwner( resolveTitleDerivedAgentType(normalizedTitle, label), diff --git a/src/renderer/src/components/status-bar/workspace-space-presentation.ts b/src/renderer/src/components/status-bar/workspace-space-presentation.ts index 85274483d96..ad09532a652 100644 --- a/src/renderer/src/components/status-bar/workspace-space-presentation.ts +++ b/src/renderer/src/components/status-bar/workspace-space-presentation.ts @@ -1,4 +1,4 @@ -import { detectAgentStatusFromTitle, isExplicitAgentStatusFresh } from '@/lib/agent-status' +import { classifyTitleActivity, isExplicitAgentStatusFresh } from '@/lib/pane-agent-evidence' import { tabHasLivePty } from '@/lib/tab-has-live-pty' import { AGENT_STATUS_STALE_AFTER_MS, @@ -82,12 +82,12 @@ function countTitleActiveAgentsForTab( const paneTitles = runtimePaneTitlesByTabId[tab.id] if (paneTitles && Object.keys(paneTitles).length > 0) { return Object.values(paneTitles).filter((title) => { - const status = detectAgentStatusFromTitle(title) + const status = classifyTitleActivity(title) return status === 'working' || status === 'permission' }).length } - const status = detectAgentStatusFromTitle(tab.title) + const status = classifyTitleActivity(tab.title) return status === 'working' || status === 'permission' ? 1 : 0 } diff --git a/src/renderer/src/components/tab-bar/BrowserTab.test.tsx b/src/renderer/src/components/tab-bar/BrowserTab.test.tsx index f4dc863f8e8..2dfd84d87e5 100644 --- a/src/renderer/src/components/tab-bar/BrowserTab.test.tsx +++ b/src/renderer/src/components/tab-bar/BrowserTab.test.tsx @@ -42,6 +42,18 @@ vi.mock('@dnd-kit/sortable', () => ({ })) vi.mock('lucide-react', () => ({ + ArrowDown: function ArrowDown(props: Record) { + return { type: 'ArrowDown', props } + }, + ArrowLeft: function ArrowLeft(props: Record) { + return { type: 'ArrowLeft', props } + }, + ArrowRight: function ArrowRight(props: Record) { + return { type: 'ArrowRight', props } + }, + ArrowUp: function ArrowUp(props: Record) { + return { type: 'ArrowUp', props } + }, Columns2: function Columns2(props: Record) { return { type: 'Columns2', props } }, @@ -60,6 +72,9 @@ vi.mock('lucide-react', () => ({ PinOff: function PinOff(props: Record) { return { type: 'PinOff', props } }, + PanelRightClose: function PanelRightClose(props: Record) { + return { type: 'PanelRightClose', props } + }, Rows2: function Rows2(props: Record) { return { type: 'Rows2', props } }, diff --git a/src/renderer/src/components/tab-bar/BrowserTab.tsx b/src/renderer/src/components/tab-bar/BrowserTab.tsx index 0dfb97a9af6..45bc82e7989 100644 --- a/src/renderer/src/components/tab-bar/BrowserTab.tsx +++ b/src/renderer/src/components/tab-bar/BrowserTab.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react' import { useSortable } from '@dnd-kit/sortable' -import { Globe, X, ExternalLink, Copy, Pin, PinOff } from 'lucide-react' +import { Globe, X, ExternalLink, Copy, Pin, PinOff, PanelRightClose } from 'lucide-react' import { DropdownMenu, DropdownMenuContent, @@ -279,37 +279,36 @@ export default function BrowserTab({ sideOffset={0} align="start" > + - + {translate('auto.components.tab.bar.BrowserTab.5d6e89891f', 'Duplicate Tab')} - {isPinned ? ( - - ) : ( - - )} + {isPinned ? : } {isPinned ? translate('auto.components.tab.bar.BrowserTab.c5aaee8c39', 'Unpin Tab') : translate('auto.components.tab.bar.BrowserTab.911542656f', 'Pin Tab')} - !isPinned && onClose()} disabled={isPinned}> + {translate('auto.components.tab.bar.BrowserTab.1611a1324b', 'Close')} + {translate('auto.components.tab.bar.BrowserTab.9dd880bd56', 'Close Tabs To The Right')} void window.api.shell.openUrl(openInBrowserUrl)} disabled={!isHttpUrl} > - + {translate('auto.components.tab.bar.BrowserTab.6e0bc8f3a8', 'Open In Browser')} diff --git a/src/renderer/src/components/tab-bar/EditorFileTab.test.tsx b/src/renderer/src/components/tab-bar/EditorFileTab.test.tsx index 59a1c18bcee..66071a7da1d 100644 --- a/src/renderer/src/components/tab-bar/EditorFileTab.test.tsx +++ b/src/renderer/src/components/tab-bar/EditorFileTab.test.tsx @@ -58,6 +58,18 @@ vi.mock('@dnd-kit/sortable', () => ({ })) vi.mock('lucide-react', () => ({ + ArrowDown: function ArrowDown(props: Record) { + return { type: 'ArrowDown', props } + }, + ArrowLeft: function ArrowLeft(props: Record) { + return { type: 'ArrowLeft', props } + }, + ArrowRight: function ArrowRight(props: Record) { + return { type: 'ArrowRight', props } + }, + ArrowUp: function ArrowUp(props: Record) { + return { type: 'ArrowUp', props } + }, Columns2: function Columns2(props: Record) { return { type: 'Columns2', props } }, @@ -70,6 +82,12 @@ vi.mock('lucide-react', () => ({ Eye: function Eye(props: Record) { return { type: 'Eye', props } }, + ListX: function ListX(props: Record) { + return { type: 'ListX', props } + }, + PanelRightClose: function PanelRightClose(props: Record) { + return { type: 'PanelRightClose', props } + }, GitCompareArrows: function GitCompareArrows(props: Record) { return { type: 'GitCompareArrows', props } }, diff --git a/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.test.tsx b/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.test.tsx index 8167bdbda5c..09257498698 100644 --- a/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.test.tsx +++ b/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.test.tsx @@ -36,12 +36,33 @@ vi.mock('@/components/ui/dropdown-menu', () => ({ })) vi.mock('lucide-react', () => ({ + ArrowDown: function ArrowDown(props: Record) { + return { type: 'ArrowDown', props } + }, + ArrowLeft: function ArrowLeft(props: Record) { + return { type: 'ArrowLeft', props } + }, + ArrowRight: function ArrowRight(props: Record) { + return { type: 'ArrowRight', props } + }, + ArrowUp: function ArrowUp(props: Record) { + return { type: 'ArrowUp', props } + }, Copy: function Copy(props: Record) { return { type: 'Copy', props } }, ExternalLink: function ExternalLink(props: Record) { return { type: 'ExternalLink', props } }, + Eye: function Eye(props: Record) { + return { type: 'Eye', props } + }, + ListX: function ListX(props: Record) { + return { type: 'ListX', props } + }, + PanelRightClose: function PanelRightClose(props: Record) { + return { type: 'PanelRightClose', props } + }, Columns2: function Columns2(props: Record) { return { type: 'Columns2', props } }, @@ -56,6 +77,9 @@ vi.mock('lucide-react', () => ({ }, PinOff: function PinOff(props: Record) { return { type: 'PinOff', props } + }, + X: function X(props: Record) { + return { type: 'X', props } } })) diff --git a/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.tsx b/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.tsx index 184c05d2d97..bbf9a75d74a 100644 --- a/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.tsx +++ b/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.tsx @@ -1,4 +1,14 @@ -import { Copy, ExternalLink, Pencil, Pin, PinOff } from 'lucide-react' +import { + Copy, + ExternalLink, + Eye, + ListX, + PanelRightClose, + Pencil, + Pin, + PinOff, + X +} from 'lucide-react' import { DropdownMenu, DropdownMenuContent, @@ -107,6 +117,11 @@ export function EditorFileTabContextMenu({ event.preventDefault() }} > + { @@ -115,24 +130,25 @@ export function EditorFileTabContextMenu({ onOpenRenameInput() }} > - + {translate('auto.components.tab.bar.EditorFileTabContextMenu.68cc610e7f', 'Rename')} {renameShortcut ? {renameShortcut} : null} - {isPinned ? : } + {isPinned ? : } {isPinned ? translate('auto.components.tab.bar.EditorFileTabContextMenu.8e9d603a09', 'Unpin Tab') : translate('auto.components.tab.bar.EditorFileTabContextMenu.fdd29eb669', 'Pin Tab')} - !isPinned && onClose()} disabled={isPinned}> + {translate('auto.components.tab.bar.EditorFileTabContextMenu.1ba8492c5b', 'Close')} {closeShortcut ? {closeShortcut} : null} + {translate( 'auto.components.tab.bar.EditorFileTabContextMenu.ba1369dd24', 'Close All Editor Tabs' @@ -142,6 +158,7 @@ export function EditorFileTabContextMenu({ ) : null} + {translate( 'auto.components.tab.bar.EditorFileTabContextMenu.e5ff31ccaf', 'Close Tabs To The Right' @@ -165,6 +182,7 @@ export function EditorFileTabContextMenu({ ) }} > + {translate( 'auto.components.tab.bar.EditorFileTabContextMenu.bfd5797ef4', 'Open Markdown Preview' @@ -178,7 +196,7 @@ export function EditorFileTabContextMenu({ void window.api.ui.writeClipboardText(file.filePath) }} > - + {translate('auto.components.tab.bar.EditorFileTabContextMenu.5b85754786', 'Copy Path')} - + {translate( 'auto.components.tab.bar.EditorFileTabContextMenu.52ce4f4605', 'Copy Relative Path' @@ -208,7 +226,7 @@ export function EditorFileTabContextMenu({ window.api.shell.openPath(file.filePath) }} > - + {revealLabel} diff --git a/src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx b/src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx index bb4e757d1c8..d183891b6de 100644 --- a/src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx +++ b/src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx @@ -72,6 +72,18 @@ vi.mock('@dnd-kit/sortable', () => ({ })) vi.mock('lucide-react', () => ({ + ArrowDown: function ArrowDown(props: Record) { + return { type: 'ArrowDown', props } + }, + ArrowLeft: function ArrowLeft(props: Record) { + return { type: 'ArrowLeft', props } + }, + ArrowRight: function ArrowRight(props: Record) { + return { type: 'ArrowRight', props } + }, + ArrowUp: function ArrowUp(props: Record) { + return { type: 'ArrowUp', props } + }, Columns2: function Columns2(props: Record) { return { type: 'Columns2', props } }, @@ -84,6 +96,15 @@ vi.mock('lucide-react', () => ({ PanelRightClose: function PanelRightClose(props: Record) { return { type: 'PanelRightClose', props } }, + ListX: function ListX(props: Record) { + return { type: 'ListX', props } + }, + MessageSquare: function MessageSquare(props: Record) { + return { type: 'MessageSquare', props } + }, + Pencil: function Pencil(props: Record) { + return { type: 'Pencil', props } + }, Pin: function Pin(props: Record) { return { type: 'Pin', props } }, @@ -95,6 +116,9 @@ vi.mock('lucide-react', () => ({ }, X: function X(props: Record) { return { type: 'X', props } + }, + SquareTerminal: function SquareTerminal(props: Record) { + return { type: 'SquareTerminal', props } } })) diff --git a/src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx b/src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx index 71b067fa898..6fb14821294 100644 --- a/src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx +++ b/src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx @@ -50,10 +50,20 @@ vi.mock('@/components/ui/dropdown-menu', () => ({ })) vi.mock('lucide-react', () => ({ + ArrowDown: () => null, + ArrowLeft: () => null, + ArrowRight: () => null, + ArrowUp: () => null, + Columns2: () => null, + ListX: () => null, + MessageSquare: () => null, PanelBottomClose: () => null, PanelRightClose: () => null, + Pencil: () => null, Pin: () => null, - PinOff: () => null + PinOff: () => null, + SquareTerminal: () => null, + X: () => null })) vi.mock('@/i18n/i18n', () => ({ @@ -218,6 +228,7 @@ describe('SortableTabContextMenu', () => { const { container } = renderMenu() expect(container.textContent).toContain('Move Tab to Split') + expect(container.textContent).toContain('Split terminal') act(() => getButton(container, 'Right').click()) expect(storeMock.dropUnifiedTab).toHaveBeenCalledWith('tab-1', { @@ -226,7 +237,7 @@ describe('SortableTabContextMenu', () => { }) }) - it('hides split actions for a single-tab group', () => { + it('hides move-tab split actions for a single-tab group', () => { storeMock.state = { ...storeMock.state, groupsByWorktree: { @@ -243,5 +254,6 @@ describe('SortableTabContextMenu', () => { const { container } = renderMenu() expect(container.textContent).not.toContain('Move Tab to Split') + expect(container.textContent).toContain('Split terminal right') }) }) diff --git a/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx b/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx index f93afd46d89..469c4f7159a 100644 --- a/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx +++ b/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx @@ -1,10 +1,12 @@ import { MessageSquare, - PanelBottomClose, PanelRightClose, Pin, PinOff, - SquareTerminal + Pencil, + SquareTerminal, + X, + ListX } from 'lucide-react' import { DropdownMenu, @@ -18,8 +20,7 @@ import type { TerminalTab } from '../../../../shared/types' import { useAppStore } from '../../store' import { formatShortcutLabel, useOptionalShortcutLabel } from '@/hooks/useShortcutLabel' import { translate } from '@/i18n/i18n' -import { TabWorkspaceLayoutMenuSection } from './TabWorkspaceLayoutMenuSection' -import { requestActiveTerminalPaneSplit } from './request-active-terminal-pane-split' +import { TerminalTabSplitMenuSection } from './TerminalTabSplitMenuSection' const TAB_COLORS = [ { @@ -138,12 +139,6 @@ export function SortableTabContextMenu({ const splitRightShortcut = formatShortcutLabel('terminal.splitRight', keybindings) const splitDownShortcut = formatShortcutLabel('terminal.splitDown', keybindings) - const splitActiveTerminalPane = (direction: 'vertical' | 'horizontal'): void => { - if (!isActive) { - onActivate(tab.id) - } - requestActiveTerminalPaneSplit({ tabId: tab.id, direction }) - } const closeShortcut = useOptionalShortcutLabel('tab.close') const renameShortcut = useOptionalShortcutLabel('tab.rename') @@ -158,13 +153,23 @@ export function SortableTabContextMenu({ /> - {canToggleViewMode && onToggleViewMode && ( + + {canToggleViewMode && onToggleViewMode ? ( <> + {isChatView ? ( - + ) : ( - + )} {isChatView ? translate( @@ -176,42 +181,31 @@ export function SortableTabContextMenu({ 'Switch to chat view' )} - - )} - splitActiveTerminalPane('vertical')}> - - {translate( - 'auto.components.tab.bar.SortableTabContextMenu.splitTerminalRight', - 'Split terminal right' - )} - {splitRightShortcut} - - splitActiveTerminalPane('horizontal')}> - - {translate( - 'auto.components.tab.bar.SortableTabContextMenu.splitTerminalDown', - 'Split terminal down' - )} - {splitDownShortcut} - - + ) : null} - {isPinned ? : } + {isPinned ? ( + + ) : ( + + )} {isPinned ? translate('auto.components.tab.bar.SortableTabContextMenu.417722e9c2', 'Unpin Tab') : translate('auto.components.tab.bar.SortableTabContextMenu.60f958ec75', 'Pin Tab')} !isPinned && onClose(tab.id)} disabled={isPinned}> + {translate('auto.components.tab.bar.SortableTabContextMenu.89359a36f7', 'Close')} {closeShortcut ? {closeShortcut} : null} onCloseOthers(tab.id)} disabled={tabCount <= 1}> + {translate('auto.components.tab.bar.SortableTabContextMenu.8d16f9cd30', 'Close Others')} onCloseToRight(tab.id)} disabled={!hasTabsToRight}> + {translate( 'auto.components.tab.bar.SortableTabContextMenu.c1ee099c7e', 'Close Tabs To The Right' @@ -219,6 +213,7 @@ export function SortableTabContextMenu({ + {translate('auto.components.tab.bar.SortableTabContextMenu.2f697b3c31', 'Change Title')} {renameShortcut ? {renameShortcut} : null} diff --git a/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts b/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts index 03e85f8f0e5..d580d2c8c4c 100644 --- a/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts +++ b/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts @@ -59,6 +59,12 @@ vi.mock('react', async () => { } }) +// The headless React mock above stubs hooks, so zustand's useShallow (which +// calls useRef) has no dispatcher; make it a pass-through like the store mock. +vi.mock('zustand/react/shallow', () => ({ + useShallow: (selector: unknown) => selector +})) + vi.mock('lucide-react', () => ({ FilePlus: function FilePlus() { return null diff --git a/src/renderer/src/components/tab-bar/TabBar.tsx b/src/renderer/src/components/tab-bar/TabBar.tsx index 194174a59a2..e5e9c328a6e 100644 --- a/src/renderer/src/components/tab-bar/TabBar.tsx +++ b/src/renderer/src/components/tab-bar/TabBar.tsx @@ -4,6 +4,7 @@ * branches share little beyond drag data, so consolidating them would cost * more clarity than the ~5 lines of bloat is worth. */ import React, { useEffect, useMemo, useRef, useState } from 'react' +import { useShallow } from 'zustand/react/shallow' import { SortableContext } from '@dnd-kit/sortable' import { ChevronLeft, @@ -78,8 +79,8 @@ import { useTabStripOverflowNavigation } from './tab-strip-overflow-navigation' import { useTabStripDragScrollHandlers } from './tab-strip-drag-scroll' import { shouldShowWindowsShellMenu } from './windows-shell-menu-visibility' import { canToggleNativeChat } from '../native-chat/native-chat-availability' -import { findTabAgentEntry } from '../native-chat/native-chat-tab-agent-entry' -import { resolveTabAgentFromTitle } from '@/lib/use-tab-agent' +import { selectTabAgentTypesByTabId } from './tab-agent-types-by-tab-id' +import { resolveCommittedTitleAgentType } from '@/lib/pane-agent-evidence' const isWindows = navigator.userAgent.includes('Windows') const isMacOs = navigator.userAgent.includes('Mac') @@ -454,7 +455,13 @@ function TabBarInner({ // eligible when it launched an agent or has a live agent-status entry on any of // its panes (paneKey = `${unifiedTabId}:…`), mirroring the toggle button's gate. const toggleTabViewMode = useAppStore((s) => s.toggleTabViewMode) - const agentStatusByPaneKey = useAppStore((s) => s.agentStatusByPaneKey) + // Why: the strip only needs each tab's stable agent identity, but the whole + // agentStatusByPaneKey map churns on every working↔idle transition app-wide. + // Select a shallow-stable { tabId: agentType } projection so the strip + // re-renders only when a tab gains/loses/changes its agent, not on status flips. + const tabAgentTypesByTabId = useAppStore( + useShallow((s) => selectTabAgentTypesByTabId(s.agentStatusByPaneKey ?? {})) + ) const nativeChatEnabled = useAppStore((s) => s.settings?.experimentalNativeChat === true) // Why: Electron elements run in a separate process, so clicking @@ -1103,13 +1110,12 @@ function TabBarInner({ // Carry the agent *identity* (not just "an agent exists") so the // native-chat gate can reject unsupported agents like Grok. const resolvedAgent = - resolveTabAgentFromTitle(unifiedTabForItem?.label ?? '') ?? - resolveTabAgentFromTitle(terminalTab.title) + resolveCommittedTitleAgentType(unifiedTabForItem?.label ?? '') ?? + resolveCommittedTitleAgentType(terminalTab.title) // Key the live-agent lookup by the backing terminal tab id — // agent-status pane keys are `${terminalTab.id}:${leafId}`, and // the unified tab id can differ from it. - const detectedAgent = - findTabAgentEntry(agentStatusByPaneKey ?? {}, terminalTab.id)?.agentType ?? null + const detectedAgent = tabAgentTypesByTabId[terminalTab.id] ?? null const canToggleViewMode = unifiedTabForItem !== undefined && canToggleNativeChat({ diff --git a/src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts b/src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts index 1fa6e5809a8..9805727fe29 100644 --- a/src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts +++ b/src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts @@ -123,6 +123,12 @@ vi.mock('react', async () => { } }) +// The headless React mock above stubs hooks, so zustand's useShallow (which +// calls useRef) has no dispatcher; make it a pass-through like the store mock. +vi.mock('zustand/react/shallow', () => ({ + useShallow: (selector: unknown) => selector +})) + vi.mock('lucide-react', () => ({ FilePlus: function FilePlus() { return null diff --git a/src/renderer/src/components/tab-bar/TabWorkspaceLayoutMenuSection.tsx b/src/renderer/src/components/tab-bar/TabWorkspaceLayoutMenuSection.tsx index 819bb6d328f..b1ae2d1cf28 100644 --- a/src/renderer/src/components/tab-bar/TabWorkspaceLayoutMenuSection.tsx +++ b/src/renderer/src/components/tab-bar/TabWorkspaceLayoutMenuSection.tsx @@ -5,12 +5,26 @@ import { DropdownMenuSubTrigger, DropdownMenuItem } from '@/components/ui/dropdown-menu' +import { ArrowDown, ArrowLeft, ArrowRight, ArrowUp, Columns2 } from 'lucide-react' import type { TabSplitDirection } from '../../store/slices/tabs' import { translate } from '@/i18n/i18n' import { canMoveTabToNewPaneColumn, moveTabToNewPaneColumn } from './tab-move-to-pane-column' const PANE_COLUMN_DIRECTIONS: TabSplitDirection[] = ['right', 'left', 'down', 'up'] +function paneColumnDirectionIcon(direction: TabSplitDirection): React.JSX.Element { + switch (direction) { + case 'right': + return + case 'left': + return + case 'down': + return + case 'up': + return + } +} + function paneColumnDirectionLabel(direction: TabSplitDirection): string { switch (direction) { case 'right': @@ -26,10 +40,12 @@ function paneColumnDirectionLabel(direction: TabSplitDirection): string { export function TabWorkspaceLayoutMenuSection({ unifiedTabId, - groupId + groupId, + trailingSeparator = false }: { unifiedTabId: string groupId: string + trailingSeparator?: boolean }): React.JSX.Element | null { if (!canMoveTabToNewPaneColumn(unifiedTabId, groupId)) { return null @@ -37,9 +53,9 @@ export function TabWorkspaceLayoutMenuSection({ return ( <> - - + + {translate( 'auto.components.tab.bar.TabWorkspaceLayoutMenuSection.moveToPaneColumn', 'Move Tab to Split' @@ -53,11 +69,13 @@ export function TabWorkspaceLayoutMenuSection({ moveTabToNewPaneColumn({ unifiedTabId, groupId, direction }) }} > + {paneColumnDirectionIcon(direction)} {paneColumnDirectionLabel(direction)} ))} + {trailingSeparator ? : null} ) } diff --git a/src/renderer/src/components/tab-bar/TerminalTabSplitMenuSection.tsx b/src/renderer/src/components/tab-bar/TerminalTabSplitMenuSection.tsx new file mode 100644 index 00000000000..7b75e42f4bc --- /dev/null +++ b/src/renderer/src/components/tab-bar/TerminalTabSplitMenuSection.tsx @@ -0,0 +1,73 @@ +import { + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger +} from '@/components/ui/dropdown-menu' +import { PanelBottomClose, PanelRightClose, SquareTerminal } from 'lucide-react' +import { translate } from '@/i18n/i18n' +import { TabWorkspaceLayoutMenuSection } from './TabWorkspaceLayoutMenuSection' +import { requestActiveTerminalPaneSplit } from './request-active-terminal-pane-split' + +export function TerminalTabSplitMenuSection({ + unifiedTabId, + groupId, + tabId, + isActive, + onActivate, + splitRightShortcut, + splitDownShortcut, + trailingSeparator = false +}: { + unifiedTabId: string + groupId: string + tabId: string + isActive: boolean + onActivate: (tabId: string) => void + splitRightShortcut: string + splitDownShortcut: string + trailingSeparator?: boolean +}): React.JSX.Element { + const splitActiveTerminalPane = (direction: 'vertical' | 'horizontal'): void => { + if (!isActive) { + onActivate(tabId) + } + requestActiveTerminalPaneSplit({ tabId, direction }) + } + + return ( + <> + + + + + {translate( + 'auto.components.tab.bar.TerminalTabSplitMenuSection.splitTerminal', + 'Split terminal' + )} + + + splitActiveTerminalPane('vertical')}> + + {translate( + 'auto.components.tab.bar.SortableTabContextMenu.splitTerminalRight', + 'Split terminal right' + )} + {splitRightShortcut} + + splitActiveTerminalPane('horizontal')}> + + {translate( + 'auto.components.tab.bar.SortableTabContextMenu.splitTerminalDown', + 'Split terminal down' + )} + {splitDownShortcut} + + + + {trailingSeparator ? : null} + + ) +} diff --git a/src/renderer/src/components/tab-bar/tab-agent-types-by-tab-id.test.ts b/src/renderer/src/components/tab-bar/tab-agent-types-by-tab-id.test.ts new file mode 100644 index 00000000000..75ac3995670 --- /dev/null +++ b/src/renderer/src/components/tab-bar/tab-agent-types-by-tab-id.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest' +import { shallow } from 'zustand/shallow' +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import { findTabAgentEntry } from '../native-chat/native-chat-tab-agent-entry' +import { selectTabAgentTypesByTabId } from './tab-agent-types-by-tab-id' + +function entry(partial: Partial): AgentStatusEntry { + return { state: 'working', updatedAt: 0, ...partial } as AgentStatusEntry +} + +describe('selectTabAgentTypesByTabId', () => { + it('maps each tab to its first pane agent type, matching findTabAgentEntry', () => { + const map: Record = { + 'tab-1:leaf-a': entry({ agentType: 'claude' }), + 'tab-1:leaf-b': entry({ agentType: 'codex' }), + 'tab-2:leaf-a': entry({ agentType: 'codex' }) + } + const projection = selectTabAgentTypesByTabId(map) + expect(projection).toEqual({ 'tab-1': 'claude', 'tab-2': 'codex' }) + + // Parity with the lookup it replaces, for every tab. + for (const tabId of ['tab-1', 'tab-2', 'tab-missing']) { + expect(projection[tabId] ?? null).toBe(findTabAgentEntry(map, tabId)?.agentType ?? null) + } + }) + + it('a first pane without an agentType yields null even if a later pane has one', () => { + const map: Record = { + 'tab-1:leaf-a': entry({ agentType: undefined }), + 'tab-1:leaf-b': entry({ agentType: 'claude' }) + } + // First matching pane wins (claims the tab) with no agentType -> null, exactly + // like findTabAgentEntry(...)?.agentType ?? null. + expect(selectTabAgentTypesByTabId(map)['tab-1'] ?? null).toBe( + findTabAgentEntry(map, 'tab-1')?.agentType ?? null + ) + }) + + it('stays shallow-equal across a working<->idle status flip (no re-render)', () => { + const working: Record = { + 'tab-1:leaf-a': entry({ agentType: 'claude', state: 'working' }) + } + // A status write replaces the entry object but not the agentType. + const done: Record = { + 'tab-1:leaf-a': entry({ agentType: 'claude', state: 'done' }) + } + expect(shallow(selectTabAgentTypesByTabId(working), selectTabAgentTypesByTabId(done))).toBe( + true + ) + }) + + it('shallow-changes when a tab gains or changes its agent', () => { + const before = selectTabAgentTypesByTabId({ + 'tab-1:leaf-a': entry({ agentType: 'claude' }) + }) + const gained = selectTabAgentTypesByTabId({ + 'tab-1:leaf-a': entry({ agentType: 'claude' }), + 'tab-2:leaf-a': entry({ agentType: 'codex' }) + }) + expect(shallow(before, gained)).toBe(false) + }) + + it('ignores malformed pane keys with no tab id', () => { + expect(selectTabAgentTypesByTabId({ ':leaf-a': entry({ agentType: 'claude' }) })).toEqual({}) + }) +}) diff --git a/src/renderer/src/components/tab-bar/tab-agent-types-by-tab-id.ts b/src/renderer/src/components/tab-bar/tab-agent-types-by-tab-id.ts new file mode 100644 index 00000000000..5fc8335a93c --- /dev/null +++ b/src/renderer/src/components/tab-bar/tab-agent-types-by-tab-id.ts @@ -0,0 +1,41 @@ +import type { AgentStatusEntry, AgentType } from '../../../../shared/agent-status-types' + +/** + * Project `agentStatusByPaneKey` down to the stable `{ terminalTabId: agentType }` + * the tab strip actually reads (to gate the native-chat view-mode toggle). + * + * Why: agent-status pane keys are `${terminalTab.id}:${leafId}` and the tab strip + * only needs each tab's agent *identity* — which is fixed for the life of the + * agent. The full `agentStatusByPaneKey` map, however, gets a new top-level + * identity on every working↔idle status transition app-wide, so subscribing to it + * whole re-rendered every mounted tab strip on unrelated status churn. Selecting + * this projection under `useShallow` keeps the result referentially equal across + * those transitions, so the strip re-renders only when a tab actually gains, loses, + * or changes its agent. + * + * First matching pane per tab wins, mirroring `findTabAgentEntry` exactly (tab ids + * are colon-free by construction, so the substring before the first `:` is the + * tab id). A pane whose entry has no `agentType` still claims the tab and yields + * `null`, identical to `findTabAgentEntry(...)?.agentType ?? null`. + */ +export function selectTabAgentTypesByTabId( + agentStatusByPaneKey: Record +): Record { + const byTabId: Record = {} + const claimed = new Set() + for (const [paneKey, entry] of Object.entries(agentStatusByPaneKey)) { + const colon = paneKey.indexOf(':') + if (colon <= 0) { + continue + } + const tabId = paneKey.slice(0, colon) + if (claimed.has(tabId)) { + continue + } + claimed.add(tabId) + if (entry.agentType != null) { + byTabId[tabId] = entry.agentType + } + } + return byTabId +} diff --git a/src/renderer/src/components/tab-group/TabGroupPanel.tsx b/src/renderer/src/components/tab-group/TabGroupPanel.tsx index 57dbf5946fc..e6f237c9bc3 100644 --- a/src/renderer/src/components/tab-group/TabGroupPanel.tsx +++ b/src/renderer/src/components/tab-group/TabGroupPanel.tsx @@ -29,6 +29,10 @@ export default function TabGroupPanel({ hasSplitGroups, touchesRightEdge, touchesLeftEdge, + touchesBottomEdge = false, + suppressLeftBorder = false, + suppressRightBorder = false, + suppressBottomBorder = false, reserveClosedExplorerToggleSpace, reserveCollapsedSidebarHeaderSpace, isTabDragActive = false, @@ -40,6 +44,10 @@ export default function TabGroupPanel({ hasSplitGroups: boolean touchesRightEdge: boolean touchesLeftEdge: boolean + touchesBottomEdge?: boolean + suppressLeftBorder?: boolean + suppressRightBorder?: boolean + suppressBottomBorder?: boolean reserveClosedExplorerToggleSpace: boolean reserveCollapsedSidebarHeaderSpace: boolean isTabDragActive?: boolean @@ -205,7 +213,13 @@ export default function TabGroupPanel({ // border-l/border-r in those spots stacks a second 1px line // next to it, reading as a ~2px bar below the drag strip // (where the sibling border continues alone above). - ` ${touchesLeftEdge ? '' : 'border-l'} ${touchesRightEdge ? '' : 'border-r'} border-border border-b ${isFocused ? 'border-b-accent' : 'opacity-95'}` + ` ${ + touchesLeftEdge || suppressLeftBorder ? '' : 'border-l' + } ${touchesRightEdge || suppressRightBorder ? '' : 'border-r'} ${ + touchesBottomEdge || suppressBottomBorder ? '' : 'border-b' + } border-border ${ + isFocused && !touchesBottomEdge && !suppressBottomBorder ? 'border-b-accent' : '' + } ${isFocused ? '' : 'opacity-95'}` : '' }`} onPointerDown={commands.focusGroup} diff --git a/src/renderer/src/components/tab-group/TabGroupSplitLayout.tsx b/src/renderer/src/components/tab-group/TabGroupSplitLayout.tsx index db37a19d241..04cc6536249 100644 --- a/src/renderer/src/components/tab-group/TabGroupSplitLayout.tsx +++ b/src/renderer/src/components/tab-group/TabGroupSplitLayout.tsx @@ -103,9 +103,9 @@ function ResizeHandle({ return (
) @@ -121,6 +121,10 @@ function SplitNode({ touchesTopEdge, touchesRightEdge, touchesLeftEdge, + touchesBottomEdge, + suppressLeftBorder, + suppressRightBorder, + suppressBottomBorder, isTabDragActive, hoveredTabInsertion }: { @@ -133,6 +137,10 @@ function SplitNode({ touchesTopEdge: boolean touchesRightEdge: boolean touchesLeftEdge: boolean + touchesBottomEdge: boolean + suppressLeftBorder: boolean + suppressRightBorder: boolean + suppressBottomBorder: boolean isTabDragActive: boolean hoveredTabInsertion: HoveredTabInsertion | null }): React.JSX.Element { @@ -152,6 +160,10 @@ function SplitNode({ hasSplitGroups={hasSplitGroups} touchesRightEdge={touchesRightEdge} touchesLeftEdge={touchesLeftEdge} + touchesBottomEdge={touchesBottomEdge} + suppressLeftBorder={suppressLeftBorder} + suppressRightBorder={suppressRightBorder} + suppressBottomBorder={suppressBottomBorder} reserveClosedExplorerToggleSpace={touchesTopEdge && touchesRightEdge} reserveCollapsedSidebarHeaderSpace={touchesTopEdge && touchesLeftEdge} isTabDragActive={isTabDragActive} @@ -181,6 +193,12 @@ function SplitNode({ touchesTopEdge={touchesTopEdge} touchesRightEdge={isHorizontal ? false : touchesRightEdge} touchesLeftEdge={touchesLeftEdge} + touchesBottomEdge={isHorizontal ? touchesBottomEdge : false} + suppressLeftBorder={suppressLeftBorder} + // Why: the resize handle paints the inner seam — pane borders here + // stack into a triple-line bar beside the divider. + suppressRightBorder={isHorizontal ? true : suppressRightBorder} + suppressBottomBorder={isHorizontal ? suppressBottomBorder : true} isTabDragActive={isTabDragActive} hoveredTabInsertion={hoveredTabInsertion} /> @@ -201,6 +219,10 @@ function SplitNode({ touchesTopEdge={isHorizontal ? touchesTopEdge : false} touchesRightEdge={touchesRightEdge} touchesLeftEdge={isHorizontal ? false : touchesLeftEdge} + touchesBottomEdge={touchesBottomEdge} + suppressLeftBorder={isHorizontal ? true : suppressLeftBorder} + suppressRightBorder={suppressRightBorder} + suppressBottomBorder={suppressBottomBorder} isTabDragActive={isTabDragActive} hoveredTabInsertion={hoveredTabInsertion} /> @@ -275,6 +297,10 @@ export default function TabGroupSplitLayout({ touchesTopEdge={true} touchesRightEdge={true} touchesLeftEdge={true} + touchesBottomEdge={false} + suppressLeftBorder={false} + suppressRightBorder={false} + suppressBottomBorder={false} isTabDragActive={dragSplit.activeDrag !== null} hoveredTabInsertion={dragSplit.hoveredTabInsertion} /> diff --git a/src/renderer/src/components/terminal-pane/cache-timer-seeding.ts b/src/renderer/src/components/terminal-pane/cache-timer-seeding.ts index 6f788af3e28..7b1d16522d5 100644 --- a/src/renderer/src/components/terminal-pane/cache-timer-seeding.ts +++ b/src/renderer/src/components/terminal-pane/cache-timer-seeding.ts @@ -1,4 +1,5 @@ -import { detectAgentStatusFromTitle, isClaudeAgent } from '@/lib/agent-status' +import { isClaudeAgent } from '@/lib/agent-status' +import { classifyTitleActivity } from '@/lib/pane-agent-evidence' export function shouldSeedCacheTimerOnInitialTitle(args: { rawTitle: string @@ -12,7 +13,7 @@ export function shouldSeedCacheTimerOnInitialTitle(args: { return false } - const status = detectAgentStatusFromTitle(rawTitle) + const status = classifyTitleActivity(rawTitle) if (status === null || status === 'working') { return false } diff --git a/src/renderer/src/components/terminal-pane/native-chat-leaf-title-agent.ts b/src/renderer/src/components/terminal-pane/native-chat-leaf-title-agent.ts index e3527bf11ac..20f24fb6986 100644 --- a/src/renderer/src/components/terminal-pane/native-chat-leaf-title-agent.ts +++ b/src/renderer/src/components/terminal-pane/native-chat-leaf-title-agent.ts @@ -1,5 +1,5 @@ import type { TuiAgent } from '../../../../shared/types' -import { resolveTabAgentFromTitle } from '@/lib/use-tab-agent' +import { resolveCommittedTitleAgentType } from '@/lib/pane-agent-evidence' export type NativeChatLeafTitlePane = { id: number @@ -26,7 +26,7 @@ export function resolveNativeChatLeafTitleAgent({ } const targetPane = panes.find((pane) => pane.leafId === leafId) const paneAgent = targetPane - ? resolveTabAgentFromTitle(runtimePaneTitlesByPaneId[targetPane.id] ?? '') + ? resolveCommittedTitleAgentType(runtimePaneTitlesByPaneId[targetPane.id] ?? '') : null if (paneAgent) { return paneAgent @@ -36,5 +36,8 @@ export function resolveNativeChatLeafTitleAgent({ if (panes.length > 1) { return null } - return resolveTabAgentFromTitle(tabLabel ?? '') ?? resolveTabAgentFromTitle(terminalTitle ?? '') + return ( + resolveCommittedTitleAgentType(tabLabel ?? '') ?? + resolveCommittedTitleAgentType(terminalTitle ?? '') + ) } diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 7337df84310..49432d691f0 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -983,6 +983,34 @@ describe('connectPanePty', () => { ) }) + it('drops keystrokes while the replay guard is engaged, then forwards once it releases', async () => { + // Regression for the cold-restore reattach lockout: handleReattachResult's + // replay writes engaged the per-pane replay guard, and because xterm's parse + // callback never fired for the just-mounted pane the counter stuck non-zero. + // The onData handler then dropped EVERY keystroke — a live but unresponsive + // pane ("can't type after reconnecting"). This locks in the drop-site + // contract: engaged guard suppresses input, released guard forwards it. + const { connectPanePty } = await import('./pty-connection') + const pane = createPane(1) + const transport = createMockTransport('ssh:ssh-1@@pty-1') + transportFactoryQueue.push(transport) + const deps = createDeps() + + connectPanePty(pane as never, createManager(1, 1) as never, deps as never) + await flushAsyncTicks() + + transport.sendInput.mockClear() + // Engaged (as a stuck reattach would leave it): input must be suppressed. + deps.replayingPanesRef.current.set(pane.id, 3) + sendTerminalInputThroughPane(pane, 'echo hi\r') + expect(transport.sendInput).not.toHaveBeenCalled() + + // Released (via the guard's fallback or parse completion): input flows again. + deps.replayingPanesRef.current.delete(pane.id) + sendTerminalInputThroughPane(pane, 'echo hi\r') + expect(transport.sendInput).toHaveBeenCalledWith('echo hi\r') + }) + it('normalizes Pi-compatible remote runtime status to OMP after typed omp command', async () => { const { connectPanePty } = await import('./pty-connection') enableActiveRuntimeEnvironment() @@ -4115,7 +4143,11 @@ describe('connectPanePty', () => { mockStoreState = { ...mockStoreState, tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] }, - repos: [{ id: 'repo1', connectionId: 'ssh-conn-1' }] + repos: [{ id: 'repo1', connectionId: 'ssh-conn-1' }], + // Why: startup delivery assumes a live connection; a disconnected + // target now routes through the deferred-connect gate instead of + // spawning synchronously. + sshConnectionStates: new Map([['ssh-conn-1', { status: 'connected' }]]) } const pane = createPane(1) @@ -4165,7 +4197,11 @@ describe('connectPanePty', () => { mockStoreState = { ...mockStoreState, tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] }, - repos: [{ id: 'repo1', connectionId: 'ssh-conn-1' }] + repos: [{ id: 'repo1', connectionId: 'ssh-conn-1' }], + // Why: startup delivery assumes a live connection; a disconnected + // target now routes through the deferred-connect gate instead of + // spawning synchronously. + sshConnectionStates: new Map([['ssh-conn-1', { status: 'connected' }]]) } const pane = createPane(1) @@ -4303,7 +4339,11 @@ describe('connectPanePty', () => { mockStoreState = { ...mockStoreState, tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] }, - repos: [{ id: 'repo1', connectionId: 'ssh-conn-1' }] + repos: [{ id: 'repo1', connectionId: 'ssh-conn-1' }], + // Why: startup delivery assumes a live connection; a disconnected + // target now routes through the deferred-connect gate instead of + // spawning synchronously. + sshConnectionStates: new Map([['ssh-conn-1', { status: 'connected' }]]) } const pane = createPane(1) @@ -4358,7 +4398,11 @@ describe('connectPanePty', () => { mockStoreState = { ...mockStoreState, tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] }, - repos: [{ id: 'repo1', connectionId: 'ssh-conn-1' }] + repos: [{ id: 'repo1', connectionId: 'ssh-conn-1' }], + // Why: startup delivery assumes a live connection; a disconnected + // target now routes through the deferred-connect gate instead of + // spawning synchronously. + sshConnectionStates: new Map([['ssh-conn-1', { status: 'connected' }]]) } const pane = createPane(1) @@ -4413,7 +4457,11 @@ describe('connectPanePty', () => { mockStoreState = { ...mockStoreState, tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] }, - repos: [{ id: 'repo1', connectionId: 'ssh-conn-1' }] + repos: [{ id: 'repo1', connectionId: 'ssh-conn-1' }], + // Why: startup delivery assumes a live connection; a disconnected + // target now routes through the deferred-connect gate instead of + // spawning synchronously. + sshConnectionStates: new Map([['ssh-conn-1', { status: 'connected' }]]) } const pane = createPane(1) @@ -4465,7 +4513,11 @@ describe('connectPanePty', () => { mockStoreState = { ...mockStoreState, tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] }, - repos: [{ id: 'repo1', connectionId: 'ssh-conn-1' }] + repos: [{ id: 'repo1', connectionId: 'ssh-conn-1' }], + // Why: startup delivery assumes a live connection; a disconnected + // target now routes through the deferred-connect gate instead of + // spawning synchronously. + sshConnectionStates: new Map([['ssh-conn-1', { status: 'connected' }]]) } const wrapperCommand = 'bash -lc wait-for-setup-wrapper' @@ -4936,6 +4988,67 @@ describe('connectPanePty', () => { expect(deps.updateTabPtyId).toHaveBeenCalledWith('tab-1', 'fresh-pty') }) + it('reattaches via the tab-level SSH pty id when deferred bookkeeping missed the tab', async () => { + // Why: restore can miss the deferred maps (e.g. activeConnectionIdsAtShutdown + // wasn't persisted). The tab's own app SSH pty id must still drive a + // connect-then-reattach instead of a fresh spawn into a missing provider. + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'ssh:conn-1@@pty-7' }] }, + ptyIdsByTabId: { 'tab-1': [] }, + repos: [{ id: 'repo1', connectionId: 'conn-1' }], + sshConnectionStates: new Map() + } as StoreState + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(12) + + const windowApi = (globalThis as unknown as { window: { api: { ssh: { connect: unknown } } } }) + .window.api + expect(windowApi.ssh.connect).toHaveBeenCalledWith({ targetId: 'conn-1' }) + expect(transport.connect).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: 'ssh:conn-1@@pty-7' }) + ) + expect(deps.onPtyErrorRef.current).not.toHaveBeenCalled() + }) + + it('connects a disconnected SSH target before fresh-spawning instead of erroring', async () => { + // Why: spawning against a disconnected target throws "No PTY provider" + // and strands the pane behind a toast that never retries. + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('fresh-ssh-pty') + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] }, + ptyIdsByTabId: { 'tab-1': [] }, + repos: [{ id: 'repo1', connectionId: 'conn-1' }], + sshConnectionStates: new Map() + } as StoreState + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + // Why: no spawn may fire before the SSH connection is established. + expect(transport.connect).not.toHaveBeenCalled() + await flushAsyncTicks(12) + + const windowApi = (globalThis as unknown as { window: { api: { ssh: { connect: unknown } } } }) + .window.api + expect(windowApi.ssh.connect).toHaveBeenCalledWith({ targetId: 'conn-1' }) + expect(transport.connect).toHaveBeenCalledWith( + expect.not.objectContaining({ sessionId: expect.any(String) }) + ) + expect(deps.onPtyErrorRef.current).not.toHaveBeenCalled() + }) + it('spawns a fresh PTY when a non-deferred SSH reattach reports expired via onError', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport() diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 7277ddca2e5..e9494c6722b 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -108,6 +108,7 @@ import { makePaneKey, parseLegacyNumericPaneKey } from '../../../../shared/stabl import { createTerminalCommandLifecycle } from './terminal-command-lifecycle' import { createPaneForegroundAgentTracker } from './pane-foreground-agent-tracker' import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id' +import { resolveSshPaneConnectGate } from './ssh-pane-connect-gate' import { dispatchTerminalCommandFinishedEvent } from '@/hooks/terminal-command-finished-event' import { e2eConfig } from '@/lib/e2e-config' import { @@ -183,7 +184,7 @@ import { normalizeCompatibleAgentTitleForOwner, resolveCompatibleAgentTypeForOwner } from '../../../../shared/agent-title-owner' -import { resolveExplicitTerminalTitleAgentType } from '../../../../shared/terminal-title-agent-type' +import { resolveCommittedTitleAgentType } from '@/lib/pane-agent-evidence' import { isExpectedAgentProcess, recognizeAgentProcessFromCommandLine @@ -1675,7 +1676,7 @@ export function connectPanePty( (entry) => entry.id === deps.tabId ) const title = currentTitle ?? tab?.title - if (!title || resolveExplicitTerminalTitleAgentType(title) === null) { + if (!title || resolveCommittedTitleAgentType(title) === null) { return } const neutralTitle = neutralTerminalTitle() @@ -6002,13 +6003,23 @@ export function connectPanePty( deps.restoredLeafId && deps.restoredPtyIdByLeafId ? (deps.restoredPtyIdByLeafId[deps.restoredLeafId] ?? null) : null - const pendingSessionId = - restoredLeafSessionId ?? storeState.deferredSshSessionIdsByTabId[deps.tabId] - const isDeferredTarget = storeState.deferredSshReconnectTargets.includes(connectionId) + const gate = resolveSshPaneConnectGate({ + connectionId, + sshStatus: storeState.sshConnectionStates.get(connectionId)?.status, + isDeferredTarget: storeState.deferredSshReconnectTargets.includes(connectionId), + restoredLeafSessionId, + deferredTabSessionId: storeState.deferredSshSessionIdsByTabId[deps.tabId], + tabPtyId: storeState.tabsByWorktree[deps.worktreeId]?.find((t) => t.id === deps.tabId) + ?.ptyId, + hasLeafSessionMap: Boolean( + deps.restoredPtyIdByLeafId && Object.keys(deps.restoredPtyIdByLeafId).length > 0 + ) + }) + const pendingSessionId = gate.pendingSessionId console.warn( - `[pty-connection] SSH tab=${deps.tabId} connectionId=${connectionId} pendingSessionId=${pendingSessionId} isDeferredTarget=${isDeferredTarget}` + `[pty-connection] SSH tab=${deps.tabId} connectionId=${connectionId} pendingSessionId=${pendingSessionId} sshConnected=${gate.sshConnected}` ) - if (pendingSessionId || isDeferredTarget) { + if (gate.enterDeferredFlow) { void (async () => { // Why: if the target requires a passphrase/password and no credential // is cached yet, auto-firing ssh.connect would surprise the user — diff --git a/src/renderer/src/components/terminal-pane/pty-transport.test.ts b/src/renderer/src/components/terminal-pane/pty-transport.test.ts index c29517e59ed..ad09ceca61c 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.test.ts @@ -1391,6 +1391,50 @@ describe('createIpcPtyTransport', () => { expect(onError).not.toHaveBeenCalled() }) + it('recovers a stale cross-connection SSH reattach as expired instead of a red error toast', async () => { + // Why: a restored SSH pty id embeds the connection it was created under. If + // the pane reattaches under a different connection the main-side router + // rejects with "belongs to SSH connection" — that session is unreachable, so + // we drop it (sessionExpired) and spawn fresh rather than surfacing a crash. + const { createIpcPtyTransport } = await import('./pty-transport') + const spawnMock = vi + .fn() + .mockRejectedValue( + new Error( + 'PTY ssh:ssh-1779863656395-57g1q1@@pty-3 belongs to SSH connection "ssh-1779863656395-57g1q1"' + ) + ) + ;(globalThis as { window: typeof window }).window = { + ...originalWindow, + api: { + ...originalWindow?.api, + pty: { + ...originalWindow?.api?.pty, + spawn: spawnMock, + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + onData: vi.fn(() => () => {}), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn(() => () => {}) + } + } + } as unknown as typeof window + + const onError = vi.fn() + const result = await createIpcPtyTransport({ connectionId: 'ssh-other' }).connect({ + url: '', + sessionId: 'ssh:ssh-1779863656395-57g1q1@@pty-3', + callbacks: { onError } + }) + + expect(onError).not.toHaveBeenCalled() + expect(result).toEqual({ + id: 'ssh:ssh-1779863656395-57g1q1@@pty-3', + sessionExpired: true + }) + }) + it('surfaces terminal session state save failures without the Electron IPC wrapper', async () => { const { createIpcPtyTransport } = await import('./pty-transport') const wrappedMessage = `Error invoking remote method 'pty:spawn': Error: ${createTerminalSessionStateSaveFailureMessage()}` diff --git a/src/renderer/src/components/terminal-pane/pty-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport.ts index 41ae33de55c..c624edd01d9 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.ts @@ -57,6 +57,12 @@ export type { export { extractLastOscTitle } from '../../../../shared/agent-detection' const SSH_SESSION_EXPIRED_ERROR = 'SSH_SESSION_EXPIRED' +// Why: an app SSH PTY id embeds the connection it was created under. When a pane +// restored after a workspace/host change reattaches a session that belongs to a +// *different* connection, the main-side id router rejects it with this phrase. +// That session is unreachable from this pane, so it is stale like an expired one +// — recover by spawning fresh rather than surfacing a red "file an issue" crash. +const SSH_PTY_CONNECTION_MISMATCH_MARKER = 'belongs to SSH connection' const STALE_TITLE_TIMEOUT = 3000 // ms before stale working title is cleared const MAX_PTY_SIDE_EFFECTS_PER_DRAIN = 64 @@ -699,7 +705,12 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra return spawnResult.id } catch (err) { const msg = extractIpcErrorMessage(err, err instanceof Error ? err.message : String(err)) - if (connectionId && options.sessionId && msg.includes(SSH_SESSION_EXPIRED_ERROR)) { + if ( + connectionId && + options.sessionId && + (msg.includes(SSH_SESSION_EXPIRED_ERROR) || + msg.includes(SSH_PTY_CONNECTION_MISMATCH_MARKER)) + ) { return { id: options.sessionId, sessionExpired: true diff --git a/src/renderer/src/components/terminal-pane/replay-guard.test.ts b/src/renderer/src/components/terminal-pane/replay-guard.test.ts index c1e2788f15d..c26acbfca7c 100644 --- a/src/renderer/src/components/terminal-pane/replay-guard.test.ts +++ b/src/renderer/src/components/terminal-pane/replay-guard.test.ts @@ -157,6 +157,74 @@ describe('replay-guard', () => { expect(ref.current.has(1)).toBe(false) }) + it('auto-releases the guard when xterm never fires the parse callback', () => { + // Repro of the cold-restore reattach lockout (main #7661): + // handleReattachResult replays three chunks into a just-mounted / + // offscreen pane whose terminal never flushes, so xterm's parse callback + // never runs and the counter would stay pinned at 3 — isPaneReplaying() + // stuck true drops EVERY keystroke. The probe-certified stall path (probe + // never parses either => wedged release) must free the guard. + vi.useFakeTimers() + try { + const ref = makeRef() + const { pane } = makeFakePane(1) + replayIntoTerminal(pane, ref, '\x1b[2J\x1b[3J\x1b[H', 400) + replayIntoTerminal(pane, ref, 'scrollback bytes', 400) + replayIntoTerminal(pane, ref, '--- session restored ---', 400) + expect(isPaneReplaying(ref, 1)).toBe(true) + + // Never flush — the probe write never parses; the wedged release fires + // one stall window after the probe (400 + 400). + vi.advanceTimersByTime(1000) + + expect(isPaneReplaying(ref, 1)).toBe(false) + expect(ref.current.has(1)).toBe(false) + } finally { + vi.useRealTimers() + } + }) + + it('parse completion cancels the stall probe without over-releasing', () => { + vi.useFakeTimers() + try { + const ref = makeRef() + const { pane, terminal } = makeFakePane(1) + replayIntoTerminal(pane, ref, 'a', 400) + replayIntoTerminal(pane, ref, 'b', 400) + + terminal.flush() + expect(isPaneReplaying(ref, 1)).toBe(false) + + // The already-cancelled stall timer must not fire and underflow the counter. + vi.advanceTimersByTime(1000) + expect(isPaneReplaying(ref, 1)).toBe(false) + expect(ref.current.has(1)).toBe(false) + } finally { + vi.useRealTimers() + } + }) + + it('async replay resolves even when the parse callback never fires', async () => { + vi.useFakeTimers() + try { + const ref = makeRef() + const { pane } = makeFakePane(1) + let resolved = false + const promise = replayIntoTerminalAsync(pane, ref, 'x', 400).then(() => { + resolved = true + }) + expect(isPaneReplaying(ref, 1)).toBe(true) + + await vi.advanceTimersByTimeAsync(1000) + await promise + + expect(resolved).toBe(true) + expect(isPaneReplaying(ref, 1)).toBe(false) + } finally { + vi.useRealTimers() + } + }) + it('schedules a follow-up repaint for replayed cursor restores', () => { const scheduledFrames: FrameRequestCallback[] = [] const originalRequestAnimationFrame = globalThis.requestAnimationFrame diff --git a/src/renderer/src/components/terminal-pane/ssh-pane-connect-gate.test.ts b/src/renderer/src/components/terminal-pane/ssh-pane-connect-gate.test.ts new file mode 100644 index 00000000000..0ecf6311628 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/ssh-pane-connect-gate.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest' +import { resolveSshPaneConnectGate } from './ssh-pane-connect-gate' + +const BASE = { + connectionId: 'conn-1', + sshStatus: undefined as string | undefined, + isDeferredTarget: false, + restoredLeafSessionId: null as string | null, + deferredTabSessionId: undefined as string | undefined, + tabPtyId: null as string | null, + hasLeafSessionMap: false +} + +describe('resolveSshPaneConnectGate', () => { + it('routes a disconnected target through the deferred flow even without a session id', () => { + const gate = resolveSshPaneConnectGate(BASE) + expect(gate).toEqual({ pendingSessionId: null, enterDeferredFlow: true, sshConnected: false }) + }) + + it('skips the deferred flow for a connected target with no restore state', () => { + const gate = resolveSshPaneConnectGate({ ...BASE, sshStatus: 'connected' }) + expect(gate).toEqual({ pendingSessionId: null, enterDeferredFlow: false, sshConnected: true }) + }) + + it('falls back to the tab-level app SSH pty id when the deferred maps missed the tab', () => { + const gate = resolveSshPaneConnectGate({ ...BASE, tabPtyId: 'ssh:conn-1@@pty-7' }) + expect(gate.pendingSessionId).toBe('ssh:conn-1@@pty-7') + expect(gate.enterDeferredFlow).toBe(true) + }) + + it('ignores a tab pty id that belongs to a different connection', () => { + const gate = resolveSshPaneConnectGate({ ...BASE, tabPtyId: 'ssh:conn-2@@pty-7' }) + expect(gate.pendingSessionId).toBeNull() + }) + + it('ignores the tab-level fallback when a per-leaf session map exists', () => { + // Why: every leaf of a split mounts its own pane; the tab-level id must + // not be reattached by all of them. + const gate = resolveSshPaneConnectGate({ + ...BASE, + tabPtyId: 'ssh:conn-1@@pty-7', + hasLeafSessionMap: true + }) + expect(gate.pendingSessionId).toBeNull() + }) + + it('ignores the tab-level fallback once connected — live panes attach normally', () => { + const gate = resolveSshPaneConnectGate({ + ...BASE, + sshStatus: 'connected', + tabPtyId: 'ssh:conn-1@@pty-7' + }) + expect(gate.pendingSessionId).toBeNull() + expect(gate.enterDeferredFlow).toBe(false) + }) + + it('prefers the restored leaf session, then the deferred map, then the fallback', () => { + const gate = resolveSshPaneConnectGate({ + ...BASE, + restoredLeafSessionId: 'ssh:conn-1@@pty-1', + deferredTabSessionId: 'ssh:conn-1@@pty-2', + tabPtyId: 'ssh:conn-1@@pty-3' + }) + expect(gate.pendingSessionId).toBe('ssh:conn-1@@pty-1') + }) + + it('still enters the deferred flow for deferred targets that already connected', () => { + // Why: connecting via Settings does not remove the target from the + // deferred list; the gate consumes it and reattaches. + const gate = resolveSshPaneConnectGate({ + ...BASE, + sshStatus: 'connected', + isDeferredTarget: true + }) + expect(gate.enterDeferredFlow).toBe(true) + }) + + it('does not force a connect for runtime-owned targets', () => { + // Why: their relay health is owned by the runtime layer; users cannot + // connect to them directly, so a reconnect flow would strand the pane. + const gate = resolveSshPaneConnectGate({ + ...BASE, + connectionId: 'runtime-ssh-env-1' + }) + expect(gate.enterDeferredFlow).toBe(false) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/ssh-pane-connect-gate.ts b/src/renderer/src/components/terminal-pane/ssh-pane-connect-gate.ts new file mode 100644 index 00000000000..3e2367f492c --- /dev/null +++ b/src/renderer/src/components/terminal-pane/ssh-pane-connect-gate.ts @@ -0,0 +1,51 @@ +import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id' +import { isRuntimeOwnedSshTargetId } from '../../../../shared/execution-host' + +export type SshPaneConnectGate = { + /** Session the pane should reattach after connecting (null → fresh spawn). */ + pendingSessionId: string | null + /** True when the pane must run the deferred-connect flow before any spawn. */ + enterDeferredFlow: boolean + sshConnected: boolean +} + +// Why: an SSH pane must never call pty:spawn while its target's provider is +// unregistered — main rejects with "No PTY provider" and the pane strands +// behind an error toast that never retries. This decides, from restore +// bookkeeping plus the live connection state, whether the pane needs the +// deferred-connect flow first and which session it should reattach. +export function resolveSshPaneConnectGate(input: { + connectionId: string + sshStatus: string | undefined + isDeferredTarget: boolean + restoredLeafSessionId: string | null + deferredTabSessionId: string | undefined + tabPtyId: string | null | undefined + hasLeafSessionMap: boolean +}): SshPaneConnectGate { + const sshConnected = input.sshStatus === 'connected' + // Why: the deferred maps can miss a tab (e.g. activeConnectionIdsAtShutdown + // wasn't persisted, so restore registered no deferred target). The tab's own + // restored app SSH pty id still names the session — reattach it rather than + // spawning a replacement shell. Skipped when a per-leaf session map exists: + // leaves carry their own ids and the tab-level id must not be attached by + // every leaf of a split. + const fallbackTabSessionId = + !sshConnected && + !input.hasLeafSessionMap && + input.tabPtyId && + parseAppSshPtyId(input.tabPtyId)?.connectionId === input.connectionId + ? input.tabPtyId + : null + const pendingSessionId = + input.restoredLeafSessionId ?? input.deferredTabSessionId ?? fallbackTabSessionId ?? null + // Why: runtime-owned targets are excluded — their relay health is owned by + // the runtime layer and users cannot connect to them directly. + const needsConnectBeforeSpawn = !sshConnected && !isRuntimeOwnedSshTargetId(input.connectionId) + return { + pendingSessionId, + enterDeferredFlow: + Boolean(pendingSessionId) || input.isDeferredTarget || needsConnectBeforeSpawn, + sshConnected + } +} diff --git a/src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts b/src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts new file mode 100644 index 00000000000..c0112fa2c41 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts @@ -0,0 +1,183 @@ +import { describe, expect, it } from 'vitest' +import { + armTerminalImePendingCandidateKeyRelease, + clearTerminalImePendingCandidateKeyRelease, + createTerminalImePendingCandidateKeyReleases, + isTerminalImeCandidateSelectionKeyEvent, + shouldApplyTerminalImePendingCandidateKeyRelease +} from './terminal-ime-candidate-key-release-guard' +import { TERMINAL_IME_CANDIDATE_GUARD_POST_COMPOSITION_MS } from './terminal-ime-composition-tracker' +import { event } from './xterm-bypass-event-fixture' + +describe('terminal IME candidate key release guard', () => { + it('recognizes only unmodified Space and digit candidate selectors', () => { + expect(isTerminalImeCandidateSelectionKeyEvent(event({ key: ' ' }))).toBe(true) + expect(isTerminalImeCandidateSelectionKeyEvent(event({ key: '2' }))).toBe(true) + expect(isTerminalImeCandidateSelectionKeyEvent(event({ key: 'a' }))).toBe(false) + expect(isTerminalImeCandidateSelectionKeyEvent(event({ key: ' ', ctrlKey: true }))).toBe(false) + // Shift+Space is fcitx's full-/half-width toggle, not a candidate selector. + expect(isTerminalImeCandidateSelectionKeyEvent(event({ key: ' ', shiftKey: true }))).toBe(false) + }) + + it('arms a pending release guard from a suppressed candidate keydown', () => { + const releases = createTerminalImePendingCandidateKeyReleases() + armTerminalImePendingCandidateKeyRelease(releases, event({ key: '2' }), 10) + expect(releases.get('2')).toBe(10 + TERMINAL_IME_CANDIDATE_GUARD_POST_COMPOSITION_MS) + }) + + it('does not arm from keyup, non-candidate, or Shift-modified keys', () => { + const releases = createTerminalImePendingCandidateKeyReleases() + armTerminalImePendingCandidateKeyRelease(releases, event({ type: 'keyup', key: '2' }), 10) + armTerminalImePendingCandidateKeyRelease(releases, event({ key: 'a' }), 10) + armTerminalImePendingCandidateKeyRelease(releases, event({ key: ' ', shiftKey: true }), 10) + expect(releases.size).toBe(0) + }) + + it('guards the matching keypress and keyup after insertText clears the tracker', () => { + const releases = createTerminalImePendingCandidateKeyReleases() + armTerminalImePendingCandidateKeyRelease(releases, event({ key: ' ' }), 10) + expect( + shouldApplyTerminalImePendingCandidateKeyRelease( + event({ type: 'keypress', key: ' ' }), + releases, + 20 + ) + ).toBe(true) + expect( + shouldApplyTerminalImePendingCandidateKeyRelease( + event({ type: 'keyup', key: ' ' }), + releases, + 20 + ) + ).toBe(true) + }) + + it('does not guard fresh keydowns, other keys, modified keypresses, or expired keypresses', () => { + const releases = createTerminalImePendingCandidateKeyReleases() + armTerminalImePendingCandidateKeyRelease(releases, event({ key: '2' }), 10) + expect( + shouldApplyTerminalImePendingCandidateKeyRelease(event({ key: '2' }), releases, 20) + ).toBe(false) + expect( + shouldApplyTerminalImePendingCandidateKeyRelease( + event({ type: 'keyup', key: '3' }), + releases, + 20 + ) + ).toBe(false) + expect( + shouldApplyTerminalImePendingCandidateKeyRelease( + event({ type: 'keypress', key: '2', ctrlKey: true }), + releases, + 20 + ) + ).toBe(false) + expect( + shouldApplyTerminalImePendingCandidateKeyRelease( + event({ type: 'keypress', key: '2' }), + releases, + 10 + TERMINAL_IME_CANDIDATE_GUARD_POST_COMPOSITION_MS + 1 + ) + ).toBe(false) + expect( + shouldApplyTerminalImePendingCandidateKeyRelease( + event({ type: 'keyup', key: '2' }), + releases, + 10 + TERMINAL_IME_CANDIDATE_GUARD_POST_COMPOSITION_MS + 1 + ) + ).toBe(true) + }) + + it('guards held-key repeat keydowns while the release is pending, even past expiry', () => { + const releases = createTerminalImePendingCandidateKeyReleases() + armTerminalImePendingCandidateKeyRelease(releases, event({ key: '2' }), 10) + // Linux auto-repeat delay (~500ms) outlives the 250ms guard window; the + // held selector's repeats must stay suppressed until its keyup. + expect( + shouldApplyTerminalImePendingCandidateKeyRelease( + event({ key: '2', repeat: true }), + releases, + 10 + TERMINAL_IME_CANDIDATE_GUARD_POST_COMPOSITION_MS + 500 + ) + ).toBe(true) + }) + + it('does not guard repeat keydowns without a pending release for that key', () => { + const releases = createTerminalImePendingCandidateKeyReleases() + armTerminalImePendingCandidateKeyRelease(releases, event({ key: '2' }), 10) + expect( + shouldApplyTerminalImePendingCandidateKeyRelease( + event({ key: '3', repeat: true }), + releases, + 20 + ) + ).toBe(false) + expect( + shouldApplyTerminalImePendingCandidateKeyRelease( + event({ key: '2', repeat: true, ctrlKey: true }), + releases, + 20 + ) + ).toBe(false) + }) + + it('clears a stale pending release on a fresh non-repeat keydown but not on repeats', () => { + const releases = createTerminalImePendingCandidateKeyReleases() + armTerminalImePendingCandidateKeyRelease(releases, event({ key: '2' }), 10) + clearTerminalImePendingCandidateKeyRelease(releases, event({ key: '2', repeat: true })) + expect(releases.has('2')).toBe(true) + // A new physical press means the prior keyup was missed (focus change + // mid-hold); its stale entry must not guard the new press's repeats. + clearTerminalImePendingCandidateKeyRelease(releases, event({ key: '2' })) + expect(releases.has('2')).toBe(false) + }) + + it('guards a pending matching keyup even if modifier state changed after keydown', () => { + const releases = createTerminalImePendingCandidateKeyReleases() + armTerminalImePendingCandidateKeyRelease(releases, event({ key: '2' }), 10) + expect( + shouldApplyTerminalImePendingCandidateKeyRelease( + event({ type: 'keyup', key: '2', shiftKey: true }), + releases, + 10 + TERMINAL_IME_CANDIDATE_GUARD_POST_COMPOSITION_MS + 1 + ) + ).toBe(true) + }) + + it('clears on the matching keyup', () => { + const releases = createTerminalImePendingCandidateKeyReleases() + armTerminalImePendingCandidateKeyRelease(releases, event({ key: '2' }), 10) + clearTerminalImePendingCandidateKeyRelease(releases, event({ type: 'keypress', key: '2' })) + expect(releases.has('2')).toBe(true) + clearTerminalImePendingCandidateKeyRelease(releases, event({ type: 'keyup', key: '3' })) + expect(releases.has('2')).toBe(true) + clearTerminalImePendingCandidateKeyRelease(releases, event({ type: 'keyup', key: '2' })) + expect(releases.has('2')).toBe(false) + }) + + it('tracks overlapping candidate keys independently so each keyup clears its own guard', () => { + const releases = createTerminalImePendingCandidateKeyReleases() + // Second candidate keydown arrives before the first key's keyup. + armTerminalImePendingCandidateKeyRelease(releases, event({ key: '2' }), 10) + armTerminalImePendingCandidateKeyRelease(releases, event({ key: '3' }), 12) + expect( + shouldApplyTerminalImePendingCandidateKeyRelease( + event({ type: 'keyup', key: '2' }), + releases, + 20 + ) + ).toBe(true) + expect( + shouldApplyTerminalImePendingCandidateKeyRelease( + event({ type: 'keyup', key: '3' }), + releases, + 20 + ) + ).toBe(true) + // The first key's keyup no longer strands the second key's pending release. + clearTerminalImePendingCandidateKeyRelease(releases, event({ type: 'keyup', key: '2' })) + expect(releases.has('3')).toBe(true) + clearTerminalImePendingCandidateKeyRelease(releases, event({ type: 'keyup', key: '3' })) + expect(releases.size).toBe(0) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.ts b/src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.ts new file mode 100644 index 00000000000..210d8de22d2 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.ts @@ -0,0 +1,94 @@ +import type { XtermBypassEvent } from './xterm-bypass-policy' +import { TERMINAL_IME_CANDIDATE_GUARD_POST_COMPOSITION_MS } from './terminal-ime-composition-tracker' + +// Why: candidate keys can overlap (a second selector keydown before the first +// keyup), so pending releases are tracked per key rather than in one slot; +// otherwise an overwritten key's keyup can no longer clear its own guard. +export type TerminalImePendingCandidateKeyReleases = Map + +// Why: Sogou/fcitx can deliver candidate-selection keys as plain key events +// (#7543: digit selection inserts only the digit). While the IME owns them, +// they must not reach xterm's encoders or Chromium's default text insertion. +const TERMINAL_IME_CANDIDATE_SELECTION_KEYS = new Set([ + ' ', + '0', + '1', + '2', + '3', + '4', + '5', + '6', + '7', + '8', + '9' +]) + +function isTerminalImeCandidateSelectionKey(key: string): boolean { + return TERMINAL_IME_CANDIDATE_SELECTION_KEYS.has(key) +} + +export function isTerminalImeCandidateSelectionKeyEvent(event: XtermBypassEvent): boolean { + // Modified chords are never candidate selectors: Ctrl/Meta/Alt are IME + // toggles, and Shift+Space is fcitx's full-/half-width width toggle. + if (event.ctrlKey || event.metaKey || event.altKey || event.shiftKey) { + return false + } + return isTerminalImeCandidateSelectionKey(event.key) +} + +export function createTerminalImePendingCandidateKeyReleases(): TerminalImePendingCandidateKeyReleases { + return new Map() +} + +export function armTerminalImePendingCandidateKeyRelease( + releases: TerminalImePendingCandidateKeyReleases, + event: XtermBypassEvent, + now: number +): void { + if (event.type !== 'keydown' || !isTerminalImeCandidateSelectionKeyEvent(event)) { + return + } + releases.set(event.key, now + TERMINAL_IME_CANDIDATE_GUARD_POST_COMPOSITION_MS) +} + +export function shouldApplyTerminalImePendingCandidateKeyRelease( + event: XtermBypassEvent, + releases: TerminalImePendingCandidateKeyReleases, + now: number +): boolean { + if (event.type === 'keydown') { + // Why: key auto-repeat outlives the 250ms window (Linux repeat delay is + // ~500ms), so a held selector's repeats stay owned by its pending release; + // a fresh press means the prior keyup was missed and the entry is stale. + return ( + event.repeat === true && + isTerminalImeCandidateSelectionKeyEvent(event) && + releases.has(event.key) + ) + } + if (event.type === 'keyup') { + // Why: keyup modifier flags can reflect keys pressed after the original + // selector keydown, so release suppression matches only the pending key. + return isTerminalImeCandidateSelectionKey(event.key) && releases.has(event.key) + } + if (!isTerminalImeCandidateSelectionKeyEvent(event)) { + return false + } + const expiresAt = releases.get(event.key) + if (expiresAt === undefined) { + return false + } + return now <= expiresAt +} + +export function clearTerminalImePendingCandidateKeyRelease( + releases: TerminalImePendingCandidateKeyReleases, + event: XtermBypassEvent +): void { + // Why: a non-repeat keydown is a new physical press, so any surviving entry + // for that key lost its keyup (focus change mid-hold) and must not guard the + // new press's repeats. Callers clear before arming. + if (event.type === 'keyup' || (event.type === 'keydown' && event.repeat !== true)) { + releases.delete(event.key) + } +} diff --git a/src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts b/src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts new file mode 100644 index 00000000000..858745afb3d --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts @@ -0,0 +1,170 @@ +// @vitest-environment happy-dom +import { describe, expect, it } from 'vitest' +import { + installTerminalImeCompositionTracker, + TERMINAL_IME_CANDIDATE_GUARD_POST_COMPOSITION_MS, + TERMINAL_IME_CANDIDATE_GUARD_STALE_COMPOSITION_EXPIRY_MS, + type TerminalImeCompositionTracker +} from './terminal-ime-composition-tracker' + +type TrackerHarness = { + tracker: TerminalImeCompositionTracker + element: HTMLElement + advance: (ms: number) => void + composition: ( + type: 'compositionstart' | 'compositionupdate' | 'compositionend', + data: string + ) => void + input: (inputType: string) => void + blur: () => void +} + +function installTracker(): TrackerHarness { + let now = 0 + const element = document.createElement('div') + const tracker = installTerminalImeCompositionTracker(element, { now: () => now }) + return { + tracker, + element, + advance: (ms) => { + now += ms + }, + composition: (type, data) => { + const event = new CompositionEvent(type, { bubbles: true }) + Object.defineProperty(event, 'data', { value: data }) + element.dispatchEvent(event) + }, + input: (inputType) => { + element.dispatchEvent(new InputEvent('input', { inputType, bubbles: true })) + }, + blur: () => { + element.dispatchEvent(new Event('blur', { bubbles: true })) + } + } +} + +describe('installTerminalImeCompositionTracker', () => { + it('activates on compositionstart', () => { + const harness = installTracker() + harness.composition('compositionstart', '') + expect(harness.tracker.isActive()).toBe(true) + }) + + it('keeps composition active through Sogou-style empty compositionupdate', () => { + const harness = installTracker() + harness.composition('compositionstart', '') + harness.composition('compositionupdate', 'ni') + harness.composition('compositionupdate', '') + expect(harness.tracker.isActive()).toBe(true) + }) + + it('clears on compositionend', () => { + const harness = installTracker() + harness.composition('compositionstart', '') + harness.composition('compositionupdate', '你') + harness.composition('compositionend', '你') + expect(harness.tracker.isActive()).toBe(false) + }) + + it('still clears on non-composition input', () => { + const harness = installTracker() + harness.composition('compositionstart', '') + harness.input('insertText') + expect(harness.tracker.isActive()).toBe(false) + }) + + it('does not clear on insertCompositionText input', () => { + const harness = installTracker() + harness.composition('compositionstart', '') + harness.input('insertCompositionText') + expect(harness.tracker.isActive()).toBe(true) + }) + + it('clears on blur', () => { + const harness = installTracker() + harness.composition('compositionstart', '') + harness.blur() + expect(harness.tracker.isActive()).toBe(false) + expect(harness.tracker.isCandidateKeyGuardActive()).toBe(false) + }) + + describe('candidate key guard', () => { + it('is active while a composition is live', () => { + const harness = installTracker() + expect(harness.tracker.isCandidateKeyGuardActive()).toBe(false) + harness.composition('compositionstart', '') + expect(harness.tracker.isCandidateKeyGuardActive()).toBe(true) + }) + + it('stays active through empty compositionupdate events', () => { + const harness = installTracker() + harness.composition('compositionstart', '') + harness.composition('compositionupdate', '') + expect(harness.tracker.isCandidateKeyGuardActive()).toBe(true) + }) + + it('expires after prolonged composition-event silence so stale IME state cannot keep terminal keys dead', () => { + const harness = installTracker() + harness.composition('compositionstart', '') + harness.advance(TERMINAL_IME_CANDIDATE_GUARD_STALE_COMPOSITION_EXPIRY_MS) + expect(harness.tracker.isCandidateKeyGuardActive()).toBe(true) + expect(harness.tracker.isActive()).toBe(true) + harness.advance(1) + expect(harness.tracker.isCandidateKeyGuardActive()).toBe(false) + expect(harness.tracker.isActive()).toBe(false) + }) + + it('refreshes the expiry window on composition activity', () => { + const harness = installTracker() + harness.composition('compositionstart', '') + harness.advance(TERMINAL_IME_CANDIDATE_GUARD_STALE_COMPOSITION_EXPIRY_MS) + harness.composition('compositionupdate', '') + harness.advance(TERMINAL_IME_CANDIDATE_GUARD_STALE_COMPOSITION_EXPIRY_MS) + expect(harness.tracker.isCandidateKeyGuardActive()).toBe(true) + }) + + it('does not arm the post-composition window without Sogou-style empty updates', () => { + const harness = installTracker() + harness.composition('compositionstart', '') + harness.composition('compositionupdate', '你') + harness.composition('compositionend', '你') + expect(harness.tracker.isCandidateKeyGuardActive()).toBe(false) + }) + + it('absorbs the Sogou-style committing key after compositionend, then expires', () => { + const harness = installTracker() + harness.composition('compositionstart', '') + harness.composition('compositionupdate', '你') + harness.composition('compositionupdate', '') + harness.composition('compositionend', '你') + expect(harness.tracker.isCandidateKeyGuardActive()).toBe(true) + harness.advance(TERMINAL_IME_CANDIDATE_GUARD_POST_COMPOSITION_MS) + expect(harness.tracker.isCandidateKeyGuardActive()).toBe(true) + harness.advance(1) + expect(harness.tracker.isCandidateKeyGuardActive()).toBe(false) + }) + + it('drops the post-composition window once ordinary typing resumes', () => { + const harness = installTracker() + harness.composition('compositionstart', '') + harness.composition('compositionupdate', '') + harness.composition('compositionend', '你') + harness.input('insertText') + expect(harness.tracker.isCandidateKeyGuardActive()).toBe(false) + }) + }) + + it('handles a missing terminal element', () => { + const tracker = installTerminalImeCompositionTracker(null) + expect(tracker.isActive()).toBe(false) + expect(tracker.isCandidateKeyGuardActive()).toBe(false) + expect(() => tracker.dispose()).not.toThrow() + }) + + it('stops tracking after dispose', () => { + const harness = installTracker() + harness.tracker.dispose() + harness.composition('compositionstart', '') + expect(harness.tracker.isActive()).toBe(false) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.ts b/src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.ts index 0e3c9e4bbfb..73f6d83a0b0 100644 --- a/src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.ts +++ b/src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.ts @@ -2,47 +2,112 @@ import type { IDisposable } from '@xterm/xterm' export type TerminalImeCompositionTracker = IDisposable & { isActive: () => boolean + /** True while candidate-selection keys (Space/digits) should be treated as + * IME-owned: during a live composition, and briefly after compositionend to + * absorb the committing key's trailing press/release. */ + isCandidateKeyGuardActive: () => boolean } +// Why: suppressed candidate keys are preventDefault-ed and fire no input +// event, so a stale tracker (missed compositionend) has no natural unstick +// path. Expire the guard so Space/digits cannot stay dead indefinitely. +export const TERMINAL_IME_CANDIDATE_GUARD_STALE_COMPOSITION_EXPIRY_MS = 10_000 +// Why: Sogou/fcitx can deliver the committing Space/digit as plain keydown and +// keyup after compositionend; a narrow window absorbs those trailing events +// without making the keys globally unavailable after IME use. +export const TERMINAL_IME_CANDIDATE_GUARD_POST_COMPOSITION_MS = 250 + export function installTerminalImeCompositionTracker( - terminalElement: HTMLElement | null | undefined + terminalElement: HTMLElement | null | undefined, + options?: { now?: () => number } ): TerminalImeCompositionTracker { + const now = options?.now ?? ((): number => Date.now()) let active = false + let lastCompositionEventAt: number | null = null + let compositionEndedAt: number | null = null + let sawEmptyCompositionUpdate = false + + const isActiveAt = (at: number): boolean => + active && + (lastCompositionEventAt === null || + at - lastCompositionEventAt <= TERMINAL_IME_CANDIDATE_GUARD_STALE_COMPOSITION_EXPIRY_MS) + + const isCandidateKeyGuardActive = (): boolean => { + const at = now() + if (isActiveAt(at)) { + return true + } + return ( + compositionEndedAt !== null && + at - compositionEndedAt <= TERMINAL_IME_CANDIDATE_GUARD_POST_COMPOSITION_MS + ) + } + if (!terminalElement) { return { isActive: () => active, + isCandidateKeyGuardActive, dispose: () => undefined } } const markActive = (): void => { active = true + lastCompositionEventAt = now() + compositionEndedAt = null + sawEmptyCompositionUpdate = false } const updateComposition = (event: Event): void => { - active = !(event instanceof CompositionEvent) || event.data !== '' + lastCompositionEventAt = now() + // Why: Sogou/fcitx can emit empty compositionupdate data while its + // candidate popup is still open — empty data must not deactivate. + // compositionend, non-composition input, and blur own deactivation. + if (!(event instanceof CompositionEvent)) { + return + } + if (event.data === '') { + sawEmptyCompositionUpdate = true + return + } + active = true + } + const handleCompositionEnd = (): void => { + active = false + // Why: only Sogou/fcitx-style empty updates prove a trailing plain + // Space/digit is likely IME-owned; broad post-end guards drop real typing. + compositionEndedAt = sawEmptyCompositionUpdate ? now() : null + sawEmptyCompositionUpdate = false } const handleInput = (event: Event): void => { if (event instanceof InputEvent && event.inputType === 'insertCompositionText') { return } active = false + // Why: real non-composition input means ordinary typing resumed; keeping + // the post-end window would swallow a legitimate Space/digit. + compositionEndedAt = null + sawEmptyCompositionUpdate = false } const markInactive = (): void => { active = false + lastCompositionEventAt = null + compositionEndedAt = null + sawEmptyCompositionUpdate = false } terminalElement.addEventListener('compositionstart', markActive, true) terminalElement.addEventListener('compositionupdate', updateComposition, true) - terminalElement.addEventListener('compositionend', markInactive, true) + terminalElement.addEventListener('compositionend', handleCompositionEnd, true) terminalElement.addEventListener('input', handleInput, true) terminalElement.addEventListener('blur', markInactive, true) return { - isActive: () => active, + isActive: () => isActiveAt(now()), + isCandidateKeyGuardActive, dispose: () => { terminalElement.removeEventListener('compositionstart', markActive, true) terminalElement.removeEventListener('compositionupdate', updateComposition, true) - terminalElement.removeEventListener('compositionend', markInactive, true) + terminalElement.removeEventListener('compositionend', handleCompositionEnd, true) terminalElement.removeEventListener('input', handleInput, true) terminalElement.removeEventListener('blur', markInactive, true) } diff --git a/src/renderer/src/components/terminal-pane/use-notification-dispatch.ts b/src/renderer/src/components/terminal-pane/use-notification-dispatch.ts index d1a97b33aed..2a0c2f4f3e6 100644 --- a/src/renderer/src/components/terminal-pane/use-notification-dispatch.ts +++ b/src/renderer/src/components/terminal-pane/use-notification-dispatch.ts @@ -1,6 +1,6 @@ import { useCallback } from 'react' import { useAppStore } from '@/store' -import { resolveExplicitTerminalTitleAgentType } from '../../../../shared/terminal-title-agent-type' +import { resolveCommittedTitleAgentType } from '@/lib/pane-agent-evidence' import { getRepoMapFromState, getWorktreeMapFromState } from '@/store/selectors' import { playDesktopNotificationSound } from '@/lib/desktop-notification-sound' import { buildAgentNotificationId } from '../../../../shared/agent-notification-id' @@ -52,7 +52,7 @@ export function dispatchTerminalNotification( // not lend its prompt/agentType or timing id to this notification. const explicitTitleAgentType = event.source === 'agent-task-complete' && event.terminalTitle - ? resolveExplicitTerminalTitleAgentType(event.terminalTitle) + ? resolveCommittedTitleAgentType(event.terminalTitle) : null const storedAgentStatus = event.source === 'agent-task-complete' && event.paneKey diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index b183ad66aaf..74f9ead694e 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -66,6 +66,12 @@ import { parseOsc7 } from './parse-osc7' import { guardParserHandler } from './terminal-parser-handler-guard' import { resolveTerminalJisYenInput } from './terminal-jis-yen-input' import { installTerminalImeCompositionTracker } from './terminal-ime-composition-tracker' +import { + armTerminalImePendingCandidateKeyRelease, + clearTerminalImePendingCandidateKeyRelease, + createTerminalImePendingCandidateKeyReleases, + shouldApplyTerminalImePendingCandidateKeyRelease +} from './terminal-ime-candidate-key-release-guard' import { DISABLED_MAC_NATIVE_TEXT_INPUT_SOURCE_FEATURES, getMacNativeTextInputSourceTracker @@ -74,6 +80,7 @@ import { installTerminalImeNativeTextForwarder } from './terminal-ime-native-tex import { shouldBypassXtermKeyboardEvent, shouldHandleTerminalInterruptKeyboardEvent, + shouldPreventDefaultTerminalImeCandidateKey, shouldSuppressTerminalImeKeyboardEvent, shouldSuppressTerminalInterruptKeyup, shouldSuppressTerminalModifierKeyboardEvent, @@ -874,7 +881,16 @@ export function useTerminalPaneLifecycle({ // encoder runs, letting the browser and Electron paths fire normally. // See xterm-bypass-policy.ts for the rule derivation. let pendingTerminalInterruptKeyup = false + const pendingTerminalImeCandidateKeyReleases = + createTerminalImePendingCandidateKeyReleases() const isMac = navigator.userAgent.includes('Mac') + // Why: Android/ChromeOS UAs also contain "Linux"; keep the Sogou/fcitx + // candidate-key policy scoped to desktop Linux so paired web clients on + // those platforms keep their previous IME behavior. + const isLinux = + !isMac && + navigator.userAgent.includes('Linux') && + !/Android|CrOS/.test(navigator.userAgent) const macNativeTextInputSourceTracker = isMac ? getMacNativeTextInputSourceTracker() : null const imeCompositionTracker = installTerminalImeCompositionTracker(pane.terminal.element) imeCompositionDisposablesRef.current.set(pane.id, imeCompositionTracker) @@ -896,14 +912,39 @@ export function useTerminalPaneLifecycle({ } imeNativeTextForwarderDisposablesRef.current.set(pane.id, imeNativeTextForwarder) pane.terminal.attachCustomKeyEventHandler((e) => { - if ( - shouldSuppressTerminalImeKeyboardEvent(e, { - compositionActive: imeCompositionTracker.isActive(), - isMac - }) - ) { + const now = Date.now() + const pendingCandidateReleaseGuardActive = + shouldApplyTerminalImePendingCandidateKeyRelease( + e, + pendingTerminalImeCandidateKeyReleases, + now + ) + const imeKeyboardOptions = { + compositionActive: imeCompositionTracker.isActive(), + candidateKeyGuardActive: + imeCompositionTracker.isCandidateKeyGuardActive() || + pendingCandidateReleaseGuardActive, + pendingCandidateKeyReleaseActive: pendingCandidateReleaseGuardActive, + isMac, + isLinux + } + if (shouldSuppressTerminalImeKeyboardEvent(e, imeKeyboardOptions)) { + // Why: clear before arm — a fresh keydown drops any stale pending + // release for its key before the new press arms its own. + clearTerminalImePendingCandidateKeyRelease(pendingTerminalImeCandidateKeyReleases, e) + if (shouldPreventDefaultTerminalImeCandidateKey(e, imeKeyboardOptions)) { + // Why: without preventDefault the suppressed candidate keydown + // still fires a keypress and mutates the helper textarea. + e.preventDefault() + armTerminalImePendingCandidateKeyRelease( + pendingTerminalImeCandidateKeyReleases, + e, + now + ) + } return false } + clearTerminalImePendingCandidateKeyRelease(pendingTerminalImeCandidateKeyReleases, e) if (pendingTerminalInterruptKeyup && shouldSuppressTerminalInterruptKeyup(e)) { pendingTerminalInterruptKeyup = false return false diff --git a/src/renderer/src/components/terminal-pane/xterm-bypass-event-fixture.ts b/src/renderer/src/components/terminal-pane/xterm-bypass-event-fixture.ts new file mode 100644 index 00000000000..a4548ab5b99 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/xterm-bypass-event-fixture.ts @@ -0,0 +1,17 @@ +import type { XtermBypassEvent } from './xterm-bypass-policy' + +// Shared test fixture: builds a fully-defaulted XtermBypassEvent so the bypass +// and IME candidate-guard suites all stay in sync when the event shape changes. +export function event(overrides: Partial): XtermBypassEvent { + return { + type: 'keydown', + key: '', + code: '', + defaultPrevented: false, + metaKey: false, + ctrlKey: false, + altKey: false, + shiftKey: false, + ...overrides + } +} diff --git a/src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts b/src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts index b5b0d40d3cd..1f3a78326cd 100644 --- a/src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts +++ b/src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts @@ -1,23 +1,10 @@ import { describe, expect, it } from 'vitest' import { shouldBypassXtermKeyboardEvent, - shouldSuppressTerminalImeKeyboardEvent, - type XtermBypassEvent + shouldPreventDefaultTerminalImeCandidateKey, + shouldSuppressTerminalImeKeyboardEvent } from './xterm-bypass-policy' - -function event(overrides: Partial): XtermBypassEvent { - return { - type: 'keydown', - key: '', - code: '', - defaultPrevented: false, - metaKey: false, - ctrlKey: false, - altKey: false, - shiftKey: false, - ...overrides - } -} +import { event } from './xterm-bypass-event-fixture' describe('shouldBypassXtermKeyboardEvent — Windows/Linux', () => { const withSel = { isMac: false, hasSelection: true } @@ -161,60 +148,263 @@ describe('shouldBypassXtermKeyboardEvent — Windows/Linux', () => { }) describe('shouldSuppressTerminalImeKeyboardEvent — Windows/Linux', () => { - const idle = { isMac: false, compositionActive: false } - const composing = { isMac: false, compositionActive: true } + const windowsIdle = { + isMac: false, + isLinux: false, + compositionActive: false, + candidateKeyGuardActive: false, + pendingCandidateKeyReleaseActive: false + } + const linuxIdle = { + isMac: false, + isLinux: true, + compositionActive: false, + candidateKeyGuardActive: false, + pendingCandidateKeyReleaseActive: false + } + const linuxComposing = { ...linuxIdle, compositionActive: true, candidateKeyGuardActive: true } + // Post-compositionend guard: the tracker is already inactive but the + // committing key's trailing press/release must still be absorbed. + const linuxPostCompositionGuard = { ...linuxIdle, candidateKeyGuardActive: true } + const windowsComposing = { + ...windowsIdle, + compositionActive: true, + candidateKeyGuardActive: true + } it('suppresses keyboard events while Chromium reports active IME composition', () => { - expect( - shouldSuppressTerminalImeKeyboardEvent( - event({ key: 'Backspace', code: 'Backspace', isComposing: true }), - idle - ) - ).toBe(true) + for (const options of [windowsIdle, linuxIdle]) { + expect( + shouldSuppressTerminalImeKeyboardEvent( + event({ key: 'Backspace', code: 'Backspace', isComposing: true }), + options + ) + ).toBe(true) + } }) it('suppresses Windows IME Process keys', () => { + // Why: Windows preedit can hit the textarea before compositionstart; + // letting the 229 keydown through would flush it via xterm's textarea diff. expect( shouldSuppressTerminalImeKeyboardEvent( event({ key: 'Process', code: 'KeyN', keyCode: 229 }), - idle + windowsIdle ) ).toBe(true) }) - it('does not suppress ordinary Backspace outside IME composition', () => { + it('lets standalone Linux 229 keydowns reach xterm so its CompositionHelper can diff text', () => { + // Why: Sogou/fcitx candidate commits can ride a bare 229 keydown outside a + // composition session; xterm must see it to schedule its textarea diff. expect( - shouldSuppressTerminalImeKeyboardEvent(event({ key: 'Backspace', code: 'Backspace' }), idle) + shouldSuppressTerminalImeKeyboardEvent( + event({ key: 'Process', code: 'KeyN', keyCode: 229 }), + linuxIdle + ) ).toBe(false) }) + it('suppresses Linux 229 keydowns while the composition tracker is active', () => { + expect( + shouldSuppressTerminalImeKeyboardEvent( + event({ key: 'Process', code: 'KeyN', keyCode: 229 }), + linuxComposing + ) + ).toBe(true) + }) + + it('suppresses 229 / Process keyups so kitty release reporting cannot leak', () => { + for (const options of [windowsIdle, linuxIdle]) { + expect( + shouldSuppressTerminalImeKeyboardEvent( + event({ type: 'keyup', key: 'Process', code: 'KeyN', keyCode: 229 }), + options + ) + ).toBe(true) + } + }) + + it('does not suppress ordinary Backspace outside IME composition', () => { + for (const options of [windowsIdle, linuxIdle]) { + expect( + shouldSuppressTerminalImeKeyboardEvent( + event({ key: 'Backspace', code: 'Backspace' }), + options + ) + ).toBe(false) + } + }) + it('suppresses IME-owned editing keys while composition is active', () => { expect( shouldSuppressTerminalImeKeyboardEvent( event({ key: 'Backspace', code: 'Backspace' }), - composing + linuxComposing ) ).toBe(true) expect( shouldSuppressTerminalImeKeyboardEvent( event({ key: 'ArrowDown', code: 'ArrowDown' }), - composing + linuxComposing ) ).toBe(true) }) it('does not suppress ordinary text keys solely because composition is active', () => { expect( - shouldSuppressTerminalImeKeyboardEvent(event({ key: 'a', code: 'KeyA' }), composing) + shouldSuppressTerminalImeKeyboardEvent(event({ key: 'a', code: 'KeyA' }), linuxComposing) ).toBe(false) }) it('does not suppress keypress events because they carry committed text', () => { - expect( - shouldSuppressTerminalImeKeyboardEvent( - event({ type: 'keypress', key: '中', code: '', isComposing: true }), - idle - ) - ).toBe(false) + for (const options of [windowsIdle, linuxIdle]) { + expect( + shouldSuppressTerminalImeKeyboardEvent( + event({ type: 'keypress', key: '中', code: '', isComposing: true }), + options + ) + ).toBe(false) + } + }) + + describe('candidate-selection keys (Sogou Space/digit commit)', () => { + it('suppresses Space and digit keydowns and keyups while the candidate guard is active', () => { + for (const options of [linuxComposing, linuxPostCompositionGuard]) { + for (const key of [' ', '0', '2', '9']) { + expect(shouldSuppressTerminalImeKeyboardEvent(event({ key, code: '' }), options)).toBe( + true + ) + expect( + shouldSuppressTerminalImeKeyboardEvent(event({ type: 'keyup', key, code: '' }), options) + ).toBe(true) + } + } + }) + + it('suppresses the follow-on candidate keypress so _keyPress cannot forward the selector', () => { + expect( + shouldSuppressTerminalImeKeyboardEvent( + event({ type: 'keypress', key: ' ', code: 'Space' }), + linuxPostCompositionGuard + ) + ).toBe(true) + expect( + shouldSuppressTerminalImeKeyboardEvent( + event({ type: 'keypress', key: '2', code: 'Digit2' }), + linuxComposing + ) + ).toBe(true) + }) + + it('leaves Space and digits alone once the guard has expired', () => { + for (const type of ['keydown', 'keyup', 'keypress']) { + expect( + shouldSuppressTerminalImeKeyboardEvent( + event({ type, key: ' ', code: 'Space' }), + linuxIdle + ) + ).toBe(false) + expect( + shouldSuppressTerminalImeKeyboardEvent( + event({ type, key: '2', code: 'Digit2' }), + linuxIdle + ) + ).toBe(false) + } + }) + + it('does not treat modified chords such as Ctrl+Space (IME toggle) as candidate keys', () => { + expect( + shouldSuppressTerminalImeKeyboardEvent( + event({ key: ' ', code: 'Space', ctrlKey: true }), + linuxPostCompositionGuard + ) + ).toBe(false) + }) + + it('does not treat Shift+Space (fcitx full-/half-width toggle) as a candidate key', () => { + expect( + shouldSuppressTerminalImeKeyboardEvent( + event({ key: ' ', code: 'Space', shiftKey: true }), + linuxPostCompositionGuard + ) + ).toBe(false) + }) + + it('leaves ordinary letters unsuppressed while the guard is active', () => { + expect( + shouldSuppressTerminalImeKeyboardEvent( + event({ key: 'a', code: 'KeyA' }), + linuxPostCompositionGuard + ) + ).toBe(false) + }) + + it('does not apply the Linux/Sogou candidate guard to Windows', () => { + expect( + shouldSuppressTerminalImeKeyboardEvent(event({ key: ' ', code: 'Space' }), windowsComposing) + ).toBe(false) + expect( + shouldSuppressTerminalImeKeyboardEvent( + event({ type: 'keypress', key: '2', code: 'Digit2' }), + windowsComposing + ) + ).toBe(false) + }) + + it('suppresses a pending Linux candidate release even if modifier state changed after keydown', () => { + expect( + shouldSuppressTerminalImeKeyboardEvent( + event({ type: 'keyup', key: '2', code: 'Digit2', shiftKey: true }), + { + ...linuxIdle, + candidateKeyGuardActive: true, + pendingCandidateKeyReleaseActive: true + } + ) + ).toBe(true) + }) + }) + + describe('shouldPreventDefaultTerminalImeCandidateKey', () => { + it('prevents the default on candidate keydowns while the guard is active', () => { + expect( + shouldPreventDefaultTerminalImeCandidateKey( + event({ key: ' ', code: 'Space' }), + linuxComposing + ) + ).toBe(true) + expect( + shouldPreventDefaultTerminalImeCandidateKey( + event({ key: '2', code: 'Digit2' }), + linuxPostCompositionGuard + ) + ).toBe(true) + }) + + it('does not prevent the default for keyups, expired guards, or non-candidate keys', () => { + expect( + shouldPreventDefaultTerminalImeCandidateKey( + event({ type: 'keyup', key: ' ', code: 'Space' }), + linuxComposing + ) + ).toBe(false) + expect( + shouldPreventDefaultTerminalImeCandidateKey(event({ key: ' ', code: 'Space' }), linuxIdle) + ).toBe(false) + expect( + shouldPreventDefaultTerminalImeCandidateKey( + event({ key: 'a', code: 'KeyA' }), + linuxComposing + ) + ).toBe(false) + expect( + shouldPreventDefaultTerminalImeCandidateKey( + event({ key: ' ', code: 'Space' }), + windowsComposing + ) + ).toBe(false) + }) }) }) diff --git a/src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts b/src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts index 6d0a70ada5b..dc69ea2578d 100644 --- a/src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts +++ b/src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts @@ -1,23 +1,10 @@ import { describe, expect, it } from 'vitest' import { shouldBypassXtermKeyboardEvent, - shouldSuppressTerminalImeKeyboardEvent, - type XtermBypassEvent + shouldPreventDefaultTerminalImeCandidateKey, + shouldSuppressTerminalImeKeyboardEvent } from './xterm-bypass-policy' - -function event(overrides: Partial): XtermBypassEvent { - return { - type: 'keydown', - key: '', - code: '', - defaultPrevented: false, - metaKey: false, - ctrlKey: false, - altKey: false, - shiftKey: false, - ...overrides - } -} +import { event } from './xterm-bypass-event-fixture' describe('shouldBypassXtermKeyboardEvent — macOS', () => { const opts = { isMac: true, hasSelection: true } @@ -164,8 +151,20 @@ describe('shouldBypassXtermKeyboardEvent — macOS', () => { }) describe('shouldSuppressTerminalImeKeyboardEvent — macOS', () => { - const idle = { isMac: true, compositionActive: false } - const composing = { isMac: true, compositionActive: true } + const idle = { + isMac: true, + isLinux: false, + compositionActive: false, + candidateKeyGuardActive: false, + pendingCandidateKeyReleaseActive: false + } + const composing = { + isMac: true, + isLinux: false, + compositionActive: true, + candidateKeyGuardActive: true, + pendingCandidateKeyReleaseActive: false + } it('suppresses keyboard events while Chromium reports active IME composition', () => { expect( @@ -238,4 +237,19 @@ describe('shouldSuppressTerminalImeKeyboardEvent — macOS', () => { ) ).toBe(false) }) + + it('does not apply the Linux/Sogou candidate guard to macOS', () => { + expect( + shouldSuppressTerminalImeKeyboardEvent(event({ key: ' ', code: 'Space' }), composing) + ).toBe(false) + expect( + shouldSuppressTerminalImeKeyboardEvent( + event({ type: 'keypress', key: '2', code: 'Digit2' }), + composing + ) + ).toBe(false) + expect( + shouldPreventDefaultTerminalImeCandidateKey(event({ key: ' ', code: 'Space' }), composing) + ).toBe(false) + }) }) diff --git a/src/renderer/src/components/terminal-pane/xterm-bypass-policy.ts b/src/renderer/src/components/terminal-pane/xterm-bypass-policy.ts index 57a29c36a4c..34e1240ef84 100644 --- a/src/renderer/src/components/terminal-pane/xterm-bypass-policy.ts +++ b/src/renderer/src/components/terminal-pane/xterm-bypass-policy.ts @@ -1,4 +1,5 @@ import { keybindingMatchesInput } from '../../../../shared/keybindings' +import { isTerminalImeCandidateSelectionKeyEvent } from './terminal-ime-candidate-key-release-guard' // Why: when a CLI activates kitty progressive enhancement (CSI > N u), xterm's // KittyKeyboard encoder turns every modifier chord — including plain Cmd+C — @@ -18,6 +19,7 @@ export type XtermBypassEvent = { code?: string keyCode?: number isComposing?: boolean + repeat?: boolean defaultPrevented?: boolean metaKey: boolean ctrlKey: boolean @@ -35,9 +37,18 @@ export type XtermBypassOptions = { export type XtermImeKeyboardOptions = { compositionActive: boolean + /** True while Linux/Sogou candidate-selection keys (Space/digits) are + * IME-owned: live composition plus a short post-compositionend window. */ + candidateKeyGuardActive: boolean + /** True when the pending-release guard already matched this specific event. */ + pendingCandidateKeyReleaseActive: boolean // Required so no caller silently falls back to non-mac 229 suppression, // which re-swallows the first key after a macOS IME input-source switch. isMac: boolean + // Required Linux/Windows split: Linux passes standalone 229 keydowns like + // macOS; the Windows-only suppression guards its preedit-diff race (preedit + // can hit the textarea before compositionstart and be flushed by the diff). + isLinux: boolean } export const TERMINAL_INTERRUPT_INPUT = '\x03' @@ -74,18 +85,55 @@ export function shouldSuppressTerminalImeKeyboardEvent( event: XtermBypassEvent, options: XtermImeKeyboardOptions ): boolean { + const { + compositionActive, + candidateKeyGuardActive, + pendingCandidateKeyReleaseActive, + isMac, + isLinux + } = options + const suppressCandidateKey = + isLinux && + (pendingCandidateKeyReleaseActive || + (candidateKeyGuardActive && isTerminalImeCandidateSelectionKeyEvent(event))) + if (event.type === 'keypress') { + // Why: a suppressed candidate keydown is not preventDefault-ed by xterm, + // so its native keypress still fires and _keyPress would forward the + // literal Space/digit to the PTY. + return suppressCandidateKey + } if (!isXtermHandledKeyEvent(event.type)) { return false } - const { compositionActive, isMac } = options // Why: IMEs own Process-key / composing keystrokes — letting xterm translate - // them corrupts committed CJK text. Bare macOS keydown 229 is exempt: it must - // reach xterm's CompositionHelper or the first key after an input-source - // switch is swallowed. + // them corrupts committed CJK text. Bare macOS/Linux keydown 229 is exempt: + // it must reach xterm's CompositionHelper so it can schedule its textarea + // diff (macOS: first key after an input-source switch; Linux: Sogou/fcitx + // candidate commits outside a composition session). Windows keeps full + // suppression until verified against its preedit-diff race. + const passesStandalone229Keydown = isMac || isLinux return ( event.isComposing === true || - (event.keyCode === 229 && (event.type !== 'keydown' || compositionActive || !isMac)) || - (compositionActive && TERMINAL_IME_OWNED_KEYS.has(event.key)) + (event.keyCode === 229 && + (event.type !== 'keydown' || compositionActive || !passesStandalone229Keydown)) || + (compositionActive && TERMINAL_IME_OWNED_KEYS.has(event.key)) || + suppressCandidateKey + ) +} + +export function shouldPreventDefaultTerminalImeCandidateKey( + event: XtermBypassEvent, + options: XtermImeKeyboardOptions +): boolean { + // Why: returning false from attachCustomKeyEventHandler does not + // preventDefault — the candidate keydown would still fire a keypress and + // write into the helper textarea, where a later 229 diff could flush the + // leaked selector to the PTY. + return ( + event.type === 'keydown' && + options.isLinux && + options.candidateKeyGuardActive && + isTerminalImeCandidateSelectionKeyEvent(event) ) } diff --git a/src/renderer/src/components/terminal/active-terminal-repair.test.ts b/src/renderer/src/components/terminal/active-terminal-repair.test.ts index 2d08164d4f0..f62cd0e4da7 100644 --- a/src/renderer/src/components/terminal/active-terminal-repair.test.ts +++ b/src/renderer/src/components/terminal/active-terminal-repair.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from 'vitest' import type { TerminalTab } from '../../../../shared/types' -import { shouldRepairActiveTerminalTab } from './active-terminal-repair' +import { + resolveRepairedActiveTerminalTabId, + shouldRepairActiveTerminalTab +} from './active-terminal-repair' function tab(id: string): TerminalTab { return { @@ -50,3 +53,49 @@ describe('shouldRepairActiveTerminalTab', () => { ).toBe(false) }) }) + +describe('resolveRepairedActiveTerminalTabId', () => { + it('returns null when no repair is needed', () => { + expect( + resolveRepairedActiveTerminalTabId({ + activeTabType: 'terminal', + activeTabId: 'terminal-2', + rememberedTabId: 'terminal-1', + tabs: [tab('terminal-1'), tab('terminal-2')] + }) + ).toBeNull() + }) + + it('restores the remembered tab instead of the first tab when repairing', () => { + // Why (regression): a repair firing on a transient worktree-switch render + // must not reset the selection to Terminal 1 — it should land on the tab + // the worktree remembers the user was on. + expect( + resolveRepairedActiveTerminalTabId({ + activeTabType: 'terminal', + activeTabId: 'stale-from-other-worktree', + rememberedTabId: 'terminal-2', + tabs: [tab('terminal-1'), tab('terminal-2')] + }) + ).toBe('terminal-2') + }) + + it('falls back to the first tab when the remembered tab is missing or stale', () => { + expect( + resolveRepairedActiveTerminalTabId({ + activeTabType: 'terminal', + activeTabId: 'missing', + rememberedTabId: null, + tabs: [tab('terminal-1'), tab('terminal-2')] + }) + ).toBe('terminal-1') + expect( + resolveRepairedActiveTerminalTabId({ + activeTabType: 'terminal', + activeTabId: 'missing', + rememberedTabId: 'no-longer-open', + tabs: [tab('terminal-1'), tab('terminal-2')] + }) + ).toBe('terminal-1') + }) +}) diff --git a/src/renderer/src/components/terminal/active-terminal-repair.ts b/src/renderer/src/components/terminal/active-terminal-repair.ts index 8f6691f957b..86a491775bc 100644 --- a/src/renderer/src/components/terminal/active-terminal-repair.ts +++ b/src/renderer/src/components/terminal/active-terminal-repair.ts @@ -16,3 +16,19 @@ export function shouldRepairActiveTerminalTab(args: { } return true } + +// Resolve which terminal tab to open after a project/agent is selected, or null if no repair is needed. +export function resolveRepairedActiveTerminalTabId(args: { + activeTabType: WorkspaceVisibleTabType + activeTabId: string | null + rememberedTabId: string | null | undefined + tabs: TerminalTab[] +}): string | null { + if (!shouldRepairActiveTerminalTab(args)) { + return null + } + if (args.rememberedTabId && args.tabs.some((tab) => tab.id === args.rememberedTabId)) { + return args.rememberedTabId + } + return args.tabs[0].id +} diff --git a/src/renderer/src/hooks/ssh-reconnect-pane-retry.test.ts b/src/renderer/src/hooks/ssh-reconnect-pane-retry.test.ts new file mode 100644 index 00000000000..623c72cc57c --- /dev/null +++ b/src/renderer/src/hooks/ssh-reconnect-pane-retry.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' +import { shouldRetryPaneSpawnOnSshReconnect } from './ssh-reconnect-pane-retry' + +describe('shouldRetryPaneSpawnOnSshReconnect', () => { + it('retries tabs with no ptyId at all', () => { + expect( + shouldRetryPaneSpawnOnSshReconnect({ + targetId: 'conn-1', + tabPtyId: null, + deferredSessionId: undefined + }) + ).toBe(true) + }) + + it('retries tabs still holding a deferred session for this target', () => { + // Why: the stale wake-hint ptyId reads as live, but an unconsumed deferred + // entry proves no pane ever reattached — the pane is stranded. + expect( + shouldRetryPaneSpawnOnSshReconnect({ + targetId: 'conn-1', + tabPtyId: 'ssh:conn-1@@pty-7', + deferredSessionId: 'ssh:conn-1@@pty-7' + }) + ).toBe(true) + }) + + it('leaves tabs whose deferred entry was already consumed alone', () => { + expect( + shouldRetryPaneSpawnOnSshReconnect({ + targetId: 'conn-1', + tabPtyId: 'ssh:conn-1@@pty-7', + deferredSessionId: undefined + }) + ).toBe(false) + }) + + it('ignores deferred sessions that belong to another target', () => { + expect( + shouldRetryPaneSpawnOnSshReconnect({ + targetId: 'conn-1', + tabPtyId: 'ssh:conn-2@@pty-3', + deferredSessionId: 'ssh:conn-2@@pty-3' + }) + ).toBe(false) + }) +}) diff --git a/src/renderer/src/hooks/ssh-reconnect-pane-retry.ts b/src/renderer/src/hooks/ssh-reconnect-pane-retry.ts new file mode 100644 index 00000000000..94f2e346aff --- /dev/null +++ b/src/renderer/src/hooks/ssh-reconnect-pane-retry.ts @@ -0,0 +1,22 @@ +import { parseAppSshPtyId } from '../../../shared/ssh-pty-id' + +// Why: on SSH (re)connect, panes that never got a live PTY must remount and +// retry. Two shapes qualify: tabs with no ptyId at all (their spawn failed +// outright), and tabs still holding a deferred reattach session for this +// target — their restored wake-hint ptyId reads as live but nothing is +// attached. The deferred entry is consumed synchronously the moment a pane +// starts reattaching, so a remaining entry proves the tab is stranded (e.g. +// the user cancelled the passphrase prompt and connected later via Settings). +export function shouldRetryPaneSpawnOnSshReconnect(args: { + targetId: string + tabPtyId: string | null | undefined + deferredSessionId: string | undefined +}): boolean { + if (!args.tabPtyId) { + return true + } + return ( + args.deferredSessionId != null && + parseAppSshPtyId(args.deferredSessionId)?.connectionId === args.targetId + ) +} diff --git a/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts b/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts index 00aa3fe4fea..51b552f3436 100644 --- a/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts +++ b/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts @@ -159,6 +159,40 @@ describe('useComposerState host-context boundaries', () => { expect(submitSection).toContain('runtimeEnvironmentId: folderTargetRuntimeEnvironmentId') }) + it('detects composer agents against the repo host: SSH, then runtime, then local (#7082)', () => { + // Why: a repo owned by a paired runtime must show the runtime's agents, not + // the local machine's. SSH stays first priority; runtime falls through before + // local so an SSH repo never double-detects. Regression guard for #7082. + const selectorSection = sourceBetween( + HOOK_SOURCE, + 'const detectedAgentList = useAppStore', + 'const ensureDetectedAgents = useAppStore' + ) + expect(selectorSection).toContain('if (isRemote) {') + expect(selectorSection).toContain('s.remoteDetectedAgentIds[connectionId]') + expect(selectorSection).toContain('if (runtimeEnvironmentId) {') + expect(selectorSection).toContain('s.runtimeDetectedAgentIds[runtimeEnvironmentId]') + expect(selectorSection).toContain('return s.detectedAgentIds') + // SSH branch is checked before the runtime branch. + expect(selectorSection.indexOf('if (isRemote) {')).toBeLessThan( + selectorSection.indexOf('if (runtimeEnvironmentId) {') + ) + + expect(HOOK_SOURCE).toContain( + 'const runtimeEnvironmentId = selectedRepoSettings?.activeRuntimeEnvironmentId?.trim() || null' + ) + + // Detection effect fans out to the same three hosts in the same order and + // re-runs when the runtime environment changes. + const detectSection = sourceBetween(HOOK_SOURCE, 'const detect = isRemote', 'void detect.then') + expect(detectSection).toContain('ensureRemoteDetectedAgents(connectionId)') + expect(detectSection).toContain('ensureRuntimeDetectedAgents(runtimeEnvironmentId)') + expect(detectSection).toContain('ensureDetectedAgents()') + expect(HOOK_SOURCE).toContain( + '}, [connectionId, runtimeEnvironmentId, isRemote, selectedRepoSshStatus, disabledTuiAgents])' + ) + }) + it('seeds initial workspace run target from the task source context', () => { expect( resolveInitialWorkspaceRunSeed({ diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index dc6e1aa1957..d1316b98678 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -1122,20 +1122,25 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS const [tuiAgent, setTuiAgent] = useState( persistDraft ? (newWorkspaceDraft?.agent ?? fallbackDefaultAgent) : fallbackDefaultAgent ) - // Why: when the selected repo is remote (has a connectionId), read the - // per-connection agent list instead of the local one. This ensures the - // Create Workspace dialog shows agents installed on the SSH host, not the - // local machine. + // Why: when the selected repo has a connectionId or runtime environment, read + // the per-host agent list instead of the local one. This ensures the Create + // Workspace dialog shows agents installed on the SSH host or paired runtime, + // not the local machine. const connectionId = selectedRepoConnectionId const isRemote = typeof connectionId === 'string' + const runtimeEnvironmentId = selectedRepoSettings?.activeRuntimeEnvironmentId?.trim() || null const detectedAgentList = useAppStore((s) => { if (isRemote) { return s.remoteDetectedAgentIds[connectionId] ?? null } + if (runtimeEnvironmentId) { + return s.runtimeDetectedAgentIds[runtimeEnvironmentId] ?? null + } return s.detectedAgentIds }) const ensureDetectedAgents = useAppStore((s) => s.ensureDetectedAgents) const ensureRemoteDetectedAgents = useAppStore((s) => s.ensureRemoteDetectedAgents) + const ensureRuntimeDetectedAgents = useAppStore((s) => s.ensureRuntimeDetectedAgents) const detectedAgentIds = useMemo | null>( () => (detectedAgentList ? new Set(detectedAgentList) : null), [detectedAgentList] @@ -1696,14 +1701,18 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS ]) // Why: detect agents for the selected repo. For local repos this runs once - // on mount (deduped by the store). For remote repos it re-runs when the - // selected repo changes so the agent list matches the SSH host. + // on mount (deduped by the store). For remote/runtime repos it re-runs when + // the selected repo changes so the agent list matches the correct host. useEffect(() => { if (isRemote && selectedRepoSshStatus !== 'connected') { return } let cancelled = false - const detect = isRemote ? ensureRemoteDetectedAgents(connectionId) : ensureDetectedAgents() + const detect = isRemote + ? ensureRemoteDetectedAgents(connectionId) + : runtimeEnvironmentId + ? ensureRuntimeDetectedAgents(runtimeEnvironmentId) + : ensureDetectedAgents() void detect.then((ids) => { if (cancelled) { return @@ -1722,11 +1731,11 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS return () => { cancelled = true } - // Why: re-run when connectionId changes (user picks a different repo) so - // detection targets the correct host. Draft/settings deps are intentionally - // excluded — detection is a best-effort PATH snapshot. + // Why: re-run when connectionId/runtimeEnvironmentId changes (user picks a + // different repo) so detection targets the correct host. Draft/settings deps + // are intentionally excluded — detection is a best-effort PATH snapshot. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [connectionId, isRemote, selectedRepoSshStatus, disabledTuiAgents]) + }, [connectionId, runtimeEnvironmentId, isRemote, selectedRepoSshStatus, disabledTuiAgents]) // Per-repo: load yaml hooks + issue command template. useEffect(() => { diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 8ae66a5fcda..af6295f2923 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -2,6 +2,7 @@ import { useEffect } from 'react' import { toast } from 'sonner' import { useAppStore } from '../store' +import { shouldRetryPaneSpawnOnSshReconnect } from './ssh-reconnect-pane-retry' import { getWorktreeMapFromState, getRepoMapFromState } from '@/store/selectors' import { applyUIZoom } from '@/lib/ui-zoom' import { activateAndRevealWorktree } from '@/lib/worktree-activation' @@ -2615,8 +2616,10 @@ export function useIpcEvents(): void { void Promise.all(remoteRepos.map((r) => store.fetchWorktrees(r.id))).then(async () => { await useAppStore.getState().fetchWorktreeLineage() // Why: terminal panes that failed to spawn (no PTY provider on cold - // start) sit inert. Bumping generation forces TerminalPane to remount - // and retry pty:spawn. Only bump tabs with no live ptyId. + // start) or whose deferred reattach never ran sit inert. Bumping + // generation forces TerminalPane to remount and retry; the remount + // routes through the deferred-connect gate, which reattaches the + // stranded session or spawns fresh now that the provider exists. const freshStore = useAppStore.getState() const remoteRepoIds = new Set(remoteRepos.map((r) => r.id)) const worktreeIds = Object.values(freshStore.worktreesByRepo) @@ -2626,13 +2629,18 @@ export function useIpcEvents(): void { for (const worktreeId of worktreeIds) { const tabs = freshStore.tabsByWorktree[worktreeId] ?? [] - const hasDead = tabs.some((t) => !t.ptyId) - if (hasDead) { + const needsRetry = (t: { id: string; ptyId?: string | null }): boolean => + shouldRetryPaneSpawnOnSshReconnect({ + targetId, + tabPtyId: t.ptyId, + deferredSessionId: freshStore.deferredSshSessionIdsByTabId[t.id] + }) + if (tabs.some(needsRetry)) { useAppStore.setState((s) => ({ tabsByWorktree: { ...s.tabsByWorktree, [worktreeId]: (s.tabsByWorktree[worktreeId] ?? []).map((t) => - t.ptyId ? t : { ...t, generation: (t.generation ?? 0) + 1 } + needsRetry(t) ? { ...t, generation: (t.generation ?? 0) + 1 } : t ) } })) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 2d4efa1e146..d15a81c4661 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -2771,6 +2771,9 @@ "EditorFileTabCloseButton": { "4655cf570e": "Close tab", "a768f428f1": "Close tab" + }, + "TerminalTabSplitMenuSection": { + "splitTerminal": "Split terminal" } } }, @@ -4758,7 +4761,10 @@ "agentPermissionsDescription": "Choose whether Orca launches agents with fewer permission prompts or with manual checks.", "agentPermissionsYolo": "Yolo", "agentPermissionsManual": "Manual", - "3f1bdf3cb4": "Environment text is too large to parse safely." + "3f1bdf3cb4": "Environment text is too large to parse safely.", + "codexSessionSource": "Codex home to import from", + "codexSessionSourceInfo": "About importing Codex history", + "codexSessionSourceTooltip": "Orca runs Codex in an isolated home. Point this at your existing Codex home to import that session history. Empty uses ~/.codex." }, "AppIconSelector": { "d5a112dc9b": "Next icon", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 66a2b10f23b..84d5a861a54 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -2771,6 +2771,9 @@ "EditorFileTabCloseButton": { "4655cf570e": "Cerrar pestaña", "a768f428f1": "Cerrar pestaña" + }, + "TerminalTabSplitMenuSection": { + "splitTerminal": "Split terminal" } } }, @@ -4758,7 +4761,10 @@ "agentPermissionsDescription": "Elige si Orca lanza agents con menos prompts de permisos o con checks manuales.", "agentPermissionsYolo": "Yolo", "agentPermissionsManual": "Manual", - "3f1bdf3cb4": "El texto de entorno es demasiado grande para analizarlo de forma segura." + "3f1bdf3cb4": "El texto de entorno es demasiado grande para analizarlo de forma segura.", + "codexSessionSource": "Codex home to import from", + "codexSessionSourceInfo": "About importing Codex history", + "codexSessionSourceTooltip": "Orca runs Codex in an isolated home. Point this at your existing Codex home to import that session history. Empty uses ~/.codex." }, "AppIconSelector": { "d5a112dc9b": "Icono siguiente", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 2b76dba34a1..20e253a584d 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -2771,6 +2771,9 @@ "EditorFileTabCloseButton": { "4655cf570e": "タブを閉じる", "a768f428f1": "タブを閉じる" + }, + "TerminalTabSplitMenuSection": { + "splitTerminal": "Split terminal" } } }, @@ -4743,7 +4746,10 @@ "agentPermissionsDescription": "Choose whether Orca launches agents with fewer permission prompts or with manual checks.", "agentPermissionsYolo": "Yolo", "agentPermissionsManual": "手動", - "3f1bdf3cb4": "環境テキストが大きすぎるため安全に解析できません。" + "3f1bdf3cb4": "環境テキストが大きすぎるため安全に解析できません。", + "codexSessionSource": "Codex home to import from", + "codexSessionSourceInfo": "About importing Codex history", + "codexSessionSourceTooltip": "Orca runs Codex in an isolated home. Point this at your existing Codex home to import that session history. Empty uses ~/.codex." }, "AppIconSelector": { "d5a112dc9b": "次へのアイコン", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 646b74a42f7..cd83a58fa86 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -2771,6 +2771,9 @@ "EditorFileTabCloseButton": { "4655cf570e": "탭 닫기", "a768f428f1": "탭 닫기" + }, + "TerminalTabSplitMenuSection": { + "splitTerminal": "Split terminal" } } }, @@ -4743,7 +4746,10 @@ "agentPermissionsDescription": "Orca가 agents를 실행할 때 권한 프롬프트를 줄일지, 수동 확인을 유지할지 선택합니다.", "agentPermissionsYolo": "Yolo", "agentPermissionsManual": "수동", - "3f1bdf3cb4": "환경 텍스트가 너무 커서 안전하게 파싱할 수 없습니다." + "3f1bdf3cb4": "환경 텍스트가 너무 커서 안전하게 파싱할 수 없습니다.", + "codexSessionSource": "Codex home to import from", + "codexSessionSourceInfo": "About importing Codex history", + "codexSessionSourceTooltip": "Orca runs Codex in an isolated home. Point this at your existing Codex home to import that session history. Empty uses ~/.codex." }, "AppIconSelector": { "d5a112dc9b": "다음 아이콘", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 66de9505f78..5be00891377 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -2771,6 +2771,9 @@ "EditorFileTabCloseButton": { "4655cf570e": "关闭选项卡", "a768f428f1": "关闭选项卡" + }, + "TerminalTabSplitMenuSection": { + "splitTerminal": "Split terminal" } } }, @@ -4743,7 +4746,10 @@ "agentPermissionsDescription": "选择 Orca 是以更少的权限提示启动智能体,还是手动检查。", "agentPermissionsYolo": "Yolo", "agentPermissionsManual": "手动", - "3f1bdf3cb4": "环境文本过长,无法安全解析。" + "3f1bdf3cb4": "环境文本过长,无法安全解析。", + "codexSessionSource": "Codex home to import from", + "codexSessionSourceInfo": "About importing Codex history", + "codexSessionSourceTooltip": "Orca runs Codex in an isolated home. Point this at your existing Codex home to import that session history. Empty uses ~/.codex." }, "AppIconSelector": { "d5a112dc9b": "下一个图标", diff --git a/src/renderer/src/lib/active-agent-note-target.ts b/src/renderer/src/lib/active-agent-note-target.ts index 5bbc1253a4f..9ee0dfc511d 100644 --- a/src/renderer/src/lib/active-agent-note-target.ts +++ b/src/renderer/src/lib/active-agent-note-target.ts @@ -15,10 +15,10 @@ import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' import { isTerminalLeafId, makePaneKey } from '../../../shared/stable-pane-id' import type { TerminalLayoutSnapshot } from '../../../shared/types' import { - detectAgentStatusFromTitle, - getAgentLabel, - isExplicitAgentStatusFresh -} from './agent-status' + classifyTitleActivity, + isExplicitAgentStatusFresh, + resolveTitleActivityLabel +} from '@/lib/pane-agent-evidence' import { resolveRuntimePaneTitleForLeaf } from './runtime-pane-title-leaf-id' const ACTIVE_AGENT_PROBE_RPC_TIMEOUT_MS = 3000 @@ -271,5 +271,5 @@ function getFocusedRuntimePaneTitle( } function isRecognizedAgentTitle(title: string): boolean { - return detectAgentStatusFromTitle(title) !== null && getAgentLabel(title) !== null + return classifyTitleActivity(title) !== null && resolveTitleActivityLabel(title) !== null } diff --git a/src/renderer/src/lib/agent-ready-wait.ts b/src/renderer/src/lib/agent-ready-wait.ts index 70cbb4ade04..8bd7cb8677b 100644 --- a/src/renderer/src/lib/agent-ready-wait.ts +++ b/src/renderer/src/lib/agent-ready-wait.ts @@ -1,4 +1,4 @@ -import { detectAgentStatusFromTitle } from '../../../shared/agent-detection' +import { classifyTitleActivity } from '@/lib/pane-agent-evidence' import { isExpectedAgentProcess } from '../../../shared/agent-process-recognition' import { isShellProcess } from './tui-agent-startup' import { useAppStore } from '@/store' @@ -51,7 +51,7 @@ function titleSuggestsReady(tabId: string): boolean { } } } - return titles.some((title) => detectAgentStatusFromTitle(title) === 'idle') + return titles.some((title) => classifyTitleActivity(title) === 'idle') } /** diff --git a/src/renderer/src/lib/agent-send-title-status.ts b/src/renderer/src/lib/agent-send-title-status.ts index 11f949fde88..9ae50293693 100644 --- a/src/renderer/src/lib/agent-send-title-status.ts +++ b/src/renderer/src/lib/agent-send-title-status.ts @@ -1,4 +1,5 @@ -import { type AgentStatus, detectAgentStatusFromTitle, getAgentLabel } from './agent-status' +import type { AgentStatus } from './agent-status' +import { classifyTitleActivity, resolveTitleActivityLabel } from '@/lib/pane-agent-evidence' const EXPLICIT_IDLE_SEND_TITLE_RE = /(^|\s)(ready|idle|done)(\s|$|[.!?])/i const CLAUDE_IDLE_PREFIX = '\u2733' @@ -6,11 +7,11 @@ const GEMINI_IDLE_PREFIX = '\u25c7' const PI_IDLE_PREFIX = '\u03c0 - ' export function detectAgentSendTitleStatus(title: string | null | undefined): AgentStatus | null { - if (!title || getAgentLabel(title) === null) { + if (!title || resolveTitleActivityLabel(title) === null) { return null } - const status = detectAgentStatusFromTitle(title) + const status = classifyTitleActivity(title) if (status !== 'idle') { return status } diff --git a/src/renderer/src/lib/agent-startup-delayed-delivery.ts b/src/renderer/src/lib/agent-startup-delayed-delivery.ts index 9512b267cf4..5ca13944b32 100644 --- a/src/renderer/src/lib/agent-startup-delayed-delivery.ts +++ b/src/renderer/src/lib/agent-startup-delayed-delivery.ts @@ -1,6 +1,13 @@ import { useAppStore } from '@/store' import type { AgentStartupPlan } from '@/lib/tui-agent-startup' import { parsePaneKey } from '../../../shared/stable-pane-id' +import { + agentStartupDeliveryKey as deliveryKey, + clearConsumedAgentStartupDeliveriesForTests, + isAgentStartupDeliveryConsumed, + markAgentStartupDeliveryConsumed, + releaseAgentStartupDeliveryConsumed +} from './agent-startup-delivery-guards' type AppStoreSnapshot = ReturnType @@ -13,7 +20,6 @@ type PendingAgentStartupDelivery = { } const pendingAgentStartupDeliveries = new Map() -const consumedAgentStartupDeliveries = new Set() const staleStartupRecheckTimers = new Map>() let unsubscribePendingAgentStartupDeliveries: (() => void) | null = null @@ -99,7 +105,7 @@ function stopPendingAgentStartupSubscriptionIfIdle(): void { export function queuePendingAgentStartupDelivery(delivery: PendingAgentStartupDelivery): void { const key = deliveryKey(delivery) - if (consumedAgentStartupDeliveries.has(key)) { + if (isAgentStartupDeliveryConsumed(key)) { return } pendingAgentStartupDeliveries.set(key, delivery) @@ -109,7 +115,7 @@ export function queuePendingAgentStartupDelivery(delivery: PendingAgentStartupDe export function resetAgentStartupDelayedDeliveryForTests(): void { pendingAgentStartupDeliveries.clear() - consumedAgentStartupDeliveries.clear() + clearConsumedAgentStartupDeliveriesForTests() for (const timer of staleStartupRecheckTimers.values()) { globalThis.clearTimeout(timer) } @@ -124,10 +130,10 @@ export function beginAgentStartupDeliveryAttempt(args: { launchToken: string }): boolean { const key = deliveryKey(args) - if (consumedAgentStartupDeliveries.has(key)) { + if (isAgentStartupDeliveryConsumed(key)) { return false } - consumedAgentStartupDeliveries.add(key) + markAgentStartupDeliveryConsumed(key) pendingAgentStartupDeliveries.delete(key) clearStaleStartupRecheck(key) return true @@ -138,13 +144,7 @@ export function releaseAgentStartupDeliveryAttempt(args: { tabId: string launchToken: string }): void { - consumedAgentStartupDeliveries.delete(deliveryKey(args)) -} - -function deliveryKey( - delivery: Pick -): string { - return `${delivery.worktreeId}\0${delivery.tabId}\0${delivery.launchToken}` + releaseAgentStartupDeliveryConsumed(deliveryKey(args)) } function flushPendingAgentStartupDeliveries(): void { diff --git a/src/renderer/src/lib/agent-startup-delivery-guards.ts b/src/renderer/src/lib/agent-startup-delivery-guards.ts new file mode 100644 index 00000000000..5e1f4d71d14 --- /dev/null +++ b/src/renderer/src/lib/agent-startup-delivery-guards.ts @@ -0,0 +1,48 @@ +// Leaf module (imports nothing from the store) holding the consumed +// agent-startup-delivery guard set, so store slices can purge it on tab/worktree +// removal without creating a store → agent-startup-delayed-delivery → store +// import cycle. + +export function agentStartupDeliveryKey(delivery: { + worktreeId: string + tabId: string + launchToken: string +}): string { + return `${delivery.worktreeId}\0${delivery.tabId}\0${delivery.launchToken}` +} + +const consumedAgentStartupDeliveries = new Set() + +export function isAgentStartupDeliveryConsumed(key: string): boolean { + return consumedAgentStartupDeliveries.has(key) +} + +export function markAgentStartupDeliveryConsumed(key: string): void { + consumedAgentStartupDeliveries.add(key) +} + +export function releaseAgentStartupDeliveryConsumed(key: string): void { + consumedAgentStartupDeliveries.delete(key) +} + +export function clearConsumedAgentStartupDeliveriesForTests(): void { + consumedAgentStartupDeliveries.clear() +} + +// Why: one consumed guard is recorded per launch that reached delivery and is +// never released on the happy path, so guards for removed tabs/worktrees would +// accumulate for the renderer's whole session. Drop them when the owning tab is +// closed or its worktree is removed. +export function forgetAgentStartupDeliveriesForTabs(tabIds: Iterable): void { + const tabIdSet = tabIds instanceof Set ? tabIds : new Set(tabIds) + if (tabIdSet.size === 0) { + return + } + for (const key of consumedAgentStartupDeliveries) { + // agentStartupDeliveryKey() is `${worktreeId}\0${tabId}\0${launchToken}`. + const tabId = key.split('\0')[1] ?? '' + if (tabIdSet.has(tabId)) { + consumedAgentStartupDeliveries.delete(key) + } + } +} diff --git a/src/renderer/src/lib/agent-status-terminal-title.ts b/src/renderer/src/lib/agent-status-terminal-title.ts index d3162d52684..c5c7bca56d4 100644 --- a/src/renderer/src/lib/agent-status-terminal-title.ts +++ b/src/renderer/src/lib/agent-status-terminal-title.ts @@ -1,4 +1,4 @@ -import { detectAgentStatusFromTitle } from '../../../shared/agent-detection' +import { classifyTitleActivity } from './pane-agent-evidence' import type { ParsedAgentStatusPayload } from '../../../shared/agent-status-types' import { getSyntheticAgentTerminalTitle, @@ -26,7 +26,7 @@ function shouldReplaceCurrentTitle( if (!currentTitle?.trim()) { return true } - const currentStatus = detectAgentStatusFromTitle(currentTitle) + const currentStatus = classifyTitleActivity(currentTitle) if (currentStatus === 'working') { return true } diff --git a/src/renderer/src/lib/agent-status.ts b/src/renderer/src/lib/agent-status.ts index 39618185154..141da5c2a3a 100644 --- a/src/renderer/src/lib/agent-status.ts +++ b/src/renderer/src/lib/agent-status.ts @@ -1,9 +1,5 @@ import type { TerminalTab, TuiAgent, Worktree } from '../../../shared/types' -import type { - AgentStatusEntry, - AgentStatusState, - AgentType -} from '../../../shared/agent-status-types' +import type { AgentStatusState, AgentType } from '../../../shared/agent-status-types' import { tabHasLivePty } from './tab-has-live-pty' import type { WorktreeStatus } from './worktree-status' import { tuiAgentToAgentKind } from '../../../shared/agent-kind' @@ -23,11 +19,8 @@ export { isClaudeManagementTitle, getAgentLabel } from '../../../shared/agent-detection' -import { - type AgentStatus, - detectAgentStatusFromTitle, - getAgentLabel -} from '../../../shared/agent-detection' +import type { AgentStatus } from '../../../shared/agent-detection' +import { classifyTitleActivity, resolveTitleActivityLabel } from './pane-agent-evidence' type AgentQueryArgs = { tabsByWorktree: Record @@ -84,8 +77,8 @@ export function getWorkingAgentsPerWorktree({ const paneTitles = runtimePaneTitlesByTabId[tab.id] if (paneTitles && Object.keys(paneTitles).length > 0) { for (const [paneIdStr, title] of Object.entries(paneTitles)) { - if (detectAgentStatusFromTitle(title) === 'working') { - const label = getAgentLabel(title) + if (classifyTitleActivity(title) === 'working') { + const label = resolveTitleActivityLabel(title) if (label) { agents.push({ label, @@ -96,8 +89,8 @@ export function getWorkingAgentsPerWorktree({ } } } - } else if (detectAgentStatusFromTitle(tab.title) === 'working') { - const label = getAgentLabel(tab.title) + } else if (classifyTitleActivity(tab.title) === 'working') { + const label = resolveTitleActivityLabel(tab.title) if (label) { agents.push({ label, status: 'working', tabId: tab.id, paneId: null }) } @@ -210,16 +203,9 @@ export function agentKindForAgentType(agentType: AgentType | null | undefined): return tuiAgent ? tuiAgentToAgentKind(tuiAgent) : 'other' } -// Why: explicit agent status entries (from hook-based reports) can go stale if -// the agent process exits without sending a final update. This helper lets -// callers decide whether to trust the entry based on a configurable TTL. -export function isExplicitAgentStatusFresh( - entry: Pick, - now: number, - staleAfterMs: number -): boolean { - return now - entry.updatedAt <= staleAfterMs -} +// Why: the freshness gate moved into the pane-agent-evidence resolvers; the +// re-export keeps this module's many existing importers unchanged. +export { isExplicitAgentStatusFresh } from './pane-agent-evidence' /** * Map an explicit AgentStatusState to the visual Status used by @@ -297,13 +283,13 @@ function countWorkingAgentsForTab( // (for example restored-but-unvisited worktrees). if (paneTitles && Object.keys(paneTitles).length > 0) { for (const title of Object.values(paneTitles)) { - if (detectAgentStatusFromTitle(title) === 'working') { + if (classifyTitleActivity(title) === 'working') { count += 1 } } return count } - if (detectAgentStatusFromTitle(tab.title) === 'working') { + if (classifyTitleActivity(tab.title) === 'working') { count += 1 } return count diff --git a/src/renderer/src/lib/ai-vault-resume-command.test.ts b/src/renderer/src/lib/ai-vault-resume-command.test.ts index dc0345491bf..4d5db5d7237 100644 --- a/src/renderer/src/lib/ai-vault-resume-command.test.ts +++ b/src/renderer/src/lib/ai-vault-resume-command.test.ts @@ -123,6 +123,52 @@ describe('ai vault resume command runtime', () => { ).toBe("cd 'C:\\Users\\alice\\repo' && claude '--resume' 'session one'") }) + it('queues a PowerShell-valid local OMP resume by absolute transcript path', () => { + // Regression: local rebuilds must forward session.filePath so OMP resumes by + // path, and queued Windows commands must match the live tab shell. + const state = makeState({ worktreePath: 'C:\\Users\\alice\\repo' }) + + const command = buildAiVaultResumeCommandForWorktree({ + state, + worktreeId: 'repo-1::worktree-1', + session: { + agent: 'omp', + sessionId: '019f27cd-4268-7000-96e7-62f42a55c144', + filePath: 'C:\\Users\\alice\\.omp\\agent\\sessions\\repo\\sess.jsonl', + cwd: 'C:\\Users\\alice\\repo', + codexHome: null + } + }) + + expect(command).toBe( + "Set-Location -LiteralPath 'C:\\Users\\alice\\repo'; omp --resume 'C:\\Users\\alice\\.omp\\agent\\sessions\\repo\\sess.jsonl'" + ) + expect(command).not.toContain('019f27cd-4268-7000-96e7-62f42a55c144') + }) + + it('keeps cmd quoting for local OMP resume when cmd.exe is configured', () => { + const state = makeState({ + worktreePath: 'C:\\Users\\alice\\repo', + terminalWindowsShell: 'cmd.exe' + }) + + expect( + buildAiVaultResumeCommandForWorktree({ + state, + worktreeId: 'repo-1::worktree-1', + session: { + agent: 'omp', + sessionId: '019f27cd-4268-7000-96e7-62f42a55c144', + filePath: 'C:\\Users\\alice\\.omp\\agent\\sessions\\repo\\sess.jsonl', + cwd: 'C:\\Users\\alice\\repo', + codexHome: null + } + }) + ).toBe( + 'cmd /d /s /c "cd /d ""C:\\Users\\alice\\repo"" && omp --resume ""C:\\Users\\alice\\.omp\\agent\\sessions\\repo\\sess.jsonl"""' + ) + }) + it('keeps the cmd wrapper for the copy-to-clipboard command on Windows', () => { // Regression guard: the copy path is self-contained for pasting into cmd.exe // and must stay cmd-wrapped even though the queued path now follows the shell. diff --git a/src/renderer/src/lib/ai-vault-resume-command.ts b/src/renderer/src/lib/ai-vault-resume-command.ts index 691d69efc09..d71f3653fbb 100644 --- a/src/renderer/src/lib/ai-vault-resume-command.ts +++ b/src/renderer/src/lib/ai-vault-resume-command.ts @@ -26,7 +26,9 @@ type AiVaultResumeCommandSession = Pick< AiVaultSession, 'agent' | 'sessionId' | 'cwd' | 'codexHome' > & - Partial> + Partial< + Pick + > export type AiVaultResumeStartup = { command: string @@ -127,10 +129,15 @@ export function buildAiVaultResumeStartupForWorktree(args: { command: buildAiVaultResumeCommand({ agent: args.session.agent, sessionId: args.session.sessionId, + // Why: OMP resumes by absolute transcript path, so local rebuilds must + // forward it too — otherwise a custom OMP_CODING_AGENT_DIR / WSL-store + // session would resume by id against the default store and miss. + resumeFilePath: args.session.filePath, cwd: args.session.cwd, platform, commandOverride: args.commandOverride, - codexHome + codexHome, + shell: queuedShell }) } } diff --git a/src/renderer/src/lib/foreground-terminal-tabs.ts b/src/renderer/src/lib/foreground-terminal-tabs.ts index 7824598db26..925be716ccb 100644 --- a/src/renderer/src/lib/foreground-terminal-tabs.ts +++ b/src/renderer/src/lib/foreground-terminal-tabs.ts @@ -55,6 +55,15 @@ export function getForegroundTerminalTabLastSeenAtById(): Record return Object.fromEntries(foregroundTerminalTabLastSeenAtById) } +// Why: retired terminal tab ids never recur, so their last-seen timestamps would +// accumulate for the renderer's whole session. Drop them when a tab is closed or +// its worktree is removed (mirrors forgetAgentHibernationTabOutput). +export function forgetForegroundTerminalTabs(tabIds: Iterable): void { + for (const tabId of tabIds) { + foregroundTerminalTabLastSeenAtById.delete(tabId) + } +} + export function resetForegroundTerminalTabIdsForTests(): void { explicitForegroundTerminalTabIds = new Set() visibleTerminalTabClaimsByToken.clear() diff --git a/src/renderer/src/lib/pane-agent-evidence.test.ts b/src/renderer/src/lib/pane-agent-evidence.test.ts new file mode 100644 index 00000000000..0bd618e2940 --- /dev/null +++ b/src/renderer/src/lib/pane-agent-evidence.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from 'vitest' +import { + AGENT_STATUS_STALE_AFTER_MS, + type AgentStatusEntry +} from '../../../shared/agent-status-types' +import { + classifyTitleActivity, + isExplicitAgentStatusFresh, + resolveCommittedTitleAgentType, + resolvePaneAgentActivity, + resolveTitleActivityLabel +} from './pane-agent-evidence' + +const NOW = 1_700_000_000_000 + +function entry(overrides: Partial = {}): AgentStatusEntry { + return { + paneKey: 'tab-1:leaf-1', + agentType: 'claude', + state: 'working', + prompt: '', + updatedAt: NOW, + stateStartedAt: NOW, + stateHistory: [], + ...overrides + } as AgentStatusEntry +} + +describe('isExplicitAgentStatusFresh', () => { + it('accepts an entry exactly at the staleness boundary', () => { + expect( + isExplicitAgentStatusFresh( + { updatedAt: NOW - AGENT_STATUS_STALE_AFTER_MS }, + NOW, + AGENT_STATUS_STALE_AFTER_MS + ) + ).toBe(true) + }) + + it('rejects an entry past the staleness boundary', () => { + expect( + isExplicitAgentStatusFresh( + { updatedAt: NOW - AGENT_STATUS_STALE_AFTER_MS - 1 }, + NOW, + AGENT_STATUS_STALE_AFTER_MS + ) + ).toBe(false) + }) +}) + +describe('classifyTitleActivity', () => { + it('classifies working, permission, idle, and unclassifiable titles', () => { + expect(classifyTitleActivity('mimo working')).toBe('working') + expect(classifyTitleActivity('Claude - action required')).toBe('permission') + expect(classifyTitleActivity('✳ Claude Code ready')).toBe('idle') + expect(classifyTitleActivity('vim')).toBe(null) + }) +}) + +describe('title agent identity facets', () => { + it('splits the bare Claude spinner into activity label without committed identity', () => { + expect(resolveTitleActivityLabel('⠋ compiling everything')).toBe('Claude Code') + expect(resolveCommittedTitleAgentType('⠋ compiling everything')).toBe(null) + }) + + it('commits identity when the title names the agent explicitly', () => { + expect(resolveTitleActivityLabel('✳ Claude Code working')).toBe('Claude Code') + expect(resolveCommittedTitleAgentType('✳ Claude Code working')).toBe('claude') + }) + + it('returns neither facet for a plain shell title', () => { + expect(resolveTitleActivityLabel('zsh')).toBe(null) + expect(resolveCommittedTitleAgentType('zsh')).toBe(null) + }) +}) + +describe('resolvePaneAgentActivity', () => { + it('reports a fresh hook row as the authoritative source and keeps the title layer visible', () => { + const decision = resolvePaneAgentActivity({ + explicitEntry: entry({ state: 'waiting' }), + liveTitle: '⠋ running the tests', + hasLivePty: true, + now: NOW + }) + expect(decision).toEqual({ + hookState: 'waiting', + hookAgentType: 'claude', + titleStatus: 'working', + source: 'hook', + confidence: 'authoritative', + livePtyRequired: false + }) + }) + + it('treats a stale hook row as absent and falls back to the title', () => { + const decision = resolvePaneAgentActivity({ + explicitEntry: entry({ updatedAt: NOW - AGENT_STATUS_STALE_AFTER_MS - 1 }), + liveTitle: '⠋ running the tests', + hasLivePty: true, + now: NOW + }) + expect(decision.hookState).toBe(null) + expect(decision.source).toBe('title') + expect(decision.confidence).toBe('fallback') + expect(decision.titleStatus).toBe('working') + expect(decision.livePtyRequired).toBe(false) + }) + + it('flags title-only evidence without a live PTY so liveness-gated consumers drop it', () => { + const decision = resolvePaneAgentActivity({ + explicitEntry: undefined, + liveTitle: '⠋ running the tests', + hasLivePty: false, + now: NOW + }) + expect(decision.source).toBe('title') + expect(decision.livePtyRequired).toBe(true) + }) + + it('reports none when there is no fresh hook and the title carries no status', () => { + const decision = resolvePaneAgentActivity({ + explicitEntry: undefined, + liveTitle: 'bash', + hasLivePty: true, + now: NOW + }) + expect(decision).toEqual({ + hookState: null, + hookAgentType: undefined, + titleStatus: null, + source: 'none', + confidence: 'authoritative', + livePtyRequired: false + }) + }) + + it('passes hook state through raw, including done', () => { + const decision = resolvePaneAgentActivity({ + explicitEntry: entry({ state: 'done' }), + liveTitle: null, + hasLivePty: true, + now: NOW + }) + expect(decision.hookState).toBe('done') + expect(decision.source).toBe('hook') + }) +}) diff --git a/src/renderer/src/lib/pane-agent-evidence.ts b/src/renderer/src/lib/pane-agent-evidence.ts new file mode 100644 index 00000000000..4d67758951a --- /dev/null +++ b/src/renderer/src/lib/pane-agent-evidence.ts @@ -0,0 +1,121 @@ +import type { AgentStatus } from '../../../shared/agent-detection' +import { detectAgentStatusFromTitle, getAgentLabel } from '../../../shared/agent-detection' +import { resolveExplicitTerminalTitleAgentType } from '../../../shared/terminal-title-agent-type' +import type { TuiAgent } from '../../../shared/types' +import { + AGENT_STATUS_STALE_AFTER_MS, + type AgentStatusEntry, + type AgentStatusState, + type AgentType +} from '../../../shared/agent-status-types' + +// Why: explicit agent status entries (from hook-based reports) can go stale if +// the agent process exits without sending a final update. This helper lets +// callers decide whether to trust the entry based on a configurable TTL. +// (Moved here from agent-status.ts so the evidence resolvers below and the +// aggregate consumers share one gate without an import cycle.) +export function isExplicitAgentStatusFresh( + entry: Pick, + now: number, + staleAfterMs: number +): boolean { + return now - entry.updatedAt <= staleAfterMs +} + +/** + * Title-only activity classification for consumers whose product rule really + * is "what does this title say" (timer seeding, ready-wait polling, sort-epoch + * comparison, synthetic-title writing) — they combine no hook/liveness + * evidence, so routing them through the pane resolver would misstate intent. + */ +export function classifyTitleActivity(title: string): AgentStatus | null { + return detectAgentStatusFromTitle(title) +} + +/** + * The two facets of title-derived agent identity. The ACTIVITY LABEL treats + * Claude's bare status prefixes (spinner/`✳`/`. `/`* `) as Claude activity; + * COMMITTED identity rejects them — evidence that something runs is not proof + * of who. Consumers historically split on this by accident; pick the facet + * that matches the product rule, never both out of habit. + */ +export function resolveTitleActivityLabel(title: string): string | null { + return getAgentLabel(title) +} + +/** See resolveTitleActivityLabel — the strict facet for identity decisions. */ +export function resolveCommittedTitleAgentType(title: string): TuiAgent | null { + return resolveExplicitTerminalTitleAgentType(title) +} + +/** + * Combined pane activity evidence for aggregate consumers. The hook and title + * layers are exposed separately on purpose: consumers legitimately combine + * them differently (send targets let a live permission title override a fresh + * working hook; the worktree dot suppresses titles on hook-covered panes), so + * a single merged status would silently change behavior. + */ +export type AgentActivityDecision = { + /** Fresh (within AGENT_STATUS_STALE_AFTER_MS), pane-scoped hook state; null when absent or stale. */ + hookState: AgentStatusState | null + /** Agent identity from the fresh hook row, when one exists. */ + hookAgentType: AgentType | undefined + /** Title-derived status of the pane's live title, independent of hook state. */ + titleStatus: AgentStatus | null + /** Which evidence layer holds the strongest current claim. */ + source: 'hook' | 'title' | 'none' + confidence: 'authoritative' | 'fallback' + /** True when the only claim is a title without live-PTY proof — liveness-gated consumers must treat it as absent. */ + livePtyRequired: boolean +} + +export type ResolvePaneAgentActivityInput = { + explicitEntry: AgentStatusEntry | undefined + liveTitle: string | null + hasLivePty: boolean + now: number +} + +export function resolvePaneAgentActivity( + input: ResolvePaneAgentActivityInput +): AgentActivityDecision { + const freshEntry = + input.explicitEntry && + isExplicitAgentStatusFresh(input.explicitEntry, input.now, AGENT_STATUS_STALE_AFTER_MS) + ? input.explicitEntry + : null + const titleStatus = input.liveTitle !== null ? detectAgentStatusFromTitle(input.liveTitle) : null + if (freshEntry) { + return { + hookState: freshEntry.state, + hookAgentType: freshEntry.agentType, + titleStatus, + source: 'hook', + confidence: 'authoritative', + livePtyRequired: false + } + } + if (titleStatus !== null) { + return { + hookState: null, + hookAgentType: undefined, + titleStatus, + source: 'title', + confidence: 'fallback', + livePtyRequired: !input.hasLivePty + } + } + return { + hookState: null, + hookAgentType: undefined, + titleStatus: null, + source: 'none', + confidence: 'authoritative', + livePtyRequired: false + } +} + +// Deliberately absent: a resolvePaneAgentOwner precedence resolver. The only +// Phase 2 identity consumer (native-chat toggle) reads hook identity without a +// freshness gate, so a gated owner resolver would change its behavior; the +// owner resolver lands with its first real consumer in a later slice. diff --git a/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts b/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts index d7c80e76220..e0ca805801e 100644 --- a/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts +++ b/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts @@ -1,5 +1,6 @@ import { WebglAddon } from '@xterm/addon-webgl' import type { ManagedPaneInternal } from './pane-manager-types' +import { forceRepaintThroughRenderPause } from './terminal-render-pause-release' import { getTerminalWebglAutoDecision, resetTerminalWebglAutoDecision @@ -126,9 +127,16 @@ export function resetWebglTextureAtlas(pane: ManagedPaneInternal): void { // context-loss event. Clearing the atlas preserves GPU rendering and forces // a fresh paint when the pane becomes visible/focused again. pane.webglAddon?.clearTextureAtlas() - // Why: refresh even without a WebGL addon so recovery never silently - // no-ops — a DOM-rendered pane can hold stale pixels after reveal too. - pane.terminal.refresh(0, pane.terminal.rows - 1) + // Why: on reveal xterm's IntersectionObserver can still report the pane as + // not intersecting, so a plain refresh() is swallowed by RenderService's + // paused-render gate and the cleared model never repaints (stale bottom rows + // until a drag-select forces a redraw). Force the paused render through + // first; only fall back to refresh() when the terminal was not gated. + if (!forceRepaintThroughRenderPause(pane.terminal)) { + // Why: refresh even without a WebGL addon so recovery never silently + // no-ops — a DOM-rendered pane can hold stale pixels after reveal too. + pane.terminal.refresh(0, pane.terminal.rows - 1) + } } catch { /* ignore — pane may have been disposed in the meantime */ } diff --git a/src/renderer/src/lib/pane-manager/terminal-render-pause-release.test.ts b/src/renderer/src/lib/pane-manager/terminal-render-pause-release.test.ts new file mode 100644 index 00000000000..e3d84677424 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-render-pause-release.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it, vi } from 'vitest' +import { forceRepaintThroughRenderPause } from './terminal-render-pause-release' + +type FakeRenderService = { + _isPaused?: boolean + _needsFullRefresh?: boolean + refreshRows?: ReturnType +} + +function createTerminal(options: { + rows?: number + renderService?: FakeRenderService | null + withoutCore?: boolean +}): unknown { + const { rows = 24, renderService, withoutCore } = options + if (withoutCore) { + return { rows } + } + return { + rows, + _core: { _renderService: renderService ?? null } + } +} + +describe('forceRepaintThroughRenderPause', () => { + it('drives a synchronous full-viewport render and clears the pause latches when paused', () => { + const refreshRows = vi.fn() + const renderService: FakeRenderService = { + _isPaused: true, + _needsFullRefresh: true, + refreshRows + } + const terminal = createTerminal({ rows: 30, renderService }) + + expect(forceRepaintThroughRenderPause(terminal)).toBe(true) + expect(refreshRows).toHaveBeenCalledWith(0, 29, true) + expect(renderService._isPaused).toBe(false) + expect(renderService._needsFullRefresh).toBe(false) + }) + + it('leaves the terminal untouched and returns false when not paused', () => { + const refreshRows = vi.fn() + const renderService: FakeRenderService = { + _isPaused: false, + _needsFullRefresh: false, + refreshRows + } + const terminal = createTerminal({ renderService }) + + expect(forceRepaintThroughRenderPause(terminal)).toBe(false) + expect(refreshRows).not.toHaveBeenCalled() + }) + + it('returns false when the render service internals are unavailable', () => { + expect(forceRepaintThroughRenderPause(createTerminal({ withoutCore: true }))).toBe(false) + expect(forceRepaintThroughRenderPause(createTerminal({ renderService: null }))).toBe(false) + expect(forceRepaintThroughRenderPause(createTerminal({ renderService: {} }))).toBe(false) + expect(forceRepaintThroughRenderPause(null)).toBe(false) + }) + + it('returns false without rendering when the row count is invalid', () => { + const refreshRows = vi.fn() + const terminal = createTerminal({ + rows: 0, + renderService: { _isPaused: true, refreshRows } + }) + + expect(forceRepaintThroughRenderPause(terminal)).toBe(false) + expect(refreshRows).not.toHaveBeenCalled() + }) + + it('returns false when the forced render throws (disposed mid-frame)', () => { + const renderService: FakeRenderService = { + _isPaused: true, + _needsFullRefresh: true, + refreshRows: vi.fn(() => { + throw new Error('terminal disposed') + }) + } + const terminal = createTerminal({ renderService }) + + expect(forceRepaintThroughRenderPause(terminal)).toBe(false) + // Latch is still cleared — the observer reasserts authority on its next + // callback, and we must not leave a half-serviced full-refresh queued. + expect(renderService._isPaused).toBe(false) + }) +}) diff --git a/src/renderer/src/lib/pane-manager/terminal-render-pause-release.ts b/src/renderer/src/lib/pane-manager/terminal-render-pause-release.ts new file mode 100644 index 00000000000..88b53129d87 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-render-pause-release.ts @@ -0,0 +1,73 @@ +/** + * Forces a full synchronous repaint through xterm's RenderService even when its + * IntersectionObserver still reports the screen element as not intersecting. + * + * Why: on tab/worktree reveal the pane is DOM-visible but xterm's own + * observer callback can lag a frame (worse under load), leaving + * `RenderService._isPaused === true`. While paused, `refreshRows` early-returns + * and only latches `_needsFullRefresh`, so the reveal-repaint's + * `terminal.refresh()` is swallowed — the freshly-cleared render model never + * repaints and the canvas keeps compositing stale rows (classic "bottom rows + * missing until you drag-select" symptom). We can't wait for the observer, so + * we clear the latch and drive one synchronous full render ourselves; the + * observer reasserts authority naturally on its next callback. + * + * All access is behind typeof guards: an xterm upgrade that renames these + * internals degrades to a no-op (callers keep their existing refresh path), it + * never throws into a render frame. + */ + +type MaybePausableRenderService = { + _isPaused?: boolean + _needsFullRefresh?: boolean + refreshRows?: (start: number, end: number, sync?: boolean) => void +} + +type PausableRenderService = MaybePausableRenderService & { + refreshRows: (start: number, end: number, sync?: boolean) => void +} + +type TerminalWithRenderService = { + rows?: number + _core?: { + _renderService?: MaybePausableRenderService + } +} + +function getRenderService(terminal: unknown): PausableRenderService | null { + const service = (terminal as TerminalWithRenderService | null)?._core?._renderService + return service && typeof service.refreshRows === 'function' + ? (service as PausableRenderService) + : null +} + +/** + * If xterm's renderer is paused (observer hasn't caught up to the reveal), + * clear the pause latch and force a synchronous full-viewport repaint. + * Returns true when it drove the render, false when it left the terminal + * untouched (not paused, or internals unavailable) so the caller can fall back + * to its normal `terminal.refresh()`. + */ +export function forceRepaintThroughRenderPause(terminal: unknown): boolean { + const service = getRenderService(terminal) + if (!service || service._isPaused !== true) { + return false + } + + const rows = (terminal as TerminalWithRenderService).rows + if (typeof rows !== 'number' || rows < 1) { + return false + } + + // Why: leave the latch as if the pending full refresh was serviced — we are + // about to service it — so the observer's next callback doesn't queue a + // redundant second full repaint. + service._isPaused = false + service._needsFullRefresh = false + try { + service.refreshRows(0, rows - 1, true) + return true + } catch { + return false + } +} diff --git a/src/renderer/src/lib/running-agent-targets.ts b/src/renderer/src/lib/running-agent-targets.ts index 332f419dbd2..eedc06e36ba 100644 --- a/src/renderer/src/lib/running-agent-targets.ts +++ b/src/renderer/src/lib/running-agent-targets.ts @@ -1,11 +1,8 @@ import type { AppState } from '@/store/types' -import { - AGENT_STATUS_STALE_AFTER_MS, - type AgentStatusEntry -} from '../../../shared/agent-status-types' +import type { AgentStatusEntry } from '../../../shared/agent-status-types' import type { TerminalTab } from '../../../shared/types' import { parsePaneKey } from '../../../shared/stable-pane-id' -import { isExplicitAgentStatusFresh } from './agent-status' +import { resolvePaneAgentActivity } from '@/lib/pane-agent-evidence' import { detectAgentSendTitleStatus } from './agent-send-title-status' import { resolveRuntimePaneTitleLeafResolution } from './runtime-pane-title-leaf-id' @@ -58,12 +55,23 @@ export function deriveRunningAgentSendTargets( : null let disabledReason: string | undefined + // Why: the shared resolver gates hook freshness; a null hookState means the + // entry is stale (entries here always exist), and otherwise carries the + // fresh entry.state. The live-title layer stays local because it needs the + // send-gated detector (label + strict idle-send gate), which the resolver's + // raw titleStatus does not reproduce. + const decision = resolvePaneAgentActivity({ + explicitEntry: entry, + liveTitle: null, + hasLivePty: ptyId !== null, + now + }) // Why: hook-backed rows can go stale while the same PTY is still a live // agent; live titles are the runtime proof that the row remains targetable. const liveTitleStatus = ptyId ? detectLiveAgentPaneStatus(state, parsed.tabId, parsed.leafId, tab.title) : null - if (!isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS)) { + if (decision.hookState === null) { if (liveTitleStatus === 'permission') { disabledReason = 'Agent needs permission' } else if (liveTitleStatus === null) { @@ -71,7 +79,7 @@ export function deriveRunningAgentSendTargets( } } else if (!ptyId) { disabledReason = 'Terminal is no longer available' - } else if (entry.state === 'blocked' || entry.state === 'waiting') { + } else if (decision.hookState === 'blocked' || decision.hookState === 'waiting') { disabledReason = 'Agent needs permission' } else if (liveTitleStatus === 'permission') { disabledReason = 'Agent needs permission' diff --git a/src/renderer/src/lib/use-tab-agent.ts b/src/renderer/src/lib/use-tab-agent.ts index 6253ced22f4..40bf92d6f7b 100644 --- a/src/renderer/src/lib/use-tab-agent.ts +++ b/src/renderer/src/lib/use-tab-agent.ts @@ -13,8 +13,6 @@ import { import { resolveExplicitTerminalTitleAgentType } from '../../../shared/terminal-title-agent-type' import type { TerminalTab, TuiAgent } from '../../../shared/types' -export { resolveExplicitTerminalTitleAgentType as resolveTabAgentFromTitle } from '../../../shared/terminal-title-agent-type' - // A shell name, or the tab's neutral default title — where Orca's // inferred-interrupt reset parks it. Blank titles are no evidence either way. function titleShowsNoAgent(title: string, defaultTitle?: string): boolean { diff --git a/src/renderer/src/lib/worktree-status.ts b/src/renderer/src/lib/worktree-status.ts index 5018c731055..0c3c87bf062 100644 --- a/src/renderer/src/lib/worktree-status.ts +++ b/src/renderer/src/lib/worktree-status.ts @@ -1,4 +1,4 @@ -import { detectAgentStatusFromTitle } from '@/lib/agent-status' +import { classifyTitleActivity } from '@/lib/pane-agent-evidence' import { tabHasLivePty } from '@/lib/tab-has-live-pty' import { resolveRuntimePaneTitleLeafIdFromRoot } from '@/lib/runtime-pane-title-leaf-id' import type { @@ -91,7 +91,7 @@ function tabHasStatus( ) { continue } - if (detectAgentStatusFromTitle(title) === status) { + if (classifyTitleActivity(title) === status) { return true } } @@ -103,7 +103,7 @@ function tabHasStatus( if (agentStatusPaneIds && agentStatusPaneIds.size > 0) { return false } - return detectAgentStatusFromTitle(tab.title) === status + return classifyTitleActivity(tab.title) === status } export function getWorktreeStatusLabel(status: WorktreeStatus): string { diff --git a/src/renderer/src/store/slices/agent-status-drop-ipc.test.ts b/src/renderer/src/store/slices/agent-status-drop-ipc.test.ts index 4e1bf41b6ab..06e8e900a2d 100644 --- a/src/renderer/src/store/slices/agent-status-drop-ipc.test.ts +++ b/src/renderer/src/store/slices/agent-status-drop-ipc.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentStatusEntry } from '../../../../shared/agent-status-types' import type { TerminalTab } from '../../../../shared/types' import type { RetainedAgentEntry } from './agent-status' +import { RECENTLY_CLOSED_AGENT_STATUS_TAB_IDS_MAX } from './agent-status' import { createTestStore } from './store-test-helpers' // Why: dropAgentStatus and dismissRetainedAgentsByWorktree mirror the renderer- @@ -119,6 +120,24 @@ describe('dropAgentStatusByTabPrefix -> IPC fan-out', () => { 'tab-new': true }) }) + + it('FIFO-caps recentlyClosedAgentStatusTabIds so it cannot grow unbounded', () => { + stubWindowApi() + const store = createTestStore() + const cap = RECENTLY_CLOSED_AGENT_STATUS_TAB_IDS_MAX + + for (let i = 0; i < cap + 5; i++) { + store.getState().dropAgentStatusByTabPrefix(`tab-${i}`) + } + + const closed = store.getState().recentlyClosedAgentStatusTabIds + expect(Object.keys(closed)).toHaveLength(cap) + // Oldest evicted, most-recent retained (a status event for a tab closed + // >cap tabs ago cannot still arrive, so suppression is unaffected). + expect(closed['tab-0']).toBeUndefined() + expect(closed['tab-4']).toBeUndefined() + expect(closed[`tab-${cap + 4}`]).toBe(true) + }) }) describe('dismissRetainedAgentsByWorktree → IPC fan-out', () => { diff --git a/src/renderer/src/store/slices/agent-status-retained-leak.test.ts b/src/renderer/src/store/slices/agent-status-retained-leak.test.ts new file mode 100644 index 00000000000..2259af3cf5c --- /dev/null +++ b/src/renderer/src/store/slices/agent-status-retained-leak.test.ts @@ -0,0 +1,126 @@ +/** + * Memory-leak regression: retainedAgentsByPaneKey must stay bounded. + * + * `retainedAgentsByPaneKey` is a Record keyed by ephemeral paneKey + * (`${tabId}:${leafId}`, where leafId is a fresh UUID minted per pane and never + * reused). Every agent that finishes and then vanishes from the live map is + * snapshotted here via `retainAgents`. Each snapshot is a full RetainedAgentEntry + * — an AgentStatusEntry (which carries an up-to-8KB lastAssistantMessage, an + * up-to-16KB interactivePrompt, a prompt, and up to 20 stateHistory rows) plus a + * full TerminalTab snapshot. + * + * Before the fix, `retainAgents` only ever wrote entries and never capped them, + * so the map grew monotonically with the number of distinct completed-agent + * paneKeys observed. The only removal paths are worktree removal + * (`pruneRetainedAgents`) and explicit user dismissal — neither of which runs + * while a long-lived worktree stays open. Under a multi-hour multi-agent / + * orchestration session (sub-agents complete continuously), this large-payload + * accumulator is the dominant driver of the renderer JS-heap OOM seen in the + * Windows crash bundles (heap climbing to the 3586 MB old-space limit). + * + * The fix caps it to MAX_RETAINED_AGENTS, evicting the oldest-retained keys + * (insertion order == retention order, so the newest completions survive). + */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import type { TerminalTab } from '../../../../shared/types' +import type { RetainedAgentEntry } from './agent-status' +import { createTestStore } from './store-test-helpers' + +// MAX_RETAINED_AGENTS is module-private; mirror its value here. +const MAX_RETAINED_AGENTS = 500 + +// Approximate the worst-case per-entry payload the production type permits, so +// the leak's byte weight (not just entry count) is visible in the assertions. +const BIG_ASSISTANT_MESSAGE = 'a'.repeat(8 * 1024) +const BIG_INTERACTIVE_PROMPT = 'q'.repeat(16 * 1024) + +function makeRetained(index: number, worktreeId = 'wt-x'): RetainedAgentEntry { + const paneKey = `tab-${index}:leaf-${index}` + const entry: AgentStatusEntry = { + state: 'done', + prompt: `prompt ${index}`, + updatedAt: index, + stateStartedAt: index, + paneKey, + stateHistory: [], + lastAssistantMessage: BIG_ASSISTANT_MESSAGE, + interactivePrompt: BIG_INTERACTIVE_PROMPT + } + return { + entry, + worktreeId, + tab: { id: `tab-${index}`, title: 'claude' } as unknown as TerminalTab, + agentType: 'claude', + startedAt: index + } +} + +describe('retainedAgentsByPaneKey stays bounded (leak regression)', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it('caps retainedAgentsByPaneKey and keeps the most recently retained keys', () => { + const store = createTestStore() + + // Drive the production retention path with more distinct ephemeral paneKeys + // than the cap allows — one call per completion, as production does. + const total = MAX_RETAINED_AGENTS + 200 + for (let i = 0; i < total; i++) { + store.getState().retainAgents([makeRetained(i)]) + } + + const retained = store.getState().retainedAgentsByPaneKey + // Bounded — not `total`. Without the cap this is MAX_RETAINED_AGENTS + 200. + expect(Object.keys(retained)).toHaveLength(MAX_RETAINED_AGENTS) + + // The newest completions survive; the oldest are evicted. + expect(retained[`tab-${total - 1}:leaf-${total - 1}`]).toBeDefined() + expect(retained['tab-0:leaf-0']).toBeUndefined() + // The exact eviction boundary: everything before (total - cap) is gone. + expect( + retained[`tab-${total - MAX_RETAINED_AGENTS - 1}:leaf-${total - MAX_RETAINED_AGENTS - 1}`] + ).toBeUndefined() + expect( + retained[`tab-${total - MAX_RETAINED_AGENTS}:leaf-${total - MAX_RETAINED_AGENTS}`] + ).toBeDefined() + }) + + it('caps even when many completions are retained in a single batch', () => { + const store = createTestStore() + const total = MAX_RETAINED_AGENTS + 50 + const batch = Array.from({ length: total }, (_, i) => makeRetained(i)) + + store.getState().retainAgents(batch) + + const retained = store.getState().retainedAgentsByPaneKey + expect(Object.keys(retained)).toHaveLength(MAX_RETAINED_AGENTS) + expect(retained[`tab-${total - 1}:leaf-${total - 1}`]).toBeDefined() + expect(retained['tab-0:leaf-0']).toBeUndefined() + }) + + it('does not evict anything while under the cap', () => { + const store = createTestStore() + for (let i = 0; i < MAX_RETAINED_AGENTS; i++) { + store.getState().retainAgents([makeRetained(i)]) + } + const retained = store.getState().retainedAgentsByPaneKey + expect(Object.keys(retained)).toHaveLength(MAX_RETAINED_AGENTS) + expect(retained['tab-0:leaf-0']).toBeDefined() + }) + + it('re-retaining an existing paneKey overwrites in place and never grows the count', () => { + const store = createTestStore() + const first = makeRetained(0) + store.getState().retainAgents([first]) + // Same paneKey, fresh snapshot (e.g. a later status update for the same pane). + const updated = makeRetained(0) + updated.entry.prompt = 'updated' + store.getState().retainAgents([updated]) + + const retained = store.getState().retainedAgentsByPaneKey + expect(Object.keys(retained)).toHaveLength(1) + expect(retained['tab-0:leaf-0'].entry.prompt).toBe('updated') + }) +}) diff --git a/src/renderer/src/store/slices/agent-status.ts b/src/renderer/src/store/slices/agent-status.ts index bde89b4b0ce..a3336bb7c9c 100644 --- a/src/renderer/src/store/slices/agent-status.ts +++ b/src/renderer/src/store/slices/agent-status.ts @@ -215,6 +215,32 @@ export type AgentStatusSlice = { clearRetentionSuppressedPaneKeys: (paneKeys: string[]) => void } +// Why: retainedAgentsByPaneKey snapshots a completed agent (a full +// AgentStatusEntry — up to ~24KB of prompt/message text — plus a TerminalTab) +// per ephemeral paneKey. paneKeys never recur, and the map is pruned only on +// worktree removal or manual dismissal, so a long-lived worktree in a busy +// multi-agent session grows it without bound — the dominant driver of the +// renderer JS-heap OOM. Cap by insertion order (== retention order), evicting +// the oldest completions first so the newest — the ones a user is most likely +// to still care about — always survive. Evicted rows just stop showing in the +// recently-completed overlay. +const MAX_RETAINED_AGENTS = 500 + +function capRetainedAgents( + retained: Record, + maxEntries = MAX_RETAINED_AGENTS +): Record { + const keys = Object.keys(retained) + if (keys.length <= maxEntries) { + return retained + } + const capped: Record = {} + for (const key of keys.slice(keys.length - maxEntries)) { + capped[key] = retained[key] + } + return capped +} + function paneKeyMatchesAnyTabPrefix(paneKey: string, tabPrefixes: string[]): boolean { for (const prefix of tabPrefixes) { if (paneKey.startsWith(prefix)) { @@ -735,6 +761,35 @@ function getLaunchConfigForEntry( : undefined } +// Why: the renderer twin of the main-process closedAgentStatusTabIds set that +// #7561 FIFO-capped. It suppresses late hook/status events for a just-closed tab, +// so it must outlive the tab briefly — but tabId is ephemeral and it was only +// ever added to, growing one entry per tab-close for the renderer's whole life. +export const RECENTLY_CLOSED_AGENT_STATUS_TAB_IDS_MAX = 1024 + +// delete-then-set for LRU recency, then evict the oldest keys past the cap (Record +// key order is insertion order for non-integer string keys). A status event for a +// tab closed >MAX tabs ago cannot still arrive, so eviction is safe. +function boundRecentlyClosedAgentStatusTabIds( + existing: Record, + tabId: string +): Record { + const next: Record = {} + for (const key of Object.keys(existing)) { + if (key !== tabId) { + next[key] = true + } + } + next[tabId] = true + const keys = Object.keys(next) + if (keys.length > RECENTLY_CLOSED_AGENT_STATUS_TAB_IDS_MAX) { + for (const stale of keys.slice(0, keys.length - RECENTLY_CLOSED_AGENT_STATUS_TAB_IDS_MAX)) { + delete next[stale] + } + } + return next +} + function getLaunchConfigForStatusMetadata( state: AppState, metadata: AgentLaunchConfigStatusMetadata @@ -1634,10 +1689,10 @@ export const createAgentStatusSlice: StateCreator = { - ...s.recentlyClosedAgentStatusTabIds, - [tabIdPrefix]: true - } + const nextClosedTabs = boundRecentlyClosedAgentStatusTabIds( + s.recentlyClosedAgentStatusTabIds, + tabIdPrefix + ) if ( liveKeys.length === 0 && @@ -2178,7 +2233,10 @@ export const createAgentStatusSlice: StateCreator ({ + toast: { info: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn() } +})) + +vi.mock('@/components/terminal-pane/pty-dispatcher', () => ({ + restorePtyDataHandlersAfterFailedShutdown: vi.fn(), + unregisterPtyDataHandlers: vi.fn() +})) + +vi.mock('@/lib/agent-status', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) } +}) + +const mockApi = { + worktrees: { list: vi.fn().mockResolvedValue([]), remove: vi.fn().mockResolvedValue(undefined) }, + pty: { kill: vi.fn().mockResolvedValue(undefined) } +} + +// @ts-expect-error -- minimal window.api stub for the store under test +globalThis.window = { api: mockApi } + +import { createTestStore, seedStore, makeWorktree, makeTab } from './store-test-helpers' + +const WT1 = 'repo1::/path/wt1' +const WT2 = 'repo1::/path/wt2' +const TAB1 = 'tab-wt1' +const TAB2 = 'tab-wt2' +const PTY1 = 'pty-wt1' +const PTY2 = 'pty-wt2' + +function seedMaps(store: ReturnType): void { + seedStore(store, { + worktreesByRepo: { + repo1: [ + makeWorktree({ id: WT1, repoId: 'repo1', path: '/path/wt1' }), + makeWorktree({ id: WT2, repoId: 'repo1', path: '/path/wt2' }) + ] + }, + tabsByWorktree: { + [WT1]: [makeTab({ id: TAB1, worktreeId: WT1, ptyId: PTY1 })], + [WT2]: [makeTab({ id: TAB2, worktreeId: WT2, ptyId: PTY2 })] + }, + // Drives the doomed-ptyId derivation in buildWorktreePurgeState. + ptyIdsByTabId: { [TAB1]: [PTY1], [TAB2]: [PTY2] }, + lastKnownRelayPtyIdByTabId: { [TAB1]: PTY1, [TAB2]: PTY2 }, + pendingInitialCwdByTabId: { [TAB1]: '/path/wt1', [TAB2]: '/path/wt2' }, + pendingIssueCommandSplitByTabId: { + [TAB1]: { command: 'a' }, + [TAB2]: { command: 'b' } + }, + pendingSetupSplitByTabId: { + [TAB1]: { command: 'setup-a', direction: 'vertical' }, + [TAB2]: { command: 'setup-b', direction: 'vertical' } + }, + pendingStartupByTabId: { [TAB1]: { command: 'start-a' }, [TAB2]: { command: 'start-b' } }, + codexRestartNoticeByPtyId: { + [PTY1]: { previousAccountLabel: 'a1', nextAccountLabel: 'a2' }, + [PTY2]: { previousAccountLabel: 'b1', nextAccountLabel: 'b2' } + }, + migrationUnsupportedByPtyId: { + [PTY1]: { + ptyId: PTY1, + paneKey: `${TAB1}:leaf-1`, + reason: 'legacy-numeric-pane-key', + source: 'local', + updatedAt: 1 + }, + [PTY2]: { + ptyId: PTY2, + paneKey: `${TAB2}:leaf-1`, + reason: 'legacy-numeric-pane-key', + source: 'local', + updatedAt: 2 + } + } + }) +} + +describe('bulk worktree purge evicts the per-tab/per-pty terminal maps it previously leaked', () => { + beforeEach(() => vi.clearAllMocks()) + + it('drops removed worktree entries (both tabId- and ptyId-keyed), retains survivors', () => { + const store = createTestStore() + seedMaps(store) + + store.getState().purgeWorktreeTerminalState([WT1]) + const s = store.getState() + + // Removed worktree's tab/pty: every map evicted. + expect(s.lastKnownRelayPtyIdByTabId[TAB1]).toBeUndefined() + expect(s.pendingInitialCwdByTabId[TAB1]).toBeUndefined() + expect(s.pendingIssueCommandSplitByTabId[TAB1]).toBeUndefined() + expect(s.pendingSetupSplitByTabId[TAB1]).toBeUndefined() + expect(s.pendingStartupByTabId[TAB1]).toBeUndefined() + expect(s.codexRestartNoticeByPtyId[PTY1]).toBeUndefined() + expect(s.migrationUnsupportedByPtyId[PTY1]).toBeUndefined() + + // Surviving worktree's tab/pty: every entry retained (no over-eviction). + expect(s.lastKnownRelayPtyIdByTabId[TAB2]).toBe(PTY2) + expect(s.pendingInitialCwdByTabId[TAB2]).toBe('/path/wt2') + expect(s.pendingIssueCommandSplitByTabId[TAB2]).toEqual({ command: 'b' }) + expect(s.pendingSetupSplitByTabId[TAB2]).toEqual({ command: 'setup-b', direction: 'vertical' }) + expect(s.pendingStartupByTabId[TAB2]).toEqual({ command: 'start-b' }) + expect(s.codexRestartNoticeByPtyId[PTY2]).toBeDefined() + expect(s.migrationUnsupportedByPtyId[PTY2]).toBeDefined() + }) +}) diff --git a/src/renderer/src/store/slices/repos.ts b/src/renderer/src/store/slices/repos.ts index c418bb5fa1b..da0cb011d56 100644 --- a/src/renderer/src/store/slices/repos.ts +++ b/src/renderer/src/store/slices/repos.ts @@ -49,6 +49,7 @@ import { getRepoIdFromWorktreeId } from '../../../../shared/worktree-id' import { selectProjectGroupRemovalTargets } from './project-group-removal-targets' import { reconcileFetchedRepos } from './repo-identity-reconcile' import { splitRepoReorderByHost } from './repo-reorder-host-split' +import { omitSparsePresetsForRepos } from './sparse-presets' import { findRepoForHost, getRepoHostIdentity, @@ -2404,7 +2405,14 @@ export const createRepoSlice: StateCreator = (set, repo && !projectHostSetups.some((setup) => setup.projectId === result.project.id) ? s.projects.filter((project) => project.id !== result.project.id) : s.projects - return { repos, projects, projectHostSetups } + const survivingRepoIds = new Set(repos.map((r) => r.id)) + const removedRepoIds = s.repos.filter((r) => !survivingRepoIds.has(r.id)).map((r) => r.id) + return { + repos, + projects, + projectHostSetups, + ...omitSparsePresetsForRepos(s, removedRepoIds) + } }) return { ...result, repo } } catch (err) { @@ -2669,8 +2677,13 @@ export const createRepoSlice: StateCreator = (set, delete nextLastVisitedAtByWorktreeId[id] } } + const survivingRepoIds = new Set(nextRepos.map((r) => r.id)) + const removedRepoIds = s.repos.filter((r) => !survivingRepoIds.has(r.id)).map((r) => r.id) return { repos: nextRepos, + // Why: drop the removed repos' sparse-preset maps so they don't outlive + // the repo for the renderer's whole session. + ...omitSparsePresetsForRepos(s, removedRepoIds), ...mergeProjectCompatibilityForHostRepoChange({ previous: { projects: s.projects, projectHostSetups: s.projectHostSetups }, nextRepos, diff --git a/src/renderer/src/store/slices/sparse-presets-repo-removal-purge-leak.test.ts b/src/renderer/src/store/slices/sparse-presets-repo-removal-purge-leak.test.ts new file mode 100644 index 00000000000..d03b8418345 --- /dev/null +++ b/src/renderer/src/store/slices/sparse-presets-repo-removal-purge-leak.test.ts @@ -0,0 +1,70 @@ +/** + * Memory-leak regression: removing a repo must purge its sparse-preset maps. + * + * The four per-repo maps (`sparsePresetsByRepo`, `sparsePresetsLoadingByRepo`, + * `sparsePresetsLoadStatusByRepo`, `sparsePresetsErrorByRepo`) are populated + * lazily by `fetchSparsePresets` but were never pruned when a repo was removed, + * so orphaned entries accumulated for the renderer's whole session. repoIds are + * random UUIDs (never reused), so re-adding a repo cannot reclaim the old keys. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { createTestStore, makeWorktree } from './store-test-helpers' +import type { Repo, SparsePreset } from '../../../../shared/types' + +const repo1: Repo = { id: 'repo-1', path: '/r1', displayName: 'R1', badgeColor: '#000', addedAt: 1 } +const repo2: Repo = { id: 'repo-2', path: '/r2', displayName: 'R2', badgeColor: '#111', addedAt: 2 } + +const reposRemove = vi.fn().mockResolvedValue(undefined) + +beforeEach(() => { + reposRemove.mockReset().mockResolvedValue(undefined) + vi.stubGlobal('window', { + api: { + repos: { remove: reposRemove }, + pty: { kill: vi.fn() }, + runtimeEnvironments: { call: vi.fn() } + } + }) +}) + +function preset(id: string, repoId: string): SparsePreset { + return { id, repoId, name: id, directories: ['src'], createdAt: 1, updatedAt: 1 } +} + +function seed(store: ReturnType): void { + store.setState({ + repos: [repo1, repo2], + worktreesByRepo: { + [repo1.id]: [makeWorktree({ id: 'repo-1::/r1/wt1', repoId: repo1.id, path: '/r1/wt1' })], + [repo2.id]: [makeWorktree({ id: 'repo-2::/r2/wt1', repoId: repo2.id, path: '/r2/wt1' })] + }, + sparsePresetsByRepo: { + [repo1.id]: [preset('p1', repo1.id)], + [repo2.id]: [preset('p2', repo2.id)] + }, + sparsePresetsLoadingByRepo: { [repo1.id]: false, [repo2.id]: false }, + sparsePresetsLoadStatusByRepo: { [repo1.id]: 'loaded', [repo2.id]: 'loaded' }, + sparsePresetsErrorByRepo: { [repo1.id]: 'stale', [repo2.id]: 'boom' } + }) +} + +describe('removeProject purges the removed repo sparse-preset maps (leak regression)', () => { + it('drops all four sparse-preset maps for the removed repo, keeps the surviving repo', async () => { + const store = createTestStore() + seed(store) + + await store.getState().removeProject(repo1.id) + + const s = store.getState() + // Removed repo: every sparse-preset map key is gone. + expect(repo1.id in s.sparsePresetsByRepo).toBe(false) + expect(repo1.id in s.sparsePresetsLoadingByRepo).toBe(false) + expect(repo1.id in s.sparsePresetsLoadStatusByRepo).toBe(false) + expect(repo1.id in s.sparsePresetsErrorByRepo).toBe(false) + // Surviving repo: retained (guard over-eviction). + expect(s.sparsePresetsByRepo[repo2.id]).toEqual([preset('p2', repo2.id)]) + expect(s.sparsePresetsLoadingByRepo[repo2.id]).toBe(false) + expect(s.sparsePresetsLoadStatusByRepo[repo2.id]).toBe('loaded') + expect(s.sparsePresetsErrorByRepo[repo2.id]).toBe('boom') + }) +}) diff --git a/src/renderer/src/store/slices/sparse-presets.ts b/src/renderer/src/store/slices/sparse-presets.ts index 5a111cad84d..af118c7703b 100644 --- a/src/renderer/src/store/slices/sparse-presets.ts +++ b/src/renderer/src/store/slices/sparse-presets.ts @@ -26,6 +26,58 @@ export type SparsePresetsSlice = { removeSparsePreset: (args: { repoId: string; presetId: string }) => Promise } +type SparsePresetsMaps = Pick< + AppState, + | 'sparsePresetsByRepo' + | 'sparsePresetsLoadingByRepo' + | 'sparsePresetsLoadStatusByRepo' + | 'sparsePresetsErrorByRepo' +> + +// Why: the four per-repo sparse-preset maps are populated lazily per repo but +// were never pruned when a repo was removed, so orphaned entries accumulated for +// the renderer's whole session. Call this from the repo-removal reducers to drop +// entries for repos that no longer exist. Returns only the maps that changed so +// unrelated selectors don't re-run. +export function omitSparsePresetsForRepos( + s: SparsePresetsMaps, + removedRepoIds: Iterable +): Partial { + const removed = removedRepoIds instanceof Set ? removedRepoIds : new Set(removedRepoIds) + if (removed.size === 0) { + return {} + } + const omit = (obj: Record): Record => { + let changed = false + const out = { ...obj } + for (const id of removed) { + if (id in out) { + delete out[id] + changed = true + } + } + return changed ? out : obj + } + const result: Partial = {} + const byRepo = omit(s.sparsePresetsByRepo) + if (byRepo !== s.sparsePresetsByRepo) { + result.sparsePresetsByRepo = byRepo + } + const loading = omit(s.sparsePresetsLoadingByRepo) + if (loading !== s.sparsePresetsLoadingByRepo) { + result.sparsePresetsLoadingByRepo = loading + } + const status = omit(s.sparsePresetsLoadStatusByRepo) + if (status !== s.sparsePresetsLoadStatusByRepo) { + result.sparsePresetsLoadStatusByRepo = status + } + const error = omit(s.sparsePresetsErrorByRepo) + if (error !== s.sparsePresetsErrorByRepo) { + result.sparsePresetsErrorByRepo = error + } + return result +} + export const createSparsePresetsSlice: StateCreator = ( set, get diff --git a/src/renderer/src/store/slices/tab-worktree-orphan-map-purge-leak.test.ts b/src/renderer/src/store/slices/tab-worktree-orphan-map-purge-leak.test.ts new file mode 100644 index 00000000000..f34c6627eb0 --- /dev/null +++ b/src/renderer/src/store/slices/tab-worktree-orphan-map-purge-leak.test.ts @@ -0,0 +1,157 @@ +/** + * Memory-leak regression: three module-level maps keyed by tab/worktree id grew + * monotonically over a session because no removal path evicted their entries. + * + * - `detachedHeadAutoDerivedDisplayNames` (worktrees.ts) — worktree-keyed; only + * self-evicted when a branch reappeared, so a worktree removed while detached + * leaked its entry. + * - `foregroundTerminalTabLastSeenAtById` (lib/foreground-terminal-tabs) — + * tab-keyed; gained an entry per foregrounded tab, never dropped on close. + * - `consumedAgentStartupDeliveries` (lib/agent-startup-delivery-guards) — + * (worktreeId,tabId,launchToken)-keyed; one permanent guard per delivery, + * only released on the retry path, so removed tabs/worktrees leaked it. + * + * worktreeId and tabId are unbounded, ephemeral key spaces (fresh ids, never reused). + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import type * as AgentStatusModule from '@/lib/agent-status' +import { + getForegroundTerminalTabLastSeenAtById, + resetForegroundTerminalTabIdsForTests, + setForegroundTerminalTabIds +} from '@/lib/foreground-terminal-tabs' +import { + beginAgentStartupDeliveryAttempt, + resetAgentStartupDelayedDeliveryForTests +} from '@/lib/agent-startup-delayed-delivery' + +vi.mock('sonner', () => ({ + toast: { info: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn() } +})) + +vi.mock('@/components/terminal-pane/pty-dispatcher', () => ({ + restorePtyDataHandlersAfterFailedShutdown: vi.fn(), + unregisterPtyDataHandlers: vi.fn() +})) + +vi.mock('@/lib/agent-status', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) } +}) + +const mockApi = { + worktrees: { + list: vi.fn().mockResolvedValue([]), + remove: vi.fn().mockResolvedValue(undefined), + forceDeletePreservedBranch: vi.fn().mockResolvedValue({ deleted: true }), + updateMeta: vi.fn().mockResolvedValue({}) + }, + pty: { kill: vi.fn().mockResolvedValue(undefined) }, + runtimeEnvironments: { call: vi.fn().mockResolvedValue({ ok: true, result: {} }) } +} + +// @ts-expect-error -- minimal window.api stub for the store under test +globalThis.window = { api: mockApi } + +import { createTestStore, seedStore, makeWorktree, makeTab } from './store-test-helpers' +import { + getDetachedHeadAutoDerivedDisplayNameForTests, + setDetachedHeadAutoDerivedDisplayNameForTests +} from './worktrees' + +const WT1 = 'repo1::/path/wt1' +const WT2 = 'repo1::/path/wt2' +const TAB1 = 'tab-wt1' +const TAB2 = 'tab-wt2' +const TOKEN1 = 'launch-1' +const TOKEN2 = 'launch-2' + +function seedMaps(store: ReturnType): void { + seedStore(store, { + worktreesByRepo: { + repo1: [ + makeWorktree({ id: WT1, repoId: 'repo1', path: '/path/wt1' }), + makeWorktree({ id: WT2, repoId: 'repo1', path: '/path/wt2' }) + ] + }, + tabsByWorktree: { + [WT1]: [makeTab({ id: TAB1, worktreeId: WT1 })], + [WT2]: [makeTab({ id: TAB2, worktreeId: WT2 })] + } + }) + setDetachedHeadAutoDerivedDisplayNameForTests(WT1, 'wt1 (a1b2c3d)') + setDetachedHeadAutoDerivedDisplayNameForTests(WT2, 'wt2 (e4f5g6h)') + // Records a last-seen timestamp for both tabs. + setForegroundTerminalTabIds([TAB1, TAB2]) + // Consumes a startup delivery guard for each tab. + expect( + beginAgentStartupDeliveryAttempt({ worktreeId: WT1, tabId: TAB1, launchToken: TOKEN1 }) + ).toBe(true) + expect( + beginAgentStartupDeliveryAttempt({ worktreeId: WT2, tabId: TAB2, launchToken: TOKEN2 }) + ).toBe(true) +} + +// A second begin returns false while the guard is still present, true once it is +// cleared — so this observes the consumed-set without reaching into internals. +function startupGuardCleared(worktreeId: string, tabId: string, launchToken: string): boolean { + return beginAgentStartupDeliveryAttempt({ worktreeId, tabId, launchToken }) +} + +describe('tab/worktree removal evicts the module maps it previously leaked', () => { + beforeEach(() => { + vi.clearAllMocks() + mockApi.worktrees.remove.mockResolvedValue(undefined) + resetForegroundTerminalTabIdsForTests() + resetAgentStartupDelayedDeliveryForTests() + }) + + afterEach(() => { + resetForegroundTerminalTabIdsForTests() + resetAgentStartupDelayedDeliveryForTests() + }) + + it('bulk purgeWorktreeTerminalState drops the removed worktree/tab entries only', () => { + const store = createTestStore() + seedMaps(store) + + store.getState().purgeWorktreeTerminalState([WT1]) + + // Removed worktree/tab: all three maps evicted. + expect(getDetachedHeadAutoDerivedDisplayNameForTests(WT1)).toBeUndefined() + expect(getForegroundTerminalTabLastSeenAtById()[TAB1]).toBeUndefined() + expect(startupGuardCleared(WT1, TAB1, TOKEN1)).toBe(true) + // Surviving worktree/tab: retained (guard over-eviction). + expect(getDetachedHeadAutoDerivedDisplayNameForTests(WT2)).toBe('wt2 (e4f5g6h)') + expect(getForegroundTerminalTabLastSeenAtById()[TAB2]).toBeGreaterThan(0) + expect(startupGuardCleared(WT2, TAB2, TOKEN2)).toBe(false) + }) + + it('single removeWorktree drops the removed worktree/tab entries only', async () => { + const store = createTestStore() + seedMaps(store) + + const result = await store.getState().removeWorktree(WT1) + expect(result).toEqual({ ok: true }) + + expect(getDetachedHeadAutoDerivedDisplayNameForTests(WT1)).toBeUndefined() + expect(getForegroundTerminalTabLastSeenAtById()[TAB1]).toBeUndefined() + expect(startupGuardCleared(WT1, TAB1, TOKEN1)).toBe(true) + expect(getDetachedHeadAutoDerivedDisplayNameForTests(WT2)).toBe('wt2 (e4f5g6h)') + expect(getForegroundTerminalTabLastSeenAtById()[TAB2]).toBeGreaterThan(0) + expect(startupGuardCleared(WT2, TAB2, TOKEN2)).toBe(false) + }) + + it('closeTab drops the closed tab foreground + startup entries only', () => { + const store = createTestStore() + seedMaps(store) + + store.getState().closeTab(TAB1) + + // closeTab is tab-scoped: the tab-keyed maps drop, the worktree-keyed one stays. + expect(getForegroundTerminalTabLastSeenAtById()[TAB1]).toBeUndefined() + expect(startupGuardCleared(WT1, TAB1, TOKEN1)).toBe(true) + expect(getForegroundTerminalTabLastSeenAtById()[TAB2]).toBeGreaterThan(0) + expect(startupGuardCleared(WT2, TAB2, TOKEN2)).toBe(false) + }) +}) diff --git a/src/renderer/src/store/slices/tabs-hydration.test.ts b/src/renderer/src/store/slices/tabs-hydration.test.ts index a2db41f8cdf..afd5a05eced 100644 --- a/src/renderer/src/store/slices/tabs-hydration.test.ts +++ b/src/renderer/src/store/slices/tabs-hydration.test.ts @@ -307,6 +307,37 @@ describe('buildHydratedTabState – legacy format', () => { expect(group.activeTabId).toBe('/f1') }) + it('restores each worktree remembered terminal, not the globally-active one', () => { + // Why (regression): the legacy branch used the global session.activeTabId, + // so every worktree except the last-focused one lost its remembered + // terminal on restart and reopened on the first tab. Use the per-worktree + // activeTabIdByWorktree map instead. + const terminal = (id: string, worktreeId: string, sortOrder: number) => ({ + id, + ptyId: null, + worktreeId, + title: id, + customTitle: null, + color: null, + sortOrder, + createdAt: 100 + sortOrder + }) + const session: WorkspaceSessionState = { + ...makeBaseSession(), + // The globally-active tab belongs to w1. + activeTabId: 'w1-terminal-1', + tabsByWorktree: { + w1: [terminal('w1-terminal-1', 'w1', 0)], + w2: [terminal('w2-terminal-1', 'w2', 0), terminal('w2-terminal-2', 'w2', 1)] + }, + activeTabIdByWorktree: { w1: 'w1-terminal-1', w2: 'w2-terminal-2' } + } + + const result = buildHydratedTabState(session, new Set(['w1', 'w2'])) + expect(result.groupsByWorktree.w2[0].activeTabId).toBe('w2-terminal-2') + expect(result.groupsByWorktree.w1[0].activeTabId).toBe('w1-terminal-1') + }) + it('skips worktrees with no tabs or files', () => { const session: WorkspaceSessionState = { ...makeBaseSession(), diff --git a/src/renderer/src/store/slices/tabs-hydration.ts b/src/renderer/src/store/slices/tabs-hydration.ts index de20454415c..bdf11e82288 100644 --- a/src/renderer/src/store/slices/tabs-hydration.ts +++ b/src/renderer/src/store/slices/tabs-hydration.ts @@ -251,8 +251,17 @@ function hydrateLegacyFormat( let activeTabId: string | null = null if (activeTabType === 'editor') { activeTabId = session.activeFileIdByWorktree?.[worktreeId] ?? null - } else if (session.activeTabId && terminalTabs.some((t) => t.id === session.activeTabId)) { - activeTabId = session.activeTabId + } else { + // Why: honor this worktree's own remembered terminal before the global + // active tab. The global session.activeTabId only names the last-focused + // worktree's tab, so using it here reset every other worktree to its + // first terminal on restart. + const rememberedTabId = session.activeTabIdByWorktree?.[worktreeId] + if (rememberedTabId && terminalTabs.some((t) => t.id === rememberedTabId)) { + activeTabId = rememberedTabId + } else if (session.activeTabId && terminalTabs.some((t) => t.id === session.activeTabId)) { + activeTabId = session.activeTabId + } } if (activeTabId && !tabs.some((t) => t.id === activeTabId)) { activeTabId = tabs[0]?.id ?? null diff --git a/src/renderer/src/store/slices/tabs.test.ts b/src/renderer/src/store/slices/tabs.test.ts index 802261b8424..ea5e42dd1df 100644 --- a/src/renderer/src/store/slices/tabs.test.ts +++ b/src/renderer/src/store/slices/tabs.test.ts @@ -2125,6 +2125,59 @@ describe('TabsSlice', () => { }) }) + it('promotes legacy terminals to the worktree remembered tab, not always the first one', () => { + // Why (regression): selecting a non-first terminal, switching to another + // worktree, then returning must land on the remembered tab. Reconcile + // used to seed a freshly-ensured group with restoredLegacyTabs[0], so the + // remembered selection in activeTabIdByWorktree was silently dropped and + // the worktree always reopened on Terminal 1. + const firstTerminalId = 'runtime-terminal-1' + const secondTerminalId = 'runtime-terminal-2' + + store.setState({ + tabsByWorktree: { + [WT]: [ + { + id: firstTerminalId, + ptyId: 'pty-1', + worktreeId: WT, + title: 'Terminal 1', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + }, + { + id: secondTerminalId, + ptyId: 'pty-2', + worktreeId: WT, + title: 'Terminal 2', + customTitle: null, + color: null, + sortOrder: 1, + createdAt: 2 + } + ] + }, + ptyIdsByTabId: { + [firstTerminalId]: ['pty-1'], + [secondTerminalId]: ['pty-2'] + }, + unifiedTabsByWorktree: { [WT]: [] }, + groupsByWorktree: { [WT]: [] }, + activeGroupIdByWorktree: {}, + // The user had Terminal 2 active before leaving this worktree. + activeTabIdByWorktree: { [WT]: secondTerminalId } + }) + + const result = store.getState().reconcileWorktreeTabModel(WT) + const restoredGroup = store.getState().groupsByWorktree[WT]?.[0] + + expect(result.renderableTabCount).toBe(2) + expect(result.activeRenderableTabId).toBe(secondTerminalId) + expect(restoredGroup?.activeTabId).toBe(secondTerminalId) + }) + it('keeps a sole terminal renderable after its PTY exits so a failed direnv does not strand the worktree', () => { // Why (regression): a PR worktree's only terminal can die on startup (a // failing .envrc/direnv). pty-connection keeps that dead pane mounted diff --git a/src/renderer/src/store/slices/tabs.ts b/src/renderer/src/store/slices/tabs.ts index 41fa3646de2..42759c342df 100644 --- a/src/renderer/src/store/slices/tabs.ts +++ b/src/renderer/src/store/slices/tabs.ts @@ -1871,6 +1871,16 @@ export const createTabsSlice: StateCreator = (set, })) const reconciledUnifiedTabs = restoredLegacyTabs.length > 0 ? [...unifiedTabs, ...restoredLegacyTabs] : unifiedTabs + // Why: when a freshly-ensured group has no active tab yet, seed it from the + // worktree's remembered selection before the first restored tab. Otherwise + // returning to a worktree whose terminals only exist in the runtime slice + // always reopens on Terminal 1 and drops the tab the user left on. + const rememberedLegacyActiveTabId = state.activeTabIdByWorktree[worktreeId] + const restoredLegacyTabIds = new Set(restoredLegacyTabs.map((tab) => tab.id)) + const legacyFallbackActiveTabId = + rememberedLegacyActiveTabId && restoredLegacyTabIds.has(rememberedLegacyActiveTabId) + ? rememberedLegacyActiveTabId + : (restoredLegacyTabs[0]?.id ?? null) const reconciledGroups = restoredLegacyTabs.length > 0 && reconciliationGroup ? updateGroup(ensuredGroupState!.groupsByWorktree[worktreeId] ?? [], { @@ -1879,7 +1889,7 @@ export const createTabsSlice: StateCreator = (set, // after split groups became the source of truth. Restoring them // into the active/root group keeps existing live PTYs reachable // instead of making activation spawn a duplicate "Terminal 2". - activeTabId: reconciliationGroup.activeTabId ?? restoredLegacyTabs[0]?.id ?? null, + activeTabId: reconciliationGroup.activeTabId ?? legacyFallbackActiveTabId, tabOrder: dedupeTabOrder([ ...reconciliationGroup.tabOrder, ...restoredLegacyTabs.map((tab) => tab.id) diff --git a/src/renderer/src/store/slices/terminal-helpers.ts b/src/renderer/src/store/slices/terminal-helpers.ts index 3976605569c..7f42c049ab6 100644 --- a/src/renderer/src/store/slices/terminal-helpers.ts +++ b/src/renderer/src/store/slices/terminal-helpers.ts @@ -1,5 +1,5 @@ import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/types' -import { detectAgentStatusFromTitle } from '@/lib/agent-status' +import { classifyTitleActivity } from '@/lib/pane-agent-evidence' export function emptyLayoutSnapshot(): TerminalLayoutSnapshot { return { @@ -37,5 +37,5 @@ function getResetTitle(tab: TerminalTab, index: number): string { // Why: reset any recognized agent title on hydration. The prior-session // agent is no longer running after a restart, so showing a stale // "Claude done" or spinner would be misleading. - return detectAgentStatusFromTitle(tab.title) ? fallbackTitle : tab.title + return classifyTitleActivity(tab.title) ? fallbackTitle : tab.title } diff --git a/src/renderer/src/store/slices/terminals-hydration.test.ts b/src/renderer/src/store/slices/terminals-hydration.test.ts index 9b913be950e..60502611a66 100644 --- a/src/renderer/src/store/slices/terminals-hydration.test.ts +++ b/src/renderer/src/store/slices/terminals-hydration.test.ts @@ -97,7 +97,14 @@ import { getDefaultWorkspaceSession } from '../../../../shared/constants' import { folderWorkspaceKey, worktreeWorkspaceKey } from '../../../../shared/workspace-scope' -import { createTestStore, makeLayout, makeTab, makeWorktree, seedStore } from './store-test-helpers' +import { + createTestStore, + makeLayout, + makeTab, + makeWorktree, + seedStore, + TEST_REPO +} from './store-test-helpers' import { canGoBackWorktreeHistory } from './worktree-nav-history' describe('hydrateWorkspaceSession', () => { @@ -418,6 +425,40 @@ describe('hydrateWorkspaceSession', () => { ]) }) + it('stashes deferred SSH session ids for worktrees not yet in worktreesByRepo', async () => { + // Why: at cold start SSH worktrees are absent from worktreesByRepo (relay + // discovery needs the connection). The deferred stash must fall back to + // the repo id embedded in the composite worktree id — otherwise restored + // SSH panes fresh-spawn into a missing PTY provider and strand an + // "SSH connection is not active" toast. + const store = createTestStore() + const worktreeId = 'repo1::/home/user/remote-project' + const sshSessionId = 'ssh:ssh-target-1@@pty-7' + seedStore(store, { + repos: [{ ...TEST_REPO, connectionId: 'ssh-target-1' }], + worktreesByRepo: {} + }) + + const session: WorkspaceSessionState = { + activeRepoId: 'repo1', + activeWorktreeId: worktreeId, + activeTabId: 'tab-1', + tabsByWorktree: { + [worktreeId]: [makeTab({ id: 'tab-1', worktreeId, ptyId: null })] + }, + terminalLayoutsByTabId: {}, + activeWorktreeIdsOnShutdown: [worktreeId], + remoteSessionIdsByTabId: { 'tab-1': sshSessionId } + } + + store.getState().hydrateWorkspaceSession(session) + await store.getState().reconnectPersistedTerminals() + + expect(store.getState().deferredSshSessionIdsByTabId).toMatchObject({ + 'tab-1': sshSessionId + }) + }) + it('resets persisted agent titles to the fallback label on hydration', () => { const store = createTestStore() const worktreeId = 'repo1::/wt-1' diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index c733db5019b..724a879a2d8 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -39,8 +39,11 @@ import { WINDOWS_GIT_BASH_SHELL } from '../../../../shared/windows-terminal-shel import type { AgentStartedTelemetry } from '../../lib/worktree-activation' import { scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph' import { forgetAgentHibernationTabOutput } from '@/lib/agent-hibernation-output-activity' +import { forgetForegroundTerminalTabs } from '@/lib/foreground-terminal-tabs' +import { forgetAgentStartupDeliveriesForTabs } from '@/lib/agent-startup-delivery-guards' import { clearTransientTerminalState, emptyLayoutSnapshot } from './terminal-helpers' -import { isClaudeAgent, detectAgentStatusFromTitle } from '@/lib/agent-status' +import { isClaudeAgent } from '@/lib/agent-status' +import { classifyTitleActivity } from '@/lib/pane-agent-evidence' import { buildOrphanTerminalCleanupPatch, getOrphanTerminalIds } from './terminal-orphan-helpers' import { dedupeTabOrder, @@ -876,7 +879,7 @@ export const createTerminalSlice: StateCreator if (!tab.title || !isClaudeAgent(tab.title)) { continue } - const status = detectAgentStatusFromTitle(tab.title) + const status = classifyTitleActivity(tab.title) if (status === null || status === 'working') { continue } @@ -1341,6 +1344,10 @@ export const createTerminalSlice: StateCreator // a fresh leafId at epoch 0), so drop the panes' hibernation output epochs to // keep that module-level map from growing for the renderer's whole lifetime. forgetAgentHibernationTabOutput(tabId) + // Why: same rationale for the tab's foreground last-seen timestamp and any + // consumed agent-startup delivery guards — retired tab ids never recur. + forgetForegroundTerminalTabs([tabId]) + forgetAgentStartupDeliveriesForTabs([tabId]) for (const tabs of Object.values(get().unifiedTabsByWorktree)) { const workspaceItem = tabs.find( (entry) => entry.contentType === 'terminal' && entry.entityId === tabId @@ -1635,7 +1642,7 @@ export const createTerminalSlice: StateCreator // event fires. Bumping only on classification change keeps incidental // title noise (spinner frame, prompt suffix) from churning the sidebar. const classificationChanged = - detectAgentStatusFromTitle(prevTitle ?? '') !== detectAgentStatusFromTitle(title) + classifyTitleActivity(prevTitle ?? '') !== classifyTitleActivity(title) // Why: locate the owning worktree so we can suppress the sortEpoch // bump when the changing pane lives in the active worktree. Title // changes there are side-effects of the user's click (PTY remount on @@ -1678,7 +1685,7 @@ export const createTerminalSlice: StateCreator // Why: clearing a 'working'/'permission'-classified title back to none // changes the title-heuristic verdict for that pane, so the smart sort // needs a re-sort. See setRuntimePaneTitle for the rationale. - const hadClassification = detectAgentStatusFromTitle(prevTitle ?? '') !== null + const hadClassification = classifyTitleActivity(prevTitle ?? '') !== null // Why: same active-worktree gate as setRuntimePaneTitle — clears that // fire as a side-effect of a click-driven PTY teardown in the active // worktree must not re-rank the sidebar. Skip bumping when no owner is @@ -3317,7 +3324,12 @@ export const createTerminalSlice: StateCreator const worktree = Object.values(get().worktreesByRepo) .flat() .find((entry) => entry.id === worktreeId) - const repo = worktree ? get().repos.find((entry) => entry.id === worktree.repoId) : null + // Why: SSH worktrees aren't in worktreesByRepo at cold start (relay + // discovery needs the connection). Fall back to the repo id embedded in + // the composite worktree id so their sessions still reach the deferred + // map — otherwise panes fresh-spawn into a missing PTY provider. + const repoId = worktree?.repoId ?? getRepoIdFromWorktreeId(worktreeId) + const repo = repoId ? get().repos.find((entry) => entry.id === repoId) : null if (!repo?.connectionId) { continue } diff --git a/src/renderer/src/store/slices/workspace-cleanup.ts b/src/renderer/src/store/slices/workspace-cleanup.ts index 357e4e74a74..502cead194a 100644 --- a/src/renderer/src/store/slices/workspace-cleanup.ts +++ b/src/renderer/src/store/slices/workspace-cleanup.ts @@ -21,7 +21,7 @@ import { type WorkspaceCleanupScanProgress, type WorkspaceCleanupScanResult } from '../../../../shared/workspace-cleanup' -import { detectAgentStatusFromTitle, isExplicitAgentStatusFresh } from '@/lib/agent-status' +import { classifyTitleActivity, isExplicitAgentStatusFresh } from '@/lib/pane-agent-evidence' import { translate } from '@/i18n/i18n' export type WorkspaceCleanupFailure = { @@ -737,7 +737,7 @@ function hasWorkingTitleAgent(state: AppState, tabs: { id: string; title: string const titles = paneTitles && Object.keys(paneTitles).length > 0 ? Object.values(paneTitles) : [tab.title] for (const title of titles) { - const status = detectAgentStatusFromTitle(title) + const status = classifyTitleActivity(title) if (status === 'working' || status === 'permission') { return true } @@ -812,7 +812,7 @@ function hasIdleAgentTitleForPty( } function isIdleAgentTitle(title: string): boolean { - return detectAgentStatusFromTitle(title) === 'idle' + return classifyTitleActivity(title) === 'idle' } function getPaneKeyTabId(paneKey: AgentStatusEntry['paneKey']): string { diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index 248bde1eb6f..0ff92d352b7 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -42,6 +42,8 @@ import { moveFocusToRendererBeforeFocusedWebviewHidden } from './browser-webview import { toast } from 'sonner' import { requestVirtualizedScrollAnchorRecord } from '@/hooks/requestVirtualizedScrollAnchorRecord' import { forgetAgentHibernationTabOutput } from '@/lib/agent-hibernation-output-activity' +import { forgetForegroundTerminalTabs } from '@/lib/foreground-terminal-tabs' +import { forgetAgentStartupDeliveriesForTabs } from '@/lib/agent-startup-delivery-guards' import { branchName } from '@/lib/git-utils' import { markInputQuietSchedulerInput, scheduleAfterInputQuiet } from '@/lib/input-quiet-scheduler' import { clearSessionCommitDraftForWorktree } from '@/lib/source-control-commit-draft-session' @@ -1451,6 +1453,19 @@ export function resetHostedReviewLinkMutationGenerationForTests(): void { hostedReviewLinkWorktreeIdAliases.clear() } +export function setDetachedHeadAutoDerivedDisplayNameForTests( + worktreeId: string, + displayName: string +): void { + detachedHeadAutoDerivedDisplayNames.set(worktreeId, displayName) +} + +export function getDetachedHeadAutoDerivedDisplayNameForTests( + worktreeId: string +): string | undefined { + return detachedHeadAutoDerivedDisplayNames.get(worktreeId) +} + function hostedReviewLinksAreCleared(worktree: Worktree): boolean { return HOSTED_REVIEW_LINK_KEYS.every((key) => worktree[key] == null) && !worktree.pushTarget } @@ -1814,12 +1829,22 @@ function buildWorktreePurgeState(s: AppState, worktreeIds: string[]): Partial() + // Why: some terminal/agent maps are keyed by ptyId, not tabId. Collect the + // doomed panes' ptyIds now (while ptyIdsByTabId is still populated) so this + // bulk path can evict them the same way shutdownWorktreeTerminals does on the + // single-removeWorktree path. + const doomedPtyIds = new Set() const doomedBrowserWorkspaceIds = new Set() const doomedPageIds = new Set() const removedFileIds = new Set() for (const id of worktreeIdSet) { for (const tab of s.tabsByWorktree[id] ?? []) { doomedTabIds.add(tab.id) + // Null-tolerant like the omit* helpers below: some callers pass partial + // state that omits this slice; the production store always inits it to {}. + for (const ptyId of s.ptyIdsByTabId?.[tab.id] ?? []) { + doomedPtyIds.add(ptyId) + } // Why: a removed worktree's panes are gone for good, so drop their // hibernation output epochs from that module-level map (mirrors the // hosted-review prune above). A future pane mints a fresh leafId at epoch 0. @@ -1828,7 +1853,14 @@ function buildWorktreePurgeState(s: AppState, worktreeIds: string[]): Partial(obj: Record): Record => { + let changed = false + const out = { ...obj } + for (const ptyId of doomedPtyIds) { + if (ptyId in out) { + delete out[ptyId] + changed = true + } + } + return changed ? out : obj + } // Pane-scoped maps are keyed by `${tabId}:${leafId}` (stable-pane-id); tabId // never contains ":", so the segment before the first ":" is the owning tab. const omitByPaneKeyTabPrefix = (obj: Record): Record => { @@ -1996,6 +2039,21 @@ function buildWorktreePurgeState(s: AppState, worktreeIds: string[]): Partial const tabs = get().tabsByWorktree[worktreeId] ?? [] const tabIds = new Set(tabs.map((t) => t.id)) + // Why: this path deletes tabsByWorktree wholesale (not via closeTab), so + // purge the module-level maps keyed by this worktree/its tabs here too — + // matching buildWorktreePurgeState so retired ids don't accumulate. + detachedHeadAutoDerivedDisplayNames.delete(worktreeId) + forgetForegroundTerminalTabs(tabIds) + forgetAgentStartupDeliveriesForTabs(tabIds) + // Why: deletion is async (backend + terminal/browser teardown awaited // above), so snapshot the sidebar's current top-row anchor in the same // tick we remove the row. Recording at click time goes stale across the diff --git a/src/shared/ai-vault-resume-command.test.ts b/src/shared/ai-vault-resume-command.test.ts new file mode 100644 index 00000000000..65fe12d4c99 --- /dev/null +++ b/src/shared/ai-vault-resume-command.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest' + +import { buildAiVaultResumeCommand } from './ai-vault-types' + +describe('buildAiVaultResumeCommand', () => { + it('wraps Windows cwd changes in cmd so PowerShell and cmd launch the same resume command', () => { + expect( + buildAiVaultResumeCommand({ + agent: 'codex', + sessionId: 'session-1', + cwd: 'C:\\Users\\Ada Lovelace\\repo', + platform: 'win32' + }) + ).toBe('cmd /d /s /c "cd /d ""C:\\Users\\Ada Lovelace\\repo"" && codex resume ""session-1"""') + }) + + it('carries non-default Codex homes in copied resume commands', () => { + expect( + buildAiVaultResumeCommand({ + agent: 'codex', + sessionId: 'session-1', + cwd: '/repo/app', + platform: 'darwin', + codexHome: '/Users/ada/Library/Application Support/Orca/codex-runtime-home/home' + }) + ).toBe( + "cd '/repo/app' && CODEX_HOME='/Users/ada/Library/Application Support/Orca/codex-runtime-home/home' codex resume 'session-1'" + ) + + expect( + buildAiVaultResumeCommand({ + agent: 'codex', + sessionId: 'session-1', + cwd: 'C:\\Users\\Ada Lovelace\\repo', + platform: 'win32', + codexHome: 'C:\\Users\\Ada\\AppData\\Roaming\\Orca\\codex-runtime-home\\home' + }) + ).toBe( + 'cmd /d /s /c "cd /d ""C:\\Users\\Ada Lovelace\\repo"" && set ""CODEX_HOME=C:\\Users\\Ada\\AppData\\Roaming\\Orca\\codex-runtime-home\\home"" && codex resume ""session-1"""' + ) + }) + + it('resumes OMP by absolute transcript path so it resolves across session-dir roots', () => { + expect( + buildAiVaultResumeCommand({ + agent: 'omp', + sessionId: '019f27cd-4268-7000-96e7-62f42a55c144', + resumeFilePath: + '/Users/ada/.omp/agent/sessions/repo/2026-07-03T11-30-29-357Z_019f27be/OmpScannerTests.jsonl', + cwd: '/Users/ada/repo', + platform: 'darwin' + }) + ).toBe( + "cd '/Users/ada/repo' && omp --resume '/Users/ada/.omp/agent/sessions/repo/2026-07-03T11-30-29-357Z_019f27be/OmpScannerTests.jsonl'" + ) + }) + + it('quotes queued OMP resume paths for the provided Windows shell', () => { + expect( + buildAiVaultResumeCommand({ + agent: 'omp', + sessionId: '019f27cd-4268-7000-96e7-62f42a55c144', + resumeFilePath: 'C:\\Users\\Ada Lovelace\\.omp\\agent\\sessions\\repo\\sess.jsonl', + cwd: 'C:\\Users\\Ada Lovelace\\repo', + platform: 'win32', + shell: 'powershell' + }) + ).toBe( + "Set-Location -LiteralPath 'C:\\Users\\Ada Lovelace\\repo'; omp --resume 'C:\\Users\\Ada Lovelace\\.omp\\agent\\sessions\\repo\\sess.jsonl'" + ) + }) + + it('falls back to the session id when no OMP transcript path is known', () => { + expect( + buildAiVaultResumeCommand({ + agent: 'omp', + sessionId: '019f27cd-4268-7000-96e7-62f42a55c144', + resumeFilePath: null, + cwd: '/Users/ada/repo', + platform: 'darwin' + }) + ).toBe("cd '/Users/ada/repo' && omp --resume '019f27cd-4268-7000-96e7-62f42a55c144'") + }) +}) diff --git a/src/shared/ai-vault-types.ts b/src/shared/ai-vault-types.ts index 58bf82597ab..df6abfb8349 100644 --- a/src/shared/ai-vault-types.ts +++ b/src/shared/ai-vault-types.ts @@ -12,6 +12,7 @@ export const AI_VAULT_AGENTS = [ 'codex', 'hermes', 'pi', + 'omp', 'cursor', 'gemini', 'rovo', @@ -34,6 +35,7 @@ export const AI_VAULT_AGENT_LABELS = { codex: 'Codex', hermes: 'Hermes', pi: 'Pi', + omp: 'OMP', cursor: 'Cursor', gemini: 'Gemini', rovo: 'Rovo Dev', @@ -102,13 +104,23 @@ export function buildAiVaultResumeCommand(args: { platform: NodeJS.Platform commandOverride?: string | null codexHome?: string | null + resumeFilePath?: string | null + shell?: AgentStartupShell }): string { - const { agent, sessionId, cwd, platform, commandOverride, codexHome } = args + const { agent, sessionId, cwd, platform, commandOverride, codexHome, resumeFilePath, shell } = + args const baseCommand = commandOverride?.trim() || defaultAiVaultResumeCommandBase(agent) - const sessionArg = quoteShellArg(sessionId, platform) + // Why: OMP's `--resume` accepts an absolute transcript path, which resolves + // regardless of which session-dir root (custom OMP_CODING_AGENT_DIR / WSL + // home) the file was discovered under, where an id-prefix lookup scoped to + // the default store would miss it. Falls back to the id if no path is known. + const resumeTarget = agent === 'omp' && resumeFilePath?.trim() ? resumeFilePath.trim() : sessionId + const sessionArg = shell + ? quoteStartupArg(resumeTarget, shell) + : quoteShellArg(resumeTarget, platform) const resumeCommand = buildAgentResumeInvocation(agent, baseCommand, sessionArg) - return buildAiVaultResumeShellCommand({ resumeCommand, cwd, platform, codexHome }) + return buildAiVaultResumeShellCommand({ resumeCommand, cwd, platform, codexHome, shell }) } export function buildAiVaultResumeShellCommand(args: { @@ -222,6 +234,9 @@ function buildAgentResumeInvocation( case 'devin': case 'openclaw': case 'droid': + // Why: OMP resumes by absolute transcript path (see buildAiVaultResumeCommand), + // but the `--resume ` invocation form is identical to the others here. + case 'omp': return `${baseCommand} --resume ${sessionArg}` } } diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 0e06ff419ce..782c9416f72 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -440,6 +440,7 @@ export function getDefaultPersistedState(homedir: string): PersistedState { workspaceSession: getDefaultWorkspaceSession(), workspaceSessionsByHostId: {}, sshTargets: [], + deletedSshConfigAliases: [], sshRemotePtyLeases: [], claudeLivePtySessionIds: [], migrationUnsupportedPtyEntries: [], diff --git a/src/shared/ssh-pty-id.test.ts b/src/shared/ssh-pty-id.test.ts new file mode 100644 index 00000000000..b8fcfd8d373 --- /dev/null +++ b/src/shared/ssh-pty-id.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' +import { toSshExecutionHostId } from './execution-host' +import { parseAppSshPtyId, toAppSshPtyId, toRelaySshPtyId } from './ssh-pty-id' + +describe('ssh pty id routing', () => { + const CONNECTION = 'ssh-1779863656395-57g1q1' + const HOST_ID = toSshExecutionHostId(CONNECTION) // "ssh:ssh-1779863656395-57g1q1" + const APP_ID = toAppSshPtyId(CONNECTION, 'pty-3') // "ssh:ssh-1779863656395-57g1q1@@pty-3" + + it('encodes the bare target id into the app pty id', () => { + expect(APP_ID).toBe('ssh:ssh-1779863656395-57g1q1@@pty-3') + expect(parseAppSshPtyId(APP_ID)).toEqual({ connectionId: CONNECTION, relayPtyId: 'pty-3' }) + }) + + it('round-trips app <-> relay ids for the matching bare connection', () => { + expect(toRelaySshPtyId(CONNECTION, APP_ID)).toBe('pty-3') + expect(toAppSshPtyId(CONNECTION, APP_ID)).toBe(APP_ID) + }) + + // Why: reconnect/restore callers may pass the execution-host id form + // ("ssh:") from a workspace `hostId`; it names the same connection + // as the bare id embedded in the app pty id and must not throw or misencode. + it('accepts the execution-host id form as the same connection', () => { + expect(toRelaySshPtyId(HOST_ID, APP_ID)).toBe('pty-3') + expect(toAppSshPtyId(HOST_ID, APP_ID)).toBe(APP_ID) + // A fresh app id built from the host-id form still stores the bare id. + expect(toAppSshPtyId(HOST_ID, 'pty-7')).toBe('ssh:ssh-1779863656395-57g1q1@@pty-7') + }) + + it('still rejects a pty id owned by a genuinely different connection', () => { + expect(() => toRelaySshPtyId('ssh-other', APP_ID)).toThrow( + `belongs to SSH connection "${CONNECTION}"` + ) + expect(() => toAppSshPtyId(toSshExecutionHostId('ssh-other'), APP_ID)).toThrow( + `belongs to SSH connection "${CONNECTION}"` + ) + }) +}) diff --git a/src/shared/ssh-pty-id.ts b/src/shared/ssh-pty-id.ts index 992111cd664..5f7d2a5663e 100644 --- a/src/shared/ssh-pty-id.ts +++ b/src/shared/ssh-pty-id.ts @@ -1,6 +1,18 @@ +import { parseExecutionHostId } from './execution-host' + const SSH_PTY_ID_PREFIX = 'ssh:' const SSH_PTY_ID_SEPARATOR = '@@' +// Why: reconnect/restore paths sometimes hand these routers the execution-host +// id form ("ssh:", from a workspace `hostId`) instead of the bare SSH +// target id that app PTY ids embed. Both name the same connection, so collapse +// to the bare id before comparing/encoding — otherwise a valid reattach throws a +// spurious "belongs to SSH connection" error at the user. +function normalizeConnectionId(connectionId: string): string { + const parsed = parseExecutionHostId(connectionId) + return parsed?.kind === 'ssh' ? parsed.targetId : connectionId +} + // Why: SSH relays allocate target-local ids like "pty-1"; app-wide routing // needs the target id embedded so two relays cannot collide after restore. export type ParsedSshPtyId = { @@ -32,14 +44,15 @@ export function parseAppSshPtyId(ptyId: string): ParsedSshPtyId | null { } export function toAppSshPtyId(connectionId: string, relayPtyId: string): string { + const normalizedConnectionId = normalizeConnectionId(connectionId) const parsed = parseAppSshPtyId(relayPtyId) if (parsed) { - if (parsed.connectionId !== connectionId) { + if (parsed.connectionId !== normalizedConnectionId) { throw new Error(`PTY ${relayPtyId} belongs to SSH connection "${parsed.connectionId}"`) } return relayPtyId } - return `${SSH_PTY_ID_PREFIX}${encodeURIComponent(connectionId)}${SSH_PTY_ID_SEPARATOR}${relayPtyId}` + return `${SSH_PTY_ID_PREFIX}${encodeURIComponent(normalizedConnectionId)}${SSH_PTY_ID_SEPARATOR}${relayPtyId}` } export function toRelaySshPtyId(connectionId: string, ptyId: string): string { @@ -47,7 +60,7 @@ export function toRelaySshPtyId(connectionId: string, ptyId: string): string { if (!parsed) { return ptyId } - if (parsed.connectionId !== connectionId) { + if (parsed.connectionId !== normalizeConnectionId(connectionId)) { throw new Error(`PTY ${ptyId} belongs to SSH connection "${parsed.connectionId}"`) } return parsed.relayPtyId diff --git a/src/shared/types.ts b/src/shared/types.ts index a5f94e86046..cfbdd8f50b1 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -2806,6 +2806,16 @@ export type GlobalSettings = { geminiCliOAuthEnabled: boolean /** Per-agent CLI command overrides. A missing key means use the catalog default binary name. */ agentCmdOverrides: Partial> + /** Why: Orca bridges Codex session history from the user's real Codex home into + * its managed home so /resume finds it, but defaults to ~/.codex. Users who run + * Codex with a custom CODEX_HOME can point history discovery at that folder here. + * History-only: this does not change which account/config/hooks Orca uses. */ + codexSessionSourceHome?: { + /** Absolute host path; empty/undefined falls back to ~/.codex. */ + host?: string + /** Per-WSL-distro absolute Linux path; missing distro falls back to /.codex. */ + wsl?: Record + } /** Per-agent default CLI arguments appended after the binary/path and before prompts. */ agentDefaultArgs?: Partial> /** Per-agent launch environment defaults used when yolo mode is exposed as env. */ @@ -3520,6 +3530,10 @@ export type PersistedState = { * pre-partition builds keep working. Optional/absent on legacy files. */ workspaceSessionsByHostId?: Partial> sshTargets: SshTarget[] + /** SSH config aliases the user explicitly deleted. Suppresses re-import of the + * matching ~/.ssh/config host on the next sync so a deleted host does not + * reappear. Cleared for an alias when the user re-adds it or re-adopts config. */ + deletedSshConfigAliases: string[] sshRemotePtyLeases: SshRemotePtyLease[] /** Daemon session ids of live local Claude launches. Seeds the Claude * live-PTY gate on startup so an early OAuth refresh cannot rotate the diff --git a/tests/e2e/artificial-opencode-hidden-pressure-scenario.ts b/tests/e2e/artificial-opencode-hidden-pressure-scenario.ts index 8de2a2c240c..7e856c8dfae 100644 --- a/tests/e2e/artificial-opencode-hidden-pressure-scenario.ts +++ b/tests/e2e/artificial-opencode-hidden-pressure-scenario.ts @@ -85,11 +85,17 @@ type HiddenPressureAckGate = { // Why: restore still has to finish promptly, but parallel Electron workers on // Linux CI can overshoot the 1s product target without a responsiveness regression. +// Main relaxed this to 4s for drain-plus-poll overhead on loaded OSS runners; +// this branch KEEPS the strict budget — the background keep-tail global budget +// bounds the aggregate a reveal drains, so a slow restore here is a regression. const MAX_HIDDEN_RESTORE_LATENCY_MS = 1_500 // Why: Phase-4 hidden-delivery gate contract — hidden PTY bytes are dropped in // main after model ingestion, so renderer-delivery pressure must stay FAR // below the old 2 MB ACK-backpressure target instead of reaching it. const MAIN_RENDERER_PRESSURE_TARGET_CHARS = 2 * 1024 * 1024 +// Why: in this hidden real-PTY pressure case, maxTimerDriftMs and worst-key +// latency catch the same isolated CI starvation spike; median remains strict. +const MAX_HIDDEN_PRESSURE_TIMER_DRIFT_MS = 3_000 export async function runHiddenRealPtyPressureScenario< TMeasurement extends HiddenPressureMeasurement, @@ -208,11 +214,7 @@ export async function runHiddenRealPtyPressureScenario< // detector — the original regression (input freezing for seconds) shows up in // the median too. Aligns with ssh-docker-relay-perf's 2s worst-key tolerance. expect(measurement.worstLatencyMs).toBeLessThan(3_000) - // Why: maxTimerDriftMs is a single-worst-tick metric that spikes on a loaded - // OSS runner (seen at 155ms under 8MB of in-flight backpressure). Median above - // is the real responsiveness guard; align the spike tolerance with the sibling - // terminal-load suite's MAX_TIMER_DRIFT_MS. - expect(measurement.maxTimerDriftMs).toBeLessThan(250) + expect(measurement.maxTimerDriftMs).toBeLessThan(MAX_HIDDEN_PRESSURE_TIMER_DRIFT_MS) await deps.releaseTerminalAckGate(orcaPage) const restoreLatencyMs = await measureHiddenOutputRestoreLatency( diff --git a/tests/e2e/chinese-ime-chat-input-repro.spec.ts b/tests/e2e/chinese-ime-chat-input-repro.spec.ts index edb2fede1a3..8994e235cdd 100644 --- a/tests/e2e/chinese-ime-chat-input-repro.spec.ts +++ b/tests/e2e/chinese-ime-chat-input-repro.spec.ts @@ -79,6 +79,8 @@ function stripTerminalControls(value: string): string { const CODEX_READY_RE = /Ask Codex|OpenAI/i const CODEX_TRUST_PROMPT_RE = /Do you trust|trust this folder|Trust this/i const CODEX_UPDATE_PROMPT_RE = /update available|install update|Skip for now/i +const LINUX_IME_POLICY_USER_AGENT = + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/146 Safari/537.36' function terminalImeHarnessScript(runId: string): string { return ` @@ -229,6 +231,9 @@ async function installImeEventProbe(page: Page): Promise { ]) { textarea.addEventListener(type, record, true) } + // Why: the Linux/Sogou post-composition guard lives on the terminal + // element; record terminal-targeted compositionend without doubling key logs. + pane.terminal.element?.addEventListener('compositionend', record, true) }) } @@ -239,6 +244,19 @@ async function readImeEventLog(page: Page): Promise { }) } +async function reloadWithLinuxImePolicy(page: Page): Promise { + await page.addInitScript((userAgent) => { + Object.defineProperty(navigator, 'userAgent', { + get: () => userAgent, + configurable: true + }) + }, LINUX_IME_POLICY_USER_AGENT) + // Why: the Sogou repro validates Linux-gated terminal IME policy on macOS + // runners; reload before the terminal pane mounts so lifecycle code sees it. + await page.reload({ waitUntil: 'domcontentloaded' }) + await page.waitForFunction(() => Boolean(window.__store), null, { timeout: 30_000 }) +} + async function readPromptState(page: Page): Promise { const content = stripTerminalControls(await getTerminalContent(page, 20_000)) const matches = [...content.matchAll(/\[SUBMITTED_JSON_[^\]]+\]("[\s\S]*?")/g)] @@ -320,6 +338,64 @@ async function dispatchImeProcessKey(session: CDPSession, code: string): Promise }) } +async function dispatchCandidateSelectionKey( + session: CDPSession, + candidate: { key: string; code: string; keyCode: number }, + commitBetweenKeys?: () => Promise +): Promise { + // Why: #7543 proves Sogou can forward candidate selectors as plain key + // events, not Process/229. `text` is set so keyDown produces the natural + // keypress — rawKeyDown generates none and would prove nothing about leaks. + await session.send('Input.dispatchKeyEvent', { + type: 'keyDown', + key: candidate.key, + code: candidate.code, + windowsVirtualKeyCode: candidate.keyCode, + nativeVirtualKeyCode: candidate.keyCode, + text: candidate.key, + unmodifiedText: candidate.key + }) + // Why: committing between keyDown and keyUp keeps xterm's _keyDownSeen set, + // matching the trace shape where an insertText commit is actually at risk. + await commitBetweenKeys?.() + await session.send('Input.dispatchKeyEvent', { + type: 'keyUp', + key: candidate.key, + code: candidate.code, + windowsVirtualKeyCode: candidate.keyCode, + nativeVirtualKeyCode: candidate.keyCode + }) +} + +async function dispatchSogouEmptyCompositionUpdate(page: Page): Promise { + // Why: Sogou/fcitx emits empty compositionupdate data while its candidate + // popup is still open (#6765); Orca's tracker must not flip inactive on it. + await page.evaluate(() => { + const active = document.activeElement + if (!(active instanceof HTMLTextAreaElement)) { + throw new Error('xterm helper textarea is not focused') + } + active.dispatchEvent(new CompositionEvent('compositionupdate', { data: '', bubbles: true })) + }) +} + +async function dispatchSogouPostCompositionEnd(page: Page, data: string): Promise { + // Why: some Sogou/fcitx traces deliver the plain selector key after + // compositionend; target the terminal element so Orca's tracker sees the end + // without making xterm finalize a synthetic preedit string. + await page.evaluate((data) => { + const active = document.activeElement + if (!(active instanceof HTMLTextAreaElement)) { + throw new Error('xterm helper textarea is not focused') + } + const terminalElement = active.closest('.xterm') + if (!(terminalElement instanceof HTMLElement)) { + throw new Error('xterm terminal element was not found') + } + terminalElement.dispatchEvent(new CompositionEvent('compositionend', { data, bubbles: false })) + }, data) +} + async function composeAndCommitChineseText( session: CDPSession, page: Page, @@ -473,6 +549,126 @@ test.describe('Chinese IME terminal chat input repro', () => { } }) + test('keeps Sogou-style candidate selection keys out of the PTY while committing Chinese text', async ({ + orcaPage, + testRepoPath + }, testInfo) => { + await reloadWithLinuxImePolicy(orcaPage) + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + + const ptyId = await waitForActivePanePtyId(orcaPage) + const runId = randomUUID() + const scriptPath = path.join(testRepoPath, `.orca-sogou-ime-harness-${runId}.cjs`) + let session: CDPSession | null = null + let harnessStarted = false + + try { + // Why: create the session/harness inside the try so a mid-setup throw + // still hits finally and removes the harness script. + writeFileSync(scriptPath, terminalImeHarnessScript(runId)) + session = await orcaPage.context().newCDPSession(orcaPage) + await sendToTerminal(orcaPage, ptyId, `node ${JSON.stringify(scriptPath)}\r`) + harnessStarted = true + await waitForTerminalOutput(orcaPage, `IME_HARNESS_READY_${runId}`, 10_000, 20_000) + await focusActiveTerminalInput(orcaPage) + await installImeEventProbe(orcaPage) + + // Space selects the first candidate. Sogou keeps emitting empty + // compositionupdate frames while the popup is open, and the plain Space + // press arrives around the commit rather than as a Process/229 key. + await setImeComposition(session, 'n') + await orcaPage.waitForTimeout(80) + await setImeComposition(session, 'ni') + await orcaPage.waitForTimeout(80) + await dispatchSogouEmptyCompositionUpdate(orcaPage) + await dispatchCandidateSelectionKey(session, { key: ' ', code: 'Space', keyCode: 32 }, () => + commitImeText(session, '你') + ) + await waitForLivePrompt(orcaPage, '你') + await attachImeEvidence(orcaPage, testInfo, 'sogou-after-space-commit') + await orcaPage.keyboard.press('Enter') + await expect + .poll(async () => (await readPromptState(orcaPage))?.submitted.at(-1) ?? null, { + timeout: 5_000, + message: 'space-selected candidate did not submit the committed Chinese character' + }) + .toBe('你') + + // Digit selects a non-first candidate for a word/phrase commit — the + // #7543 shape where only the number used to reach the TUI. + await setImeComposition(session, 'nihao') + await orcaPage.waitForTimeout(80) + await dispatchSogouEmptyCompositionUpdate(orcaPage) + await dispatchCandidateSelectionKey(session, { key: '2', code: 'Digit2', keyCode: 50 }, () => + commitImeText(session, '你好') + ) + await waitForLivePrompt(orcaPage, '你好') + await attachImeEvidence(orcaPage, testInfo, 'sogou-after-digit-commit') + await orcaPage.keyboard.press('Enter') + await expect + .poll(async () => (await readPromptState(orcaPage))?.submitted.at(-1) ?? null, { + timeout: 5_000, + message: 'digit-selected candidate did not submit the committed Chinese phrase' + }) + .toBe('你好') + + // Post-composition traces can deliver the selector after compositionend; + // the short post-end guard must still keep that plain digit out of the PTY. + const postCompositionLogStart = (await readImeEventLog(orcaPage)).length + await setImeComposition(session, 'zaijian') + await orcaPage.waitForTimeout(80) + await dispatchSogouEmptyCompositionUpdate(orcaPage) + await dispatchSogouPostCompositionEnd(orcaPage, '再见') + await dispatchCandidateSelectionKey(session, { key: '3', code: 'Digit3', keyCode: 51 }, () => + commitImeText(session, '再见') + ) + await waitForLivePrompt(orcaPage, '再见') + const postCompositionLog = await readImeEventLog(orcaPage) + const postCompositionEndIndex = postCompositionLog.findIndex( + (entry, index) => + index >= postCompositionLogStart && + entry.type === 'compositionend' && + entry.data === '再见' + ) + const postCompositionSelectorIndex = postCompositionLog.findIndex( + (entry, index) => + index > postCompositionEndIndex && entry.type === 'keydown' && entry.key === '3' + ) + expect( + postCompositionEndIndex, + 'Sogou post-composition repro must dispatch compositionend before the plain selector' + ).toBeGreaterThanOrEqual(postCompositionLogStart) + expect( + postCompositionSelectorIndex, + 'plain digit selector must arrive after compositionend in the post-composition repro' + ).toBeGreaterThan(postCompositionEndIndex) + await attachImeEvidence(orcaPage, testInfo, 'sogou-after-post-composition-digit-commit') + await orcaPage.keyboard.press('Enter') + await expect + .poll(async () => (await readPromptState(orcaPage))?.submitted.at(-1) ?? null, { + timeout: 5_000, + message: 'post-composition digit-selected candidate did not submit cleanly' + }) + .toBe('再见') + + const promptState = await readPromptState(orcaPage) + expect( + promptState?.submitted, + 'candidate Space/digit selectors and pinyin preedit must not leak into the PTY' + ).toEqual(['你', '你好', '再见']) + } finally { + await attachImeEvidence(orcaPage, testInfo, 'sogou-final-ime-evidence').catch(() => undefined) + await session?.detach().catch(() => undefined) + if (harnessStarted) { + await sendToTerminal(orcaPage, ptyId, '\x03').catch(() => undefined) + } + rmSync(scriptPath, { force: true }) + } + }) + test('keeps composed Chinese text stable in the real Codex TUI input @real-codex-ime', async ({ orcaPage }, testInfo) => { diff --git a/tests/e2e/ssh-docker-relay-perf.spec.ts b/tests/e2e/ssh-docker-relay-perf.spec.ts index c9679ac39d7..f36f44e9445 100644 --- a/tests/e2e/ssh-docker-relay-perf.spec.ts +++ b/tests/e2e/ssh-docker-relay-perf.spec.ts @@ -340,6 +340,104 @@ test.describe('Docker SSH relay perf', () => { } }) + test('keeps remote typing responsive while relay file streams and git churn are active', async ({ + orcaPage + }, testInfo) => { + test.slow() + let target: DockerSshRelayTarget | null = null + try { + target = startDockerSshRelayTarget(testInfo) + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + const remote = await connectDockerRemote(orcaPage, target) + await ensureTerminalVisible(orcaPage, 45_000) + await waitForActiveTerminalManager(orcaPage, 60_000) + const ptyId = await waitForActivePanePtyId(orcaPage, 60_000) + + const runId = String(Date.now()) + // Large remote binaries: each read streams ~8MB of fs.streamChunk frames + // over the same SSH channel that carries the pty echo. + const loadFiles = [ + `${DOCKER_SSH_RELAY_REMOTE_REPO_PATH}/stream-load-a.png`, + `${DOCKER_SSH_RELAY_REMOTE_REPO_PATH}/stream-load-b.png` + ] + await execInTerminal( + orcaPage, + ptyId, + `dd if=/dev/urandom of=${shellQuote(loadFiles[0])} bs=1M count=8 status=none && ` + + `dd if=/dev/urandom of=${shellQuote(loadFiles[1])} bs=1M count=8 status=none && ` + + `echo LOAD_FILES_READY_${runId}` + ) + await waitForTerminalOutput(orcaPage, `LOAD_FILES_READY_${runId}`, 60_000, 80_000) + + await execInTerminal(orcaPage, ptyId, `node -e ${shellQuote(remoteTypingLoadScript(runId))}`) + await waitForTerminalOutput(orcaPage, `REMOTE_TUI_READY_${runId}`, 30_000, 80_000) + + // Background relay pressure: continuous large file reads plus git status + // refreshes, mirroring file preview + source-control churn while typing. + await orcaPage.evaluate( + ({ targetId, files, repoPath }) => { + const state = { stopped: false, reads: 0, errors: [] as string[] } + ;(window as unknown as { __sshRelayLoad: typeof state }).__sshRelayLoad = state + const loop = async (run: () => Promise): Promise => { + while (!state.stopped) { + try { + await run() + state.reads += 1 + } catch (err) { + state.errors.push(String(err)) + await new Promise((r) => setTimeout(r, 100)) + } + } + } + for (const filePath of files) { + void loop(() => window.api.fs.readFile({ filePath, connectionId: targetId })) + } + void loop(() => window.api.git.status({ worktreePath: repoPath, connectionId: targetId })) + }, + { + targetId: remote.targetId, + files: loadFiles, + repoPath: DOCKER_SSH_RELAY_REMOTE_REPO_PATH + } + ) + // Let the bulk load ramp before measuring. + await orcaPage.waitForTimeout(1_000) + + const measurement = await measureRemoteTyping(orcaPage, ptyId, runId) + const load = await orcaPage.evaluate(() => { + const state = ( + window as unknown as { + __sshRelayLoad: { stopped: boolean; reads: number; errors: string[] } + } + ).__sshRelayLoad + state.stopped = true + return { reads: state.reads, errors: state.errors.slice(0, 3) } + }) + + const summary = + `median=${measurement.medianLatencyMs.toFixed(1)}ms ` + + `worst=${measurement.worstLatencyMs.toFixed(1)}ms ` + + `bulkReads=${load.reads} ` + + `samples=${measurement.latencies.map((value) => value.toFixed(1)).join(',')}` + console.log(`[docker-ssh-relay-perf:busy] ${summary}`) + testInfo.annotations.push({ + type: 'docker-ssh-relay-typing-busy', + description: summary + }) + + // The load must actually have been streaming and error-free, otherwise + // the latency numbers prove nothing. + expect(load.errors).toEqual([]) + expect(load.reads).toBeGreaterThan(0) + expect(measurement.medianLatencyMs).toBeLessThan(MAX_MEDIAN_KEY_LATENCY_MS) + expect(measurement.worstLatencyMs).toBeLessThan(MAX_WORST_KEY_LATENCY_MS) + await stopRemoteLoad(orcaPage, ptyId) + } finally { + cleanupDockerSshRelayTarget(target) + } + }) + test('keeps an SSH workspace terminal usable after disconnect and reconnect', async ({ orcaPage }, testInfo) => { diff --git a/tests/e2e/terminal-reveal-paused-render-repro.spec.ts b/tests/e2e/terminal-reveal-paused-render-repro.spec.ts new file mode 100644 index 00000000000..5b280037da4 --- /dev/null +++ b/tests/e2e/terminal-reveal-paused-render-repro.spec.ts @@ -0,0 +1,268 @@ +import type { Page } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { getActiveTabId, waitForSessionReady } from './helpers/store' +import { + execInTerminal, + waitForActivePanePtyId, + waitForActiveTerminalManager, + waitForTerminalOutput +} from './helpers/terminal' +import { captureStableTabScreenshot } from './terminal-tab-screenshot' +import { compareTerminalScreenshots } from './terminal-screenshot-diff' + +/** + * Reproduction for the "missing bottom rows on reveal, recover on drag-select" + * bug (PR #7614). The mechanism is xterm's RenderService gating refreshRows() on + * its IntersectionObserver: while `_isPaused` is true (the observer can lag a + * frame behind a just-revealed pane, worse under load), refresh() early-returns + * and only latches `_needsFullRefresh`. The reveal-repaint's terminal.refresh() + * is then swallowed and the freshly-cleared render model never repaints. + * + * This spec drives the REAL production reveal path (manager.resetWebglTextureAtlases + * -> resetWebglTextureAtlas -> forceRepaintThroughRenderPause) against a real + * xterm Terminal + RenderService. It: + * 1. proves the bug: while paused, a plain refresh() renders nothing; + * 2. proves the fix: the real reveal repaint forces a full-viewport render + * through the paused gate and clears the pause latch; + * 3. confirms recovery at the pixel level. + * + * The paused state is set deterministically rather than raced, because headless + * Electron does not reliably reproduce the observer-lag timing (documented for + * this bug class). The gate we force is the exact one the field bug hits. + */ + +const BOTTOM_MARKER = 'REVEAL_PAUSED_RENDER_BOTTOM_MARKER' + +type RenderProbeResult = { + paused: boolean + renderedRanges: [number, number][] + rows: number +} + +type RevealRenderDebug = { + installProbe: () => boolean + setPaused: (paused: boolean) => boolean + dirtyModelLikeReveal: () => boolean + plainRefresh: () => void + runRealRevealRepaint: () => void + read: () => RenderProbeResult +} + +type RevealProbeWindow = Window & { + __revealRenderProbe?: RevealRenderDebug +} + +/** + * Installs an in-page probe that instruments the active pane's REAL xterm + * RenderService. Everything here runs against production objects; the only + * test-only code is the recording wrapper around `_renderRows` and the manual + * flip of `_isPaused` that stands in for the observer-lag race. + */ +async function installRevealRenderProbe(page: Page, tabId: string): Promise { + await page.evaluate((tabId) => { + const manager = window.__paneManagers?.get(tabId) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + if (!manager || !pane) { + throw new Error('No active pane for reveal render probe') + } + + type PausableRenderService = { + _isPaused?: boolean + _needsFullRefresh?: boolean + _renderRows?: (start: number, end: number) => void + __revealProbeRanges?: [number, number][] + __revealProbeInstalled?: boolean + } + type TerminalInternals = { + rows?: number + _core?: { _renderService?: PausableRenderService } + buffer?: { active?: { getLine?: (y: number) => unknown } } + refresh?: (start: number, end: number) => void + } + + const terminal = pane.terminal as unknown as TerminalInternals + const service = terminal._core?._renderService + if (!service || typeof service._renderRows !== 'function') { + throw new Error('Real RenderService._renderRows unavailable — cannot probe') + } + + const debug: RevealRenderDebug = { + installProbe: () => { + if (service.__revealProbeInstalled) { + service.__revealProbeRanges = [] + return true + } + service.__revealProbeRanges = [] + const original = service._renderRows!.bind(service) + service._renderRows = (start: number, end: number): void => { + service.__revealProbeRanges!.push([start, end]) + original(start, end) + } + service.__revealProbeInstalled = true + return true + }, + setPaused: (paused: boolean) => { + service._isPaused = paused + return service._isPaused === paused + }, + dirtyModelLikeReveal: () => { + // Why: reveal clears the WebGL render model so a full rebuild is forced. + // clearTextureAtlas() routes through RenderService and, crucially, also + // requests a redraw — which is exactly what the paused gate then eats. + const withAtlas = pane as unknown as { + webglAddon?: { clearTextureAtlas?: () => void } + } + withAtlas.webglAddon?.clearTextureAtlas?.() + return true + }, + plainRefresh: () => { + const rows = terminal.rows ?? 0 + terminal.refresh?.(0, Math.max(0, rows - 1)) + }, + runRealRevealRepaint: () => { + // The real production reveal path — contains the fix under test. + manager.resetWebglTextureAtlases() + }, + read: () => ({ + paused: service._isPaused === true, + renderedRanges: (service.__revealProbeRanges ?? []).slice(), + rows: terminal.rows ?? 0 + }) + } + + ;(window as RevealProbeWindow).__revealRenderProbe = debug + }, tabId) +} + +async function probeRead(page: Page): Promise { + return page.evaluate(() => { + const probe = (window as RevealProbeWindow).__revealRenderProbe + if (!probe) { + throw new Error('Reveal render probe not installed') + } + return probe.read() + }) +} + +async function probeCall( + page: Page, + method: + | 'installProbe' + | 'setPaused' + | 'dirtyModelLikeReveal' + | 'plainRefresh' + | 'runRealRevealRepaint', + paused?: boolean +): Promise { + await page.evaluate( + ({ method, paused }) => { + const probe = (window as RevealProbeWindow).__revealRenderProbe + if (!probe) { + throw new Error('Reveal render probe not installed') + } + if (method === 'setPaused') { + probe.setPaused(paused ?? false) + return + } + ;(probe[method] as () => void)() + }, + { method, paused } + ) +} + +async function forceWebglOn(page: Page, tabId: string): Promise { + await page.evaluate((id) => { + const state = window.__store?.getState() + if (state?.settings) { + window.__store?.setState({ + settings: { ...state.settings, terminalGpuAcceleration: 'on' } + }) + } + window.__paneManagers?.get(id)?.setTerminalGpuAcceleration?.('on') + }, tabId) +} + +test.describe('terminal reveal paused-render recovery', () => { + test("reveal repaint forces a render through xterm's paused gate", async ({ orcaPage }) => { + // Why: __store / __paneManagers live on the main Orca renderer window + // (orcaPage), not Playwright's default first page. + const page = orcaPage + await waitForSessionReady(page) + await waitForActiveTerminalManager(page) + const tabId = (await getActiveTabId(page))! + const ptyId = await waitForActivePanePtyId(page) + + await forceWebglOn(page, tabId) + + // Fill the viewport down to the bottom so a swallowed repaint is observable + // both in the render ranges and on-screen. + await execInTerminal( + page, + ptyId, + `for i in $(seq 1 40); do echo "line $i ${BOTTOM_MARKER}_$i"; done` + ) + await waitForTerminalOutput(page, `${BOTTOM_MARKER}_40`) + // Clear-screen + reprint so the marker sits on the real bottom rows. + await execInTerminal( + page, + ptyId, + `clear; printf '%s\\n' "$(seq 1 30)"; echo "${BOTTOM_MARKER}_FINAL"` + ) + await waitForTerminalOutput(page, `${BOTTOM_MARKER}_FINAL`) + + const revealed = await captureStableTabScreenshot(page, tabId) + + await installRevealRenderProbe(page, tabId) + await probeCall(page, 'installProbe') + + // ---- Control: prove the bug. This sequence (clearTextureAtlas + refresh) + // is byte-for-byte the PRE-FIX resetWebglTextureAtlas on origin/main, so it + // faithfully replays the old reveal path. While paused, it renders nothing. + await probeCall(page, 'setPaused', true) + await probeCall(page, 'dirtyModelLikeReveal') + await probeCall(page, 'plainRefresh') + // Give any (non-existent) queued render a frame to land. + await page.waitForTimeout(80) + const afterPlainRefresh = await probeRead(page) + + expect(afterPlainRefresh.paused, 'terminal is in the paused-render state').toBe(true) + expect( + afterPlainRefresh.renderedRanges, + 'BUG REPRODUCED: while paused, plain refresh() is swallowed by the gate — no render fires' + ).toHaveLength(0) + + // ---- Fix: the real reveal repaint must force a full render through the gate. + await probeCall(page, 'setPaused', true) + await probeCall(page, 'dirtyModelLikeReveal') + await probeCall(page, 'runRealRevealRepaint') + await page.waitForTimeout(80) + const afterRealReveal = await probeRead(page) + + expect( + afterRealReveal.renderedRanges.length, + 'FIX: reveal repaint drove at least one render despite the paused gate' + ).toBeGreaterThan(0) + + const fullViewportRender = afterRealReveal.renderedRanges.some( + ([start, end]) => start === 0 && end >= afterRealReveal.rows - 1 + ) + expect( + fullViewportRender, + 'FIX: reveal repaint rendered the FULL viewport (0..rows-1), not a partial range' + ).toBe(true) + + expect( + afterRealReveal.paused, + 'FIX: pause latch is cleared so the observer can reassert authority cleanly' + ).toBe(false) + + // ---- Pixel-level confirmation: the surface still shows the correct content + // after being driven through the paused gate (no stale/blank bottom rows). + const afterFix = await captureStableTabScreenshot(page, tabId) + const diff = compareTerminalScreenshots(revealed, afterFix) + expect( + diff.matches, + `recovered surface matches the revealed content (diffRatio=${diff.diffRatio})` + ).toBe(true) + }) +})