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:
@@ -8,6 +8,7 @@ import {
|
||||
persistAttachmentFromDataUrl,
|
||||
persistAttachmentFromFileUri,
|
||||
} from "@/attachments/service";
|
||||
import { collectRetainedAttachmentIds } from "@/attachments/gc-retention";
|
||||
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||
import { useSessionStore, type SessionState } from "@/stores/session-store";
|
||||
import { useWorkspaceAttachmentsStore } from "@/attachments/workspace-attachments-store";
|
||||
@@ -138,6 +139,9 @@ async function runAttachmentGc(): Promise<void> {
|
||||
for (const id of useDraftStore.getState().collectActiveAttachmentIds()) {
|
||||
referencedIds.add(id);
|
||||
}
|
||||
for (const id of collectRetainedAttachmentIds()) {
|
||||
referencedIds.add(id);
|
||||
}
|
||||
|
||||
const pendingByDraftId = useCreateFlowStore.getState().pendingByDraftId;
|
||||
for (const pendingCreate of Object.values(pendingByDraftId)) {
|
||||
|
||||
@@ -5,9 +5,11 @@ import type { WorkspaceDescriptorPayload } from "@getpaseo/protocol/messages";
|
||||
|
||||
import {
|
||||
normalizeWorkspaceDescriptor,
|
||||
selectAgentTimelineState,
|
||||
useSessionStore,
|
||||
type WorkspaceDescriptor,
|
||||
} from "./session-store";
|
||||
import type { StreamItem } from "../types/stream";
|
||||
import { patchWorkspaceScripts } from "../contexts/session-workspace-scripts";
|
||||
|
||||
function createWorkspace(
|
||||
@@ -53,6 +55,54 @@ function getTestSessionReferences() {
|
||||
};
|
||||
}
|
||||
|
||||
describe("agent timeline state", () => {
|
||||
it("commits canonical items, range, and older availability as one synced state", () => {
|
||||
initializeTestSession();
|
||||
const items: StreamItem[] = [
|
||||
{
|
||||
kind: "assistant_message",
|
||||
id: "canonical-row",
|
||||
text: "canonical",
|
||||
timestamp: new Date("2026-07-27T10:00:00.000Z"),
|
||||
},
|
||||
];
|
||||
|
||||
useSessionStore.getState().applyAgentTimelineResponseState("test-server", "agent-1", {
|
||||
items,
|
||||
head: [],
|
||||
range: { epoch: "epoch-1", startSeq: 51, endSeq: 100 },
|
||||
older: "available",
|
||||
synchronized: true,
|
||||
acknowledgedClientMessageIds: [],
|
||||
});
|
||||
|
||||
expect(
|
||||
selectAgentTimelineState(useSessionStore.getState().sessions["test-server"], "agent-1"),
|
||||
).toEqual({
|
||||
status: "synced",
|
||||
items,
|
||||
range: { epoch: "epoch-1", startSeq: 51, endSeq: 100 },
|
||||
older: "available",
|
||||
});
|
||||
});
|
||||
|
||||
it("represents an empty authoritative timeline without inventing a range", () => {
|
||||
initializeTestSession();
|
||||
useSessionStore.getState().applyAgentTimelineResponseState("test-server", "agent-1", {
|
||||
items: [],
|
||||
head: [],
|
||||
range: null,
|
||||
older: "none",
|
||||
synchronized: true,
|
||||
acknowledgedClientMessageIds: [],
|
||||
});
|
||||
|
||||
expect(
|
||||
selectAgentTimelineState(useSessionStore.getState().sessions["test-server"], "agent-1"),
|
||||
).toEqual({ status: "synced", items: [], range: null, older: "none" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeWorkspaceDescriptor", () => {
|
||||
it("normalizes workspace scripts and invalid activity timestamps", () => {
|
||||
const scripts = [
|
||||
|
||||
@@ -336,8 +336,6 @@ export interface AgentTimelineCursorState {
|
||||
export interface SessionReplicaTimeline {
|
||||
agentId: string;
|
||||
items: StreamItem[];
|
||||
cursor: AgentTimelineCursorState | null;
|
||||
hasOlder: boolean;
|
||||
}
|
||||
|
||||
export interface SessionReplica {
|
||||
@@ -347,6 +345,33 @@ export interface SessionReplica {
|
||||
timeline: SessionReplicaTimeline | null;
|
||||
}
|
||||
|
||||
export type AgentTimelineState =
|
||||
| { status: "cold" }
|
||||
| { status: "painted"; items: StreamItem[] }
|
||||
| {
|
||||
status: "synced";
|
||||
items: StreamItem[];
|
||||
range: AgentTimelineCursorState | null;
|
||||
older: "available" | "none";
|
||||
};
|
||||
|
||||
export function selectAgentTimelineState(
|
||||
session: SessionState | undefined,
|
||||
agentId: string,
|
||||
): AgentTimelineState {
|
||||
if (!session) return { status: "cold" };
|
||||
const items = session.agentStreamTail.get(agentId) ?? [];
|
||||
if (session.agentAuthoritativeHistoryApplied.get(agentId) === true) {
|
||||
return {
|
||||
status: "synced",
|
||||
items,
|
||||
range: session.agentTimelineCursor.get(agentId) ?? null,
|
||||
older: session.agentTimelineHasOlder.get(agentId) === true ? "available" : "none",
|
||||
};
|
||||
}
|
||||
return items.length > 0 ? { status: "painted", items } : { status: "cold" };
|
||||
}
|
||||
|
||||
export type WorkspaceRestoreStatus = "restoring" | "failed" | "needs-host-upgrade";
|
||||
|
||||
// Per-session state
|
||||
@@ -520,6 +545,18 @@ interface SessionStoreActions {
|
||||
agentId: string,
|
||||
applied: boolean,
|
||||
) => void;
|
||||
applyAgentTimelineResponseState: (
|
||||
serverId: string,
|
||||
agentId: string,
|
||||
state: {
|
||||
items: StreamItem[];
|
||||
head: StreamItem[];
|
||||
range: AgentTimelineCursorState | null;
|
||||
older: "available" | "none";
|
||||
synchronized: boolean;
|
||||
acknowledgedClientMessageIds: string[];
|
||||
},
|
||||
) => void;
|
||||
|
||||
// Initializing agents
|
||||
setInitializingAgents: (
|
||||
@@ -758,16 +795,8 @@ export const useSessionStore = create<SessionStore>()(
|
||||
const session = createInitialSessionState(serverId, null);
|
||||
const timeline = replica.timeline;
|
||||
const agentStreamTail = new Map<string, StreamItem[]>();
|
||||
const agentTimelineCursor = new Map<string, AgentTimelineCursorState>();
|
||||
const agentTimelineHasOlder = new Map<string, boolean>();
|
||||
const agentAuthoritativeHistoryApplied = new Map<string, boolean>();
|
||||
const agentHistorySyncGeneration = new Map<string, number>();
|
||||
if (timeline) {
|
||||
agentStreamTail.set(timeline.agentId, timeline.items);
|
||||
agentTimelineHasOlder.set(timeline.agentId, timeline.hasOlder);
|
||||
agentAuthoritativeHistoryApplied.set(timeline.agentId, true);
|
||||
agentHistorySyncGeneration.set(timeline.agentId, session.historySyncGeneration);
|
||||
if (timeline.cursor) agentTimelineCursor.set(timeline.agentId, timeline.cursor);
|
||||
}
|
||||
const agentLastActivity = new Map(prev.agentLastActivity);
|
||||
for (const agent of replica.agents.values()) {
|
||||
@@ -784,10 +813,6 @@ export const useSessionStore = create<SessionStore>()(
|
||||
workspaces: replica.workspaces,
|
||||
emptyProjects: replica.emptyProjects,
|
||||
agentStreamTail,
|
||||
agentTimelineCursor,
|
||||
agentTimelineHasOlder,
|
||||
agentAuthoritativeHistoryApplied,
|
||||
agentHistorySyncGeneration,
|
||||
},
|
||||
},
|
||||
agentLastActivity,
|
||||
@@ -1457,6 +1482,61 @@ export const useSessionStore = create<SessionStore>()(
|
||||
});
|
||||
},
|
||||
|
||||
applyAgentTimelineResponseState: (serverId, agentId, state) => {
|
||||
set((prev) => {
|
||||
const session = prev.sessions[serverId];
|
||||
if (!session) return prev;
|
||||
|
||||
const nextTail = new Map(session.agentStreamTail);
|
||||
nextTail.set(agentId, state.items);
|
||||
const nextHead = new Map(session.agentStreamHead);
|
||||
if (state.head.length > 0) nextHead.set(agentId, state.head);
|
||||
else nextHead.delete(agentId);
|
||||
const nextCursor = new Map(session.agentTimelineCursor);
|
||||
if (state.range) nextCursor.set(agentId, state.range);
|
||||
else nextCursor.delete(agentId);
|
||||
const nextHasOlder = new Map(session.agentTimelineHasOlder);
|
||||
nextHasOlder.set(agentId, state.older === "available");
|
||||
const nextAuthoritative = new Map(session.agentAuthoritativeHistoryApplied);
|
||||
const nextSyncGeneration = new Map(session.agentHistorySyncGeneration);
|
||||
const currentSubmissions = session.messageSubmissions.get(agentId) ?? [];
|
||||
const observedSubmissions = observeMessageSubmissionCanonical(
|
||||
currentSubmissions,
|
||||
state.acknowledgedClientMessageIds,
|
||||
);
|
||||
let messageSubmissions = session.messageSubmissions;
|
||||
if (observedSubmissions !== currentSubmissions) {
|
||||
messageSubmissions = new Map(session.messageSubmissions);
|
||||
if (observedSubmissions.length > 0) {
|
||||
messageSubmissions.set(agentId, observedSubmissions);
|
||||
} else {
|
||||
messageSubmissions.delete(agentId);
|
||||
}
|
||||
}
|
||||
if (state.synchronized) {
|
||||
nextAuthoritative.set(agentId, true);
|
||||
nextSyncGeneration.set(agentId, session.historySyncGeneration);
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
sessions: {
|
||||
...prev.sessions,
|
||||
[serverId]: {
|
||||
...session,
|
||||
agentStreamTail: nextTail,
|
||||
agentStreamHead: nextHead,
|
||||
agentTimelineCursor: nextCursor,
|
||||
agentTimelineHasOlder: nextHasOlder,
|
||||
agentAuthoritativeHistoryApplied: nextAuthoritative,
|
||||
agentHistorySyncGeneration: nextSyncGeneration,
|
||||
messageSubmissions,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
// Initializing agents
|
||||
setInitializingAgents: (serverId, state) => {
|
||||
set((prev) => {
|
||||
|
||||
Reference in New Issue
Block a user