* 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
269 lines
8.4 KiB
TypeScript
269 lines
8.4 KiB
TypeScript
import type {
|
|
DaemonClient,
|
|
FetchAgentHistoryOptions,
|
|
FetchAgentHistoryPageInfo,
|
|
} from "@getpaseo/client/internal/daemon-client";
|
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
|
import { useCallback, useMemo, useSyncExternalStore } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
|
import { getHostRuntimeStore, isHostRuntimeConnected, useHosts } from "@/runtime/host-runtime";
|
|
import { buildAgentDirectoryState } from "@/utils/agent-directory-sync";
|
|
import { agentHistoryQueryKey, allAgentHistoryQueryKey } from "./agent-history-query-key";
|
|
|
|
const AGENT_HISTORY_PAGE_LIMIT = 200;
|
|
const AGENT_HISTORY_SORT: NonNullable<FetchAgentHistoryOptions["sort"]> = [
|
|
{ key: "updated_at", direction: "desc" },
|
|
];
|
|
const AGENT_HISTORY_ALL_HOSTS_FAILED_MESSAGE = "No connected hosts could load agent history";
|
|
|
|
export interface AgentHistoryResult {
|
|
agents: AggregatedAgent[];
|
|
isLoading: boolean;
|
|
isInitialLoad: boolean;
|
|
isRevalidating: boolean;
|
|
isError: boolean;
|
|
hasMore: boolean;
|
|
isLoadingMore: boolean;
|
|
refreshAll: () => Promise<void>;
|
|
loadMore: () => void;
|
|
}
|
|
|
|
export interface AgentHistoryPage {
|
|
agents: AggregatedAgent[];
|
|
pageInfo: FetchAgentHistoryPageInfo;
|
|
}
|
|
|
|
export type AgentHistoryClient = Pick<DaemonClient, "fetchAgentHistory">;
|
|
|
|
export interface AgentHistoryHost {
|
|
serverId: string;
|
|
serverLabel: string;
|
|
client: AgentHistoryClient;
|
|
}
|
|
|
|
interface AgentHistoryBatchPage {
|
|
agents: AggregatedAgent[];
|
|
pageInfoByServerId: Record<string, FetchAgentHistoryPageInfo>;
|
|
}
|
|
|
|
type AgentHistoryCursorByServerId = Record<string, string | null>;
|
|
|
|
export async function fetchAgentHistoryPage(input: {
|
|
client: AgentHistoryClient;
|
|
serverId: string;
|
|
cursor: string | null;
|
|
}): Promise<AgentHistoryPage> {
|
|
const payload = await input.client.fetchAgentHistory({
|
|
sort: AGENT_HISTORY_SORT,
|
|
page: input.cursor
|
|
? { limit: AGENT_HISTORY_PAGE_LIMIT, cursor: input.cursor }
|
|
: { limit: AGENT_HISTORY_PAGE_LIMIT },
|
|
});
|
|
|
|
const { agents } = buildAgentDirectoryState({
|
|
serverId: input.serverId,
|
|
entries: payload.entries,
|
|
});
|
|
|
|
return {
|
|
agents: Array.from(agents.values(), (agent) => ({
|
|
id: agent.id,
|
|
serverId: input.serverId,
|
|
serverLabel: input.serverId,
|
|
title: agent.title ?? null,
|
|
status: agent.status,
|
|
lastActivityAt: agent.lastActivityAt,
|
|
cwd: agent.cwd,
|
|
workspaceId: agent.workspaceId,
|
|
provider: agent.provider,
|
|
pendingPermissionCount: agent.pendingPermissions.length,
|
|
requiresAttention: agent.requiresAttention,
|
|
attentionReason: agent.attentionReason,
|
|
attentionTimestamp: agent.attentionTimestamp ?? null,
|
|
archivedAt: agent.archivedAt ?? null,
|
|
createdAt: agent.createdAt,
|
|
labels: agent.labels,
|
|
projectPlacement: agent.projectPlacement,
|
|
})),
|
|
pageInfo: payload.pageInfo,
|
|
};
|
|
}
|
|
|
|
function sortByLatestActivity(agents: AggregatedAgent[]): AggregatedAgent[] {
|
|
return [...agents].sort((a, b) => b.lastActivityAt.getTime() - a.lastActivityAt.getTime());
|
|
}
|
|
|
|
function getNextAgentHistoryPageParam(
|
|
page: AgentHistoryBatchPage,
|
|
): AgentHistoryCursorByServerId | null {
|
|
const cursorByServerId: AgentHistoryCursorByServerId = {};
|
|
for (const [serverId, pageInfo] of Object.entries(page.pageInfoByServerId)) {
|
|
if (pageInfo.hasMore && pageInfo.nextCursor) {
|
|
cursorByServerId[serverId] = pageInfo.nextCursor;
|
|
}
|
|
}
|
|
|
|
return Object.keys(cursorByServerId).length > 0 ? cursorByServerId : null;
|
|
}
|
|
|
|
export async function fetchAgentHistoryBatch(input: {
|
|
hosts: readonly AgentHistoryHost[];
|
|
cursorByServerId: AgentHistoryCursorByServerId | null;
|
|
}): Promise<AgentHistoryBatchPage> {
|
|
const cursorByServerId = input.cursorByServerId ?? {};
|
|
const hasCursorFilter = Object.keys(cursorByServerId).length > 0;
|
|
const hostsToFetch = hasCursorFilter
|
|
? input.hosts.filter((host) => Object.hasOwn(cursorByServerId, host.serverId))
|
|
: input.hosts;
|
|
|
|
const settledPages = await Promise.allSettled(
|
|
hostsToFetch.map(async (host) => {
|
|
const page = await fetchAgentHistoryPage({
|
|
client: host.client,
|
|
serverId: host.serverId,
|
|
cursor: cursorByServerId[host.serverId] ?? null,
|
|
});
|
|
return { host, page };
|
|
}),
|
|
);
|
|
const pages = settledPages.flatMap((result) =>
|
|
result.status === "fulfilled" ? [result.value] : [],
|
|
);
|
|
if (pages.length === 0) {
|
|
throw new Error(AGENT_HISTORY_ALL_HOSTS_FAILED_MESSAGE);
|
|
}
|
|
|
|
const agents = pages.flatMap(({ host, page }) =>
|
|
page.agents.map((agent) => Object.assign({}, agent, { serverLabel: host.serverLabel })),
|
|
);
|
|
const pageInfoByServerId = Object.fromEntries(
|
|
pages.map(({ host, page }) => [host.serverId, page.pageInfo]),
|
|
);
|
|
|
|
return {
|
|
agents: sortByLatestActivity(agents),
|
|
pageInfoByServerId,
|
|
};
|
|
}
|
|
|
|
export function useAgentHistory(options: {
|
|
serverId?: string | null;
|
|
enabled?: boolean;
|
|
}): AgentHistoryResult {
|
|
const { t } = useTranslation();
|
|
const daemons = useHosts();
|
|
const runtime = getHostRuntimeStore();
|
|
const runtimeVersion = useSyncExternalStore(
|
|
(onStoreChange) => runtime.subscribeAll(onStoreChange),
|
|
() => runtime.getVersion(),
|
|
() => runtime.getVersion(),
|
|
);
|
|
const serverId = useMemo(() => {
|
|
const value = options.serverId;
|
|
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
|
}, [options.serverId]);
|
|
const enabled = options.enabled ?? true;
|
|
const targetHosts = useMemo(() => {
|
|
void runtimeVersion;
|
|
const serverLabelById = new Map(daemons.map((daemon) => [daemon.serverId, daemon.label]));
|
|
const serverIds = serverId ? [serverId] : daemons.map((daemon) => daemon.serverId);
|
|
const hosts: AgentHistoryHost[] = [];
|
|
|
|
for (const targetServerId of serverIds) {
|
|
const snapshot = runtime.getSnapshot(targetServerId);
|
|
const client = runtime.getClient(targetServerId);
|
|
if (!client || !isHostRuntimeConnected(snapshot)) {
|
|
continue;
|
|
}
|
|
hosts.push({
|
|
serverId: targetServerId,
|
|
serverLabel: serverLabelById.get(targetServerId) ?? targetServerId,
|
|
client,
|
|
});
|
|
}
|
|
|
|
return hosts;
|
|
}, [daemons, runtime, runtimeVersion, serverId]);
|
|
const targetServerIds = useMemo(() => targetHosts.map((host) => host.serverId), [targetHosts]);
|
|
const queryKey = useMemo(
|
|
() => (serverId ? agentHistoryQueryKey(serverId) : allAgentHistoryQueryKey(targetServerIds)),
|
|
[serverId, targetServerIds],
|
|
);
|
|
const serverLabelById = useMemo(
|
|
() => new Map(daemons.map((daemon) => [daemon.serverId, daemon.label])),
|
|
[daemons],
|
|
);
|
|
|
|
const historyQuery = useInfiniteQuery<
|
|
AgentHistoryBatchPage,
|
|
Error,
|
|
{ pages: AgentHistoryBatchPage[] },
|
|
readonly unknown[],
|
|
AgentHistoryCursorByServerId | null
|
|
>({
|
|
queryKey,
|
|
enabled: Boolean(enabled && targetHosts.length > 0),
|
|
staleTime: 30_000,
|
|
initialPageParam: null,
|
|
getNextPageParam: getNextAgentHistoryPageParam,
|
|
queryFn: async ({ pageParam }) => {
|
|
if (targetHosts.length === 0) {
|
|
throw new Error(t("workspace.terminal.hostDisconnected"));
|
|
}
|
|
return fetchAgentHistoryBatch({
|
|
hosts: targetHosts,
|
|
cursorByServerId: pageParam,
|
|
});
|
|
},
|
|
});
|
|
const {
|
|
data,
|
|
fetchNextPage,
|
|
hasNextPage,
|
|
isError,
|
|
isFetching,
|
|
isFetchingNextPage,
|
|
isLoading,
|
|
refetch,
|
|
} = historyQuery;
|
|
|
|
const refreshAll = useCallback(async () => {
|
|
if (!enabled || targetHosts.length === 0) {
|
|
return;
|
|
}
|
|
await refetch();
|
|
}, [enabled, refetch, targetHosts.length]);
|
|
|
|
const loadMore = useCallback(() => {
|
|
if (!enabled || targetHosts.length === 0 || !hasNextPage || isFetchingNextPage) {
|
|
return;
|
|
}
|
|
void fetchNextPage();
|
|
}, [enabled, fetchNextPage, hasNextPage, isFetchingNextPage, targetHosts.length]);
|
|
|
|
const agents = useMemo(() => {
|
|
const historyAgents = (data?.pages ?? []).flatMap((page) => page.agents);
|
|
const labelledAgents = historyAgents.map((agent) =>
|
|
Object.assign({}, agent, {
|
|
serverLabel: serverLabelById.get(agent.serverId) ?? agent.serverLabel,
|
|
}),
|
|
);
|
|
return sortByLatestActivity(labelledAgents);
|
|
}, [data?.pages, serverLabelById]);
|
|
const isInitialLoad = isLoading && agents.length === 0;
|
|
const isRevalidating = isFetching && !isFetchingNextPage && agents.length > 0;
|
|
|
|
return {
|
|
agents,
|
|
isLoading,
|
|
isInitialLoad,
|
|
isRevalidating,
|
|
isError,
|
|
hasMore: hasNextPage,
|
|
isLoadingMore: isFetchingNextPage,
|
|
refreshAll,
|
|
loadMore,
|
|
};
|
|
}
|