Files
paseo/packages/app/src/hooks/use-agent-initialization.ts
T
Mohamed Boudra 7709c42b13 fix(app): keep timeline history and previews stable
Treat persisted timeline replicas as display-only so authoritative pagination always comes from the daemon. Require renewed user intent between older-history pages unless the viewport genuinely remains at the history edge.

Model assistant image acquisition as loading, loaded, or failed so recreating a preview URL cannot be rendered as an error.
2026-07-28 16:45:39 +02:00

168 lines
5.3 KiB
TypeScript

import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { selectAgentTimelineState, useSessionStore } from "@/stores/session-store";
import {
createInitDeferred,
getInitDeferred,
getInitKey,
INIT_TIMEOUT_MS,
rejectInitDeferred,
refreshInitTimeout,
} from "@/utils/agent-initialization";
import { getHostRuntimeStore, type HostRuntimeStore } from "@/runtime/host-runtime";
import { planInitialAgentTimelineSync, planTimelineTailFetch } from "@/timeline/timeline-sync-plan";
import { i18n } from "@/i18n/i18next";
export type SetAgentInitializing = (agentId: string, initializing: boolean) => void;
export function createHistorySyncTimeoutError(): Error {
return new Error(`History sync timed out after ${Math.round(INIT_TIMEOUT_MS / 1000)}s`);
}
export function refreshAgentInitializationTimeout(input: {
key: string;
agentId: string;
setAgentInitializing: SetAgentInitializing;
}): void {
refreshInitTimeout({
key: input.key,
onTimeout: () => {
input.setAgentInitializing(input.agentId, false);
rejectInitDeferred(input.key, createHistorySyncTimeoutError());
},
});
}
export interface EnsureAgentIsInitializedInput {
serverId: string;
agentId: string;
client: Pick<DaemonClient, "fetchAgentTimeline"> | null;
runtime: Pick<HostRuntimeStore, "fetchAgentTimeline">;
setAgentInitializing: SetAgentInitializing;
hostDisconnectedMessage?: string;
}
export function ensureAgentIsInitialized(input: EnsureAgentIsInitializedInput): Promise<void> {
const { serverId, agentId, client, setAgentInitializing } = input;
const key = getInitKey(serverId, agentId);
const existing = getInitDeferred(key);
if (existing) {
return existing.promise;
}
const session = useSessionStore.getState().sessions[serverId];
const timeline = selectAgentTimelineState(session, agentId);
const cursor = timeline.status === "synced" ? (timeline.range ?? undefined) : undefined;
const hasAuthoritativeHistory = timeline.status === "synced";
const timelineRequest = planInitialAgentTimelineSync({ cursor, hasAuthoritativeHistory });
const deferred = createInitDeferred(key, timelineRequest.direction);
refreshAgentInitializationTimeout({ key, agentId, setAgentInitializing });
setAgentInitializing(agentId, true);
if (!client) {
setAgentInitializing(agentId, false);
rejectInitDeferred(
key,
new Error(input.hostDisconnectedMessage ?? i18n.t("workspace.terminal.hostDisconnected")),
);
return deferred.promise;
}
input.runtime.fetchAgentTimeline(serverId, agentId, timelineRequest).catch((error) => {
setAgentInitializing(agentId, false);
rejectInitDeferred(key, error instanceof Error ? error : new Error(String(error)));
});
return deferred.promise;
}
export interface RefreshAgentInput {
serverId: string;
agentId: string;
client: Pick<DaemonClient, "refreshAgent"> | null;
runtime: Pick<HostRuntimeStore, "fetchAgentTimeline">;
setAgentInitializing: SetAgentInitializing;
hostDisconnectedMessage?: string;
}
export async function refreshAgent(input: RefreshAgentInput): Promise<void> {
const { serverId, agentId, client, runtime, setAgentInitializing } = input;
if (!client) {
throw new Error(input.hostDisconnectedMessage ?? i18n.t("workspace.terminal.hostDisconnected"));
}
setAgentInitializing(agentId, true);
try {
await client.refreshAgent(agentId);
await runtime.fetchAgentTimeline(serverId, agentId, planTimelineTailFetch());
} catch (error) {
setAgentInitializing(agentId, false);
throw error;
}
}
export function createSetAgentInitializing(
serverId: string,
setInitializingAgents: ReturnType<typeof useSessionStore.getState>["setInitializingAgents"],
): SetAgentInitializing {
return (agentId, initializing) => {
setInitializingAgents(serverId, (prev) => {
if (prev.get(agentId) === initializing) {
return prev;
}
const next = new Map(prev);
next.set(agentId, initializing);
return next;
});
};
}
export function useAgentInitialization({
serverId,
client,
}: {
serverId: string;
client: DaemonClient | null;
}) {
const { t } = useTranslation();
const setInitializingAgents = useSessionStore((state) => state.setInitializingAgents);
const setAgentInitializing = useMemo(
() => createSetAgentInitializing(serverId, setInitializingAgents),
[serverId, setInitializingAgents],
);
const ensureAgentIsInitializedCallback = useCallback(
(agentId: string): Promise<void> =>
ensureAgentIsInitialized({
serverId,
agentId,
client,
runtime: getHostRuntimeStore(),
setAgentInitializing,
hostDisconnectedMessage: t("workspace.terminal.hostDisconnected"),
}),
[client, serverId, setAgentInitializing, t],
);
const refreshAgentCallback = useCallback(
(agentId: string): Promise<void> =>
refreshAgent({
serverId,
agentId,
client,
runtime: getHostRuntimeStore(),
setAgentInitializing,
hostDisconnectedMessage: t("workspace.terminal.hostDisconnected"),
}),
[client, serverId, setAgentInitializing, t],
);
return {
ensureAgentIsInitialized: ensureAgentIsInitializedCallback,
refreshAgent: refreshAgentCallback,
};
}