Files
paseo/packages/app/src/hooks/use-projects.ts
T
Mohamed Boudra 0ad69d3eb0 fix(app): paint conversations immediately after restart (#3907)
* fix(app): paint cached conversations before reconnect

Cold startup restored every saved host before publishing any session, so a valid cached chat was blocked behind unrelated directory validation and network-only readiness. Directory and timeline owners now resolve demand through typed storage and authoritative reconciliation while consumers remain source-agnostic.

* fix(app): stabilize demand-driven cache restore

Create host projections before network availability, route cached workspace and timeline preparation through their owners, and coalesce directory reconciliation per online connection. Update browser cache fixtures for per-agent timeline rows.

* fix(app): resume cached timeline through owner

* fix(app): surface timeline owner load errors
2026-08-26 22:55:48 +02:00

195 lines
5.8 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from "react";
import equal from "fast-deep-equal";
import { useStoreWithEqualityFn } from "zustand/traditional";
import {
getHostRuntimeStore,
isHostRuntimeDirectoryLoading,
useHosts,
type HostRuntimeSnapshot,
} from "@/runtime/host-runtime";
import {
useSessionStore,
type ProjectDescriptor,
type WorkspaceDescriptor,
} from "@/stores/session-store";
import { buildProjects, type ProjectHost, type ProjectSummary } from "@/utils/projects";
export interface ProjectHostError {
serverId: string;
serverName: string;
message: string;
}
export interface ProjectHostReplica {
serverId: string;
serverName: string;
workspaces: WorkspaceDescriptor[];
projects: ProjectDescriptor[];
}
export interface ProjectHostRuntimeState {
serverId: string;
isOnline: boolean;
isLoading: boolean;
isFetching: boolean;
error: string | null;
}
export interface DerivedProjectsResult {
projects: ProjectSummary[];
hostErrors: ProjectHostError[];
isLoading: boolean;
isFetching: boolean;
}
export interface UseProjectsResult {
projects: ProjectSummary[];
hostErrors: ProjectHostError[];
isLoading: boolean;
isFetching: boolean;
refetch: () => void;
}
export interface UseProjectsOptions {
enabled?: boolean;
}
const EMPTY_PROJECT_HOST_REPLICAS: ProjectHostReplica[] = [];
const EMPTY_PROJECT_HOST_RUNTIME_STATES: ProjectHostRuntimeState[] = [];
function toProjectHostRuntimeState(
serverId: string,
snapshot: HostRuntimeSnapshot | null,
): ProjectHostRuntimeState {
const isFetching =
snapshot?.agentDirectoryStatus === "initial_loading" ||
snapshot?.agentDirectoryStatus === "revalidating";
return {
serverId,
isOnline: snapshot?.connectionStatus === "online",
isLoading: isHostRuntimeDirectoryLoading(snapshot),
isFetching,
error: snapshot?.agentDirectoryError ?? null,
};
}
function selectProjectHostReplicas(
hosts: readonly { serverId: string; label: string }[],
enabled: boolean,
): (state: ReturnType<typeof useSessionStore.getState>) => ProjectHostReplica[] {
if (!enabled) {
return () => EMPTY_PROJECT_HOST_REPLICAS;
}
return (state) =>
hosts.map((host) => {
const session = state.sessions[host.serverId];
return {
serverId: host.serverId,
serverName: host.label,
workspaces: Array.from(session?.workspaces.values() ?? []),
projects: Array.from(session?.projects.values() ?? []),
};
});
}
export function deriveProjectsFromReplica(input: {
replicas: readonly ProjectHostReplica[];
runtimeStates: readonly ProjectHostRuntimeState[];
}): DerivedProjectsResult {
const runtimeByServerId = new Map(
input.runtimeStates.map((state) => [state.serverId, state] as const),
);
const hosts: ProjectHost[] = input.replicas.map((replica) => {
const runtimeState = runtimeByServerId.get(replica.serverId);
return {
serverId: replica.serverId,
serverName: replica.serverName,
isOnline: runtimeState?.isOnline ?? false,
workspaces: replica.workspaces,
projects: replica.projects,
};
});
const hostErrors = input.replicas.flatMap((replica) => {
const message = runtimeByServerId.get(replica.serverId)?.error;
return message
? [
{
serverId: replica.serverId,
serverName: replica.serverName,
message,
},
]
: [];
});
return {
...buildProjects({ hosts }),
hostErrors,
isLoading: input.runtimeStates.some((state) => state.isLoading),
isFetching: input.runtimeStates.some((state) => state.isFetching),
};
}
function useProjectHostRuntimeStates(
serverIds: readonly string[],
enabled: boolean,
): ProjectHostRuntimeState[] {
const runtime = getHostRuntimeStore();
const previousStatesRef = useRef<ProjectHostRuntimeState[]>([]);
const subscribe = useCallback(
(onStoreChange: () => void) =>
enabled ? runtime.subscribeAll(onStoreChange) : () => undefined,
[enabled, runtime],
);
const getSnapshot = useCallback(() => (enabled ? runtime.getVersion() : 0), [enabled, runtime]);
const runtimeSnapshotTick = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
return useMemo(() => {
if (!enabled) {
previousStatesRef.current = EMPTY_PROJECT_HOST_RUNTIME_STATES;
return EMPTY_PROJECT_HOST_RUNTIME_STATES;
}
void runtimeSnapshotTick;
const nextStates = serverIds.map((serverId) =>
toProjectHostRuntimeState(serverId, runtime.getSnapshot(serverId)),
);
if (equal(previousStatesRef.current, nextStates)) {
return previousStatesRef.current;
}
previousStatesRef.current = nextStates;
return nextStates;
}, [enabled, runtime, runtimeSnapshotTick, serverIds]);
}
export function useProjects(options: UseProjectsOptions = {}): UseProjectsResult {
const enabled = options.enabled ?? true;
const hosts = useHosts();
const runtime = getHostRuntimeStore();
const serverIds = useMemo(
() => (enabled ? hosts.map((host) => host.serverId) : []),
[enabled, hosts],
);
useEffect(() => {
const releases = serverIds.map((serverId) => runtime.acquireDirectoryDemand(serverId));
return () => releases.forEach((release) => release());
}, [runtime, serverIds]);
const replicaSelector = useMemo(
() => selectProjectHostReplicas(hosts, enabled),
[enabled, hosts],
);
const replicas = useStoreWithEqualityFn(useSessionStore, replicaSelector, equal);
const runtimeStates = useProjectHostRuntimeStates(serverIds, enabled);
const derived = useMemo(
() => deriveProjectsFromReplica({ replicas, runtimeStates }),
[replicas, runtimeStates],
);
const refetch = useCallback(() => {
if (!enabled) return;
for (const serverId of serverIds) void runtime.refreshDirectories(serverId);
}, [enabled, runtime, serverIds]);
return {
...derived,
refetch,
};
}