refactor(agent-screen): extract state machine for agent loading flow
This commit is contained in:
@@ -290,7 +290,8 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
onError: handleDictationError,
|
onError: handleDictationError,
|
||||||
canStart: canStartDictation,
|
canStart: canStartDictation,
|
||||||
canConfirm: canConfirmDictation,
|
canConfirm: canConfirmDictation,
|
||||||
autoStopWhenHidden: { isVisible: isScreenFocused },
|
autoStopWhenHidden:
|
||||||
|
Platform.OS === 'web' ? undefined : { isVisible: isScreenFocused },
|
||||||
enableDuration: true,
|
enableDuration: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,247 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { Agent } from "@/contexts/session-context";
|
||||||
|
import {
|
||||||
|
deriveAgentScreenViewState,
|
||||||
|
type AgentScreenMachineInput,
|
||||||
|
type AgentScreenMachineMemory,
|
||||||
|
} from "./use-agent-screen-state-machine";
|
||||||
|
|
||||||
|
function createAgent(id: string): Agent {
|
||||||
|
const now = new Date("2026-02-19T00:00:00.000Z");
|
||||||
|
return {
|
||||||
|
serverId: "server-1",
|
||||||
|
id,
|
||||||
|
provider: "claude",
|
||||||
|
status: "running",
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
lastUserMessageAt: now,
|
||||||
|
lastActivityAt: now,
|
||||||
|
capabilities: {
|
||||||
|
supportsStreaming: true,
|
||||||
|
supportsSessionPersistence: true,
|
||||||
|
supportsDynamicModes: true,
|
||||||
|
supportsMcpServers: true,
|
||||||
|
supportsReasoningStream: true,
|
||||||
|
supportsToolInvocations: true,
|
||||||
|
},
|
||||||
|
currentModeId: null,
|
||||||
|
availableModes: [],
|
||||||
|
pendingPermissions: [],
|
||||||
|
persistence: null,
|
||||||
|
runtimeInfo: {
|
||||||
|
provider: "claude",
|
||||||
|
sessionId: "session-1",
|
||||||
|
model: null,
|
||||||
|
modeId: null,
|
||||||
|
},
|
||||||
|
title: "Agent",
|
||||||
|
cwd: "/repo",
|
||||||
|
model: null,
|
||||||
|
labels: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createBaseInput(): AgentScreenMachineInput {
|
||||||
|
return {
|
||||||
|
agent: null,
|
||||||
|
placeholderAgent: null,
|
||||||
|
missingAgentState: { kind: "idle" },
|
||||||
|
isConnected: true,
|
||||||
|
isArchivingCurrentAgent: false,
|
||||||
|
isHistorySyncing: false,
|
||||||
|
needsAuthoritativeSync: false,
|
||||||
|
shouldUseOptimisticStream: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("deriveAgentScreenViewState", () => {
|
||||||
|
it("returns boot loading before first interactive paint", () => {
|
||||||
|
const memory: AgentScreenMachineMemory = {
|
||||||
|
hasRenderedReady: false,
|
||||||
|
lastReadyAgent: null,
|
||||||
|
};
|
||||||
|
const input = createBaseInput();
|
||||||
|
|
||||||
|
const result = deriveAgentScreenViewState({ input, memory });
|
||||||
|
|
||||||
|
expect(result.state.tag).toBe("boot");
|
||||||
|
if (result.state.tag !== "boot") {
|
||||||
|
throw new Error("expected boot state");
|
||||||
|
}
|
||||||
|
expect(result.state.reason).toBe("loading");
|
||||||
|
expect(result.state.source).toBe("none");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays ready after first paint even if agent is temporarily missing", () => {
|
||||||
|
const memory: AgentScreenMachineMemory = {
|
||||||
|
hasRenderedReady: true,
|
||||||
|
lastReadyAgent: createAgent("agent-1"),
|
||||||
|
};
|
||||||
|
const input = createBaseInput();
|
||||||
|
|
||||||
|
const result = deriveAgentScreenViewState({ input, memory });
|
||||||
|
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows reconnecting sync status without blocking after first paint", () => {
|
||||||
|
const memory: AgentScreenMachineMemory = {
|
||||||
|
hasRenderedReady: true,
|
||||||
|
lastReadyAgent: createAgent("agent-1"),
|
||||||
|
};
|
||||||
|
const input: AgentScreenMachineInput = {
|
||||||
|
...createBaseInput(),
|
||||||
|
isConnected: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = deriveAgentScreenViewState({ input, memory });
|
||||||
|
|
||||||
|
expect(result.state.tag).toBe("ready");
|
||||||
|
if (result.state.tag !== "ready") {
|
||||||
|
throw new Error("expected ready state");
|
||||||
|
}
|
||||||
|
expect(result.state.syncStatus).toBe("reconnecting");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows non-blocking catching-up state after first paint", () => {
|
||||||
|
const memory: AgentScreenMachineMemory = {
|
||||||
|
hasRenderedReady: true,
|
||||||
|
lastReadyAgent: createAgent("agent-1"),
|
||||||
|
};
|
||||||
|
const input: AgentScreenMachineInput = {
|
||||||
|
...createBaseInput(),
|
||||||
|
needsAuthoritativeSync: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = deriveAgentScreenViewState({ input, memory });
|
||||||
|
|
||||||
|
expect(result.state.tag).toBe("ready");
|
||||||
|
if (result.state.tag !== "ready") {
|
||||||
|
throw new Error("expected ready state");
|
||||||
|
}
|
||||||
|
expect(result.state.syncStatus).toBe("catching_up");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps ready with sync_error when refresh fails after first paint", () => {
|
||||||
|
const memory: AgentScreenMachineMemory = {
|
||||||
|
hasRenderedReady: true,
|
||||||
|
lastReadyAgent: createAgent("agent-1"),
|
||||||
|
};
|
||||||
|
const input: AgentScreenMachineInput = {
|
||||||
|
...createBaseInput(),
|
||||||
|
missingAgentState: { kind: "error", message: "network timeout" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = deriveAgentScreenViewState({ input, memory });
|
||||||
|
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns blocking error before first paint when refresh fails", () => {
|
||||||
|
const memory: AgentScreenMachineMemory = {
|
||||||
|
hasRenderedReady: false,
|
||||||
|
lastReadyAgent: null,
|
||||||
|
};
|
||||||
|
const input: AgentScreenMachineInput = {
|
||||||
|
...createBaseInput(),
|
||||||
|
missingAgentState: { kind: "error", message: "network timeout" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = deriveAgentScreenViewState({ input, memory });
|
||||||
|
|
||||||
|
expect(result.state.tag).toBe("error");
|
||||||
|
if (result.state.tag !== "error") {
|
||||||
|
throw new Error("expected error state");
|
||||||
|
}
|
||||||
|
expect(result.state.message).toContain("network timeout");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns not_found when resolver confirms missing agent", () => {
|
||||||
|
const memory: AgentScreenMachineMemory = {
|
||||||
|
hasRenderedReady: true,
|
||||||
|
lastReadyAgent: createAgent("agent-1"),
|
||||||
|
};
|
||||||
|
const input: AgentScreenMachineInput = {
|
||||||
|
...createBaseInput(),
|
||||||
|
missingAgentState: { kind: "not_found", message: "agent missing" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = deriveAgentScreenViewState({ input, memory });
|
||||||
|
|
||||||
|
expect(result.state.tag).toBe("not_found");
|
||||||
|
if (result.state.tag !== "not_found") {
|
||||||
|
throw new Error("expected not_found state");
|
||||||
|
}
|
||||||
|
expect(result.state.message).toContain("missing");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("promotes optimistic source while placeholder is used", () => {
|
||||||
|
const memory: AgentScreenMachineMemory = {
|
||||||
|
hasRenderedReady: false,
|
||||||
|
lastReadyAgent: null,
|
||||||
|
};
|
||||||
|
const input: AgentScreenMachineInput = {
|
||||||
|
...createBaseInput(),
|
||||||
|
placeholderAgent: createAgent("draft-agent"),
|
||||||
|
shouldUseOptimisticStream: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = deriveAgentScreenViewState({ input, memory });
|
||||||
|
|
||||||
|
expect(result.state.tag).toBe("ready");
|
||||||
|
if (result.state.tag !== "ready") {
|
||||||
|
throw new Error("expected ready state");
|
||||||
|
}
|
||||||
|
expect(result.state.source).toBe("optimistic");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps optimistic flow non-blocking while transitioning to authoritative stream", () => {
|
||||||
|
const initialMemory: AgentScreenMachineMemory = {
|
||||||
|
hasRenderedReady: false,
|
||||||
|
lastReadyAgent: null,
|
||||||
|
};
|
||||||
|
const optimisticInput: AgentScreenMachineInput = {
|
||||||
|
...createBaseInput(),
|
||||||
|
placeholderAgent: createAgent("draft-agent"),
|
||||||
|
shouldUseOptimisticStream: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const optimistic = 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 handoffInput: AgentScreenMachineInput = {
|
||||||
|
...createBaseInput(),
|
||||||
|
};
|
||||||
|
const handoff = deriveAgentScreenViewState({
|
||||||
|
input: handoffInput,
|
||||||
|
memory: optimistic.memory,
|
||||||
|
});
|
||||||
|
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import { useRef } from "react";
|
||||||
|
import type { Agent } from "@/contexts/session-context";
|
||||||
|
|
||||||
|
export type AgentScreenMissingState =
|
||||||
|
| { kind: "idle" }
|
||||||
|
| { kind: "resolving" }
|
||||||
|
| { kind: "not_found"; message: string }
|
||||||
|
| { kind: "error"; message: string };
|
||||||
|
|
||||||
|
export interface AgentScreenMachineInput {
|
||||||
|
agent: Agent | null;
|
||||||
|
placeholderAgent: Agent | null;
|
||||||
|
missingAgentState: AgentScreenMissingState;
|
||||||
|
isConnected: boolean;
|
||||||
|
isArchivingCurrentAgent: boolean;
|
||||||
|
isHistorySyncing: boolean;
|
||||||
|
needsAuthoritativeSync: boolean;
|
||||||
|
shouldUseOptimisticStream: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentScreenMachineMemory {
|
||||||
|
hasRenderedReady: boolean;
|
||||||
|
lastReadyAgent: Agent | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AgentScreenSyncStatus =
|
||||||
|
| "idle"
|
||||||
|
| "catching_up"
|
||||||
|
| "reconnecting"
|
||||||
|
| "sync_error";
|
||||||
|
|
||||||
|
export type AgentScreenViewState =
|
||||||
|
| {
|
||||||
|
tag: "boot";
|
||||||
|
reason: "loading" | "resolving";
|
||||||
|
source: "none";
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
tag: "not_found";
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
tag: "error";
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
tag: "ready";
|
||||||
|
agent: Agent;
|
||||||
|
source: "authoritative" | "optimistic" | "stale";
|
||||||
|
syncStatus: AgentScreenSyncStatus;
|
||||||
|
isArchiving: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function deriveAgentScreenViewState({
|
||||||
|
input,
|
||||||
|
memory,
|
||||||
|
}: {
|
||||||
|
input: AgentScreenMachineInput;
|
||||||
|
memory: AgentScreenMachineMemory;
|
||||||
|
}): { state: AgentScreenViewState; memory: AgentScreenMachineMemory } {
|
||||||
|
const nextMemory: AgentScreenMachineMemory = {
|
||||||
|
hasRenderedReady: memory.hasRenderedReady,
|
||||||
|
lastReadyAgent: memory.lastReadyAgent,
|
||||||
|
};
|
||||||
|
|
||||||
|
const candidateAgent = input.agent ?? input.placeholderAgent;
|
||||||
|
if (candidateAgent) {
|
||||||
|
nextMemory.hasRenderedReady = true;
|
||||||
|
nextMemory.lastReadyAgent = candidateAgent;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.missingAgentState.kind === "not_found") {
|
||||||
|
return {
|
||||||
|
state: {
|
||||||
|
tag: "not_found",
|
||||||
|
message: input.missingAgentState.message,
|
||||||
|
},
|
||||||
|
memory: nextMemory,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.missingAgentState.kind === "error" && !nextMemory.hasRenderedReady) {
|
||||||
|
return {
|
||||||
|
state: {
|
||||||
|
tag: "error",
|
||||||
|
message: input.missingAgentState.message,
|
||||||
|
},
|
||||||
|
memory: nextMemory,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayAgent =
|
||||||
|
candidateAgent ?? (nextMemory.hasRenderedReady ? nextMemory.lastReadyAgent : null);
|
||||||
|
if (!displayAgent) {
|
||||||
|
return {
|
||||||
|
state: {
|
||||||
|
tag: "boot",
|
||||||
|
reason: input.missingAgentState.kind === "resolving" ? "resolving" : "loading",
|
||||||
|
source: "none",
|
||||||
|
},
|
||||||
|
memory: nextMemory,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const source: "authoritative" | "optimistic" | "stale" = input.agent
|
||||||
|
? "authoritative"
|
||||||
|
: input.shouldUseOptimisticStream
|
||||||
|
? "optimistic"
|
||||||
|
: "stale";
|
||||||
|
|
||||||
|
let syncStatus: AgentScreenSyncStatus = "idle";
|
||||||
|
if (!input.isConnected) {
|
||||||
|
syncStatus = "reconnecting";
|
||||||
|
} else if (input.missingAgentState.kind === "error") {
|
||||||
|
syncStatus = "sync_error";
|
||||||
|
} else if (input.needsAuthoritativeSync || input.isHistorySyncing) {
|
||||||
|
syncStatus = "catching_up";
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
state: {
|
||||||
|
tag: "ready",
|
||||||
|
agent: displayAgent,
|
||||||
|
source,
|
||||||
|
syncStatus,
|
||||||
|
isArchiving: input.isArchivingCurrentAgent,
|
||||||
|
},
|
||||||
|
memory: nextMemory,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAgentScreenStateMachine({
|
||||||
|
routeKey,
|
||||||
|
input,
|
||||||
|
}: {
|
||||||
|
routeKey: string;
|
||||||
|
input: AgentScreenMachineInput;
|
||||||
|
}): AgentScreenViewState {
|
||||||
|
const routeKeyRef = useRef(routeKey);
|
||||||
|
const memoryRef = useRef<AgentScreenMachineMemory>({
|
||||||
|
hasRenderedReady: false,
|
||||||
|
lastReadyAgent: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (routeKeyRef.current !== routeKey) {
|
||||||
|
routeKeyRef.current = routeKey;
|
||||||
|
memoryRef.current = {
|
||||||
|
hasRenderedReady: false,
|
||||||
|
lastReadyAgent: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = deriveAgentScreenViewState({
|
||||||
|
input,
|
||||||
|
memory: memoryRef.current,
|
||||||
|
});
|
||||||
|
memoryRef.current = result.memory;
|
||||||
|
return result.state;
|
||||||
|
}
|
||||||
@@ -63,6 +63,10 @@ import {
|
|||||||
} from "@/hooks/use-checkout-status-query";
|
} from "@/hooks/use-checkout-status-query";
|
||||||
import { useAgentInitialization } from "@/hooks/use-agent-initialization";
|
import { useAgentInitialization } from "@/hooks/use-agent-initialization";
|
||||||
import { useArchiveAgent } from "@/hooks/use-archive-agent";
|
import { useArchiveAgent } from "@/hooks/use-archive-agent";
|
||||||
|
import {
|
||||||
|
useAgentScreenStateMachine,
|
||||||
|
type AgentScreenMissingState,
|
||||||
|
} from "@/hooks/use-agent-screen-state-machine";
|
||||||
import { useToast } from "@/contexts/toast-context";
|
import { useToast } from "@/contexts/toast-context";
|
||||||
import { getInitDeferred, getInitKey } from "@/utils/agent-initialization";
|
import { getInitDeferred, getInitKey } from "@/utils/agent-initialization";
|
||||||
import {
|
import {
|
||||||
@@ -85,8 +89,6 @@ import type { ExplorerCheckoutContext } from "@/stores/panel-store";
|
|||||||
|
|
||||||
const DROPDOWN_WIDTH = 220;
|
const DROPDOWN_WIDTH = 220;
|
||||||
const EMPTY_STREAM_ITEMS: StreamItem[] = [];
|
const EMPTY_STREAM_ITEMS: StreamItem[] = [];
|
||||||
const RECONNECT_NOTICE_DELAY_MS = 10_000;
|
|
||||||
const CONNECTED_NOTICE_DURATION_MS = 2_500;
|
|
||||||
const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__);
|
const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__);
|
||||||
|
|
||||||
function logAgentExplorer(event: string, details: Record<string, unknown>): void {
|
function logAgentExplorer(event: string, details: Record<string, unknown>): void {
|
||||||
@@ -199,12 +201,6 @@ type AgentScreenContentProps = {
|
|||||||
connectionStatus: ConnectionStatus;
|
connectionStatus: ConnectionStatus;
|
||||||
};
|
};
|
||||||
|
|
||||||
type MissingAgentState =
|
|
||||||
| { kind: "idle" }
|
|
||||||
| { kind: "resolving" }
|
|
||||||
| { kind: "not_found"; message: string }
|
|
||||||
| { kind: "error"; message: string };
|
|
||||||
|
|
||||||
function toErrorMessage(error: unknown): string {
|
function toErrorMessage(error: unknown): string {
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
return error.message;
|
return error.message;
|
||||||
@@ -479,14 +475,10 @@ function AgentScreenContent({
|
|||||||
(state) => state.sessions[serverId]?.focusedAgentId ?? null
|
(state) => state.sessions[serverId]?.focusedAgentId ?? null
|
||||||
);
|
);
|
||||||
const { ensureAgentIsInitialized, refreshAgent } = useAgentInitialization(serverId);
|
const { ensureAgentIsInitialized, refreshAgent } = useAgentInitialization(serverId);
|
||||||
const [missingAgentState, setMissingAgentState] = useState<MissingAgentState>({
|
const [missingAgentState, setMissingAgentState] = useState<AgentScreenMissingState>({
|
||||||
kind: "idle",
|
kind: "idle",
|
||||||
});
|
});
|
||||||
const [showReconnectNotice, setShowReconnectNotice] = useState(false);
|
const reconnectToastArmedRef = useRef(false);
|
||||||
const [dismissedReconnectNotice, setDismissedReconnectNotice] = useState(false);
|
|
||||||
const [showConnectedNotice, setShowConnectedNotice] = useState(false);
|
|
||||||
const reconnectNoticeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
const connectedNoticeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
const initAttemptTokenRef = useRef(0);
|
const initAttemptTokenRef = useRef(0);
|
||||||
const setFocusedAgentId = useCallback(
|
const setFocusedAgentId = useCallback(
|
||||||
(agentId: string | null) => {
|
(agentId: string | null) => {
|
||||||
@@ -511,58 +503,43 @@ function AgentScreenContent({
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const agentModel = extractAgentModel(agent);
|
|
||||||
const modelDisplayValue = agentModel ?? "Unknown";
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (reconnectNoticeTimeoutRef.current) {
|
|
||||||
clearTimeout(reconnectNoticeTimeoutRef.current);
|
|
||||||
reconnectNoticeTimeoutRef.current = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (connectionStatus === "online") {
|
if (connectionStatus === "online") {
|
||||||
if (showReconnectNotice || dismissedReconnectNotice) {
|
reconnectToastArmedRef.current = false;
|
||||||
setShowConnectedNotice(true);
|
|
||||||
}
|
|
||||||
setShowReconnectNotice(false);
|
|
||||||
setDismissedReconnectNotice(false);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (connectionStatus === "idle") {
|
||||||
setShowConnectedNotice(false);
|
return;
|
||||||
if (!showReconnectNotice && !dismissedReconnectNotice) {
|
|
||||||
reconnectNoticeTimeoutRef.current = setTimeout(() => {
|
|
||||||
setShowReconnectNotice(true);
|
|
||||||
}, RECONNECT_NOTICE_DELAY_MS);
|
|
||||||
}
|
}
|
||||||
|
if (!reconnectToastArmedRef.current) {
|
||||||
return () => {
|
reconnectToastArmedRef.current = true;
|
||||||
if (reconnectNoticeTimeoutRef.current) {
|
toast.show("Reconnecting...", {
|
||||||
clearTimeout(reconnectNoticeTimeoutRef.current);
|
durationMs: 2200,
|
||||||
reconnectNoticeTimeoutRef.current = null;
|
testID: "agent-reconnecting-toast",
|
||||||
}
|
});
|
||||||
};
|
}
|
||||||
}, [connectionStatus, dismissedReconnectNotice, showReconnectNotice]);
|
}, [connectionStatus, toast]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!showConnectedNotice) {
|
if (missingAgentState.kind !== "error") {
|
||||||
if (connectedNoticeTimeoutRef.current) {
|
|
||||||
clearTimeout(connectedNoticeTimeoutRef.current);
|
|
||||||
connectedNoticeTimeoutRef.current = null;
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
connectedNoticeTimeoutRef.current = setTimeout(() => {
|
toast.error("Failed to refresh agent. Retrying in background.");
|
||||||
setShowConnectedNotice(false);
|
}, [missingAgentState.kind, toast]);
|
||||||
connectedNoticeTimeoutRef.current = null;
|
|
||||||
}, CONNECTED_NOTICE_DURATION_MS);
|
useFocusEffect(
|
||||||
return () => {
|
useCallback(() => {
|
||||||
if (connectedNoticeTimeoutRef.current) {
|
if (!resolvedAgentId || !isConnected) {
|
||||||
clearTimeout(connectedNoticeTimeoutRef.current);
|
return;
|
||||||
connectedNoticeTimeoutRef.current = null;
|
|
||||||
}
|
}
|
||||||
};
|
ensureAgentIsInitialized(resolvedAgentId).catch((error) => {
|
||||||
}, [showConnectedNotice]);
|
console.warn("[AgentScreen] focus sync failed", {
|
||||||
|
agentId: resolvedAgentId,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, [ensureAgentIsInitialized, isConnected, resolvedAgentId])
|
||||||
|
);
|
||||||
|
|
||||||
const isGitCheckout = activeExplorerCheckout?.isGit ?? false;
|
const isGitCheckout = activeExplorerCheckout?.isGit ?? false;
|
||||||
const isArchivingCurrentAgent = Boolean(
|
const isArchivingCurrentAgent = Boolean(
|
||||||
@@ -646,7 +623,6 @@ function AgentScreenContent({
|
|||||||
}, [optimisticStreamItems, streamItems]);
|
}, [optimisticStreamItems, streamItems]);
|
||||||
|
|
||||||
const shouldUseOptimisticStream = isPendingCreateForRoute && optimisticStreamItems.length > 0;
|
const shouldUseOptimisticStream = isPendingCreateForRoute && optimisticStreamItems.length > 0;
|
||||||
const shouldBlockForHistorySync = !shouldUseOptimisticStream && (needsAuthoritativeSync || isHistorySyncing);
|
|
||||||
|
|
||||||
const placeholderAgent: Agent | null = useMemo(() => {
|
const placeholderAgent: Agent | null = useMemo(() => {
|
||||||
if (!shouldUseOptimisticStream || !resolvedAgentId) {
|
if (!shouldUseOptimisticStream || !resolvedAgentId) {
|
||||||
@@ -687,9 +663,26 @@ function AgentScreenContent({
|
|||||||
};
|
};
|
||||||
}, [resolvedAgentId, serverId, shouldUseOptimisticStream]);
|
}, [resolvedAgentId, serverId, shouldUseOptimisticStream]);
|
||||||
|
|
||||||
const effectiveAgent = agent ?? placeholderAgent;
|
const viewState = useAgentScreenStateMachine({
|
||||||
const providerLabel = (effectiveAgent?.provider ?? "Provider").replace(/^\w/, (m) =>
|
routeKey: `${serverId}:${resolvedAgentId ?? ""}`,
|
||||||
m.toUpperCase()
|
input: {
|
||||||
|
agent: agent ?? null,
|
||||||
|
placeholderAgent,
|
||||||
|
missingAgentState,
|
||||||
|
isConnected,
|
||||||
|
isArchivingCurrentAgent,
|
||||||
|
isHistorySyncing,
|
||||||
|
needsAuthoritativeSync,
|
||||||
|
shouldUseOptimisticStream,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const effectiveAgent = viewState.tag === "ready" ? viewState.agent : null;
|
||||||
|
const agentModel = extractAgentModel(effectiveAgent ?? agent);
|
||||||
|
const modelDisplayValue = agentModel ?? "Unknown";
|
||||||
|
const providerLabel = (effectiveAgent?.provider ?? "Provider").replace(
|
||||||
|
/^\w/,
|
||||||
|
(m) => m.toUpperCase()
|
||||||
);
|
);
|
||||||
const providerSessionId =
|
const providerSessionId =
|
||||||
effectiveAgent?.runtimeInfo?.sessionId ?? effectiveAgent?.persistence?.sessionId ?? null;
|
effectiveAgent?.runtimeInfo?.sessionId ?? effectiveAgent?.persistence?.sessionId ?? null;
|
||||||
@@ -931,34 +924,44 @@ function AgentScreenContent({
|
|||||||
[theme.colors.primary, toast]
|
[theme.colors.primary, toast]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!effectiveAgent) {
|
const syncBannerMessage =
|
||||||
if (missingAgentState.kind === "not_found") {
|
viewState.tag !== "ready"
|
||||||
return (
|
? null
|
||||||
<View style={styles.container} testID="agent-not-found">
|
: viewState.syncStatus === "catching_up"
|
||||||
<MenuHeader title="Agent" />
|
? "Refreshing agent history..."
|
||||||
<View style={styles.errorContainer}>
|
: viewState.syncStatus === "sync_error"
|
||||||
<Text style={styles.errorText}>Agent not found</Text>
|
? "Refresh delayed. Retrying in background."
|
||||||
</View>
|
: null;
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (missingAgentState.kind === "error") {
|
|
||||||
return (
|
|
||||||
<View style={styles.container} testID="agent-load-error">
|
|
||||||
<MenuHeader title="Agent" />
|
|
||||||
<View style={styles.errorContainer}>
|
|
||||||
<Text style={styles.errorText}>Failed to load agent</Text>
|
|
||||||
<Text style={styles.statusText}>{missingAgentState.message}</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if (viewState.tag === "not_found") {
|
||||||
|
return (
|
||||||
|
<View style={styles.container} testID="agent-not-found">
|
||||||
|
<MenuHeader title="Agent" />
|
||||||
|
<View style={styles.errorContainer}>
|
||||||
|
<Text style={styles.errorText}>Agent not found</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (viewState.tag === "error") {
|
||||||
|
return (
|
||||||
|
<View style={styles.container} testID="agent-load-error">
|
||||||
|
<MenuHeader title="Agent" />
|
||||||
|
<View style={styles.errorContainer}>
|
||||||
|
<Text style={styles.errorText}>Failed to load agent</Text>
|
||||||
|
<Text style={styles.statusText}>{viewState.message}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (viewState.tag === "boot" || !effectiveAgent) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.container} testID="agent-loading">
|
<View style={styles.container} testID="agent-loading">
|
||||||
<MenuHeader title="Agent" />
|
<MenuHeader title="Agent" />
|
||||||
<View style={styles.errorContainer}>
|
<View style={styles.errorContainer}>
|
||||||
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
<ActivityIndicator size="large" color={theme.colors.foregroundMuted} />
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -968,7 +971,7 @@ function AgentScreenContent({
|
|||||||
<View style={styles.outerContainer}>
|
<View style={styles.outerContainer}>
|
||||||
<FileDropZone
|
<FileDropZone
|
||||||
onFilesDropped={handleFilesDropped}
|
onFilesDropped={handleFilesDropped}
|
||||||
disabled={isInitializing || shouldBlockForHistorySync || isArchivingCurrentAgent}
|
disabled={isArchivingCurrentAgent}
|
||||||
>
|
>
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -1149,7 +1152,7 @@ function AgentScreenContent({
|
|||||||
|
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
leading={<RotateCcw size={theme.iconSize.md} color={theme.colors.foreground} />}
|
leading={<RotateCcw size={theme.iconSize.md} color={theme.colors.foreground} />}
|
||||||
disabled={isInitializing || shouldBlockForHistorySync}
|
disabled={isInitializing}
|
||||||
trailing={
|
trailing={
|
||||||
isInitializing ? (
|
isInitializing ? (
|
||||||
<ActivityIndicator
|
<ActivityIndicator
|
||||||
@@ -1169,75 +1172,31 @@ function AgentScreenContent({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{(showReconnectNotice || showConnectedNotice) && (
|
{syncBannerMessage ? (
|
||||||
<View
|
<View style={styles.syncBanner} testID="agent-sync-banner">
|
||||||
style={[
|
<Text style={styles.syncBannerText}>{syncBannerMessage}</Text>
|
||||||
styles.connectionNotice,
|
|
||||||
showReconnectNotice
|
|
||||||
? styles.connectionNoticeReconnecting
|
|
||||||
: styles.connectionNoticeConnected,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{showConnectedNotice ? (
|
|
||||||
<CheckCircle2
|
|
||||||
size={theme.iconSize.sm}
|
|
||||||
color={theme.colors.palette.green[600]}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
<Text
|
|
||||||
style={[
|
|
||||||
styles.connectionNoticeText,
|
|
||||||
showReconnectNotice
|
|
||||||
? styles.connectionNoticeTextReconnecting
|
|
||||||
: styles.connectionNoticeTextConnected,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{showReconnectNotice ? "Reconnecting..." : "Connected"}
|
|
||||||
</Text>
|
|
||||||
{showReconnectNotice ? (
|
|
||||||
<Pressable
|
|
||||||
onPress={() => {
|
|
||||||
setShowReconnectNotice(false);
|
|
||||||
setDismissedReconnectNotice(true);
|
|
||||||
}}
|
|
||||||
style={styles.connectionNoticeDismiss}
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel="Dismiss reconnecting notice"
|
|
||||||
>
|
|
||||||
<Text style={styles.connectionNoticeDismissText}>Dismiss</Text>
|
|
||||||
</Pressable>
|
|
||||||
) : null}
|
|
||||||
</View>
|
</View>
|
||||||
)}
|
) : null}
|
||||||
|
|
||||||
{/* Content Area with Keyboard Animation */}
|
{/* Content Area with Keyboard Animation */}
|
||||||
<View style={styles.contentContainer}>
|
<View style={styles.contentContainer}>
|
||||||
{shouldBlockForHistorySync ? (
|
<ReanimatedAnimated.View
|
||||||
<View style={styles.loadingContainer}>
|
style={[styles.content, animatedKeyboardStyle]}
|
||||||
<ActivityIndicator size="large" color={theme.colors.primary} />
|
>
|
||||||
</View>
|
<AgentStreamView
|
||||||
) : (
|
agentId={effectiveAgent.id}
|
||||||
<ReanimatedAnimated.View
|
serverId={serverId}
|
||||||
style={[styles.content, animatedKeyboardStyle]}
|
agent={effectiveAgent}
|
||||||
>
|
streamItems={
|
||||||
<AgentStreamView
|
shouldUseOptimisticStream ? mergedStreamItems : streamItems
|
||||||
agentId={effectiveAgent.id}
|
}
|
||||||
serverId={serverId}
|
pendingPermissions={pendingPermissions}
|
||||||
agent={effectiveAgent}
|
/>
|
||||||
streamItems={
|
</ReanimatedAnimated.View>
|
||||||
shouldUseOptimisticStream ? mergedStreamItems : streamItems
|
|
||||||
}
|
|
||||||
pendingPermissions={pendingPermissions}
|
|
||||||
/>
|
|
||||||
</ReanimatedAnimated.View>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Agent Input Area */}
|
{/* Agent Input Area */}
|
||||||
{agent &&
|
{resolvedAgentId && !isArchivingCurrentAgent && (
|
||||||
resolvedAgentId &&
|
|
||||||
!shouldBlockForHistorySync &&
|
|
||||||
!isArchivingCurrentAgent && (
|
|
||||||
<AgentInputArea
|
<AgentInputArea
|
||||||
agentId={resolvedAgentId}
|
agentId={resolvedAgentId}
|
||||||
serverId={serverId}
|
serverId={serverId}
|
||||||
@@ -1377,58 +1336,24 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
},
|
},
|
||||||
connectionNotice: {
|
syncBanner: {
|
||||||
marginHorizontal: theme.spacing[4],
|
marginHorizontal: theme.spacing[4],
|
||||||
marginTop: theme.spacing[1],
|
marginTop: theme.spacing[1],
|
||||||
marginBottom: theme.spacing[2],
|
marginBottom: theme.spacing[2],
|
||||||
minHeight: 32,
|
|
||||||
borderRadius: theme.borderRadius.full,
|
borderRadius: theme.borderRadius.full,
|
||||||
|
paddingVertical: theme.spacing[2],
|
||||||
paddingHorizontal: theme.spacing[3],
|
paddingHorizontal: theme.spacing[3],
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: theme.spacing[2],
|
|
||||||
},
|
|
||||||
connectionNoticeReconnecting: {
|
|
||||||
backgroundColor: `${theme.colors.palette.yellow[400]}22`,
|
|
||||||
borderWidth: theme.borderWidth[1],
|
borderWidth: theme.borderWidth[1],
|
||||||
borderColor: theme.colors.palette.yellow[400],
|
borderColor: theme.colors.border,
|
||||||
|
backgroundColor: theme.colors.surface1,
|
||||||
},
|
},
|
||||||
connectionNoticeConnected: {
|
syncBannerText: {
|
||||||
backgroundColor: theme.colors.palette.green[100],
|
|
||||||
borderWidth: theme.borderWidth[1],
|
|
||||||
borderColor: theme.colors.palette.green[400],
|
|
||||||
},
|
|
||||||
connectionNoticeText: {
|
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
fontWeight: theme.fontWeight.medium,
|
|
||||||
},
|
|
||||||
connectionNoticeTextReconnecting: {
|
|
||||||
color: theme.colors.foreground,
|
|
||||||
},
|
|
||||||
connectionNoticeTextConnected: {
|
|
||||||
color: theme.colors.palette.green[800],
|
|
||||||
},
|
|
||||||
connectionNoticeDismiss: {
|
|
||||||
marginLeft: "auto",
|
|
||||||
paddingVertical: theme.spacing[1],
|
|
||||||
paddingHorizontal: theme.spacing[2],
|
|
||||||
borderRadius: theme.borderRadius.full,
|
|
||||||
backgroundColor: theme.colors.surface0,
|
|
||||||
},
|
|
||||||
connectionNoticeDismissText: {
|
|
||||||
fontSize: theme.fontSize.xs,
|
|
||||||
fontWeight: theme.fontWeight.medium,
|
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
},
|
},
|
||||||
content: {
|
content: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
loadingContainer: {
|
|
||||||
flex: 1,
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
gap: 16,
|
|
||||||
},
|
|
||||||
archivingOverlay: {
|
archivingOverlay: {
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
top: 0,
|
top: 0,
|
||||||
|
|||||||
Reference in New Issue
Block a user