Files
paseo/packages/app/src/components/sidebar-workspace-list.test.tsx
T
Mohamed BoudraandGitHub a49c658d1f 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
2026-06-26 12:16:49 +08:00

470 lines
12 KiB
TypeScript

/**
* @vitest-environment jsdom
*/
import { act } from "@testing-library/react";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { WorkspaceScriptPayload } from "@getpaseo/protocol/messages";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createRoot, type Root } from "react-dom/client";
import React from "react";
import type { ReactElement } from "react";
vi.hoisted(() => {
(globalThis as unknown as { __DEV__: boolean }).__DEV__ = false;
});
const pathnameState = vi.hoisted(() => ({
value: "/",
}));
vi.mock("expo-router", () => ({
router: {
dismissTo: vi.fn(),
},
useLocalSearchParams: () => ({}),
usePathname: () => pathnameState.value,
}));
import {
createSidebarWorkspaceEntry,
type SidebarProjectEntry,
} from "@/hooks/use-sidebar-workspaces-list";
import { useSidebarWorkspacesList } from "@/hooks/use-sidebar-workspaces-list";
import { patchWorkspaceScripts } from "@/contexts/session-workspace-scripts";
import {
getHostRuntimeStore,
type HostRuntimeController,
type HostRuntimeSnapshot,
} from "@/runtime/host-runtime";
import type { HostProfile } from "@/types/host-connection";
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
import { useWorkspaceFields } from "@/stores/session-store-hooks";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
vi.mock("@react-native-async-storage/async-storage", () => ({
default: {
getItem: vi.fn().mockResolvedValue(null),
setItem: vi.fn().mockResolvedValue(undefined),
removeItem: vi.fn().mockResolvedValue(undefined),
},
}));
const SERVER_ID = "sidebar-render-count";
interface RenderCounts {
frame: number;
headers: Record<string, number>;
rows: Record<string, number>;
projectSelection: Record<string, number>;
rowSelection: Record<string, number>;
}
const runningScript: WorkspaceScriptPayload = {
scriptName: "web",
type: "service",
hostname: "web.paseo.localhost",
port: 3000,
proxyUrl: "http://web.paseo.localhost:6767",
lifecycle: "running",
health: "healthy",
exitCode: null,
terminalId: null,
};
function workspace(input: {
id: string;
projectId: string;
projectDisplayName: string;
name: string;
status?: WorkspaceDescriptor["status"];
scripts?: WorkspaceDescriptor["scripts"];
}): WorkspaceDescriptor {
return {
id: input.id,
projectId: input.projectId,
projectDisplayName: input.projectDisplayName,
projectRootPath: `/repo/${input.projectId}`,
workspaceDirectory: `/repo/${input.projectId}/${input.id}`,
projectKind: "git",
workspaceKind: input.name === "main" ? "local_checkout" : "worktree",
name: input.name,
status: input.status ?? "done",
statusEnteredAt: null,
archivingAt: null,
diffStat: null,
scripts: input.scripts ?? [],
};
}
function createWorkspaces(): WorkspaceDescriptor[] {
return [
workspace({
id: "a-main",
projectId: "project-a",
projectDisplayName: "Project A",
name: "main",
scripts: [runningScript],
}),
workspace({
id: "a-one",
projectId: "project-a",
projectDisplayName: "Project A",
name: "one",
}),
workspace({
id: "a-two",
projectId: "project-a",
projectDisplayName: "Project A",
name: "two",
}),
workspace({
id: "b-main",
projectId: "project-b",
projectDisplayName: "Project B",
name: "main",
}),
workspace({
id: "b-one",
projectId: "project-b",
projectDisplayName: "Project B",
name: "one",
}),
workspace({
id: "b-two",
projectId: "project-b",
projectDisplayName: "Project B",
name: "two",
}),
];
}
function makeHost(): HostProfile {
const now = "2026-04-19T00:00:00.000Z";
return {
serverId: SERVER_ID,
label: "Render Count Host",
lifecycle: {},
connections: [],
preferredConnectionId: null,
createdAt: now,
updatedAt: now,
};
}
function setHostProfiles(hosts: HostProfile[]): void {
(
getHostRuntimeStore() as unknown as {
setHostsAndSync: (hosts: HostProfile[]) => void;
}
).setHostsAndSync(hosts);
}
function initializeSidebarState(workspaces: WorkspaceDescriptor[]): void {
act(() => {
setHostProfiles([makeHost()]);
useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient);
useSessionStore
.getState()
.setWorkspaces(SERVER_ID, new Map(workspaces.map((entry) => [entry.id, entry])));
useSessionStore.getState().setHasHydratedWorkspaces(SERVER_ID, true);
useSidebarOrderStore.setState({
projectOrder: ["project-a", "project-b"],
workspaceOrderByProject: {
["project-a"]: [`${SERVER_ID}:a-main`, `${SERVER_ID}:a-one`, `${SERVER_ID}:a-two`],
["project-b"]: [`${SERVER_ID}:b-main`, `${SERVER_ID}:b-one`, `${SERVER_ID}:b-two`],
},
});
});
}
function resetCounts(counts: RenderCounts): void {
counts.frame = 0;
counts.headers = {};
counts.rows = {};
counts.projectSelection = {};
counts.rowSelection = {};
}
function incrementRecord(record: Record<string, number>, key: string): void {
record[key] = (record[key] ?? 0) + 1;
}
function ProjectHeaderProbe({
project,
counts,
}: {
project: SidebarProjectEntry;
counts: RenderCounts;
}): null {
incrementRecord(counts.headers, project.projectKey);
return null;
}
function WorkspaceRowProbe({
serverId,
workspaceId,
counts,
}: {
serverId: string;
workspaceId: string;
counts: RenderCounts;
}): null {
const workspaceEntry = useWorkspaceFields(serverId, workspaceId, (entry) =>
createSidebarWorkspaceEntry({ serverId, workspace: entry }),
);
if (workspaceEntry) {
incrementRecord(counts.rows, workspaceEntry.workspaceId);
}
return null;
}
function ProjectActiveProbe({
serverId,
project,
counts,
}: {
serverId: string;
project: SidebarProjectEntry;
counts: RenderCounts;
}): null {
const activeSelection = useActiveWorkspaceSelection();
const isActive =
activeSelection?.serverId === serverId &&
project.workspaces.some((entry) => entry.workspaceId === activeSelection.workspaceId);
void isActive;
incrementRecord(counts.projectSelection, project.projectKey);
return null;
}
function WorkspaceSelectionProbe({
serverId,
workspaceId,
counts,
}: {
serverId: string;
workspaceId: string;
counts: RenderCounts;
}): null {
const activeSelection = useActiveWorkspaceSelection();
const selected =
activeSelection?.serverId === serverId && activeSelection.workspaceId === workspaceId;
void selected;
incrementRecord(counts.rowSelection, workspaceId);
return null;
}
function SidebarFrameProbe({ counts }: { counts: RenderCounts }): ReactElement {
counts.frame += 1;
const { projects } = useSidebarWorkspacesList({ hostFilter: SERVER_ID });
return (
<>
{projects.map((project) => (
<div key={project.projectKey}>
<ProjectHeaderProbe project={project} counts={counts} />
<ProjectActiveProbe serverId={SERVER_ID} project={project} counts={counts} />
{project.workspaces.map((entry) => (
<React.Fragment key={entry.workspaceKey}>
<WorkspaceRowProbe
serverId={entry.serverId}
workspaceId={entry.workspaceId}
counts={counts}
/>
<WorkspaceSelectionProbe
serverId={entry.serverId}
workspaceId={entry.workspaceId}
counts={counts}
/>
</React.Fragment>
))}
</div>
))}
</>
);
}
function getHostController(): HostRuntimeController {
const controllers = (
getHostRuntimeStore() as unknown as {
controllers: Map<string, HostRuntimeController>;
}
).controllers;
const controller = controllers.get(SERVER_ID);
if (!controller) {
throw new Error("Host runtime controller was not initialized");
}
return controller;
}
function updateControllerSnapshot(
patch: Partial<Omit<HostRuntimeSnapshot, "serverId" | "clientGeneration">>,
): void {
(
getHostController() as unknown as {
updateSnapshot: (
patch: Partial<Omit<HostRuntimeSnapshot, "serverId" | "clientGeneration">>,
) => void;
}
).updateSnapshot(patch);
}
async function renderProbe(counts: RenderCounts): Promise<{ root: Root; container: HTMLElement }> {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
await act(async () => {
renderSidebarFrame(root, counts);
});
resetCounts(counts);
return { root, container };
}
function renderSidebarFrame(root: Root, counts: RenderCounts) {
root.render(<SidebarFrameProbe counts={counts} />);
}
describe("sidebar workspace render isolation", () => {
let root: Root | null = null;
let container: HTMLElement | null = null;
beforeEach(async () => {
initializeSidebarState(createWorkspaces());
});
afterEach(() => {
if (root) {
act(() => {
root?.unmount();
});
}
root = null;
container?.remove();
container = null;
act(() => {
pathnameState.value = "/";
setHostProfiles([]);
useSessionStore.getState().clearSession(SERVER_ID);
useSidebarOrderStore.setState({
projectOrder: [],
workspaceOrderByProject: {},
});
});
});
it("re-renders only the changed workspace row for a status update", async () => {
const counts: RenderCounts = {
frame: 0,
headers: {},
rows: {},
projectSelection: {},
rowSelection: {},
};
({ root, container } = await renderProbe(counts));
act(() => {
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [
{
...createWorkspaces()[1],
status: "running",
},
]);
});
expect(counts.frame).toBe(0);
expect(counts.headers).toEqual({});
expect(counts.rows).toEqual({ "a-one": 1 });
});
it("does not re-render the sidebar for a host-runtime probe tick with no content change", async () => {
const counts: RenderCounts = {
frame: 0,
headers: {},
rows: {},
projectSelection: {},
rowSelection: {},
};
({ root, container } = await renderProbe(counts));
act(() => {
const probeByConnectionId = getHostController().getSnapshot().probeByConnectionId;
updateControllerSnapshot({
probeByConnectionId: new Map(probeByConnectionId),
});
});
expect(counts).toEqual({
frame: 0,
headers: {},
rows: {},
projectSelection: {},
rowSelection: {},
});
});
it("does not re-render for a deep-equal scripts patch", async () => {
const counts: RenderCounts = {
frame: 0,
headers: {},
rows: {},
projectSelection: {},
rowSelection: {},
};
({ root, container } = await renderProbe(counts));
const applyRunningScript = (current: Parameters<typeof patchWorkspaceScripts>[0]) =>
patchWorkspaceScripts(current, {
workspaceId: "a-main",
scripts: [{ ...runningScript }],
});
act(() => {
useSessionStore.getState().setWorkspaces(SERVER_ID, applyRunningScript);
});
expect(counts).toEqual({
frame: 0,
headers: {},
rows: {},
projectSelection: {},
rowSelection: {},
});
});
it("updates active selection probes from the active workspace route", async () => {
const counts: RenderCounts = {
frame: 0,
headers: {},
rows: {},
projectSelection: {},
rowSelection: {},
};
act(() => {
pathnameState.value = `/h/${SERVER_ID}/workspace/a-one`;
});
({ root, container } = await renderProbe(counts));
act(() => {
pathnameState.value = `/h/${SERVER_ID}/workspace/b-two`;
if (root) {
renderSidebarFrame(root, counts);
}
});
expect(counts.frame).toBe(1);
expect(counts.projectSelection).toEqual({
"project-a": 1,
"project-b": 1,
});
expect(counts.rowSelection).toEqual({
"a-main": 1,
"a-one": 1,
"a-two": 1,
"b-main": 1,
"b-one": 1,
"b-two": 1,
});
});
});