From a64895ca0c4fb3713cfe7047fd65a90b779c641d Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Tue, 1 Sep 2026 21:23:29 +0800 Subject: [PATCH] fix(web): distinguish remote workspace navigation targets --- .../workspaces/WorkspaceListSection.tsx | 10 +---- .../sessionNavigationProjection.test.ts | 40 ++++++++++++++++++- .../NavPanel/sessionNavigationProjection.ts | 24 ++++++++++- 3 files changed, 64 insertions(+), 10 deletions(-) diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSection.tsx b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSection.tsx index 4a8e268b1a..ba24103c5d 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSection.tsx +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSection.tsx @@ -5,9 +5,9 @@ import { notificationService } from '@/shared/notification-system'; import WorkspaceItem from './WorkspaceItem'; import SessionsSection, { type WorkspaceSessionScope } from '../sessions/SessionsSection'; import { isRemoteWorkspace } from '@/shared/types'; -import { isSamePath } from '@/shared/utils/pathUtils'; import { useWorkspaceSessionViewStore } from '../../workspaceSessionView'; import { + isWorkspaceBackedSessionGroupActive, projectWorkspaceBackedSessionGroups, type SessionNavigationScope, } from '../../sessionNavigationProjection'; @@ -65,9 +65,6 @@ const WorkspaceListSection: React.FC = ({ variant }) [sessionGroups], ); const activeWorkspace = openedWorkspacesList.find(workspace => workspace.id === activeWorkspaceId); - const activeProjectPath = activeWorkspace?.worktree && !activeWorkspace.worktree.isMain - ? activeWorkspace.worktree.mainRepoPath - : activeWorkspace?.rootPath; const emptyLabel = variant === 'assistants' ? t('nav.workspaces.emptyAssistants') : variant === 'projects' @@ -289,10 +286,7 @@ const WorkspaceListSection: React.FC = ({ variant }) > 1} isDragging={draggedWorkspaceId === workspace.id} diff --git a/src/web-ui/src/app/components/NavPanel/sessionNavigationProjection.test.ts b/src/web-ui/src/app/components/NavPanel/sessionNavigationProjection.test.ts index 9ddf29d8a5..d30ea3b59c 100644 --- a/src/web-ui/src/app/components/NavPanel/sessionNavigationProjection.test.ts +++ b/src/web-ui/src/app/components/NavPanel/sessionNavigationProjection.test.ts @@ -4,7 +4,10 @@ import { WorkspaceType, type WorkspaceInfo, } from '@/shared/types'; -import { projectWorkspaceBackedSessionGroups } from './sessionNavigationProjection'; +import { + isWorkspaceBackedSessionGroupActive, + projectWorkspaceBackedSessionGroups, +} from './sessionNavigationProjection'; const createWorkspace = ( id: string, @@ -88,3 +91,38 @@ describe('projectWorkspaceBackedSessionGroups', () => { ).map(group => group.workspace.id)).toEqual(['linked-worktree']); }); }); + +describe('isWorkspaceBackedSessionGroupActive', () => { + it('distinguishes identical paths opened through different remote connections', () => { + const firstRemote = createWorkspace('remote-first', WorkspaceKind.Remote, { + rootPath: '/workspace', + connectionId: 'ssh-first', + sshHost: 'host-a.example', + }); + const secondRemote = createWorkspace('remote-second', WorkspaceKind.Remote, { + rootPath: '/workspace', + connectionId: 'ssh-second', + sshHost: 'host-b.example', + }); + + expect(isWorkspaceBackedSessionGroupActive(firstRemote, firstRemote)).toBe(true); + expect(isWorkspaceBackedSessionGroupActive(secondRemote, firstRemote)).toBe(false); + }); + + it('keeps the canonical local project active for its selected worktree', () => { + const canonicalProject = createWorkspace('canonical-project', WorkspaceKind.Normal, { + rootPath: '/repo', + }); + const linkedWorktree = createWorkspace('linked-worktree', WorkspaceKind.Normal, { + rootPath: '/repo/.worktrees/feature', + worktree: { + path: '/repo/.worktrees/feature', + mainRepoPath: '/repo', + branch: 'feature', + isMain: false, + }, + }); + + expect(isWorkspaceBackedSessionGroupActive(canonicalProject, linkedWorktree)).toBe(true); + }); +}); diff --git a/src/web-ui/src/app/components/NavPanel/sessionNavigationProjection.ts b/src/web-ui/src/app/components/NavPanel/sessionNavigationProjection.ts index e733e0cd03..2f5b9ab71f 100644 --- a/src/web-ui/src/app/components/NavPanel/sessionNavigationProjection.ts +++ b/src/web-ui/src/app/components/NavPanel/sessionNavigationProjection.ts @@ -1,5 +1,5 @@ import type { WorkspaceInfo } from '@/shared/types'; -import { isLinkedWorktreeWorkspace } from '@/shared/types'; +import { isLinkedWorktreeWorkspace, isRemoteWorkspace } from '@/shared/types'; import { isSamePath } from '@/shared/utils/pathUtils'; export type SessionNavigationScope = 'all' | 'assistants' | 'projects'; @@ -19,6 +19,28 @@ export interface WorkspaceBackedSessionGroup { workspace: WorkspaceInfo; } +/** + * Resolve the active sidebar group without treating a remote POSIX path as a + * workspace identity. The same path can be open on multiple remote hosts, so + * remote workspaces must match by their stable workspace id. Local path + * matching remains available for a linked worktree whose canonical project is + * the visible navigation group. + */ +export function isWorkspaceBackedSessionGroupActive( + workspace: WorkspaceInfo, + activeWorkspace: WorkspaceInfo | null | undefined, +): boolean { + if (!activeWorkspace) return false; + if (workspace.id === activeWorkspace.id) return true; + if (isRemoteWorkspace(workspace) || isRemoteWorkspace(activeWorkspace)) return false; + + const activeProjectPath = activeWorkspace.worktree && !activeWorkspace.worktree.isMain + ? activeWorkspace.worktree.mainRepoPath + : activeWorkspace.rootPath; + + return Boolean(activeProjectPath && isSamePath(workspace.rootPath, activeProjectPath)); +} + const isWorkspaceInScope = ( workspace: WorkspaceInfo, scope: SessionNavigationScope,