refactor timeline loading to cursor-based fetch API
This commit is contained in:
@@ -9,6 +9,7 @@ import {
|
||||
applyStreamEvent,
|
||||
generateMessageId,
|
||||
hydrateStreamState,
|
||||
reduceStreamUpdate,
|
||||
type StreamItem,
|
||||
} from "@/types/stream";
|
||||
import type {
|
||||
@@ -303,6 +304,9 @@ export function SessionProvider({
|
||||
const clearAgentStreamHead = useSessionStore(
|
||||
(state) => state.clearAgentStreamHead
|
||||
);
|
||||
const setAgentTimelineCursor = useSessionStore(
|
||||
(state) => state.setAgentTimelineCursor
|
||||
);
|
||||
const setInitializingAgents = useSessionStore(
|
||||
(state) => state.setInitializingAgents
|
||||
);
|
||||
@@ -371,7 +375,7 @@ export function SessionProvider({
|
||||
) => Promise<void>)
|
||||
| null
|
||||
>(null);
|
||||
const hasRequestedInitialSnapshotRef = useRef(false);
|
||||
const hasBootstrappedAgentUpdatesRef = useRef(false);
|
||||
const agentUpdatesSubscriptionIdRef = useRef<string | null>(null);
|
||||
const sessionStateTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||
null
|
||||
@@ -580,6 +584,15 @@ export function SessionProvider({
|
||||
return next;
|
||||
});
|
||||
|
||||
setAgentTimelineCursor(serverId, (prev) => {
|
||||
if (!prev.has(agentId)) {
|
||||
return prev;
|
||||
}
|
||||
const next = new Map(prev);
|
||||
next.delete(agentId);
|
||||
return next;
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -644,6 +657,7 @@ export function SessionProvider({
|
||||
setAgentLastActivity,
|
||||
setPendingPermissions,
|
||||
setQueuedMessages,
|
||||
setAgentTimelineCursor,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -665,18 +679,6 @@ export function SessionProvider({
|
||||
[serverId, setFileExplorer]
|
||||
);
|
||||
|
||||
const refreshAgentMutation = useMutation({
|
||||
mutationFn: async ({ agentId }: { agentId: string }) => {
|
||||
if (!agentId) {
|
||||
throw new Error("Agent id is required");
|
||||
}
|
||||
if (!client) {
|
||||
throw new Error("Daemon client unavailable");
|
||||
}
|
||||
return await client.refreshAgent(agentId);
|
||||
},
|
||||
});
|
||||
|
||||
const directoryListingMutation = useMutation({
|
||||
mutationFn: async ({ agentId, path }: { agentId: string; path: string }) => {
|
||||
if (!agentId) {
|
||||
@@ -740,9 +742,109 @@ export function SessionProvider({
|
||||
},
|
||||
});
|
||||
|
||||
const applyTimelineResponse = useCallback(
|
||||
(
|
||||
payload: Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "fetch_agent_timeline_response" }
|
||||
>["payload"]
|
||||
) => {
|
||||
const agentId = payload.agentId;
|
||||
const initKey = getInitKey(serverId, agentId);
|
||||
|
||||
if (payload.error) {
|
||||
setInitializingAgents(serverId, (prev) => {
|
||||
if (prev.get(agentId) !== true) {
|
||||
return prev;
|
||||
}
|
||||
const next = new Map(prev);
|
||||
next.set(agentId, false);
|
||||
return next;
|
||||
});
|
||||
rejectInitDeferred(initKey, new Error(payload.error));
|
||||
return;
|
||||
}
|
||||
|
||||
const hydratedEvents: Array<{
|
||||
event: AgentStreamEventPayload;
|
||||
timestamp: Date;
|
||||
}> = payload.entries.map((entry) => ({
|
||||
event: {
|
||||
type: "timeline",
|
||||
provider: entry.provider,
|
||||
item: entry.item,
|
||||
},
|
||||
timestamp: new Date(entry.timestamp),
|
||||
}));
|
||||
|
||||
const replace = payload.reset || payload.direction !== "after";
|
||||
if (replace) {
|
||||
const hydrated = hydrateStreamState(hydratedEvents);
|
||||
setAgentStreamTail(serverId, (prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(agentId, hydrated);
|
||||
return next;
|
||||
});
|
||||
clearAgentStreamHead(serverId, agentId);
|
||||
} else if (hydratedEvents.length > 0) {
|
||||
setAgentStreamTail(serverId, (prev) => {
|
||||
const next = new Map(prev);
|
||||
const current = next.get(agentId) ?? [];
|
||||
const updated = hydratedEvents.reduce<StreamItem[]>(
|
||||
(state, { event, timestamp }) => reduceStreamUpdate(state, event, timestamp),
|
||||
current
|
||||
);
|
||||
next.set(agentId, updated);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
setAgentTimelineCursor(serverId, (prev) => {
|
||||
const next = new Map(prev);
|
||||
if (payload.startCursor && payload.endCursor) {
|
||||
next.set(agentId, {
|
||||
epoch: payload.epoch,
|
||||
startSeq: payload.startCursor.seq,
|
||||
endSeq: payload.endCursor.seq,
|
||||
});
|
||||
} else if (payload.reset) {
|
||||
next.delete(agentId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
const deferredUpdate = pendingAgentUpdatesRef.current.get(agentId);
|
||||
pendingAgentUpdatesRef.current.delete(agentId);
|
||||
if (deferredUpdate) {
|
||||
applyAgentUpdatePayload(deferredUpdate);
|
||||
}
|
||||
|
||||
setInitializingAgents(serverId, (prev) => {
|
||||
if (prev.get(agentId) !== true) {
|
||||
return prev;
|
||||
}
|
||||
const next = new Map(prev);
|
||||
next.set(agentId, false);
|
||||
return next;
|
||||
});
|
||||
|
||||
resolveInitDeferred(initKey);
|
||||
markAgentHistorySynchronized(serverId, agentId);
|
||||
},
|
||||
[
|
||||
applyAgentUpdatePayload,
|
||||
clearAgentStreamHead,
|
||||
markAgentHistorySynchronized,
|
||||
serverId,
|
||||
setAgentStreamTail,
|
||||
setAgentTimelineCursor,
|
||||
setInitializingAgents,
|
||||
]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!connectionSnapshot.isConnected) {
|
||||
hasRequestedInitialSnapshotRef.current = false;
|
||||
hasBootstrappedAgentUpdatesRef.current = false;
|
||||
const subscriptionId = agentUpdatesSubscriptionIdRef.current;
|
||||
if (subscriptionId && client) {
|
||||
try {
|
||||
@@ -754,10 +856,10 @@ export function SessionProvider({
|
||||
agentUpdatesSubscriptionIdRef.current = null;
|
||||
return;
|
||||
}
|
||||
if (hasRequestedInitialSnapshotRef.current) {
|
||||
if (hasBootstrappedAgentUpdatesRef.current) {
|
||||
return;
|
||||
}
|
||||
hasRequestedInitialSnapshotRef.current = true;
|
||||
hasBootstrappedAgentUpdatesRef.current = true;
|
||||
|
||||
try {
|
||||
if (!agentUpdatesSubscriptionIdRef.current) {
|
||||
@@ -803,7 +905,7 @@ export function SessionProvider({
|
||||
|
||||
const unsubAgentStream = client.on("agent_stream", (message) => {
|
||||
if (message.type !== "agent_stream") return;
|
||||
const { agentId, event, timestamp } = message.payload;
|
||||
const { agentId, event, timestamp, seq, epoch } = message.payload;
|
||||
const parsedTimestamp = new Date(timestamp);
|
||||
|
||||
if (event.type === "attention_required") {
|
||||
@@ -846,51 +948,37 @@ export function SessionProvider({
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
event.type === "timeline" &&
|
||||
typeof seq === "number" &&
|
||||
typeof epoch === "string"
|
||||
) {
|
||||
setAgentTimelineCursor(serverId, (prev) => {
|
||||
const current = prev.get(agentId);
|
||||
const next = new Map(prev);
|
||||
if (!current || current.epoch !== epoch) {
|
||||
next.set(agentId, { epoch, startSeq: seq, endSeq: seq });
|
||||
return next;
|
||||
}
|
||||
next.set(agentId, {
|
||||
epoch,
|
||||
startSeq: Math.min(current.startSeq, seq),
|
||||
endSeq: Math.max(current.endSeq, seq),
|
||||
});
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
// NOTE: We don't update lastActivityAt on every stream event to prevent
|
||||
// cascading rerenders. The agent_update handler updates agent.lastActivityAt
|
||||
// on status changes, which is sufficient for sorting and display purposes.
|
||||
});
|
||||
|
||||
const unsubAgentStreamSnapshot = client.on(
|
||||
"agent_stream_snapshot",
|
||||
const unsubAgentTimeline = client.on(
|
||||
"fetch_agent_timeline_response",
|
||||
(message) => {
|
||||
if (message.type !== "agent_stream_snapshot") return;
|
||||
const { agentId, events } = message.payload;
|
||||
|
||||
const hydrated = hydrateStreamState(
|
||||
events.map(({ event, timestamp }) => ({
|
||||
event: event as AgentStreamEventPayload,
|
||||
timestamp: new Date(timestamp),
|
||||
}))
|
||||
);
|
||||
const initKey = getInitKey(serverId, agentId);
|
||||
const hasInFlightHistorySync = Boolean(getInitDeferred(initKey));
|
||||
|
||||
setAgentStreamTail(serverId, (prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(agentId, hydrated);
|
||||
return next;
|
||||
});
|
||||
clearAgentStreamHead(serverId, agentId);
|
||||
|
||||
const deferredUpdate = pendingAgentUpdatesRef.current.get(agentId);
|
||||
pendingAgentUpdatesRef.current.delete(agentId);
|
||||
if (hasInFlightHistorySync && deferredUpdate) {
|
||||
applyAgentUpdatePayload(deferredUpdate);
|
||||
}
|
||||
|
||||
setInitializingAgents(serverId, (prev) => {
|
||||
if (prev.get(agentId) !== true) {
|
||||
return prev;
|
||||
}
|
||||
const next = new Map(prev);
|
||||
next.set(agentId, false);
|
||||
return next;
|
||||
});
|
||||
|
||||
// Resolve the initialization promise (even for empty history)
|
||||
resolveInitDeferred(initKey);
|
||||
markAgentHistorySynchronized(serverId, agentId);
|
||||
if (message.type !== "fetch_agent_timeline_response") return;
|
||||
applyTimelineResponse(message.payload);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1233,6 +1321,14 @@ export function SessionProvider({
|
||||
return next;
|
||||
});
|
||||
clearAgentStreamHead(serverId, agentId);
|
||||
setAgentTimelineCursor(serverId, (prev) => {
|
||||
if (!prev.has(agentId)) {
|
||||
return prev;
|
||||
}
|
||||
const next = new Map(prev);
|
||||
next.delete(agentId);
|
||||
return next;
|
||||
});
|
||||
|
||||
// Remove draft input
|
||||
clearDraftInput(agentId);
|
||||
@@ -1292,7 +1388,7 @@ export function SessionProvider({
|
||||
return () => {
|
||||
unsubAgentUpdate();
|
||||
unsubAgentStream();
|
||||
unsubAgentStreamSnapshot();
|
||||
unsubAgentTimeline();
|
||||
unsubStatus();
|
||||
unsubPermissionRequest();
|
||||
unsubPermissionResolved();
|
||||
@@ -1313,6 +1409,7 @@ export function SessionProvider({
|
||||
setAgentStreamTail,
|
||||
setAgentStreamHead,
|
||||
clearAgentStreamHead,
|
||||
setAgentTimelineCursor,
|
||||
setInitializingAgents,
|
||||
setAgents,
|
||||
setAgentLastActivity,
|
||||
@@ -1323,63 +1420,9 @@ export function SessionProvider({
|
||||
clearDraftInput,
|
||||
notifyAgentAttention,
|
||||
applyAgentUpdatePayload,
|
||||
markAgentHistorySynchronized,
|
||||
applyTimelineResponse,
|
||||
]);
|
||||
|
||||
const initializeAgent = useCallback(
|
||||
({ agentId, requestId }: { agentId: string; requestId?: string }) => {
|
||||
setInitializingAgents(serverId, (prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(agentId, true);
|
||||
return next;
|
||||
});
|
||||
|
||||
if (!client) {
|
||||
console.warn("[Session] initializeAgent skipped: daemon unavailable");
|
||||
setInitializingAgents(serverId, (prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(agentId, false);
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
client
|
||||
.initializeAgent(agentId, requestId)
|
||||
.catch((error) => {
|
||||
console.warn("[Session] initializeAgent failed", { agentId, error });
|
||||
setInitializingAgents(serverId, (prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(agentId, false);
|
||||
return next;
|
||||
});
|
||||
});
|
||||
},
|
||||
[serverId, client, setInitializingAgents]
|
||||
);
|
||||
|
||||
const refreshAgent = useCallback(
|
||||
({ agentId }: { agentId: string }) => {
|
||||
setInitializingAgents(serverId, (prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(agentId, true);
|
||||
return next;
|
||||
});
|
||||
|
||||
refreshAgentMutation
|
||||
.mutateAsync({ agentId })
|
||||
.catch((error) => {
|
||||
console.warn("[Session] refreshAgent failed", { agentId, error });
|
||||
setInitializingAgents(serverId, (prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(agentId, false);
|
||||
return next;
|
||||
});
|
||||
});
|
||||
},
|
||||
[serverId, refreshAgentMutation, setInitializingAgents]
|
||||
);
|
||||
|
||||
const sendAgentMessage = useCallback(
|
||||
async (
|
||||
agentId: string,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback } from "react";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import type { FetchAgentTimelineOptions } from "@server/client/daemon-client";
|
||||
import {
|
||||
attachInitTimeout,
|
||||
createInitDeferred,
|
||||
@@ -9,10 +10,49 @@ import {
|
||||
} from "@/utils/agent-initialization";
|
||||
|
||||
const INIT_TIMEOUT_MS = 5 * 60_000;
|
||||
const DEFAULT_INITIAL_TIMELINE_LIMIT = 200;
|
||||
|
||||
type TimelineCursorState = {
|
||||
epoch: string;
|
||||
endSeq: number;
|
||||
};
|
||||
|
||||
function buildInitialTimelineRequest(
|
||||
cursor: TimelineCursorState | undefined
|
||||
): FetchAgentTimelineOptions {
|
||||
if (!cursor) {
|
||||
return {
|
||||
direction: "tail",
|
||||
limit: DEFAULT_INITIAL_TIMELINE_LIMIT,
|
||||
projection: "projected",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
direction: "after",
|
||||
cursor: { epoch: cursor.epoch, seq: cursor.endSeq },
|
||||
// Catch up all missing canonical rows by default.
|
||||
limit: 0,
|
||||
projection: "projected",
|
||||
};
|
||||
}
|
||||
|
||||
export function useAgentInitialization(serverId: string) {
|
||||
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
|
||||
const setInitializingAgents = useSessionStore((state) => state.setInitializingAgents);
|
||||
const setAgentInitializing = useCallback(
|
||||
(agentId: string, initializing: boolean) => {
|
||||
setInitializingAgents(serverId, (prev) => {
|
||||
if (prev.get(agentId) === initializing) {
|
||||
return prev;
|
||||
}
|
||||
const next = new Map(prev);
|
||||
next.set(agentId, initializing);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[serverId, setInitializingAgents]
|
||||
);
|
||||
|
||||
const ensureAgentIsInitialized = useCallback(
|
||||
(agentId: string): Promise<void> => {
|
||||
@@ -24,14 +64,7 @@ export function useAgentInitialization(serverId: string) {
|
||||
|
||||
const deferred = createInitDeferred(key);
|
||||
const timeoutId = setTimeout(() => {
|
||||
setInitializingAgents(serverId, (prev) => {
|
||||
if (prev.get(agentId) !== true) {
|
||||
return prev;
|
||||
}
|
||||
const next = new Map(prev);
|
||||
next.set(agentId, false);
|
||||
return next;
|
||||
});
|
||||
setAgentInitializing(agentId, false);
|
||||
rejectInitDeferred(
|
||||
key,
|
||||
new Error(
|
||||
@@ -41,34 +74,26 @@ export function useAgentInitialization(serverId: string) {
|
||||
}, INIT_TIMEOUT_MS);
|
||||
attachInitTimeout(key, timeoutId);
|
||||
|
||||
setInitializingAgents(serverId, (prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(agentId, true);
|
||||
return next;
|
||||
});
|
||||
setAgentInitializing(agentId, true);
|
||||
|
||||
if (!client) {
|
||||
setInitializingAgents(serverId, (prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(agentId, false);
|
||||
return next;
|
||||
});
|
||||
setAgentInitializing(agentId, false);
|
||||
rejectInitDeferred(key, new Error("Host is not connected"));
|
||||
return deferred.promise;
|
||||
}
|
||||
|
||||
const session = useSessionStore.getState().sessions[serverId];
|
||||
const cursor = session?.agentTimelineCursor.get(agentId);
|
||||
const timelineRequest = buildInitialTimelineRequest(cursor);
|
||||
|
||||
client
|
||||
.initializeAgent(agentId)
|
||||
.fetchAgentTimeline(agentId, timelineRequest)
|
||||
.then(() => {
|
||||
// No-op: the actual "timeline hydrated" signal is the `agent_stream_snapshot`
|
||||
// message, handled in SessionContext.
|
||||
// No-op: hydration completion is handled by SessionContext
|
||||
// when it processes fetch_agent_timeline_response.
|
||||
})
|
||||
.catch((error) => {
|
||||
setInitializingAgents(serverId, (prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(agentId, false);
|
||||
return next;
|
||||
});
|
||||
setAgentInitializing(agentId, false);
|
||||
rejectInitDeferred(
|
||||
key,
|
||||
error instanceof Error ? error : new Error(String(error))
|
||||
@@ -77,7 +102,7 @@ export function useAgentInitialization(serverId: string) {
|
||||
|
||||
return deferred.promise;
|
||||
},
|
||||
[client, serverId, setInitializingAgents]
|
||||
[client, serverId, setAgentInitializing]
|
||||
);
|
||||
|
||||
const refreshAgent = useCallback(
|
||||
@@ -85,24 +110,21 @@ export function useAgentInitialization(serverId: string) {
|
||||
if (!client) {
|
||||
throw new Error("Host is not connected");
|
||||
}
|
||||
setInitializingAgents(serverId, (prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(agentId, true);
|
||||
return next;
|
||||
});
|
||||
setAgentInitializing(agentId, true);
|
||||
|
||||
try {
|
||||
await client.refreshAgent(agentId);
|
||||
} catch (error) {
|
||||
setInitializingAgents(serverId, (prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(agentId, false);
|
||||
return next;
|
||||
await client.fetchAgentTimeline(agentId, {
|
||||
direction: "tail",
|
||||
limit: DEFAULT_INITIAL_TIMELINE_LIMIT,
|
||||
projection: "projected",
|
||||
});
|
||||
} catch (error) {
|
||||
setAgentInitializing(agentId, false);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[client, serverId, setInitializingAgents]
|
||||
[client, setAgentInitializing]
|
||||
);
|
||||
|
||||
return { ensureAgentIsInitialized, refreshAgent };
|
||||
|
||||
@@ -157,6 +157,12 @@ export type DaemonServerInfo = {
|
||||
hostname: string | null;
|
||||
};
|
||||
|
||||
export interface AgentTimelineCursorState {
|
||||
epoch: string;
|
||||
startSeq: number;
|
||||
endSeq: number;
|
||||
}
|
||||
|
||||
// Per-session state
|
||||
export interface SessionState {
|
||||
serverId: string;
|
||||
@@ -189,6 +195,7 @@ export interface SessionState {
|
||||
// Stream state (head/tail model)
|
||||
agentStreamTail: Map<string, StreamItem[]>;
|
||||
agentStreamHead: Map<string, StreamItem[]>;
|
||||
agentTimelineCursor: Map<string, AgentTimelineCursorState>;
|
||||
historySyncGeneration: number;
|
||||
agentHistorySyncGeneration: Map<string, number>;
|
||||
|
||||
@@ -240,6 +247,12 @@ interface SessionStoreActions {
|
||||
setAgentStreamTail: (serverId: string, state: Map<string, StreamItem[]> | ((prev: Map<string, StreamItem[]>) => Map<string, StreamItem[]>)) => void;
|
||||
setAgentStreamHead: (serverId: string, state: Map<string, StreamItem[]> | ((prev: Map<string, StreamItem[]>) => Map<string, StreamItem[]>)) => void;
|
||||
clearAgentStreamHead: (serverId: string, agentId: string) => void;
|
||||
setAgentTimelineCursor: (
|
||||
serverId: string,
|
||||
state:
|
||||
| Map<string, AgentTimelineCursorState>
|
||||
| ((prev: Map<string, AgentTimelineCursorState>) => Map<string, AgentTimelineCursorState>)
|
||||
) => void;
|
||||
bumpHistorySyncGeneration: (serverId: string) => void;
|
||||
markAgentHistorySynchronized: (serverId: string, agentId: string) => void;
|
||||
|
||||
@@ -321,6 +334,7 @@ function createInitialSessionState(serverId: string, client: DaemonClient, audio
|
||||
currentAssistantMessage: "",
|
||||
agentStreamTail: new Map(),
|
||||
agentStreamHead: new Map(),
|
||||
agentTimelineCursor: new Map(),
|
||||
historySyncGeneration: 0,
|
||||
agentHistorySyncGeneration: new Map(),
|
||||
initializingAgents: new Map(),
|
||||
@@ -603,6 +617,30 @@ export const useSessionStore = create<SessionStore>()(
|
||||
});
|
||||
},
|
||||
|
||||
setAgentTimelineCursor: (serverId, state) => {
|
||||
set((prev) => {
|
||||
const session = prev.sessions[serverId];
|
||||
if (!session) {
|
||||
return prev;
|
||||
}
|
||||
const nextState =
|
||||
typeof state === "function" ? state(session.agentTimelineCursor) : state;
|
||||
if (session.agentTimelineCursor === nextState) {
|
||||
return prev;
|
||||
}
|
||||
logSessionStoreUpdate("setAgentTimelineCursor", serverId, {
|
||||
agentCount: nextState.size,
|
||||
});
|
||||
return {
|
||||
...prev,
|
||||
sessions: {
|
||||
...prev.sessions,
|
||||
[serverId]: { ...session, agentTimelineCursor: nextState },
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
bumpHistorySyncGeneration: (serverId) => {
|
||||
set((prev) => {
|
||||
const session = prev.sessions[serverId];
|
||||
|
||||
Reference in New Issue
Block a user