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:
@@ -111,6 +111,47 @@ describe("ensureAgentIsInitialized", () => {
|
||||
expect(getInitDeferred(getInitKey(serverId, agentId))?.requestDirection).toBe("tail");
|
||||
});
|
||||
|
||||
it("requests a bounded projected tail after restoring painted replica items", () => {
|
||||
const client = new FakeDaemonClient();
|
||||
const runtime = new FakeTimelineRuntime();
|
||||
useSessionStore.getState().restoreSessionReplica(serverId, {
|
||||
agents: new Map(),
|
||||
workspaces: new Map(),
|
||||
emptyProjects: new Map(),
|
||||
timeline: {
|
||||
agentId,
|
||||
items: [
|
||||
{
|
||||
kind: "assistant_message",
|
||||
id: "painted-item",
|
||||
text: "Painted before hydration",
|
||||
timestamp: new Date("2026-07-27T10:00:00.000Z"),
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
void ensureAgentIsInitialized({
|
||||
serverId,
|
||||
agentId,
|
||||
client: client as never,
|
||||
runtime,
|
||||
setAgentInitializing: bindSetAgentInitializing(),
|
||||
});
|
||||
|
||||
expect(runtime.requests).toEqual([
|
||||
{
|
||||
serverId,
|
||||
agentId,
|
||||
request: {
|
||||
direction: "tail",
|
||||
limit: TIMELINE_FETCH_PAGE_SIZE,
|
||||
projection: "projected",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("times out initialization after 65 seconds", async () => {
|
||||
vi.useFakeTimers();
|
||||
const client = new FakeDaemonClient();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { selectAgentTimelineState, useSessionStore } from "@/stores/session-store";
|
||||
import {
|
||||
createInitDeferred,
|
||||
getInitDeferred,
|
||||
@@ -52,8 +52,9 @@ export function ensureAgentIsInitialized(input: EnsureAgentIsInitializedInput):
|
||||
}
|
||||
|
||||
const session = useSessionStore.getState().sessions[serverId];
|
||||
const cursor = session?.agentTimelineCursor.get(agentId);
|
||||
const hasAuthoritativeHistory = session?.agentAuthoritativeHistoryApplied.get(agentId) === true;
|
||||
const timeline = selectAgentTimelineState(session, agentId);
|
||||
const cursor = timeline.status === "synced" ? (timeline.range ?? undefined) : undefined;
|
||||
const hasAuthoritativeHistory = timeline.status === "synced";
|
||||
const timelineRequest = planInitialAgentTimelineSync({ cursor, hasAuthoritativeHistory });
|
||||
|
||||
const deferred = createInitDeferred(key, timelineRequest.direction);
|
||||
|
||||
@@ -84,7 +84,7 @@ describe("loadOlderAgentHistory", () => {
|
||||
const client = createClient();
|
||||
const inFlight = createInFlight();
|
||||
|
||||
await loadOlderAgentHistory(agentId, {
|
||||
const started = await loadOlderAgentHistory(agentId, {
|
||||
client,
|
||||
cursor: undefined,
|
||||
hasOlder: true,
|
||||
@@ -94,13 +94,14 @@ describe("loadOlderAgentHistory", () => {
|
||||
|
||||
expect(client.calls).toEqual([]);
|
||||
expect(inFlight.values).toEqual([false]);
|
||||
expect(started).toBe(false);
|
||||
});
|
||||
|
||||
it("no-ops when the daemon says no older history exists", async () => {
|
||||
const client = createClient();
|
||||
const inFlight = createInFlight();
|
||||
|
||||
await loadOlderAgentHistory(agentId, {
|
||||
const started = await loadOlderAgentHistory(agentId, {
|
||||
client,
|
||||
cursor: someCursor,
|
||||
hasOlder: false,
|
||||
@@ -110,13 +111,14 @@ describe("loadOlderAgentHistory", () => {
|
||||
|
||||
expect(client.calls).toEqual([]);
|
||||
expect(inFlight.values).toEqual([false]);
|
||||
expect(started).toBe(false);
|
||||
});
|
||||
|
||||
it("no-ops when a request is already in flight", async () => {
|
||||
const client = createClient();
|
||||
const inFlight = createInFlight(true);
|
||||
|
||||
await loadOlderAgentHistory(agentId, {
|
||||
const started = await loadOlderAgentHistory(agentId, {
|
||||
client,
|
||||
cursor: someCursor,
|
||||
hasOlder: true,
|
||||
@@ -126,13 +128,14 @@ describe("loadOlderAgentHistory", () => {
|
||||
|
||||
expect(client.calls).toEqual([]);
|
||||
expect(inFlight.values).toEqual([true]);
|
||||
expect(started).toBe(true);
|
||||
});
|
||||
|
||||
it("requests the page before the current start cursor and clears in-flight on success", async () => {
|
||||
const client = createClient();
|
||||
const inFlight = createInFlight();
|
||||
|
||||
await loadOlderAgentHistory(agentId, {
|
||||
const started = await loadOlderAgentHistory(agentId, {
|
||||
client,
|
||||
cursor: someCursor,
|
||||
hasOlder: true,
|
||||
@@ -152,6 +155,7 @@ describe("loadOlderAgentHistory", () => {
|
||||
},
|
||||
]);
|
||||
expect(inFlight.values).toEqual([false, true, false]);
|
||||
expect(started).toBe(true);
|
||||
});
|
||||
|
||||
it("shows a panel toast, warns, and clears in-flight on failure", async () => {
|
||||
|
||||
@@ -2,7 +2,11 @@ import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ToastApi } from "@/components/toast-host";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import { useSessionStore, type AgentTimelineCursorState } from "@/stores/session-store";
|
||||
import {
|
||||
selectAgentTimelineState,
|
||||
useSessionStore,
|
||||
type AgentTimelineCursorState,
|
||||
} from "@/stores/session-store";
|
||||
import { planTimelineOlderFetch } from "@/timeline/timeline-sync-plan";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
|
||||
@@ -36,11 +40,14 @@ export interface LoadOlderAgentHistoryDeps {
|
||||
export async function loadOlderAgentHistory(
|
||||
agentId: string,
|
||||
deps: LoadOlderAgentHistoryDeps,
|
||||
): Promise<void> {
|
||||
): Promise<boolean> {
|
||||
const { client, cursor, hasOlder, isLoadingOlder, setInFlight, toast, logger, failedMessage } =
|
||||
deps;
|
||||
if (!client || !cursor || !hasOlder || isLoadingOlder) {
|
||||
return;
|
||||
if (isLoadingOlder) {
|
||||
return true;
|
||||
}
|
||||
if (!client || !cursor || !hasOlder) {
|
||||
return false;
|
||||
}
|
||||
|
||||
setInFlight(true);
|
||||
@@ -58,6 +65,7 @@ export async function loadOlderAgentHistory(
|
||||
} finally {
|
||||
setInFlight(false);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function useLoadOlderAgentHistory({
|
||||
@@ -70,15 +78,17 @@ export function useLoadOlderAgentHistory({
|
||||
toast?: ToastApi | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const hasOlder =
|
||||
useSessionStore((state) => state.sessions[serverId]?.agentTimelineHasOlder.get(agentId)) ===
|
||||
true;
|
||||
const hasOlder = useSessionStore((state) => {
|
||||
const timeline = selectAgentTimelineState(state.sessions[serverId], agentId);
|
||||
return timeline.status === "synced" && timeline.older === "available";
|
||||
});
|
||||
const isLoadingOlder =
|
||||
useSessionStore((state) =>
|
||||
state.sessions[serverId]?.agentTimelineOlderFetchInFlight.get(agentId),
|
||||
) === true;
|
||||
const progressKey = useSessionStore((state) => {
|
||||
const cursor = state.sessions[serverId]?.agentTimelineCursor.get(agentId);
|
||||
const timeline = selectAgentTimelineState(state.sessions[serverId], agentId);
|
||||
const cursor = timeline.status === "synced" ? timeline.range : null;
|
||||
return cursor ? `${cursor.epoch}:${cursor.startSeq}` : null;
|
||||
});
|
||||
const setOlderFetchInFlight = useSessionStore(
|
||||
@@ -99,17 +109,18 @@ export function useLoadOlderAgentHistory({
|
||||
[agentId, serverId, setOlderFetchInFlight],
|
||||
);
|
||||
|
||||
const loadOlder = useCallback(() => {
|
||||
const loadOlder = useCallback(async (): Promise<boolean> => {
|
||||
const session = useSessionStore.getState().sessions[serverId];
|
||||
void loadOlderAgentHistory(agentId, {
|
||||
const timeline = selectAgentTimelineState(session, agentId);
|
||||
return await loadOlderAgentHistory(agentId, {
|
||||
client: session?.client
|
||||
? {
|
||||
fetchAgentTimeline: (timelineAgentId, request) =>
|
||||
getHostRuntimeStore().fetchAgentTimeline(serverId, timelineAgentId, request),
|
||||
}
|
||||
: null,
|
||||
cursor: session?.agentTimelineCursor.get(agentId),
|
||||
hasOlder: session?.agentTimelineHasOlder.get(agentId) === true,
|
||||
cursor: timeline.status === "synced" ? (timeline.range ?? undefined) : undefined,
|
||||
hasOlder: timeline.status === "synced" && timeline.older === "available",
|
||||
isLoadingOlder: session?.agentTimelineOlderFetchInFlight.get(agentId) === true,
|
||||
setInFlight,
|
||||
toast,
|
||||
|
||||
Reference in New Issue
Block a user