Merge sidebar workspaces across all connected hosts (#1538)

* Merge workspaces across all hosts in the sidebar

Replace per-host sidebar sections with a single merged project list.
Projects that exist on multiple hosts (matched by projectKey) are
collapsed into one row. Host identity is surfaced via:

- A status-only footer pill showing online/offline host counts
- Hover cards with a mandatory host row on every workspace entry
- Subtitles showing the host name when a project spans >1 host
- A host filter in the grouping dropdown (not a sidebar switcher)

Order and group-mode stores migrated from per-server to global keys
with zustand persist migrations. New-workspace screen now shows a
merged project list with a host selector combobox.

Key files:
- workspace-structure.ts: merge algorithm by projectKey
- sidebar-view-store.ts / sidebar-order-store.ts: global state
- sidebar-workspace-list.tsx: context-based subtitle injection
- new-workspace-screen.tsx: host selector in footer row

* Add host filtering to the merged sidebar

The sidebar now merges workspaces from every connected host, so a single
global "active host" no longer fits. Host selection becomes explicit per
action via a host chooser, and agent history pages across all hosts at
once. Removes the active-host / active-server-id model and the
all-agents-list hook that fed it.

* Fix rebase fallout: translate archive host error, update stale workspace-key test

archiveWorkspacesOptimistically used a raw "Host is not connected" string
that the i18n completeness test forbids; route it through i18n.t with the
existing hostDisconnected key. The selectors memoization test asserted
un-prefixed workspace keys, but merged-sidebar keys are serverId-prefixed.

* Fix sidebar review fallout

* Address sidebar review followups

* Fix sidebar workspace identity migration

* Address sidebar review followups

* Handle partial host history failures

* Fix merged sidebar project removal and E2E drift

* Restore history routes and project removal shape

* Remove sidebar host context plumbing

* Tighten sidebar status and host filter state

* Keep new workspace project selection host-compatible

* Migrate sidebar view storage

* Streamline host selection flows

* Update all-host history on archive

* Fix empty project sidebar action

Code regression: the main merge dropped the empty-project New workspace child row while keeping the empty project persistence contract. Restore the row using the project host target so the sidebar stays aligned with the no-host-selection model.

* Fix Japanese shortcut translation key

Stale-base integration: main added the Japanese locale after this branch added cycleAgentMode to the English shortcut help map. Merging main exposed the missing ja key in CI typecheck and resources.test.

* Fix Playwright speech teardown race
This commit is contained in:
Mohamed Boudra
2026-06-26 12:16:49 +08:00
committed by GitHub
parent 1109e453bc
commit a49c658d1f
103 changed files with 6856 additions and 2857 deletions
+104 -49
View File
@@ -1,11 +1,18 @@
import type { EmptyProjectDescriptor, WorkspaceDescriptor } from "@/stores/session-store";
import { projectDisplayNameFromProjectId } from "@/utils/project-display-name";
export interface WorkspaceStructureHostPlacement {
serverId: string;
iconWorkingDir: string;
canCreateWorktree: boolean;
}
export interface WorkspaceStructureProject {
projectKey: string;
projectName: string;
projectKind: WorkspaceDescriptor["projectKind"];
iconWorkingDir: string;
hosts: WorkspaceStructureHostPlacement[];
workspaceKeys: string[];
}
@@ -13,8 +20,6 @@ export interface WorkspaceStructure {
projects: WorkspaceStructureProject[];
}
const EMPTY_WORKSPACE_STRUCTURE: WorkspaceStructure = { projects: [] };
function compareWorkspaceStructureItems(
left: { workspaceId: string; workspaceName: string },
right: { workspaceId: string; workspaceName: string },
@@ -42,68 +47,118 @@ function compareWorkspaceStructureProjects(
});
}
export function buildWorkspaceStructureProjects(input: {
function canCreateWorktreeForProjectKind(projectKind: WorkspaceDescriptor["projectKind"]): boolean {
return projectKind === "git";
}
interface WorkspaceStructureSession {
serverId: string;
workspaces: Iterable<WorkspaceDescriptor>;
emptyProjects?: Iterable<EmptyProjectDescriptor>;
}): WorkspaceStructureProject[] {
const workspaceList = Array.from(input.workspaces);
const emptyProjectList = Array.from(input.emptyProjects ?? []);
if (workspaceList.length === 0 && emptyProjectList.length === 0) {
return EMPTY_WORKSPACE_STRUCTURE.projects;
}
}
export function buildWorkspaceStructureProjects(input: {
sessions: WorkspaceStructureSession[];
}): WorkspaceStructureProject[] {
const byProject = new Map<
string,
WorkspaceStructureProject & {
{
projectKey: string;
projectName: string;
projectKind: WorkspaceDescriptor["projectKind"];
iconWorkingDir: string;
hosts: Map<string, WorkspaceStructureHostPlacement>;
workspaces: Array<{ workspaceId: string; workspaceName: string; workspaceKey: string }>;
}
>();
for (const emptyProject of emptyProjectList) {
byProject.set(emptyProject.projectId, {
projectKey: emptyProject.projectId,
projectName:
emptyProject.projectDisplayName || projectDisplayNameFromProjectId(emptyProject.projectId),
projectKind: emptyProject.projectKind,
iconWorkingDir: emptyProject.projectRootPath,
workspaceKeys: [],
workspaces: [],
});
}
for (const session of input.sessions) {
for (const emptyProject of session.emptyProjects ?? []) {
const projectKey = emptyProject.projectId;
const placement = {
serverId: session.serverId,
iconWorkingDir: emptyProject.projectRootPath,
canCreateWorktree: canCreateWorktreeForProjectKind(emptyProject.projectKind),
};
const existing = byProject.get(projectKey);
for (const workspace of workspaceList) {
const project =
byProject.get(workspace.projectId) ??
({
projectKey: workspace.projectId,
projectName:
workspace.projectDisplayName || projectDisplayNameFromProjectId(workspace.projectId),
projectKind: workspace.projectKind,
if (!existing) {
byProject.set(projectKey, {
projectKey,
projectName:
emptyProject.projectCustomName ??
emptyProject.projectDisplayName ??
projectDisplayNameFromProjectId(projectKey),
projectKind: emptyProject.projectKind,
iconWorkingDir: emptyProject.projectRootPath,
hosts: new Map([[session.serverId, placement]]),
workspaces: [],
});
continue;
}
existing.hosts.set(session.serverId, placement);
}
for (const workspace of session.workspaces) {
const projectKey = workspace.project?.projectKey ?? workspace.projectId;
const existing = byProject.get(projectKey);
if (!existing) {
byProject.set(projectKey, {
projectKey,
projectName:
workspace.projectCustomName ??
workspace.projectDisplayName ??
projectDisplayNameFromProjectId(projectKey),
projectKind: workspace.projectKind,
iconWorkingDir: workspace.projectRootPath,
hosts: new Map([
[
session.serverId,
{
serverId: session.serverId,
iconWorkingDir: workspace.projectRootPath,
canCreateWorktree: canCreateWorktreeForProjectKind(workspace.projectKind),
},
],
]),
workspaces: [
{
workspaceId: workspace.id,
workspaceName: workspace.name,
workspaceKey: `${session.serverId}:${workspace.id}`,
},
],
});
continue;
}
existing.hosts.set(session.serverId, {
serverId: session.serverId,
iconWorkingDir: workspace.projectRootPath,
workspaceKeys: [],
workspaces: [],
} satisfies WorkspaceStructureProject & {
workspaces: Array<{ workspaceId: string; workspaceName: string; workspaceKey: string }>;
canCreateWorktree: canCreateWorktreeForProjectKind(workspace.projectKind),
});
project.workspaces.push({
workspaceId: workspace.id,
workspaceName: workspace.name,
workspaceKey: `${input.serverId}:${workspace.id}`,
});
byProject.set(workspace.projectId, project);
existing.workspaces.push({
workspaceId: workspace.id,
workspaceName: workspace.name,
workspaceKey: `${session.serverId}:${workspace.id}`,
});
}
}
const projects = Array.from(byProject.values()).map(
({ workspaces: projectWorkspaces, ...project }) => {
const sortedWorkspaces = [...projectWorkspaces].sort(compareWorkspaceStructureItems);
return Object.assign({}, project, {
workspaceKeys: sortedWorkspaces.map((workspace) => workspace.workspaceId),
});
},
);
const projects: WorkspaceStructureProject[] = [];
for (const raw of byProject.values()) {
const sortedWorkspaces = [...raw.workspaces].sort(compareWorkspaceStructureItems);
projects.push({
projectKey: raw.projectKey,
projectName: raw.projectName,
projectKind: raw.projectKind,
iconWorkingDir: raw.iconWorkingDir,
hosts: Array.from(raw.hosts.values()),
workspaceKeys: sortedWorkspaces.map((w) => w.workspaceKey),
});
}
projects.sort(compareWorkspaceStructureProjects);
return projects;