From 917c577433c9aa8a26e7b6d1ebc0fa838b509acc Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 20 Oct 2026 03:04:03 -0700 Subject: [PATCH] fix(renderer): no owner route reads the Active Server focus, so a local row never routes to the focused server (#31058) * fix(renderer): no route resolver reads the Active Server focus, so a local row never routes to the focused server The worktree route's single-focused-runtime branch, the folder owner's focus fallback and the worktree host default's focus fallback are gone. The client stamps every server row on arrival, so an unstamped row is this machine's: terminal spawn and teardown, browser tabs, quick-open, chat and path links now agree on that. resolveOwnerWithLegacyFallback folds into resolveOwner, and the focus-free write route becomes the one operation route. Setup terminals keep their explicit focused-runtime choice. * fix(renderer): setup terminals keep their focused-runtime host, unstamped repos stay local for the composer, and fixtures stamp server rows * chore: merge duplicate route imports, drop new fixture casts, prune focus-read baseline --- config/focus-setting-read-baseline.txt | 99 ++--------- .../use-automations-page-setup-state.test.tsx | 2 +- .../components/editor/editor-model-owner.ts | 4 +- .../native-chat/native-chat-file-link.test.ts | 6 +- .../sidebar/run-worktree-delete-with-toast.ts | 8 +- ...y-connection-remote-runtime-attach.spec.ts | 7 +- .../pty-connection-test-store-fixtures.ts | 7 +- .../src/hooks/useAgentDetectionTarget.test.ts | 10 +- .../agent-background-session-test-state.ts | 10 +- .../src/lib/editor-file-operation-owner.ts | 6 +- .../src/lib/folder-workspace-runtime-owner.ts | 18 +- ...aunch-agent-in-new-tab-web-runtime.test.ts | 5 +- .../src/lib/launch-agent-in-new-tab.test.ts | 4 + .../src/lib/repo-runtime-owner.test.ts | 6 +- src/renderer/src/lib/repo-runtime-owner.ts | 4 +- .../resolve-owner-acceptance.test-fixture.ts | 6 +- .../src/lib/resolve-owner-acceptance.test.ts | 9 +- src/renderer/src/lib/resolve-owner.ts | 56 +----- .../lib/terminal-pty-owner-acceptance.test.ts | 6 +- .../src/lib/terminal-worktree-route.test.ts | 9 +- .../src/lib/terminal-worktree-route.ts | 11 +- .../src/lib/worktree-operation-route.test.ts | 36 ++-- .../src/lib/worktree-operation-route.ts | 19 +- .../src/lib/worktree-runtime-owner.test.ts | 14 +- .../src/lib/worktree-runtime-owner.ts | 166 ++++-------------- .../worktree-sort-order-host-split.test.ts | 6 +- .../browser-remote-page-lifecycle.test.ts | 5 + .../slices/terminal-tab-retirement.test.ts | 6 +- .../host-qualified-worktree-removal.ts | 4 +- .../worktrees/teardown/remove-worktree.ts | 8 +- 30 files changed, 193 insertions(+), 364 deletions(-) diff --git a/config/focus-setting-read-baseline.txt b/config/focus-setting-read-baseline.txt index d7aba00c714..789b4b18901 100644 --- a/config/focus-setting-read-baseline.txt +++ b/config/focus-setting-read-baseline.txt @@ -4,7 +4,7 @@ # the default host, through defaultCreationHost. Everything else routes by the owner. # Prune after removing reads: pnpm check:focus-read-ratchet --prune 2 src/renderer/src/App.tsx -5 src/renderer/src/app-shell/use-app-shell-services.ts +3 src/renderer/src/app-shell/use-app-shell-services.ts 1 src/renderer/src/components/JiraIssueWorkspace.tsx 1 src/renderer/src/components/Landing.tsx 2 src/renderer/src/components/LinearIssueTextEditor.tsx @@ -24,23 +24,16 @@ 1 src/renderer/src/components/automations/use-automations-page-destination-form.ts 2 src/renderer/src/components/automations/use-automations-page-setup-state.ts 1 src/renderer/src/components/browser-pane/ReopenBrowserPageOnServerButton.tsx -1 src/renderer/src/components/browser-pane/assemble-chrome/browser-egress-indicator.tsx -1 src/renderer/src/components/browser-pane/assemble-chrome/browser-page-viewport-overlays.tsx 1 src/renderer/src/components/browser-pane/assemble-chrome/browser-workspace-pane.tsx -1 src/renderer/src/components/browser-pane/assemble-chrome/ssh-routed-browser-page-gate.tsx 1 src/renderer/src/components/browser-pane/browser-reopen-on-server.ts 1 src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-pane.tsx 1 src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-page-lifecycle.ts 1 src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-page-stream.ts 1 src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-stream-activation.ts -2 src/renderer/src/components/browser-pane/use-ssh-workspace-browser-route.ts -1 src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.tsx 1 src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-actions.ts 1 src/renderer/src/components/browser-pane/workspace-doc/workspace-doc-page-pane.tsx 2 src/renderer/src/components/cmd-j/quick-action-context.ts -2 src/renderer/src/components/dashboard/dashboard-card-terminal-input.ts -1 src/renderer/src/components/dashboard/launch-dashboard-agent.ts -1 src/renderer/src/components/dashboard/use-agent-row-conversation-name.ts +1 src/renderer/src/components/dashboard/dashboard-card-terminal-input.ts 2 src/renderer/src/components/editor/EditorContent.tsx 1 src/renderer/src/components/editor/EditorDiffFileSurface.tsx 1 src/renderer/src/components/editor/EditorEditFileSurface.tsx @@ -76,21 +69,12 @@ 1 src/renderer/src/components/linear-item-drawer-edit-section.tsx 2 src/renderer/src/components/native-chat-resume-modal.test-support.tsx 1 src/renderer/src/components/native-chat-resume-tree.test-support.ts -1 src/renderer/src/components/native-chat/NativeChatAgentMessageSenders.tsx 1 src/renderer/src/components/native-chat/NativeChatResolvedView.tsx 1 src/renderer/src/components/native-chat/NativeChatVisualFrame.tsx -1 src/renderer/src/components/native-chat/StructuredAgentSessionAttentionBridge.tsx -1 src/renderer/src/components/native-chat/StructuredAgentSessionPaneOverlayLayer.tsx -1 src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx -5 src/renderer/src/components/native-chat/native-chat-agent-message-sender-label.ts -2 src/renderer/src/components/native-chat/native-chat-attachment-upload.ts 1 src/renderer/src/components/native-chat/native-chat-file-link.ts 3 src/renderer/src/components/native-chat/native-chat-http-link-source-owner.ts 1 src/renderer/src/components/native-chat/native-chat-link-target-actions.ts 2 src/renderer/src/components/native-chat/native-chat-runtime-owner.ts -1 src/renderer/src/components/native-chat/native-chat-skill-discovery-context.ts -1 src/renderer/src/components/native-chat/use-native-chat-file-drops.ts -2 src/renderer/src/components/native-chat/use-native-chat-workspace-file-drop.ts 1 src/renderer/src/components/new-workspace/use-jira-source-connection.ts 4 src/renderer/src/components/new-workspace/use-linear-source-connection.ts 1 src/renderer/src/components/new-workspace/use-smart-workspace-name-field-controller.ts @@ -102,8 +86,6 @@ 2 src/renderer/src/components/right-sidebar/FileExplorer.tsx 1 src/renderer/src/components/right-sidebar/FileExplorerFilesTreePane.tsx 1 src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksRow.tsx -1 src/renderer/src/components/right-sidebar/ai-vault-original-pane-actions.ts -1 src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-workspace.ts 3 src/renderer/src/components/right-sidebar/checks-panel-hosted-review-click-routing.ts 1 src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-controller-state.tsx 1 src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-create-review.tsx @@ -115,9 +97,9 @@ 2 src/renderer/src/components/right-sidebar/use-file-explorer-tree-pane-state.ts 1 src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts 4 src/renderer/src/components/right-sidebar/useFileSearchPanel.ts -2 src/renderer/src/components/right-sidebar/useFileSearchRunner.ts -2 src/renderer/src/components/right-sidebar/useFileSearchScope.ts -2 src/renderer/src/components/right-sidebar/useGitStatusPolling.ts +1 src/renderer/src/components/right-sidebar/useFileSearchRunner.ts +1 src/renderer/src/components/right-sidebar/useFileSearchScope.ts +1 src/renderer/src/components/right-sidebar/useGitStatusPolling.ts 1 src/renderer/src/components/settings/BaseRefPicker.tsx 2 src/renderer/src/components/settings/BrowserPane.tsx 1 src/renderer/src/components/settings/CliSkillRuntimeSetup.tsx @@ -161,7 +143,6 @@ 1 src/renderer/src/components/sidebar/SidebarTaskNavButton.tsx 1 src/renderer/src/components/sidebar/WorkspaceAttachmentSearch.tsx 1 src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx -1 src/renderer/src/components/sidebar/WorktreeCardPorts.tsx 1 src/renderer/src/components/sidebar/WorktreeList.tsx 1 src/renderer/src/components/sidebar/WorktreeRowOpenInSubMenu.tsx # reads focus through resolveOwnerWithLegacyFallback, newly counted (round 3 F2); F8 nets it down 1 src/renderer/src/components/sidebar/WorktreeVisibilityDialog.tsx @@ -196,10 +177,8 @@ 1 src/renderer/src/components/skills/SkillsPage.tsx 2 src/renderer/src/components/status-bar/ClaudeSwitcherMenu.tsx 1 src/renderer/src/components/status-bar/CodexSwitcherMenu.tsx -1 src/renderer/src/components/status-bar/PortsStatusSegment.tsx 1 src/renderer/src/components/status-bar/SshStatusSegment.tsx 1 src/renderer/src/components/status-bar/StatusBarSurface.tsx -1 src/renderer/src/components/status-bar/ports-status-popover-rows.tsx 2 src/renderer/src/components/status-bar/provider-account-sync-key.ts 1 src/renderer/src/components/status-bar/usage-provider-settings-target.ts 2 src/renderer/src/components/status-bar/use-codex-switcher-controller.ts @@ -207,11 +186,9 @@ 1 src/renderer/src/components/status-bar/use-workspace-space-git-refresh-action.ts 1 src/renderer/src/components/status-bar/use-workspace-space-manager-panel.ts 1 src/renderer/src/components/status-bar/workspace-space-decision-details.ts -1 src/renderer/src/components/tab-bar/QuickLaunchButton.tsx 1 src/renderer/src/components/tab-bar/TabBar.tsx -1 src/renderer/src/components/tab-bar/TabBarItemRow.tsx 3 src/renderer/src/components/tab-bar/tab-bar-windows-shell-launch-test-harness.ts -4 src/renderer/src/components/tab-bar/use-tab-bar-runtime-model.ts +3 src/renderer/src/components/tab-bar/use-tab-bar-runtime-model.ts 2 src/renderer/src/components/tab-bar/web-runtime-tab-move-mirror.ts 1 src/renderer/src/components/tab-bar/windows-shell-menu-visibility.ts 2 src/renderer/src/components/tab-group/useTabGroupActivationCommands.ts @@ -221,21 +198,15 @@ 9 src/renderer/src/components/task-page/TaskPage.tsx 2 src/renderer/src/components/task-page/jira/IssueAssigneeField.tsx 1 src/renderer/src/components/task-page/linear/Filters.tsx -1 src/renderer/src/components/terminal-pane/TerminalPaneSurface.tsx 1 src/renderer/src/components/terminal-pane/pending-split-close-test-fixture.ts 1 src/renderer/src/components/terminal-pane/pty-connection/agent-idle-working-handlers.ts -1 src/renderer/src/components/terminal-pane/pty-connection/interrupt-input-intent.ts -1 src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts -1 src/renderer/src/components/terminal-pane/pty-connection/pane-host-capability-flags.ts 1 src/renderer/src/components/terminal-pane/retire-unbound-runtime-terminal-pane.ts -1 src/renderer/src/components/terminal-pane/terminal-ctrl-arrow-conpty.ts 1 src/renderer/src/components/terminal-pane/terminal-fit-restore.ts -2 src/renderer/src/components/terminal-pane/terminal-input-host-platform.ts +1 src/renderer/src/components/terminal-pane/terminal-input-host-platform.ts 1 src/renderer/src/components/terminal-pane/terminal-keyboard-hook.ts 1 src/renderer/src/components/terminal-pane/terminal-keyboard-runtime.ts 1 src/renderer/src/components/terminal-pane/terminal-link-open-hints.ts 2 src/renderer/src/components/terminal-pane/terminal-pane-close-admission.ts -1 src/renderer/src/components/terminal-pane/terminal-pane-manager-options.ts 2 src/renderer/src/components/terminal-pane/terminal-pane-menu-paste.ts 3 src/renderer/src/components/terminal-pane/terminal-pane-mount-preparation.ts 1 src/renderer/src/components/terminal-pane/terminal-pane-paste-execution.ts @@ -286,9 +257,7 @@ 1 src/renderer/src/hooks/ipc-events/tab-lifecycle-ipc-bridge.ts 1 src/renderer/src/hooks/ipc-events/terminal-request-ipc-bridge.ts 2 src/renderer/src/hooks/use-active-skill-discovery-runtime-target.ts -1 src/renderer/src/hooks/use-terminal-quick-command-hosts.ts 1 src/renderer/src/hooks/useActiveProjectSkillRuntime.ts -2 src/renderer/src/hooks/useAgentDetectionTarget.ts 1 src/renderer/src/hooks/useEditorExternalWatch.ts 1 src/renderer/src/hooks/useInstalledAgentSkills.ts 1 src/renderer/src/hooks/useIpcEvents.ts @@ -297,28 +266,18 @@ 7 src/renderer/src/hooks/useSettingsNavigationMetadata.ts 1 src/renderer/src/hooks/useUnreadDockBadge.ts 2 src/renderer/src/hooks/useWindowsTerminalCapabilityOwnerKey.ts -4 src/renderer/src/lib/activate-ai-vault-structured-session.ts +3 src/renderer/src/lib/activate-ai-vault-structured-session.ts 1 src/renderer/src/lib/active-agent-note-send.ts 1 src/renderer/src/lib/active-agent-note-target.ts -1 src/renderer/src/lib/agent-account-restart-startup.ts 1 src/renderer/src/lib/agent-background-session-launch-host.ts -1 src/renderer/src/lib/agent-detection-target-inventory.ts 1 src/renderer/src/lib/agent-hibernation-coordinator-test-fixture.ts -2 src/renderer/src/lib/agent-launch-route-input.ts -1 src/renderer/src/lib/agent-message-send.ts -1 src/renderer/src/lib/ai-vault-resume-platform.ts -1 src/renderer/src/lib/ai-vault-tab-title-requests.ts -2 src/renderer/src/lib/ai-vault-tab-title-sync-inputs.ts 1 src/renderer/src/lib/client-creation-action-error.ts 3 src/renderer/src/lib/client-creation-action-policy.ts 1 src/renderer/src/lib/default-creation-host.ts # the sanctioned reader of the default host 1 src/renderer/src/lib/doc-preview-grants.ts # reads focus through resolveOwnerWithLegacyFallback, newly counted (round 3 F2); F8 nets it down -1 src/renderer/src/lib/empty-workspace-default-agent-chat.ts -3 src/renderer/src/lib/execute-open-editor-path-move.ts -1 src/renderer/src/lib/execution-host-display-label.ts -2 src/renderer/src/lib/execution-host-facts.ts # reads focus through resolveOwnerWithLegacyFallback, newly counted (round 3 F2); F8 nets it down +1 src/renderer/src/lib/execute-open-editor-path-move.ts +1 src/renderer/src/lib/execution-host-facts.ts # reads focus through resolveOwnerWithLegacyFallback, newly counted (round 3 F2); F8 nets it down 5 src/renderer/src/lib/file-preview.ts -2 src/renderer/src/lib/folder-workspace-runtime-owner.ts 6 src/renderer/src/lib/http-link-destinations.ts 3 src/renderer/src/lib/http-link-routing.ts 1 src/renderer/src/lib/launch-agent-background-session.ts @@ -326,42 +285,32 @@ 2 src/renderer/src/lib/launch-agent-in-new-tab.ts 2 src/renderer/src/lib/launch-agent-web-host-tab.ts 2 src/renderer/src/lib/launch-ai-vault-session.ts -1 src/renderer/src/lib/launch-structured-agent-session.ts 1 src/renderer/src/lib/linear-issue-url-lookup.ts 1 src/renderer/src/lib/local-preflight-context.ts -3 src/renderer/src/lib/open-agent-message-sender.ts 2 src/renderer/src/lib/provider-runtime-context.ts -2 src/renderer/src/lib/remap-open-editor-tabs-for-path-change.ts 1 src/renderer/src/lib/remote-workspace-drop-test-fixtures.ts 2 src/renderer/src/lib/repo-runtime-owner.ts 1 src/renderer/src/lib/runtime-session-mirror-owners.ts 1 src/renderer/src/lib/runtime-session-mirror-targets.ts -1 src/renderer/src/lib/runtime-workspace-file-route.ts 1 src/renderer/src/lib/single-runtime-legacy-owner.ts -1 src/renderer/src/lib/sleeping-agent-session-launch.ts -1 src/renderer/src/lib/structured-agent-session-launch.ts -1 src/renderer/src/lib/structured-agent-session-provisional-tab.ts -4 src/renderer/src/lib/structured-agent-session-tab-activation.ts +1 src/renderer/src/lib/structured-agent-session-tab-activation.ts 1 src/renderer/src/lib/tab-number-shortcuts.ts 1 src/renderer/src/lib/terminal-close-route.ts 1 src/renderer/src/lib/terminal-tab-owner-activation.ts 6 src/renderer/src/lib/terminal-worktree-route.ts # resolveTerminalPtyOwner: teardown, close and PTY identity read spawn's attribution (round 3 F8), which retired the deprecated resolver's readers 3 src/renderer/src/lib/unread-badge-count-selector.ts 2 src/renderer/src/lib/web-runtime-worktree-terminal-after-wake.ts -5 src/renderer/src/lib/workspace-browser-tab-open.ts +4 src/renderer/src/lib/workspace-browser-tab-open.ts 1 src/renderer/src/lib/workspace-file-browser-open-target.ts # moved out of file-preview.ts (round 3 F7), same read -1 src/renderer/src/lib/workspace-file-drag-source.ts 1 src/renderer/src/lib/workspace-tab-palette-activation.ts 1 src/renderer/src/lib/worktree-activation-pty-inventory.ts # reads focus through resolveOwnerWithLegacyFallback, newly counted (round 3 F2); F8 nets it down -3 src/renderer/src/lib/worktree-activation.ts +2 src/renderer/src/lib/worktree-activation.ts 1 src/renderer/src/lib/worktree-creation-agent-seeds.ts 1 src/renderer/src/lib/worktree-jump-navigation.ts -2 src/renderer/src/lib/worktree-operation-route.ts 1 src/renderer/src/lib/worktree-row-open-in-owner.ts # reads focus through resolveOwnerWithLegacyFallback, newly counted (round 3 F2); F8 nets it down -8 src/renderer/src/lib/worktree-runtime-owner.ts # typed owner resolver and the legacy-fallback adapter the folds moved to (round 3 F0); F8 deleted the deprecated pair +3 src/renderer/src/lib/worktree-runtime-owner.ts # typed owner resolver and the legacy-fallback adapter the folds moved to (round 3 F0); F8 deleted the deprecated pair 1 src/renderer/src/lib/worktree-sort-order-host-split.ts 1 src/renderer/src/lib/worktree-sort-order-persistence.ts # its split helper calls resolveOwnerWithLegacyFallback directly (round 3 F4); F8 nets it down -1 src/renderer/src/runtime/local-structured-session-tabs-sync/snapshot-apply.ts 17 src/renderer/src/runtime/runtime-jira-client.ts 2 src/renderer/src/runtime/runtime-jira-summary-client.ts 1 src/renderer/src/runtime/runtime-jira-target.ts @@ -369,10 +318,7 @@ 8 src/renderer/src/runtime/runtime-linear-client.ts 6 src/renderer/src/runtime/runtime-linear-issue-mutations.ts 12 src/renderer/src/runtime/runtime-linear-project-client.ts -3 src/renderer/src/runtime/structured-agent-session-owner.ts -1 src/renderer/src/runtime/structured-conversation-name.ts 5 src/renderer/src/runtime/use-runtime-session-mirror-environment-key.ts -1 src/renderer/src/runtime/use-worktree-runtime-target.ts 2 src/renderer/src/runtime/web-runtime-browser-creation.ts 1 src/renderer/src/runtime/web-runtime-initial-terminal-bootstrap-dispatch.ts 1 src/renderer/src/runtime/web-runtime-session-tab-lifecycle.ts @@ -384,7 +330,6 @@ 2 src/renderer/src/runtime/web-session-tabs-sync/global-session-subscription.ts 1 src/renderer/src/runtime/web-session-tabs-sync/tracking-decisions.ts 3 src/renderer/src/runtime/web-session-tabs-sync/use-web-session-tabs-sync.ts -1 src/renderer/src/store/folder-workspaces/folder-workspace-mutations.ts 1 src/renderer/src/store/github/repository-routing.ts 2 src/renderer/src/store/github/visible-hosted-review-refresh-targets.ts 2 src/renderer/src/store/github/work-item-aggregate-actions.ts @@ -393,25 +338,19 @@ 9 src/renderer/src/store/index.ts 2 src/renderer/src/store/project-groups/project-group-mutations.ts 1 src/renderer/src/store/repos/all-host-repo-catalog-actions.ts -1 src/renderer/src/store/repos/converted-ssh-browser-pages.ts 2 src/renderer/src/store/repos/repo-add-actions.ts 1 src/renderer/src/store/repos/repo-update.ts 1 src/renderer/src/store/repos/runtime-repo-catalog-actions.ts 1 src/renderer/src/store/slices/browser-slice-test-harness.ts -1 src/renderer/src/store/slices/browser/browser-close-actions.ts 16 src/renderer/src/store/slices/browser/browser-cookie-import-actions.ts 1 src/renderer/src/store/slices/browser/browser-host-actions.ts -10 src/renderer/src/store/slices/browser/browser-host-state.ts -2 src/renderer/src/store/slices/browser/browser-page-conversion-actions.ts -1 src/renderer/src/store/slices/browser/browser-page-focus-actions.ts +7 src/renderer/src/store/slices/browser/browser-host-state.ts +1 src/renderer/src/store/slices/browser/browser-page-conversion-actions.ts 14 src/renderer/src/store/slices/browser/browser-profile-import-actions.ts 14 src/renderer/src/store/slices/browser/browser-profile-list-actions.ts -7 src/renderer/src/store/slices/browser/browser-tab-actions.ts -1 src/renderer/src/store/slices/browser/browser-tab-focus-actions.ts +6 src/renderer/src/store/slices/browser/browser-tab-actions.ts 1 src/renderer/src/store/slices/browser/create-browser-slice.ts 1 src/renderer/src/store/slices/browser/paired-browser-tab-creator.ts -2 src/renderer/src/store/slices/diff-comment-host-write.ts -1 src/renderer/src/store/slices/diff-comment-persistence.ts 2 src/renderer/src/store/slices/editor-slice-test-harness.ts 1 src/renderer/src/store/slices/editor/actions/markdown-link-action.ts 1 src/renderer/src/store/slices/editor/actions/markdown-preview-actions.ts @@ -444,12 +383,11 @@ 2 src/renderer/src/store/slices/preflight.ts 1 src/renderer/src/store/slices/repo-host-identity.ts 1 src/renderer/src/store/slices/repo-reorder-host-split.ts -4 src/renderer/src/store/slices/repos.ts +3 src/renderer/src/store/slices/repos.ts 1 src/renderer/src/store/slices/runtime-status.ts 2 src/renderer/src/store/slices/settings-owner-hydration-publication.ts 5 src/renderer/src/store/slices/settings.ts 8 src/renderer/src/store/slices/store-test-helpers.ts -1 src/renderer/src/store/slices/tabs/tabs-close-actions.ts 2 src/renderer/src/store/slices/tabs/tabs-host-mirroring.ts 1 src/renderer/src/store/slices/terminal-tab-retirement.ts 3 src/renderer/src/store/slices/workspace-attention/workspace-unread-characterization-fixture.ts @@ -466,7 +404,6 @@ 1 src/renderer/src/store/slices/worktrees/metadata/worktree-lineage-actions.ts 3 src/renderer/src/store/slices/worktrees/metadata/worktree-lineage-host-merge.ts 3 src/renderer/src/store/slices/worktrees/session/worktree-terminal-reference.ts -1 src/renderer/src/store/slices/worktrees/teardown/purged-structured-launch-retirement.ts 2 src/renderer/src/store/terminals/terminal-active-workspace-creation.ts 1 src/renderer/src/store/terminals/terminal-tab-appearance.ts 1 src/renderer/src/store/terminals/terminal-tab-close-providers.ts diff --git a/src/renderer/src/components/automations/use-automations-page-setup-state.test.tsx b/src/renderer/src/components/automations/use-automations-page-setup-state.test.tsx index 47befa87f4f..61bb1ddae58 100644 --- a/src/renderer/src/components/automations/use-automations-page-setup-state.test.tsx +++ b/src/renderer/src/components/automations/use-automations-page-setup-state.test.tsx @@ -46,7 +46,7 @@ describe('automation hooks cache key', () => { 'owner:repo-1' ], ['local owner', [{ id: 'repo-1', executionHostId: 'local' }], 'focused', 'local:repo-1'], - ['unstamped row follows focus', [{ id: 'repo-1' }], 'focused', 'focused:repo-1'], + ['unstamped row stays local', [{ id: 'repo-1' }], 'focused', 'local:repo-1'], ['missing row follows focus', [], 'focused', 'focused:repo-1'], ['missing row, local focus', [], null, 'local:repo-1'], [ diff --git a/src/renderer/src/components/editor/editor-model-owner.ts b/src/renderer/src/components/editor/editor-model-owner.ts index 75f1ab422e1..9115c03b521 100644 --- a/src/renderer/src/components/editor/editor-model-owner.ts +++ b/src/renderer/src/components/editor/editor-model-owner.ts @@ -1,6 +1,6 @@ import type { OpenFile } from '@/store/slices/editor' import type { WorktreeOperationRouteState } from '@/lib/worktree-operation-route' -import { resolveWriteRoute } from '@/lib/resolve-owner' +import { resolveWorktreeOperationRoute } from '@/lib/worktree-operation-route' import { toRuntimeExecutionHostId, toSshExecutionHostId } from '../../../../shared/execution-host' export function getEditorModelOwnerKey(file: OpenFile, state: WorktreeOperationRouteState): string { @@ -8,7 +8,7 @@ export function getEditorModelOwnerKey(file: OpenFile, state: WorktreeOperationR const route = captured ?? // The same focus-free owner the file's writes take, so retained text keys on that host. - resolveWriteRoute( + resolveWorktreeOperationRoute( state.activeWorktreeId === file.worktreeId ? { ...state, activeWorktreeId: null } : state, file.worktreeId ) diff --git a/src/renderer/src/components/native-chat/native-chat-file-link.test.ts b/src/renderer/src/components/native-chat/native-chat-file-link.test.ts index b14fb0f653e..1abade7d057 100644 --- a/src/renderer/src/components/native-chat/native-chat-file-link.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-file-link.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from 'vitest' import type { Tab } from '../../../../shared/tab-types' import type { TerminalTab } from '../../../../shared/terminal-tab-types' -import type { AppState } from '@/store/types' import { folderWorkspaceKey } from '../../../../shared/workspace-scope' import { FLOATING_TERMINAL_WORKTREE_ID, getDefaultSettings } from '../../../../shared/constants' import { createNativeChatFileHref } from '../../../../shared/native-chat-href-routing' @@ -58,7 +57,10 @@ describe('resolveNativeChatFileLinkContext', () => { expect( resolveNativeChatFileLinkContext( state({ - settings: { activeRuntimeEnvironmentId: 'env-1' } as AppState['settings'] + // The client stamps a server's rows on arrival; focus alone never names the owner. + worktreesByRepo: { + repo: [{ ...worktreeFixture('wt-1', '/repo/worktree'), hostId: 'runtime:env-1' }] + } }), 'tab-1' ) diff --git a/src/renderer/src/components/sidebar/run-worktree-delete-with-toast.ts b/src/renderer/src/components/sidebar/run-worktree-delete-with-toast.ts index 1c70a89476e..84300d165cc 100644 --- a/src/renderer/src/components/sidebar/run-worktree-delete-with-toast.ts +++ b/src/renderer/src/components/sidebar/run-worktree-delete-with-toast.ts @@ -2,12 +2,14 @@ import { toast } from 'sonner' import { useAppStore } from '@/store' import { activateAndRevealWorktree } from '@/lib/worktree-activation' import { translate } from '@/i18n/i18n' -import { resolveWorktreeOperationRouteForHost } from '@/lib/worktree-operation-route' +import { + resolveWorktreeOperationRoute, + resolveWorktreeOperationRouteForHost +} from '@/lib/worktree-operation-route' import type { WorktreeRemovalTarget } from '../../../../shared/worktree/removal' import { showDeleteWorktreeFailureToast } from './delete-worktree-failure-toast' import type { WorktreeDeleteWithToastOptions } from './worktree-delete-request' import { getDeleteStateForWorktreeHost } from './worktree-delete-state-host-match' -import { resolveWriteRoute } from '@/lib/resolve-owner' // A failed delete usually means unresolved changes, so land on the diff panel. function viewWorktreeDiff( @@ -44,7 +46,7 @@ export function runWorktreeDeleteWithToast( const currentState = useAppStore.getState() const route = target.executionHostId ? resolveWorktreeOperationRouteForHost(currentState, worktreeId, target.executionHostId) - : resolveWriteRoute(currentState, worktreeId) + : resolveWorktreeOperationRoute(currentState, worktreeId) const hasKnownChanges = (useAppStore.getState().gitStatusByWorktree[worktreeId]?.length ?? 0) > 0 showDeleteWorktreeFailureToast({ diff --git a/src/renderer/src/components/terminal-pane/pty-connection-remote-runtime-attach.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-remote-runtime-attach.spec.ts index 096207fa92e..3eade27f9f2 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-remote-runtime-attach.spec.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-remote-runtime-attach.spec.ts @@ -451,9 +451,10 @@ export function registerPtyConnectionRemoteRuntimeAttachTests(): void { expect(createIpcPtyTransport).toHaveBeenCalled() }) - it('uses the focused runtime only for ownerless mixed-version publications', async () => { + it('spawns an unstamped local worktree locally while a single server is focused', async () => { const { connectPanePty } = await import('./pty-connection') const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const { createIpcPtyTransport } = await import('./pty-transport') const transport = createMockTransport() transportFactoryQueue.push(transport) mockStoreState = { @@ -468,7 +469,9 @@ export function registerPtyConnectionRemoteRuntimeAttachTests(): void { connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never) - expect(createRemoteRuntimePtyTransport).toHaveBeenCalledWith('legacy-hub', expect.any(Object)) + // Why: the client stamps every server row on arrival, so an unstamped row is this machine's. + expect(createRemoteRuntimePtyTransport).not.toHaveBeenCalled() + expect(createIpcPtyTransport).toHaveBeenCalled() }) it('runs an inline setup terminal locally instead of failing its host closed', async () => { diff --git a/src/renderer/src/components/terminal-pane/pty-connection-test-store-fixtures.ts b/src/renderer/src/components/terminal-pane/pty-connection-test-store-fixtures.ts index 2aa6e3c7ce5..7fdaec460dc 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-test-store-fixtures.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-test-store-fixtures.ts @@ -114,9 +114,14 @@ export function buildActiveRuntimeEnvironmentState( ): StoreState { return { ...current, + // The client stamps a server's rows on arrival; focus alone never names the owner. + repos: current.repos.map((repo) => ({ + ...repo, + executionHostId: repo.executionHostId ?? `runtime:${environmentId}` + })), settings: { ...current.settings, activeRuntimeEnvironmentId: environmentId } - } as StoreState + } } diff --git a/src/renderer/src/hooks/useAgentDetectionTarget.test.ts b/src/renderer/src/hooks/useAgentDetectionTarget.test.ts index 833d2d6cc5e..5bafe42a62f 100644 --- a/src/renderer/src/hooks/useAgentDetectionTarget.test.ts +++ b/src/renderer/src/hooks/useAgentDetectionTarget.test.ts @@ -105,17 +105,23 @@ describe('getAgentDetectionTargetKeyForWorktree', () => { expect(getAgentDetectionTargetKeyForWorktree(state, 'repo-1::/remote/worktree')).toBeUndefined() }) - it('keeps the active runtime fallback for hydrated legacy worktrees', () => { + it('reads a server row by its stamp, never by the focused runtime', () => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resolver reads only these owner fields. const state = { settings: { activeRuntimeEnvironmentId: 'env-1' }, folderWorkspaces: [], projectGroups: [], - repos: [{ id: 'repo-1', connectionId: null, executionHostId: null }], + repos: [{ id: 'repo-1', connectionId: null, executionHostId: 'runtime:env-1' }], worktreesByRepo: { 'repo-1': [{ id: 'repo-1::worktree-1', repoId: 'repo-1' }] } } as unknown as Parameters[0] + const unstamped = { + ...state, + repos: state.repos?.map((repo) => ({ ...repo, executionHostId: null })) + } + expect(getAgentDetectionTargetKeyForWorktree(unstamped, 'repo-1::worktree-1')).toBe('local') const key = getAgentDetectionTargetKeyForWorktree(state, 'repo-1::worktree-1') // The workspace rides along so the paired host resolves its runtime (#19885). expect(key).toBe('runtime:env-1:repo-1%3A%3Aworktree-1') diff --git a/src/renderer/src/lib/agent-background-session-test-state.ts b/src/renderer/src/lib/agent-background-session-test-state.ts index 815c55945b3..64d2d942643 100644 --- a/src/renderer/src/lib/agent-background-session-test-state.ts +++ b/src/renderer/src/lib/agent-background-session-test-state.ts @@ -24,7 +24,13 @@ export type AgentBackgroundSessionTestState = { | { kind: 'windows-host' } | { kind: 'wsl'; distro: string | null } }[] - repos: { id: string; connectionId: string | null; path: string; projectGroupId?: string | null }[] + repos: { + id: string + connectionId: string | null + path: string + projectGroupId?: string | null + executionHostId?: `runtime:${string}` + }[] folderWorkspaces: { id: string projectGroupId: string @@ -163,6 +169,8 @@ export function useRemoteAgentBackgroundRuntime(state: AgentBackgroundSessionTes activeRuntimeEnvironmentId: 'env-1', terminalMainSideEffectAuthority: undefined } + // The client stamps a server's rows on arrival; focus alone never names the owner. + state.repos = state.repos.map((repo) => ({ ...repo, executionHostId: 'runtime:env-1' })) } /** The tab id reserved before the spawn; the run tab adopts it once the PTY is live. */ diff --git a/src/renderer/src/lib/editor-file-operation-owner.ts b/src/renderer/src/lib/editor-file-operation-owner.ts index bcbb3777845..7427ccd211c 100644 --- a/src/renderer/src/lib/editor-file-operation-owner.ts +++ b/src/renderer/src/lib/editor-file-operation-owner.ts @@ -12,9 +12,9 @@ import { } from './worktree-operation-generation' import { getFloatingWorkspaceOperationRoute, + resolveWorktreeOperationRoute, type WorktreeOperationRoute } from './worktree-operation-route' -import { resolveWriteRoute } from './resolve-owner' import { resolveExplicitWorktreeOperationRouteResult } from './worktree-operation-catalog-route' export type EditorFileOperationProvenance = { @@ -89,7 +89,7 @@ function resolveEditorOwnerRoute( ): WorktreeOperationRoute | null { // Neither workspace focus nor the Active Server focus can replace a file's catalog owner. const ownerState = { ...state, activeWorktreeId: null, activeWorkspaceExecutionHostId: null } - const route = resolveWriteRoute(ownerState, worktreeId) + const route = resolveWorktreeOperationRoute(ownerState, worktreeId) const scope = parseWorkspaceKey(worktreeId) const hostId = capturedHostId ?? @@ -100,7 +100,7 @@ function resolveEditorOwnerRoute( // Same-id folders may use a host qualifier only when the catalog confirms that owner. const folder = findFolderWorkspaceOwner(ownerState, scope.folderWorkspaceId, hostId) const qualifiedRoute = folder - ? resolveWriteRoute({ ...ownerState, folderWorkspaces: [folder] }, worktreeId) + ? resolveWorktreeOperationRoute({ ...ownerState, folderWorkspaces: [folder] }, worktreeId) : null return qualifiedRoute?.executionHostId === hostId ? qualifiedRoute : null } diff --git a/src/renderer/src/lib/folder-workspace-runtime-owner.ts b/src/renderer/src/lib/folder-workspace-runtime-owner.ts index 4ee299282cc..5d32701baf5 100644 --- a/src/renderer/src/lib/folder-workspace-runtime-owner.ts +++ b/src/renderer/src/lib/folder-workspace-runtime-owner.ts @@ -1,4 +1,8 @@ -import { parseExecutionHostId, toSshExecutionHostId } from '../../../shared/execution-host' +import { + parseExecutionHostId, + toRuntimeExecutionHostId, + toSshExecutionHostId +} from '../../../shared/execution-host' import type { ExecutionHostId, ParsedExecutionHost } from '../../../shared/execution-host' import type { FolderWorkspace } from '../../../shared/folder-workspace-types' import type { ProjectGroup } from '../../../shared/project-group-types' @@ -7,10 +11,7 @@ import { findIndexedFolderWorkspaceOwner, findIndexedProjectGroupOwner } from './worktree-runtime-owner-index' -import { - getSingleFocusedRuntimeEnvironmentId, - type SingleRuntimeLegacyOwnerState -} from './single-runtime-legacy-owner' +import type { SingleRuntimeLegacyOwnerState } from './single-runtime-legacy-owner' import { getPairedWebClientEnvironmentId } from './paired-web-client-host' type RuntimeExecutionHost = Extract @@ -113,7 +114,8 @@ export function getRuntimeEnvironmentIdForFolderWorkspace( if (restoredRuntimeHost) { return restoredRuntimeHost.environmentId } - return getSingleFocusedRuntimeEnvironmentId(state) + // Why not the Active Server focus: the client stamps every server folder on arrival. + return getPairedWebClientEnvironmentId(state) } export function getExplicitRuntimeEnvironmentIdForFolderWorkspace( @@ -179,6 +181,6 @@ export function getExecutionHostIdForFolderWorkspace( if (restoredRuntimeHost) { return restoredRuntimeHost.id } - const environmentId = getSingleFocusedRuntimeEnvironmentId(state) - return environmentId ? `runtime:${encodeURIComponent(environmentId)}` : 'local' + const environmentId = getPairedWebClientEnvironmentId(state) + return environmentId ? toRuntimeExecutionHostId(environmentId) : 'local' } diff --git a/src/renderer/src/lib/launch-agent-in-new-tab-web-runtime.test.ts b/src/renderer/src/lib/launch-agent-in-new-tab-web-runtime.test.ts index 024755a0d76..0f5f4895907 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab-web-runtime.test.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab-web-runtime.test.ts @@ -17,7 +17,10 @@ const store = { activeRuntimeEnvironmentId: 'web-runtime' as string | null }, projects: [{ id: 'repo-1', localWindowsRuntimePreference: { kind: 'inherit-global' as const } }], - repos: [{ id: 'repo-1', connectionId: null, path: '/repo' }], + // The client stamps a server's rows on arrival; focus alone never names the owner. + repos: [ + { id: 'repo-1', connectionId: null, path: '/repo', executionHostId: 'runtime:web-runtime' } + ], worktreesByRepo: { 'repo-1': [ { diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts index 3b7d4051ad0..219e82188be 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts @@ -540,6 +540,8 @@ describe('launchAgentInNewTab', () => { activeRuntimeEnvironmentId: 'web-runtime', experimentalNativeChat: true } + // The client stamps a server's rows on arrival; focus alone never names the owner. + Object.assign(store.repos[0], { executionHostId: 'runtime:web-runtime' }) const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') launchAgentInNewTab({ requestId: 'request-12', agent: 'codex', worktreeId: 'wt-1' }) @@ -562,6 +564,8 @@ describe('launchAgentInNewTab', () => { activeRuntimeEnvironmentId: 'web-runtime', experimentalNativeChat: true } + // The client stamps a server's rows on arrival; focus alone never names the owner. + Object.assign(store.repos[0], { executionHostId: 'runtime:web-runtime' }) const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') launchAgentInNewTab({ requestId: 'request-13', agent: 'codex', worktreeId: 'wt-1' }) diff --git a/src/renderer/src/lib/repo-runtime-owner.test.ts b/src/renderer/src/lib/repo-runtime-owner.test.ts index db85a7de548..90824275b54 100644 --- a/src/renderer/src/lib/repo-runtime-owner.test.ts +++ b/src/renderer/src/lib/repo-runtime-owner.test.ts @@ -43,7 +43,7 @@ describe('getRuntimeEnvironmentIdForRepo', () => { ).toBeNull() }) - it('falls back to the focused runtime for legacy repos without an owner', () => { + it('keeps legacy repos without an owner local while a runtime is focused', () => { expect( getRuntimeEnvironmentIdForRepo( { @@ -52,7 +52,7 @@ describe('getRuntimeEnvironmentIdForRepo', () => { }, 'repo-1' ) - ).toBe('focused-runtime') + ).toBeNull() }) it('uses the focused host row when duplicate repo ids exist', () => { @@ -102,7 +102,7 @@ describe('getRuntimeEnvironmentIdForRepo owner matrix', () => { ['local row', [row('local')], 'focused', null], ['direct SSH row', [row(null, 'ssh-1')], 'focused', null], ['nested runtime row', [row('runtime:owner', 'ssh-1')], 'focused', 'owner'], - ['unstamped row', [row(null)], 'focused', 'focused'], + ['unstamped row', [row(null)], 'focused', null], ['missing row', [], 'focused', 'focused'], ['duplicates, local focus', [row('local'), row('runtime:owner')], null, null], ['duplicates, matching focus', [row('local'), row('runtime:owner')], 'owner', 'owner'], diff --git a/src/renderer/src/lib/repo-runtime-owner.ts b/src/renderer/src/lib/repo-runtime-owner.ts index 4550e890666..7c24b52e611 100644 --- a/src/renderer/src/lib/repo-runtime-owner.ts +++ b/src/renderer/src/lib/repo-runtime-owner.ts @@ -53,8 +53,8 @@ export function getRuntimeEnvironmentIdForRepo( return null } const repo = findRepoOwner(state, repoId) - const hasExplicitOwner = Boolean(repo?.executionHostId?.trim() || repo?.connectionId?.trim()) - if (repo && hasExplicitOwner) { + if (repo) { + // Why: an unstamped row is local; the client stamps every server row on arrival. const parsed = parseExecutionHostId(getRepoExecutionHostId(repo)) return parsed?.kind === 'runtime' ? parsed.environmentId : null } diff --git a/src/renderer/src/lib/resolve-owner-acceptance.test-fixture.ts b/src/renderer/src/lib/resolve-owner-acceptance.test-fixture.ts index af9a9ea2c47..b36ba137248 100644 --- a/src/renderer/src/lib/resolve-owner-acceptance.test-fixture.ts +++ b/src/renderer/src/lib/resolve-owner-acceptance.test-fixture.ts @@ -46,7 +46,7 @@ export const LEGACY_FALLBACK_OWNER_ACCEPTANCE: Record = { 'no focus | folder:missing-folder': 'self@local', 'single focus | global-floating-terminal': 'self@local', 'single focus | local-repo::wt': 'self@local', - 'single focus | legacy-repo::wt': 'env:focused-env@local', + 'single focus | legacy-repo::wt': 'self@local', 'single focus | runtime-repo::wt': 'env:owner-env@local', 'single focus | ssh-repo::wt': 'self@ssh:box', 'single focus | hub-repo::wt': 'env:hub-env@ssh:box', @@ -63,9 +63,9 @@ export const LEGACY_FALLBACK_OWNER_ACCEPTANCE: Record = { 'single focus | folder:ssh-folder': 'self@ssh:box', 'single focus | folder:twin-folder': 'ambiguous', 'single focus | folder:group-twin-folder': 'ambiguous', - 'single focus | folder:unstamped-folder': 'env:focused-env@local', + 'single focus | folder:unstamped-folder': 'self@local', 'single focus | folder:restored-folder': 'env:owner-env@local', - 'single focus | folder:missing-folder': 'env:focused-env@local', + 'single focus | folder:missing-folder': 'self@local', 'active local | twin-repo::wt': 'self@local', 'active ssh:box | twin-repo::wt': 'self@ssh:box', 'active local | rival-repo::wt': 'self@local', diff --git a/src/renderer/src/lib/resolve-owner-acceptance.test.ts b/src/renderer/src/lib/resolve-owner-acceptance.test.ts index aa0233914ca..4678ac46eda 100644 --- a/src/renderer/src/lib/resolve-owner-acceptance.test.ts +++ b/src/renderer/src/lib/resolve-owner-acceptance.test.ts @@ -27,7 +27,10 @@ const DELIBERATE_CHANGES: Record = { 'folder:group-twin-folder': 'D77', 'rival-repo::wt': 'B: servers contest the SSH host', 'conflict-repo::wt': 'B', - 'stale-hub-repo::wt': 'C: reached through the server whose repo row names the target' + 'stale-hub-repo::wt': 'C: reached through the server whose repo row names the target', + 'legacy-repo::wt': 'an unstamped row is local; the Active Server focus never claims it', + 'folder:unstamped-folder': 'same', + 'folder:missing-folder': 'same' } describe('resolveOwnerWithLegacyFallback acceptance (round-3 design §5)', () => { @@ -85,12 +88,12 @@ describe('resolveOwnerWithLegacyFallback probes', () => { expect(ownerOf(single, 'onboarding-setup::/tmp/skill')).toBe('self@local') }) - it('sends a known unstamped repo with no worktree row to the single focused runtime, as spawn does', () => { + it('keeps a known unstamped repo with no worktree row local under single focus', () => { const noWorktreeRow = { ...single, worktreesByRepo: { ...single.worktreesByRepo, 'legacy-repo': [] } } - expect(ownerOf(noWorktreeRow, 'legacy-repo::wt')).toBe('env:focused-env@local') + expect(ownerOf(noWorktreeRow, 'legacy-repo::wt')).toBe('self@local') }) it('lets a named host win over the single focused runtime', () => { diff --git a/src/renderer/src/lib/resolve-owner.ts b/src/renderer/src/lib/resolve-owner.ts index 579981cce4a..8b124bf3024 100644 --- a/src/renderer/src/lib/resolve-owner.ts +++ b/src/renderer/src/lib/resolve-owner.ts @@ -1,6 +1,5 @@ /** - * The owner of a workspace as a {@link HostAuthority}, decided from the rows alone. It never reads - * the "Active Server" focus setting. + * The owner of a workspace as a {@link HostAuthority}, decided from the rows alone. */ import { getRepoSshConnectionId, @@ -25,7 +24,6 @@ import { getFloatingWorkspaceOperationRoute, resolveWorktreeOperationRouteResultForHost, resolveWorktreeOperationRouteResult, - type WorktreeOperationRoute, type WorktreeOperationRouteResolution, type WorktreeOperationRouteState } from './worktree-operation-route' @@ -92,7 +90,7 @@ export function namedHostForOwnerRef( ) } -function resolveRoute( +export function resolveOwner( state: WorktreeOperationRouteState, ref: WorkspaceOwnerRef ): ExecutionHostOwnerMatch { @@ -149,56 +147,6 @@ function nestedRepoTargetId( return repo ? getRepoSshConnectionId(repo) : null } -export function resolveOwner( - state: WorktreeOperationRouteState, - ref: WorkspaceOwnerRef -): ExecutionHostOwnerMatch { - return resolveRoute(focusFreeRouteState(state), ref) -} - -/** - * The route for a write that names no host, decided by the rows alone: the "Active Server" focus - * fallback that terminal reads keep is never reachable for a write. - */ -export function resolveWriteRouteResult( - state: WorktreeOperationRouteState, - workspaceId: string -): WorktreeOperationRouteResolution { - return resolveWorktreeOperationRouteResult(focusFreeRouteState(state), workspaceId) -} - -/** {@link resolveWriteRouteResult}, refusing ambiguity and absence alike. */ -export function resolveWriteRoute( - state: WorktreeOperationRouteState, - workspaceId: string -): WorktreeOperationRoute | null { - const resolution = resolveWriteRouteResult(state, workspaceId) - return resolution.kind === 'resolved' ? resolution.route : null -} - -/** - * Only the fields the route resolvers read, so per-row selectors do not copy the whole store. - * Focus is the only settings field they read; blanking it makes this focus-free. - */ -function focusFreeRouteState(state: WorktreeOperationRouteState): { - [K in keyof Required]: WorktreeOperationRouteState[K] -} { - return { - settings: null, - runtimeEnvironments: state.runtimeEnvironments, - folderWorkspaces: state.folderWorkspaces, - projectGroups: state.projectGroups, - restoredRuntimeHostIdByWorkspaceSessionKey: state.restoredRuntimeHostIdByWorkspaceSessionKey, - activeWorktreeId: state.activeWorktreeId, - activeWorkspaceExecutionHostId: state.activeWorkspaceExecutionHostId, - repos: state.repos, - worktreesByRepo: state.worktreesByRepo, - detectedWorktreesByRepo: state.detectedWorktreesByRepo, - runtimeEnvironmentCatalogHydrated: state.runtimeEnvironmentCatalogHydrated, - removedRuntimeEnvironmentIds: state.removedRuntimeEnvironmentIds - } -} - /** Two hosts publish this workspace (or contest it), so no host-routed action may pick one. */ export function isTwinWorkspace( state: WorktreeOperationRouteState, diff --git a/src/renderer/src/lib/terminal-pty-owner-acceptance.test.ts b/src/renderer/src/lib/terminal-pty-owner-acceptance.test.ts index 9d5d261ecac..f19f94c67f9 100644 --- a/src/renderer/src/lib/terminal-pty-owner-acceptance.test.ts +++ b/src/renderer/src/lib/terminal-pty-owner-acceptance.test.ts @@ -23,7 +23,11 @@ const DELIBERATE_CHANGES: Record = { 'single focus | folder:group-twin-folder': 'ambiguous', // B: two servers contest the selected SSH host, so spawn refuses and teardown must too. 'active ssh:box | rival-repo::wt': 'ambiguous', - 'active ssh:box | conflict-repo::wt': 'ambiguous' + 'active ssh:box | conflict-repo::wt': 'ambiguous', + // An unstamped row is this machine's; the Active Server focus never claims its PTYs. + 'single focus | legacy-repo::wt': null, + 'single focus | folder:unstamped-folder': null, + 'single focus | folder:missing-folder': null } describe('resolveTerminalPtyOwner acceptance', () => { diff --git a/src/renderer/src/lib/terminal-worktree-route.test.ts b/src/renderer/src/lib/terminal-worktree-route.test.ts index 34598739157..672ae8a38ea 100644 --- a/src/renderer/src/lib/terminal-worktree-route.test.ts +++ b/src/renderer/src/lib/terminal-worktree-route.test.ts @@ -179,17 +179,16 @@ describe('resolveTerminalHostOwnership teardown', () => { }) }) - it('keeps a hostless worktree on the focused runtime for teardown too', () => { - // Why: unlike the host-agnostic surfaces, an ownerless worktree row really does spawn on the - // focused HUB, and its wake hint is `ssh:`-shaped — downgrading it would kill the wrong host. + it('spawns and tears down a hostless worktree locally, never on the focused runtime', () => { + // Why: the client stamps every server row on arrival, so a row with no host is this machine's. const state = focusedState({ repos: [{ id: 'repo-1', connectionId: null, executionHostId: null }], worktreesByRepo: { 'repo-1': [{ id: 'repo-1::/w', repoId: 'repo-1' }] } } as unknown as Partial) for (const purpose of ['spawn', 'teardown'] as const) { expect(resolveTerminalHostOwnership(state, 'repo-1::/w', purpose)).toEqual({ - kind: 'runtime', - runtimeEnvironmentId: 'hub-a' + kind: 'local-or-ssh', + runtimeEnvironmentId: null }) } }) diff --git a/src/renderer/src/lib/terminal-worktree-route.ts b/src/renderer/src/lib/terminal-worktree-route.ts index 48605f2d1e2..53160cd7de9 100644 --- a/src/renderer/src/lib/terminal-worktree-route.ts +++ b/src/renderer/src/lib/terminal-worktree-route.ts @@ -33,9 +33,9 @@ export type TerminalHostOwnership = | { kind: 'unresolved'; runtimeEnvironmentId: null } /** - * `spawn` asks which host should start a new process, so a merely focused runtime is a fine guess. - * `teardown` asks which provider can kill an existing one, where that same guess strands the local - * PTY the surface was actually spawned on. + * `spawn` asks which host should start a new process, so a setup terminal may follow the focused + * runtime. `teardown` asks which provider can kill an existing one, where that guess strands the + * local PTY the surface was actually spawned on. */ export type TerminalHostOwnershipPurpose = 'spawn' | 'teardown' @@ -214,8 +214,9 @@ function resolveFloatingScopeOwnership( if (runtimeEnvironmentIdUnlessTwin(state, worktreeId)) { return ownershipForRuntimeEnvironmentId(runtimeEnvironmentId) } - // Why: this surface publishes no runtime owner and only looks runtime-owned because exactly one - // runtime is focused — a guess that flips as catalogs hydrate, stranding the local PTY (STA-2639). + // Why: this surface publishes no runtime owner and only looks runtime-owned through a guess (the + // focused runtime, a web client's server) that flips as catalogs hydrate, stranding the local + // PTY (STA-2639). return LOCAL_OR_SSH_TERMINAL_HOST } diff --git a/src/renderer/src/lib/worktree-operation-route.test.ts b/src/renderer/src/lib/worktree-operation-route.test.ts index 48ff464ea87..a4115002dbe 100644 --- a/src/renderer/src/lib/worktree-operation-route.test.ts +++ b/src/renderer/src/lib/worktree-operation-route.test.ts @@ -119,7 +119,7 @@ describe('resolveWorktreeOperationRouteResult', () => { }) }) - it('uses the focused runtime only for legacy publications with no owner evidence', () => { + it('routes an unstamped row local, never to the focused runtime', () => { expect( resolveWorktreeOperationRouteResult( { @@ -131,10 +131,7 @@ describe('resolveWorktreeOperationRouteResult', () => { ) ).toEqual({ kind: 'resolved', - route: { - executionHostId: 'runtime:legacy-hub', - runtimeEnvironmentId: 'legacy-hub' - } + route: { executionHostId: 'local', runtimeEnvironmentId: null } }) }) @@ -239,7 +236,7 @@ describe('resolveWorktreeOperationRouteResult', () => { ).toEqual({ kind: 'ambiguous' }) }) - it('routes a focused runtime only when it is the single saved runtime', () => { + it('never routes an unstamped row to the single saved, focused runtime', () => { expect( resolveWorktreeOperationRouteResult( { @@ -250,10 +247,7 @@ describe('resolveWorktreeOperationRouteResult', () => { }, WORKTREE_ID ) - ).toEqual({ - kind: 'resolved', - route: { executionHostId: 'runtime:hub-a', runtimeEnvironmentId: 'hub-a' } - }) + ).toEqual({ kind: 'missing' }) }) it('ignores unrelated removed-runtime tombstones for positive local identity', () => { @@ -298,7 +292,7 @@ describe('resolveWorktreeOperationRouteResult', () => { ).toEqual({ kind: 'missing' }) }) - it('does not treat runtime focus as ownership while the saved-runtime catalog is loading', () => { + it('keeps an unstamped row local while the saved-runtime catalog is loading, focus or not', () => { expect( resolveWorktreeOperationRouteResult( { @@ -310,7 +304,10 @@ describe('resolveWorktreeOperationRouteResult', () => { }, WORKTREE_ID ) - ).toEqual({ kind: 'missing' }) + ).toEqual({ + kind: 'resolved', + route: { executionHostId: 'local', runtimeEnvironmentId: null } + }) }) it('preserves ownerless local compatibility after an empty catalog hydrates', () => { @@ -539,7 +536,7 @@ describe('resolveWorktreeOperationRouteResult', () => { ).toEqual({ kind: 'missing' }) }) - it('fails closed when the active runtime is ambiguous', () => { + it('keeps an unstamped row local whichever of several runtimes is focused', () => { expect( resolveWorktreeOperationRouteResult( { @@ -551,10 +548,13 @@ describe('resolveWorktreeOperationRouteResult', () => { }, WORKTREE_ID ) - ).toEqual({ kind: 'missing' }) + ).toEqual({ + kind: 'resolved', + route: { executionHostId: 'local', runtimeEnvironmentId: null } + }) }) - it('keeps an unambiguous active runtime authoritative over the local fallback', () => { + it('keeps an unstamped row local when the single saved runtime is focused', () => { expect( resolveWorktreeOperationRouteResult( { @@ -568,7 +568,7 @@ describe('resolveWorktreeOperationRouteResult', () => { ) ).toEqual({ kind: 'resolved', - route: { executionHostId: 'runtime:hub-a', runtimeEnvironmentId: 'hub-a' } + route: { executionHostId: 'local', runtimeEnvironmentId: null } }) }) @@ -773,7 +773,7 @@ describe('resolveWorktreeOperationRouteResult', () => { }) }) - it('scopes an ownerless folder workspace to the single focused runtime', () => { + it('keeps an ownerless folder workspace local when the single saved runtime is focused', () => { expect( resolveWorktreeOperationRouteResult( { @@ -785,7 +785,7 @@ describe('resolveWorktreeOperationRouteResult', () => { ) ).toEqual({ kind: 'resolved', - route: { executionHostId: 'runtime:hub-a', runtimeEnvironmentId: 'hub-a' } + route: { executionHostId: 'local', runtimeEnvironmentId: null } }) }) diff --git a/src/renderer/src/lib/worktree-operation-route.ts b/src/renderer/src/lib/worktree-operation-route.ts index 63bd2721a73..b6b5027c4ad 100644 --- a/src/renderer/src/lib/worktree-operation-route.ts +++ b/src/renderer/src/lib/worktree-operation-route.ts @@ -295,24 +295,9 @@ function resolveOwnerRouteResult( return { kind: 'missing' } } - // Why: pre-owner-projection runtimes published no host fields; terminal routing retains their single focused-runtime behavior. - const legacyRuntimeEnvironmentId = state.settings?.activeRuntimeEnvironmentId?.trim() + // Why no Active Server focus here: the client stamps every server row on arrival, so an + // unstamped row is never a server's. const savedRuntimeIds = state.runtimeEnvironments?.map((environment) => environment.id.trim()) - const legacyRuntimeIsUnambiguous = - savedRuntimeIds === undefined || - (savedRuntimeIds.length === 1 && savedRuntimeIds[0] === legacyRuntimeEnvironmentId) - if (legacyRuntimeEnvironmentId && !legacyRuntimeIsUnambiguous) { - return { kind: 'missing' } - } - if (legacyRuntimeEnvironmentId) { - return { - kind: 'resolved', - route: { - executionHostId: `runtime:${encodeURIComponent(legacyRuntimeEnvironmentId)}`, - runtimeEnvironmentId: legacyRuntimeEnvironmentId - } - } - } // Why: a found repo/worktree record is positive identity evidence, so keep terminal-owner // parity with the folder branch below. Every stamped row already routed above, so an unstamped // repo row here is a legacy pre-owner-projection row — local by construction, as diff --git a/src/renderer/src/lib/worktree-runtime-owner.test.ts b/src/renderer/src/lib/worktree-runtime-owner.test.ts index 296e9417166..4c614d22185 100644 --- a/src/renderer/src/lib/worktree-runtime-owner.test.ts +++ b/src/renderer/src/lib/worktree-runtime-owner.test.ts @@ -364,10 +364,18 @@ describe('the explicit owner environment (resolveOwner)', () => { }) it('does not treat the focused runtime as ownership for legacy-local worktrees', () => { - expect(runtimeEnvironmentIdWithLegacyFallbackUnlessTwin(state, 'legacy-repo::wt-legacy')).toBe( - 'focused-env' - ) + expect( + runtimeEnvironmentIdWithLegacyFallbackUnlessTwin(state, 'legacy-repo::wt-legacy') + ).toBeNull() expect(ownerEnvironmentId(state, 'legacy-repo::wt-legacy')).toBeNull() + expect(getExecutionHostIdForWorktree(state, 'legacy-repo::wt-legacy')).toBe('local') + expect(getExecutionHostIdForWorktree(state, 'unknown-repo::wt')).toBe('local') + }) + + it('keeps a setup terminal on the focused runtime, where it spawns', () => { + expect(getExecutionHostIdForWorktree(state, 'ephemeral-setup-terminal:skill-panel')).toBe( + 'runtime:focused-env' + ) }) it('returns the runtime owner when the repo or folder explicitly names one', () => { diff --git a/src/renderer/src/lib/worktree-runtime-owner.ts b/src/renderer/src/lib/worktree-runtime-owner.ts index 1ab194ce2a5..39810bbe26a 100644 --- a/src/renderer/src/lib/worktree-runtime-owner.ts +++ b/src/renderer/src/lib/worktree-runtime-owner.ts @@ -5,108 +5,39 @@ import { UNRESOLVED_OWNER_HOST_ID } from '../../../shared/execution-host' import type { ExecutionHostId } from '../../../shared/execution-host' -import type { Worktree } from '../../../shared/worktree/types' import { parseWorkspaceKey } from '../../../shared/workspace-scope' import { getRepoIdFromWorktreeId } from '@/store/slices/worktree-helpers' import { findIndexedRepoOwner as findRepoRecord, findIndexedWorktreeOwner as findWorktreeRecord, - hasIndexedDetectedWorktree, - resolveIndexedProjectGroupOwner, - resolveIndexedRepoOwner, - resolveIndexedWorktreeOwner + hasIndexedDetectedWorktree } from './worktree-runtime-owner-index' -import { getSingleFocusedRuntimeEnvironmentId } from './single-runtime-legacy-owner' import { getActiveWorkspaceExecutionHostId } from './active-workspace-host' +import { getSingleFocusedRuntimeEnvironmentId } from './single-runtime-legacy-owner' +import { isEphemeralSetupTerminalWorktreeId } from '../../../shared/ephemeral-setup-terminal-worktree-id' import { getPairedWebClientEnvironmentId } from './paired-web-client-host' import { findFolderWorkspaceOwner, getExecutionHostIdForFolderWorkspace, getRuntimeEnvironmentIdForFolderWorkspace } from './folder-workspace-runtime-owner' -import { - namedHostForOwnerRef, - resolveOwner, - someWorktreeCopy, - type WorkspaceOwnerRef -} from './resolve-owner' +import { namedHostForOwnerRef, resolveOwner, type WorkspaceOwnerRef } from './resolve-owner' import { SELF_LOCAL_AUTHORITY, type HostAuthority } from '../../../shared/host-authority' import type { ExecutionHostOwnerMatch } from '../../../shared/worktree-execution-host-resolution' -import { - getFloatingWorkspaceOperationRoute, - resolveWorktreeOperationRouteResult -} from './worktree-operation-route' +import { getFloatingWorkspaceOperationRoute } from './worktree-operation-route' import { resolveExplicitWorktreeOperationRouteResult } from './worktree-operation-catalog-route' import type { WorktreeRuntimeOwnerState } from './worktree-runtime-owner-state' -function getProjectedRuntimeOwnerEnvironmentId( - worktree: Pick | null | undefined -): string | null { - return worktree?.runtimeOwnerEnvironmentId?.trim() || null -} - function getExecutionHostIdFromWorktreeHost( hostId: string | null | undefined ): ExecutionHostId | null { return parseExecutionHostId(hostId)?.id ?? null } -/** - * The server the pre-`resolveOwner` resolver picked for a row that names no host (`null`: this - * app or a twin). Only {@link resolveOwnerWithLegacyFallback} reads it, after a named host, - * a twin and every stamped row have already been answered. - */ -function legacyRuntimeEnvironmentId( - state: WorktreeRuntimeOwnerState, - worktreeId: string -): string | null { - const workspaceScope = parseWorkspaceKey(worktreeId) - if (workspaceScope?.type === 'folder') { - return getRuntimeEnvironmentIdForFolderWorkspace(state, workspaceScope.folderWorkspaceId) - } - const indexedOwner = resolveIndexedWorktreeOwner(state.worktreesByRepo, worktreeId) - if (indexedOwner.kind === 'ambiguous') { - return null - } - if (indexedOwner.kind === 'resolved') { - const owner = indexedOwner.owner - const projectedRuntimeOwner = getProjectedRuntimeOwnerEnvironmentId(owner) - const parsedHost = parseExecutionHostId(owner.hostId) - const hasDetectedOwner = hasIndexedDetectedWorktree(state.detectedWorktreesByRepo, worktreeId) - // Why: a row on a web client that names no server is still reached through its only one. - const onlyHost = getPairedWebClientEnvironmentId(state) - if (!hasDetectedOwner && (projectedRuntimeOwner || parsedHost)) { - return ( - projectedRuntimeOwner || - (parsedHost?.kind === 'runtime' ? parsedHost.environmentId : onlyHost) || - null - ) - } - if (!hasDetectedOwner) { - const repoResolution = resolveIndexedRepoOwner(state.repos, owner.repoId) - if (repoResolution.kind === 'ambiguous') { - return null - } - if ( - repoResolution.kind === 'resolved' && - (repoResolution.owner.executionHostId?.trim() || repoResolution.owner.connectionId?.trim()) - ) { - const repoHost = parseExecutionHostId(getRepoExecutionHostId(repoResolution.owner)) - if (repoHost) { - return (repoHost.kind === 'runtime' ? repoHost.environmentId : onlyHost) || null - } - } - } - } - const resolution = resolveWorktreeOperationRouteResult(state, worktreeId) - return resolution.kind === 'resolved' ? resolution.route.runtimeEnvironmentId || null : null -} - -function getFocusedRuntimeOrLocalExecutionHostId( - state: WorktreeRuntimeOwnerState -): ExecutionHostId { - const environmentId = getSingleFocusedRuntimeEnvironmentId(state) - return environmentId ? `runtime:${encodeURIComponent(environmentId)}` : 'local' +/** A row that names no host is this machine's: the client stamps every server row on arrival. */ +function getUnstampedExecutionHostId(state: WorktreeRuntimeOwnerState): ExecutionHostId { + const environmentId = getPairedWebClientEnvironmentId(state) + return environmentId ? toRuntimeExecutionHostId(environmentId) : 'local' } /** @@ -114,8 +45,7 @@ function getFocusedRuntimeOrLocalExecutionHostId( * worktree without a repo row, a folder workspace without a folder-workspace row) and nothing more * specific — active-workspace host, detected owner, per-worktree host — applies either. A row that * exists and names no owner is a positive `'local'`; a row that has not landed is silence. - * {@link getExecutionHostIdForWorktree} papers over that silence with the focused-runtime-or-local - * default, which is the right answer for routing an operation and the wrong one for a caller that + * {@link getExecutionHostIdForWorktree} papers over that silence with the local default, which is the right answer for routing an operation and the wrong one for a caller that * reads the host as evidence. */ export function getKnownExecutionHostIdForWorktree( @@ -171,76 +101,50 @@ export function getKnownExecutionHostIdForWorktree( if (hasExplicitOwner) { return getRepoExecutionHostId(repo) } - return getFocusedRuntimeOrLocalExecutionHostId(state) + return getUnstampedExecutionHostId(state) } export function getExecutionHostIdForWorktree( state: WorktreeRuntimeOwnerState, worktreeId: string | null | undefined ): ExecutionHostId { - return ( - getKnownExecutionHostIdForWorktree(state, worktreeId) ?? - getFocusedRuntimeOrLocalExecutionHostId(state) - ) -} - -function hasRowHost(row: { executionHostId?: string | null; connectionId?: string | null }) { - return Boolean(row.executionHostId?.trim() || row.connectionId?.trim()) -} - -/** Through the owner index: rows on two hosts always include a stamped one. */ -function hasStampedRepoRow(state: WorktreeRuntimeOwnerState, repoId: string): boolean { - const repo = resolveIndexedRepoOwner(state.repos, repoId) - return repo.kind === 'ambiguous' || (repo.kind === 'resolved' && hasRowHost(repo.owner)) -} - -/** A row that names its host; without one the deprecated resolver's guess still applies. */ -function hasHostEvidence(state: WorktreeRuntimeOwnerState, workspaceId: string): boolean { - if (getFloatingWorkspaceOperationRoute(workspaceId)) { - return true + const known = getKnownExecutionHostIdForWorktree(state, worktreeId) + if (known) { + return known } - const scope = parseWorkspaceKey(workspaceId) - if (scope?.type === 'folder') { - const folder = findFolderWorkspaceOwner(state, scope.folderWorkspaceId) - if (!folder || hasRowHost(folder)) { - return Boolean(folder) - } - const group = resolveIndexedProjectGroupOwner(state.projectGroups, folder.projectGroupId) - return group.kind === 'ambiguous' || (group.kind === 'resolved' && hasRowHost(group.owner)) - } - const repoId = getRepoIdFromWorktreeId(workspaceId) - return ( - someWorktreeCopy(state, workspaceId, (row) => - Boolean(row.hostId?.trim() || row.runtimeOwnerEnvironmentId?.trim()) - ) || hasStampedRepoRow(state, repoId) - ) + // Why: a setup terminal has no row and spawns on the focused runtime (resolveTerminalHostOwnership). + const setupEnvironmentId = + worktreeId && isEphemeralSetupTerminalWorktreeId(worktreeId) + ? getSingleFocusedRuntimeEnvironmentId(state) + : null + return setupEnvironmentId + ? toRuntimeExecutionHostId(setupEnvironmentId) + : getUnstampedExecutionHostId(state) } /** - * `resolveOwner` for callers folding off the deprecated resolvers: a named host and stamped rows - * resolve as `resolveOwner` does (twins and contested hosts stay `ambiguous`), while `missing` and - * rows with no host stamp keep the deprecated answer, single focused runtime included. + * {@link resolveOwner}, reading an id no row places as this app's — or, for a folder, the server + * its saved session names, or a web client's only server. */ export function resolveOwnerWithLegacyFallback( state: WorktreeRuntimeOwnerState, ref: WorkspaceOwnerRef ): ExecutionHostOwnerMatch { const match = resolveOwner(state, ref) - if ( - namedHostForOwnerRef(state, ref) || - match.kind === 'ambiguous' || - (match.kind === 'resolved' && hasHostEvidence(state, ref.workspaceId)) - ) { + if (match.kind !== 'missing' || namedHostForOwnerRef(state, ref)) { return match } - const legacyEnvironmentId = legacyRuntimeEnvironmentId(state, ref.workspaceId) - if (legacyEnvironmentId) { - return { - kind: 'resolved', - owner: { endpoint: { kind: 'environment', environmentId: legacyEnvironmentId }, at: 'local' } - } + const scope = parseWorkspaceKey(ref.workspaceId) + const environmentId = + scope?.type === 'folder' + ? getRuntimeEnvironmentIdForFolderWorkspace(state, scope.folderWorkspaceId) + : null + return { + kind: 'resolved', + owner: environmentId + ? { endpoint: { kind: 'environment', environmentId }, at: 'local' } + : SELF_LOCAL_AUTHORITY } - return match.kind === 'resolved' ? match : { kind: 'resolved', owner: SELF_LOCAL_AUTHORITY } } /** diff --git a/src/renderer/src/lib/worktree-sort-order-host-split.test.ts b/src/renderer/src/lib/worktree-sort-order-host-split.test.ts index 76a894a1117..0b568682602 100644 --- a/src/renderer/src/lib/worktree-sort-order-host-split.test.ts +++ b/src/renderer/src/lib/worktree-sort-order-host-split.test.ts @@ -23,7 +23,7 @@ describe('splitWorktreeSortOrderByHost', () => { ]) }) - it('routes legacy worktrees without an explicit owner to the focused host', () => { + it('keeps legacy worktrees without an explicit owner local, whatever server is focused', () => { const groups = splitWorktreeSortOrderByHost( { settings: { activeRuntimeEnvironmentId: 'focused-env' }, @@ -32,9 +32,7 @@ describe('splitWorktreeSortOrderByHost', () => { }, ['legacy::wt'] ) - expect(groups).toEqual([ - { hostId: 'runtime:focused-env', orderedIds: ['legacy::wt'], rowHostIds: ['local'] } - ]) + expect(groups).toEqual([{ hostId: 'local', orderedIds: ['legacy::wt'], rowHostIds: ['local'] }]) }) it('leaves out a twin and a row only a repo row places on a server', () => { diff --git a/src/renderer/src/store/slices/browser-remote-page-lifecycle.test.ts b/src/renderer/src/store/slices/browser-remote-page-lifecycle.test.ts index 6d0631dd960..57da462aeac 100644 --- a/src/renderer/src/store/slices/browser-remote-page-lifecycle.test.ts +++ b/src/renderer/src/store/slices/browser-remote-page-lifecycle.test.ts @@ -5,6 +5,7 @@ import { resetBrowserRuntimeMocks, settingsWithRuntime } from './browser-slice-test-harness' +import { makeWorktree } from './worktrees-slice-test-fixtures' const createWebRuntimeSessionBrowserTabMock = vi.hoisted(() => vi.fn()) const runtimeEnvironmentCall = vi.fn() @@ -36,6 +37,10 @@ describe('createBrowserSlice runtime guard', () => { const store = createTestStore() store.setState({ settings: settingsWithRuntime('env-1'), + // The client stamps a server's rows on arrival; focus alone never names the owner. + worktreesByRepo: { + 'repo-1': [makeWorktree({ id: 'wt-1', repoId: 'repo-1', hostId: 'runtime:env-1' })] + }, unifiedTabsByWorktree: {}, browserTabsByWorktree: { 'wt-1': [ diff --git a/src/renderer/src/store/slices/terminal-tab-retirement.test.ts b/src/renderer/src/store/slices/terminal-tab-retirement.test.ts index 6c0446c8590..90a8f5ba369 100644 --- a/src/renderer/src/store/slices/terminal-tab-retirement.test.ts +++ b/src/renderer/src/store/slices/terminal-tab-retirement.test.ts @@ -157,8 +157,8 @@ describe('terminal tab retirement planning', () => { settings: { activeRuntimeEnvironmentId: 'env-1' }, worktreesByRepo: { repo: [ - { id: 'wt-1', repoId: 'repo' }, - { id: 'wt-2', repoId: 'repo' } + { id: 'wt-1', repoId: 'repo', hostId: 'runtime:env-1' }, + { id: 'wt-2', repoId: 'repo', hostId: 'runtime:env-1' } ] }, tabsByWorktree: { @@ -176,7 +176,7 @@ describe('terminal tab retirement planning', () => { it('deduplicates legacy and scoped aliases owned by the closing tab', () => { const state = makeState({ settings: { activeRuntimeEnvironmentId: 'env-1' }, - worktreesByRepo: { repo: [{ id: 'wt-1', repoId: 'repo' }] }, + worktreesByRepo: { repo: [{ id: 'wt-1', repoId: 'repo', hostId: 'runtime:env-1' }] }, tabsByWorktree: { 'wt-1': [makeTab('tab-1', 'wt-1', 'remote:terminal-1')] }, diff --git a/src/renderer/src/store/slices/worktrees/teardown/host-qualified-worktree-removal.ts b/src/renderer/src/store/slices/worktrees/teardown/host-qualified-worktree-removal.ts index cb76db2b3fb..30bfa94883f 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/host-qualified-worktree-removal.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/host-qualified-worktree-removal.ts @@ -12,10 +12,10 @@ import type { RuntimeClientTarget } from '../../../../../../shared/runtime-clien import type { ExecutionHostId } from '../../../../../../shared/execution-host' import { getWorktreeOperationOwnerHostIds, + resolveWorktreeOperationRoute, resolveWorktreeOperationRouteForHost, type WorktreeOperationRoute } from '@/lib/worktree-operation-route' -import { resolveWriteRoute } from '@/lib/resolve-owner' import { captureWorktreeOperationGenerationGuard } from '@/lib/worktree-operation-generation' import { getRepoIdFromWorktreeId } from '../../worktree-helpers' import { appliedWorktreeCatalogVersionPatch } from '../listing/worktree-catalog-version-state' @@ -51,7 +51,7 @@ function resolveHostQualifiedRemovalRoute( ): WorktreeOperationRoute | null { return requiredExecutionHostId ? resolveWorktreeOperationRouteForHost(get(), worktreeId, requiredExecutionHostId) - : resolveWriteRoute(get(), worktreeId) + : resolveWorktreeOperationRoute(get(), worktreeId) } export type HostQualifiedRemovalStart = diff --git a/src/renderer/src/store/slices/worktrees/teardown/remove-worktree.ts b/src/renderer/src/store/slices/worktrees/teardown/remove-worktree.ts index 5bdbd484509..29c230be14c 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/remove-worktree.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/remove-worktree.ts @@ -9,8 +9,10 @@ import { forgetHugeRepoWarningDismissalsForWorktrees } from '@/lib/source-contro import { forgetWorktreeSleepIntent } from '@/lib/worktree-sleep-intent' import { readIpcErrorDetail } from '@/lib/ipc-error' import { isArchiveHookRemovalError } from '../../../../../../shared/worktree/archive-hook-removal-gate' -import { resolveWorktreeOperationRouteResultForHost } from '@/lib/worktree-operation-route' -import { resolveWriteRouteResult } from '@/lib/resolve-owner' +import { + resolveWorktreeOperationRouteResult, + resolveWorktreeOperationRouteResultForHost +} from '@/lib/worktree-operation-route' import { beginHostQualifiedRemoval, completeSameIdHostScopedRemoval, @@ -139,7 +141,7 @@ export function createRemoveWorktree( // Missing means stale mirror; ambiguous or changed ownership must fail closed. const currentResolution = requiredExecutionHostId ? resolveWorktreeOperationRouteResultForHost(get(), worktreeId, requiredExecutionHostId) - : resolveWriteRouteResult(get(), worktreeId) + : resolveWorktreeOperationRouteResult(get(), worktreeId) if (currentResolution.kind === 'ambiguous') { throw error }