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
@@ -12,6 +12,7 @@ import {
} from "@/workspace/workspace-archive";
const SERVER_ID = "workspace-archive-test";
const SECOND_SERVER_ID = "workspace-archive-test-2";
type ArchiveWorkspacePayload = Awaited<ReturnType<DaemonClient["archiveWorkspace"]>>;
@@ -75,8 +76,12 @@ function deferred<T>(): {
return { promise, resolve, reject };
}
function storedWorkspaceOn(serverId: string, id: string): WorkspaceDescriptor | undefined {
return useSessionStore.getState().sessions[serverId]?.workspaces.get(id);
}
function storedWorkspace(id: string): WorkspaceDescriptor | undefined {
return useSessionStore.getState().sessions[SERVER_ID]?.workspaces.get(id);
return storedWorkspaceOn(SERVER_ID, id);
}
beforeEach(() => {
@@ -86,6 +91,8 @@ beforeEach(() => {
afterEach(() => {
clearWorkspaceArchivePending({ serverId: SERVER_ID, workspaceId: "workspace-1" });
clearWorkspaceArchivePending({ serverId: SERVER_ID, workspaceId: "workspace-2" });
clearWorkspaceArchivePending({ serverId: SECOND_SERVER_ID, workspaceId: "workspace-1" });
clearWorkspaceArchivePending({ serverId: SECOND_SERVER_ID, workspaceId: "workspace-2" });
useSessionStore.setState((state) => ({ ...state, sessions: {} }));
});
@@ -175,8 +182,11 @@ describe("archiveWorkspacesOptimistically", () => {
);
const failures = await archiveWorkspacesOptimistically({
client,
workspaces: [target({ workspaceId: first.id }), target({ workspaceId: second.id })],
getClient: () => client,
workspaces: [
target({ workspaceId: first.id, workspaceDirectory: first.workspaceDirectory }),
target({ workspaceId: second.id, workspaceDirectory: second.workspaceDirectory }),
],
});
expect(failures).toHaveLength(1);
@@ -184,4 +194,49 @@ describe("archiveWorkspacesOptimistically", () => {
expect(storedWorkspace(first.id)).toBeUndefined();
expect(storedWorkspace(second.id)).toEqual(second);
});
it("archives each workspace through its own server client", async () => {
const first = workspace({ id: "workspace-1" });
const second = workspace({
id: "workspace-2",
workspaceDirectory: "/repo/project/workspace-2",
name: "workspace-2",
});
useSessionStore.getState().initializeSession(SECOND_SERVER_ID, {} as DaemonClient);
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [first]);
useSessionStore.getState().mergeWorkspaces(SECOND_SERVER_ID, [second]);
const archivedByServer = new Map<string, string[]>();
const clientFor = (serverId: string) =>
createClient(async (workspaceId) => {
archivedByServer.set(serverId, [...(archivedByServer.get(serverId) ?? []), workspaceId]);
return archivePayload({ workspaceId });
});
const failures = await archiveWorkspacesOptimistically({
getClient: (serverId) => clientFor(serverId),
workspaces: [
target({
serverId: SERVER_ID,
workspaceId: first.id,
workspaceDirectory: first.workspaceDirectory,
}),
target({
serverId: SECOND_SERVER_ID,
workspaceId: second.id,
workspaceDirectory: second.workspaceDirectory,
}),
],
});
expect(failures).toEqual([]);
expect(archivedByServer).toEqual(
new Map([
[SERVER_ID, [first.id]],
[SECOND_SERVER_ID, [second.id]],
]),
);
expect(storedWorkspaceOn(SERVER_ID, first.id)).toBeUndefined();
expect(storedWorkspaceOn(SECOND_SERVER_ID, second.id)).toBeUndefined();
});
});
@@ -4,10 +4,12 @@ import {
} from "@/contexts/session-workspace-upserts";
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity";
import { i18n } from "@/i18n/i18next";
export interface WorkspaceArchiveTarget {
serverId: string;
workspaceId: string;
workspaceDirectory?: string | null;
}
interface WorkspaceArchiveClient {
@@ -19,6 +21,7 @@ interface OptimisticWorkspaceArchiveSnapshot {
}
export interface WorkspaceArchiveFailure {
serverId: string;
workspaceId: string;
error: unknown;
}
@@ -27,6 +30,8 @@ function isWorkspaceArchiveFailure(error: unknown): error is WorkspaceArchiveFai
return (
typeof error === "object" &&
error !== null &&
"serverId" in error &&
typeof error.serverId === "string" &&
"workspaceId" in error &&
typeof error.workspaceId === "string" &&
"error" in error
@@ -45,6 +50,7 @@ function hideWorkspaceOptimistically(
markWorkspaceArchivePending({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.workspaceDirectory,
});
useSessionStore.getState().removeWorkspace(workspace.serverId, workspace.workspaceId);
return { workspace: snapshot };
@@ -98,18 +104,31 @@ export async function archiveWorkspaceOptimistically(input: {
}
export async function archiveWorkspacesOptimistically(input: {
client: WorkspaceArchiveClient;
getClient: (serverId: string) => WorkspaceArchiveClient | null;
workspaces: WorkspaceArchiveTarget[];
}): Promise<WorkspaceArchiveFailure[]> {
const results = await Promise.allSettled(
input.workspaces.map(async (workspace) => {
const client = input.getClient(workspace.serverId);
if (!client) {
throw {
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
error: new Error(i18n.t("sidebar.workspace.toasts.hostDisconnected")),
} satisfies WorkspaceArchiveFailure;
}
try {
await archiveWorkspaceOptimistically({
client: input.client,
client,
workspace,
});
} catch (error) {
throw { workspaceId: workspace.workspaceId, error } satisfies WorkspaceArchiveFailure;
throw {
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
error,
} satisfies WorkspaceArchiveFailure;
}
}),
);