Keep older chat history and image previews stable (#2490)
* 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. * fix(app): keep file image previews current * fix(app): stabilize history pagination edges * fix(app): recover transient timeline loads * fix(app): retain assistant image previews * Stabilize history pagination lifecycle * Stabilize history settlement and image previews * Retry file images after reconnect * Keep active previews and pagination requests alive * Replace image hook tests with typed ports * Integrate timeline hydration with submission authority * Harden pre-hydration timeline reconciliation * Make rewind E2E use real scroll intent * Preserve live timeline state through hydration * Keep mounted image attachments retained * Protect preview persistence and lifecycle hydration * Preserve idless live assistant continuations * Preserve timeline rows across delayed hydration * test(app): cover real assistant image files * Preserve assistant tool ordering during hydration
This commit is contained in:
@@ -40,7 +40,12 @@ import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types";
|
||||
import { getHostRuntimeStore, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { useVoiceAudioEngineOptional, useVoiceRuntimeOptional } from "@/contexts/voice-context";
|
||||
import type { AudioPlaybackSource } from "@/voice/audio-engine-types";
|
||||
import { useSessionStore, type MessageEntry, type SessionState } from "@/stores/session-store";
|
||||
import {
|
||||
selectAgentTimelineState,
|
||||
useSessionStore,
|
||||
type MessageEntry,
|
||||
type SessionState,
|
||||
} from "@/stores/session-store";
|
||||
import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
|
||||
import { sendOsNotification } from "@/utils/os-notifications";
|
||||
import { getIsAppActivelyVisible, getIsAppVisible } from "@/utils/app-visibility";
|
||||
@@ -192,11 +197,6 @@ type WorkspaceSetupProgressPayload = Extract<
|
||||
|
||||
type SessionStoreActions = ReturnType<typeof useSessionStore.getState>;
|
||||
type SetInitializingAgents = SessionStoreActions["setInitializingAgents"];
|
||||
type SetAgentStreamState = SessionStoreActions["setAgentStreamState"];
|
||||
type SetAgentTimelineCursor = SessionStoreActions["setAgentTimelineCursor"];
|
||||
type MarkAgentHistorySynchronized = SessionStoreActions["markAgentHistorySynchronized"];
|
||||
type SetAgentAuthoritativeHistoryApplied =
|
||||
SessionStoreActions["setAgentAuthoritativeHistoryApplied"];
|
||||
|
||||
function clearAgentInitializingFlag(
|
||||
setInitializingAgents: SetInitializingAgents,
|
||||
@@ -229,65 +229,6 @@ function handleTimelineError(input: {
|
||||
}
|
||||
}
|
||||
|
||||
function applyTimelineStreamPatches(input: {
|
||||
result: ProcessTimelineResponseOutput;
|
||||
agentId: string;
|
||||
serverId: string;
|
||||
currentTail: StreamItem[];
|
||||
currentHead: StreamItem[];
|
||||
setAgentStreamState: SetAgentStreamState;
|
||||
setAgentTimelineCursor: SetAgentTimelineCursor;
|
||||
}): void {
|
||||
const {
|
||||
result,
|
||||
agentId,
|
||||
serverId,
|
||||
currentTail,
|
||||
currentHead,
|
||||
setAgentStreamState,
|
||||
setAgentTimelineCursor,
|
||||
} = input;
|
||||
|
||||
if (
|
||||
result.tail !== currentTail ||
|
||||
result.head !== currentHead ||
|
||||
result.acknowledgedClientMessageIds.length > 0
|
||||
) {
|
||||
setAgentStreamState(serverId, agentId, {
|
||||
...(result.tail !== currentTail ? { tail: result.tail } : {}),
|
||||
...(result.head !== currentHead ? { head: result.head } : {}),
|
||||
...(result.acknowledgedClientMessageIds.length > 0
|
||||
? { acknowledgedClientMessageIds: result.acknowledgedClientMessageIds }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
if (result.cursorChanged) {
|
||||
setAgentTimelineCursor(serverId, (prev) => {
|
||||
const current = prev.get(agentId);
|
||||
if (!result.cursor) {
|
||||
if (!current) {
|
||||
return prev;
|
||||
}
|
||||
const next = new Map(prev);
|
||||
next.delete(agentId);
|
||||
return next;
|
||||
}
|
||||
if (
|
||||
current &&
|
||||
current.epoch === result.cursor.epoch &&
|
||||
current.startSeq === result.cursor.startSeq &&
|
||||
current.endSeq === result.cursor.endSeq
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
const next = new Map(prev);
|
||||
next.set(agentId, result.cursor);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function executeTimelineSideEffects(input: {
|
||||
sideEffects: TimelineReducerSideEffect[];
|
||||
agentId: string;
|
||||
@@ -308,8 +249,6 @@ function finalizeTimelineApplication(input: {
|
||||
serverId: string;
|
||||
shouldMarkAuthoritativeHistoryApplied: boolean;
|
||||
setInitializingAgents: SetInitializingAgents;
|
||||
setAgentAuthoritativeHistoryApplied: SetAgentAuthoritativeHistoryApplied;
|
||||
markAgentHistorySynchronized: MarkAgentHistorySynchronized;
|
||||
}): void {
|
||||
const {
|
||||
result,
|
||||
@@ -318,17 +257,13 @@ function finalizeTimelineApplication(input: {
|
||||
serverId,
|
||||
shouldMarkAuthoritativeHistoryApplied,
|
||||
setInitializingAgents,
|
||||
setAgentAuthoritativeHistoryApplied,
|
||||
markAgentHistorySynchronized,
|
||||
} = input;
|
||||
|
||||
if (result.clearInitializing) {
|
||||
clearAgentInitializingFlag(setInitializingAgents, serverId, agentId);
|
||||
}
|
||||
if (shouldMarkAuthoritativeHistoryApplied) {
|
||||
setAgentAuthoritativeHistoryApplied(serverId, agentId, true);
|
||||
useCreateFlowStore.getState().clearByAgent({ serverId, agentId });
|
||||
markAgentHistorySynchronized(serverId, agentId);
|
||||
const session = useSessionStore.getState().sessions[serverId];
|
||||
const agent = session?.agents.get(agentId) ?? session?.agentDetails.get(agentId);
|
||||
if (agent && agent.status !== "running") {
|
||||
@@ -414,14 +349,10 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
const setAgentStreamState = useSessionStore((state) => state.setAgentStreamState);
|
||||
const clearAgentStreamHead = useSessionStore((state) => state.clearAgentStreamHead);
|
||||
const setAgentTimelineCursor = useSessionStore((state) => state.setAgentTimelineCursor);
|
||||
const setAgentTimelineHasOlder = useSessionStore((state) => state.setAgentTimelineHasOlder);
|
||||
const setInitializingAgents = useSessionStore((state) => state.setInitializingAgents);
|
||||
const bumpHistorySyncGeneration = useSessionStore((state) => state.bumpHistorySyncGeneration);
|
||||
const markAgentHistorySynchronized = useSessionStore(
|
||||
(state) => state.markAgentHistorySynchronized,
|
||||
);
|
||||
const setAgentAuthoritativeHistoryApplied = useSessionStore(
|
||||
(state) => state.setAgentAuthoritativeHistoryApplied,
|
||||
const applyAgentTimelineResponseState = useSessionStore(
|
||||
(state) => state.applyAgentTimelineResponseState,
|
||||
);
|
||||
const setAgents = useSessionStore((state) => state.setAgents);
|
||||
const setWorkspaces = useSessionStore((state) => state.setWorkspaces);
|
||||
@@ -644,22 +575,15 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
const isInitializing = session?.initializingAgents.get(agentId) === true;
|
||||
const activeInitDeferred = getInitDeferred(initKey);
|
||||
const hasActiveInitDeferred = Boolean(activeInitDeferred);
|
||||
const currentCursor = session?.agentTimelineCursor.get(agentId);
|
||||
const currentTail = session?.agentStreamTail.get(agentId) ?? [];
|
||||
const timeline = selectAgentTimelineState(session, agentId);
|
||||
const currentCursor =
|
||||
timeline.status === "synced" ? (timeline.range ?? undefined) : undefined;
|
||||
const currentTail = timeline.status === "cold" ? [] : timeline.items;
|
||||
const currentHead = session?.agentStreamHead.get(agentId) ?? [];
|
||||
const sendingClientMessageIds = getSendingClientMessageIds(
|
||||
session?.messageSubmissions.get(agentId),
|
||||
);
|
||||
|
||||
setAgentTimelineHasOlder(serverId, (prev) => {
|
||||
if (prev.get(agentId) === payload.hasOlder) {
|
||||
return prev;
|
||||
}
|
||||
const next = new Map(prev);
|
||||
next.set(agentId, payload.hasOlder);
|
||||
return next;
|
||||
});
|
||||
|
||||
// Call pure reducer
|
||||
const result = processTimelineResponse({
|
||||
payload,
|
||||
@@ -670,6 +594,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
hasActiveInitDeferred,
|
||||
initRequestDirection: activeInitDeferred?.requestDirection ?? "tail",
|
||||
sendingClientMessageIds,
|
||||
hasAuthoritativeBaseline: timeline.status === "synced",
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
@@ -683,14 +608,13 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
return;
|
||||
}
|
||||
|
||||
applyTimelineStreamPatches({
|
||||
result,
|
||||
agentId,
|
||||
serverId,
|
||||
currentTail,
|
||||
currentHead,
|
||||
setAgentStreamState,
|
||||
setAgentTimelineCursor,
|
||||
applyAgentTimelineResponseState(serverId, agentId, {
|
||||
items: result.tail,
|
||||
head: result.head,
|
||||
range: result.cursorChanged ? (result.cursor ?? null) : (currentCursor ?? null),
|
||||
older: payload.hasOlder ? "available" : "none",
|
||||
synchronized: shouldMarkAuthoritativeHistoryApplied,
|
||||
acknowledgedClientMessageIds: result.acknowledgedClientMessageIds,
|
||||
});
|
||||
|
||||
executeTimelineSideEffects({
|
||||
@@ -706,20 +630,9 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
serverId,
|
||||
shouldMarkAuthoritativeHistoryApplied,
|
||||
setInitializingAgents,
|
||||
setAgentAuthoritativeHistoryApplied,
|
||||
markAgentHistorySynchronized,
|
||||
});
|
||||
},
|
||||
[
|
||||
markAgentHistorySynchronized,
|
||||
recoverTimelineGap,
|
||||
serverId,
|
||||
setAgentAuthoritativeHistoryApplied,
|
||||
setAgentStreamState,
|
||||
setAgentTimelineCursor,
|
||||
setAgentTimelineHasOlder,
|
||||
setInitializingAgents,
|
||||
],
|
||||
[applyAgentTimelineResponseState, recoverTimelineGap, serverId, setInitializingAgents],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -730,16 +643,20 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
const sync = createViewedTimelineSync({
|
||||
initialDeliveryMode,
|
||||
setSubscription: (agentIds) => client.setAgentTimelineSubscription(agentIds),
|
||||
readCursor: (agentId) =>
|
||||
useSessionStore.getState().sessions[serverId]?.agentTimelineCursor.get(agentId),
|
||||
readCursor: (agentId) => {
|
||||
const timeline = selectAgentTimelineState(
|
||||
useSessionStore.getState().sessions[serverId],
|
||||
agentId,
|
||||
);
|
||||
return timeline.status === "synced" ? (timeline.range ?? undefined) : undefined;
|
||||
},
|
||||
hasAuthoritativeHistory: (agentId) =>
|
||||
useSessionStore
|
||||
.getState()
|
||||
.sessions[serverId]?.agentAuthoritativeHistoryApplied.get(agentId) === true,
|
||||
selectAgentTimelineState(useSessionStore.getState().sessions[serverId], agentId).status ===
|
||||
"synced",
|
||||
fetchPage: async (agentId, request) => {
|
||||
const session = useSessionStore.getState().sessions[serverId];
|
||||
const initKey = getInitKey(serverId, agentId);
|
||||
const shouldInitialize = session?.agentAuthoritativeHistoryApplied.get(agentId) !== true;
|
||||
const shouldInitialize = selectAgentTimelineState(session, agentId).status !== "synced";
|
||||
if (shouldInitialize) {
|
||||
if (!getInitDeferred(initKey)) {
|
||||
const deferred = createInitDeferred(initKey, request.direction ?? "tail");
|
||||
|
||||
Reference in New Issue
Block a user