diff --git a/src/main/skills/pane-skill-discovery-workspace.test.ts b/src/main/skills/pane-skill-discovery-workspace.test.ts index 5cf53db474e..db546f4216a 100644 --- a/src/main/skills/pane-skill-discovery-workspace.test.ts +++ b/src/main/skills/pane-skill-discovery-workspace.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from 'vitest' -import type { FolderWorkspace, ProjectGroup, Repo, WorkspaceSessionState } from '../../shared/types' +import type { + FolderWorkspace, + ProjectGroup, + Repo, + WorktreeMeta, + WorkspaceSessionState +} from '../../shared/types' import { resolvePaneSkillDiscoveryWorkspace } from './pane-skill-discovery-workspace' const EMPTY_SESSION = { tabsByWorktree: {} } as WorkspaceSessionState @@ -16,6 +22,22 @@ function repo(overrides: Partial = {}): Repo { } } +function worktreeMeta(hostId: WorktreeMeta['hostId']): WorktreeMeta { + return { + hostId, + displayName: 'repo', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 1 + } +} + function session(worktreeId: string, startupCwd?: string): WorkspaceSessionState { return { tabsByWorktree: { @@ -42,6 +64,63 @@ describe('resolvePaneSkillDiscoveryWorkspace', () => { ).toEqual({ connectionId: 'target-1', cwd: '/remote/repo/packages/app' }) }) + it('accepts the legacy local mirror of an authoritative SSH pane session', () => { + const worktreeId = 'repo-1::/work/demo-project' + + expect( + resolvePaneSkillDiscoveryWorkspace({ + worktreeId, + terminalTabId: 'tab-1', + repos: [repo({ path: '/work/demo-project' })], + projectGroups: [], + folderWorkspaces: [], + worktreeMeta: worktreeMeta('ssh:target-1'), + sessions: [ + { hostId: 'local', session: session(worktreeId, '/work/demo-project') }, + { hostId: 'ssh:target-1', session: session(worktreeId, '/work/demo-project') } + ] + }) + ).toEqual({ connectionId: 'target-1', cwd: '/work/demo-project' }) + }) + + it('rejects duplicate pane sessions on distinct non-local hosts', () => { + const worktreeId = 'repo-1::/remote/repo' + + expect(() => + resolvePaneSkillDiscoveryWorkspace({ + worktreeId, + terminalTabId: 'tab-1', + repos: [repo()], + projectGroups: [], + folderWorkspaces: [], + worktreeMeta: worktreeMeta('ssh:target-1'), + sessions: [ + { hostId: 'ssh:target-1', session: session(worktreeId) }, + { hostId: 'ssh:target-2', session: session(worktreeId) } + ] + }) + ).toThrow('pane_skill_discovery_owner_ambiguous') + }) + + it('rejects a legacy local pane that does not mirror the SSH startup directory', () => { + const worktreeId = 'repo-1::/remote/repo' + + expect(() => + resolvePaneSkillDiscoveryWorkspace({ + worktreeId, + terminalTabId: 'tab-1', + repos: [repo()], + projectGroups: [], + folderWorkspaces: [], + worktreeMeta: worktreeMeta('ssh:target-1'), + sessions: [ + { hostId: 'local', session: session(worktreeId, '/local/repo') }, + { hostId: 'ssh:target-1', session: session(worktreeId, '/remote/repo') } + ] + }) + ).toThrow('pane_skill_discovery_owner_ambiguous') + }) + it('routes an executionHostId-only SSH repo instead of falling back locally', () => { expect( resolvePaneSkillDiscoveryWorkspace({ diff --git a/src/main/skills/pane-skill-discovery-workspace.ts b/src/main/skills/pane-skill-discovery-workspace.ts index 8ef32d8a7a4..4cef5a3a70b 100644 --- a/src/main/skills/pane-skill-discovery-workspace.ts +++ b/src/main/skills/pane-skill-discovery-workspace.ts @@ -38,9 +38,9 @@ export function resolvePaneSkillDiscoveryWorkspace( ): PaneSkillDiscoveryWorkspace { const scope = parseWorkspaceKey(args.worktreeId) const worktreeId = scope?.type === 'worktree' ? scope.worktreeId : args.worktreeId - const sessionOwner = findPaneSession(args.sessions, worktreeId, args.terminalTabId) if (scope?.type === 'folder') { + const sessionOwner = findPaneSession(args.sessions, worktreeId, args.terminalTabId) const workspace = selectFolderWorkspace( args.folderWorkspaces.filter((candidate) => candidate.id === scope.folderWorkspaceId), sessionOwner?.hostId @@ -72,6 +72,7 @@ export function resolvePaneSkillDiscoveryWorkspace( throw new Error('pane_skill_discovery_workspace_not_found') } const metaHostId = parseOptionalHostId(args.worktreeMeta?.hostId) + const sessionOwner = findPaneSession(args.sessions, worktreeId, args.terminalTabId, metaHostId) assertMatchingHost(sessionOwner?.hostId, metaHostId) const knownHostId = sessionOwner?.hostId ?? metaHostId const repo = selectPaneRepo(candidates, knownHostId) @@ -171,7 +172,8 @@ function declaredHostId(owner: { function findPaneSession( sessions: readonly HostSession[], worktreeId: string, - terminalTabId: string | undefined + terminalTabId: string | undefined, + authoritativeHostId?: ExecutionHostId | null ): { hostId: ExecutionHostId; tab: { startupCwd?: string } } | null { if (!terminalTabId) { return null @@ -186,11 +188,35 @@ function findPaneSession( ) ) if (matches.length > 1) { + const authoritativeMatches = authoritativeHostId + ? matches.filter((match) => match.hostId === authoritativeHostId) + : [] + const authoritativeMatch = authoritativeMatches.length === 1 ? authoritativeMatches[0] : null + // Why: save compatibility mirrors remote tabs locally; only matching local rows yield to persisted ownership. + if ( + authoritativeHostId !== LOCAL_EXECUTION_HOST_ID && + authoritativeMatch && + matches.every( + (match) => + match === authoritativeMatch || + (match.hostId === LOCAL_EXECUTION_HOST_ID && + paneSessionsMirror(match.tab, authoritativeMatch.tab)) + ) + ) { + return authoritativeMatch + } throw new Error('pane_skill_discovery_owner_ambiguous') } return matches[0] ?? null } +function paneSessionsMirror( + left: { startupCwd?: string }, + right: { startupCwd?: string } +): boolean { + return (left.startupCwd?.trim() || null) === (right.startupCwd?.trim() || null) +} + function selectPaneRepo(candidates: readonly Repo[], hostId: ExecutionHostId | null): Repo { if (!hostId) { if (candidates.length !== 1) {