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:
Mohamed Boudra
2026-07-28 22:29:03 +02:00
committed by GitHub
parent bbf3d0f9cc
commit 504b687f89
55 changed files with 4550 additions and 722 deletions
@@ -11,6 +11,7 @@ import {
import {
normalizeEmptyProjectDescriptor,
normalizeWorkspaceDescriptor,
selectAgentTimelineState,
useSessionStore,
type Agent,
type SessionReplica,
@@ -37,14 +38,6 @@ const StoredAgentSchema = z.object({
const StoredTimelineSchema = z.object({
agentId: z.string(),
items: z.unknown(),
cursor: z
.object({
epoch: z.string(),
startSeq: z.number().int().nonnegative(),
endSeq: z.number().int().nonnegative(),
})
.nullable(),
hasOlder: z.boolean(),
});
const StoredHostSchema = z.object({
@@ -141,8 +134,6 @@ function deserializeTimeline(stored: StoredHost["timeline"]): SessionReplica["ti
return {
agentId: stored.agentId,
items: decoded,
cursor: stored.cursor,
hasOlder: stored.hasOlder,
};
}
@@ -376,24 +367,24 @@ export class ReplicaCache {
focusedAgentId ? session.messageSubmissions.get(focusedAgentId) : undefined,
),
);
const items = focusedAgentId
? session.agentStreamTail
.get(focusedAgentId)
?.filter(
const timelineState = focusedAgentId
? selectAgentTimelineState(session, focusedAgentId)
: { status: "cold" as const };
const items =
timelineState.status === "cold"
? undefined
: timelineState.items.filter(
(item) =>
item.kind !== "user_message" ||
item.messageId !== undefined ||
!item.clientMessageId ||
!localSubmissionIds.has(item.clientMessageId),
)
: undefined;
);
const timeline =
focusedAgent && items
? {
agentId: focusedAgent.id,
items: encodeDates(items.slice(-MAX_TIMELINE_ITEMS)),
cursor: session.agentTimelineCursor.get(focusedAgent.id) ?? null,
hasOlder: session.agentTimelineHasOlder.get(focusedAgent.id) ?? false,
}
: null;
const stored: StoredHost = {