feat: implement daemon connectivity updates and relay protocol versioning
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+42
-90
@@ -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,
|
||||
Reference in New Issue
Block a user