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:
@@ -4,26 +4,28 @@ import type {
|
||||
FetchAgentHistoryPageInfo,
|
||||
} from "@getpaseo/client/internal/daemon-client";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useCallback, useMemo, useSyncExternalStore } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected, useHosts } from "@/runtime/host-runtime";
|
||||
import { getHostRuntimeStore, isHostRuntimeConnected, useHosts } from "@/runtime/host-runtime";
|
||||
import { buildAgentDirectoryState } from "@/utils/agent-directory-sync";
|
||||
import { agentHistoryQueryKey } from "./agent-history-query-key";
|
||||
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: () => void;
|
||||
refreshAll: () => Promise<void>;
|
||||
loadMore: () => void;
|
||||
}
|
||||
|
||||
@@ -34,6 +36,19 @@ export interface AgentHistoryPage {
|
||||
|
||||
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;
|
||||
@@ -75,70 +90,167 @@ export async function fetchAgentHistoryPage(input: {
|
||||
};
|
||||
}
|
||||
|
||||
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 client = useHostRuntimeClient(serverId ?? "");
|
||||
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
|
||||
const queryKey = useMemo(() => agentHistoryQueryKey(serverId), [serverId]);
|
||||
const serverLabel = daemons.find((daemon) => daemon.serverId === serverId)?.label ?? serverId;
|
||||
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<
|
||||
AgentHistoryPage,
|
||||
AgentHistoryBatchPage,
|
||||
Error,
|
||||
{ pages: AgentHistoryPage[] },
|
||||
ReturnType<typeof agentHistoryQueryKey>,
|
||||
string | null
|
||||
{ pages: AgentHistoryBatchPage[] },
|
||||
readonly unknown[],
|
||||
AgentHistoryCursorByServerId | null
|
||||
>({
|
||||
queryKey,
|
||||
enabled: Boolean(enabled && serverId && client && isConnected),
|
||||
enabled: Boolean(enabled && targetHosts.length > 0),
|
||||
staleTime: 30_000,
|
||||
initialPageParam: null as string | null,
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.pageInfo.hasMore ? lastPage.pageInfo.nextCursor : null,
|
||||
initialPageParam: null,
|
||||
getNextPageParam: getNextAgentHistoryPageParam,
|
||||
queryFn: async ({ pageParam }) => {
|
||||
if (!serverId || !client) {
|
||||
if (targetHosts.length === 0) {
|
||||
throw new Error(t("workspace.terminal.hostDisconnected"));
|
||||
}
|
||||
return fetchAgentHistoryPage({ client, serverId, cursor: pageParam });
|
||||
return fetchAgentHistoryBatch({
|
||||
hosts: targetHosts,
|
||||
cursorByServerId: pageParam,
|
||||
});
|
||||
},
|
||||
});
|
||||
const { data, fetchNextPage, hasNextPage, isFetching, isFetchingNextPage, isLoading, refetch } =
|
||||
historyQuery;
|
||||
const {
|
||||
data,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isError,
|
||||
isFetching,
|
||||
isFetchingNextPage,
|
||||
isLoading,
|
||||
refetch,
|
||||
} = historyQuery;
|
||||
|
||||
const refreshAll = useCallback(() => {
|
||||
if (!serverId || !client || !isConnected) {
|
||||
const refreshAll = useCallback(async () => {
|
||||
if (!enabled || targetHosts.length === 0) {
|
||||
return;
|
||||
}
|
||||
void refetch();
|
||||
}, [client, isConnected, refetch, serverId]);
|
||||
await refetch();
|
||||
}, [enabled, refetch, targetHosts.length]);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
if (!serverId || !client || !isConnected || !hasNextPage || isFetchingNextPage) {
|
||||
if (!enabled || targetHosts.length === 0 || !hasNextPage || isFetchingNextPage) {
|
||||
return;
|
||||
}
|
||||
void fetchNextPage();
|
||||
}, [client, fetchNextPage, hasNextPage, isConnected, isFetchingNextPage, serverId]);
|
||||
}, [enabled, fetchNextPage, hasNextPage, isFetchingNextPage, targetHosts.length]);
|
||||
|
||||
const agents = useMemo(
|
||||
() =>
|
||||
(data?.pages ?? [])
|
||||
.flatMap((page) => page.agents)
|
||||
.map((agent) =>
|
||||
Object.assign({}, agent, {
|
||||
serverLabel: serverLabel ?? agent.serverLabel,
|
||||
}),
|
||||
),
|
||||
[data?.pages, serverLabel],
|
||||
);
|
||||
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;
|
||||
|
||||
@@ -147,6 +259,7 @@ export function useAgentHistory(options: {
|
||||
isLoading,
|
||||
isInitialLoad,
|
||||
isRevalidating,
|
||||
isError,
|
||||
hasMore: hasNextPage,
|
||||
isLoadingMore: isFetchingNextPage,
|
||||
refreshAll,
|
||||
|
||||
Reference in New Issue
Block a user