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:
@@ -1,10 +1,28 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import type {
|
||||
DaemonClient,
|
||||
FetchAgentHistoryEntry,
|
||||
FetchAgentHistoryOptions,
|
||||
} from "@getpaseo/client/internal/daemon-client";
|
||||
import { type AgentHistoryClient, fetchAgentHistoryPage } from "./use-agent-history";
|
||||
import type { AgentHistoryClient, AgentHistoryHost } from "./use-agent-history";
|
||||
import { allAgentHistoryQueryKey } from "./agent-history-query-key";
|
||||
|
||||
(
|
||||
globalThis as unknown as {
|
||||
__DEV__: boolean;
|
||||
}
|
||||
).__DEV__ = false;
|
||||
|
||||
type UseAgentHistoryModule = typeof import("./use-agent-history");
|
||||
|
||||
let fetchAgentHistoryBatch: UseAgentHistoryModule["fetchAgentHistoryBatch"];
|
||||
let fetchAgentHistoryPage: UseAgentHistoryModule["fetchAgentHistoryPage"];
|
||||
|
||||
beforeAll(async () => {
|
||||
const module = await import("./use-agent-history");
|
||||
fetchAgentHistoryBatch = module.fetchAgentHistoryBatch;
|
||||
fetchAgentHistoryPage = module.fetchAgentHistoryPage;
|
||||
});
|
||||
|
||||
type FetchAgentHistory = DaemonClient["fetchAgentHistory"];
|
||||
type FetchAgentHistoryResult = Awaited<ReturnType<FetchAgentHistory>>;
|
||||
@@ -30,6 +48,17 @@ function createClient(pages: FetchAgentHistoryResult[]): FakeAgentHistoryClient
|
||||
};
|
||||
}
|
||||
|
||||
function createFailingClient(): FakeAgentHistoryClient {
|
||||
const calls: FetchAgentHistoryOptions[] = [];
|
||||
return {
|
||||
calls,
|
||||
fetchAgentHistory: async (options) => {
|
||||
calls.push(options ?? {});
|
||||
throw new Error("Host history failed");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function historyPayload(input: {
|
||||
entries: FetchAgentHistoryEntry[];
|
||||
hasMore?: boolean;
|
||||
@@ -105,6 +134,12 @@ function historyEntry(input: {
|
||||
}
|
||||
|
||||
describe("fetchAgentHistoryPage", () => {
|
||||
it("builds the all-host query key independent of host order", () => {
|
||||
expect(allAgentHistoryQueryKey(["server-b", "server-a"])).toEqual(
|
||||
allAgentHistoryQueryKey(["server-a", "server-b"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("requests the first page with the default limit and updated_at descending sort", async () => {
|
||||
const client = createClient([
|
||||
historyPayload({
|
||||
@@ -206,4 +241,134 @@ describe("fetchAgentHistoryPage", () => {
|
||||
|
||||
expect(page.agents[0]?.archivedAt).toEqual(new Date("2026-04-01T10:05:00.000Z"));
|
||||
});
|
||||
|
||||
it("fetches and sorts history across hosts with host labels", async () => {
|
||||
const serverAClient = createClient([
|
||||
historyPayload({
|
||||
entries: [
|
||||
historyEntry({
|
||||
id: "older-a",
|
||||
cwd: "/repo/a",
|
||||
updatedAt: "2026-04-01T10:00:00.000Z",
|
||||
title: "Older A",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
]);
|
||||
const serverBClient = createClient([
|
||||
historyPayload({
|
||||
entries: [
|
||||
historyEntry({
|
||||
id: "newer-b",
|
||||
cwd: "/repo/b",
|
||||
updatedAt: "2026-04-02T10:00:00.000Z",
|
||||
title: "Newer B",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
]);
|
||||
|
||||
const page = await fetchAgentHistoryBatch({
|
||||
hosts: [
|
||||
{ serverId: "server-a", serverLabel: "MacBook", client: serverAClient },
|
||||
{ serverId: "server-b", serverLabel: "Linux box", client: serverBClient },
|
||||
] satisfies AgentHistoryHost[],
|
||||
cursorByServerId: null,
|
||||
});
|
||||
|
||||
expect(page.agents.map((agent) => `${agent.serverLabel}:${agent.id}`)).toEqual([
|
||||
"Linux box:newer-b",
|
||||
"MacBook:older-a",
|
||||
]);
|
||||
});
|
||||
|
||||
it("fetches only hosts with a cursor when loading the next all-host page", async () => {
|
||||
const serverAClient = createClient([
|
||||
historyPayload({
|
||||
entries: [
|
||||
historyEntry({
|
||||
id: "next-a",
|
||||
cwd: "/repo/a",
|
||||
updatedAt: "2026-04-01T10:00:00.000Z",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
]);
|
||||
const serverBClient = createClient([
|
||||
historyPayload({
|
||||
entries: [
|
||||
historyEntry({
|
||||
id: "next-b",
|
||||
cwd: "/repo/b",
|
||||
updatedAt: "2026-04-02T10:00:00.000Z",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
]);
|
||||
|
||||
const page = await fetchAgentHistoryBatch({
|
||||
hosts: [
|
||||
{ serverId: "server-a", serverLabel: "MacBook", client: serverAClient },
|
||||
{ serverId: "server-b", serverLabel: "Linux box", client: serverBClient },
|
||||
] satisfies AgentHistoryHost[],
|
||||
cursorByServerId: { "server-b": "cursor-b" },
|
||||
});
|
||||
|
||||
expect(page.agents.map((agent) => agent.id)).toEqual(["next-b"]);
|
||||
expect(serverAClient.calls).toEqual([]);
|
||||
expect(serverBClient.calls).toEqual([
|
||||
{
|
||||
sort: [{ key: "updated_at", direction: "desc" }],
|
||||
page: { limit: 200, cursor: "cursor-b" },
|
||||
} satisfies FetchAgentHistoryOptions,
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps fulfilled host history when another host fails", async () => {
|
||||
const failedClient = createFailingClient();
|
||||
const healthyClient = createClient([
|
||||
historyPayload({
|
||||
entries: [
|
||||
historyEntry({
|
||||
id: "healthy-history",
|
||||
cwd: "/repo/healthy",
|
||||
updatedAt: "2026-04-02T10:00:00.000Z",
|
||||
}),
|
||||
],
|
||||
hasMore: true,
|
||||
nextCursor: "healthy-cursor",
|
||||
}),
|
||||
]);
|
||||
|
||||
const page = await fetchAgentHistoryBatch({
|
||||
hosts: [
|
||||
{ serverId: "failed-host", serverLabel: "Failed", client: failedClient },
|
||||
{ serverId: "healthy-host", serverLabel: "Healthy", client: healthyClient },
|
||||
] satisfies AgentHistoryHost[],
|
||||
cursorByServerId: null,
|
||||
});
|
||||
|
||||
expect(page.agents.map((agent) => `${agent.serverLabel}:${agent.id}`)).toEqual([
|
||||
"Healthy:healthy-history",
|
||||
]);
|
||||
expect(page.pageInfoByServerId).toEqual({
|
||||
"healthy-host": {
|
||||
nextCursor: "healthy-cursor",
|
||||
prevCursor: null,
|
||||
hasMore: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("throws when every requested host history fetch fails", async () => {
|
||||
await expect(
|
||||
fetchAgentHistoryBatch({
|
||||
hosts: [
|
||||
{ serverId: "failed-a", serverLabel: "Failed A", client: createFailingClient() },
|
||||
{ serverId: "failed-b", serverLabel: "Failed B", client: createFailingClient() },
|
||||
] satisfies AgentHistoryHost[],
|
||||
cursorByServerId: null,
|
||||
}),
|
||||
).rejects.toThrow("No connected hosts could load agent history");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user