* 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
375 lines
10 KiB
TypeScript
375 lines
10 KiB
TypeScript
import { beforeAll, describe, expect, it } from "vitest";
|
|
import type {
|
|
DaemonClient,
|
|
FetchAgentHistoryEntry,
|
|
FetchAgentHistoryOptions,
|
|
} from "@getpaseo/client/internal/daemon-client";
|
|
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>>;
|
|
|
|
interface FakeAgentHistoryClient extends AgentHistoryClient {
|
|
calls: FetchAgentHistoryOptions[];
|
|
}
|
|
|
|
function createClient(pages: FetchAgentHistoryResult[]): FakeAgentHistoryClient {
|
|
const calls: FetchAgentHistoryOptions[] = [];
|
|
let index = 0;
|
|
return {
|
|
calls,
|
|
fetchAgentHistory: async (options) => {
|
|
calls.push(options ?? {});
|
|
const page = pages[index] ?? pages[pages.length - 1];
|
|
index += 1;
|
|
if (!page) {
|
|
throw new Error("No more history pages configured");
|
|
}
|
|
return page;
|
|
},
|
|
};
|
|
}
|
|
|
|
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;
|
|
nextCursor?: string | null;
|
|
}): FetchAgentHistoryResult {
|
|
return {
|
|
requestId: "req_history",
|
|
entries: input.entries,
|
|
pageInfo: {
|
|
nextCursor: input.nextCursor ?? null,
|
|
prevCursor: null,
|
|
hasMore: input.hasMore ?? false,
|
|
},
|
|
};
|
|
}
|
|
|
|
function historyEntry(input: {
|
|
id: string;
|
|
cwd: string;
|
|
updatedAt: string;
|
|
title?: string | null;
|
|
archivedAt?: string | null;
|
|
}): FetchAgentHistoryEntry {
|
|
return {
|
|
agent: {
|
|
id: input.id,
|
|
provider: "codex",
|
|
status: "closed",
|
|
createdAt: input.updatedAt,
|
|
updatedAt: input.updatedAt,
|
|
lastUserMessageAt: null,
|
|
lastError: undefined,
|
|
runtimeInfo: {
|
|
provider: "codex",
|
|
sessionId: null,
|
|
},
|
|
capabilities: {
|
|
supportsStreaming: true,
|
|
supportsSessionPersistence: true,
|
|
supportsDynamicModes: true,
|
|
supportsMcpServers: true,
|
|
supportsReasoningStream: true,
|
|
supportsToolInvocations: true,
|
|
},
|
|
currentModeId: null,
|
|
availableModes: [],
|
|
pendingPermissions: [],
|
|
persistence: null,
|
|
title: input.title ?? null,
|
|
cwd: input.cwd,
|
|
model: null,
|
|
thinkingOptionId: null,
|
|
requiresAttention: false,
|
|
attentionReason: null,
|
|
attentionTimestamp: null,
|
|
archivedAt: input.archivedAt ?? null,
|
|
labels: {},
|
|
},
|
|
project: {
|
|
projectKey: input.cwd,
|
|
projectName: "workspace",
|
|
checkout: {
|
|
cwd: input.cwd,
|
|
isGit: false,
|
|
currentBranch: null,
|
|
remoteUrl: null,
|
|
worktreeRoot: null,
|
|
isPaseoOwnedWorktree: false,
|
|
mainRepoRoot: null,
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
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({
|
|
entries: [
|
|
historyEntry({
|
|
id: "history-1",
|
|
cwd: "/repo",
|
|
updatedAt: "2026-04-02T10:00:00.000Z",
|
|
title: "History one",
|
|
}),
|
|
],
|
|
hasMore: true,
|
|
nextCursor: "cursor-2",
|
|
}),
|
|
]);
|
|
|
|
const page = await fetchAgentHistoryPage({ client, serverId: "server-1", cursor: null });
|
|
|
|
expect(client.calls).toEqual([
|
|
{
|
|
sort: [{ key: "updated_at", direction: "desc" }],
|
|
page: { limit: 200 },
|
|
} satisfies FetchAgentHistoryOptions,
|
|
]);
|
|
expect(page.agents.map((agent) => agent.id)).toEqual(["history-1"]);
|
|
expect(page.pageInfo).toEqual({
|
|
nextCursor: "cursor-2",
|
|
prevCursor: null,
|
|
hasMore: true,
|
|
});
|
|
});
|
|
|
|
it("passes the cursor when fetching subsequent pages", async () => {
|
|
const client = createClient([
|
|
historyPayload({
|
|
entries: [
|
|
historyEntry({
|
|
id: "history-2",
|
|
cwd: "/repo",
|
|
updatedAt: "2026-04-01T10:00:00.000Z",
|
|
title: "History two",
|
|
}),
|
|
],
|
|
}),
|
|
]);
|
|
|
|
await fetchAgentHistoryPage({ client, serverId: "server-1", cursor: "cursor-2" });
|
|
|
|
expect(client.calls.at(-1)).toEqual({
|
|
sort: [{ key: "updated_at", direction: "desc" }],
|
|
page: { limit: 200, cursor: "cursor-2" },
|
|
} satisfies FetchAgentHistoryOptions);
|
|
});
|
|
|
|
it("maps daemon history entries into aggregated agents tagged with the requested server", async () => {
|
|
const client = createClient([
|
|
historyPayload({
|
|
entries: [
|
|
historyEntry({
|
|
id: "history-1",
|
|
cwd: "/repo",
|
|
updatedAt: "2026-04-02T10:00:00.000Z",
|
|
title: "History one",
|
|
}),
|
|
],
|
|
}),
|
|
]);
|
|
|
|
const page = await fetchAgentHistoryPage({ client, serverId: "server-1", cursor: null });
|
|
|
|
expect(page.agents).toEqual([
|
|
expect.objectContaining({
|
|
id: "history-1",
|
|
serverId: "server-1",
|
|
serverLabel: "server-1",
|
|
title: "History one",
|
|
cwd: "/repo",
|
|
provider: "codex",
|
|
archivedAt: null,
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it("carries archived entries through with their archivedAt timestamp", async () => {
|
|
const client = createClient([
|
|
historyPayload({
|
|
entries: [
|
|
historyEntry({
|
|
id: "history-archived",
|
|
cwd: "/repo",
|
|
updatedAt: "2026-04-01T10:00:00.000Z",
|
|
archivedAt: "2026-04-01T10:05:00.000Z",
|
|
}),
|
|
],
|
|
}),
|
|
]);
|
|
|
|
const page = await fetchAgentHistoryPage({ client, serverId: "server-1", cursor: null });
|
|
|
|
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");
|
|
});
|
|
});
|