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,
|
||||
canStart: canStartDictation,
|
||||
canConfirm: canConfirmDictation,
|
||||
autoStopWhenHidden: { isVisible: isScreenFocused },
|
||||
autoStopWhenHidden:
|
||||
Platform.OS === 'web' ? undefined : { isVisible: isScreenFocused },
|
||||
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";
|
||||
import { useAgentInitialization } from "@/hooks/use-agent-initialization";
|
||||
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 { getInitDeferred, getInitKey } from "@/utils/agent-initialization";
|
||||
import {
|
||||
@@ -85,8 +89,6 @@ import type { ExplorerCheckoutContext } from "@/stores/panel-store";
|
||||
|
||||
const DROPDOWN_WIDTH = 220;
|
||||
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__);
|
||||
|
||||
function logAgentExplorer(event: string, details: Record<string, unknown>): void {
|
||||
@@ -199,12 +201,6 @@ type AgentScreenContentProps = {
|
||||
connectionStatus: ConnectionStatus;
|
||||
};
|
||||
|
||||
type MissingAgentState =
|
||||
| { kind: "idle" }
|
||||
| { kind: "resolving" }
|
||||
| { kind: "not_found"; message: string }
|
||||
| { kind: "error"; message: string };
|
||||
|
||||
function toErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
@@ -479,14 +475,10 @@ function AgentScreenContent({
|
||||
(state) => state.sessions[serverId]?.focusedAgentId ?? null
|
||||
);
|
||||
const { ensureAgentIsInitialized, refreshAgent } = useAgentInitialization(serverId);
|
||||
const [missingAgentState, setMissingAgentState] = useState<MissingAgentState>({
|
||||
const [missingAgentState, setMissingAgentState] = useState<AgentScreenMissingState>({
|
||||
kind: "idle",
|
||||
});
|
||||
const [showReconnectNotice, setShowReconnectNotice] = useState(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 reconnectToastArmedRef = useRef(false);
|
||||
const initAttemptTokenRef = useRef(0);
|
||||
const setFocusedAgentId = useCallback(
|
||||
(agentId: string | null) => {
|
||||
@@ -511,58 +503,43 @@ function AgentScreenContent({
|
||||
};
|
||||
});
|
||||
|
||||
const agentModel = extractAgentModel(agent);
|
||||
const modelDisplayValue = agentModel ?? "Unknown";
|
||||
|
||||
useEffect(() => {
|
||||
if (reconnectNoticeTimeoutRef.current) {
|
||||
clearTimeout(reconnectNoticeTimeoutRef.current);
|
||||
reconnectNoticeTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
if (connectionStatus === "online") {
|
||||
if (showReconnectNotice || dismissedReconnectNotice) {
|
||||
setShowConnectedNotice(true);
|
||||
}
|
||||
setShowReconnectNotice(false);
|
||||
setDismissedReconnectNotice(false);
|
||||
reconnectToastArmedRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
setShowConnectedNotice(false);
|
||||
if (!showReconnectNotice && !dismissedReconnectNotice) {
|
||||
reconnectNoticeTimeoutRef.current = setTimeout(() => {
|
||||
setShowReconnectNotice(true);
|
||||
}, RECONNECT_NOTICE_DELAY_MS);
|
||||
if (connectionStatus === "idle") {
|
||||
return;
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (reconnectNoticeTimeoutRef.current) {
|
||||
clearTimeout(reconnectNoticeTimeoutRef.current);
|
||||
reconnectNoticeTimeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [connectionStatus, dismissedReconnectNotice, showReconnectNotice]);
|
||||
if (!reconnectToastArmedRef.current) {
|
||||
reconnectToastArmedRef.current = true;
|
||||
toast.show("Reconnecting...", {
|
||||
durationMs: 2200,
|
||||
testID: "agent-reconnecting-toast",
|
||||
});
|
||||
}
|
||||
}, [connectionStatus, toast]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showConnectedNotice) {
|
||||
if (connectedNoticeTimeoutRef.current) {
|
||||
clearTimeout(connectedNoticeTimeoutRef.current);
|
||||
connectedNoticeTimeoutRef.current = null;
|
||||
}
|
||||
if (missingAgentState.kind !== "error") {
|
||||
return;
|
||||
}
|
||||
connectedNoticeTimeoutRef.current = setTimeout(() => {
|
||||
setShowConnectedNotice(false);
|
||||
connectedNoticeTimeoutRef.current = null;
|
||||
}, CONNECTED_NOTICE_DURATION_MS);
|
||||
return () => {
|
||||
if (connectedNoticeTimeoutRef.current) {
|
||||
clearTimeout(connectedNoticeTimeoutRef.current);
|
||||
connectedNoticeTimeoutRef.current = null;
|
||||
toast.error("Failed to refresh agent. Retrying in background.");
|
||||
}, [missingAgentState.kind, toast]);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
if (!resolvedAgentId || !isConnected) {
|
||||
return;
|
||||
}
|
||||
};
|
||||
}, [showConnectedNotice]);
|
||||
ensureAgentIsInitialized(resolvedAgentId).catch((error) => {
|
||||
console.warn("[AgentScreen] focus sync failed", {
|
||||
agentId: resolvedAgentId,
|
||||
error,
|
||||
});
|
||||
});
|
||||
}, [ensureAgentIsInitialized, isConnected, resolvedAgentId])
|
||||
);
|
||||
|
||||
const isGitCheckout = activeExplorerCheckout?.isGit ?? false;
|
||||
const isArchivingCurrentAgent = Boolean(
|
||||
@@ -646,7 +623,6 @@ function AgentScreenContent({
|
||||
}, [optimisticStreamItems, streamItems]);
|
||||
|
||||
const shouldUseOptimisticStream = isPendingCreateForRoute && optimisticStreamItems.length > 0;
|
||||
const shouldBlockForHistorySync = !shouldUseOptimisticStream && (needsAuthoritativeSync || isHistorySyncing);
|
||||
|
||||
const placeholderAgent: Agent | null = useMemo(() => {
|
||||
if (!shouldUseOptimisticStream || !resolvedAgentId) {
|
||||
@@ -687,9 +663,26 @@ function AgentScreenContent({
|
||||
};
|
||||
}, [resolvedAgentId, serverId, shouldUseOptimisticStream]);
|
||||
|
||||
const effectiveAgent = agent ?? placeholderAgent;
|
||||
const providerLabel = (effectiveAgent?.provider ?? "Provider").replace(/^\w/, (m) =>
|
||||
m.toUpperCase()
|
||||
const viewState = useAgentScreenStateMachine({
|
||||
routeKey: `${serverId}:${resolvedAgentId ?? ""}`,
|
||||
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 =
|
||||
effectiveAgent?.runtimeInfo?.sessionId ?? effectiveAgent?.persistence?.sessionId ?? null;
|
||||
@@ -931,34 +924,44 @@ function AgentScreenContent({
|
||||
[theme.colors.primary, toast]
|
||||
);
|
||||
|
||||
if (!effectiveAgent) {
|
||||
if (missingAgentState.kind === "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 (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>
|
||||
);
|
||||
}
|
||||
const syncBannerMessage =
|
||||
viewState.tag !== "ready"
|
||||
? null
|
||||
: viewState.syncStatus === "catching_up"
|
||||
? "Refreshing agent history..."
|
||||
: viewState.syncStatus === "sync_error"
|
||||
? "Refresh delayed. Retrying in background."
|
||||
: null;
|
||||
|
||||
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 (
|
||||
<View style={styles.container} testID="agent-loading">
|
||||
<MenuHeader title="Agent" />
|
||||
<View style={styles.errorContainer}>
|
||||
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
||||
<ActivityIndicator size="large" color={theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
@@ -968,7 +971,7 @@ function AgentScreenContent({
|
||||
<View style={styles.outerContainer}>
|
||||
<FileDropZone
|
||||
onFilesDropped={handleFilesDropped}
|
||||
disabled={isInitializing || shouldBlockForHistorySync || isArchivingCurrentAgent}
|
||||
disabled={isArchivingCurrentAgent}
|
||||
>
|
||||
<View style={styles.container}>
|
||||
{/* Header */}
|
||||
@@ -1149,7 +1152,7 @@ function AgentScreenContent({
|
||||
|
||||
<DropdownMenuItem
|
||||
leading={<RotateCcw size={theme.iconSize.md} color={theme.colors.foreground} />}
|
||||
disabled={isInitializing || shouldBlockForHistorySync}
|
||||
disabled={isInitializing}
|
||||
trailing={
|
||||
isInitializing ? (
|
||||
<ActivityIndicator
|
||||
@@ -1169,75 +1172,31 @@ function AgentScreenContent({
|
||||
}
|
||||
/>
|
||||
|
||||
{(showReconnectNotice || showConnectedNotice) && (
|
||||
<View
|
||||
style={[
|
||||
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}
|
||||
{syncBannerMessage ? (
|
||||
<View style={styles.syncBanner} testID="agent-sync-banner">
|
||||
<Text style={styles.syncBannerText}>{syncBannerMessage}</Text>
|
||||
</View>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{/* Content Area with Keyboard Animation */}
|
||||
<View style={styles.contentContainer}>
|
||||
{shouldBlockForHistorySync ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={theme.colors.primary} />
|
||||
</View>
|
||||
) : (
|
||||
<ReanimatedAnimated.View
|
||||
style={[styles.content, animatedKeyboardStyle]}
|
||||
>
|
||||
<AgentStreamView
|
||||
agentId={effectiveAgent.id}
|
||||
serverId={serverId}
|
||||
agent={effectiveAgent}
|
||||
streamItems={
|
||||
shouldUseOptimisticStream ? mergedStreamItems : streamItems
|
||||
}
|
||||
pendingPermissions={pendingPermissions}
|
||||
/>
|
||||
</ReanimatedAnimated.View>
|
||||
)}
|
||||
<ReanimatedAnimated.View
|
||||
style={[styles.content, animatedKeyboardStyle]}
|
||||
>
|
||||
<AgentStreamView
|
||||
agentId={effectiveAgent.id}
|
||||
serverId={serverId}
|
||||
agent={effectiveAgent}
|
||||
streamItems={
|
||||
shouldUseOptimisticStream ? mergedStreamItems : streamItems
|
||||
}
|
||||
pendingPermissions={pendingPermissions}
|
||||
/>
|
||||
</ReanimatedAnimated.View>
|
||||
</View>
|
||||
|
||||
{/* Agent Input Area */}
|
||||
{agent &&
|
||||
resolvedAgentId &&
|
||||
!shouldBlockForHistorySync &&
|
||||
!isArchivingCurrentAgent && (
|
||||
{resolvedAgentId && !isArchivingCurrentAgent && (
|
||||
<AgentInputArea
|
||||
agentId={resolvedAgentId}
|
||||
serverId={serverId}
|
||||
@@ -1377,58 +1336,24 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flex: 1,
|
||||
overflow: "hidden",
|
||||
},
|
||||
connectionNotice: {
|
||||
syncBanner: {
|
||||
marginHorizontal: theme.spacing[4],
|
||||
marginTop: theme.spacing[1],
|
||||
marginBottom: theme.spacing[2],
|
||||
minHeight: 32,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
connectionNoticeReconnecting: {
|
||||
backgroundColor: `${theme.colors.palette.yellow[400]}22`,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.palette.yellow[400],
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
connectionNoticeConnected: {
|
||||
backgroundColor: theme.colors.palette.green[100],
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.palette.green[400],
|
||||
},
|
||||
connectionNoticeText: {
|
||||
syncBannerText: {
|
||||
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,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 16,
|
||||
},
|
||||
archivingOverlay: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
|
||||
Reference in New Issue
Block a user