Keep selected projects when switching hosts (#2700)

* fix(app): preserve project selection across hosts

Rendered project identity and persisted equivalence were conflated after duplicate-project handling. Keep them separate so host changes resolve selectable equivalents deterministically without resetting legacy sidebar identity.

* fix(app): restore selected clone after host round trip

Keep manual origin and current destination placements separately so switching back restores the chosen checkout while archive gaps preserve the active host selection.

* fix(app): key status icons by project view

* fix(app): preserve origin during archive gaps
This commit is contained in:
Mohamed Boudra
2026-07-31 15:42:08 +02:00
committed by GitHub
parent 87ef631ac4
commit 99eca19a76
56 changed files with 1234 additions and 428 deletions
@@ -14,7 +14,7 @@ function ws(
return {
serverId: input.serverId ?? "srv",
workspaceId: input.workspaceId ?? input.workspaceKey.split(":")[1] ?? "ws",
projectKey: input.projectKey ?? "proj",
projectViewKey: input.projectViewKey ?? "proj",
projectName: input.projectName ?? "Project",
projectRootPath: input.projectRootPath,
workspaceDirectory: input.workspaceDirectory,
@@ -124,9 +124,24 @@ describe("buildStatusGroups", () => {
]);
const workspaces = [
ws({ workspaceKey: "srv:1", statusBucket: "done", projectKey: "proj-b", name: "zebra" }),
ws({ workspaceKey: "srv:2", statusBucket: "done", projectKey: "proj-a", name: "alpha" }),
ws({ workspaceKey: "srv:3", statusBucket: "done", projectKey: "proj-a", name: "alpha" }),
ws({
workspaceKey: "srv:1",
statusBucket: "done",
projectViewKey: "proj-b",
name: "zebra",
}),
ws({
workspaceKey: "srv:2",
statusBucket: "done",
projectViewKey: "proj-a",
name: "alpha",
}),
ws({
workspaceKey: "srv:3",
statusBucket: "done",
projectViewKey: "proj-a",
name: "alpha",
}),
];
const groups = buildStatusGroups(workspaces, projectNames);
@@ -26,7 +26,7 @@ export interface StatusGroup {
export function buildStatusGroups(
workspaces: SidebarWorkspaceEntry[],
projectNamesByKey: Map<string, string>,
projectNamesByViewKey: Map<string, string>,
): StatusGroup[] {
const bucketRows = new Map<StatusBucket, SidebarWorkspaceEntry[]>();
@@ -46,7 +46,7 @@ export function buildStatusGroups(
const rows = bucketRows.get(bucket);
if (!rows || rows.length === 0) continue;
rows.sort((a, b) => compareStatusRows(a, b, projectNamesByKey));
rows.sort((a, b) => compareStatusRows(a, b, projectNamesByViewKey));
groups.push({ bucket, label: STATUS_BUCKET_LABELS[bucket], rows });
}
@@ -56,7 +56,7 @@ export function buildStatusGroups(
function compareStatusRows(
a: SidebarWorkspaceEntry,
b: SidebarWorkspaceEntry,
projectNamesByKey: Map<string, string>,
projectNamesByViewKey: Map<string, string>,
): number {
const aTime = a.statusEnteredAt?.getTime() ?? null;
const bTime = b.statusEnteredAt?.getTime() ?? null;
@@ -69,8 +69,8 @@ function compareStatusRows(
return 1;
}
const aProject = projectNamesByKey.get(a.projectKey) ?? "";
const bProject = projectNamesByKey.get(b.projectKey) ?? "";
const aProject = projectNamesByViewKey.get(a.projectViewKey) ?? "";
const bProject = projectNamesByViewKey.get(b.projectViewKey) ?? "";
const projectCmp = aProject.localeCompare(bProject);
if (projectCmp !== 0) return projectCmp;
@@ -83,6 +83,7 @@ function project(input: {
>;
}): WorkspaceStructureProject {
return {
viewKey: input.projectKey,
projectKey: input.projectKey,
projectName: input.projectName ?? input.projectKey,
projectKind: input.projectKind ?? "git",
@@ -227,7 +228,7 @@ describe("buildSidebarProjectsFromStructure", () => {
],
});
expect(projects.map((entry) => entry.projectKey)).toEqual(["project-b", "project-a"]);
expect(projects.map((entry) => entry.viewKey)).toEqual(["project-b", "project-a"]);
});
it("preserves the structure hook workspace order", () => {
@@ -327,7 +328,7 @@ describe("shared sidebar workspace model", () => {
]);
expect(model.projects).toEqual([
expect.objectContaining({
projectKey: "getpaseo/paseo",
viewKey: "getpaseo/paseo",
hosts: [
{
serverId: "host-a",
@@ -366,7 +367,7 @@ describe("shared sidebar workspace model", () => {
["host-a:main", "done", "main"],
["host-b:feature", "running", "feature/status-flow"],
]);
expect(model.projectNamesByKey).toEqual(new Map([["getpaseo/paseo", "getpaseo/paseo"]]));
expect(model.projectNamesByViewKey).toEqual(new Map([["getpaseo/paseo", "getpaseo/paseo"]]));
});
it("preserves unchanged row identities when another workspace updates", () => {
@@ -443,7 +444,7 @@ describe("shared sidebar workspace model", () => {
],
});
expect(entries.get("srv:clone-a")?.projectKey).toBe(projectKey);
expect(entries.get("srv:clone-a")?.projectViewKey).toBe(projectKey);
});
});
@@ -529,8 +530,8 @@ describe("computeSidebarOrderUpdates", () => {
expect(updates.projectOrder).toEqual(["project-a", "project-b"]);
expect(updates.workspaceOrders).toEqual([
{ projectKey: "project-a", order: ["srv:ws-2", "srv:ws-1"] },
{ projectKey: "project-b", order: ["srv:ws-3"] },
{ projectViewKey: "project-a", order: ["srv:ws-2", "srv:ws-1"] },
{ projectViewKey: "project-b", order: ["srv:ws-3"] },
]);
});
@@ -550,7 +551,7 @@ describe("computeSidebarOrderUpdates", () => {
expect(updates.workspaceOrders).toEqual([
{
projectKey: "project-a",
projectViewKey: "project-a",
order: ["srv:newest", "srv:newer", "srv:old-b", "srv:old-a"],
},
]);
@@ -19,7 +19,7 @@ export interface SidebarWorkspacePlacement {
workspaceKey: string;
serverId: string;
workspaceId: string;
projectKey: string;
projectViewKey: string;
projectName: string;
projectRootPath?: string;
workspaceDirectory?: string;
@@ -50,7 +50,7 @@ export interface SidebarWorkspaceEntry extends SidebarStatusWorkspacePlacement {
}
export interface SidebarProjectEntry {
projectKey: string;
viewKey: string;
projectName: string;
projectKind: WorkspaceDescriptor["projectKind"];
iconWorkingDir: string;
@@ -61,7 +61,7 @@ export interface SidebarProjectEntry {
export interface SidebarWorkspacePlacementModel {
workspaces: SidebarWorkspacePlacement[];
projects: SidebarProjectEntry[];
projectNamesByKey: Map<string, string>;
projectNamesByViewKey: Map<string, string>;
}
export interface SidebarWorkspaceSession {
@@ -122,11 +122,11 @@ interface EffectiveWorkspaceStatus {
enteredAt: Date | null;
}
function projectNameForWorkspace(workspace: WorkspaceDescriptor, projectKey: string): string {
function projectNameForWorkspace(workspace: WorkspaceDescriptor): string {
return (
workspace.projectCustomName ??
workspace.projectDisplayName ??
projectDisplayNameFromProjectId(projectKey)
projectDisplayNameFromProjectId(workspace.projectId)
);
}
@@ -141,18 +141,18 @@ function normalizeCurrentBranch(currentBranch: string | null | undefined): strin
export function createSidebarWorkspaceEntry(input: {
serverId: string;
workspace: WorkspaceDescriptor;
projectKey?: string;
projectViewKey?: string;
pendingCreateAttempts?: Record<string, PendingCreateAttempt>;
workspaceAgentActivity?: ReadonlyMap<string, WorkspaceAgentActivity>;
}): SidebarWorkspaceEntry {
const projectKey = input.projectKey ?? input.workspace.projectId;
const projectViewKey = input.projectViewKey ?? input.workspace.projectId;
const effectiveStatus = deriveEffectiveWorkspaceStatus(input);
return {
workspaceKey: `${input.serverId}:${input.workspace.id}`,
serverId: input.serverId,
workspaceId: input.workspace.id,
projectKey,
projectName: projectNameForWorkspace(input.workspace, projectKey),
projectViewKey,
projectName: projectNameForWorkspace(input.workspace),
projectRootPath: input.workspace.projectRootPath,
workspaceDirectory: input.workspace.workspaceDirectory,
projectKind: input.workspace.projectKind,
@@ -228,8 +228,8 @@ export function buildSidebarWorkspacePlacementModel(input: {
return {
projects,
workspaces: projects.flatMap((project) => project.workspaces),
projectNamesByKey: new Map(
projects.map((project) => [project.projectKey, project.projectName]),
projectNamesByViewKey: new Map(
projects.map((project) => [project.viewKey, project.projectName]),
),
};
}
@@ -247,7 +247,7 @@ function createStructuralWorkspaceEntry(input: {
workspaceKey: identity.workspaceKey,
serverId: identity.serverId,
workspaceId: identity.workspaceId,
projectKey: input.project.projectKey,
projectViewKey: input.project.viewKey,
projectName: input.project.projectName,
projectRootPath: input.project.iconWorkingDir,
workspaceDirectory: undefined,
@@ -324,7 +324,7 @@ export function buildSidebarWorkspaceEntries(input: {
const entry = createSidebarWorkspaceEntry({
serverId: placement.serverId,
workspace,
projectKey: placement.projectKey,
projectViewKey: placement.projectViewKey,
pendingCreateAttempts: input.pendingCreateAttempts,
workspaceAgentActivity: session.workspaceAgentActivity,
});
@@ -369,6 +369,7 @@ export function buildSidebarProjectsFromStructure(input: {
}): SidebarProjectEntry[] {
return buildSidebarProjectsFromHostProjects({
projects: input.projects.map((project) => ({
viewKey: project.viewKey,
projectKey: project.projectKey,
projectName: project.projectName,
projectKind: project.projectKind,
@@ -387,7 +388,7 @@ export function buildSidebarProjectsFromHostProjects(input: {
}
return input.projects.map((project) => ({
projectKey: project.projectKey,
viewKey: project.viewKey,
projectName: project.projectName,
projectKind: project.projectKind,
iconWorkingDir: project.iconWorkingDir,
@@ -498,13 +499,13 @@ export function prependMissingOrderKeys(input: {
export interface SidebarOrderUpdates {
projectOrder: string[] | null;
workspaceOrders: Array<{ projectKey: string; order: string[] }>;
workspaceOrders: Array<{ projectViewKey: string; order: string[] }>;
}
export function computeSidebarOrderUpdates(input: {
projects: SidebarProjectEntry[];
persistedProjectOrder: string[];
getWorkspaceOrder: (projectKey: string) => string[];
getWorkspaceOrder: (projectViewKey: string) => string[];
}): SidebarOrderUpdates {
if (input.projects.length === 0) {
return { projectOrder: null, workspaceOrders: [] };
@@ -512,19 +513,19 @@ export function computeSidebarOrderUpdates(input: {
const nextProjectOrder = appendMissingOrderKeys({
currentOrder: input.persistedProjectOrder,
visibleKeys: input.projects.map((project) => project.projectKey),
visibleKeys: input.projects.map((project) => project.viewKey),
});
const projectOrder = nextProjectOrder === input.persistedProjectOrder ? null : nextProjectOrder;
const workspaceOrders: Array<{ projectKey: string; order: string[] }> = [];
const workspaceOrders: Array<{ projectViewKey: string; order: string[] }> = [];
for (const project of input.projects) {
const persistedWorkspaceOrder = input.getWorkspaceOrder(project.projectKey);
const persistedWorkspaceOrder = input.getWorkspaceOrder(project.viewKey);
const nextWorkspaceOrder = prependMissingOrderKeys({
currentOrder: persistedWorkspaceOrder,
visibleKeys: project.workspaces.map((workspace) => workspace.workspaceKey),
});
if (nextWorkspaceOrder !== persistedWorkspaceOrder) {
workspaceOrders.push({ projectKey: project.projectKey, order: nextWorkspaceOrder });
workspaceOrders.push({ projectViewKey: project.viewKey, order: nextWorkspaceOrder });
}
}
@@ -10,7 +10,7 @@ function placement(workspaceKey: string): SidebarWorkspacePlacement {
workspaceKey,
serverId: "s1",
workspaceId: workspaceKey,
projectKey: "p1",
projectViewKey: "p1",
projectName: "Project 1",
projectKind: "git",
workspaceKind: "worktree",
@@ -20,7 +20,7 @@ function placement(workspaceKey: string): SidebarWorkspacePlacement {
function project(projectKey: string, workspaces: SidebarWorkspacePlacement[]): SidebarProjectEntry {
return {
projectKey,
viewKey: projectKey,
projectName: projectKey,
projectKind: "git",
iconWorkingDir: "",
@@ -17,7 +17,7 @@ function workspace(projectKey: string, workspaceId: string): SidebarWorkspaceEnt
workspaceKey: `srv:${workspaceId}`,
serverId: "srv",
workspaceId,
projectKey,
projectViewKey: projectKey,
projectName: projectKey,
projectRootPath: `/repo/${projectKey}`,
workspaceDirectory: `/repo/${projectKey}/${workspaceId}`,
@@ -40,7 +40,7 @@ function workspace(projectKey: string, workspaceId: string): SidebarWorkspaceEnt
function project(projectKey: string): SidebarProjectEntry {
return {
projectKey,
viewKey: projectKey,
projectName: projectKey,
projectKind: "git",
iconWorkingDir: `/repo/${projectKey}`,
@@ -41,7 +41,7 @@ const EMPTY_PROJECT_NAMES = new Map<string, string>();
export interface SidebarWorkspacesListResult {
workspacePlacements: SidebarWorkspacePlacement[];
projects: SidebarProjectEntry[];
projectNamesByKey: Map<string, string>;
projectNamesByViewKey: Map<string, string>;
isLoading: boolean;
isInitialLoad: boolean;
isRevalidating: boolean;
@@ -100,23 +100,25 @@ export function useSidebarWorkspacesList(options?: {
const projects = sidebarModel.projects.length > 0 ? sidebarModel.projects : EMPTY_PROJECTS;
const workspacePlacements =
sidebarModel.workspaces.length > 0 ? sidebarModel.workspaces : EMPTY_WORKSPACES;
const projectNamesByKey =
sidebarModel.projectNamesByKey.size > 0 ? sidebarModel.projectNamesByKey : EMPTY_PROJECT_NAMES;
const projectNamesByViewKey =
sidebarModel.projectNamesByViewKey.size > 0
? sidebarModel.projectNamesByViewKey
: EMPTY_PROJECT_NAMES;
useEffect(() => {
const orderStore = useSidebarOrderStore.getState();
const updates = computeSidebarOrderUpdates({
projects,
persistedProjectOrder,
getWorkspaceOrder: (projectKey) =>
orderStore.workspaceOrderByProject[projectKey] ?? EMPTY_ORDER,
getWorkspaceOrder: (projectViewKey) =>
orderStore.workspaceOrderByProject[projectViewKey] ?? EMPTY_ORDER,
});
if (updates.projectOrder) {
orderStore.setProjectOrder(updates.projectOrder);
}
for (const { projectKey, order } of updates.workspaceOrders) {
orderStore.setWorkspaceOrder(projectKey, order);
for (const { projectViewKey, order } of updates.workspaceOrders) {
orderStore.setWorkspaceOrder(projectViewKey, order);
}
}, [persistedProjectOrder, projects]);
@@ -144,7 +146,7 @@ export function useSidebarWorkspacesList(options?: {
return {
workspacePlacements,
projects,
projectNamesByKey,
projectNamesByViewKey,
...loadingState,
refreshAll,
};