From ffda00ec66b06703405425549ef67c328dae2539 Mon Sep 17 00:00:00 2001 From: Wooseong Kim Date: Thu, 1 Oct 2026 20:15:24 +0900 Subject: [PATCH 1/2] fix(sidebar): place a runtime-stamped project group on its own host A connectionless executionHostId is the group's host. Placement still fell back to the focused server, so the group moved when focus changed. Visibility already honored the stamp. Fixes #13944 --- .../sidebar/folder-workspace-host-id.ts | 22 ++- .../host-section-rows.runtime-group.test.ts | 182 ++++++++++++++++++ .../components/sidebar/host-section-rows.ts | 23 ++- 3 files changed, 216 insertions(+), 11 deletions(-) create mode 100644 src/renderer/src/components/sidebar/host-section-rows.runtime-group.test.ts diff --git a/src/renderer/src/components/sidebar/folder-workspace-host-id.ts b/src/renderer/src/components/sidebar/folder-workspace-host-id.ts index a29a0e9baf53..0b1f63212945 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-host-id.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-host-id.ts @@ -1,21 +1,23 @@ -import { toSshExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' +import type { ExecutionHostId } from '../../../../shared/execution-host' import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' import type { ProjectGroup } from '../../../../shared/project-group-types' +import { getFolderWorkspaceExecutionHostIdForRows } from './worktree-list/listing/host-filtering' /** * Which host section a folder workspace's row belongs to. * - * Single source of truth: header counting, host bucketing and reveal all resolve - * the host through here. Two resolvers that drift is how folder workspaces went - * missing from the sidebar in the first place (#15362). Deliberately distinct - * from the host *filter* resolver, which also consults executionHostId — this - * one must match where the row actually renders. + * Header counting, host bucketing and reveal all resolve the host through here + * (#15362). This matches the visibility filter: a connectionless runtime stamp + * is the group's host, not whichever server happens to be focused (#13944). */ export function getFolderWorkspaceHostId( - folderWorkspace: Pick, - projectGroup: Pick, + folderWorkspace: Pick, + projectGroup: Pick, defaultHostId: ExecutionHostId ): ExecutionHostId { - const connectionId = folderWorkspace.connectionId ?? projectGroup.connectionId - return connectionId ? toSshExecutionHostId(connectionId) : defaultHostId + return getFolderWorkspaceExecutionHostIdForRows({ + folderWorkspace, + projectGroup, + defaultHostId + }) } diff --git a/src/renderer/src/components/sidebar/host-section-rows.runtime-group.test.ts b/src/renderer/src/components/sidebar/host-section-rows.runtime-group.test.ts new file mode 100644 index 000000000000..1d2184e807d9 --- /dev/null +++ b/src/renderer/src/components/sidebar/host-section-rows.runtime-group.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, it } from 'vitest' +import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' +import type { ExecutionHostId } from '../../../../shared/execution-host' +import type { ProjectGroup } from '../../../../shared/project-group-types' +import type { Repo } from '../../../../shared/repo-types' +import type { Worktree } from '../../../../shared/worktree/types' +import { addHostSectionRows, type HostSectionOption } from './host-section-rows' +import type { Row } from './worktree-list/grouping/row-types' + +const FOCUSED: ExecutionHostId = 'runtime:env-1' +const OWNER: ExecutionHostId = 'runtime:env-2' + +function repo(id: string, executionHostId: ExecutionHostId): Repo { + return { + id, + path: `/${id}`, + displayName: id, + badgeColor: '#000000', + addedAt: 0, + connectionId: null, + executionHostId + } +} + +function item(id: string, project: Repo): Extract { + const worktree: Worktree = { + id, + repoId: project.id, + path: `/${project.id}/${id}`, + branch: `refs/heads/${id}`, + head: 'abc123', + isBare: false, + isMainWorktree: false, + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + comment: '', + isUnread: false, + isPinned: false, + displayName: id, + sortOrder: 0, + lastActivityAt: 0 + } + return { + type: 'item', + rowKey: `repo:${project.id}:${id}`, + sectionKey: `repo:${project.id}`, + worktree, + repo: project, + depth: 0, + groupDepth: 0, + lineageTrail: [], + isLastLineageChild: true, + lineageChildCount: 0 + } +} + +function projectGroup( + executionHostId: ExecutionHostId | null, + connectionId: string | null +): ProjectGroup { + return { + id: 'group-a', + name: 'Server group', + parentPath: '/srv/project', + connectionId, + executionHostId, + parentGroupId: null, + createdFrom: 'manual', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1 + } +} + +function groupHeader(group: ProjectGroup): Extract { + return { + type: 'header', + key: `group:${group.id}`, + label: group.name, + count: 1, + tone: 'text-foreground', + projectGroup: group + } +} + +function folderRow(group: ProjectGroup): Extract { + const folderWorkspace: FolderWorkspace = { + id: 'folder-1', + projectGroupId: group.id, + name: 'Folder workspace', + folderPath: '/srv/project', + connectionId: null, + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 1, + createdAt: 1, + updatedAt: 1 + } + return { + type: 'folder-workspace', + key: 'folder-workspace:folder-1', + folderWorkspace, + projectGroup: group, + depth: 0, + groupDepth: 0 + } +} + +const hostOptions: HostSectionOption[] = [ + { id: FOCUSED, kind: 'runtime', label: 'env-1', detail: 'Orca server', health: 'available' }, + { id: OWNER, kind: 'runtime', label: 'env-2', detail: 'Orca server', health: 'available' } +] + +function sectioned(rows: Row[]) { + return addHostSectionRows({ + rows, + hostOptions, + workspaceHostScope: 'all', + defaultHostId: FOCUSED + }) +} + +function rowKey(row: { type: string; key?: string; worktree?: { id: string } }): string { + return row.type === 'item' ? row.worktree!.id : (row.key ?? row.type) +} + +describe('runtime-stamped project groups', () => { + it('keeps a connectionless runtime group under its owner when another server is focused', () => { + const focusedRepo = repo('focused-project', FOCUSED) + const ownerRepo = repo('owner-project', OWNER) + const rows = sectioned([ + groupHeader(projectGroup(OWNER, null)), + item('focused-wt', focusedRepo), + item('owner-wt', ownerRepo) + ]) + + expect(rows.map(rowKey)).toEqual([ + 'host:runtime:env-1', + 'focused-wt', + 'host:runtime:env-2', + 'group:group-a', + 'owner-wt' + ]) + }) + + it('places a connectionless folder workspace under the stamped group host', () => { + const focusedRepo = repo('focused-project', FOCUSED) + const rows = sectioned([item('focused-wt', focusedRepo), folderRow(projectGroup(OWNER, null))]) + + expect(rows.map(rowKey)).toEqual([ + 'host:runtime:env-1', + 'focused-wt', + 'host:runtime:env-2', + 'folder-workspace:folder-1' + ]) + }) + + it('keeps an unstamped project group header buffered across host runs', () => { + const rows = sectioned([ + groupHeader(projectGroup(null, null)), + item('owner-wt', repo('owner-project', OWNER)), + item('focused-wt', repo('focused-project', FOCUSED)) + ]) + + expect(rows.map(rowKey)).toEqual([ + 'host:runtime:env-1', + 'group:group-a', + 'focused-wt', + 'host:runtime:env-2', + 'group:group-a', + 'owner-wt' + ]) + }) +}) diff --git a/src/renderer/src/components/sidebar/host-section-rows.ts b/src/renderer/src/components/sidebar/host-section-rows.ts index a9b96eb4d3d7..cd4026ef2f18 100644 --- a/src/renderer/src/components/sidebar/host-section-rows.ts +++ b/src/renderer/src/components/sidebar/host-section-rows.ts @@ -4,6 +4,7 @@ import { getLocalExecutionHostLabel, getRepoExecutionHostId, getWorktreeExecutionHostId, + normalizeExecutionHostId, type ExecutionHostId, type ExecutionHostKind, type ExecutionHostScope @@ -14,6 +15,7 @@ import type { SshConnectionStatus } from '../../../../shared/ssh-types' import type { Repo } from '../../../../shared/repo-types' import type { Row } from './worktree-list/grouping/row-types' import { getFolderWorkspaceHostId } from './folder-workspace-host-id' +import { getProjectGroupExecutionHostIdForRows } from './worktree-list/listing/host-filtering' export type HostHeaderRow = { type: 'host-header' @@ -55,6 +57,25 @@ function getRepoHostId( return defaultHostId } +function getHeaderHostId( + row: Extract, + defaultHostId: ExecutionHostId +): ExecutionHostId | null { + if (row.repo) { + return getRepoHostId(row.repo, defaultHostId) + } + const projectGroup = row.projectGroup + if (!projectGroup || projectGroup.id === null) { + return null + } + // Why null: Ungrouped, All, and Pinned have no host stamp and stay buffered. + // A stamped group follows that host instead of the focused server (#13944). + if (!normalizeExecutionHostId(projectGroup.executionHostId) && !projectGroup.connectionId) { + return null + } + return getProjectGroupExecutionHostIdForRows(projectGroup, defaultHostId) +} + function getRowHostId(row: Row, defaultHostId: ExecutionHostId): ExecutionHostId | null { switch (row.type) { case 'item': @@ -66,7 +87,7 @@ function getRowHostId(row: Row, defaultHostId: ExecutionHostId): ExecutionHostId case 'folder-workspace': return getFolderWorkspaceHostId(row.folderWorkspace, row.projectGroup, defaultHostId) case 'header': - return row.repo ? getRepoHostId(row.repo, defaultHostId) : null + return getHeaderHostId(row, defaultHostId) } } From af9ecb1a32c329c42d012d58f38b73d2d6d4cbc3 Mon Sep 17 00:00:00 2001 From: Wooseong Kim Date: Thu, 1 Oct 2026 22:08:12 +0900 Subject: [PATCH 2/2] test(sidebar): cover a runtime project group on the sidebar host filter The host-section cases skipped the project-grouping flags the sidebar passes, so they could pass without the filtered view placing the group under its owner. Co-authored-by: Cursor --- .../host-section-rows.runtime-group.test.ts | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/src/renderer/src/components/sidebar/host-section-rows.runtime-group.test.ts b/src/renderer/src/components/sidebar/host-section-rows.runtime-group.test.ts index 1d2184e807d9..bad1e2b0fb8b 100644 --- a/src/renderer/src/components/sidebar/host-section-rows.runtime-group.test.ts +++ b/src/renderer/src/components/sidebar/host-section-rows.runtime-group.test.ts @@ -128,6 +128,17 @@ function sectioned(rows: Row[]) { }) } +function renderedHostFilter(rows: Row[]) { + return addHostSectionRows({ + rows, + hostOptions, + workspaceHostScope: 'all', + visibleWorkspaceHostIds: [FOCUSED, OWNER], + defaultHostId: FOCUSED, + preferProjectGrouping: true + }) +} + function rowKey(row: { type: string; key?: string; worktree?: { id: string } }): string { return row.type === 'item' ? row.worktree!.id : (row.key ?? row.type) } @@ -179,4 +190,36 @@ describe('runtime-stamped project groups', () => { 'owner-wt' ]) }) + + it('keeps a runtime-stamped group under its owner when the sidebar filters to both hosts', () => { + const rows = renderedHostFilter([ + groupHeader(projectGroup(OWNER, null)), + item('owner-wt', repo('owner-project', OWNER)), + item('focused-wt', repo('focused-project', FOCUSED)) + ]) + + expect(rows.map(rowKey)).toEqual([ + 'host:runtime:env-1', + 'focused-wt', + 'host:runtime:env-2', + 'group:group-a', + 'owner-wt' + ]) + }) + + it('does not add host headers in the unfiltered projects view', () => { + const rows = addHostSectionRows({ + rows: [ + groupHeader(projectGroup(OWNER, null)), + item('owner-wt', repo('owner-project', OWNER)) + ], + hostOptions, + workspaceHostScope: 'all', + visibleWorkspaceHostIds: null, + defaultHostId: FOCUSED, + preferProjectGrouping: true + }) + + expect(rows.map(rowKey)).toEqual(['group:group-a', 'owner-wt']) + }) })