feat: implement daemon connectivity updates and relay protocol versioning

This commit is contained in:
Mohamed Boudra
2026-02-20 19:27:56 +07:00
parent 6dad3212da
commit 0350f00840
49 changed files with 4146 additions and 1351 deletions
@@ -4,8 +4,13 @@ import {
deriveAgentScreenViewState,
type AgentScreenMachineInput,
type AgentScreenMachineMemory,
type AgentScreenViewState,
} from "./use-agent-screen-state-machine";
type ReadyState = Extract<AgentScreenViewState, { tag: "ready" }>;
type CatchingUpSyncState = Extract<ReadyState["sync"], { status: "catching_up" }>;
type SyncErrorSyncState = Extract<ReadyState["sync"], { status: "sync_error" }>;
function createAgent(id: string): Agent {
const now = new Date("2026-02-19T00:00:00.000Z");
return {
@@ -52,15 +57,49 @@ function createBaseInput(): AgentScreenMachineInput {
isHistorySyncing: false,
needsAuthoritativeSync: false,
shouldUseOptimisticStream: false,
hasHydratedHistoryBefore: false,
};
}
function createBaseMemory(
overrides: Partial<AgentScreenMachineMemory> = {}
): AgentScreenMachineMemory {
return {
hasRenderedReady: false,
lastReadyAgent: null,
activeToastLatch: "none",
hadInitialSyncFailure: false,
...overrides,
};
}
function expectReadyState(state: AgentScreenViewState): ReadyState {
expect(state.tag).toBe("ready");
if (state.tag !== "ready") {
throw new Error("expected ready state");
}
return state;
}
function expectCatchingUpSync(state: ReadyState): CatchingUpSyncState {
expect(state.sync.status).toBe("catching_up");
if (state.sync.status !== "catching_up") {
throw new Error("expected catching_up sync state");
}
return state.sync;
}
function expectSyncErrorSync(state: ReadyState): SyncErrorSyncState {
expect(state.sync.status).toBe("sync_error");
if (state.sync.status !== "sync_error") {
throw new Error("expected sync_error sync state");
}
return state.sync;
}
describe("deriveAgentScreenViewState", () => {
it("returns boot loading before first interactive paint", () => {
const memory: AgentScreenMachineMemory = {
hasRenderedReady: false,
lastReadyAgent: null,
};
const memory = createBaseMemory();
const input = createBaseInput();
const result = deriveAgentScreenViewState({ input, memory });
@@ -74,87 +113,174 @@ describe("deriveAgentScreenViewState", () => {
});
it("stays ready after first paint even if agent is temporarily missing", () => {
const memory: AgentScreenMachineMemory = {
const memory = createBaseMemory({
hasRenderedReady: true,
lastReadyAgent: createAgent("agent-1"),
};
});
const input = createBaseInput();
const result = deriveAgentScreenViewState({ input, memory });
const ready = expectReadyState(result.state);
expect(result.state.tag).toBe("ready");
if (result.state.tag !== "ready") {
throw new Error("expected ready state");
}
expect(result.state.source).toBe("stale");
expect(result.state.syncStatus).toBe("idle");
expect(result.state.agent.id).toBe("agent-1");
expect(ready.source).toBe("stale");
expect(ready.sync.status).toBe("idle");
expect(ready.agent.id).toBe("agent-1");
});
it("shows reconnecting sync status without blocking after first paint", () => {
const memory: AgentScreenMachineMemory = {
const memory = createBaseMemory({
hasRenderedReady: true,
lastReadyAgent: createAgent("agent-1"),
};
});
const input: AgentScreenMachineInput = {
...createBaseInput(),
isConnected: false,
};
const result = deriveAgentScreenViewState({ input, memory });
const ready = expectReadyState(result.state);
expect(result.state.tag).toBe("ready");
if (result.state.tag !== "ready") {
throw new Error("expected ready state");
}
expect(result.state.syncStatus).toBe("reconnecting");
expect(ready.sync.status).toBe("reconnecting");
});
it("shows non-blocking catching-up state after first paint", () => {
const memory: AgentScreenMachineMemory = {
it("shows overlay catching-up state for first open while loading history", () => {
const memory = createBaseMemory({
hasRenderedReady: true,
lastReadyAgent: createAgent("agent-1"),
};
});
const input: AgentScreenMachineInput = {
...createBaseInput(),
needsAuthoritativeSync: true,
};
const result = deriveAgentScreenViewState({ input, memory });
const ready = expectReadyState(result.state);
const sync = expectCatchingUpSync(ready);
expect(result.state.tag).toBe("ready");
if (result.state.tag !== "ready") {
throw new Error("expected ready state");
}
expect(result.state.syncStatus).toBe("catching_up");
expect(sync.ui).toBe("overlay");
expect(sync.shouldEmitHistoryRefreshToast).toBe(false);
});
it("uses toast catching-up state for already-hydrated agents", () => {
const memory = createBaseMemory({
hasRenderedReady: true,
lastReadyAgent: createAgent("agent-1"),
});
const input: AgentScreenMachineInput = {
...createBaseInput(),
needsAuthoritativeSync: true,
hasHydratedHistoryBefore: true,
};
const result = deriveAgentScreenViewState({ input, memory });
const ready = expectReadyState(result.state);
const sync = expectCatchingUpSync(ready);
expect(sync.ui).toBe("toast");
expect(sync.shouldEmitHistoryRefreshToast).toBe(true);
});
it("keeps sync errors non-blocking once the screen was ready", () => {
const memory = createBaseMemory({
hasRenderedReady: true,
lastReadyAgent: createAgent("agent-1"),
});
const input: AgentScreenMachineInput = {
...createBaseInput(),
needsAuthoritativeSync: true,
missingAgentState: { kind: "error", message: "network timeout" },
};
const result = deriveAgentScreenViewState({ input, memory });
const ready = expectReadyState(result.state);
const sync = expectSyncErrorSync(ready);
expect(sync.shouldEmitSyncErrorToast).toBe(true);
});
it("remembers first-load sync failure and keeps catch-up overlay off after error clears", () => {
const initialMemory = createBaseMemory({
hasRenderedReady: true,
lastReadyAgent: createAgent("agent-1"),
});
const errorInput: AgentScreenMachineInput = {
...createBaseInput(),
needsAuthoritativeSync: true,
missingAgentState: { kind: "error", message: "network timeout" },
};
const errorResult = deriveAgentScreenViewState({
input: errorInput,
memory: initialMemory,
});
const errorReady = expectReadyState(errorResult.state);
const errorSync = expectSyncErrorSync(errorReady);
expect(errorSync.shouldEmitSyncErrorToast).toBe(true);
expect(errorResult.memory.hadInitialSyncFailure).toBe(true);
const retryInput: AgentScreenMachineInput = {
...createBaseInput(),
needsAuthoritativeSync: true,
missingAgentState: { kind: "idle" },
};
const retryResult = deriveAgentScreenViewState({
input: retryInput,
memory: errorResult.memory,
});
const retryReady = expectReadyState(retryResult.state);
const retrySync = expectCatchingUpSync(retryReady);
expect(retrySync.ui).toBe("silent");
expect(retrySync.shouldEmitHistoryRefreshToast).toBe(false);
expect(retryResult.memory.hadInitialSyncFailure).toBe(true);
});
it("keeps ready with sync_error when refresh fails after first paint", () => {
const memory: AgentScreenMachineMemory = {
const memory = createBaseMemory({
hasRenderedReady: true,
lastReadyAgent: createAgent("agent-1"),
};
});
const input: AgentScreenMachineInput = {
...createBaseInput(),
missingAgentState: { kind: "error", message: "network timeout" },
};
const result = deriveAgentScreenViewState({ input, memory });
const ready = expectReadyState(result.state);
const sync = expectSyncErrorSync(ready);
expect(result.state.tag).toBe("ready");
if (result.state.tag !== "ready") {
throw new Error("expected ready state");
}
expect(result.state.source).toBe("stale");
expect(result.state.syncStatus).toBe("sync_error");
expect(result.state.agent.id).toBe("agent-1");
expect(ready.source).toBe("stale");
expect(ready.agent.id).toBe("agent-1");
expect(sync.shouldEmitSyncErrorToast).toBe(true);
});
it("emits sync error toast only on transition into sync_error", () => {
const memory = createBaseMemory({
hasRenderedReady: true,
lastReadyAgent: createAgent("agent-1"),
});
const input: AgentScreenMachineInput = {
...createBaseInput(),
missingAgentState: { kind: "error", message: "network timeout" },
};
const first = deriveAgentScreenViewState({ input, memory });
const firstReady = expectReadyState(first.state);
const firstSync = expectSyncErrorSync(firstReady);
expect(firstSync.shouldEmitSyncErrorToast).toBe(true);
const second = deriveAgentScreenViewState({
input,
memory: first.memory,
});
const secondReady = expectReadyState(second.state);
const secondSync = expectSyncErrorSync(secondReady);
expect(secondSync.shouldEmitSyncErrorToast).toBe(false);
});
it("returns blocking error before first paint when refresh fails", () => {
const memory: AgentScreenMachineMemory = {
hasRenderedReady: false,
lastReadyAgent: null,
};
const memory = createBaseMemory();
const input: AgentScreenMachineInput = {
...createBaseInput(),
missingAgentState: { kind: "error", message: "network timeout" },
@@ -170,10 +296,10 @@ describe("deriveAgentScreenViewState", () => {
});
it("returns not_found when resolver confirms missing agent", () => {
const memory: AgentScreenMachineMemory = {
const memory = createBaseMemory({
hasRenderedReady: true,
lastReadyAgent: createAgent("agent-1"),
};
});
const input: AgentScreenMachineInput = {
...createBaseInput(),
missingAgentState: { kind: "not_found", message: "agent missing" },
@@ -189,10 +315,7 @@ describe("deriveAgentScreenViewState", () => {
});
it("promotes optimistic source while placeholder is used", () => {
const memory: AgentScreenMachineMemory = {
hasRenderedReady: false,
lastReadyAgent: null,
};
const memory = createBaseMemory();
const input: AgentScreenMachineInput = {
...createBaseInput(),
placeholderAgent: createAgent("draft-agent"),
@@ -200,19 +323,14 @@ describe("deriveAgentScreenViewState", () => {
};
const result = deriveAgentScreenViewState({ input, memory });
const ready = expectReadyState(result.state);
expect(result.state.tag).toBe("ready");
if (result.state.tag !== "ready") {
throw new Error("expected ready state");
}
expect(result.state.source).toBe("optimistic");
expect(ready.source).toBe("optimistic");
expect(ready.sync.status).toBe("idle");
});
it("keeps optimistic flow non-blocking while transitioning to authoritative stream", () => {
const initialMemory: AgentScreenMachineMemory = {
hasRenderedReady: false,
lastReadyAgent: null,
};
const initialMemory = createBaseMemory();
const optimisticInput: AgentScreenMachineInput = {
...createBaseInput(),
placeholderAgent: createAgent("draft-agent"),
@@ -223,11 +341,8 @@ describe("deriveAgentScreenViewState", () => {
input: optimisticInput,
memory: initialMemory,
});
expect(optimistic.state.tag).toBe("ready");
if (optimistic.state.tag !== "ready") {
throw new Error("expected optimistic ready state");
}
expect(optimistic.state.source).toBe("optimistic");
const optimisticReady = expectReadyState(optimistic.state);
expect(optimisticReady.source).toBe("optimistic");
const handoffInput: AgentScreenMachineInput = {
...createBaseInput(),
@@ -236,12 +351,94 @@ describe("deriveAgentScreenViewState", () => {
input: handoffInput,
memory: optimistic.memory,
});
const handoffReady = expectReadyState(handoff.state);
expect(handoff.state.tag).toBe("ready");
if (handoff.state.tag !== "ready") {
throw new Error("expected handoff ready state");
}
expect(handoff.state.source).toBe("stale");
expect(handoff.state.agent.id).toBe("draft-agent");
expect(handoffReady.source).toBe("stale");
expect(handoffReady.agent.id).toBe("draft-agent");
});
it("emits history refresh toast only on transition into toast catch-up state", () => {
const memory = createBaseMemory({
hasRenderedReady: true,
lastReadyAgent: createAgent("agent-1"),
});
const input: AgentScreenMachineInput = {
...createBaseInput(),
needsAuthoritativeSync: true,
hasHydratedHistoryBefore: true,
};
const first = deriveAgentScreenViewState({ input, memory });
const firstReady = expectReadyState(first.state);
const firstSync = expectCatchingUpSync(firstReady);
expect(firstSync.ui).toBe("toast");
expect(firstSync.shouldEmitHistoryRefreshToast).toBe(true);
const second = deriveAgentScreenViewState({
input,
memory: first.memory,
});
const secondReady = expectReadyState(second.state);
const secondSync = expectCatchingUpSync(secondReady);
expect(secondSync.ui).toBe("toast");
expect(secondSync.shouldEmitHistoryRefreshToast).toBe(false);
});
it("re-arms history refresh toast after leaving and re-entering catch-up", () => {
const baseInput: AgentScreenMachineInput = {
...createBaseInput(),
hasHydratedHistoryBefore: true,
};
const initialMemory = createBaseMemory({
hasRenderedReady: true,
lastReadyAgent: createAgent("agent-1"),
});
const firstCatchingUp = deriveAgentScreenViewState({
input: { ...baseInput, needsAuthoritativeSync: true },
memory: initialMemory,
});
const firstCatchingUpReady = expectReadyState(firstCatchingUp.state);
const firstCatchingUpSync = expectCatchingUpSync(firstCatchingUpReady);
expect(firstCatchingUpSync.ui).toBe("toast");
expect(firstCatchingUpSync.shouldEmitHistoryRefreshToast).toBe(true);
const idle = deriveAgentScreenViewState({
input: { ...baseInput, needsAuthoritativeSync: false },
memory: firstCatchingUp.memory,
});
const idleReady = expectReadyState(idle.state);
expect(idleReady.sync.status).toBe("idle");
const secondCatchingUp = deriveAgentScreenViewState({
input: { ...baseInput, needsAuthoritativeSync: true },
memory: idle.memory,
});
const secondCatchingUpReady = expectReadyState(secondCatchingUp.state);
const secondCatchingUpSync = expectCatchingUpSync(secondCatchingUpReady);
expect(secondCatchingUpSync.ui).toBe("toast");
expect(secondCatchingUpSync.shouldEmitHistoryRefreshToast).toBe(true);
});
it("clears initial sync failure memory after history is hydrated", () => {
const memory = createBaseMemory({
hasRenderedReady: true,
lastReadyAgent: createAgent("agent-1"),
hadInitialSyncFailure: true,
});
const input: AgentScreenMachineInput = {
...createBaseInput(),
hasHydratedHistoryBefore: true,
needsAuthoritativeSync: true,
};
const result = deriveAgentScreenViewState({ input, memory });
const ready = expectReadyState(result.state);
const sync = expectCatchingUpSync(ready);
expect(sync.ui).toBe("toast");
expect(result.memory.hadInitialSyncFailure).toBe(false);
});
});
@@ -16,18 +16,35 @@ export interface AgentScreenMachineInput {
isHistorySyncing: boolean;
needsAuthoritativeSync: boolean;
shouldUseOptimisticStream: boolean;
hasHydratedHistoryBefore: boolean;
}
export type AgentScreenToastLatch = "none" | "history_refresh" | "sync_error";
export interface AgentScreenMachineMemory {
hasRenderedReady: boolean;
lastReadyAgent: Agent | null;
activeToastLatch: AgentScreenToastLatch;
hadInitialSyncFailure: boolean;
}
export type AgentScreenSyncStatus =
| "idle"
| "catching_up"
| "reconnecting"
| "sync_error";
export type AgentScreenReadySyncState =
| { status: "idle" }
| { status: "reconnecting" }
| {
status: "catching_up";
ui: "overlay" | "silent";
shouldEmitHistoryRefreshToast: false;
}
| {
status: "catching_up";
ui: "toast";
shouldEmitHistoryRefreshToast: boolean;
}
| {
status: "sync_error";
shouldEmitSyncErrorToast: boolean;
};
export type AgentScreenViewState =
| {
@@ -47,7 +64,7 @@ export type AgentScreenViewState =
tag: "ready";
agent: Agent;
source: "authoritative" | "optimistic" | "stale";
syncStatus: AgentScreenSyncStatus;
sync: AgentScreenReadySyncState;
isArchiving: boolean;
};
@@ -61,8 +78,21 @@ export function deriveAgentScreenViewState({
const nextMemory: AgentScreenMachineMemory = {
hasRenderedReady: memory.hasRenderedReady,
lastReadyAgent: memory.lastReadyAgent,
activeToastLatch: memory.activeToastLatch,
hadInitialSyncFailure: memory.hadInitialSyncFailure,
};
if (input.hasHydratedHistoryBefore) {
nextMemory.hadInitialSyncFailure = false;
}
if (
input.missingAgentState.kind === "error" &&
!input.hasHydratedHistoryBefore
) {
nextMemory.hadInitialSyncFailure = true;
}
const candidateAgent = input.agent ?? input.placeholderAgent;
if (candidateAgent) {
nextMemory.hasRenderedReady = true;
@@ -108,13 +138,49 @@ export function deriveAgentScreenViewState({
? "optimistic"
: "stale";
let syncStatus: AgentScreenSyncStatus = "idle";
let sync: AgentScreenReadySyncState;
if (!input.isConnected) {
syncStatus = "reconnecting";
nextMemory.activeToastLatch = "none";
sync = { status: "reconnecting" };
} else if (input.missingAgentState.kind === "error") {
syncStatus = "sync_error";
const shouldEmitSyncErrorToast = memory.activeToastLatch !== "sync_error";
nextMemory.activeToastLatch = "sync_error";
sync = {
status: "sync_error",
shouldEmitSyncErrorToast,
};
} else if (input.needsAuthoritativeSync || input.isHistorySyncing) {
syncStatus = "catching_up";
let ui: "overlay" | "toast" | "silent";
if (input.shouldUseOptimisticStream) {
ui = "silent";
} else if (input.hasHydratedHistoryBefore) {
ui = "toast";
} else if (nextMemory.hadInitialSyncFailure) {
ui = "silent";
} else {
ui = "overlay";
}
if (ui === "toast") {
const shouldEmitHistoryRefreshToast =
memory.activeToastLatch !== "history_refresh";
nextMemory.activeToastLatch = "history_refresh";
sync = {
status: "catching_up",
ui,
shouldEmitHistoryRefreshToast,
};
} else {
nextMemory.activeToastLatch = "none";
sync = {
status: "catching_up",
ui,
shouldEmitHistoryRefreshToast: false,
};
}
} else {
nextMemory.activeToastLatch = "none";
sync = { status: "idle" };
}
return {
@@ -122,7 +188,7 @@ export function deriveAgentScreenViewState({
tag: "ready",
agent: displayAgent,
source,
syncStatus,
sync,
isArchiving: input.isArchivingCurrentAgent,
},
memory: nextMemory,
@@ -140,6 +206,8 @@ export function useAgentScreenStateMachine({
const memoryRef = useRef<AgentScreenMachineMemory>({
hasRenderedReady: false,
lastReadyAgent: null,
activeToastLatch: "none",
hadInitialSyncFailure: false,
});
if (routeKeyRef.current !== routeKey) {
@@ -147,6 +215,8 @@ export function useAgentScreenStateMachine({
memoryRef.current = {
hasRenderedReady: false,
lastReadyAgent: null,
activeToastLatch: "none",
hadInitialSyncFailure: false,
};
}
+12 -76
View File
@@ -4,7 +4,7 @@ import { useDaemonConnections } from "@/contexts/daemon-connections-context";
import { useSessionStore } from "@/stores/session-store";
import type { AgentDirectoryEntry } from "@/types/agent-directory";
import type { Agent } from "@/stores/session-store";
import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
export interface AggregatedAgent extends AgentDirectoryEntry {
serverId: string;
@@ -21,6 +21,7 @@ export interface AggregatedAgentsResult {
export function useAggregatedAgents(): AggregatedAgentsResult {
const { connectionStates } = useDaemonConnections();
const runtime = getHostRuntimeStore();
const sessionAgents = useSessionStore(
useShallow((state) => {
@@ -32,53 +33,9 @@ export function useAggregatedAgents(): AggregatedAgentsResult {
})
);
const sessionClients = useSessionStore(
useShallow((state) => {
const result: Record<string, NonNullable<typeof state.sessions[string]["client"]> | null> = {};
for (const [serverId, session] of Object.entries(state.sessions)) {
result[serverId] = session.client ?? null;
}
return result;
})
);
const refreshAll = useCallback(() => {
for (const [serverId, client] of Object.entries(sessionClients)) {
if (!client) {
continue;
}
void (async () => {
try {
const agentsList = await client.fetchAgents({
filter: { labels: { ui: "true" } },
});
const agents = new Map();
const pendingPermissions = new Map();
const agentLastActivity = new Map();
for (const { agent: snapshot } of agentsList.entries) {
const agent = normalizeAgentSnapshot(snapshot, serverId);
agents.set(agent.id, agent);
agentLastActivity.set(agent.id, agent.lastActivityAt);
for (const request of agent.pendingPermissions) {
const key = derivePendingPermissionKey(agent.id, request);
pendingPermissions.set(key, { key, agentId: agent.id, request });
}
}
const store = useSessionStore.getState();
store.setAgents(serverId, agents);
for (const [agentId, timestamp] of agentLastActivity.entries()) {
store.setAgentLastActivity(agentId, timestamp);
}
store.setPendingPermissions(serverId, pendingPermissions);
} catch (error) {
console.warn("[useAggregatedAgents] Failed to refresh session", { serverId, error });
}
})();
}
}, [sessionClients]);
runtime.refreshAllAgentDirectories();
}, [runtime]);
const result = useMemo(() => {
const allAgents: AggregatedAgent[] = [];
@@ -127,35 +84,14 @@ export function useAggregatedAgents(): AggregatedAgentsResult {
// Check if we have any cached data
const hasAnyData = allAgents.length > 0;
// Check if any connection is currently loading
const isConnecting = Array.from(connectionStates.entries()).some(([, c]) => {
// First-time connection (never received agent list)
if (c.status === 'connecting' && !c.hasEverReceivedAgentList) {
return true;
}
if (c.status === 'online' && !c.hasEverReceivedAgentList) {
return true;
}
// Reconnecting (have received agent list before)
if (c.status === 'connecting' && c.hasEverReceivedAgentList) {
return true;
}
if (c.status === 'online' && !c.agentListReady && c.hasEverReceivedAgentList) {
return true;
}
return false;
});
// isInitialLoad: Loading for the first time (no cached data)
const isInitialLoad = isConnecting && !hasAnyData;
// isRevalidating: Loading but we have cached data (reconnecting)
const isRevalidating = isConnecting && hasAnyData;
// isLoading: Generic loading flag (either initial or revalidating)
const isLoading = isConnecting;
// Align list loading with the runtime directory-sync machine.
const isLoading = Array.from(connectionStates.values()).some(
(connection) =>
connection.agentDirectoryStatus === "initial_loading" ||
connection.agentDirectoryStatus === "revalidating"
);
const isInitialLoad = isLoading && !hasAnyData;
const isRevalidating = isLoading && hasAnyData;
return {
agents: allAgents,
+27 -43
View File
@@ -1,15 +1,18 @@
import { useCallback, useMemo } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
import { useSessionStore, type Agent } from "@/stores/session-store";
import { useHostRuntimeSession } from "@/runtime/host-runtime";
import type { AggregatedAgent, AggregatedAgentsResult } from "@/hooks/use-aggregated-agents";
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
const ALL_AGENTS_STALE_TIME = 60_000;
import {
getHostRuntimeStore,
isHostRuntimeDirectoryLoading,
useHostRuntimeSession,
} from "@/runtime/host-runtime";
import type {
AggregatedAgent,
AggregatedAgentsResult,
} from "@/hooks/use-aggregated-agents";
function toAggregatedAgent(params: {
source: Agent | ReturnType<typeof normalizeAgentSnapshot>;
source: Agent;
serverId: string;
serverLabel: string;
}): AggregatedAgent {
@@ -35,7 +38,8 @@ export function useAllAgentsList(options?: {
serverId?: string | null;
}): AggregatedAgentsResult {
const { connectionStates } = useDaemonConnections();
const queryClient = useQueryClient();
const runtime = getHostRuntimeStore();
const serverId = useMemo(() => {
const value = options?.serverId;
return typeof value === "string" && value.trim().length > 0
@@ -46,54 +50,35 @@ export function useAllAgentsList(options?: {
const session = useSessionStore((state) =>
serverId ? state.sessions[serverId] : undefined
);
const { client, isConnected } = useHostRuntimeSession(serverId ?? "");
const liveAgents = session?.agents ?? null;
const canFetch = Boolean(serverId && client && isConnected);
const agentsQuery = useQuery({
queryKey: ["allAgents", serverId] as const,
queryFn: async () => {
if (!client) {
throw new Error("Daemon client not available");
}
return await client.fetchAgents({
filter: { labels: { ui: "true" } },
});
},
enabled: canFetch,
staleTime: ALL_AGENTS_STALE_TIME,
refetchOnMount: "always" as const,
});
const { snapshot } = useHostRuntimeSession(serverId ?? "");
const refreshAll = useCallback(() => {
if (!serverId) {
if (!serverId || snapshot?.connectionStatus !== "online") {
return;
}
void queryClient.invalidateQueries({
queryKey: ["allAgents", serverId],
});
}, [queryClient, serverId]);
void runtime.refreshAgentDirectory({ serverId }).catch(() => undefined);
}, [runtime, serverId, snapshot?.connectionStatus]);
const agents = useMemo(() => {
if (!serverId) {
if (!serverId || !liveAgents) {
return [];
}
const data = agentsQuery.data?.entries ?? [];
const serverLabel = connectionStates.get(serverId)?.daemon.label ?? serverId;
const list: AggregatedAgent[] = [];
for (const entry of data) {
const snapshot = entry.agent;
const normalized = normalizeAgentSnapshot(snapshot, serverId);
const live = liveAgents?.get(snapshot.id);
for (const agent of liveAgents.values()) {
const aggregated = toAggregatedAgent({
source: live ?? normalized,
source: agent,
serverId,
serverLabel,
});
if (aggregated.archivedAt) {
continue;
}
if (aggregated.labels.ui !== "true") {
continue;
}
list.push(aggregated);
}
@@ -110,16 +95,15 @@ export function useAllAgentsList(options?: {
});
return list;
}, [agentsQuery.data, connectionStates, liveAgents, serverId]);
}, [connectionStates, liveAgents, serverId]);
const isFetching =
canFetch && (agentsQuery.isPending || agentsQuery.isFetching);
const isInitialLoad = isFetching && agents.length === 0;
const isRevalidating = isFetching && agents.length > 0;
const isDirectoryLoading = Boolean(serverId && isHostRuntimeDirectoryLoading(snapshot));
const isInitialLoad = isDirectoryLoading && agents.length === 0;
const isRevalidating = isDirectoryLoading && agents.length > 0;
return {
agents,
isLoading: isFetching,
isLoading: isDirectoryLoading,
isInitialLoad,
isRevalidating,
refreshAll,
@@ -1,62 +0,0 @@
import { useEffect, useMemo } from "react";
import { AppState } from "react-native";
import { DaemonClient } from "@server/client/daemon-client";
import { createTauriWebSocketTransportFactory } from "@/utils/tauri-daemon-transport";
function runDaemonRequest(label: string, promise: Promise<unknown>): void {
void promise.catch((error) => {
console.warn(`[DaemonClient] ${label} failed`, error);
});
}
type DaemonClientOptions = {
daemonPublicKeyB64?: string;
};
export function useDaemonClient(
url: string,
options: DaemonClientOptions = {}
): DaemonClient {
const client = useMemo(
() => {
const tauriTransportFactory = createTauriWebSocketTransportFactory();
return new DaemonClient({
url,
suppressSendErrors: true,
...(tauriTransportFactory
? { transportFactory: tauriTransportFactory }
: {}),
e2ee: options.daemonPublicKeyB64
? {
enabled: true,
daemonPublicKeyB64: options.daemonPublicKeyB64,
}
: undefined,
});
},
[options.daemonPublicKeyB64, url]
);
useEffect(() => {
runDaemonRequest("connect", client.connect());
return () => {
runDaemonRequest("close", client.close());
};
}, [client]);
useEffect(() => {
const subscription = AppState.addEventListener("change", (nextState) => {
if (nextState !== "active") {
return;
}
client.ensureConnected();
});
return () => {
subscription.remove();
};
}, [client]);
return client;
}
@@ -1,21 +1,22 @@
import { useCallback, useMemo } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
import { useSessionStore, type Agent } from "@/stores/session-store";
import { useHostRuntimeSession } from "@/runtime/host-runtime";
import {
getHostRuntimeStore,
isHostRuntimeDirectoryLoading,
useHostRuntimeSession,
} from "@/runtime/host-runtime";
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import {
deriveSidebarStateBucket,
isSidebarActiveAgent,
} from "@/utils/sidebar-agent-state";
import type { ProjectPlacementPayload } from "@server/shared/messages";
import { resolveProjectPlacement } from "@/utils/project-placement";
const SIDEBAR_AGENTS_STALE_TIME = 15_000;
const SIDEBAR_AGENTS_REFETCH_INTERVAL = 10_000;
const SIDEBAR_DONE_FILL_TARGET = 50;
export interface SidebarProjectOption {
export interface SidebarProjectFilterOption {
projectKey: string;
projectName: string;
activeCount: number;
@@ -29,9 +30,9 @@ export interface SidebarAgentListEntry {
project: ProjectPlacementPayload;
}
export interface SidebarAgentsGroupedResult {
export interface SidebarAgentsListResult {
entries: SidebarAgentListEntry[];
projectOptions: SidebarProjectOption[];
projectFilterOptions: SidebarProjectFilterOption[];
hasMoreEntries: boolean;
isLoading: boolean;
isInitialLoad: boolean;
@@ -60,7 +61,6 @@ function compareByTitleAsc(
return titleCmp;
}
// Deterministic tie-breaker so running rows stay stable while status updates stream.
return left.agent.id.localeCompare(right.agent.id, undefined, {
numeric: true,
sensitivity: "base",
@@ -121,7 +121,7 @@ function applySidebarDefaultOrdering(
}
function toAggregatedAgent(params: {
source: Agent | ReturnType<typeof normalizeAgentSnapshot>;
source: Agent;
serverId: string;
serverLabel: string;
}): AggregatedAgent & { createdAt: Date } {
@@ -144,63 +144,41 @@ function toAggregatedAgent(params: {
};
}
export function useSidebarAgentsGrouped(options?: {
export function useSidebarAgentsList(options?: {
isOpen?: boolean;
serverId?: string | null;
selectedProjectKeys?: string[];
}): SidebarAgentsGroupedResult {
selectedProjectFilterKeys?: string[];
}): SidebarAgentsListResult {
const { connectionStates } = useDaemonConnections();
const queryClient = useQueryClient();
const isOpen = options?.isOpen ?? true;
const runtime = getHostRuntimeStore();
const serverId = useMemo(() => {
const value = options?.serverId;
return typeof value === "string" && value.trim().length > 0
? value.trim()
: null;
}, [options?.serverId]);
const selectedProjectKeys = useMemo(
const selectedProjectFilterKeys = useMemo(
() =>
new Set(
(options?.selectedProjectKeys ?? [])
(options?.selectedProjectFilterKeys ?? [])
.map((item) => item.trim())
.filter((item) => item.length > 0)
),
[options?.selectedProjectKeys]
[options?.selectedProjectFilterKeys]
);
const session = useSessionStore((state) =>
serverId ? state.sessions[serverId] : undefined
);
const { client, isConnected } = useHostRuntimeSession(serverId ?? "");
const liveAgents = session?.agents ?? null;
const canFetch = Boolean(serverId && client && isConnected);
const { snapshot } = useHostRuntimeSession(serverId ?? "");
const agentsQuery = useQuery({
queryKey: ["sidebarAgentsList", serverId] as const,
queryFn: async () => {
if (!client) {
throw new Error("Daemon client not available");
}
return await client.fetchAgents({
filter: { labels: { ui: "true" } },
sort: [
{ key: "status_priority", direction: "asc" },
{ key: "updated_at", direction: "desc" },
],
});
},
enabled: canFetch,
staleTime: SIDEBAR_AGENTS_STALE_TIME,
refetchInterval: isOpen ? SIDEBAR_AGENTS_REFETCH_INTERVAL : false,
refetchIntervalInBackground: isOpen,
refetchOnMount: "always" as const,
});
const { entries, projectOptions, hasAnyData, hasMoreEntries } = useMemo(() => {
if (!serverId) {
const { entries, projectFilterOptions, hasAnyData, hasMoreEntries } = useMemo(() => {
if (!serverId || !liveAgents) {
return {
entries: [] as SidebarAgentListEntry[],
projectOptions: [] as SidebarProjectOption[],
projectFilterOptions: [] as SidebarProjectFilterOption[],
hasAnyData: false,
hasMoreEntries: false,
};
@@ -208,7 +186,7 @@ export function useSidebarAgentsGrouped(options?: {
const serverLabel = connectionStates.get(serverId)?.daemon.label ?? serverId;
const seenAgentIds = new Set<string>();
const byProject = new Map<string, SidebarProjectOption>();
const byProject = new Map<string, SidebarProjectFilterOption>();
const mergedEntries: SidebarAgentListEntry[] = [];
const pushEntry = (entry: SidebarAgentListEntry): void => {
@@ -246,43 +224,26 @@ export function useSidebarAgentsGrouped(options?: {
});
};
const fetchedEntries = agentsQuery.data?.entries ?? [];
for (const fetchedEntry of fetchedEntries) {
const normalized = normalizeAgentSnapshot(fetchedEntry.agent, serverId);
const live = liveAgents?.get(fetchedEntry.agent.id);
const project = live?.projectPlacement ?? fetchedEntry.project;
if (!project) {
for (const live of liveAgents.values()) {
if (live.archivedAt || live.labels.ui !== "true") {
continue;
}
const project = resolveProjectPlacement({
projectPlacement: live.projectPlacement ?? null,
cwd: live.cwd,
});
const agent = toAggregatedAgent({
source: live ?? normalized,
source: live,
serverId,
serverLabel,
});
pushEntry({ agent, project });
}
if (liveAgents) {
for (const live of liveAgents.values()) {
if (live.archivedAt || live.labels.ui !== "true") {
continue;
}
if (!live.projectPlacement) {
continue;
}
const agent = toAggregatedAgent({
source: live,
serverId,
serverLabel,
});
pushEntry({ agent, project: live.projectPlacement });
}
}
const filteredEntries =
selectedProjectKeys.size > 0
selectedProjectFilterKeys.size > 0
? mergedEntries.filter((entry) =>
selectedProjectKeys.has(entry.project.projectKey)
selectedProjectFilterKeys.has(entry.project.projectKey)
)
: mergedEntries;
@@ -296,37 +257,28 @@ export function useSidebarAgentsGrouped(options?: {
return {
entries: ordered.entries,
projectOptions: options,
projectFilterOptions: options,
hasAnyData: ordered.entries.length > 0,
hasMoreEntries: ordered.hasMore,
};
}, [
agentsQuery.data?.entries,
connectionStates,
liveAgents,
selectedProjectKeys,
serverId,
]);
}, [connectionStates, liveAgents, selectedProjectFilterKeys, serverId]);
const refreshAll = useCallback(() => {
if (!serverId) {
if (!serverId || snapshot?.connectionStatus !== "online") {
return;
}
void queryClient.invalidateQueries({
queryKey: ["sidebarAgentsList", serverId],
});
}, [queryClient, serverId]);
void runtime.refreshAgentDirectory({ serverId }).catch(() => undefined);
}, [runtime, serverId, snapshot?.connectionStatus]);
const isFetching =
canFetch && (agentsQuery.isPending || agentsQuery.isFetching);
const isInitialLoad = isFetching && !hasAnyData;
const isRevalidating = isFetching && hasAnyData;
const isDirectoryLoading = Boolean(serverId && isHostRuntimeDirectoryLoading(snapshot));
const isInitialLoad = isDirectoryLoading && !hasAnyData;
const isRevalidating = isDirectoryLoading && hasAnyData;
return {
entries,
projectOptions,
projectFilterOptions,
hasMoreEntries,
isLoading: isFetching,
isLoading: isDirectoryLoading,
isInitialLoad,
isRevalidating,
refreshAll,