Improve startup provider snapshots and dev harness
This commit is contained in:
@@ -21,6 +21,7 @@ import { getProviderIcon } from "@/components/provider-icons";
|
||||
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
|
||||
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
|
||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
|
||||
interface AgentListProps {
|
||||
agents: AggregatedAgent[];
|
||||
@@ -37,6 +38,62 @@ type FlatListItem =
|
||||
| { type: "header"; key: string; title: string }
|
||||
| { type: "agent"; key: string; agent: AggregatedAgent };
|
||||
|
||||
function buildHistoricalAgentDetail(agent: AggregatedAgent): Agent {
|
||||
return {
|
||||
serverId: agent.serverId,
|
||||
id: agent.id,
|
||||
provider: agent.provider,
|
||||
status: agent.status,
|
||||
createdAt: agent.createdAt,
|
||||
updatedAt: agent.lastActivityAt,
|
||||
lastUserMessageAt: null,
|
||||
lastActivityAt: agent.lastActivityAt,
|
||||
capabilities: {
|
||||
supportsStreaming: false,
|
||||
supportsSessionPersistence: false,
|
||||
supportsDynamicModes: false,
|
||||
supportsMcpServers: false,
|
||||
supportsReasoningStream: false,
|
||||
supportsToolInvocations: false,
|
||||
},
|
||||
currentModeId: null,
|
||||
availableModes: [],
|
||||
pendingPermissions: [],
|
||||
persistence: null,
|
||||
runtimeInfo: {
|
||||
provider: agent.provider,
|
||||
sessionId: null,
|
||||
},
|
||||
title: agent.title,
|
||||
cwd: agent.cwd,
|
||||
model: null,
|
||||
thinkingOptionId: null,
|
||||
requiresAttention: agent.requiresAttention,
|
||||
attentionReason: agent.attentionReason,
|
||||
attentionTimestamp: agent.attentionTimestamp,
|
||||
archivedAt: agent.archivedAt,
|
||||
labels: agent.labels,
|
||||
};
|
||||
}
|
||||
|
||||
function rememberArchivedAgentDetail(agent: AggregatedAgent) {
|
||||
if (!agent.archivedAt) {
|
||||
return;
|
||||
}
|
||||
|
||||
useSessionStore.getState().setAgentDetails(agent.serverId, (previous) => {
|
||||
const existing = previous.get(agent.id);
|
||||
const next = new Map(previous);
|
||||
next.set(agent.id, {
|
||||
...buildHistoricalAgentDetail(agent),
|
||||
...existing,
|
||||
archivedAt: existing?.archivedAt ?? agent.archivedAt,
|
||||
cwd: existing?.cwd ?? agent.cwd,
|
||||
});
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function deriveDateSectionLabel(lastActivityAt: Date): string {
|
||||
const now = new Date();
|
||||
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
@@ -251,6 +308,7 @@ export function AgentList({
|
||||
return;
|
||||
}
|
||||
|
||||
rememberArchivedAgentDetail(agent);
|
||||
const route = prepareWorkspaceTab({
|
||||
serverId,
|
||||
workspaceId,
|
||||
|
||||
@@ -878,7 +878,7 @@ export const AgentStatusBar = memo(function AgentStatusBar({
|
||||
entries: snapshotEntries,
|
||||
isLoading: snapshotIsLoading,
|
||||
refetchIfStale: refetchSnapshotIfStale,
|
||||
} = useProvidersSnapshot(serverId, agent?.cwd);
|
||||
} = useProvidersSnapshot(serverId);
|
||||
|
||||
const snapshotSelectedEntry = useMemo(() => {
|
||||
if (!snapshotEntries || !agent?.provider) {
|
||||
|
||||
@@ -418,7 +418,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
error: snapshotError,
|
||||
refresh: refreshSnapshot,
|
||||
refetchIfStale: refetchSnapshotIfStale,
|
||||
} = useProvidersSnapshot(formState.serverId, formState.workingDir);
|
||||
} = useProvidersSnapshot(formState.serverId);
|
||||
|
||||
const allProviderEntries = useMemo(() => snapshotEntries ?? [], [snapshotEntries]);
|
||||
const snapshotProviderDefinitions = useMemo(
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import React from "react";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type {
|
||||
DaemonClient,
|
||||
FetchAgentHistoryEntry,
|
||||
FetchAgentHistoryOptions,
|
||||
} from "@server/client/daemon-client";
|
||||
import { useSessionStore, type Agent } from "@/stores/session-store";
|
||||
import { useAgentHistory } from "./use-agent-history";
|
||||
|
||||
const { mockClient, mockRuntimeStore } = vi.hoisted(() => {
|
||||
const mockClient = {
|
||||
fetchAgentHistory: vi.fn(),
|
||||
};
|
||||
const mockRuntimeStore = {
|
||||
refreshAgentDirectory: vi.fn(),
|
||||
};
|
||||
return { mockClient, mockRuntimeStore };
|
||||
});
|
||||
|
||||
vi.mock("@/runtime/host-runtime", () => ({
|
||||
getHostRuntimeStore: () => mockRuntimeStore,
|
||||
useHostRuntimeClient: () => mockClient,
|
||||
useHostRuntimeIsConnected: () => true,
|
||||
useHosts: () => [{ serverId: "server-1", label: "Local" }],
|
||||
}));
|
||||
|
||||
function createQueryClient(): QueryClient {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function renderAgentHistoryHook(options?: { enabled?: boolean }) {
|
||||
const queryClient = createQueryClient();
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(QueryClientProvider, { client: queryClient }, children);
|
||||
|
||||
return renderHook(() => useAgentHistory({ serverId: "server-1", enabled: options?.enabled }), {
|
||||
wrapper,
|
||||
});
|
||||
}
|
||||
|
||||
function makeHistoryPayload(input: {
|
||||
entries: FetchAgentHistoryEntry[];
|
||||
hasMore?: boolean;
|
||||
nextCursor?: string | null;
|
||||
}): Awaited<ReturnType<DaemonClient["fetchAgentHistory"]>> {
|
||||
return {
|
||||
requestId: "req_history",
|
||||
entries: input.entries,
|
||||
pageInfo: {
|
||||
nextCursor: input.nextCursor ?? null,
|
||||
prevCursor: null,
|
||||
hasMore: input.hasMore ?? false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeHistoryEntry(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,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeActiveAgent(): Agent {
|
||||
const timestamp = new Date("2026-04-01T10:00:00.000Z");
|
||||
return {
|
||||
serverId: "server-1",
|
||||
id: "active-1",
|
||||
provider: "codex",
|
||||
status: "idle",
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
lastUserMessageAt: null,
|
||||
lastActivityAt: timestamp,
|
||||
capabilities: {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: true,
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
},
|
||||
currentModeId: null,
|
||||
availableModes: [],
|
||||
pendingPermissions: [],
|
||||
persistence: null,
|
||||
title: "Active",
|
||||
cwd: "/repo",
|
||||
model: null,
|
||||
labels: {},
|
||||
archivedAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
mockClient.fetchAgentHistory.mockReset();
|
||||
mockRuntimeStore.refreshAgentDirectory.mockReset();
|
||||
useSessionStore.setState({ sessions: {}, agentLastActivity: new Map() });
|
||||
});
|
||||
|
||||
describe("useAgentHistory", () => {
|
||||
it("loads history one page at a time without refreshing active agents", async () => {
|
||||
mockClient.fetchAgentHistory
|
||||
.mockResolvedValueOnce(
|
||||
makeHistoryPayload({
|
||||
entries: [
|
||||
makeHistoryEntry({
|
||||
id: "history-1",
|
||||
cwd: "/repo",
|
||||
updatedAt: "2026-04-02T10:00:00.000Z",
|
||||
title: "History one",
|
||||
}),
|
||||
],
|
||||
hasMore: true,
|
||||
nextCursor: "cursor-2",
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
makeHistoryPayload({
|
||||
entries: [
|
||||
makeHistoryEntry({
|
||||
id: "history-2",
|
||||
cwd: "/repo",
|
||||
updatedAt: "2026-04-01T10:00:00.000Z",
|
||||
title: "History two",
|
||||
archivedAt: "2026-04-01T10:05:00.000Z",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
useSessionStore
|
||||
.getState()
|
||||
.initializeSession("server-1", mockClient as unknown as DaemonClient);
|
||||
useSessionStore.getState().setAgents("server-1", new Map([["active-1", makeActiveAgent()]]));
|
||||
});
|
||||
|
||||
const { result } = renderAgentHistoryHook();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockClient.fetchAgentHistory).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(mockClient.fetchAgentHistory.mock.calls.map(([options]) => options)).toEqual([
|
||||
{
|
||||
sort: [{ key: "updated_at", direction: "desc" }],
|
||||
page: { limit: 200 },
|
||||
},
|
||||
] satisfies FetchAgentHistoryOptions[]);
|
||||
await waitFor(() => {
|
||||
expect(result.current.agents.map((agent) => agent.id)).toEqual(["history-1"]);
|
||||
});
|
||||
expect(result.current.hasMore).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
result.current.loadMore();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockClient.fetchAgentHistory).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
expect(mockClient.fetchAgentHistory.mock.calls.at(-1)?.[0]).toEqual({
|
||||
sort: [{ key: "updated_at", direction: "desc" }],
|
||||
page: { limit: 200, cursor: "cursor-2" },
|
||||
} satisfies FetchAgentHistoryOptions);
|
||||
await waitFor(() => {
|
||||
expect(result.current.agents.map((agent) => agent.id)).toEqual(["history-1", "history-2"]);
|
||||
});
|
||||
expect(result.current.hasMore).toBe(false);
|
||||
expect(
|
||||
Array.from(useSessionStore.getState().sessions["server-1"]?.agents.keys() ?? []),
|
||||
).toEqual(["active-1"]);
|
||||
expect(mockRuntimeStore.refreshAgentDirectory).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("waits until history is enabled before calling the history RPC", async () => {
|
||||
mockClient.fetchAgentHistory.mockResolvedValue(makeHistoryPayload({ entries: [] }));
|
||||
|
||||
const queryClient = createQueryClient();
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(QueryClientProvider, { client: queryClient }, children);
|
||||
let enabled = false;
|
||||
const { rerender } = renderHook(() => useAgentHistory({ serverId: "server-1", enabled }), {
|
||||
wrapper,
|
||||
});
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(mockClient.fetchAgentHistory).not.toHaveBeenCalled();
|
||||
|
||||
enabled = true;
|
||||
rerender();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockClient.fetchAgentHistory).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
import type {
|
||||
DaemonClient,
|
||||
FetchAgentHistoryOptions,
|
||||
FetchAgentHistoryPageInfo,
|
||||
} from "@server/client/daemon-client";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected, useHosts } from "@/runtime/host-runtime";
|
||||
import { buildAgentDirectoryState } from "@/utils/agent-directory-sync";
|
||||
|
||||
const AGENT_HISTORY_PAGE_LIMIT = 200;
|
||||
const AGENT_HISTORY_SORT: NonNullable<FetchAgentHistoryOptions["sort"]> = [
|
||||
{ key: "updated_at", direction: "desc" },
|
||||
];
|
||||
|
||||
export interface AgentHistoryResult {
|
||||
agents: AggregatedAgent[];
|
||||
isLoading: boolean;
|
||||
isInitialLoad: boolean;
|
||||
isRevalidating: boolean;
|
||||
hasMore: boolean;
|
||||
isLoadingMore: boolean;
|
||||
refreshAll: () => void;
|
||||
loadMore: () => void;
|
||||
}
|
||||
|
||||
type AgentHistoryPage = {
|
||||
agents: AggregatedAgent[];
|
||||
pageInfo: FetchAgentHistoryPageInfo;
|
||||
};
|
||||
|
||||
export function agentHistoryQueryKey(serverId: string | null) {
|
||||
return ["agentHistory", serverId] as const;
|
||||
}
|
||||
|
||||
async function fetchAgentHistoryPage(input: {
|
||||
client: DaemonClient;
|
||||
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,
|
||||
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,
|
||||
})),
|
||||
pageInfo: payload.pageInfo,
|
||||
};
|
||||
}
|
||||
|
||||
export function useAgentHistory(options: {
|
||||
serverId?: string | null;
|
||||
enabled?: boolean;
|
||||
}): AgentHistoryResult {
|
||||
const daemons = useHosts();
|
||||
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 historyQuery = useInfiniteQuery<
|
||||
AgentHistoryPage,
|
||||
Error,
|
||||
{ pages: AgentHistoryPage[] },
|
||||
ReturnType<typeof agentHistoryQueryKey>,
|
||||
string | null
|
||||
>({
|
||||
queryKey,
|
||||
enabled: Boolean(enabled && serverId && client && isConnected),
|
||||
staleTime: 30_000,
|
||||
initialPageParam: null as string | null,
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.pageInfo.hasMore ? lastPage.pageInfo.nextCursor : null,
|
||||
queryFn: async ({ pageParam }) => {
|
||||
if (!serverId || !client) {
|
||||
throw new Error("Host is not connected");
|
||||
}
|
||||
return fetchAgentHistoryPage({ client, serverId, cursor: pageParam });
|
||||
},
|
||||
});
|
||||
const { data, fetchNextPage, hasNextPage, isFetching, isFetchingNextPage, isLoading, refetch } =
|
||||
historyQuery;
|
||||
|
||||
const refreshAll = useCallback(() => {
|
||||
if (!serverId || !client || !isConnected) {
|
||||
return;
|
||||
}
|
||||
void refetch();
|
||||
}, [client, isConnected, refetch, serverId]);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
if (!serverId || !client || !isConnected || !hasNextPage || isFetchingNextPage) {
|
||||
return;
|
||||
}
|
||||
void fetchNextPage();
|
||||
}, [client, fetchNextPage, hasNextPage, isConnected, isFetchingNextPage, serverId]);
|
||||
|
||||
const agents = useMemo(
|
||||
() =>
|
||||
(data?.pages ?? [])
|
||||
.flatMap((page) => page.agents)
|
||||
.map((agent) => ({
|
||||
...agent,
|
||||
serverLabel: serverLabel ?? agent.serverLabel,
|
||||
})),
|
||||
[data?.pages, serverLabel],
|
||||
);
|
||||
const isInitialLoad = isLoading && agents.length === 0;
|
||||
const isRevalidating = isFetching && !isFetchingNextPage && agents.length > 0;
|
||||
|
||||
return {
|
||||
agents,
|
||||
isLoading,
|
||||
isInitialLoad,
|
||||
isRevalidating,
|
||||
hasMore: Boolean(hasNextPage),
|
||||
isLoadingMore: isFetchingNextPage,
|
||||
refreshAll,
|
||||
loadMore,
|
||||
};
|
||||
}
|
||||
|
||||
export const __private__ = {
|
||||
fetchAgentHistoryPage,
|
||||
};
|
||||
@@ -9,11 +9,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { DaemonClient } from "@server/client/daemon-client";
|
||||
import type { ProviderSnapshotEntry } from "@server/server/agent/agent-sdk-types";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import {
|
||||
providersSnapshotQueryKey,
|
||||
shouldApplyProvidersSnapshotUpdate,
|
||||
useProvidersSnapshot,
|
||||
} from "./use-providers-snapshot";
|
||||
import { providersSnapshotQueryKey, useProvidersSnapshot } from "./use-providers-snapshot";
|
||||
|
||||
type ProviderSnapshotUpdateMessage = {
|
||||
type: "providers_snapshot_update";
|
||||
@@ -80,12 +76,12 @@ function enableProvidersSnapshot(): void {
|
||||
});
|
||||
}
|
||||
|
||||
function renderProvidersSnapshotHook(cwd?: string | null) {
|
||||
function renderProvidersSnapshotHook() {
|
||||
const queryClient = createQueryClient();
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(QueryClientProvider, { client: queryClient }, children);
|
||||
|
||||
return renderHook(() => useProvidersSnapshot(serverId, cwd), { wrapper });
|
||||
return renderHook(() => useProvidersSnapshot(serverId), { wrapper });
|
||||
}
|
||||
|
||||
const readyCodexModel = { provider: "codex", id: "gpt-5.4", label: "GPT-5.4" } as const;
|
||||
@@ -124,7 +120,10 @@ async function waitForSnapshotEntries(
|
||||
});
|
||||
}
|
||||
|
||||
async function emitProvidersSnapshotUpdate(entries: ProviderSnapshotEntry[]): Promise<void> {
|
||||
async function emitProvidersSnapshotUpdate(
|
||||
entries: ProviderSnapshotEntry[],
|
||||
cwd = "/repo",
|
||||
): Promise<void> {
|
||||
const listener = snapshotUpdateListeners.at(-1);
|
||||
expect(listener).toBeDefined();
|
||||
|
||||
@@ -132,7 +131,7 @@ async function emitProvidersSnapshotUpdate(entries: ProviderSnapshotEntry[]): Pr
|
||||
listener?.({
|
||||
type: "providers_snapshot_update",
|
||||
payload: {
|
||||
cwd: "/repo",
|
||||
cwd,
|
||||
entries,
|
||||
generatedAt: "2026-01-01T00:00:01.000Z",
|
||||
},
|
||||
@@ -155,23 +154,8 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("providers snapshot hook cache scope", () => {
|
||||
it("uses no cwd in the settings query key", () => {
|
||||
expect(providersSnapshotQueryKey(serverId)).toEqual(["providersSnapshot", serverId, null]);
|
||||
});
|
||||
|
||||
it("accepts concrete home update events for settings snapshots", () => {
|
||||
expect(shouldApplyProvidersSnapshotUpdate(undefined, "/Users/alex")).toBe(true);
|
||||
expect(shouldApplyProvidersSnapshotUpdate(null, "/home/alex")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps workspace snapshot updates scoped to matching cwd keys", () => {
|
||||
expect(shouldApplyProvidersSnapshotUpdate("/Users/alex/project", "/Users/alex/project")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
shouldApplyProvidersSnapshotUpdate("/Users/alex/project-a", "/Users/alex/project-b"),
|
||||
).toBe(false);
|
||||
expect(shouldApplyProvidersSnapshotUpdate("/Users/alex/project", "/Users/alex")).toBe(false);
|
||||
it("uses a global query key without cwd", () => {
|
||||
expect(providersSnapshotQueryKey(serverId)).toEqual(["providersSnapshot", serverId]);
|
||||
});
|
||||
|
||||
it("sends no cwd for settings snapshot loads and refreshes", async () => {
|
||||
@@ -196,7 +180,7 @@ describe("providers snapshot hook cache scope", () => {
|
||||
expect(mockClient.getProvidersSnapshot).toHaveBeenLastCalledWith({});
|
||||
});
|
||||
|
||||
it("sends cwd for workspace snapshot loads and refreshes", async () => {
|
||||
it("does not send cwd for repeated snapshot loads and refreshes", async () => {
|
||||
enableProvidersSnapshot();
|
||||
mockClient.getProvidersSnapshot.mockResolvedValue(providersSnapshot([]));
|
||||
mockClient.refreshProvidersSnapshot.mockResolvedValue({
|
||||
@@ -204,21 +188,30 @@ describe("providers snapshot hook cache scope", () => {
|
||||
requestId: "workspace-refresh",
|
||||
});
|
||||
|
||||
const { result } = renderProvidersSnapshotHook("/repo");
|
||||
const { result } = renderProvidersSnapshotHook();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockClient.getProvidersSnapshot).toHaveBeenCalledWith({ cwd: "/repo" });
|
||||
expect(mockClient.getProvidersSnapshot).toHaveBeenCalledWith({});
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.refresh(["codex"]);
|
||||
});
|
||||
|
||||
expect(mockClient.refreshProvidersSnapshot).toHaveBeenCalledWith({
|
||||
cwd: "/repo",
|
||||
providers: ["codex"],
|
||||
});
|
||||
expect(mockClient.getProvidersSnapshot).toHaveBeenLastCalledWith({ cwd: "/repo" });
|
||||
expect(mockClient.refreshProvidersSnapshot).toHaveBeenCalledWith({ providers: ["codex"] });
|
||||
expect(mockClient.getProvidersSnapshot).toHaveBeenLastCalledWith({});
|
||||
});
|
||||
|
||||
it("applies provider snapshot updates from other cwd values to the global cache", async () => {
|
||||
enableProvidersSnapshot();
|
||||
mockClient.getProvidersSnapshot.mockResolvedValue(providersSnapshot([]));
|
||||
|
||||
const { result } = renderProvidersSnapshotHook();
|
||||
|
||||
await waitForSnapshotEntries(result, []);
|
||||
await emitProvidersSnapshotUpdate([codexEntry("ready", [readyCodexModel])], "/repo-b");
|
||||
|
||||
await waitForSnapshotEntries(result, [codexEntry("ready", [readyCodexModel])]);
|
||||
});
|
||||
|
||||
it("refetches loading snapshot updates through the read path but ignores empty updates", async () => {
|
||||
@@ -227,7 +220,7 @@ describe("providers snapshot hook cache scope", () => {
|
||||
.mockResolvedValueOnce(providersSnapshot([codexEntry("ready", [])]))
|
||||
.mockResolvedValueOnce(providersSnapshot([codexEntry("ready", [readyCodexModel])]));
|
||||
|
||||
renderProvidersSnapshotHook("/repo");
|
||||
renderProvidersSnapshotHook();
|
||||
|
||||
await waitForSnapshotReads(1);
|
||||
await emitProvidersSnapshotUpdate([]);
|
||||
@@ -237,7 +230,7 @@ describe("providers snapshot hook cache scope", () => {
|
||||
await emitProvidersSnapshotUpdate([codexEntry("loading")]);
|
||||
await waitForSnapshotReads(2);
|
||||
|
||||
expect(mockClient.getProvidersSnapshot).toHaveBeenLastCalledWith({ cwd: "/repo" });
|
||||
expect(mockClient.getProvidersSnapshot).toHaveBeenLastCalledWith({});
|
||||
expect(mockClient.refreshProvidersSnapshot).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -252,13 +245,13 @@ describe("providers snapshot hook cache scope", () => {
|
||||
.mockResolvedValueOnce(providersSnapshot(entries))
|
||||
.mockResolvedValueOnce(providersSnapshot([codexEntry("ready", [readyCodexModel])]));
|
||||
|
||||
const { result } = renderProvidersSnapshotHook("/repo");
|
||||
const { result } = renderProvidersSnapshotHook();
|
||||
|
||||
await waitForSnapshotEntries(result, entries);
|
||||
await openSelectorForSelectedProvider(result);
|
||||
await waitForSnapshotReads(2);
|
||||
|
||||
expect(mockClient.getProvidersSnapshot).toHaveBeenLastCalledWith({ cwd: "/repo" });
|
||||
expect(mockClient.getProvidersSnapshot).toHaveBeenLastCalledWith({});
|
||||
expect(mockClient.refreshProvidersSnapshot).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -266,7 +259,7 @@ describe("providers snapshot hook cache scope", () => {
|
||||
enableProvidersSnapshot();
|
||||
mockClient.getProvidersSnapshot.mockResolvedValue(providersSnapshot([codexEntry("ready", [])]));
|
||||
|
||||
const { result } = renderProvidersSnapshotHook("/repo");
|
||||
const { result } = renderProvidersSnapshotHook();
|
||||
|
||||
await waitForSnapshotEntries(result, [codexEntry("ready", [])]);
|
||||
await openSelectorForSelectedProvider(result);
|
||||
|
||||
@@ -6,40 +6,8 @@ import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { queryClient as singletonQueryClient } from "@/query/query-client";
|
||||
|
||||
export function normalizeProvidersSnapshotCwdKey(cwd?: string | null): string | null {
|
||||
const trimmed = cwd?.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return trimmed.replace(/^\/(?:Users|home)\/[^/]+/, "~");
|
||||
}
|
||||
|
||||
export function shouldApplyProvidersSnapshotUpdate(
|
||||
currentCwd?: string | null,
|
||||
messageCwd?: string | null,
|
||||
): boolean {
|
||||
const currentCwdKey = normalizeProvidersSnapshotCwdKey(currentCwd);
|
||||
const messageCwdKey = normalizeProvidersSnapshotCwdKey(messageCwd);
|
||||
return messageCwdKey === currentCwdKey || (currentCwdKey === null && messageCwdKey === "~");
|
||||
}
|
||||
|
||||
export function providersSnapshotQueryKey(serverId: string | null, cwd?: string | null) {
|
||||
return ["providersSnapshot", serverId, normalizeProvidersSnapshotCwdKey(cwd)] as const;
|
||||
}
|
||||
|
||||
function providersSnapshotRequest(cwd?: string): { cwd?: string } {
|
||||
return cwd ? { cwd } : {};
|
||||
}
|
||||
|
||||
function refreshProvidersSnapshotRequest(
|
||||
cwd: string | undefined,
|
||||
providers: AgentProvider[] | undefined,
|
||||
): { cwd?: string; providers?: AgentProvider[] } {
|
||||
return {
|
||||
...providersSnapshotRequest(cwd),
|
||||
...(providers ? { providers } : {}),
|
||||
};
|
||||
export function providersSnapshotQueryKey(serverId: string | null) {
|
||||
return ["providersSnapshot", serverId] as const;
|
||||
}
|
||||
|
||||
interface UseProvidersSnapshotResult {
|
||||
@@ -59,23 +27,17 @@ interface UseProvidersSnapshotOptions {
|
||||
|
||||
export function useProvidersSnapshot(
|
||||
serverId: string | null,
|
||||
cwd?: string | null,
|
||||
options: UseProvidersSnapshotOptions = {},
|
||||
): UseProvidersSnapshotResult {
|
||||
const queryClient = useQueryClient();
|
||||
const client = useHostRuntimeClient(serverId ?? "");
|
||||
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
|
||||
const enabled = options.enabled ?? true;
|
||||
const normalizedCwd = cwd?.trim() || undefined;
|
||||
const normalizedCwdKey = normalizeProvidersSnapshotCwdKey(normalizedCwd);
|
||||
const supportsSnapshot = useSessionStore(
|
||||
(state) => state.sessions[serverId ?? ""]?.serverInfo?.features?.providersSnapshot === true,
|
||||
);
|
||||
|
||||
const queryKey = useMemo(
|
||||
() => providersSnapshotQueryKey(serverId, normalizedCwdKey),
|
||||
[normalizedCwdKey, serverId],
|
||||
);
|
||||
const queryKey = useMemo(() => providersSnapshotQueryKey(serverId), [serverId]);
|
||||
|
||||
const snapshotQuery = useQuery({
|
||||
queryKey,
|
||||
@@ -85,7 +47,7 @@ export function useProvidersSnapshot(
|
||||
if (!client) {
|
||||
throw new Error("Host is not connected");
|
||||
}
|
||||
return client.getProvidersSnapshot(providersSnapshotRequest(normalizedCwd));
|
||||
return client.getProvidersSnapshot({});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -94,9 +56,7 @@ export function useProvidersSnapshot(
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
await client.refreshProvidersSnapshot(
|
||||
refreshProvidersSnapshotRequest(normalizedCwd, providers),
|
||||
);
|
||||
await client.refreshProvidersSnapshot(providers ? { providers } : {});
|
||||
},
|
||||
});
|
||||
const { mutateAsync: refreshSnapshot, isPending: isRefreshing } = refreshMutation;
|
||||
@@ -110,9 +70,6 @@ export function useProvidersSnapshot(
|
||||
if (message.type !== "providers_snapshot_update") {
|
||||
return;
|
||||
}
|
||||
if (!shouldApplyProvidersSnapshotUpdate(normalizedCwd, message.payload.cwd)) {
|
||||
return;
|
||||
}
|
||||
queryClient.setQueryData(queryKey, {
|
||||
entries: message.payload.entries,
|
||||
generatedAt: message.payload.generatedAt,
|
||||
@@ -127,17 +84,7 @@ export function useProvidersSnapshot(
|
||||
});
|
||||
}
|
||||
});
|
||||
}, [
|
||||
client,
|
||||
enabled,
|
||||
isConnected,
|
||||
normalizedCwd,
|
||||
normalizedCwdKey,
|
||||
queryClient,
|
||||
queryKey,
|
||||
serverId,
|
||||
supportsSnapshot,
|
||||
]);
|
||||
}, [client, enabled, isConnected, queryClient, queryKey, serverId, supportsSnapshot]);
|
||||
|
||||
const refresh = useCallback(
|
||||
async (providers?: AgentProvider[]) => {
|
||||
@@ -145,10 +92,10 @@ export function useProvidersSnapshot(
|
||||
return;
|
||||
}
|
||||
await refreshSnapshot(providers);
|
||||
const snapshot = await client.getProvidersSnapshot(providersSnapshotRequest(normalizedCwd));
|
||||
const snapshot = await client.getProvidersSnapshot({});
|
||||
queryClient.setQueryData(queryKey, snapshot);
|
||||
},
|
||||
[client, normalizedCwd, queryClient, queryKey, refreshSnapshot],
|
||||
[client, queryClient, queryKey, refreshSnapshot],
|
||||
);
|
||||
|
||||
const refetchIfStale = useCallback(
|
||||
@@ -184,16 +131,11 @@ export function useProvidersSnapshot(
|
||||
};
|
||||
}
|
||||
|
||||
export function prefetchProvidersSnapshot(
|
||||
serverId: string,
|
||||
client: DaemonClient,
|
||||
cwd?: string | null,
|
||||
): void {
|
||||
const normalizedCwd = cwd?.trim() || undefined;
|
||||
const queryKey = providersSnapshotQueryKey(serverId, normalizedCwd);
|
||||
export function prefetchProvidersSnapshot(serverId: string, client: DaemonClient): void {
|
||||
const queryKey = providersSnapshotQueryKey(serverId);
|
||||
void singletonQueryClient.prefetchQuery({
|
||||
queryKey,
|
||||
staleTime: 60_000,
|
||||
queryFn: () => client.getProvidersSnapshot(providersSnapshotRequest(normalizedCwd)),
|
||||
queryFn: () => client.getProvidersSnapshot({}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ const {
|
||||
streamRenderCount,
|
||||
latestStreamPermissionKeys,
|
||||
latestStreamText,
|
||||
runtimeIsConnected,
|
||||
theme,
|
||||
runtimeClient,
|
||||
} = vi.hoisted(() => {
|
||||
@@ -71,6 +72,7 @@ const {
|
||||
streamRenderCount: vi.fn(),
|
||||
latestStreamPermissionKeys: { current: [] as string[] },
|
||||
latestStreamText: { current: null as string | null },
|
||||
runtimeIsConnected: { current: false },
|
||||
theme: {
|
||||
colors: {
|
||||
foreground: "#ffffff",
|
||||
@@ -121,8 +123,8 @@ function createPanelTestStyles(factory: PanelTestStyleFactory | PanelTestStyles)
|
||||
vi.mock("@/runtime/host-runtime", () => ({
|
||||
useHosts: () => [{ serverId: "server", label: "Test server" }],
|
||||
useHostRuntimeClient: () => runtimeClient,
|
||||
useHostRuntimeIsConnected: () => false,
|
||||
useHostRuntimeConnectionStatus: () => "offline",
|
||||
useHostRuntimeIsConnected: () => runtimeIsConnected.current,
|
||||
useHostRuntimeConnectionStatus: () => (runtimeIsConnected.current ? "online" : "offline"),
|
||||
useHostRuntimeLastError: () => null,
|
||||
}));
|
||||
|
||||
@@ -240,6 +242,51 @@ function makeAgent(overrides: Partial<Agent> = {}): Agent {
|
||||
};
|
||||
}
|
||||
|
||||
function makeFetchedAgentResult(agent: Agent): Awaited<ReturnType<DaemonClient["fetchAgent"]>> {
|
||||
return {
|
||||
project: {
|
||||
projectKey: agent.cwd,
|
||||
projectName: "workspace",
|
||||
checkout: {
|
||||
cwd: agent.cwd,
|
||||
isGit: false,
|
||||
currentBranch: null,
|
||||
remoteUrl: null,
|
||||
worktreeRoot: null,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
},
|
||||
},
|
||||
agent: {
|
||||
id: agent.id,
|
||||
provider: agent.provider,
|
||||
status: agent.status,
|
||||
createdAt: agent.createdAt.toISOString(),
|
||||
updatedAt: agent.updatedAt.toISOString(),
|
||||
lastUserMessageAt: agent.lastUserMessageAt?.toISOString() ?? null,
|
||||
runtimeInfo: agent.runtimeInfo ?? {
|
||||
provider: agent.provider,
|
||||
sessionId: null,
|
||||
},
|
||||
capabilities: agent.capabilities,
|
||||
currentModeId: agent.currentModeId,
|
||||
availableModes: agent.availableModes,
|
||||
pendingPermissions: agent.pendingPermissions,
|
||||
persistence: agent.persistence,
|
||||
title: agent.title,
|
||||
cwd: agent.cwd,
|
||||
model: agent.model,
|
||||
thinkingOptionId: agent.thinkingOptionId ?? null,
|
||||
requiresAttention: agent.requiresAttention ?? false,
|
||||
attentionReason: agent.attentionReason ?? null,
|
||||
attentionTimestamp: agent.attentionTimestamp?.toISOString() ?? null,
|
||||
archivedAt: agent.archivedAt?.toISOString() ?? null,
|
||||
labels: agent.labels,
|
||||
lastError: agent.lastError ?? undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function seedReadyAgent(agent: Agent = makeAgent()) {
|
||||
const store = useSessionStore.getState();
|
||||
store.initializeSession("server", makeClient());
|
||||
@@ -367,6 +414,9 @@ describe("AgentPanel render isolation", () => {
|
||||
streamRenderCount.mockClear();
|
||||
latestStreamPermissionKeys.current = [];
|
||||
latestStreamText.current = null;
|
||||
runtimeIsConnected.current = false;
|
||||
runtimeClient.fetchAgent.mockReset();
|
||||
runtimeClient.fetchAgentTimeline.mockReset();
|
||||
});
|
||||
|
||||
it("refreshes the stream view without invoking Composer for stream-only updates", async () => {
|
||||
@@ -463,4 +513,49 @@ describe("AgentPanel render isolation", () => {
|
||||
expect(latestStreamPermissionKeys.current).toEqual([]);
|
||||
expect(streamRenderCount).toHaveBeenCalledTimes(initialRenderCount + 2);
|
||||
});
|
||||
|
||||
it("renders an archived lazy detail without adding it to the active agent store", async () => {
|
||||
const archivedAgent = makeAgent({
|
||||
archivedAt: new Date("2026-04-20T00:00:02.000Z"),
|
||||
});
|
||||
runtimeIsConnected.current = true;
|
||||
runtimeClient.fetchAgent.mockResolvedValue(makeFetchedAgentResult(archivedAgent));
|
||||
runtimeClient.fetchAgentTimeline.mockResolvedValue({
|
||||
agent: null,
|
||||
events: [],
|
||||
nextCursor: null,
|
||||
hasMore: false,
|
||||
});
|
||||
useSessionStore
|
||||
.getState()
|
||||
.initializeSession("server", runtimeClient as unknown as DaemonClient);
|
||||
useSessionStore.getState().setAgentAuthoritativeHistoryApplied("server", "agent", true);
|
||||
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await renderAgentPanel(root, {
|
||||
isWorkspaceFocused: true,
|
||||
isPaneFocused: true,
|
||||
isInteractive: true,
|
||||
});
|
||||
|
||||
const timeoutAt = Date.now() + 300;
|
||||
while (
|
||||
!container?.querySelector('[data-testid="archived-agent-callout"]') &&
|
||||
Date.now() < timeoutAt
|
||||
) {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
expect(container?.querySelector('[data-testid="archived-agent-callout"]')).not.toBeNull();
|
||||
|
||||
expect(useSessionStore.getState().sessions.server?.agents.has("agent")).toBe(false);
|
||||
expect(
|
||||
useSessionStore.getState().sessions.server?.agentDetails.get("agent")?.archivedAt,
|
||||
).toEqual(archivedAgent.archivedAt);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,53 +1,54 @@
|
||||
import type { DaemonClient } from "@server/client/daemon-client";
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ActivityIndicator, Text, View } from "react-native";
|
||||
import ReanimatedAnimated from "react-native-reanimated";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import invariant from "tiny-invariant";
|
||||
import { shallow, useShallow } from "zustand/shallow";
|
||||
import { useStoreWithEqualityFn } from "zustand/traditional";
|
||||
import invariant from "tiny-invariant";
|
||||
import { AgentStreamView, type AgentStreamViewHandle } from "@/components/agent-stream-view";
|
||||
import { Composer } from "@/components/composer";
|
||||
import { ArchivedAgentCallout } from "@/components/archived-agent-callout";
|
||||
import { Composer } from "@/components/composer";
|
||||
import { FileDropZone } from "@/components/file-drop-zone";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import type { ImageAttachment } from "@/components/message-input";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import { ToastViewport, useToastHost } from "@/components/toast-host";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import { useAgentAttentionClear } from "@/hooks/use-agent-attention-clear";
|
||||
import { useAgentInitialization } from "@/hooks/use-agent-initialization";
|
||||
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
|
||||
import {
|
||||
useAgentScreenStateMachine,
|
||||
type AgentScreenAgent,
|
||||
type AgentScreenMissingState,
|
||||
useAgentScreenStateMachine,
|
||||
} from "@/hooks/use-agent-screen-state-machine";
|
||||
import { useArchiveAgent } from "@/hooks/use-archive-agent";
|
||||
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
|
||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { usePaneContext, usePaneFocus } from "@/panels/pane-context";
|
||||
import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry";
|
||||
import {
|
||||
type HostRuntimeConnectionStatus,
|
||||
useHostRuntimeClient,
|
||||
useHostRuntimeConnectionStatus,
|
||||
useHostRuntimeIsConnected,
|
||||
useHostRuntimeLastError,
|
||||
useHosts,
|
||||
type HostRuntimeConnectionStatus,
|
||||
} from "@/runtime/host-runtime";
|
||||
import { getInitDeferred, getInitKey } from "@/utils/agent-initialization";
|
||||
import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
||||
import { mergePendingCreateImages } from "@/utils/pending-create-images";
|
||||
import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||
import { buildDraftStoreKey } from "@/stores/draft-keys";
|
||||
import { useSessionStore, type Agent } from "@/stores/session-store";
|
||||
import type { PendingPermission } from "@/types/shared";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import {
|
||||
deriveRouteBottomAnchorIntent,
|
||||
deriveRouteBottomAnchorRequest,
|
||||
} from "@/screens/agent/agent-ready-screen-bottom-anchor";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||
import { buildDraftStoreKey } from "@/stores/draft-keys";
|
||||
import { type Agent, useSessionStore } from "@/stores/session-store";
|
||||
import type { PendingPermission } from "@/types/shared";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import { getInitDeferred, getInitKey } from "@/utils/agent-initialization";
|
||||
import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
||||
import { mergePendingCreateImages } from "@/utils/pending-create-images";
|
||||
import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||
|
||||
function formatProviderLabel(provider: Agent["provider"]): string {
|
||||
if (!provider) {
|
||||
@@ -74,13 +75,63 @@ function resolveWorkspaceAgentTabLabel(title: string | null | undefined): string
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function shouldStoreFetchedAgentInActiveDirectory(agent: Agent): boolean {
|
||||
return !agent.archivedAt && Boolean(agent.projectPlacement);
|
||||
}
|
||||
|
||||
type FetchAgentResult = Awaited<ReturnType<DaemonClient["fetchAgent"]>>;
|
||||
|
||||
function storeFetchedAgentDetail(input: {
|
||||
serverId: string;
|
||||
result: NonNullable<FetchAgentResult>;
|
||||
}): Agent {
|
||||
const normalized = normalizeAgentSnapshot(input.result.agent, input.serverId);
|
||||
const hydrated: Agent = {
|
||||
...normalized,
|
||||
projectPlacement: input.result.project,
|
||||
};
|
||||
const store = useSessionStore.getState();
|
||||
|
||||
if (shouldStoreFetchedAgentInActiveDirectory(hydrated)) {
|
||||
store.setAgents(input.serverId, (previous) => {
|
||||
const next = new Map(previous);
|
||||
next.set(hydrated.id, hydrated);
|
||||
return next;
|
||||
});
|
||||
} else {
|
||||
store.setAgentDetails(input.serverId, (previous) => {
|
||||
const next = new Map(previous);
|
||||
next.set(hydrated.id, hydrated);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
store.setPendingPermissions(input.serverId, (previous) => {
|
||||
const next = new Map(previous);
|
||||
for (const [key, pending] of next.entries()) {
|
||||
if (pending.agentId === hydrated.id) {
|
||||
next.delete(key);
|
||||
}
|
||||
}
|
||||
for (const request of hydrated.pendingPermissions) {
|
||||
const key = derivePendingPermissionKey(hydrated.id, request);
|
||||
next.set(key, { key, agentId: hydrated.id, request });
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
return hydrated;
|
||||
}
|
||||
|
||||
function useAgentPanelDescriptor(
|
||||
target: { kind: "agent"; agentId: string },
|
||||
context: { serverId: string },
|
||||
): PanelDescriptor {
|
||||
const descriptorState = useSessionStore(
|
||||
useShallow((state) => {
|
||||
const agent = state.sessions[context.serverId]?.agents?.get(target.agentId) ?? null;
|
||||
const session = state.sessions[context.serverId];
|
||||
const agent =
|
||||
session?.agents?.get(target.agentId) ?? session?.agentDetails?.get(target.agentId) ?? null;
|
||||
return {
|
||||
provider: agent?.provider ?? "codex",
|
||||
title: agent?.title ?? null,
|
||||
@@ -262,17 +313,27 @@ function AgentPanelBody({
|
||||
const { theme } = useUnistyles();
|
||||
const { isArchivingAgent } = useArchiveAgent();
|
||||
const hasSession = useSessionStore((state) => Boolean(state.sessions[serverId]));
|
||||
const setAgents = useSessionStore((state) => state.setAgents);
|
||||
const setPendingPermissions = useSessionStore((state) => state.setPendingPermissions);
|
||||
const projectPlacement = useStoreWithEqualityFn(
|
||||
useSessionStore,
|
||||
(state) =>
|
||||
agentId ? (state.sessions[serverId]?.agents?.get(agentId)?.projectPlacement ?? null) : null,
|
||||
(state) => {
|
||||
if (!agentId) {
|
||||
return null;
|
||||
}
|
||||
const session = state.sessions[serverId];
|
||||
return (
|
||||
session?.agents?.get(agentId)?.projectPlacement ??
|
||||
session?.agentDetails?.get(agentId)?.projectPlacement ??
|
||||
null
|
||||
);
|
||||
},
|
||||
(a, b) => a === b || JSON.stringify(a) === JSON.stringify(b),
|
||||
);
|
||||
const agentState = useSessionStore(
|
||||
useShallow((state) => {
|
||||
const agent = agentId ? (state.sessions[serverId]?.agents?.get(agentId) ?? null) : null;
|
||||
const session = state.sessions[serverId];
|
||||
const agent = agentId
|
||||
? (session?.agents?.get(agentId) ?? session?.agentDetails?.get(agentId) ?? null)
|
||||
: null;
|
||||
return {
|
||||
serverId: agent?.serverId ?? null,
|
||||
id: agent?.id ?? null,
|
||||
@@ -325,29 +386,7 @@ function AgentPanelBody({
|
||||
return;
|
||||
}
|
||||
|
||||
const normalized = normalizeAgentSnapshot(result.agent, serverId);
|
||||
const hydrated = {
|
||||
...normalized,
|
||||
projectPlacement: result.project,
|
||||
};
|
||||
setAgents(serverId, (previous) => {
|
||||
const next = new Map(previous);
|
||||
next.set(hydrated.id, hydrated);
|
||||
return next;
|
||||
});
|
||||
setPendingPermissions(serverId, (previous) => {
|
||||
const next = new Map(previous);
|
||||
for (const [key, pending] of next.entries()) {
|
||||
if (pending.agentId === hydrated.id) {
|
||||
next.delete(key);
|
||||
}
|
||||
}
|
||||
for (const request of hydrated.pendingPermissions) {
|
||||
const key = derivePendingPermissionKey(hydrated.id, request);
|
||||
next.set(key, { key, agentId: hydrated.id, request });
|
||||
}
|
||||
return next;
|
||||
});
|
||||
storeFetchedAgentDetail({ serverId, result });
|
||||
setLookupState({ tag: "idle" });
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -361,17 +400,7 @@ function AgentPanelBody({
|
||||
}
|
||||
setLookupState({ tag: "error", message });
|
||||
});
|
||||
}, [
|
||||
agentId,
|
||||
agentState.id,
|
||||
client,
|
||||
hasSession,
|
||||
isConnected,
|
||||
lookupState.tag,
|
||||
serverId,
|
||||
setAgents,
|
||||
setPendingPermissions,
|
||||
]);
|
||||
}, [agentId, agentState.id, client, hasSession, isConnected, lookupState.tag, serverId]);
|
||||
|
||||
if (lookupState.tag === "not_found") {
|
||||
return (
|
||||
@@ -471,7 +500,10 @@ function ChatAgentContent({
|
||||
|
||||
const agentState = useSessionStore(
|
||||
useShallow((state) => {
|
||||
const agent = agentId ? (state.sessions[serverId]?.agents?.get(agentId) ?? null) : null;
|
||||
const session = state.sessions[serverId];
|
||||
const agent = agentId
|
||||
? (session?.agents?.get(agentId) ?? session?.agentDetails?.get(agentId) ?? null)
|
||||
: null;
|
||||
return {
|
||||
serverId: agent?.serverId ?? null,
|
||||
id: agent?.id ?? null,
|
||||
@@ -486,8 +518,17 @@ function ChatAgentContent({
|
||||
);
|
||||
const projectPlacement = useStoreWithEqualityFn(
|
||||
useSessionStore,
|
||||
(state) =>
|
||||
agentId ? (state.sessions[serverId]?.agents?.get(agentId)?.projectPlacement ?? null) : null,
|
||||
(state) => {
|
||||
if (!agentId) {
|
||||
return null;
|
||||
}
|
||||
const session = state.sessions[serverId];
|
||||
return (
|
||||
session?.agents?.get(agentId)?.projectPlacement ??
|
||||
session?.agentDetails?.get(agentId)?.projectPlacement ??
|
||||
null
|
||||
);
|
||||
},
|
||||
(a, b) => a === b || JSON.stringify(a) === JSON.stringify(b),
|
||||
);
|
||||
const pendingByDraftId = useCreateFlowStore((state) => state.pendingByDraftId);
|
||||
@@ -505,8 +546,6 @@ function ChatAgentContent({
|
||||
const agentHistorySyncGeneration = useSessionStore((state) =>
|
||||
agentId ? (state.sessions[serverId]?.agentHistorySyncGeneration?.get(agentId) ?? -1) : -1,
|
||||
);
|
||||
const setAgents = useSessionStore((state) => state.setAgents);
|
||||
const setPendingPermissions = useSessionStore((state) => state.setPendingPermissions);
|
||||
const hasSession = useSessionStore((state) => Boolean(state.sessions[serverId]));
|
||||
const { ensureAgentIsInitialized } = useAgentInitialization({
|
||||
serverId,
|
||||
@@ -776,7 +815,9 @@ function ChatAgentContent({
|
||||
if (attemptToken !== initAttemptTokenRef.current) {
|
||||
return;
|
||||
}
|
||||
const currentAgent = useSessionStore.getState().sessions[serverId]?.agents.get(agentId);
|
||||
const currentSession = useSessionStore.getState().sessions[serverId];
|
||||
const currentAgent =
|
||||
currentSession?.agents.get(agentId) ?? currentSession?.agentDetails.get(agentId);
|
||||
if (!currentAgent) {
|
||||
const result = await client.fetchAgent(agentId);
|
||||
if (attemptToken !== initAttemptTokenRef.current) {
|
||||
@@ -789,29 +830,7 @@ function ChatAgentContent({
|
||||
});
|
||||
return;
|
||||
}
|
||||
const normalized = normalizeAgentSnapshot(result.agent, serverId);
|
||||
const hydrated = {
|
||||
...normalized,
|
||||
projectPlacement: result.project,
|
||||
};
|
||||
setAgents(serverId, (previous) => {
|
||||
const next = new Map(previous);
|
||||
next.set(hydrated.id, hydrated);
|
||||
return next;
|
||||
});
|
||||
setPendingPermissions(serverId, (previous) => {
|
||||
const next = new Map(previous);
|
||||
for (const [key, pending] of next.entries()) {
|
||||
if (pending.agentId === hydrated.id) {
|
||||
next.delete(key);
|
||||
}
|
||||
}
|
||||
for (const request of hydrated.pendingPermissions) {
|
||||
const key = derivePendingPermissionKey(hydrated.id, request);
|
||||
next.set(key, { key, agentId: hydrated.id, request });
|
||||
}
|
||||
return next;
|
||||
});
|
||||
storeFetchedAgentDetail({ serverId, result });
|
||||
}
|
||||
if (attemptToken !== initAttemptTokenRef.current) {
|
||||
return;
|
||||
@@ -838,8 +857,6 @@ function ChatAgentContent({
|
||||
isConnected,
|
||||
missingAgentState.kind,
|
||||
serverId,
|
||||
setAgents,
|
||||
setPendingPermissions,
|
||||
shouldUseOptimisticStream,
|
||||
]);
|
||||
|
||||
|
||||
@@ -1112,7 +1112,7 @@ describe("HostRuntimeStore", () => {
|
||||
|
||||
expect(fakeClient.fetchAgentsCalls).toHaveLength(1);
|
||||
expect(fakeClient.fetchAgentsCalls[0]).toEqual({
|
||||
filter: { includeArchived: true },
|
||||
scope: "active",
|
||||
sort: [{ key: "updated_at", direction: "desc" }],
|
||||
subscribe: { subscriptionId: "app:srv_test" },
|
||||
page: { limit: 200 },
|
||||
@@ -1160,7 +1160,7 @@ describe("HostRuntimeStore", () => {
|
||||
|
||||
expect(fakeClient.fetchAgentsCalls).toHaveLength(1);
|
||||
expect(fakeClient.fetchAgentsCalls[0]).toEqual({
|
||||
filter: { includeArchived: true },
|
||||
scope: "active",
|
||||
sort: [{ key: "updated_at", direction: "desc" }],
|
||||
subscribe: { subscriptionId: "app:srv_no_session" },
|
||||
page: { limit: 200 },
|
||||
@@ -1169,7 +1169,7 @@ describe("HostRuntimeStore", () => {
|
||||
store.syncHosts([]);
|
||||
});
|
||||
|
||||
it("fetches all pages during bootstrap so older workspace agents are present", async () => {
|
||||
it("fetches all pages during bootstrap within the active agent scope", async () => {
|
||||
const host = makeHost({
|
||||
serverId: "srv_paged",
|
||||
connections: [
|
||||
@@ -1234,13 +1234,13 @@ describe("HostRuntimeStore", () => {
|
||||
|
||||
expect(fakeClient.fetchAgentsCalls).toHaveLength(2);
|
||||
expect(fakeClient.fetchAgentsCalls[0]).toEqual({
|
||||
filter: { includeArchived: true },
|
||||
scope: "active",
|
||||
sort: [{ key: "updated_at", direction: "desc" }],
|
||||
subscribe: { subscriptionId: "app:srv_paged" },
|
||||
page: { limit: 200 },
|
||||
});
|
||||
expect(fakeClient.fetchAgentsCalls[1]).toEqual({
|
||||
filter: { includeArchived: true },
|
||||
scope: "active",
|
||||
sort: [{ key: "updated_at", direction: "desc" }],
|
||||
page: { limit: 200, cursor: "cursor-page-2" },
|
||||
});
|
||||
@@ -1314,13 +1314,13 @@ describe("HostRuntimeStore", () => {
|
||||
|
||||
expect(fakeClient.fetchAgentsCalls).toEqual([
|
||||
{
|
||||
filter: { includeArchived: true },
|
||||
scope: "active",
|
||||
sort: [{ key: "updated_at", direction: "desc" }],
|
||||
subscribe: { subscriptionId: "app:srv_resubscribe" },
|
||||
page: { limit: 200 },
|
||||
},
|
||||
{
|
||||
filter: { includeArchived: true },
|
||||
scope: "active",
|
||||
sort: [{ key: "updated_at", direction: "desc" }],
|
||||
subscribe: { subscriptionId: "app:srv_resubscribe" },
|
||||
page: { limit: 200 },
|
||||
@@ -1331,7 +1331,7 @@ describe("HostRuntimeStore", () => {
|
||||
useSessionStore.getState().clearSession(host.serverId);
|
||||
});
|
||||
|
||||
it("rehydrates archived agents over stale active session state after reconnect bootstrap", async () => {
|
||||
it("replaces stale active session state when active bootstrap omits an agent", async () => {
|
||||
const host = makeHost({
|
||||
serverId: "srv_archived_rehydrate",
|
||||
connections: [
|
||||
@@ -1346,15 +1346,7 @@ describe("HostRuntimeStore", () => {
|
||||
fakeClient.setConnectionState({ status: "connected" });
|
||||
fakeClient.fetchAgentsResponses.push(
|
||||
makeFetchAgentsPayload({
|
||||
entries: [
|
||||
makeFetchAgentsEntry({
|
||||
id: "agent-archived",
|
||||
cwd: "/Users/moboudra/dev/paseo",
|
||||
updatedAt: "2026-03-30T15:30:00.000Z",
|
||||
archivedAt: "2026-03-30T15:31:00.000Z",
|
||||
title: "Archived remotely",
|
||||
}),
|
||||
],
|
||||
entries: [],
|
||||
subscriptionId: "app:srv_archived_rehydrate",
|
||||
}),
|
||||
);
|
||||
@@ -1397,17 +1389,16 @@ describe("HostRuntimeStore", () => {
|
||||
store.syncHosts([host]);
|
||||
|
||||
const timeoutAt = Date.now() + 300;
|
||||
let archivedAt =
|
||||
useSessionStore.getState().sessions[host.serverId]?.agents.get("agent-archived")
|
||||
?.archivedAt ?? null;
|
||||
while (!archivedAt && Date.now() < timeoutAt) {
|
||||
while (
|
||||
useSessionStore.getState().sessions[host.serverId]?.agents.has("agent-archived") &&
|
||||
Date.now() < timeoutAt
|
||||
) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
archivedAt =
|
||||
useSessionStore.getState().sessions[host.serverId]?.agents.get("agent-archived")
|
||||
?.archivedAt ?? null;
|
||||
}
|
||||
|
||||
expect(archivedAt?.toISOString()).toBe("2026-03-30T15:31:00.000Z");
|
||||
expect(useSessionStore.getState().sessions[host.serverId]?.agents.has("agent-archived")).toBe(
|
||||
false,
|
||||
);
|
||||
|
||||
store.syncHosts([]);
|
||||
useSessionStore.getState().clearSession(host.serverId);
|
||||
|
||||
@@ -4,6 +4,7 @@ import equal from "fast-deep-equal/es6";
|
||||
import {
|
||||
DaemonClient,
|
||||
type ConnectionState,
|
||||
type FetchAgentsEntry,
|
||||
type FetchAgentsOptions,
|
||||
} from "@server/client/daemon-client";
|
||||
import {
|
||||
@@ -31,8 +32,8 @@ import {
|
||||
createDesktopLocalDaemonTransportFactory,
|
||||
} from "@/desktop/daemon/desktop-daemon-transport";
|
||||
import { isDev } from "@/constants/platform";
|
||||
import { applyFetchedAgentDirectory } from "@/utils/agent-directory-sync";
|
||||
import { useSessionStore, type Agent } from "@/stores/session-store";
|
||||
import { replaceFetchedAgentDirectory } from "@/utils/agent-directory-sync";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
|
||||
export type HostRuntimeConnectionStatus = "idle" | "connecting" | "online" | "offline" | "error";
|
||||
|
||||
@@ -1759,7 +1760,7 @@ export class HostRuntimeStore {
|
||||
subscribe?: FetchAgentsOptions["subscribe"];
|
||||
page?: FetchAgentsOptions["page"];
|
||||
}): Promise<{
|
||||
agents: ReturnType<typeof applyFetchedAgentDirectory>["agents"];
|
||||
agents: ReturnType<typeof replaceFetchedAgentDirectory>["agents"];
|
||||
subscriptionId: string | null;
|
||||
}> {
|
||||
const controller = this.controllers.get(input.serverId);
|
||||
@@ -1778,23 +1779,18 @@ export class HostRuntimeStore {
|
||||
let cursor = input.page?.cursor ?? null;
|
||||
let includeSubscribe = true;
|
||||
let subscriptionId: string | null = null;
|
||||
const allAgents = new Map<string, Agent>();
|
||||
const allEntries: FetchAgentsEntry[] = [];
|
||||
|
||||
while (true) {
|
||||
const payload = await client.fetchAgents({
|
||||
filter: input.filter ?? { includeArchived: true },
|
||||
scope: input.filter ? undefined : "active",
|
||||
...(input.filter ? { filter: input.filter } : {}),
|
||||
sort: DEFAULT_AGENT_DIRECTORY_SORT,
|
||||
...(includeSubscribe && input.subscribe ? { subscribe: input.subscribe } : {}),
|
||||
page: cursor ? { limit: pageLimit, cursor } : { limit: pageLimit },
|
||||
});
|
||||
|
||||
const pageAgents = applyFetchedAgentDirectory({
|
||||
serverId: input.serverId,
|
||||
entries: payload.entries,
|
||||
}).agents;
|
||||
for (const [agentId, agent] of pageAgents) {
|
||||
allAgents.set(agentId, agent);
|
||||
}
|
||||
allEntries.push(...payload.entries);
|
||||
|
||||
subscriptionId = subscriptionId ?? payload.subscriptionId ?? null;
|
||||
includeSubscribe = false;
|
||||
@@ -1810,9 +1806,14 @@ export class HostRuntimeStore {
|
||||
cursor = nextCursor;
|
||||
}
|
||||
|
||||
const { agents } = replaceFetchedAgentDirectory({
|
||||
serverId: input.serverId,
|
||||
entries: allEntries,
|
||||
});
|
||||
|
||||
controller.markAgentDirectorySyncReady();
|
||||
return {
|
||||
agents: allAgents,
|
||||
agents,
|
||||
subscriptionId,
|
||||
};
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AgentHistoryResult } from "@/hooks/use-agent-history";
|
||||
import { SessionsScreen } from "@/screens/sessions-screen";
|
||||
|
||||
const { historyResult, navigate } = vi.hoisted(() => ({
|
||||
historyResult: {
|
||||
current: null as AgentHistoryResult | null,
|
||||
},
|
||||
navigate: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("react-native", () => ({
|
||||
View: ({ children, ...props }: React.PropsWithChildren<Record<string, unknown>>) =>
|
||||
React.createElement("div", props, children),
|
||||
Text: ({ children, ...props }: React.PropsWithChildren<Record<string, unknown>>) =>
|
||||
React.createElement("span", props, children),
|
||||
}));
|
||||
|
||||
vi.mock("react-native-unistyles", () => {
|
||||
const theme = {
|
||||
spacing: { 4: 16, 6: 24 },
|
||||
fontSize: { lg: 18 },
|
||||
colors: {
|
||||
surface0: "#111",
|
||||
foregroundMuted: "#999",
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
StyleSheet: {
|
||||
create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory),
|
||||
},
|
||||
useUnistyles: () => ({ theme }),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@react-navigation/native", () => ({
|
||||
useIsFocused: () => true,
|
||||
}));
|
||||
|
||||
vi.mock("expo-router", () => ({
|
||||
router: {
|
||||
navigate,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("lucide-react-native", () => ({
|
||||
ChevronLeft: () => React.createElement("span", { "data-icon": "ChevronLeft" }),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/headers/menu-header", () => ({
|
||||
MenuHeader: ({ title }: { title: string }) =>
|
||||
React.createElement("header", { "data-testid": "menu-header" }, title),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/button", () => ({
|
||||
Button: ({ children, onPress }: React.PropsWithChildren<{ onPress?: () => void }>) =>
|
||||
React.createElement("button", { onClick: onPress }, children),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/loading-spinner", () => ({
|
||||
LoadingSpinner: ({ color, size }: { color: string; size?: string }) =>
|
||||
React.createElement("div", {
|
||||
"data-color": color,
|
||||
"data-size": size,
|
||||
"data-testid": "sessions-loading-spinner",
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/agent-list", () => ({
|
||||
AgentList: ({ agents }: { agents: unknown[] }) =>
|
||||
React.createElement("div", { "data-agent-count": agents.length, "data-testid": "agent-list" }),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-agent-history", () => ({
|
||||
useAgentHistory: () => {
|
||||
if (!historyResult.current) {
|
||||
throw new Error("Expected history result");
|
||||
}
|
||||
return historyResult.current;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/utils/host-routes", () => ({
|
||||
buildHostOpenProjectRoute: (serverId: string) => `/h/${serverId}/open-project`,
|
||||
}));
|
||||
|
||||
function makeHistoryResult(overrides: Partial<AgentHistoryResult> = {}): AgentHistoryResult {
|
||||
return {
|
||||
agents: [],
|
||||
isLoading: false,
|
||||
isInitialLoad: false,
|
||||
isRevalidating: false,
|
||||
hasMore: false,
|
||||
isLoadingMore: false,
|
||||
refreshAll: vi.fn(),
|
||||
loadMore: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("SessionsScreen", () => {
|
||||
let container: HTMLElement | null = null;
|
||||
let root: Root | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("React", React);
|
||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
navigate.mockReset();
|
||||
historyResult.current = makeHistoryResult();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (root) {
|
||||
act(() => {
|
||||
root?.unmount();
|
||||
});
|
||||
}
|
||||
root = null;
|
||||
container?.remove();
|
||||
container = null;
|
||||
historyResult.current = null;
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("shows the shared loader during the initial React Query history load", () => {
|
||||
historyResult.current = makeHistoryResult({ isInitialLoad: true, isLoading: true });
|
||||
|
||||
act(() => {
|
||||
root?.render(<SessionsScreen serverId="server-1" />);
|
||||
});
|
||||
|
||||
expect(container?.querySelector('[data-testid="sessions-loading-spinner"]')).not.toBeNull();
|
||||
expect(container?.textContent).not.toContain("No sessions yet");
|
||||
expect(container?.textContent).not.toContain("Back");
|
||||
});
|
||||
|
||||
it("shows the empty state after history finishes loading with no sessions", () => {
|
||||
historyResult.current = makeHistoryResult();
|
||||
|
||||
act(() => {
|
||||
root?.render(<SessionsScreen serverId="server-1" />);
|
||||
});
|
||||
|
||||
expect(container?.querySelector('[data-testid="sessions-loading-spinner"]')).toBeNull();
|
||||
expect(container?.textContent).toContain("No sessions yet");
|
||||
expect(container?.textContent).toContain("Back");
|
||||
});
|
||||
});
|
||||
@@ -2,12 +2,13 @@ import { useMemo, useState, useCallback, useEffect } from "react";
|
||||
import { View, Text } from "react-native";
|
||||
import { useIsFocused } from "@react-navigation/native";
|
||||
import { router } from "expo-router";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { ChevronLeft } from "lucide-react-native";
|
||||
import { MenuHeader } from "@/components/headers/menu-header";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { AgentList } from "@/components/agent-list";
|
||||
import { useAllAgentsList } from "@/hooks/use-all-agents-list";
|
||||
import { useAgentHistory } from "@/hooks/use-agent-history";
|
||||
import { buildHostOpenProjectRoute } from "@/utils/host-routes";
|
||||
|
||||
export function SessionsScreen({ serverId }: { serverId: string }) {
|
||||
@@ -21,10 +22,11 @@ export function SessionsScreen({ serverId }: { serverId: string }) {
|
||||
}
|
||||
|
||||
function SessionsScreenContent({ serverId }: { serverId: string }) {
|
||||
const { agents, isRevalidating, refreshAll } = useAllAgentsList({
|
||||
serverId,
|
||||
includeArchived: true,
|
||||
});
|
||||
const { theme } = useUnistyles();
|
||||
const { agents, hasMore, isInitialLoad, isLoadingMore, isRevalidating, loadMore, refreshAll } =
|
||||
useAgentHistory({
|
||||
serverId,
|
||||
});
|
||||
|
||||
// Track user-initiated refresh to avoid showing spinner on background revalidation
|
||||
const [isManualRefresh, setIsManualRefresh] = useState(false);
|
||||
@@ -48,7 +50,11 @@ function SessionsScreenContent({ serverId }: { serverId: string }) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<MenuHeader title="Sessions" />
|
||||
{sortedAgents.length === 0 ? (
|
||||
{isInitialLoad ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<LoadingSpinner size="large" color={theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
) : sortedAgents.length === 0 ? (
|
||||
<View style={styles.emptyContainer}>
|
||||
<Text style={styles.emptyText}>No sessions yet</Text>
|
||||
<Button
|
||||
@@ -65,6 +71,15 @@ function SessionsScreenContent({ serverId }: { serverId: string }) {
|
||||
showCheckoutInfo={false}
|
||||
isRefreshing={isManualRefresh && isRevalidating}
|
||||
onRefresh={handleRefresh}
|
||||
listFooterComponent={
|
||||
hasMore ? (
|
||||
<View style={styles.footer}>
|
||||
<Button variant="ghost" onPress={loadMore} disabled={isLoadingMore}>
|
||||
{isLoadingMore ? "Loading..." : "Load more"}
|
||||
</Button>
|
||||
</View>
|
||||
) : null
|
||||
}
|
||||
showAttentionIndicator={false}
|
||||
/>
|
||||
)}
|
||||
@@ -88,4 +103,13 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.lg,
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
footer: {
|
||||
alignItems: "center",
|
||||
paddingVertical: theme.spacing[4],
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -88,6 +88,25 @@ describe("workspace agent visibility", () => {
|
||||
expect(result.knownAgentIds.has("other-workspace-agent")).toBe(false);
|
||||
});
|
||||
|
||||
it("treats lazy historical details as known without making them active", () => {
|
||||
const workspaceDirectory = "/repo/worktree";
|
||||
const active = makeAgent({ id: "active-agent", cwd: workspaceDirectory });
|
||||
const historicalDetail = makeAgent({
|
||||
id: "historical-agent",
|
||||
cwd: workspaceDirectory,
|
||||
archivedAt: new Date("2026-03-04T00:01:00.000Z"),
|
||||
});
|
||||
|
||||
const result = deriveWorkspaceAgentVisibility({
|
||||
sessionAgents: new Map([[active.id, active]]),
|
||||
agentDetails: new Map([[historicalDetail.id, historicalDetail]]),
|
||||
workspaceDirectory,
|
||||
});
|
||||
|
||||
expect(result.activeAgentIds).toEqual(new Set(["active-agent"]));
|
||||
expect(result.knownAgentIds).toEqual(new Set(["active-agent", "historical-agent"]));
|
||||
});
|
||||
|
||||
it("prunes archived agent tabs so archiving on one client closes tabs on all clients", () => {
|
||||
const knownAgentIds = new Set(["archived-agent"]);
|
||||
const activeAgentIds = new Set<string>();
|
||||
|
||||
@@ -12,11 +12,12 @@ export interface WorkspaceAgentVisibility {
|
||||
|
||||
export function deriveWorkspaceAgentVisibility(input: {
|
||||
sessionAgents: Map<string, Agent> | undefined;
|
||||
agentDetails?: Map<string, Agent> | undefined;
|
||||
workspaceDirectory: string | null | undefined;
|
||||
}): WorkspaceAgentVisibility {
|
||||
const { sessionAgents, workspaceDirectory } = input;
|
||||
const { sessionAgents, agentDetails, workspaceDirectory } = input;
|
||||
const normalizedWorkspaceDirectory = normalizeWorkspaceId(workspaceDirectory);
|
||||
if (!sessionAgents || !normalizedWorkspaceDirectory) {
|
||||
if ((!sessionAgents && !agentDetails) || !normalizedWorkspaceDirectory) {
|
||||
return {
|
||||
activeAgentIds: new Set<string>(),
|
||||
knownAgentIds: new Set<string>(),
|
||||
@@ -25,7 +26,7 @@ export function deriveWorkspaceAgentVisibility(input: {
|
||||
|
||||
const activeAgentIds = new Set<string>();
|
||||
const knownAgentIds = new Set<string>();
|
||||
for (const agent of sessionAgents.values()) {
|
||||
for (const agent of sessionAgents?.values() ?? []) {
|
||||
if (normalizeWorkspaceId(agent.cwd) !== normalizedWorkspaceDirectory) {
|
||||
continue;
|
||||
}
|
||||
@@ -34,6 +35,12 @@ export function deriveWorkspaceAgentVisibility(input: {
|
||||
activeAgentIds.add(agent.id);
|
||||
}
|
||||
}
|
||||
for (const agent of agentDetails?.values() ?? []) {
|
||||
if (normalizeWorkspaceId(agent.cwd) !== normalizedWorkspaceDirectory) {
|
||||
continue;
|
||||
}
|
||||
knownAgentIds.add(agent.id);
|
||||
}
|
||||
|
||||
return { activeAgentIds, knownAgentIds };
|
||||
}
|
||||
|
||||
@@ -1,5 +1,41 @@
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
|
||||
export type WorkspaceHeaderCheckoutState =
|
||||
| { kind: "pending" }
|
||||
| { kind: "error" }
|
||||
| { kind: "ready"; checkout: { isGit: boolean; currentBranch: string | null } };
|
||||
|
||||
type WorkspaceHeaderRenderState =
|
||||
| { kind: "skeleton" }
|
||||
| {
|
||||
kind: "ready";
|
||||
title: string;
|
||||
subtitle: string;
|
||||
shouldShowSubtitle: boolean;
|
||||
isGitCheckout: boolean;
|
||||
currentBranchName: string | null;
|
||||
};
|
||||
|
||||
function trimNonEmpty(value: string | null | undefined): string | null {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function areHeaderLabelsEquivalent(
|
||||
a: string | null | undefined,
|
||||
b: string | null | undefined,
|
||||
): boolean {
|
||||
const normalizedA = trimNonEmpty(a)?.toLocaleLowerCase();
|
||||
const normalizedB = trimNonEmpty(b)?.toLocaleLowerCase();
|
||||
if (!normalizedA || !normalizedB) {
|
||||
return false;
|
||||
}
|
||||
return normalizedA === normalizedB;
|
||||
}
|
||||
|
||||
export function resolveWorkspaceHeader(input: { workspace: WorkspaceDescriptor }): {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
@@ -10,6 +46,35 @@ export function resolveWorkspaceHeader(input: { workspace: WorkspaceDescriptor }
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveWorkspaceHeaderRenderState(input: {
|
||||
workspace: WorkspaceDescriptor | null;
|
||||
checkoutState: WorkspaceHeaderCheckoutState;
|
||||
}): WorkspaceHeaderRenderState {
|
||||
if (!input.workspace) {
|
||||
return { kind: "skeleton" };
|
||||
}
|
||||
|
||||
if (input.checkoutState.kind === "pending" && input.workspace.projectKind === "git") {
|
||||
return { kind: "skeleton" };
|
||||
}
|
||||
|
||||
const header = resolveWorkspaceHeader({ workspace: input.workspace });
|
||||
const checkout = input.checkoutState.kind === "ready" ? input.checkoutState.checkout : null;
|
||||
const currentBranchName =
|
||||
checkout?.isGit && checkout.currentBranch !== "HEAD"
|
||||
? trimNonEmpty(checkout.currentBranch)
|
||||
: null;
|
||||
|
||||
return {
|
||||
kind: "ready",
|
||||
title: header.title,
|
||||
subtitle: header.subtitle,
|
||||
shouldShowSubtitle: !areHeaderLabelsEquivalent(header.title, header.subtitle),
|
||||
isGitCheckout: checkout?.isGit ?? false,
|
||||
currentBranchName,
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldRenderMissingWorkspaceDescriptor(input: {
|
||||
workspace: WorkspaceDescriptor | null;
|
||||
hasHydratedWorkspaces: boolean;
|
||||
|
||||
@@ -96,8 +96,9 @@ import {
|
||||
import { buildWorkspaceTabMenuEntries } from "@/screens/workspace/workspace-tab-menu";
|
||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||
import {
|
||||
resolveWorkspaceHeader,
|
||||
resolveWorkspaceHeaderRenderState,
|
||||
shouldRenderMissingWorkspaceDescriptor,
|
||||
type WorkspaceHeaderCheckoutState,
|
||||
} from "@/screens/workspace/workspace-header-source";
|
||||
import {
|
||||
deriveWorkspaceAgentVisibility,
|
||||
@@ -153,18 +154,6 @@ function decodeSegment(value: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
function areHeaderLabelsEquivalent(
|
||||
a: string | null | undefined,
|
||||
b: string | null | undefined,
|
||||
): boolean {
|
||||
const normalizedA = trimNonEmpty(a)?.toLocaleLowerCase();
|
||||
const normalizedB = trimNonEmpty(b)?.toLocaleLowerCase();
|
||||
if (!normalizedA || !normalizedB) {
|
||||
return false;
|
||||
}
|
||||
return normalizedA === normalizedB;
|
||||
}
|
||||
|
||||
function getFallbackTabOptionLabel(tab: WorkspaceTabDescriptor): string {
|
||||
if (tab.target.kind === "draft") {
|
||||
return "New Agent";
|
||||
@@ -658,9 +647,8 @@ function WorkspaceScreenContent({
|
||||
workspaceDescriptor && !workspaceExecutionAuthority,
|
||||
);
|
||||
|
||||
// Warm the server-side provider snapshot for this workspace cwd so the model
|
||||
// picker is ready when opened. Consumers share the same query cache key.
|
||||
useProvidersSnapshot(normalizedServerId, workspaceDirectory, {
|
||||
// Warm the global provider snapshot so the model picker is ready when opened.
|
||||
useProvidersSnapshot(normalizedServerId, {
|
||||
enabled: isRouteFocused,
|
||||
});
|
||||
const [pendingTerminalCreateInput, setPendingTerminalCreateInput] = useState<{
|
||||
@@ -675,6 +663,7 @@ function WorkspaceScreenContent({
|
||||
(state) =>
|
||||
deriveWorkspaceAgentVisibility({
|
||||
sessionAgents: state.sessions[normalizedServerId]?.agents,
|
||||
agentDetails: state.sessions[normalizedServerId]?.agentDetails,
|
||||
workspaceDirectory,
|
||||
}),
|
||||
workspaceAgentVisibilityEqual,
|
||||
@@ -879,21 +868,36 @@ function WorkspaceScreenContent({
|
||||
pendingTerminalCreateInput,
|
||||
toast,
|
||||
]);
|
||||
const workspaceHeader = workspaceDescriptor
|
||||
? resolveWorkspaceHeader({ workspace: workspaceDescriptor })
|
||||
: null;
|
||||
const isWorkspaceHeaderLoading = workspaceHeader === null || isCheckoutStatusLoading;
|
||||
const workspaceHeaderTitle = workspaceHeader?.title ?? "";
|
||||
const workspaceHeaderSubtitle = workspaceHeader?.subtitle ?? "";
|
||||
const shouldShowWorkspaceHeaderSubtitle = !areHeaderLabelsEquivalent(
|
||||
workspaceHeaderTitle,
|
||||
workspaceHeaderSubtitle,
|
||||
);
|
||||
|
||||
const isGitCheckout = checkoutQuery.data?.isGit ?? false;
|
||||
let workspaceHeaderCheckoutState: WorkspaceHeaderCheckoutState;
|
||||
if (isCheckoutStatusLoading) {
|
||||
workspaceHeaderCheckoutState = { kind: "pending" };
|
||||
} else if (checkoutQuery.isError || !checkoutQuery.data) {
|
||||
workspaceHeaderCheckoutState = { kind: "error" };
|
||||
} else {
|
||||
workspaceHeaderCheckoutState = {
|
||||
kind: "ready",
|
||||
checkout: {
|
||||
isGit: checkoutQuery.data.isGit,
|
||||
currentBranch: checkoutQuery.data.currentBranch,
|
||||
},
|
||||
};
|
||||
}
|
||||
const workspaceHeaderRenderState = resolveWorkspaceHeaderRenderState({
|
||||
workspace: workspaceDescriptor,
|
||||
checkoutState: workspaceHeaderCheckoutState,
|
||||
});
|
||||
const isWorkspaceHeaderLoading = workspaceHeaderRenderState.kind === "skeleton";
|
||||
const workspaceHeaderTitle =
|
||||
workspaceHeaderRenderState.kind === "ready" ? workspaceHeaderRenderState.title : "";
|
||||
const workspaceHeaderSubtitle =
|
||||
workspaceHeaderRenderState.kind === "ready" ? workspaceHeaderRenderState.subtitle : "";
|
||||
const shouldShowWorkspaceHeaderSubtitle =
|
||||
workspaceHeaderRenderState.kind === "ready" && workspaceHeaderRenderState.shouldShowSubtitle;
|
||||
const isGitCheckout =
|
||||
workspaceHeaderRenderState.kind === "ready" ? workspaceHeaderRenderState.isGitCheckout : false;
|
||||
const currentBranchName =
|
||||
checkoutQuery.data?.isGit && checkoutQuery.data.currentBranch !== "HEAD"
|
||||
? trimNonEmpty(checkoutQuery.data.currentBranch)
|
||||
workspaceHeaderRenderState.kind === "ready"
|
||||
? workspaceHeaderRenderState.currentBranchName
|
||||
: null;
|
||||
|
||||
const isExplorerOpen = usePanelStore((state) =>
|
||||
|
||||
@@ -6,26 +6,32 @@ vi.hoisted(() => {
|
||||
|
||||
import {
|
||||
resolveWorkspaceHeader,
|
||||
resolveWorkspaceHeaderRenderState,
|
||||
shouldRenderMissingWorkspaceDescriptor,
|
||||
} from "./workspace-header-source";
|
||||
import { createSidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
|
||||
function createWorkspaceDescriptor(input: Partial<WorkspaceDescriptor> = {}): WorkspaceDescriptor {
|
||||
return {
|
||||
id: "/repo/main",
|
||||
projectId: "remote:github.com/getpaseo/paseo",
|
||||
projectDisplayName: "getpaseo/paseo",
|
||||
projectRootPath: "/repo/main",
|
||||
workspaceDirectory: "/repo/main",
|
||||
projectKind: "git",
|
||||
workspaceKind: "local_checkout",
|
||||
name: "feat/workspace-sot",
|
||||
status: "running",
|
||||
diffStat: null,
|
||||
scripts: [],
|
||||
...input,
|
||||
};
|
||||
}
|
||||
|
||||
describe("workspace source of truth consumption", () => {
|
||||
it("uses the same descriptor name in header and sidebar row", () => {
|
||||
const workspace: WorkspaceDescriptor = {
|
||||
id: "/repo/main",
|
||||
projectId: "remote:github.com/getpaseo/paseo",
|
||||
projectDisplayName: "getpaseo/paseo",
|
||||
projectRootPath: "/repo/main",
|
||||
workspaceDirectory: "/repo/main",
|
||||
projectKind: "git",
|
||||
workspaceKind: "checkout",
|
||||
name: "feat/workspace-sot",
|
||||
status: "running",
|
||||
diffStat: null,
|
||||
scripts: [],
|
||||
};
|
||||
const workspace = createWorkspaceDescriptor();
|
||||
|
||||
const header = resolveWorkspaceHeader({ workspace });
|
||||
const sidebarWorkspace = createSidebarWorkspaceEntry({
|
||||
@@ -39,6 +45,104 @@ describe("workspace source of truth consumption", () => {
|
||||
expect(sidebarWorkspace.statusBucket).toBe("running");
|
||||
});
|
||||
|
||||
it("keeps the header skeleton while the workspace descriptor is missing", () => {
|
||||
expect(
|
||||
resolveWorkspaceHeaderRenderState({
|
||||
workspace: null,
|
||||
checkoutState: { kind: "pending" },
|
||||
}),
|
||||
).toEqual({ kind: "skeleton" });
|
||||
});
|
||||
|
||||
it("keeps git workspace headers skeletoned until checkout status resolves", () => {
|
||||
expect(
|
||||
resolveWorkspaceHeaderRenderState({
|
||||
workspace: createWorkspaceDescriptor({ projectKind: "git" }),
|
||||
checkoutState: { kind: "pending" },
|
||||
}),
|
||||
).toEqual({ kind: "skeleton" });
|
||||
});
|
||||
|
||||
it("renders known non-git workspace identity while checkout status is pending", () => {
|
||||
expect(
|
||||
resolveWorkspaceHeaderRenderState({
|
||||
workspace: createWorkspaceDescriptor({
|
||||
projectKind: "non_git",
|
||||
workspaceKind: "directory",
|
||||
name: "notes",
|
||||
projectDisplayName: "Local folders",
|
||||
}),
|
||||
checkoutState: { kind: "pending" },
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "ready",
|
||||
title: "notes",
|
||||
subtitle: "Local folders",
|
||||
shouldShowSubtitle: true,
|
||||
isGitCheckout: false,
|
||||
currentBranchName: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("renders git checkout headers with branch affordance after checkout status resolves", () => {
|
||||
expect(
|
||||
resolveWorkspaceHeaderRenderState({
|
||||
workspace: createWorkspaceDescriptor(),
|
||||
checkoutState: {
|
||||
kind: "ready",
|
||||
checkout: { isGit: true, currentBranch: "feat/workspace-sot" },
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "ready",
|
||||
title: "feat/workspace-sot",
|
||||
subtitle: "getpaseo/paseo",
|
||||
shouldShowSubtitle: true,
|
||||
isGitCheckout: true,
|
||||
currentBranchName: "feat/workspace-sot",
|
||||
});
|
||||
});
|
||||
|
||||
it("renders non-git checkout headers without branch affordance after checkout status resolves", () => {
|
||||
expect(
|
||||
resolveWorkspaceHeaderRenderState({
|
||||
workspace: createWorkspaceDescriptor({
|
||||
projectKind: "non_git",
|
||||
workspaceKind: "directory",
|
||||
name: "notes",
|
||||
projectDisplayName: "notes",
|
||||
}),
|
||||
checkoutState: {
|
||||
kind: "ready",
|
||||
checkout: { isGit: false, currentBranch: null },
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "ready",
|
||||
title: "notes",
|
||||
subtitle: "notes",
|
||||
shouldShowSubtitle: false,
|
||||
isGitCheckout: false,
|
||||
currentBranchName: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("renders descriptor identity after checkout status errors", () => {
|
||||
expect(
|
||||
resolveWorkspaceHeaderRenderState({
|
||||
workspace: createWorkspaceDescriptor(),
|
||||
checkoutState: { kind: "error" },
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "ready",
|
||||
title: "feat/workspace-sot",
|
||||
subtitle: "getpaseo/paseo",
|
||||
shouldShowSubtitle: true,
|
||||
isGitCheckout: false,
|
||||
currentBranchName: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("renders explicit missing state only after workspace hydration", () => {
|
||||
expect(
|
||||
shouldRenderMissingWorkspaceDescriptor({
|
||||
|
||||
@@ -275,6 +275,7 @@ export interface SessionState {
|
||||
|
||||
// Agents
|
||||
agents: Map<string, Agent>;
|
||||
agentDetails: Map<string, Agent>;
|
||||
workspaces: Map<string, WorkspaceDescriptor>;
|
||||
|
||||
// Permissions
|
||||
@@ -367,6 +368,10 @@ interface SessionStoreActions {
|
||||
serverId: string,
|
||||
agents: Map<string, Agent> | ((prev: Map<string, Agent>) => Map<string, Agent>),
|
||||
) => void;
|
||||
setAgentDetails: (
|
||||
serverId: string,
|
||||
agents: Map<string, Agent> | ((prev: Map<string, Agent>) => Map<string, Agent>),
|
||||
) => void;
|
||||
setWorkspaces: (
|
||||
serverId: string,
|
||||
workspaces:
|
||||
@@ -441,6 +446,7 @@ function createInitialSessionState(serverId: string, client: DaemonClient): Sess
|
||||
agentAuthoritativeHistoryApplied: new Map(),
|
||||
initializingAgents: new Map(),
|
||||
agents: new Map(),
|
||||
agentDetails: new Map(),
|
||||
workspaces: new Map(),
|
||||
pendingPermissions: new Map(),
|
||||
fileExplorer: new Map(),
|
||||
@@ -518,10 +524,13 @@ export const useSessionStore = create<SessionStore>()(
|
||||
const nextSessions = { ...prev.sessions };
|
||||
delete nextSessions[serverId];
|
||||
let nextActivity = prev.agentLastActivity;
|
||||
if (session.agents.size > 0) {
|
||||
if (session.agents.size > 0 || session.agentDetails.size > 0) {
|
||||
const candidate = new Map(prev.agentLastActivity);
|
||||
let changed = false;
|
||||
for (const agentId of session.agents.keys()) {
|
||||
for (const agentId of new Set([
|
||||
...session.agents.keys(),
|
||||
...session.agentDetails.keys(),
|
||||
])) {
|
||||
if (candidate.delete(agentId)) {
|
||||
changed = true;
|
||||
}
|
||||
@@ -951,6 +960,26 @@ export const useSessionStore = create<SessionStore>()(
|
||||
});
|
||||
},
|
||||
|
||||
setAgentDetails: (serverId, agents) => {
|
||||
set((prev) => {
|
||||
const session = prev.sessions[serverId];
|
||||
if (!session) {
|
||||
return prev;
|
||||
}
|
||||
const nextAgents = typeof agents === "function" ? agents(session.agentDetails) : agents;
|
||||
if (session.agentDetails === nextAgents) {
|
||||
return prev;
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
sessions: {
|
||||
...prev.sessions,
|
||||
[serverId]: { ...session, agentDetails: nextAgents },
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
setWorkspaces: (serverId, workspaces) => {
|
||||
set((prev) => {
|
||||
const session = prev.sessions[serverId];
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { FetchAgentsEntry } from "@server/client/daemon-client";
|
||||
import { useSessionStore, type Agent } from "@/stores/session-store";
|
||||
import type { FetchAgentHistoryEntry, FetchAgentsEntry } from "@server/client/daemon-client";
|
||||
import { type Agent, useSessionStore } from "@/stores/session-store";
|
||||
import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
||||
import { resolveProjectPlacement } from "@/utils/project-placement";
|
||||
|
||||
type AgentDirectoryFetchEntry = FetchAgentsEntry | FetchAgentHistoryEntry;
|
||||
|
||||
type PendingPermissionEntry = {
|
||||
key: string;
|
||||
agentId: string;
|
||||
@@ -11,7 +13,7 @@ type PendingPermissionEntry = {
|
||||
|
||||
export function buildAgentDirectoryState(input: {
|
||||
serverId: string;
|
||||
entries: FetchAgentsEntry[];
|
||||
entries: AgentDirectoryFetchEntry[];
|
||||
}): {
|
||||
agents: Map<string, Agent>;
|
||||
pendingPermissions: Map<string, PendingPermissionEntry>;
|
||||
@@ -40,20 +42,24 @@ export function buildAgentDirectoryState(input: {
|
||||
return { agents, pendingPermissions };
|
||||
}
|
||||
|
||||
export function applyFetchedAgentDirectory(input: {
|
||||
export function replaceFetchedAgentDirectory(input: {
|
||||
serverId: string;
|
||||
entries: FetchAgentsEntry[];
|
||||
}): { agents: Map<string, Agent> } {
|
||||
const { agents: fetchedAgents, pendingPermissions } = buildAgentDirectoryState(input);
|
||||
|
||||
const store = useSessionStore.getState();
|
||||
|
||||
store.setAgents(input.serverId, (prev) => {
|
||||
const merged = new Map(prev);
|
||||
for (const [id, agent] of fetchedAgents) {
|
||||
merged.set(id, agent);
|
||||
store.setAgents(input.serverId, fetchedAgents);
|
||||
store.setAgentDetails(input.serverId, (prev) => {
|
||||
let next: Map<string, Agent> | null = null;
|
||||
for (const agentId of fetchedAgents.keys()) {
|
||||
if (!prev.has(agentId)) {
|
||||
continue;
|
||||
}
|
||||
next ??= new Map(prev);
|
||||
next.delete(agentId);
|
||||
}
|
||||
return merged;
|
||||
return next ?? prev;
|
||||
});
|
||||
|
||||
const lastActivityByAgentId = new Map<string, Date>();
|
||||
@@ -62,13 +68,7 @@ export function applyFetchedAgentDirectory(input: {
|
||||
}
|
||||
store.setAgentLastActivityBatch(lastActivityByAgentId);
|
||||
|
||||
store.setPendingPermissions(input.serverId, (prev) => {
|
||||
const merged = new Map(prev);
|
||||
for (const [key, entry] of pendingPermissions) {
|
||||
merged.set(key, entry);
|
||||
}
|
||||
return merged;
|
||||
});
|
||||
store.setPendingPermissions(input.serverId, new Map(pendingPermissions));
|
||||
store.setInitializingAgents(input.serverId, new Map());
|
||||
store.setHasHydratedAgents(input.serverId, true);
|
||||
return { agents: fetchedAgents };
|
||||
|
||||
Reference in New Issue
Block a user