* fix(app): restore archived agents from History History navigation lost the explicit archived-agent intent during tab reconciliation, and workspace recovery stopped after restoring the workspace. Preserve the selected tab and recover its provider session as one action while serializing provider resume with timeline hydration. * fix(app): preserve archived agent recovery invariants * fix(server): preserve timeline hydration call shape * fix(app): retain agent tabs through lookup * fix(server): upgrade in-flight timeline broadcast
320 lines
8.5 KiB
TypeScript
320 lines
8.5 KiB
TypeScript
import { useRef } from "react";
|
|
import type {
|
|
AgentCapabilityFlags,
|
|
AgentFeature,
|
|
AgentProvider,
|
|
} from "@getpaseo/protocol/agent-types";
|
|
|
|
export interface AgentScreenAgent {
|
|
serverId: string;
|
|
id: string;
|
|
provider?: AgentProvider;
|
|
status: "initializing" | "idle" | "running" | "error" | "closed";
|
|
cwd: string;
|
|
workspaceId?: string;
|
|
capabilities?: AgentCapabilityFlags;
|
|
currentModeId?: string | null;
|
|
model?: string | null;
|
|
thinkingOptionId?: string | null;
|
|
runtimeInfo?: {
|
|
model?: string | null;
|
|
modeId?: string | null;
|
|
thinkingOptionId?: string | null;
|
|
} | null;
|
|
features?: readonly AgentFeature[];
|
|
lastError?: string | null;
|
|
projectPlacement?: {
|
|
projectKey?: string;
|
|
projectName?: string;
|
|
checkout?: {
|
|
cwd?: string;
|
|
isGit?: boolean;
|
|
};
|
|
} | null;
|
|
}
|
|
|
|
export type AgentScreenMissingState =
|
|
| { kind: "idle" }
|
|
| { kind: "resolving" }
|
|
| { kind: "not_found"; message: string }
|
|
| { kind: "error"; message: string };
|
|
|
|
export interface AgentScreenMachineInput {
|
|
agent: AgentScreenAgent | null;
|
|
isArchived: boolean;
|
|
missingAgentState: AgentScreenMissingState;
|
|
isConnected: boolean;
|
|
isArchivingCurrentAgent: boolean;
|
|
isHistorySyncing: boolean;
|
|
needsAuthoritativeSync: boolean;
|
|
visibilityCatchUpStatus: "ready" | "pending" | "error";
|
|
continuity: AgentScreenContinuity;
|
|
hasHydratedHistoryBefore: boolean;
|
|
}
|
|
|
|
export type AgentScreenContinuity =
|
|
| { kind: "none" }
|
|
| { kind: "optimistic-create"; agent: AgentScreenAgent };
|
|
|
|
function hasOptimisticCreateContinuity(input: AgentScreenMachineInput): boolean {
|
|
return input.continuity.kind === "optimistic-create";
|
|
}
|
|
|
|
function shouldBlockInitialAuthoritativeReadyState(input: AgentScreenMachineInput): boolean {
|
|
return (
|
|
!input.isArchived &&
|
|
!hasOptimisticCreateContinuity(input) &&
|
|
!input.hasHydratedHistoryBefore &&
|
|
(input.needsAuthoritativeSync || input.isHistorySyncing)
|
|
);
|
|
}
|
|
|
|
export interface AgentScreenMachineMemory {
|
|
hasRenderedReady: boolean;
|
|
lastReadyAgent: AgentScreenAgent | null;
|
|
hadInitialSyncFailure: boolean;
|
|
}
|
|
|
|
export type AgentScreenReadySyncState =
|
|
| { status: "idle" }
|
|
| { status: "reconnecting" }
|
|
| {
|
|
status: "catching_up";
|
|
ui: "overlay" | "silent";
|
|
}
|
|
| { status: "sync_error" };
|
|
|
|
export type AgentScreenViewState =
|
|
| {
|
|
tag: "boot";
|
|
reason: "loading" | "resolving";
|
|
source: "none";
|
|
}
|
|
| {
|
|
tag: "not_found";
|
|
message: string;
|
|
}
|
|
| {
|
|
tag: "error";
|
|
message: string;
|
|
}
|
|
| {
|
|
tag: "ready";
|
|
agent: AgentScreenAgent;
|
|
source: "authoritative" | "optimistic" | "stale";
|
|
sync: AgentScreenReadySyncState;
|
|
isArchiving: boolean;
|
|
};
|
|
|
|
function updateInitialSyncFailureMemory(args: {
|
|
input: AgentScreenMachineInput;
|
|
nextMemory: AgentScreenMachineMemory;
|
|
}): void {
|
|
if (args.input.hasHydratedHistoryBefore) {
|
|
args.nextMemory.hadInitialSyncFailure = false;
|
|
}
|
|
if (args.input.missingAgentState.kind === "error" && !args.input.hasHydratedHistoryBefore) {
|
|
args.nextMemory.hadInitialSyncFailure = true;
|
|
}
|
|
}
|
|
|
|
function shouldUseOptimisticCreateFlowAgent(input: AgentScreenMachineInput): boolean {
|
|
return (
|
|
input.continuity.kind === "optimistic-create" && (!input.agent || input.agent.status === "idle")
|
|
);
|
|
}
|
|
|
|
function resolveCandidateAgent(args: {
|
|
input: AgentScreenMachineInput;
|
|
useOptimisticCreateFlowAgent: boolean;
|
|
}): AgentScreenAgent | null {
|
|
const { input, useOptimisticCreateFlowAgent } = args;
|
|
const continuityAgent =
|
|
input.continuity.kind === "optimistic-create" ? input.continuity.agent : null;
|
|
if (input.agent && useOptimisticCreateFlowAgent && continuityAgent) {
|
|
return { ...input.agent, status: continuityAgent.status };
|
|
}
|
|
return input.agent ?? continuityAgent;
|
|
}
|
|
|
|
function resolveAgentScreenSource(args: {
|
|
useOptimisticCreateFlowAgent: boolean;
|
|
hasAgent: boolean;
|
|
hasOptimisticCreateContinuity: boolean;
|
|
}): "authoritative" | "optimistic" | "stale" {
|
|
if (args.useOptimisticCreateFlowAgent) return "optimistic";
|
|
if (args.hasAgent) return "authoritative";
|
|
if (args.hasOptimisticCreateContinuity) return "optimistic";
|
|
return "stale";
|
|
}
|
|
|
|
function resolveCatchingUpUi(args: {
|
|
hasOptimisticCreateContinuity: boolean;
|
|
isVisibilityCatchUpPending: boolean;
|
|
hasHydratedHistoryBefore: boolean;
|
|
hadInitialSyncFailure: boolean;
|
|
}): "overlay" | "silent" {
|
|
if (args.hasOptimisticCreateContinuity) return "silent";
|
|
if (args.hasHydratedHistoryBefore) return "silent";
|
|
if (args.isVisibilityCatchUpPending) return "overlay";
|
|
if (args.hadInitialSyncFailure) return "silent";
|
|
return "overlay";
|
|
}
|
|
|
|
function resolveAgentScreenSync(args: {
|
|
input: AgentScreenMachineInput;
|
|
hadInitialSyncFailure: boolean;
|
|
}): AgentScreenReadySyncState {
|
|
const { input, hadInitialSyncFailure } = args;
|
|
if (input.isArchived) {
|
|
return { status: "idle" };
|
|
}
|
|
if (!input.isConnected) {
|
|
return { status: "reconnecting" };
|
|
}
|
|
if (input.missingAgentState.kind === "error") {
|
|
return { status: "sync_error" };
|
|
}
|
|
if (input.visibilityCatchUpStatus === "error") {
|
|
return { status: "sync_error" };
|
|
}
|
|
if (
|
|
input.visibilityCatchUpStatus === "pending" ||
|
|
input.needsAuthoritativeSync ||
|
|
input.isHistorySyncing
|
|
) {
|
|
return {
|
|
status: "catching_up",
|
|
ui: resolveCatchingUpUi({
|
|
hasOptimisticCreateContinuity: hasOptimisticCreateContinuity(input),
|
|
isVisibilityCatchUpPending: input.visibilityCatchUpStatus === "pending",
|
|
hasHydratedHistoryBefore: input.hasHydratedHistoryBefore,
|
|
hadInitialSyncFailure,
|
|
}),
|
|
};
|
|
}
|
|
return { status: "idle" };
|
|
}
|
|
|
|
export function deriveAgentScreenViewState({
|
|
input,
|
|
memory,
|
|
}: {
|
|
input: AgentScreenMachineInput;
|
|
memory: AgentScreenMachineMemory;
|
|
}): { state: AgentScreenViewState; memory: AgentScreenMachineMemory } {
|
|
const nextMemory: AgentScreenMachineMemory = {
|
|
hasRenderedReady: memory.hasRenderedReady,
|
|
lastReadyAgent: memory.lastReadyAgent,
|
|
hadInitialSyncFailure: memory.hadInitialSyncFailure,
|
|
};
|
|
|
|
updateInitialSyncFailureMemory({ input, nextMemory });
|
|
|
|
const useOptimisticCreateFlowAgent = shouldUseOptimisticCreateFlowAgent(input);
|
|
const candidateAgent = resolveCandidateAgent({ input, useOptimisticCreateFlowAgent });
|
|
const shouldBlockReadyState = shouldBlockInitialAuthoritativeReadyState(input);
|
|
|
|
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,
|
|
};
|
|
}
|
|
|
|
if (candidateAgent && shouldBlockReadyState) {
|
|
return {
|
|
state: {
|
|
tag: "boot",
|
|
reason: "loading",
|
|
source: "none",
|
|
},
|
|
memory: nextMemory,
|
|
};
|
|
}
|
|
|
|
if (candidateAgent) {
|
|
nextMemory.hasRenderedReady = true;
|
|
nextMemory.lastReadyAgent = candidateAgent;
|
|
}
|
|
|
|
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 = resolveAgentScreenSource({
|
|
useOptimisticCreateFlowAgent,
|
|
hasAgent: Boolean(input.agent),
|
|
hasOptimisticCreateContinuity: hasOptimisticCreateContinuity(input),
|
|
});
|
|
|
|
const sync = resolveAgentScreenSync({
|
|
input,
|
|
hadInitialSyncFailure: nextMemory.hadInitialSyncFailure,
|
|
});
|
|
|
|
return {
|
|
state: {
|
|
tag: "ready",
|
|
agent: displayAgent,
|
|
source,
|
|
sync,
|
|
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,
|
|
hadInitialSyncFailure: false,
|
|
});
|
|
|
|
if (routeKeyRef.current !== routeKey) {
|
|
routeKeyRef.current = routeKey;
|
|
memoryRef.current = {
|
|
hasRenderedReady: false,
|
|
lastReadyAgent: null,
|
|
hadInitialSyncFailure: false,
|
|
};
|
|
}
|
|
|
|
const result = deriveAgentScreenViewState({
|
|
input,
|
|
memory: memoryRef.current,
|
|
});
|
|
memoryRef.current = result.memory;
|
|
return result.state;
|
|
}
|