From 12a4d31cee53ddd4bdafb04ee4b162ce59191ce3 Mon Sep 17 00:00:00 2001 From: jbamlee Date: Sat, 17 Oct 2026 07:49:12 +0900 Subject: [PATCH 1/4] fix(sidebar): place a multi-host project in the group any of its rows is in A project set up on several hosts renders as one section whose anchor is the first of its rows in this Orca's catalog order, and the section was placed in a Project Group by the anchor's projectGroupId only. Each Orca anchors on its own host's row, so after Move to group on one Orca, every other Orca showing the same project kept it outside the group, next to an empty group header. Resolve the section's group from its rows: the anchor's group when it is known, otherwise the first row in manual order that is in a known group. Placement, the in-group order, and the reveal path all use that row. Co-Authored-By: Claude Opus 5.5 --- ...st-groups-cross-host-project-group.test.ts | 164 ++++++++++++++++++ .../worktree-list/grouping/build-rows.ts | 11 +- .../grouping/project-group-sections.ts | 43 ++++- 3 files changed, 209 insertions(+), 9 deletions(-) create mode 100644 src/renderer/src/components/sidebar/worktree-list-groups-cross-host-project-group.test.ts diff --git a/src/renderer/src/components/sidebar/worktree-list-groups-cross-host-project-group.test.ts b/src/renderer/src/components/sidebar/worktree-list-groups-cross-host-project-group.test.ts new file mode 100644 index 000000000000..e97f3566ad69 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list-groups-cross-host-project-group.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it } from 'vitest' +import { buildSidebarRowPlan } from './worktree-list/grouping/build-rows' +import { repo, worktree, project } from './worktree-list-groups-test-fixtures' +import type { ProjectGroup } from '../../../../shared/project-group-types' +import type { ProjectHostSetup } from '../../../../shared/project-types' +import type { Repo } from '../../../../shared/repo-types' +import type { Worktree } from '../../../../shared/worktree/types' + +// One durable project set up on this Orca's own host and on a paired host; the paired host's +// row was moved into that host's group from the paired host's own window. +const pairedHostId = 'runtime:laptop-a' +const pairedGroup: ProjectGroup = { + id: 'group-a', + name: 'Laptop A', + parentPath: null, + parentGroupId: null, + executionHostId: pairedHostId, + createdFrom: 'manual', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1 +} +const localGroup: ProjectGroup = { + ...pairedGroup, + id: 'group-local', + name: 'This Mac', + executionHostId: 'local', + tabOrder: 1 +} +const localRepo: Repo = { ...repo, projectGroupId: null } +const pairedRepo: Repo = { + ...repo, + id: 'repo-a', + path: '/Users/a/orca', + executionHostId: pairedHostId, + projectGroupId: pairedGroup.id, + projectGroupOrder: 0 +} +const localWorktree: Worktree = { ...worktree, lastActivityAt: 2 } +const pairedWorktree: Worktree = { + ...worktree, + id: 'wt-a', + repoId: pairedRepo.id, + path: '/Users/a/orca-feature', + hostId: pairedHostId, + lastActivityAt: 1 +} + +// The paired host's own Orca lists its row first; this Orca lists its local row first. +const PAIRED_HOST_ORDER = new Map([ + [pairedRepo.id, 0], + [localRepo.id, 1] +]) +const THIS_ORCA_ORDER = new Map([ + [localRepo.id, 0], + [pairedRepo.id, 1] +]) + +function planRows(args: { + repoOrder: Map + local?: Repo + paired?: Repo + groups?: ProjectGroup[] +}) { + const local = args.local ?? localRepo + const paired = args.paired ?? pairedRepo + const worktrees = [localWorktree, pairedWorktree] + const setups: ProjectHostSetup[] = [local, paired].map((entry) => ({ + id: entry.id, + projectId: project.id, + hostId: entry.executionHostId ?? 'local', + repoId: entry.id, + path: entry.path, + displayName: entry.displayName, + setupState: 'ready', + setupMethod: 'legacy-repo', + createdAt: 1, + updatedAt: 1 + })) + return buildSidebarRowPlan( + 'repo', + worktrees, + new Map([ + [local.id, local], + [paired.id, paired] + ]), + null, + new Set(), + args.repoOrder, + undefined, + 'manual', + {}, + new Map(worktrees.map((entry) => [entry.id, entry])), + false, + undefined, + args.groups ?? [pairedGroup], + new Set(), + new Map(), + new Map(), + [], + { projects: [project], projectHostSetups: setups } + ) +} + +function headerKeys(args: Parameters[0]): string[] { + return planRows(args) + .render(new Set()) + .filter((row) => row.type === 'header') + .map((row) => `${row.key}@${row.projectGroupDepth ?? 0}`) +} + +describe('a project grouped on one of its hosts', () => { + it('lands in the same group whichever host row anchors the merged project header', () => { + const onPairedHost = headerKeys({ repoOrder: PAIRED_HOST_ORDER }) + const onThisOrca = headerKeys({ repoOrder: THIS_ORCA_ORDER }) + + expect(onPairedHost).toEqual(['project-group:group-a@0', 'project:github:stablyai/orca@1']) + expect(onThisOrca).toEqual(onPairedHost) + }) + + it('reveals the project through the group it is drawn in', () => { + const section = planRows({ repoOrder: THIS_ORCA_ORDER }).occurrencePlan.sections.find( + (entry) => entry.key === `project:${project.id}` + ) + + expect(section?.revealPath?.parent?.key).toBe('project-group:group-a') + }) + + it("keeps the anchor's own group when the hosts' rows sit in different groups", () => { + const groupedLocal: Repo = { ...localRepo, projectGroupId: localGroup.id, projectGroupOrder: 0 } + + expect( + headerKeys({ + repoOrder: THIS_ORCA_ORDER, + local: groupedLocal, + groups: [pairedGroup, localGroup] + }) + ).toEqual([ + 'project-group:group-a@0', + 'project-group:group-local@0', + 'project:github:stablyai/orca@1' + ]) + const rows = planRows({ + repoOrder: THIS_ORCA_ORDER, + local: groupedLocal, + groups: [pairedGroup, localGroup] + }).render(new Set()) + const projectIndex = rows.findIndex( + (row) => row.type === 'header' && row.key === `project:${project.id}` + ) + const localGroupIndex = rows.findIndex( + (row) => row.type === 'header' && row.key === 'project-group:group-local' + ) + expect(projectIndex).toBeGreaterThan(localGroupIndex) + }) + + it('stays at the root while the paired group metadata has not arrived', () => { + expect(headerKeys({ repoOrder: THIS_ORCA_ORDER, groups: [] })).toEqual([ + 'project:github:stablyai/orca@0' + ]) + }) +}) diff --git a/src/renderer/src/components/sidebar/worktree-list/grouping/build-rows.ts b/src/renderer/src/components/sidebar/worktree-list/grouping/build-rows.ts index 6e96427562c6..d6258b1ad981 100644 --- a/src/renderer/src/components/sidebar/worktree-list/grouping/build-rows.ts +++ b/src/renderer/src/components/sidebar/worktree-list/grouping/build-rows.ts @@ -30,7 +30,8 @@ import type { OrderedGroupEntry, WorktreeGroupEntry } from './worktree-group-ent import type { ProjectGroupingModel } from '../../../../../../shared/project-section-grouping' import { appendProjectGroupSections, - getProjectGroupOccurrencePaths + getProjectGroupOccurrencePaths, + getProjectSectionGroupRepo } from './project-group-sections' import { getSidebarLineageChildren } from '../../pinned-section-worktrees' import { emitPinnedGroup } from './pinned-group-rows' @@ -142,10 +143,12 @@ export function buildSidebarRowPlan( ]) } return groups.map(([key, group]): SidebarOccurrenceSection => { - const revealPath = - groupBy === 'repo' && key !== PINNED_GROUP_KEY && group.repo?.projectGroupId - ? projectGroupPaths.get(group.repo.projectGroupId) + const sectionGroupId = + groupBy === 'repo' && key !== PINNED_GROUP_KEY + ? getProjectSectionGroupRepo(group, repoMap, repoOrder, projectGroupPaths) + ?.projectGroupId : undefined + const revealPath = sectionGroupId ? projectGroupPaths.get(sectionGroupId) : undefined return { key, group, diff --git a/src/renderer/src/components/sidebar/worktree-list/grouping/project-group-sections.ts b/src/renderer/src/components/sidebar/worktree-list/grouping/project-group-sections.ts index e11a640c55f5..23bd67a58b1a 100644 --- a/src/renderer/src/components/sidebar/worktree-list/grouping/project-group-sections.ts +++ b/src/renderer/src/components/sidebar/worktree-list/grouping/project-group-sections.ts @@ -4,12 +4,13 @@ import { type RenderableFolderWorkspace } from './folder-workspace-lanes' import type { ProjectGroup } from '../../../../../../shared/project-group-types' +import type { Repo } from '../../../../../../shared/repo-types' import type { ProjectOrderBy } from '../../../../../../shared/ui-chrome-types' import { getEffectiveProjectGroupManualRank } from '../../../../../../shared/project-groups' import { PROJECT_GROUP_META, getProjectGroupHeaderKey } from './group-keys' import { appendOrderedGroups } from './group-sections' import type { SectionAppendContext } from './group-sections' -import type { OrderedGroupEntry } from './worktree-group-entry' +import type { OrderedGroupEntry, WorktreeGroupEntry } from './worktree-group-entry' import { compareRecentRank, createRecentRankLookup, @@ -17,6 +18,36 @@ import { } from '../../../../../../shared/project-section-order' import { buildFolderWorkspaceRow } from './row-builders' +/** + * The member row that places a section in a Project Group. A project set up on several hosts is + * one section, and each Orca anchors it on its own host's row, so a member in a known group wins + * over an ungrouped anchor; otherwise every other Orca draws the moved project outside its group. + */ +export function getProjectSectionGroupRepo( + section: Pick, + repoMap: ReadonlyMap, + repoOrder: ReadonlyMap | undefined, + knownGroupIds: { has: (groupId: string) => boolean } +): Repo | undefined { + const isGrouped = (repo: Repo | undefined): repo is Repo => + Boolean(repo?.projectGroupId && knownGroupIds.has(repo.projectGroupId)) + const anchor = section.repo + if (isGrouped(anchor)) { + return anchor + } + let grouped: Repo | undefined + let groupedRank = Number.POSITIVE_INFINITY + for (const repoId of section.repoIds) { + const repo = repoMap.get(repoId) + const rank = repoOrder?.get(repoId) ?? Number.POSITIVE_INFINITY + if (isGrouped(repo) && (!grouped || rank < groupedRank)) { + grouped = repo + groupedRank = rank + } + } + return grouped ?? anchor +} + export function appendProjectGroupSections( ctx: SectionAppendContext, args: { @@ -30,9 +61,12 @@ export function appendProjectGroupSections( const { orderedGroups, projectGroups, folderWorkspaces, projectOrderBy, repoOrder } = args const { result, collapsedGroups } = ctx + const projectGroupsById = new Map(projectGroups.map((group) => [group.id, group])) const groupByProjectGroupId = new Map() + const groupRepoByEntry = new Map() for (const entry of orderedGroups) { - const repo = entry[1].repo + const repo = getProjectSectionGroupRepo(entry[1], ctx.repoMap, repoOrder, projectGroupsById) + groupRepoByEntry.set(entry, repo) const projectGroupId = repo?.projectGroupId ?? null const list = groupByProjectGroupId.get(projectGroupId) ?? [] list.push(entry) @@ -53,13 +87,12 @@ export function appendProjectGroupSections( // (projectGroupOrder), falling back to global repoOrder when unset so drag // midpoint commits and the rendered order stay aligned. return [...entries].sort((left, right) => { - const leftRank = getEffectiveProjectGroupManualRank(left[1].repo, repoOrder) - const rightRank = getEffectiveProjectGroupManualRank(right[1].repo, repoOrder) + const leftRank = getEffectiveProjectGroupManualRank(groupRepoByEntry.get(left), repoOrder) + const rightRank = getEffectiveProjectGroupManualRank(groupRepoByEntry.get(right), repoOrder) return leftRank - rightRank }) } - const projectGroupsById = new Map(projectGroups.map((group) => [group.id, group])) // Membership already decided by getRenderableFolderWorkspaces in buildRows, so // repo grouping no longer owns the filter — it only groups and orders (#15362). const folderWorkspacesByProjectGroupId = new Map() From e235f0e3c9df2841295015520df45dd2f323e358 Mon Sep 17 00:00:00 2001 From: jbamlee Date: Sat, 17 Oct 2026 07:58:46 +0900 Subject: [PATCH 2/4] fix(sidebar): resolve a project's group from all its catalog rows The section's visible members miss rows whose workspaces are pinned away or that have none yet, and the Cmd+1-9 order builds rows without the empty-project placeholders the sidebar passes. Pinning the grouped host's last workspace then moved the project back to the root, and shortcut numbering disagreed with the drawn sidebar. Collect every catalog row that maps to the section's key and resolve the group from those. Co-Authored-By: Claude Opus 5.5 --- ...st-groups-cross-host-project-group.test.ts | 29 +++++++++++++-- .../worktree-list/grouping/build-rows.ts | 19 +++++++--- .../grouping/project-group-sections.ts | 36 +++++++++++++++++-- .../recent-project-rank-scan-budget.test.ts | 3 +- 4 files changed, 77 insertions(+), 10 deletions(-) diff --git a/src/renderer/src/components/sidebar/worktree-list-groups-cross-host-project-group.test.ts b/src/renderer/src/components/sidebar/worktree-list-groups-cross-host-project-group.test.ts index e97f3566ad69..678297e87670 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups-cross-host-project-group.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups-cross-host-project-group.test.ts @@ -63,10 +63,12 @@ function planRows(args: { local?: Repo paired?: Repo groups?: ProjectGroup[] + worktrees?: Worktree[] + placeholderRepoIds?: Set }) { const local = args.local ?? localRepo const paired = args.paired ?? pairedRepo - const worktrees = [localWorktree, pairedWorktree] + const worktrees = args.worktrees ?? [localWorktree, pairedWorktree] const setups: ProjectHostSetup[] = [local, paired].map((entry) => ({ id: entry.id, projectId: project.id, @@ -96,7 +98,7 @@ function planRows(args: { false, undefined, args.groups ?? [pairedGroup], - new Set(), + args.placeholderRepoIds ?? new Set(), new Map(), new Map(), [], @@ -161,4 +163,27 @@ describe('a project grouped on one of its hosts', () => { 'project:github:stablyai/orca@0' ]) }) + + it("stays in the group while the grouped host's only workspace is pinned", () => { + const pinned = { ...pairedWorktree, isPinned: true } + + expect( + headerKeys({ repoOrder: THIS_ORCA_ORDER, worktrees: [localWorktree, pinned] }).filter( + (key) => !key.startsWith('pinned') + ) + ).toEqual(['project-group:group-a@0', 'project:github:stablyai/orca@1']) + }) + + it('places the project the same way with or without an empty-project placeholder', () => { + // The mounted sidebar passes empty repos as placeholders; Cmd+1–9 ordering passes none. + const rendered = headerKeys({ + repoOrder: THIS_ORCA_ORDER, + worktrees: [localWorktree], + placeholderRepoIds: new Set([pairedRepo.id]) + }) + const shortcutOrder = headerKeys({ repoOrder: THIS_ORCA_ORDER, worktrees: [localWorktree] }) + + expect(rendered).toEqual(['project-group:group-a@0', 'project:github:stablyai/orca@1']) + expect(shortcutOrder).toEqual(rendered) + }) }) diff --git a/src/renderer/src/components/sidebar/worktree-list/grouping/build-rows.ts b/src/renderer/src/components/sidebar/worktree-list/grouping/build-rows.ts index d6258b1ad981..78d3bd0745fe 100644 --- a/src/renderer/src/components/sidebar/worktree-list/grouping/build-rows.ts +++ b/src/renderer/src/components/sidebar/worktree-list/grouping/build-rows.ts @@ -31,7 +31,8 @@ import type { ProjectGroupingModel } from '../../../../../../shared/project-sect import { appendProjectGroupSections, getProjectGroupOccurrencePaths, - getProjectSectionGroupRepo + getProjectSectionGroupRepo, + getProjectSectionMemberRepoIds } from './project-group-sections' import { getSidebarLineageChildren } from '../../pinned-section-worktrees' import { emitPinnedGroup } from './pinned-group-rows' @@ -99,6 +100,10 @@ export function buildSidebarRowPlan( } const projectGroupPaths = getProjectGroupOccurrencePaths(projectGroups) + const sectionMemberRepoIds = + groupBy === 'repo' + ? getProjectSectionMemberRepoIds(repoMap, projectIndex) + : new Map() const occurrencePlan = buildWorktreeOccurrencePlan({ rows: worktrees, identityOf: getWorktreeHostIdentity, @@ -145,8 +150,13 @@ export function buildSidebarRowPlan( return groups.map(([key, group]): SidebarOccurrenceSection => { const sectionGroupId = groupBy === 'repo' && key !== PINNED_GROUP_KEY - ? getProjectSectionGroupRepo(group, repoMap, repoOrder, projectGroupPaths) - ?.projectGroupId + ? getProjectSectionGroupRepo( + group, + sectionMemberRepoIds.get(key) ?? [], + repoMap, + repoOrder, + projectGroupPaths + )?.projectGroupId : undefined const revealPath = sectionGroupId ? projectGroupPaths.get(sectionGroupId) : undefined return { @@ -290,7 +300,8 @@ export function buildSidebarRowPlan( projectGroups, folderWorkspaces: renderableFolderWorkspaces, projectOrderBy, - repoOrder + repoOrder, + sectionMemberRepoIds }) return result diff --git a/src/renderer/src/components/sidebar/worktree-list/grouping/project-group-sections.ts b/src/renderer/src/components/sidebar/worktree-list/grouping/project-group-sections.ts index 23bd67a58b1a..0c941505c6e1 100644 --- a/src/renderer/src/components/sidebar/worktree-list/grouping/project-group-sections.ts +++ b/src/renderer/src/components/sidebar/worktree-list/grouping/project-group-sections.ts @@ -11,6 +11,10 @@ import { PROJECT_GROUP_META, getProjectGroupHeaderKey } from './group-keys' import { appendOrderedGroups } from './group-sections' import type { SectionAppendContext } from './group-sections' import type { OrderedGroupEntry, WorktreeGroupEntry } from './worktree-group-entry' +import { + getProjectGroupingForRepo, + type ProjectGroupingIndex +} from '../../../../../../shared/project-section-grouping' import { compareRecentRank, createRecentRankLookup, @@ -18,13 +22,32 @@ import { } from '../../../../../../shared/project-section-order' import { buildFolderWorkspaceRow } from './row-builders' +/** Every catalog row of each project section, including rows whose workspaces are pinned or absent. */ +export function getProjectSectionMemberRepoIds( + repoMap: ReadonlyMap, + projectIndex: ProjectGroupingIndex | null +): Map { + const members = new Map() + for (const repoId of repoMap.keys()) { + const key = getProjectGroupingForRepo(repoId, repoMap, projectIndex).key + const list = members.get(key) + if (list) { + list.push(repoId) + } else { + members.set(key, [repoId]) + } + } + return members +} + /** * The member row that places a section in a Project Group. A project set up on several hosts is * one section, and each Orca anchors it on its own host's row, so a member in a known group wins * over an ungrouped anchor; otherwise every other Orca draws the moved project outside its group. */ export function getProjectSectionGroupRepo( - section: Pick, + section: Pick, + memberRepoIds: readonly string[], repoMap: ReadonlyMap, repoOrder: ReadonlyMap | undefined, knownGroupIds: { has: (groupId: string) => boolean } @@ -37,7 +60,7 @@ export function getProjectSectionGroupRepo( } let grouped: Repo | undefined let groupedRank = Number.POSITIVE_INFINITY - for (const repoId of section.repoIds) { + for (const repoId of memberRepoIds) { const repo = repoMap.get(repoId) const rank = repoOrder?.get(repoId) ?? Number.POSITIVE_INFINITY if (isGrouped(repo) && (!grouped || rank < groupedRank)) { @@ -56,6 +79,7 @@ export function appendProjectGroupSections( folderWorkspaces: readonly RenderableFolderWorkspace[] projectOrderBy: ProjectOrderBy repoOrder: Map | undefined + sectionMemberRepoIds: ReadonlyMap } ): void { const { orderedGroups, projectGroups, folderWorkspaces, projectOrderBy, repoOrder } = args @@ -65,7 +89,13 @@ export function appendProjectGroupSections( const groupByProjectGroupId = new Map() const groupRepoByEntry = new Map() for (const entry of orderedGroups) { - const repo = getProjectSectionGroupRepo(entry[1], ctx.repoMap, repoOrder, projectGroupsById) + const repo = getProjectSectionGroupRepo( + entry[1], + args.sectionMemberRepoIds.get(entry[0]) ?? [], + ctx.repoMap, + repoOrder, + projectGroupsById + ) groupRepoByEntry.set(entry, repo) const projectGroupId = repo?.projectGroupId ?? null const list = groupByProjectGroupId.get(projectGroupId) ?? [] diff --git a/src/renderer/src/components/sidebar/worktree-list/grouping/recent-project-rank-scan-budget.test.ts b/src/renderer/src/components/sidebar/worktree-list/grouping/recent-project-rank-scan-budget.test.ts index b71d56d6d829..a73fd6ae095c 100644 --- a/src/renderer/src/components/sidebar/worktree-list/grouping/recent-project-rank-scan-budget.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list/grouping/recent-project-rank-scan-budget.test.ts @@ -100,7 +100,8 @@ describe('recent project rank scan budget', () => { ], folderWorkspaces: [], projectOrderBy: 'recent', - repoOrder: undefined + repoOrder: undefined, + sectionMemberRepoIds: new Map() }) const expected = grouped.toSorted( (left, right) => ((Number(right[0]) * 73) % 128) - ((Number(left[0]) * 73) % 128) From f567419517344dcb3ccdbd60f0592fcd585ad391 Mon Sep 17 00:00:00 2001 From: jbamlee Date: Sat, 17 Oct 2026 08:06:04 +0900 Subject: [PATCH 3/4] fix(sidebar): place a project section by catalog order, not by its anchor The anchor is the first visible row, so it still depended on pins and on the empty-project placeholders that the Cmd+1-9 order does not pass. With rows in competing groups, the drawn sidebar and shortcut numbering then put the project in different groups. Take the first catalog row in manual order that is in a known group; in the usual case that is still this Orca's own row. Co-Authored-By: Claude Opus 5.5 --- ...st-groups-cross-host-project-group.test.ts | 20 +++++++++++++++++++ .../grouping/project-group-sections.ts | 10 ++++------ 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/src/renderer/src/components/sidebar/worktree-list-groups-cross-host-project-group.test.ts b/src/renderer/src/components/sidebar/worktree-list-groups-cross-host-project-group.test.ts index 678297e87670..7038e5a10ae5 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups-cross-host-project-group.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups-cross-host-project-group.test.ts @@ -186,4 +186,24 @@ describe('a project grouped on one of its hosts', () => { expect(rendered).toEqual(['project-group:group-a@0', 'project:github:stablyai/orca@1']) expect(shortcutOrder).toEqual(rendered) }) + + it('places competing groups the same way with or without an empty-project placeholder', () => { + // This Orca's own row ranks first but has no workspaces yet; it sits in another group. + const emptyLocal: Repo = { ...localRepo, projectGroupId: localGroup.id, projectGroupOrder: 0 } + const args = { + repoOrder: THIS_ORCA_ORDER, + local: emptyLocal, + groups: [pairedGroup, localGroup], + worktrees: [pairedWorktree] + } + const rendered = headerKeys({ ...args, placeholderRepoIds: new Set([emptyLocal.id]) }) + const shortcutOrder = headerKeys(args) + + expect(rendered).toEqual([ + 'project-group:group-a@0', + 'project-group:group-local@0', + 'project:github:stablyai/orca@1' + ]) + expect(shortcutOrder).toEqual(rendered) + }) }) diff --git a/src/renderer/src/components/sidebar/worktree-list/grouping/project-group-sections.ts b/src/renderer/src/components/sidebar/worktree-list/grouping/project-group-sections.ts index 0c941505c6e1..7cf29275f1c8 100644 --- a/src/renderer/src/components/sidebar/worktree-list/grouping/project-group-sections.ts +++ b/src/renderer/src/components/sidebar/worktree-list/grouping/project-group-sections.ts @@ -41,9 +41,10 @@ export function getProjectSectionMemberRepoIds( } /** - * The member row that places a section in a Project Group. A project set up on several hosts is - * one section, and each Orca anchors it on its own host's row, so a member in a known group wins - * over an ungrouped anchor; otherwise every other Orca draws the moved project outside its group. + * The member row that places a section in a Project Group: the first catalog row in manual order + * that is in a known group. A project set up on several hosts is one section anchored on each + * Orca's own row, so reading only the anchor drew a project moved on one Orca outside its group + * on the others; reading visible rows only let pins and placeholders move it. */ export function getProjectSectionGroupRepo( section: Pick, @@ -55,9 +56,6 @@ export function getProjectSectionGroupRepo( const isGrouped = (repo: Repo | undefined): repo is Repo => Boolean(repo?.projectGroupId && knownGroupIds.has(repo.projectGroupId)) const anchor = section.repo - if (isGrouped(anchor)) { - return anchor - } let grouped: Repo | undefined let groupedRank = Number.POSITIVE_INFINITY for (const repoId of memberRepoIds) { From 7979a8bca63ebf9d0608502f1333e0d5e03de298 Mon Sep 17 00:00:00 2001 From: jbamlee Date: Sat, 17 Oct 2026 08:18:34 +0900 Subject: [PATCH 4/4] fix(sidebar): drag a project placed by another host's row within its group A section drawn in a group through a member row still took its drag bucket, sibling ranks and commit target from the anchor, so it fell in the root bucket while drawn inside the group. Header rows now carry the row that placed them; drag buckets, in-group ranks and the reorder commit use it, and the commit names that row's host. Co-Authored-By: Claude Opus 5.5 --- .../project-header-drag-commit.test.ts | 34 ++++++++++++++++++- .../sidebar/project-header-drag-commit.ts | 16 +++++++-- .../sidebar/project-header-drag-contract.ts | 8 ++++- .../components/sidebar/project-header-drop.ts | 2 +- ...st-groups-cross-host-project-group.test.ts | 11 ++++++ .../worktree-list/drag/use-header-drag.ts | 17 ++++++++-- .../worktree-list/grouping/group-sections.ts | 5 ++- .../grouping/project-group-sections.ts | 10 +++--- .../worktree-list/grouping/row-types.ts | 2 ++ 9 files changed, 91 insertions(+), 14 deletions(-) diff --git a/src/renderer/src/components/sidebar/project-header-drag-commit.test.ts b/src/renderer/src/components/sidebar/project-header-drag-commit.test.ts index 32fc47acef35..ca6348cba25d 100644 --- a/src/renderer/src/components/sidebar/project-header-drag-commit.test.ts +++ b/src/renderer/src/components/sidebar/project-header-drag-commit.test.ts @@ -108,6 +108,38 @@ describe('commitProjectHeaderDragDrop', () => { onCommitProjectGroupOrder }) - expect(onCommitProjectGroupOrder).toHaveBeenCalledWith('c', 'group-1', -1) + expect(onCommitProjectGroupOrder).toHaveBeenCalledWith('c', 'group-1', -1, 'local') + }) + + it("reorders the row that placed a header in the group, on that row's host", () => { + const onCommitProjectGroupOrder = vi.fn() + // Header 'c' anchors on this Orca's ungrouped row; a paired host's row 'c-paired' placed it. + const placedBy = makeRepo('c-paired', { + projectGroupId: 'group-1', + projectGroupOrder: 5, + executionHostId: 'runtime:paired' + }) + const repoById = new Map([ + ['a', makeRepo('a', { projectGroupId: 'group-1', projectGroupOrder: 0 })], + ['b', makeRepo('b', { projectGroupId: 'group-1', projectGroupOrder: 1 })], + ['c', placedBy] + ]) + + commitProjectHeaderDragDrop({ + session: makeSession('c', ['a', 'b', 'c']), + sidebarDropIndex: 0, + orderedRepoIds: ['a', 'b', 'c'], + repoById, + usesProjectGroupOrdering: true, + onCommitRepoOrder: vi.fn(), + onCommitProjectGroupOrder + }) + + expect(onCommitProjectGroupOrder).toHaveBeenCalledWith( + 'c-paired', + 'group-1', + -1, + 'runtime:paired' + ) }) }) diff --git a/src/renderer/src/components/sidebar/project-header-drag-commit.ts b/src/renderer/src/components/sidebar/project-header-drag-commit.ts index 5786391c2531..7e9beeed0d1f 100644 --- a/src/renderer/src/components/sidebar/project-header-drag-commit.ts +++ b/src/renderer/src/components/sidebar/project-header-drag-commit.ts @@ -7,6 +7,7 @@ import { } from './project-header-drop' import type { ProjectHeaderDragSession } from './project-header-drag-contract' import type { Repo } from '../../../../shared/repo-types' +import { getRepoExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' export function commitProjectHeaderDragDrop(args: { session: ProjectHeaderDragSession @@ -15,7 +16,12 @@ export function commitProjectHeaderDragDrop(args: { repoById: ReadonlyMap usesProjectGroupOrdering: boolean onCommitRepoOrder: (orderedIds: string[]) => void - onCommitProjectGroupOrder: (repoId: string, projectGroupId: string | null, order: number) => void + onCommitProjectGroupOrder: ( + repoId: string, + projectGroupId: string | null, + order: number, + hostId: ExecutionHostId + ) => void }): void { const draggedRepo = args.repoById.get(args.session.repoId) if (!draggedRepo) { @@ -59,7 +65,13 @@ export function commitProjectHeaderDragDrop(args: { dropIndex: siblingDropIndex, repoOrderRankById }) - args.onCommitProjectGroupOrder(args.session.repoId, draggedRepo.projectGroupId ?? null, order) + // Why: the dragged header may be placed by another host's row, which owns the in-group rank. + args.onCommitProjectGroupOrder( + draggedRepo.id, + draggedRepo.projectGroupId ?? null, + order, + getRepoExecutionHostId(draggedRepo) + ) return } diff --git a/src/renderer/src/components/sidebar/project-header-drag-contract.ts b/src/renderer/src/components/sidebar/project-header-drag-contract.ts index f28f34eaffbf..476f13fea129 100644 --- a/src/renderer/src/components/sidebar/project-header-drag-contract.ts +++ b/src/renderer/src/components/sidebar/project-header-drag-contract.ts @@ -2,6 +2,7 @@ import type { PointerEvent } from 'react' import type { ProjectHeaderDragBucketKey, ProjectHeaderDragRect } from './project-header-drop' import type { Repo } from '../../../../shared/repo-types' +import type { ExecutionHostId } from '../../../../shared/execution-host' export type RepoDragState = { draggingRepoId: string | null @@ -21,7 +22,12 @@ export type UseRepoHeaderDragArgs = { repoById: ReadonlyMap usesProjectGroupOrdering: boolean onCommitRepoOrder: (orderedIds: string[]) => void - onCommitProjectGroupOrder: (repoId: string, projectGroupId: string | null, order: number) => void + onCommitProjectGroupOrder: ( + repoId: string, + projectGroupId: string | null, + order: number, + hostId: ExecutionHostId + ) => void getScrollContainer: () => HTMLElement | null } diff --git a/src/renderer/src/components/sidebar/project-header-drop.ts b/src/renderer/src/components/sidebar/project-header-drop.ts index 171aa8261c87..91ed91b5cdb9 100644 --- a/src/renderer/src/components/sidebar/project-header-drop.ts +++ b/src/renderer/src/components/sidebar/project-header-drop.ts @@ -36,7 +36,7 @@ export function getSidebarOrderedRepoHeaderIdsByBucket( if (row.type !== 'header' || !row.repo) { continue } - const bucketKey = getProjectHeaderDragBucketKey(row.repo) + const bucketKey = getProjectHeaderDragBucketKey(row.projectGroupRepo ?? row.repo) const list = buckets.get(bucketKey) ?? [] list.push(row.repo.id) buckets.set(bucketKey, list) diff --git a/src/renderer/src/components/sidebar/worktree-list-groups-cross-host-project-group.test.ts b/src/renderer/src/components/sidebar/worktree-list-groups-cross-host-project-group.test.ts index 7038e5a10ae5..d78f1ae9475f 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups-cross-host-project-group.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups-cross-host-project-group.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { buildSidebarRowPlan } from './worktree-list/grouping/build-rows' +import { getSidebarOrderedRepoHeaderIdsByBucket } from './project-header-drop' import { repo, worktree, project } from './worktree-list-groups-test-fixtures' import type { ProjectGroup } from '../../../../shared/project-group-types' import type { ProjectHostSetup } from '../../../../shared/project-types' @@ -206,4 +207,14 @@ describe('a project grouped on one of its hosts', () => { ]) expect(shortcutOrder).toEqual(rendered) }) + + it('drags the project with the group it is drawn in, as the row that placed it', () => { + const rows = planRows({ repoOrder: THIS_ORCA_ORDER }).render(new Set()) + const header = rows.find((row) => row.type === 'header' && row.key === `project:${project.id}`) + + expect(header?.type === 'header' ? header.projectGroupRepo?.id : undefined).toBe(pairedRepo.id) + expect(getSidebarOrderedRepoHeaderIdsByBucket(rows).get(`group:${pairedGroup.id}`)).toEqual([ + localRepo.id + ]) + }) }) diff --git a/src/renderer/src/components/sidebar/worktree-list/drag/use-header-drag.ts b/src/renderer/src/components/sidebar/worktree-list/drag/use-header-drag.ts index 0127660c7124..4c2896df64c8 100644 --- a/src/renderer/src/components/sidebar/worktree-list/drag/use-header-drag.ts +++ b/src/renderer/src/components/sidebar/worktree-list/drag/use-header-drag.ts @@ -157,11 +157,22 @@ export function useWorktreeSidebarHeaderDrag(args: { [sidebarProjectGroupHeaderIdsByBucket] ) const commitProjectGroupOrder = useCallback( - (repoId: string, projectGroupId: string | null, order: number) => { - void moveProjectToGroup(repoId, projectGroupId, order) + (repoId: string, projectGroupId: string | null, order: number, hostId: ExecutionHostId) => { + void moveProjectToGroup(repoId, projectGroupId, order, hostId) }, [moveProjectToGroup] ) + // Why: a header drawn in a group through another host's row drags and ranks as that row. + const repoByHeaderIdForDrag = useMemo(() => { + let byHeaderId: Map | null = null + for (const row of rows) { + if (row.type === 'header' && row.repo && row.projectGroupRepo) { + byHeaderId ??= new Map(repoMap) + byHeaderId.set(row.repo.id, row.projectGroupRepo) + } + } + return byHeaderId ?? repoMap + }, [repoMap, rows]) const commitProjectGroupHeaderOrder = useCallback( (groupId: string, tabOrder: number) => { if (!Number.isFinite(tabOrder)) { @@ -178,7 +189,7 @@ export function useWorktreeSidebarHeaderDrag(args: { const repoDrag = useRepoHeaderDrag({ orderedRepoIds: allRepoIds, sidebarRepoHeaderIdsByBucket, - repoById: repoMap, + repoById: repoByHeaderIdForDrag, usesProjectGroupOrdering: hasProjectGroups, onCommitRepoOrder: commitRepoReorder, onCommitProjectGroupOrder: commitProjectGroupOrder, diff --git a/src/renderer/src/components/sidebar/worktree-list/grouping/group-sections.ts b/src/renderer/src/components/sidebar/worktree-list/grouping/group-sections.ts index 8d1f98935cb8..9f7ee396a045 100644 --- a/src/renderer/src/components/sidebar/worktree-list/grouping/group-sections.ts +++ b/src/renderer/src/components/sidebar/worktree-list/grouping/group-sections.ts @@ -52,7 +52,8 @@ export type SectionAppendContext = { export function appendOrderedGroups( ctx: SectionAppendContext, groupsToAppend: OrderedGroupEntry[], - projectGroupDepth = 0 + projectGroupDepth = 0, + projectGroupRepoByKey?: ReadonlyMap ): void { const { result, @@ -72,6 +73,7 @@ export function appendOrderedGroups( for (const [key, group] of groupsToAppend) { const isCollapsed = collapsedGroups.has(key) const repo = group.repo + const projectGroupRepo = projectGroupRepoByKey?.get(key) const folderPairs = group.folderWorkspaces ?? [] const header = groupBy === 'repo' @@ -83,6 +85,7 @@ export function appendOrderedGroups( tone: PROJECT_GROUP_META.tone, icon: PROJECT_GROUP_META.icon, repo, + ...(projectGroupRepo && projectGroupRepo !== repo ? { projectGroupRepo } : {}), projectGroupDepth } : groupBy === 'workspace-status' diff --git a/src/renderer/src/components/sidebar/worktree-list/grouping/project-group-sections.ts b/src/renderer/src/components/sidebar/worktree-list/grouping/project-group-sections.ts index 7cf29275f1c8..77616e8323af 100644 --- a/src/renderer/src/components/sidebar/worktree-list/grouping/project-group-sections.ts +++ b/src/renderer/src/components/sidebar/worktree-list/grouping/project-group-sections.ts @@ -85,7 +85,7 @@ export function appendProjectGroupSections( const projectGroupsById = new Map(projectGroups.map((group) => [group.id, group])) const groupByProjectGroupId = new Map() - const groupRepoByEntry = new Map() + const groupRepoByKey = new Map() for (const entry of orderedGroups) { const repo = getProjectSectionGroupRepo( entry[1], @@ -94,7 +94,7 @@ export function appendProjectGroupSections( repoOrder, projectGroupsById ) - groupRepoByEntry.set(entry, repo) + groupRepoByKey.set(entry[0], repo) const projectGroupId = repo?.projectGroupId ?? null const list = groupByProjectGroupId.get(projectGroupId) ?? [] list.push(entry) @@ -115,8 +115,8 @@ export function appendProjectGroupSections( // (projectGroupOrder), falling back to global repoOrder when unset so drag // midpoint commits and the rendered order stay aligned. return [...entries].sort((left, right) => { - const leftRank = getEffectiveProjectGroupManualRank(groupRepoByEntry.get(left), repoOrder) - const rightRank = getEffectiveProjectGroupManualRank(groupRepoByEntry.get(right), repoOrder) + const leftRank = getEffectiveProjectGroupManualRank(groupRepoByKey.get(left[0]), repoOrder) + const rightRank = getEffectiveProjectGroupManualRank(groupRepoByKey.get(right[0]), repoOrder) return leftRank - rightRank }) } @@ -177,7 +177,7 @@ export function appendProjectGroupSections( for (const pair of folderWorkspacesByProjectGroupId.get(projectGroup.id) ?? []) { result.push(buildFolderWorkspaceRow(pair, depth + 1)) } - appendOrderedGroups(ctx, withRepoSectionDisplayLabels(repoEntries), depth + 1) + appendOrderedGroups(ctx, withRepoSectionDisplayLabels(repoEntries), depth + 1, groupRepoByKey) for (const childGroup of childGroups) { appendProjectGroup(childGroup, depth + 1) } diff --git a/src/renderer/src/components/sidebar/worktree-list/grouping/row-types.ts b/src/renderer/src/components/sidebar/worktree-list/grouping/row-types.ts index c98f10ba56d7..d2d98b53acb3 100644 --- a/src/renderer/src/components/sidebar/worktree-list/grouping/row-types.ts +++ b/src/renderer/src/components/sidebar/worktree-list/grouping/row-types.ts @@ -24,6 +24,8 @@ export type GroupHeaderRow = { tone: string icon?: React.ComponentType<{ className?: string }> repo?: Repo + /** The member row whose Project Group places this section, when it is not `repo`. */ + projectGroupRepo?: Repo projectGroup?: ProjectGroup | { id: null; name: 'Ungrouped'; tabOrder: number } projectGroupDepth?: number hostId?: ExecutionHostId