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.
This commit is contained in:
Mohamed Boudra
2026-07-28 16:45:39 +02:00
parent 963d4f9240
commit 7709c42b13
29 changed files with 2135 additions and 656 deletions
+17
View File
@@ -37,6 +37,12 @@ Initialization timeouts guard lack of catch-up progress, not the full multi-page
The first load of an agent without a local cursor is different: it fetches a bounded latest tail page. Older history remains user-driven by scrolling upward.
Reaching the history-start threshold loads one older page and preserves the visible content anchor.
Cursor progress does not trigger another page. The user must leave and return to the threshold unless
the anchored page still leaves the viewport at history start, as with short or compacted content; in
that case pagination continues as one loading operation until the page fills the viewport or history
is exhausted.
## Durable item anchors
Provider message IDs are not guaranteed for every displayed item. Paseo-generated system errors are one example. Rendered item indices are not durable either because pagination and projection can merge source rows.
@@ -61,6 +67,17 @@ recomposition while the runtime still owns the same directory snapshot and timel
Removing the host from the registry is the destructive boundary: it stops the runtime and clears the
session and host-scoped setup state together.
The durable replica cache is a display cache, not a synchronization checkpoint. Its timeline record
contains only the focused `agentId` and a truncated item tail. It never persists a cursor, epoch,
older-history availability, authority status, or sync generation because those facts would describe
the complete source dataset rather than the truncated display dataset.
Restoring that cache produces a painted timeline: the items may render immediately, but the first
daemon timeline request is still `tail`. A successful tail response atomically establishes canonical
items, range, and older-history availability. Live rows received between cache paint and that tail
response stay in the separate live head, do not advance a cursor or trigger gap recovery, and are
reconciled with the authoritative tail and subsequent catch-up.
## Selective and legacy delivery
The app chooses one delivery policy from `server_info.features.selectiveAgentTimeline`:
@@ -0,0 +1,87 @@
import type { Page } from "@playwright/test";
import { expect, test as base } from "./fixtures";
import { openWorkspaceWithAgents, type ArchiveTabAgent } from "./helpers/archive-tab";
import { seedWorkspace, type SeedDaemonClient, type SeededWorkspace } from "./helpers/seed-client";
const IMAGE_ALT = "Synthetic image";
const IMAGE_PREVIEW_ERROR = "Unable to load image preview.";
const IMAGE_PROMPT = "Emit 4096 byte image agent stream update";
const test = base.extend<{ imageWorkspace: SeededWorkspace }>({
imageWorkspace: async ({ page: _page }, provide) => {
const workspace = await seedWorkspace({ repoPrefix: "agent-tab-image-stability-" });
await provide(workspace);
await workspace.cleanup();
},
});
async function createMockAgent(
client: SeedDaemonClient,
input: { cwd: string; workspaceId: string; title: string },
): Promise<ArchiveTabAgent> {
const agent = await client.createAgent({
provider: "mock",
model: "ten-second-stream",
modeId: "load-test",
cwd: input.cwd,
workspaceId: input.workspaceId,
title: input.title,
});
await client.waitForAgentUpsert(agent.id, (snapshot) => snapshot.status === "idle", 30_000);
return { id: agent.id, ...input };
}
async function observeImagePreviewErrors(page: Page): Promise<void> {
await page.evaluate((errorText) => {
const root = document.documentElement;
root.dataset.agentTabImageErrorFlash = "false";
const inspect = () => {
const errorVisible = Array.from(document.querySelectorAll("*")).some((element) => {
if (element.children.length > 0 || element.textContent?.trim() !== errorText) {
return false;
}
const rect = element.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
});
if (errorVisible) {
root.dataset.agentTabImageErrorFlash = "true";
}
};
const observer = new MutationObserver(inspect);
observer.observe(document.body, { childList: true, subtree: true, characterData: true });
window.setTimeout(() => observer.disconnect(), 2_000);
}, IMAGE_PREVIEW_ERROR);
}
async function expectNoObservedImagePreviewError(page: Page): Promise<void> {
await page.waitForTimeout(250);
expect(await page.locator("html").getAttribute("data-agent-tab-image-error-flash")).toBe("false");
}
test("switching between settled agent tabs keeps the image timeline valid", async ({
imageWorkspace: workspace,
page,
}) => {
test.setTimeout(120_000);
const imageAgent = await createMockAgent(workspace.client, {
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: `Image timeline ${Date.now()}`,
});
const otherAgent = await createMockAgent(workspace.client, {
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: `Other timeline ${Date.now()}`,
});
await workspace.client.sendAgentMessage(imageAgent.id, IMAGE_PROMPT);
await workspace.client.waitForFinish(imageAgent.id, 15_000);
await openWorkspaceWithAgents(page, [otherAgent, imageAgent]);
await expect(page.getByRole("img", { name: IMAGE_ALT })).toBeVisible({ timeout: 30_000 });
await page.getByRole("button", { name: otherAgent.title, exact: true }).click();
await observeImagePreviewErrors(page);
await page.getByRole("button", { name: imageAgent.title, exact: true }).click();
await expect(page.getByRole("img", { name: IMAGE_ALT })).toHaveCount(1, { timeout: 30_000 });
await expectNoObservedImagePreviewError(page);
});
@@ -1,27 +1,97 @@
import { test } from "./fixtures";
import {
expectLoadedTimelineDoesNotScroll,
expectSameOlderHistoryLoadingOperation,
expectTimelineAtHistoryStart,
expectTimelinePromptNotMounted,
expectTimelinePromptVisible,
holdNextOlderTimelinePage,
holdBootstrapTimelinePage,
holdDaemonHydration,
holdOlderHistoryPages,
makeLoadedTimelineFitViewport,
openAgentTimeline,
rememberOlderHistoryLoadingOperation,
rememberTimelineViewport,
reloadAgentTimelineFromPersistedReplica,
scrollTimelineUntilOlderHistoryIsReachable,
scrollTimelineToNewestLoadedEdge,
seedLongMockAgentTimeline,
sendLiveTurnBeforeHydration,
expectTimelineViewportAnchoredAfterPrepend,
userScrollsTimelineToHistoryStart,
} from "./helpers/timeline-pagination";
test.describe("Agent timeline pagination", () => {
test("loads older history when the user scrolls to the top of a long agent timeline", async ({
test("loads one page each time the user returns to history start", async ({ page }) => {
test.setTimeout(120_000);
const agent = await seedLongMockAgentTimeline({ turns: 80 });
try {
const history = await holdOlderHistoryPages(page, agent);
await openAgentTimeline(page, agent);
await expectTimelinePromptVisible(page, agent.newestPrompt);
await expectTimelinePromptNotMounted(page, agent.oldestPrompt);
await userScrollsTimelineToHistoryStart(page);
await history.expectRequestedPages(1);
history.releasePage(1);
await history.expectSettledWithRequestedPages(1);
await userScrollsTimelineToHistoryStart(page);
await history.expectRequestedPages(2);
} finally {
await agent.cleanup();
}
});
test("keeps the visible timeline position anchored while prepending a page", async ({ page }) => {
test.setTimeout(120_000);
const agent = await seedLongMockAgentTimeline({ turns: 80 });
try {
const history = await holdOlderHistoryPages(page, agent);
await openAgentTimeline(page, agent);
await userScrollsTimelineToHistoryStart(page);
await history.expectRequestedPages(1);
const viewport = await rememberTimelineViewport(page);
history.releasePage(1);
await expectTimelineViewportAnchoredAfterPrepend(page, viewport);
} finally {
await agent.cleanup();
}
});
test("continues one loading operation while older pages still leave history start exposed", async ({
page,
}) => {
test.setTimeout(120_000);
const agent = await seedLongMockAgentTimeline({ turns: 80 });
try {
await makeLoadedTimelineFitViewport(page);
const history = await holdOlderHistoryPages(page, agent);
await openAgentTimeline(page, agent);
await expectTimelinePromptVisible(page, agent.newestPrompt);
await expectTimelinePromptNotMounted(page, agent.oldestPrompt);
await history.expectRequestedPages(1);
const loading = await rememberOlderHistoryLoadingOperation(page);
await scrollTimelineUntilOlderHistoryIsReachable(page);
history.releasePage(1);
await history.expectRequestedPages(2);
await expectTimelineAtHistoryStart(page);
await expectSameOlderHistoryLoadingOperation(page, loading);
} finally {
await agent.cleanup();
}
});
test("keeps complete loaded history reachable after reload", async ({ page }) => {
test.setTimeout(120_000);
const agent = await seedLongMockAgentTimeline({ turns: 80 });
try {
await openAgentTimeline(page, agent);
await scrollTimelineUntilOlderHistoryIsReachable(page, agent.oldestPrompt);
await expectTimelinePromptVisible(page, agent.oldestPrompt);
const hydration = await holdDaemonHydration(page);
await reloadAgentTimelineFromPersistedReplica(page, agent);
hydration.release();
await scrollTimelineUntilOlderHistoryIsReachable(page, agent.oldestPrompt);
await expectTimelinePromptVisible(page, agent.oldestPrompt);
} finally {
@@ -29,18 +99,27 @@ test.describe("Agent timeline pagination", () => {
}
});
test("loads older history when the initial page does not fill the viewport", async ({ page }) => {
test("preserves a live row received before replica hydration", async ({ page }) => {
test.setTimeout(120_000);
const agent = await seedLongMockAgentTimeline({ turns: 30 });
const agent = await seedLongMockAgentTimeline({ turns: 80 });
try {
await makeLoadedTimelineFitViewport(page);
const olderPage = await holdNextOlderTimelinePage(page, agent);
await openAgentTimeline(page, agent);
await expectTimelinePromptVisible(page, agent.newestPrompt);
await expectLoadedTimelineDoesNotScroll(page);
await olderPage.expectLoading();
olderPage.release();
await scrollTimelineUntilOlderHistoryIsReachable(page, agent.oldestPrompt);
const hydration = await holdBootstrapTimelinePage(page, agent);
await reloadAgentTimelineFromPersistedReplica(page, agent);
await hydration.waitForDelayedResponse();
const livePrompt = await sendLiveTurnBeforeHydration(agent);
await expectTimelinePromptVisible(page, livePrompt);
hydration.release();
await hydration.waitForDelayedCatchUp();
await expectTimelinePromptVisible(page, livePrompt);
hydration.releaseCatchUp();
await scrollTimelineUntilOlderHistoryIsReachable(page, agent.oldestPrompt);
await expectTimelinePromptVisible(page, agent.oldestPrompt);
await scrollTimelineToNewestLoadedEdge(page);
await expectTimelinePromptVisible(page, livePrompt);
} finally {
await agent.cleanup();
}
+178 -1
View File
@@ -15,6 +15,21 @@ export interface AgentTimelineResponseGate {
waitForDelayedResponse(): Promise<void>;
}
export interface OlderTimelinePagesGate {
getRequestCount(): number;
releasePage(pageNumber: number): void;
waitForRequestCount(count: number): Promise<void>;
}
export interface DaemonHydrationGate {
release(): void;
}
export interface BootstrapTimelineGate extends AgentTimelineResponseGate {
releaseCatchUp(): void;
waitForDelayedCatchUp(): Promise<void>;
}
function parseWebSocketJson(message: WebSocketMessage): unknown {
const rawMessage = typeof message === "string" ? message : message.toString("utf8");
try {
@@ -45,6 +60,32 @@ function getPayload(message: Record<string, unknown>): Record<string, unknown> |
: null;
}
export async function holdDaemonHydration(page: Page): Promise<DaemonHydrationGate> {
let released = false;
const delayedForwards: Array<() => void> = [];
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
ws.onMessage((message) => server.send(message));
server.onMessage((message) => {
if (released) {
ws.send(message);
return;
}
delayedForwards.push(() => ws.send(message));
});
});
return {
release() {
released = true;
for (const forward of delayedForwards.splice(0)) {
forward();
}
},
};
}
export async function delayCreatedAgentInitialTailResponse(
page: Page,
): Promise<CreatedAgentTimelineGate> {
@@ -127,6 +168,142 @@ export async function delayCreatedAgentInitialTailResponse(
export async function delayAgentOlderTimelineResponse(
page: Page,
agentId: string,
): Promise<AgentTimelineResponseGate> {
return delayAgentTimelineResponse(page, agentId, "before");
}
export async function holdAgentOlderTimelinePages(
page: Page,
agentId: string,
): Promise<OlderTimelinePagesGate> {
let requestCount = 0;
let responseCount = 0;
const releasedPages = new Set<number>();
const delayedForwards = new Map<number, Array<() => void>>();
const requestWaiters = new Map<number, Array<() => void>>();
const resolveRequestWaiters = () => {
for (const [count, resolvers] of requestWaiters) {
if (requestCount < count) continue;
requestWaiters.delete(count);
for (const resolve of resolvers) resolve();
}
};
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
ws.onMessage((message) => {
const sessionMessage = getSessionMessage(message);
if (
sessionMessage?.type === "fetch_agent_timeline_request" &&
sessionMessage.agentId === agentId &&
sessionMessage.direction === "before"
) {
requestCount += 1;
resolveRequestWaiters();
}
server.send(message);
});
server.onMessage((message) => {
const sessionMessage = getSessionMessage(message);
const payload = sessionMessage ? getPayload(sessionMessage) : null;
if (
sessionMessage?.type === "fetch_agent_timeline_response" &&
payload?.agentId === agentId &&
payload.direction === "before"
) {
responseCount += 1;
const pageNumber = responseCount;
if (releasedPages.has(pageNumber)) {
ws.send(message);
return;
}
const forwards = delayedForwards.get(pageNumber) ?? [];
forwards.push(() => ws.send(message));
delayedForwards.set(pageNumber, forwards);
return;
}
ws.send(message);
});
});
return {
getRequestCount: () => requestCount,
releasePage(pageNumber) {
releasedPages.add(pageNumber);
for (const forward of delayedForwards.get(pageNumber) ?? []) forward();
delayedForwards.delete(pageNumber);
},
waitForRequestCount(count) {
if (requestCount >= count) return Promise.resolve();
return new Promise<void>((resolve) => {
const resolvers = requestWaiters.get(count) ?? [];
resolvers.push(resolve);
requestWaiters.set(count, resolvers);
});
},
};
}
export async function delayAgentBootstrapTailResponse(
page: Page,
agentId: string,
): Promise<BootstrapTimelineGate> {
let tailReleased = false;
let catchUpReleased = false;
const delayedTailForwards: Array<() => void> = [];
const delayedCatchUpForwards: Array<() => void> = [];
let resolveDelayedTail: (() => void) | null = null;
let resolveDelayedCatchUp: (() => void) | null = null;
const delayedTail = new Promise<void>((resolve) => {
resolveDelayedTail = resolve;
});
const delayedCatchUp = new Promise<void>((resolve) => {
resolveDelayedCatchUp = resolve;
});
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
ws.onMessage((message) => server.send(message));
server.onMessage((message) => {
const sessionMessage = getSessionMessage(message);
const payload = sessionMessage ? getPayload(sessionMessage) : null;
const isTimelineResponse =
sessionMessage?.type === "fetch_agent_timeline_response" && payload?.agentId === agentId;
if (isTimelineResponse && payload.direction === "tail") {
resolveDelayedTail?.();
if (tailReleased) ws.send(message);
else delayedTailForwards.push(() => ws.send(message));
return;
}
if (isTimelineResponse && payload.direction === "after") {
resolveDelayedCatchUp?.();
if (catchUpReleased) ws.send(message);
else delayedCatchUpForwards.push(() => ws.send(message));
return;
}
ws.send(message);
});
});
return {
release() {
tailReleased = true;
for (const forward of delayedTailForwards.splice(0)) forward();
},
releaseCatchUp() {
catchUpReleased = true;
for (const forward of delayedCatchUpForwards.splice(0)) forward();
},
waitForDelayedResponse: () => delayedTail,
waitForDelayedCatchUp: () => delayedCatchUp,
};
}
async function delayAgentTimelineResponse(
page: Page,
agentId: string,
direction: "before" | "tail",
): Promise<AgentTimelineResponseGate> {
let releaseRequested = false;
let delayedResponseSeen = false;
@@ -148,7 +325,7 @@ export async function delayAgentOlderTimelineResponse(
!delayedResponseSeen &&
sessionMessage?.type === "fetch_agent_timeline_response" &&
payload?.agentId === agentId &&
payload.direction === "before"
payload.direction === direction
) {
delayedResponseSeen = true;
resolveDelayedResponse?.();
+240 -22
View File
@@ -1,10 +1,17 @@
import { expect, type Page } from "@playwright/test";
import { buildAgentRoute, seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent";
import {
delayAgentBootstrapTailResponse,
delayAgentOlderTimelineResponse,
holdDaemonHydration,
holdAgentOlderTimelinePages,
type AgentTimelineResponseGate,
type BootstrapTimelineGate,
type OlderTimelinePagesGate,
} from "./agent-timeline-gate";
export { holdDaemonHydration };
interface LongTimelineAgentOptions {
turns: number;
}
@@ -15,6 +22,24 @@ interface LongTimelineAgent extends MockAgentWorkspace {
}
const PROMPT_PREFIX = "timeline-pagination-turn";
const LIVE_BEFORE_HYDRATION_PROMPT = "timeline live before authoritative hydration";
const HISTORY_START_THRESHOLD_PX = 96;
interface TimelineViewportSnapshot {
scrollHeight: number;
scrollTop: number;
}
interface OlderHistoryLoadingOperation {
animationStartTime: number | null;
marker: string;
}
interface OlderHistoryPages {
expectRequestedPages(count: number): Promise<void>;
expectSettledWithRequestedPages(count: number): Promise<void>;
releasePage(pageNumber: number): void;
}
function promptForTurn(index: number): string {
return `${PROMPT_PREFIX}-${index}: emit 1 coalesced agent stream updates`;
@@ -50,15 +75,17 @@ export async function openAgentTimeline(page: Page, agent: LongTimelineAgent): P
}
export async function expectTimelinePromptVisible(page: Page, prompt: string): Promise<void> {
await expect(page.getByText(prompt, { exact: true })).toBeVisible({ timeout: 30_000 });
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
await expect(timeline.getByText(prompt, { exact: true })).toBeVisible({ timeout: 30_000 });
}
export async function expectTimelinePromptNotMounted(page: Page, prompt: string): Promise<void> {
await expect(page.getByText(prompt, { exact: true })).toHaveCount(0);
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
await expect(timeline.getByText(prompt, { exact: true })).toHaveCount(0);
}
export async function makeLoadedTimelineFitViewport(page: Page): Promise<void> {
await page.setViewportSize({ width: 1280, height: 8_000 });
await page.setViewportSize({ width: 1280, height: 20_000 });
}
export async function expectLoadedTimelineDoesNotScroll(page: Page): Promise<void> {
@@ -75,6 +102,33 @@ export async function expectLoadedTimelineDoesNotScroll(page: Page): Promise<voi
.toBe(true);
}
export async function reloadAgentTimelineFromPersistedReplica(
page: Page,
agent: LongTimelineAgent,
): Promise<void> {
await expect
.poll(() =>
page.evaluate((agentId) => {
const raw = localStorage.getItem("@paseo:replica-cache");
if (!raw) return false;
const cache = JSON.parse(raw) as {
hosts?: Array<{
timeline?: {
agentId?: string;
items?: unknown[];
} | null;
}>;
};
const timeline = cache.hosts?.find((host) => host.timeline?.agentId === agentId)?.timeline;
return timeline?.items?.length === 50;
}, agent.agentId),
)
.toBe(true);
await page.reload();
await expectTimelinePromptVisible(page, agent.newestPrompt);
}
export async function holdNextOlderTimelinePage(
page: Page,
agent: LongTimelineAgent,
@@ -90,6 +144,106 @@ export async function holdNextOlderTimelinePage(
};
}
export async function holdOlderHistoryPages(
page: Page,
agent: LongTimelineAgent,
): Promise<OlderHistoryPages> {
const gate: OlderTimelinePagesGate = await holdAgentOlderTimelinePages(page, agent.agentId);
return {
async expectRequestedPages(count) {
await gate.waitForRequestCount(count);
expect(gate.getRequestCount()).toBe(count);
},
async expectSettledWithRequestedPages(count) {
await waitForTimelineGeometryToSettle(page);
expect(gate.getRequestCount()).toBe(count);
},
releasePage(pageNumber) {
gate.releasePage(pageNumber);
},
};
}
export async function rememberTimelineViewport(page: Page): Promise<TimelineViewportSnapshot> {
return readTimelineViewport(page);
}
export async function expectTimelineViewportAnchoredAfterPrepend(
page: Page,
before: TimelineViewportSnapshot,
): Promise<void> {
await expect
.poll(async () => (await readTimelineViewport(page)).scrollHeight)
.toBeGreaterThan(before.scrollHeight);
await waitForTimelineGeometryToSettle(page);
const after = await readTimelineViewport(page);
const contentGrowth = after.scrollHeight - before.scrollHeight;
const scrollAdjustment = after.scrollTop - before.scrollTop;
expect(Math.abs(contentGrowth - scrollAdjustment)).toBeLessThanOrEqual(2);
}
export async function rememberOlderHistoryLoadingOperation(
page: Page,
): Promise<OlderHistoryLoadingOperation> {
const slot = page.getByTestId("load-older-history-spinner");
await expect(slot).toBeVisible();
const marker = `older-history-loading-${Date.now()}`;
await slot.evaluate((element, operationMarker) => {
const candidates = [element, ...Array.from(element.querySelectorAll("*"))];
const animated = candidates.find(
(candidate) => getComputedStyle(candidate).animationName !== "none",
);
if (!(animated instanceof HTMLElement)) {
throw new Error("Expected the older-history loader to contain an animated element");
}
animated.dataset.olderHistoryLoadingOperation = operationMarker;
}, marker);
const animated = page.locator(`[data-older-history-loading-operation="${marker}"]`);
await expect
.poll(async () => {
const startTime = await animated.evaluate((element) => element.getAnimations()[0]?.startTime);
return typeof startTime === "number" ? startTime : null;
})
.not.toBeNull();
const animationStartTime = await animated.evaluate((element) => {
const startTime = element.getAnimations()[0]?.startTime;
return typeof startTime === "number" ? startTime : null;
});
return { animationStartTime, marker };
}
export async function expectSameOlderHistoryLoadingOperation(
page: Page,
operation: OlderHistoryLoadingOperation,
): Promise<void> {
const animated = page.locator(`[data-older-history-loading-operation="${operation.marker}"]`);
await expect(animated).toBeVisible();
const animationStartTime = await animated.evaluate((element) => {
const startTime = element.getAnimations()[0]?.startTime;
return typeof startTime === "number" ? startTime : null;
});
expect(animationStartTime).toBe(operation.animationStartTime);
}
export async function expectTimelineAtHistoryStart(page: Page): Promise<void> {
await expect
.poll(async () => (await readTimelineViewport(page)).scrollTop)
.toBeLessThanOrEqual(HISTORY_START_THRESHOLD_PX);
}
export async function holdBootstrapTimelinePage(
page: Page,
agent: LongTimelineAgent,
): Promise<BootstrapTimelineGate> {
return delayAgentBootstrapTailResponse(page, agent.agentId);
}
export async function sendLiveTurnBeforeHydration(agent: LongTimelineAgent): Promise<string> {
await agent.client.sendAgentMessage(agent.agentId, LIVE_BEFORE_HYDRATION_PROMPT);
await agent.client.waitForFinish(agent.agentId, 15_000);
return LIVE_BEFORE_HYDRATION_PROMPT;
}
export async function scrollTimelineToOldestLoadedEdge(page: Page): Promise<void> {
const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
await scroll.hover();
@@ -109,31 +263,95 @@ export async function scrollTimelineToOldestLoadedEdge(page: Page): Promise<void
});
}
export async function scrollTimelineUntilOlderHistoryIsReachable(page: Page): Promise<void> {
export async function userScrollsTimelineToHistoryStart(page: Page): Promise<void> {
const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
const previousHeight = await scroll.evaluate((element) => {
await scroll.hover();
for (let step = 0; step < 60; step += 1) {
if ((await readTimelineViewport(page)).scrollTop <= HISTORY_START_THRESHOLD_PX) {
break;
}
await page.mouse.wheel(0, -1_000);
await page.evaluate(
() =>
new Promise<void>((resolve) => {
requestAnimationFrame(() => resolve());
}),
);
}
await expect
.poll(async () => (await readTimelineViewport(page)).scrollTop)
.toBeLessThanOrEqual(HISTORY_START_THRESHOLD_PX);
}
export async function scrollTimelineToNewestLoadedEdge(page: Page): Promise<void> {
const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
await scroll.evaluate((element) => {
if (!(element instanceof HTMLElement)) {
throw new Error("Agent chat scroll element is not an HTMLElement");
}
return element.scrollHeight;
element.scrollTop = element.scrollHeight;
element.dispatchEvent(new Event("scroll", { bubbles: true }));
});
}
await page.evaluate(
() =>
new Promise<void>((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
export async function scrollTimelineUntilOlderHistoryIsReachable(
page: Page,
oldestPrompt: string,
): Promise<void> {
const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
const prompt = scroll.getByText(oldestPrompt, { exact: true });
for (let attempt = 0; attempt < 10; attempt += 1) {
if ((await prompt.count()) > 0) {
await expect(prompt).toBeVisible();
return;
}
const previousHeight = await readTimelineViewport(page);
await userScrollsTimelineToHistoryStart(page);
await expect
.poll(async () => (await readTimelineViewport(page)).scrollHeight)
.toBeGreaterThan(previousHeight.scrollHeight);
await waitForTimelineGeometryToSettle(page);
}
await expect(prompt).toBeVisible();
}
async function readTimelineViewport(page: Page): Promise<TimelineViewportSnapshot> {
const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
return scroll.evaluate((element) => {
if (!(element instanceof HTMLElement)) {
throw new Error("Agent chat scroll element is not an HTMLElement");
}
return { scrollHeight: element.scrollHeight, scrollTop: element.scrollTop };
});
}
async function waitForTimelineGeometryToSettle(page: Page): Promise<void> {
const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
await scroll.evaluate(
(element) =>
new Promise<void>((resolve, reject) => {
if (!(element instanceof HTMLElement)) {
reject(new Error("Agent chat scroll element is not an HTMLElement"));
return;
}
const startedAt = performance.now();
let stableFrames = 0;
let previous = `${element.scrollTop}:${element.scrollHeight}`;
const sample = () => {
const current = `${element.scrollTop}:${element.scrollHeight}`;
stableFrames = current === previous ? stableFrames + 1 : 0;
previous = current;
if (stableFrames >= 4) {
resolve();
return;
}
if (performance.now() - startedAt > 5_000) {
reject(new Error("Timeline geometry did not settle"));
return;
}
requestAnimationFrame(sample);
};
requestAnimationFrame(sample);
}),
);
await scrollTimelineToOldestLoadedEdge(page);
await expect
.poll(async () =>
scroll.evaluate((element) => {
if (!(element instanceof HTMLElement)) {
throw new Error("Agent chat scroll element is not an HTMLElement");
}
return element.scrollHeight;
}),
)
.toBeGreaterThan(previousHeight);
await scrollTimelineToOldestLoadedEdge(page);
}
@@ -2,10 +2,12 @@ import { describe, expect, it } from "vitest";
import {
createHistoryStartPaginationState,
evaluateHistoryStartPagination,
rearmHistoryStartPagination,
isHistoryStartLoadingOperation,
settleHistoryStartPagination,
type HistoryStartPaginationInput,
} from "./history-start-pagination";
const visibleHistoryStart = {
const visibleHistoryStart: HistoryStartPaginationInput = {
distanceFromHistoryStart: 0,
hasOlderHistory: true,
isLoadingOlderHistory: false,
@@ -14,47 +16,110 @@ const visibleHistoryStart = {
};
describe("history start pagination", () => {
it("loads once for each authoritative history cursor", () => {
const initial = createHistoryStartPaginationState();
const first = evaluateHistoryStartPagination(initial, visibleHistoryStart);
const duplicate = evaluateHistoryStartPagination(first.state, visibleHistoryStart);
const nextPage = evaluateHistoryStartPagination(first.state, {
it("waits for anchored page geometry before authorizing another page", () => {
const first = evaluateHistoryStartPagination(
createHistoryStartPaginationState(),
visibleHistoryStart,
);
const inFlight = evaluateHistoryStartPagination(first.state, {
...visibleHistoryStart,
isLoadingOlderHistory: true,
});
const pageApplied = evaluateHistoryStartPagination(inFlight.state, {
...visibleHistoryStart,
isLoadingOlderHistory: true,
progressKey: "epoch-1:10",
});
expect([first.shouldLoad, inFlight.shouldLoad, pageApplied.shouldLoad]).toEqual([
true,
false,
false,
]);
expect(pageApplied.state).toEqual({ status: "settling", loadedProgressKey: "epoch-1:10" });
});
it("loads one page each time anchored geometry leaves and returns to history start", () => {
const first = evaluateHistoryStartPagination(
createHistoryStartPaginationState(),
visibleHistoryStart,
);
const pageApplied = evaluateHistoryStartPagination(first.state, {
...visibleHistoryStart,
progressKey: "epoch-1:10",
});
const settledAway = settleHistoryStartPagination(pageApplied.state, {
...visibleHistoryStart,
distanceFromHistoryStart: 300,
progressKey: "epoch-1:10",
});
const returned = evaluateHistoryStartPagination(settledAway.state, {
...visibleHistoryStart,
progressKey: "epoch-1:10",
});
expect([first.shouldLoad, duplicate.shouldLoad, nextPage.shouldLoad]).toEqual([
true,
false,
true,
]);
expect([
first.shouldLoad,
pageApplied.shouldLoad,
settledAway.shouldLoad,
returned.shouldLoad,
]).toEqual([true, false, false, true]);
});
it("allows the same revision again after the user leaves the history edge", () => {
it("continues the same loading operation when anchored geometry remains at history start", () => {
const first = evaluateHistoryStartPagination(
createHistoryStartPaginationState(),
visibleHistoryStart,
);
const away = evaluateHistoryStartPagination(first.state, {
const pageApplied = evaluateHistoryStartPagination(first.state, {
...visibleHistoryStart,
distanceFromHistoryStart: 200,
progressKey: "epoch-1:10",
});
const continued = settleHistoryStartPagination(pageApplied.state, {
...visibleHistoryStart,
progressKey: "epoch-1:10",
});
const returned = evaluateHistoryStartPagination(away.state, visibleHistoryStart);
expect([first.shouldLoad, away.shouldLoad, returned.shouldLoad]).toEqual([true, false, true]);
expect(continued.shouldLoad).toBe(true);
expect(isHistoryStartLoadingOperation(first.state)).toBe(true);
expect(isHistoryStartLoadingOperation(pageApplied.state)).toBe(true);
expect(isHistoryStartLoadingOperation(continued.state)).toBe(true);
});
it("re-arms the same cursor when the user makes another upward edge gesture", () => {
it("latches a request that finishes without cursor progress", () => {
const first = evaluateHistoryStartPagination(
createHistoryStartPaginationState(),
visibleHistoryStart,
);
const retried = evaluateHistoryStartPagination(
rearmHistoryStartPagination(first.state),
const inFlight = evaluateHistoryStartPagination(first.state, {
...visibleHistoryStart,
isLoadingOlderHistory: true,
});
const finished = evaluateHistoryStartPagination(inFlight.state, visibleHistoryStart);
const duplicate = evaluateHistoryStartPagination(finished.state, visibleHistoryStart);
const away = evaluateHistoryStartPagination(duplicate.state, {
...visibleHistoryStart,
distanceFromHistoryStart: 300,
});
expect(finished.state).toEqual({ status: "latched" });
expect([finished.shouldLoad, duplicate.shouldLoad, away.shouldLoad]).toEqual([
false,
false,
false,
]);
expect(away.state).toEqual({ status: "ready" });
});
it("does not mistake repeated edge observations for a finished request", () => {
const first = evaluateHistoryStartPagination(
createHistoryStartPaginationState(),
visibleHistoryStart,
);
const repeated = evaluateHistoryStartPagination(first.state, visibleHistoryStart);
expect([first.shouldLoad, retried.shouldLoad]).toEqual([true, true]);
expect(repeated.state).toBe(first.state);
expect(repeated.shouldLoad).toBe(false);
});
it("waits while history loading is unavailable or already active", () => {
@@ -1,33 +1,71 @@
export const HISTORY_START_THRESHOLD_PX = 96;
export interface HistoryStartPaginationState {
requestedProgressKey: string | null;
export type HistoryStartPaginationState =
| { status: "ready" }
| { status: "loading"; requestedProgressKey: string; requestObserved: boolean }
| { status: "settling"; loadedProgressKey: string }
| { status: "latched" };
export interface HistoryStartPaginationInput {
distanceFromHistoryStart: number;
hasOlderHistory: boolean;
isLoadingOlderHistory: boolean;
isReady: boolean;
progressKey: string | null;
}
export interface HistoryStartPaginationTransition {
state: HistoryStartPaginationState;
shouldLoad: boolean;
}
export function createHistoryStartPaginationState(): HistoryStartPaginationState {
return { requestedProgressKey: null };
return { status: "ready" };
}
export function rearmHistoryStartPagination(
_state: HistoryStartPaginationState,
): HistoryStartPaginationState {
return createHistoryStartPaginationState();
export function isHistoryStartLoadingOperation(state: HistoryStartPaginationState): boolean {
return state.status === "loading" || state.status === "settling";
}
export function evaluateHistoryStartPagination(
state: HistoryStartPaginationState,
input: {
distanceFromHistoryStart: number;
hasOlderHistory: boolean;
isLoadingOlderHistory: boolean;
isReady: boolean;
progressKey: string | null;
},
): { state: HistoryStartPaginationState; shouldLoad: boolean } {
if (input.distanceFromHistoryStart > HISTORY_START_THRESHOLD_PX) {
return { state: createHistoryStartPaginationState(), shouldLoad: false };
input: HistoryStartPaginationInput,
): HistoryStartPaginationTransition {
if (state.status === "loading") {
if (input.progressKey !== null && input.progressKey !== state.requestedProgressKey) {
return {
state: { status: "settling", loadedProgressKey: input.progressKey },
shouldLoad: false,
};
}
if (input.isLoadingOlderHistory && !state.requestObserved) {
return { state: { ...state, requestObserved: true }, shouldLoad: false };
}
if (!input.isLoadingOlderHistory && !input.hasOlderHistory) {
return { state: { status: "latched" }, shouldLoad: false };
}
if (
state.requestObserved &&
!input.isLoadingOlderHistory &&
input.progressKey === state.requestedProgressKey
) {
return { state: { status: "latched" }, shouldLoad: false };
}
return { state, shouldLoad: false };
}
if (state.status === "settling") {
return { state, shouldLoad: false };
}
const isAtHistoryStart = input.distanceFromHistoryStart <= HISTORY_START_THRESHOLD_PX;
if (!isAtHistoryStart) {
return state.status === "ready"
? { state, shouldLoad: false }
: { state: { status: "ready" }, shouldLoad: false };
}
if (
state.status === "latched" ||
!input.isReady ||
!input.hasOlderHistory ||
input.isLoadingOlderHistory ||
@@ -35,11 +73,42 @@ export function evaluateHistoryStartPagination(
) {
return { state, shouldLoad: false };
}
if (state.requestedProgressKey === input.progressKey) {
return { state, shouldLoad: false };
}
return {
state: { requestedProgressKey: input.progressKey },
state: {
status: "loading",
requestedProgressKey: input.progressKey,
requestObserved: false,
},
shouldLoad: true,
};
}
export function settleHistoryStartPagination(
state: HistoryStartPaginationState,
input: HistoryStartPaginationInput,
): HistoryStartPaginationTransition {
if (state.status !== "settling") {
return { state, shouldLoad: false };
}
const isAtHistoryStart = input.distanceFromHistoryStart <= HISTORY_START_THRESHOLD_PX;
if (
!isAtHistoryStart ||
!input.isReady ||
!input.hasOlderHistory ||
input.isLoadingOlderHistory ||
input.progressKey === null
) {
return {
state: isAtHistoryStart ? { status: "latched" } : { status: "ready" },
shouldLoad: false,
};
}
return {
state: {
status: "loading",
requestedProgressKey: input.progressKey,
requestObserved: false,
},
shouldLoad: true,
};
}
@@ -34,7 +34,10 @@ import {
import {
createHistoryStartPaginationState,
evaluateHistoryStartPagination,
rearmHistoryStartPagination,
isHistoryStartLoadingOperation,
settleHistoryStartPagination,
type HistoryStartPaginationInput,
type HistoryStartPaginationTransition,
} from "./history-start-pagination";
const DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION = Object.freeze({
@@ -53,6 +56,8 @@ const historyStartSlotStyle: ViewStyle = {
paddingTop: 4,
paddingBottom: 8,
};
const HISTORY_START_SETTLE_FRAMES = 2;
interface HistoryRowDisplayVariants {
regular?: StreamItem;
compact?: StreamItem;
@@ -116,7 +121,11 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
const [isNativeViewportSettling, setIsNativeViewportSettling] = useState(false);
const nativeViewportSettlingFrameIdRef = useRef<number | null>(null);
const historyStartReadyRef = useRef(false);
const historyStartPaginationStateRef = useRef(createHistoryStartPaginationState());
const [historyStartPaginationState, setHistoryStartPaginationState] = useState(
createHistoryStartPaginationState,
);
const historyStartPaginationStateRef = useRef(historyStartPaginationState);
const historyStartSettleFrameIdRef = useRef<number | null>(null);
const historyItems = useMemo(() => {
if (segments.historyVirtualized.length === 0) {
@@ -145,22 +154,63 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
),
[displayStateHistoryRows, historyRowRevision?.contentById],
);
const evaluateHistoryStart = useStableEvent(() => {
const getHistoryStartPaginationInput = useStableEvent((): HistoryStartPaginationInput => {
const metrics = streamViewportMetricsRef.current;
const hasMeasuredViewport =
metrics.viewportMeasuredForKey === metrics.containerKey &&
metrics.contentMeasuredForKey === metrics.containerKey;
const result = evaluateHistoryStartPagination(historyStartPaginationStateRef.current, {
return {
distanceFromHistoryStart: metrics.contentHeight - metrics.viewportHeight - metrics.offsetY,
hasOlderHistory,
isLoadingOlderHistory,
isReady: historyStartReadyRef.current && hasMeasuredViewport,
progressKey: olderHistoryProgressKey,
});
historyStartPaginationStateRef.current = result.state;
if (result.shouldLoad) {
onNearHistoryStart();
};
});
const applyHistoryStartPaginationTransition = useStableEvent(
(transition: HistoryStartPaginationTransition) => {
const previousState = historyStartPaginationStateRef.current;
historyStartPaginationStateRef.current = transition.state;
if (transition.state !== previousState) {
setHistoryStartPaginationState(transition.state);
}
if (transition.shouldLoad) {
onNearHistoryStart();
}
},
);
const evaluateHistoryStart = useStableEvent(() => {
const transition = evaluateHistoryStartPagination(
historyStartPaginationStateRef.current,
getHistoryStartPaginationInput(),
);
applyHistoryStartPaginationTransition(transition);
});
const scheduleHistoryStartSettle = useStableEvent(() => {
const pendingFrame = historyStartSettleFrameIdRef.current;
if (pendingFrame !== null) {
cancelAnimationFrame(pendingFrame);
}
let remainingFrames = HISTORY_START_SETTLE_FRAMES;
const settle = () => {
if (historyStartPaginationStateRef.current.status !== "settling") {
historyStartSettleFrameIdRef.current = null;
return;
}
const input = getHistoryStartPaginationInput();
if (input.isLoadingOlderHistory || remainingFrames > 0) {
remainingFrames -= input.isLoadingOlderHistory ? 0 : 1;
historyStartSettleFrameIdRef.current = requestAnimationFrame(settle);
return;
}
historyStartSettleFrameIdRef.current = null;
const transition = settleHistoryStartPagination(
historyStartPaginationStateRef.current,
input,
);
applyHistoryStartPaginationTransition(transition);
};
historyStartSettleFrameIdRef.current = requestAnimationFrame(settle);
});
const clearNativeViewportSettling = useCallback(() => {
@@ -261,7 +311,9 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
clearNativeViewportSettling();
setIsNativeViewportSettling(false);
historyStartReadyRef.current = false;
historyStartPaginationStateRef.current = createHistoryStartPaginationState();
const initialHistoryStartState = createHistoryStartPaginationState();
historyStartPaginationStateRef.current = initialHistoryStartState;
setHistoryStartPaginationState(initialHistoryStartState);
const frame = requestAnimationFrame(() => {
historyStartReadyRef.current = true;
evaluateHistoryStart();
@@ -269,6 +321,11 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
return () => {
cancelAnimationFrame(frame);
clearPendingUserScrollEnd();
const settleFrame = historyStartSettleFrameIdRef.current;
if (settleFrame !== null) {
historyStartSettleFrameIdRef.current = null;
cancelAnimationFrame(settleFrame);
}
};
}, [agentId, clearNativeViewportSettling, clearPendingUserScrollEnd, evaluateHistoryStart]);
@@ -369,16 +426,10 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
});
const handleScrollBeginDrag = useStableEvent((event: NativeSyntheticEvent<NativeScrollEvent>) => {
if (!isLoadingOlderHistory) {
historyStartPaginationStateRef.current = rearmHistoryStartPagination(
historyStartPaginationStateRef.current,
);
}
clearPendingUserScrollEnd();
isUserScrollActiveRef.current = true;
scrollKeyboardDismiss.onScrollBeginDrag(event);
bottomAnchorController.beginUserScroll();
evaluateHistoryStart();
});
// Defer drag end so momentum can take ownership, but capture the terminal
@@ -454,11 +505,23 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
contentHeight: nextContentHeight,
});
evaluateHistoryStart();
if (historyStartPaginationStateRef.current.status === "settling") {
scheduleHistoryStartSettle();
}
});
useEffect(() => {
evaluateHistoryStart();
}, [evaluateHistoryStart, hasOlderHistory, isLoadingOlderHistory, olderHistoryProgressKey]);
if (historyStartPaginationStateRef.current.status === "settling") {
scheduleHistoryStartSettle();
}
}, [
evaluateHistoryStart,
hasOlderHistory,
isLoadingOlderHistory,
olderHistoryProgressKey,
scheduleHistoryStartSettle,
]);
const renderItem = useStableEvent(
({ item, index }: ListRenderItemInfo<StreamItem>): ReactElement | null => {
@@ -499,20 +562,21 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
]);
const historyFooterContent = useMemo(() => {
if (!hasOlderHistory && !isLoadingOlderHistory) {
const isLoadingOperation = isHistoryStartLoadingOperation(historyStartPaginationState);
if (!hasOlderHistory && !isLoadingOperation) {
return null;
}
return (
<View
style={historyStartSlotStyle}
testID={isLoadingOlderHistory ? "load-older-history-spinner" : undefined}
testID={isLoadingOperation ? "load-older-history-spinner" : undefined}
>
{isLoadingOlderHistory ? (
{isLoadingOperation ? (
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
) : null}
</View>
);
}, [hasOlderHistory, isLoadingOlderHistory]);
}, [hasOlderHistory, historyStartPaginationState]);
// RN's FlatList strictMode keeps its internal renderItem wrapper stable when
// data or the live header changes, preserving the row identities above.
@@ -285,76 +285,6 @@ describe("createWebStreamStrategy", () => {
expect(scrollTo).not.toHaveBeenCalled();
});
it("fires near-history-start when the user scrolls near the top", async () => {
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
const viewportRef = React.createRef<StreamViewportHandle>();
const onNearHistoryStart = vi.fn();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
act(() => {
root?.render(
<>
{strategy.render({
agentId: "agent",
segments: {
historyVirtualized: [],
historyMounted: [userMessage(1), userMessage(2)],
liveHead: [],
},
boundary: {
hasVirtualizedHistory: false,
hasMountedHistory: true,
hasLiveHead: false,
},
renderers: createRenderers(vi.fn()),
listEmptyComponent: null,
viewportRef,
routeBottomAnchorRequest: null,
isAuthoritativeHistoryReady: true,
onNearBottomChange: vi.fn(),
onNearHistoryStart,
isLoadingOlderHistory: false,
hasOlderHistory: true,
olderHistoryProgressKey: "epoch-1:20",
scrollEnabled: true,
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
})}
</>,
);
});
const scrollContainer = container.querySelector('[data-testid="agent-chat-scroll"]');
if (!(scrollContainer instanceof HTMLElement)) {
throw new Error("Expected agent chat scroll container");
}
Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 400 });
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1200 });
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 64 });
await act(async () => {
await new Promise((resolve) => requestAnimationFrame(resolve));
});
expect(onNearHistoryStart).not.toHaveBeenCalled();
act(() => {
scrollContainer.dispatchEvent(new WheelEvent("wheel", { deltaY: -1 }));
scrollContainer?.dispatchEvent(new Event("scroll"));
});
expect(onNearHistoryStart).toHaveBeenCalledTimes(1);
act(() => {
scrollContainer.dispatchEvent(new WheelEvent("wheel", { deltaY: -1 }));
});
expect(onNearHistoryStart).toHaveBeenCalledTimes(2);
});
it("waits for bottom anchoring before evaluating a delayed initial tail", async () => {
HTMLElement.prototype.scrollTo = vi.fn(function (
this: HTMLElement,
+161 -29
View File
@@ -19,13 +19,22 @@ import { createStreamStrategy } from "./strategy";
import {
createHistoryStartPaginationState,
evaluateHistoryStartPagination,
rearmHistoryStartPagination,
isHistoryStartLoadingOperation,
settleHistoryStartPagination,
type HistoryStartPaginationInput,
type HistoryStartPaginationTransition,
} from "./history-start-pagination";
interface CreateWebStreamStrategyInput {
isMobileBreakpoint: boolean;
}
interface HistoryStartPrependAnchor {
progressKey: string;
scrollHeight: number;
scrollTop: number;
}
type ScrollBehaviorLike = "auto" | "smooth";
const WEB_BOTTOM_SETTLE_TIMEOUT_MS = 200;
@@ -33,6 +42,7 @@ const USER_SCROLL_DELTA_EPSILON = 1;
const BOTTOM_OVERSCROLL_TOLERANCE_PX = 2;
const AUTO_SCROLL_BOTTOM_THRESHOLD_PX = 64;
const AUTO_SCROLL_RESUME_THRESHOLD_PX = 1;
const HISTORY_START_SETTLE_FRAMES = 2;
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const foregroundMutedColorMapping = (theme: Theme) => ({
@@ -146,7 +156,13 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
const pendingAutoScrollTimeoutRef = useRef<number | null>(null);
const pendingVirtualRowMeasureFramesRef = useRef(new Map<Element, number>());
const historyStartReadyRef = useRef(false);
const historyStartPaginationStateRef = useRef(createHistoryStartPaginationState());
const [historyStartPaginationState, setHistoryStartPaginationState] = useState(
createHistoryStartPaginationState,
);
const historyStartPaginationStateRef = useRef(historyStartPaginationState);
const historyStartPrependAnchorRef = useRef<HistoryStartPrependAnchor | null>(null);
const historyStartPrependAnchorActiveRef = useRef(false);
const historyStartSettleFrameRef = useRef<number | null>(null);
const shouldUseVirtualizer = segments.historyVirtualized.length > 0;
const {
renderHistoryVirtualizedRow,
@@ -176,6 +192,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
});
useEffect(() => {
rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = (_item, _delta, instance) => {
if (historyStartPrependAnchorActiveRef.current) {
return false;
}
const viewportHeight = instance.scrollRect?.height ?? 0;
const scrollOffset = instance.scrollOffset ?? 0;
const remainingDistance = instance.getTotalSize() - (scrollOffset + viewportHeight);
@@ -187,25 +206,130 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
}, [rowVirtualizer]);
const virtualRows = rowVirtualizer.getVirtualItems();
const virtualTotalSize = rowVirtualizer.getTotalSize();
const evaluateHistoryStart = useStableEvent(() => {
const getHistoryStartPaginationInput = useStableEvent((): HistoryStartPaginationInput | null => {
const scrollContainer = scrollContainerRef.current;
if (!scrollContainer) {
return;
return null;
}
const bottomAnchorSettled =
!followOutputRef.current || isScrollContainerNearBottom(scrollContainer);
const result = evaluateHistoryStartPagination(historyStartPaginationStateRef.current, {
return {
distanceFromHistoryStart: scrollContainer.scrollTop,
hasOlderHistory,
isLoadingOlderHistory,
isReady: historyStartReadyRef.current && bottomAnchorSettled,
progressKey: olderHistoryProgressKey,
});
historyStartPaginationStateRef.current = result.state;
if (result.shouldLoad) {
onNearHistoryStart();
}
};
});
const applyHistoryStartPaginationTransition = useStableEvent(
(transition: HistoryStartPaginationTransition) => {
const previousState = historyStartPaginationStateRef.current;
historyStartPaginationStateRef.current = transition.state;
if (transition.state !== previousState) {
setHistoryStartPaginationState(transition.state);
}
if (!isHistoryStartLoadingOperation(transition.state)) {
historyStartPrependAnchorRef.current = null;
historyStartPrependAnchorActiveRef.current = false;
}
if (!transition.shouldLoad || olderHistoryProgressKey === null) {
return;
}
const scrollContainer = scrollContainerRef.current;
if (!scrollContainer) {
return;
}
historyStartPrependAnchorRef.current = {
progressKey: olderHistoryProgressKey,
scrollHeight: scrollContainer.scrollHeight,
scrollTop: scrollContainer.scrollTop,
};
historyStartPrependAnchorActiveRef.current = false;
onNearHistoryStart();
},
);
const evaluateHistoryStart = useStableEvent(() => {
const input = getHistoryStartPaginationInput();
if (!input) {
return;
}
const transition = evaluateHistoryStartPagination(
historyStartPaginationStateRef.current,
input,
);
applyHistoryStartPaginationTransition(transition);
});
const applyHistoryStartPrependAnchor = useStableEvent(() => {
const scrollContainer = scrollContainerRef.current;
const anchor = historyStartPrependAnchorRef.current;
if (!scrollContainer || !anchor || !historyStartPrependAnchorActiveRef.current) {
return;
}
const contentGrowth = scrollContainer.scrollHeight - anchor.scrollHeight;
scrollContainer.scrollTop = anchor.scrollTop + contentGrowth;
lastKnownScrollTopRef.current = scrollContainer.scrollTop;
});
const scheduleHistoryStartPrependSettle = useStableEvent(() => {
const pendingFrame = historyStartSettleFrameRef.current;
if (pendingFrame !== null) {
window.cancelAnimationFrame(pendingFrame);
}
let stableFrames = 0;
let previousGeometry = "";
const sample = () => {
applyHistoryStartPrependAnchor();
const scrollContainer = scrollContainerRef.current;
const input = getHistoryStartPaginationInput();
if (!scrollContainer || !input || !historyStartPrependAnchorActiveRef.current) {
historyStartSettleFrameRef.current = null;
return;
}
const geometry = `${scrollContainer.scrollHeight}:${scrollContainer.scrollTop}`;
const hasPendingRowMeasurements = pendingVirtualRowMeasureFramesRef.current.size > 0;
if (
input.isLoadingOlderHistory ||
hasPendingRowMeasurements ||
geometry !== previousGeometry
) {
stableFrames = 0;
} else {
stableFrames += 1;
}
previousGeometry = geometry;
if (stableFrames < HISTORY_START_SETTLE_FRAMES) {
historyStartSettleFrameRef.current = window.requestAnimationFrame(sample);
return;
}
historyStartSettleFrameRef.current = null;
historyStartPrependAnchorActiveRef.current = false;
const transition = settleHistoryStartPagination(
historyStartPaginationStateRef.current,
input,
);
historyStartPrependAnchorRef.current = null;
applyHistoryStartPaginationTransition(transition);
};
historyStartSettleFrameRef.current = window.requestAnimationFrame(sample);
});
useLayoutEffect(() => {
const anchor = historyStartPrependAnchorRef.current;
if (!anchor || anchor.progressKey === olderHistoryProgressKey) {
return;
}
historyStartPrependAnchorActiveRef.current = true;
evaluateHistoryStart();
applyHistoryStartPrependAnchor();
scheduleHistoryStartPrependSettle();
}, [
applyHistoryStartPrependAnchor,
evaluateHistoryStart,
olderHistoryProgressKey,
scheduleHistoryStartPrependSettle,
segments.historyMounted,
segments.historyVirtualized,
virtualTotalSize,
]);
const measureVirtualizedRowElement = useCallback(
(node: HTMLDivElement | null) => {
@@ -335,7 +459,11 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
}, [cancelPendingStickToBottom, evaluateHistoryStart, updateScrollMetrics]);
useEffect(() => {
historyStartPaginationStateRef.current = createHistoryStartPaginationState();
const initialHistoryStartState = createHistoryStartPaginationState();
historyStartPaginationStateRef.current = initialHistoryStartState;
setHistoryStartPaginationState(initialHistoryStartState);
historyStartPrependAnchorRef.current = null;
historyStartPrependAnchorActiveRef.current = false;
const frame = window.requestAnimationFrame(() => {
historyStartReadyRef.current = true;
evaluateHistoryStart();
@@ -343,6 +471,11 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
return () => {
window.cancelAnimationFrame(frame);
historyStartReadyRef.current = false;
const settleFrame = historyStartSettleFrameRef.current;
if (settleFrame !== null) {
historyStartSettleFrameRef.current = null;
window.cancelAnimationFrame(settleFrame);
}
};
}, [evaluateHistoryStart, props.agentId]);
@@ -423,6 +556,10 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
updateScrollMetrics();
evaluateHistoryStart();
const observer = new ResizeObserver(() => {
if (historyStartPrependAnchorActiveRef.current) {
applyHistoryStartPrependAnchor();
scheduleHistoryStartPrependSettle();
}
updateScrollMetrics();
evaluateHistoryStart();
if (!followOutputRef.current) {
@@ -437,7 +574,13 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
return () => {
observer.disconnect();
};
}, [evaluateHistoryStart, scheduleStickToBottom, updateScrollMetrics]);
}, [
applyHistoryStartPrependAnchor,
evaluateHistoryStart,
scheduleHistoryStartPrependSettle,
scheduleStickToBottom,
updateScrollMetrics,
]);
useEffect(() => {
const scrollContainer = scrollContainerRef.current;
@@ -447,14 +590,8 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
const handleWheel = (event: WheelEvent) => {
if (event.deltaY < 0) {
if (!isLoadingOlderHistory) {
historyStartPaginationStateRef.current = rearmHistoryStartPagination(
historyStartPaginationStateRef.current,
);
}
pendingUserScrollUpIntentRef.current = true;
cancelPendingStickToBottom();
evaluateHistoryStart();
}
};
const handlePointerDown = () => {
@@ -477,14 +614,8 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
}
const previousTouchY = lastTouchClientYRef.current;
if (previousTouchY !== null && touch.clientY > previousTouchY + 1) {
if (!isLoadingOlderHistory) {
historyStartPaginationStateRef.current = rearmHistoryStartPagination(
historyStartPaginationStateRef.current,
);
}
pendingUserScrollUpIntentRef.current = true;
cancelPendingStickToBottom();
evaluateHistoryStart();
}
lastTouchClientYRef.current = touch.clientY;
};
@@ -513,7 +644,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
scrollContainer.removeEventListener("touchend", handleTouchEnd);
scrollContainer.removeEventListener("touchcancel", handleTouchEnd);
};
}, [cancelPendingStickToBottom, evaluateHistoryStart, handleDomScroll, isLoadingOlderHistory]);
}, [cancelPendingStickToBottom, handleDomScroll]);
useEffect(() => {
const handle: StreamViewportHandle = {
@@ -595,20 +726,21 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
return renderLiveAuxiliary();
}, [renderLiveAuxiliary]);
const historyStartSlot = useMemo(() => {
if (!hasOlderHistory && !isLoadingOlderHistory) {
const isLoadingOperation = isHistoryStartLoadingOperation(historyStartPaginationState);
if (!hasOlderHistory && !isLoadingOperation) {
return null;
}
return (
<div
style={historyStartSlotStyle}
data-testid={isLoadingOlderHistory ? "load-older-history-spinner" : undefined}
data-testid={isLoadingOperation ? "load-older-history-spinner" : undefined}
>
{isLoadingOlderHistory ? (
{isLoadingOperation ? (
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
) : null}
</div>
);
}, [hasOlderHistory, isLoadingOlderHistory]);
}, [hasOlderHistory, historyStartPaginationState]);
const shouldRenderEmpty =
!boundary.hasMountedHistory &&
!boundary.hasVirtualizedHistory &&
@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import { createAssistantImageAcquisitionCache } from "./acquisition-cache";
describe("assistant image acquisition cache", () => {
it("evicts a rejected acquisition so the next request can retry", async () => {
const cache = createAssistantImageAcquisitionCache<string>({ capacity: 2 });
let attempts = 0;
await expect(
cache.acquire("image", async () => {
attempts += 1;
throw new Error("first attempt failed");
}),
).rejects.toThrow("first attempt failed");
const recovered = await cache.acquire("image", async () => {
attempts += 1;
return "recovered";
});
expect({ attempts, recovered, size: cache.size() }).toEqual({
attempts: 2,
recovered: "recovered",
size: 1,
});
});
it("bounds successful acquisitions and evicts the least recently used entry", async () => {
const cache = createAssistantImageAcquisitionCache<string>({ capacity: 2 });
const located: string[] = [];
const locate = async (key: string) => {
located.push(key);
return key;
};
await cache.acquire("a", async () => await locate("a"));
await cache.acquire("b", async () => await locate("b"));
await cache.acquire("a", async () => await locate("a-again"));
await cache.acquire("c", async () => await locate("c"));
await cache.acquire("b", async () => await locate("b-again"));
expect({ located, size: cache.size() }).toEqual({
located: ["a", "b", "c", "b-again"],
size: 2,
});
});
});
@@ -0,0 +1,45 @@
export interface AssistantImageAcquisitionCache<T> {
acquire(key: string, locate: () => Promise<T>): Promise<T>;
size(): number;
}
export function createAssistantImageAcquisitionCache<T>(input: {
capacity: number;
}): AssistantImageAcquisitionCache<T> {
if (!Number.isInteger(input.capacity) || input.capacity < 1) {
throw new Error("Assistant image acquisition cache capacity must be a positive integer.");
}
const entries = new Map<string, Promise<T>>();
return {
acquire(key, locate) {
const cached = entries.get(key);
if (cached) {
entries.delete(key);
entries.set(key, cached);
return cached;
}
const pending = locate();
entries.set(key, pending);
if (entries.size > input.capacity) {
const leastRecentlyUsedKey = entries.keys().next().value;
if (leastRecentlyUsedKey !== undefined) {
entries.delete(leastRecentlyUsedKey);
}
}
void (async () => {
try {
await pending;
} catch {
if (entries.get(key) === pending) {
entries.delete(key);
}
}
})();
return pending;
},
size() {
return entries.size;
},
};
}
@@ -0,0 +1,99 @@
import { describe, expect, it } from "vitest";
import { createAssistantImageLifecycle, transitionAssistantImageLifecycle } from "./lifecycle";
describe("assistant image lifecycle", () => {
it("keeps preview URL recreation in the public loading state", () => {
const loading = transitionAssistantImageLifecycle(createAssistantImageLifecycle(), {
type: "preview_created",
uri: "blob:first",
aspectRatio: null,
});
const loaded = transitionAssistantImageLifecycle(loading, {
type: "image_loaded",
uri: "blob:first",
aspectRatio: 1.5,
});
const recreating = transitionAssistantImageLifecycle(loaded, {
type: "preview_released",
});
expect(recreating).toEqual({ status: "loading", uri: null, aspectRatio: null });
});
it("publishes loaded only after the recreated URL loads", () => {
const recreating = transitionAssistantImageLifecycle(createAssistantImageLifecycle(), {
type: "preview_created",
uri: "blob:recreated",
aspectRatio: 1.5,
});
expect(recreating).toEqual({
status: "loading",
uri: "blob:recreated",
aspectRatio: 1.5,
});
const recreated = transitionAssistantImageLifecycle(recreating, {
type: "image_loaded",
uri: "blob:recreated",
aspectRatio: 0.75,
});
expect(recreated).toEqual({
status: "loaded",
uri: "blob:recreated",
aspectRatio: 0.75,
});
});
it("publishes failed for a terminal image failure on the current URI", () => {
const loading = transitionAssistantImageLifecycle(createAssistantImageLifecycle(), {
type: "preview_created",
uri: "blob:current",
aspectRatio: null,
});
const failed = transitionAssistantImageLifecycle(loading, {
type: "failed",
uri: "blob:current",
message: "Unable to load image preview.",
});
expect(failed).toEqual({
status: "failed",
message: "Unable to load image preview.",
});
});
it("ignores a stale load callback from a replaced URI", () => {
const current = transitionAssistantImageLifecycle(createAssistantImageLifecycle(), {
type: "preview_created",
uri: "blob:current",
aspectRatio: 1.25,
});
const afterStaleLoad = transitionAssistantImageLifecycle(current, {
type: "image_loaded",
uri: "blob:released",
aspectRatio: 2,
});
expect(afterStaleLoad).toEqual(current);
});
it("ignores a stale error callback from a replaced URI", () => {
const current = transitionAssistantImageLifecycle(createAssistantImageLifecycle(), {
type: "preview_created",
uri: "blob:current",
aspectRatio: null,
});
const afterStaleError = transitionAssistantImageLifecycle(current, {
type: "failed",
uri: "blob:released",
message: "Image unavailable",
});
expect(afterStaleError).toEqual(current);
});
});
@@ -0,0 +1,43 @@
export type AssistantImageLifecycle =
| { status: "loading"; uri: string | null; aspectRatio: number | null }
| { status: "loaded"; uri: string; aspectRatio: number }
| { status: "failed"; message: string };
export type AssistantImageLifecycleEvent =
| { type: "preview_created"; uri: string; aspectRatio: number | null }
| { type: "preview_released" }
| { type: "image_loaded"; uri: string; aspectRatio: number }
| { type: "failed"; uri: string; message: string };
export function createAssistantImageLifecycle(): AssistantImageLifecycle {
return { status: "loading", uri: null, aspectRatio: null };
}
export function transitionAssistantImageLifecycle(
state: AssistantImageLifecycle,
event: AssistantImageLifecycleEvent,
): AssistantImageLifecycle {
if (event.type === "image_loaded") {
if (state.status !== "loading" || state.uri !== event.uri) {
return state;
}
return {
status: "loaded",
uri: event.uri,
aspectRatio: event.aspectRatio,
};
}
if (event.type === "failed") {
if (state.status === "failed" || state.uri !== event.uri) {
return state;
}
return { status: "failed", message: event.message };
}
if (event.type === "preview_created") {
return { status: "loading", uri: event.uri, aspectRatio: event.aspectRatio };
}
if (event.type === "preview_released") {
return { status: "loading", uri: null, aspectRatio: null };
}
return state;
}
@@ -0,0 +1,407 @@
import { useCallback, useEffect, useMemo, useReducer, useState } from "react";
import { Image } from "react-native";
import { useTranslation } from "react-i18next";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { AttachmentMetadata } from "@/attachments/types";
import {
persistAttachmentFromBytes,
persistAttachmentFromDataUrl,
releaseAttachmentPreviewUrl,
resolveAttachmentPreviewUrl,
} from "@/attachments/service";
import {
createPreviewAttachmentId,
getFileNameFromPath,
parseImageDataUrl,
} from "@/attachments/utils";
import {
getAssistantImageMetadata,
setAssistantImageMetadata,
} from "@/utils/assistant-image-metadata";
import { resolveAssistantImageSource } from "@/utils/assistant-image-source";
import type { AssistantImageSourceResolution } from "@/utils/assistant-image-source";
import { createAssistantImageAcquisitionCache } from "./acquisition-cache";
import {
createAssistantImageLifecycle,
transitionAssistantImageLifecycle,
type AssistantImageLifecycle,
type AssistantImageLifecycleEvent,
} from "./lifecycle";
interface AssistantImageRenderBinding {
uri: string;
onLoad: () => void;
onError: () => void;
}
export type AssistantImageResult =
| {
status: "loading";
binding: AssistantImageRenderBinding | null;
aspectRatio: number | null;
}
| {
status: "loaded";
binding: AssistantImageRenderBinding;
aspectRatio: number;
}
| { status: "failed"; message: string };
interface UseAssistantImageInput {
source: string;
client?: DaemonClient | null;
workspaceRoot?: string;
serverId?: string;
}
type PreviewUrlState =
| { status: "waiting" }
| { status: "loading" }
| { status: "loaded"; uri: string }
| { status: "failed"; error: unknown };
type AttachmentAcquisitionState =
| { status: "waiting" }
| { status: "loading" }
| { status: "loaded"; attachment: AttachmentMetadata }
| { status: "failed"; error: unknown };
interface AttachmentAcquisition {
key: string;
locate: () => Promise<AttachmentMetadata>;
}
interface DataImage {
mimeType: string;
base64: string;
cacheKey: string;
}
const attachmentAcquisitionCache = createAssistantImageAcquisitionCache<AttachmentMetadata>({
capacity: 500,
});
function acquireAttachment(acquisition: AttachmentAcquisition): Promise<AttachmentMetadata> {
return attachmentAcquisitionCache.acquire(acquisition.key, acquisition.locate);
}
function useAttachmentAcquisition(
acquisition: AttachmentAcquisition | null,
): AttachmentAcquisitionState {
const [state, setState] = useState<AttachmentAcquisitionState>({ status: "waiting" });
useEffect(() => {
let disposed = false;
if (!acquisition) {
setState({ status: "waiting" });
return;
}
setState({ status: "loading" });
void (async () => {
try {
const attachment = await acquireAttachment(acquisition);
if (!disposed) {
setState({ status: "loaded", attachment });
}
} catch (error) {
if (!disposed) {
setState({ status: "failed", error });
}
}
})();
return () => {
disposed = true;
};
}, [acquisition]);
return state;
}
function createFileAcquisition(input: {
client?: DaemonClient | null;
resolution: AssistantImageSourceResolution | null;
serverId?: string;
unavailableMessage: string;
}): AttachmentAcquisition | null {
if (!input.client || input.resolution?.kind !== "file_rpc") {
return null;
}
const { client, resolution } = input;
return {
key: `file:${input.serverId ?? "unknown-server"}:${resolution.cwd}:${resolution.path}`,
locate: async () => {
const file = await client.readFile(resolution.cwd, resolution.path);
if (file.kind !== "image") {
throw new Error(input.unavailableMessage);
}
return await persistAttachmentFromBytes({
id: createPreviewAttachmentId({
mimeType: file.mime,
path: file.path || resolution.path,
size: file.size,
modifiedAt: file.modifiedAt,
contentLength: file.bytes.byteLength,
}),
bytes: file.bytes,
mimeType: file.mime,
fileName: getFileNameFromPath(file.path || resolution.path),
});
},
};
}
function createDataImageAcquisition(input: {
source: string;
dataImage: DataImage | null;
}): AttachmentAcquisition | null {
if (!input.dataImage) {
return null;
}
const { dataImage, source } = input;
return {
key: dataImage.cacheKey,
locate: async () =>
await persistAttachmentFromDataUrl({
id: createPreviewAttachmentId({
mimeType: dataImage.mimeType,
contentLength: dataImage.base64.length,
}),
dataUrl: source,
mimeType: dataImage.mimeType,
}),
};
}
function usePreviewUrl(attachment: AttachmentMetadata | null | undefined): PreviewUrlState {
const [state, setState] = useState<PreviewUrlState>({ status: "waiting" });
const id = attachment?.id;
const storageType = attachment?.storageType;
const storageKey = attachment?.storageKey;
const mimeType = attachment?.mimeType;
useEffect(() => {
let disposed = false;
let previewUrl: string | null = null;
const current = attachment;
if (!current) {
setState({ status: "waiting" });
return;
}
setState({ status: "loading" });
void (async () => {
try {
const uri = await resolveAttachmentPreviewUrl(current);
if (disposed) {
await releaseAttachmentPreviewUrl({ attachment: current, url: uri });
return;
}
previewUrl = uri;
setState({ status: "loaded", uri });
} catch (error) {
if (!disposed) {
setState({ status: "failed", error });
}
}
})();
return () => {
disposed = true;
if (previewUrl) {
void releaseAttachmentPreviewUrl({ attachment: current, url: previewUrl });
}
};
}, [attachment, id, mimeType, storageKey, storageType]);
return state;
}
function lifecycleReducer(
state: AssistantImageLifecycle,
event: AssistantImageLifecycleEvent,
): AssistantImageLifecycle {
return transitionAssistantImageLifecycle(state, event);
}
function errorMessage(error: unknown, fallback: string): string {
return error instanceof Error ? error.message : fallback;
}
function getAcquisitionFailure(input: {
hasResolution: boolean;
isFileSource: boolean;
isDataImage: boolean;
fileAttachment: AttachmentAcquisitionState;
dataImageAttachment: AttachmentAcquisitionState;
preview: PreviewUrlState;
hasDirectUri: boolean;
fallbackMessage: string;
}): AssistantImageResult | null {
if (!input.hasResolution) {
return { status: "failed", message: input.fallbackMessage };
}
if (input.isFileSource && input.fileAttachment.status === "failed") {
return {
status: "failed",
message: errorMessage(input.fileAttachment.error, input.fallbackMessage),
};
}
if (input.isDataImage && input.dataImageAttachment.status === "failed") {
return {
status: "failed",
message: errorMessage(input.dataImageAttachment.error, input.fallbackMessage),
};
}
if (!input.hasDirectUri && input.preview.status === "failed") {
return {
status: "failed",
message: errorMessage(input.preview.error, input.fallbackMessage),
};
}
return null;
}
export function useAssistantImage({
source,
client,
workspaceRoot,
serverId,
}: UseAssistantImageInput): AssistantImageResult {
const { t } = useTranslation();
const resolution = useMemo(
() => resolveAssistantImageSource({ source, workspaceRoot }),
[source, workspaceRoot],
);
const dataImage = useMemo(() => parseImageDataUrl(source), [source]);
const fileAcquisition = useMemo(
() =>
createFileAcquisition({
client,
resolution,
serverId,
unavailableMessage: t("message.attachments.imagePreviewUnavailable"),
}),
[client, resolution, serverId, t],
);
const dataImageAcquisition = useMemo(
() => createDataImageAcquisition({ source, dataImage }),
[dataImage, source],
);
const fileAttachment = useAttachmentAcquisition(fileAcquisition);
const dataImageAttachment = useAttachmentAcquisition(dataImageAcquisition);
const filePreview = usePreviewUrl(
fileAttachment.status === "loaded" ? fileAttachment.attachment : null,
);
const dataImagePreview = usePreviewUrl(
dataImageAttachment.status === "loaded" ? dataImageAttachment.attachment : null,
);
const [lifecycle, dispatch] = useReducer(lifecycleReducer, undefined, () =>
createAssistantImageLifecycle(),
);
const directUri = resolution?.kind === "direct" && !dataImage ? resolution.uri : null;
const preview = dataImage ? dataImagePreview : filePreview;
const previewUri = preview.status === "loaded" ? preview.uri : null;
const uri = directUri ?? previewUri;
const cachedMetadata = useMemo(
() => getAssistantImageMetadata({ source, workspaceRoot, serverId }),
[serverId, source, workspaceRoot],
);
useEffect(() => {
if (!uri) {
dispatch({ type: "preview_released" });
return;
}
dispatch({
type: "preview_created",
uri,
aspectRatio: cachedMetadata?.aspectRatio ?? null,
});
}, [cachedMetadata, uri]);
const handleImageError = useCallback(() => {
if (uri) {
dispatch({
type: "failed",
uri,
message: t("message.attachments.imageUnavailable"),
});
}
}, [t, uri]);
const handleImageLoad = useCallback(() => {
if (!uri) {
return;
}
Image.getSize(
uri,
(width, height) => {
const metadata = setAssistantImageMetadata(
{ source, workspaceRoot, serverId },
{ width, height },
);
if (!metadata) {
dispatch({
type: "failed",
uri,
message: t("message.attachments.imageUnavailable"),
});
return;
}
dispatch({ type: "image_loaded", uri, aspectRatio: metadata.aspectRatio });
},
() => {
dispatch({
type: "failed",
uri,
message: t("message.attachments.imageUnavailable"),
});
},
);
}, [serverId, source, t, uri, workspaceRoot]);
const acquisitionFailure = getAcquisitionFailure({
hasResolution: resolution !== null,
isFileSource: resolution?.kind === "file_rpc",
isDataImage: dataImage !== null,
fileAttachment,
dataImageAttachment,
preview,
hasDirectUri: directUri !== null,
fallbackMessage: t("message.attachments.imagePreviewLoadFailed"),
});
if (acquisitionFailure) {
return acquisitionFailure;
}
const hasCurrentLifecycleUri = lifecycle.status !== "failed" && lifecycle.uri === uri;
let binding: AssistantImageRenderBinding | null = null;
if (hasCurrentLifecycleUri && lifecycle.uri) {
binding = {
uri: lifecycle.uri,
onLoad: handleImageLoad,
onError: handleImageError,
};
}
if (lifecycle.status === "loaded" && lifecycle.uri === uri) {
return {
status: "loaded",
binding: {
uri: lifecycle.uri,
onLoad: handleImageLoad,
onError: handleImageError,
},
aspectRatio: lifecycle.aspectRatio,
};
}
if (lifecycle.status === "failed") {
return lifecycle;
}
return {
status: "loading",
binding,
aspectRatio: hasCurrentLifecycleUri ? lifecycle.aspectRatio : null,
};
}
+51 -224
View File
@@ -29,7 +29,6 @@ import {
} from "react";
import type { ComponentType, ReactNode } from "react";
import { MarkdownIt, type ASTNode, type RenderRules } from "react-native-markdown-display";
import { useQuery } from "@tanstack/react-query";
import MaskedView from "@react-native-masked-view/masked-view";
import {
Circle,
@@ -75,19 +74,7 @@ import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks";
import { formatDuration, formatMessageTimestamp } from "@/utils/time";
import { writeMarkdownToRichClipboard } from "@/utils/rich-clipboard";
import { getDefaultMarkdownClipboardEnvironment } from "@/utils/rich-clipboard-default-environment";
import {
getAssistantImageLoadStateFromMetadata,
getAssistantImageMetadata,
setAssistantImageMetadata,
type AssistantImageLoadState,
} from "@/utils/assistant-image-metadata";
import { setAssistantMarkdownBlockHeight } from "@/utils/assistant-message-height-estimate";
import { resolveAssistantImageSource } from "@/utils/assistant-image-source";
import {
createPreviewAttachmentId,
getFileNameFromPath,
parseImageDataUrl,
} from "@/attachments/utils";
import { getAgentAttachmentPillContent } from "@/attachments/attachment-pill-content";
import { PlanCard } from "./plan-card";
import { useToolCallSheet } from "./tool-call-sheet";
@@ -102,8 +89,7 @@ import {
useAssistantLinkPress,
} from "@/assistant-file-links";
import { getCompactionMarkerLabel } from "./message-compaction-label";
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
import { persistAttachmentFromBytes, persistAttachmentFromDataUrl } from "@/attachments/service";
import { useAssistantImage } from "@/assistant-image/use-assistant-image";
import {
AttachmentFrame,
AttachmentLabel,
@@ -764,11 +750,21 @@ export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
imageSurface: {
width: "100%",
overflow: "hidden",
position: "relative",
},
image: {
width: "100%",
height: "100%",
},
imageLoadingOverlay: {
position: "absolute",
top: 0,
right: 0,
bottom: 0,
left: 0,
alignItems: "center",
justifyContent: "center",
},
imageState: {
alignItems: "center",
justifyContent: "center",
@@ -785,123 +781,6 @@ export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
const ASSISTANT_IMAGE_MIN_HEIGHT = 160;
const AssistantMarkdownResolvedImage = memo(function AssistantMarkdownResolvedImage({
uri,
alt,
containerStyle,
source,
workspaceRoot,
serverId,
}: {
uri: string;
alt?: string;
containerStyle?: StyleProp<ViewStyle>;
source: string;
workspaceRoot?: string;
serverId?: string;
}) {
const cachedMetadata = useMemo(
() => getAssistantImageMetadata({ source, workspaceRoot, serverId }),
[serverId, source, workspaceRoot],
);
const [loadState, setLoadState] = useState<AssistantImageLoadState>(() =>
getAssistantImageLoadStateFromMetadata(cachedMetadata),
);
useEffect(() => {
if (cachedMetadata) {
setLoadState(getAssistantImageLoadStateFromMetadata(cachedMetadata));
return () => {};
}
setLoadState({ status: "loading" });
let cancelled = false;
Image.getSize(
uri,
(width, height) => {
if (cancelled) {
return;
}
if (width > 0 && height > 0) {
const metadata = setAssistantImageMetadata(
{ source, workspaceRoot, serverId },
{ width, height },
);
setLoadState({
status: "ready",
aspectRatio: metadata?.aspectRatio ?? width / height,
});
}
},
() => {
if (cancelled) {
return;
}
setLoadState({ status: "error" });
},
);
return () => {
cancelled = true;
};
}, [cachedMetadata, serverId, source, uri, workspaceRoot]);
const handleImageError = useCallback(() => {
setLoadState({ status: "error" });
}, []);
const { t } = useTranslation();
const surfaceStyle = useMemo<StyleProp<ViewStyle>>(
() => [
assistantMessageStylesheet.imageSurface,
loadState.status === "ready"
? { aspectRatio: loadState.aspectRatio }
: { height: ASSISTANT_IMAGE_MIN_HEIGHT },
],
[loadState],
);
const frameStyle = useMemo<StyleProp<ViewStyle>>(
() => [assistantMessageStylesheet.imageFrame, containerStyle],
[containerStyle],
);
const stateSurfaceStyle = useMemo<StyleProp<ViewStyle>>(
() => [surfaceStyle, assistantMessageStylesheet.imageState],
[surfaceStyle],
);
const imageSource = useMemo(() => ({ uri }), [uri]);
if (loadState.status !== "ready") {
return (
<View style={frameStyle}>
<View style={stateSurfaceStyle}>
{loadState.status === "loading" ? (
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
) : null}
{loadState.status === "error" ? (
<Text style={assistantMessageStylesheet.imageErrorText}>
{t("message.attachments.imageUnavailable")}
</Text>
) : null}
</View>
</View>
);
}
return (
<View style={frameStyle}>
<View style={surfaceStyle}>
<Image
source={imageSource}
style={assistantMessageStylesheet.image}
resizeMode="contain"
accessibilityLabel={alt}
onError={handleImageError}
/>
</View>
</View>
);
});
function AssistantMarkdownImage({
source,
alt,
@@ -917,12 +796,6 @@ function AssistantMarkdownImage({
workspaceRoot?: string;
serverId?: string;
}) {
const { t } = useTranslation();
const resolution = useMemo(
() => resolveAssistantImageSource({ source, workspaceRoot }),
[source, workspaceRoot],
);
const dataImage = useMemo(() => parseImageDataUrl(source), [source]);
const containerStyle = useMemo<StyleProp<ViewStyle>>(
() => ({
marginTop: hasLeadingContent ? 16 : 0,
@@ -930,64 +803,25 @@ function AssistantMarkdownImage({
}),
[hasLeadingContent],
);
const query = useQuery({
queryKey: [
"assistantMarkdownImage",
serverId ?? "unknown-server",
resolution?.kind === "file_rpc" ? resolution.cwd : null,
resolution?.kind === "file_rpc" ? resolution.path : null,
],
enabled: Boolean(client && resolution?.kind === "file_rpc"),
staleTime: 30_000,
queryFn: async () => {
if (!client || !resolution || resolution.kind !== "file_rpc") {
return null;
}
const file = await client.readFile(resolution.cwd, resolution.path);
if (file.kind !== "image") {
throw new Error(t("message.attachments.imagePreviewUnavailable"));
}
return await persistAttachmentFromBytes({
id: createPreviewAttachmentId({
mimeType: file.mime,
path: file.path || resolution.path,
size: file.size,
modifiedAt: file.modifiedAt,
contentLength: file.bytes.byteLength,
}),
bytes: file.bytes,
mimeType: file.mime,
fileName: getFileNameFromPath(file.path || resolution.path),
});
},
});
const dataImageQuery = useQuery({
queryKey: ["assistantMarkdownDataImage", dataImage?.cacheKey ?? null],
enabled: dataImage !== null,
staleTime: 30_000,
queryFn: async () => {
if (!dataImage) {
return null;
}
return await persistAttachmentFromDataUrl({
id: createPreviewAttachmentId({
mimeType: dataImage.mimeType,
contentLength: dataImage.base64.length,
}),
dataUrl: source,
mimeType: dataImage.mimeType,
});
},
});
const fileAssetUri = useAttachmentPreviewUrl(query.data);
const dataImageAssetUri = useAttachmentPreviewUrl(dataImageQuery.data);
const directUri = resolution?.kind === "direct" && !dataImage ? resolution.uri : null;
const resolvedUri = directUri ?? dataImageAssetUri ?? fileAssetUri ?? null;
const image = useAssistantImage({ source, client, workspaceRoot, serverId });
const binding = image.status === "failed" ? null : image.binding;
const aspectRatio = image.status === "failed" ? null : image.aspectRatio;
const imageUri = binding?.uri ?? "";
const imageSource = useMemo(() => ({ uri: imageUri }), [imageUri]);
const frameStyle = useMemo<StyleProp<ViewStyle>>(
() => [assistantMessageStylesheet.imageFrame, containerStyle],
[containerStyle],
);
const imageSizeStyle = useMemo<ViewStyle>(() => {
if (aspectRatio) {
return { aspectRatio };
}
return { height: ASSISTANT_IMAGE_MIN_HEIGHT };
}, [aspectRatio]);
const surfaceStyle = useMemo<StyleProp<ViewStyle>>(
() => [assistantMessageStylesheet.imageSurface, imageSizeStyle],
[imageSizeStyle],
);
const stateFrameStyle = useMemo<StyleProp<ViewStyle>>(
() => [
@@ -999,20 +833,15 @@ function AssistantMarkdownImage({
[containerStyle],
);
if (resolvedUri) {
if (image.status === "failed") {
return (
<AssistantMarkdownResolvedImage
uri={resolvedUri}
alt={alt}
containerStyle={containerStyle}
source={source}
workspaceRoot={workspaceRoot}
serverId={serverId}
/>
<View style={stateFrameStyle}>
<Text style={assistantMessageStylesheet.imageErrorText}>{image.message}</Text>
</View>
);
}
if (query.isLoading || dataImageQuery.isLoading) {
if (!binding) {
return (
<View style={stateFrameStyle}>
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
@@ -1020,29 +849,27 @@ function AssistantMarkdownImage({
);
}
const errorText = resolveAssistantImageErrorText(
query.error,
dataImageQuery.error,
t("message.attachments.imagePreviewLoadFailed"),
);
return (
<View style={stateFrameStyle}>
<Text style={assistantMessageStylesheet.imageErrorText}>{errorText}</Text>
<View style={frameStyle}>
<View style={surfaceStyle}>
<Image
source={imageSource}
style={assistantMessageStylesheet.image}
resizeMode="contain"
accessibilityLabel={alt}
onLoad={binding.onLoad}
onError={binding.onError}
/>
{image.status === "loading" ? (
<View pointerEvents="none" style={assistantMessageStylesheet.imageLoadingOverlay}>
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
</View>
) : null}
</View>
</View>
);
}
function resolveAssistantImageErrorText(
fileError: unknown,
dataError: unknown,
fallbackText: string,
): string {
if (fileError instanceof Error) return fileError.message;
if (dataError instanceof Error) return dataError.message;
return fallbackText;
}
function getInlineCodeAutoLinkUrl(
markdownParser: ReturnType<typeof MarkdownIt>,
content: string,
+26 -112
View File
@@ -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);
@@ -651,15 +582,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
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,
@@ -683,14 +605,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 +627,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 +640,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");
@@ -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);
@@ -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";
@@ -70,15 +74,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(
@@ -101,6 +107,7 @@ export function useLoadOlderAgentHistory({
const loadOlder = useCallback(() => {
const session = useSessionStore.getState().sessions[serverId];
const timeline = selectAgentTimelineState(session, agentId);
void loadOlderAgentHistory(agentId, {
client: session?.client
? {
@@ -108,8 +115,8 @@ export function useLoadOlderAgentHistory({
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,
+7 -5
View File
@@ -72,7 +72,7 @@ import { WorkspaceDraftAgentTab } from "@/composer/draft/workspace-tab";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import { buildDraftStoreKey, generateDraftId } from "@/stores/draft-keys";
import { usePanelStore } from "@/stores/panel-store";
import { type Agent, useSessionStore } from "@/stores/session-store";
import { selectAgentTimelineState, type Agent, useSessionStore } from "@/stores/session-store";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
import type { Theme } from "@/styles/theme";
@@ -792,11 +792,12 @@ function ChatAgentContent({
const historySyncGeneration = useSessionStore(
(state) => state.sessions[serverId]?.historySyncGeneration ?? 0,
);
const hasAppliedAuthoritativeHistory = useSessionStore((state) =>
const replicaTimelineStatus = useSessionStore((state) =>
agentId
? state.sessions[serverId]?.agentAuthoritativeHistoryApplied?.get(agentId) === true
: false,
? selectAgentTimelineState(state.sessions[serverId], agentId).status
: ("cold" as const),
);
const hasAppliedAuthoritativeHistory = replicaTimelineStatus === "synced";
const agentHistorySyncGeneration = useSessionStore((state) =>
agentId ? (state.sessions[serverId]?.agentHistorySyncGeneration?.get(agentId) ?? -1) : -1,
);
@@ -832,7 +833,8 @@ function ChatAgentContent({
kind: "idle",
});
const hasHydratedHistoryBefore = hasAppliedAuthoritativeHistory;
const hasHydratedHistoryBefore =
hasAppliedAuthoritativeHistory || replicaTimelineStatus === "painted";
const attentionController = useAgentAttentionClear({
agentId,
@@ -4,6 +4,7 @@ import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import {
normalizeEmptyProjectDescriptor,
normalizeWorkspaceDescriptor,
selectAgentTimelineState,
useSessionStore,
} from "@/stores/session-store";
import type { StreamItem } from "@/types/stream";
@@ -166,13 +167,14 @@ describe("ReplicaCache", () => {
expect(session?.agents.get("agent-1")?.updatedAt).toBeInstanceOf(Date);
expect(session?.workspaces.get("workspace-1")?.statusEnteredAt).toBeInstanceOf(Date);
expect(session?.agentStreamTail.get("agent-1")).toEqual([message("message-1", "Cached")]);
expect(session?.agentAuthoritativeHistoryApplied.get("agent-1")).toBe(true);
expect(session?.agentTimelineCursor.get("agent-1")).toEqual({
epoch: "epoch-1",
startSeq: 1,
endSeq: 12,
expect(session?.agentAuthoritativeHistoryApplied).toEqual(new Map());
expect(session?.agentTimelineCursor).toEqual(new Map());
expect(session?.agentTimelineHasOlder).toEqual(new Map());
expect(session?.agentHistorySyncGeneration).toEqual(new Map());
expect(selectAgentTimelineState(session, "agent-1")).toEqual({
status: "painted",
items: [message("message-1", "Cached")],
});
expect(session?.agentTimelineHasOlder.get("agent-1")).toBe(true);
});
it("persists only the focused agent view with a short timeline tail", async () => {
@@ -216,6 +218,13 @@ describe("ReplicaCache", () => {
expect(Array.from(session?.emptyProjects.keys() ?? [])).toEqual([]);
expect(Array.from(timelines?.keys() ?? [])).toEqual(["agent-2"]);
expect(timelines?.get("agent-2")).toEqual(secondTimeline.slice(-50));
const persisted = JSON.parse(storage.values.get("@paseo:replica-cache") ?? "null") as {
version: number;
hosts: Array<{ timeline: Record<string, unknown> | null }>;
};
expect(persisted.version).toBe(2);
expect(Object.keys(persisted.hosts[0]?.timeline ?? {}).sort()).toEqual(["agentId", "items"]);
});
it("evicts the least recently written host when the cache exceeds its byte budget", async () => {
@@ -243,14 +252,38 @@ describe("ReplicaCache", () => {
expect(Object.keys(useSessionStore.getState().sessions).sort()).toEqual(["host-a", "host-c"]);
});
it("drops malformed or unknown cache versions", async () => {
it("rejects version 1 cache data and overwrites it on flush", async () => {
const storage = new MemoryStorage();
storage.values.set("@paseo:replica-cache", JSON.stringify({ version: 999, hosts: [] }));
storage.values.set(
"@paseo:replica-cache",
JSON.stringify({
version: 1,
hosts: [
{
serverId: SERVER_ID,
agents: [],
workspaces: [],
emptyProjects: [],
timeline: {
agentId: "agent-1",
items: [],
cursor: { epoch: "poisoned", startSeq: 1, endSeq: 100 },
hasOlder: false,
},
},
],
}),
);
const cache = new ReplicaCache(storage);
cache.setHosts([SERVER_ID]);
await cache.restore();
await cache.flush();
expect(useSessionStore.getState().sessions[SERVER_ID]).toBeUndefined();
expect(JSON.parse(storage.values.get("@paseo:replica-cache") ?? "null")).toEqual({
version: 2,
hosts: [],
});
});
});
@@ -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 = {
@@ -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,52 @@ 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,
});
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,
});
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 = [
+94 -14
View File
@@ -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) => {
@@ -1,5 +1,5 @@
import type { AgentStreamEventPayload } from "@getpaseo/protocol/messages";
import { useSessionStore } from "@/stores/session-store";
import { selectAgentTimelineState, useSessionStore } from "@/stores/session-store";
import type { AssistantMessageItem, StreamItem } from "@/types/stream";
import {
applyStreamEvent,
@@ -255,7 +255,6 @@ function applyTimelineReplacePath(args: {
acknowledgedClientMessageIds,
};
}
interface IncrementalAcceptResult {
acceptedUnits: TimelineUnit[];
cursor: TimelineCursor | undefined;
@@ -575,6 +574,43 @@ function reconcileOverlappingProjectedReasoning(params: {
: { tail: params.tail, head: params.head, reconciled: false };
}
function reconcileProjectedUserOverlay(params: {
tail: StreamItem[];
head: StreamItem[];
unit: TimelineUnit;
}): { tail: StreamItem[]; head: StreamItem[]; reconciled: boolean } {
const { event } = params.unit;
if (event.type !== "timeline" || event.item.type !== "user_message") {
return { tail: params.tail, head: params.head, reconciled: false };
}
const userItem = event.item;
const overlayIndex = params.head.findIndex(
(item) =>
item.kind === "user_message" &&
matchesLocalUserMessageIdentity(
{
messageId: userItem.messageId,
clientMessageId: userItem.clientMessageId,
text: userItem.text,
},
item,
),
);
const overlay = params.head[overlayIndex];
if (overlayIndex < 0 || !overlay || overlay.kind !== "user_message") {
return { tail: params.tail, head: params.head, reconciled: false };
}
const canonical = reduceStreamUpdate([], event, params.unit.timestamp, {
source: "canonical",
})[0];
if (!canonical || canonical.kind !== "user_message") {
return { tail: params.tail, head: params.head, reconciled: false };
}
const head = [...params.head];
head[overlayIndex] = mergeCanonicalUserWithLocalPresentation(canonical, overlay);
return { tail: params.tail, head, reconciled: true };
}
function reconcileOverlappingProjectedStreamItems(params: {
tail: StreamItem[];
head: StreamItem[];
@@ -588,13 +624,16 @@ function reconcileOverlappingProjectedStreamItems(params: {
if (params.currentEndSeq === undefined) return { tail, head, reconciledUnits };
for (const unit of params.units) {
let reconciled = reconcileOverlappingProjectedAssistant({
tail,
head,
unit,
epoch: params.epoch,
currentEndSeq: params.currentEndSeq,
});
let reconciled = reconcileProjectedUserOverlay({ tail, head, unit });
if (!reconciled.reconciled) {
reconciled = reconcileOverlappingProjectedAssistant({
tail,
head,
unit,
epoch: params.epoch,
currentEndSeq: params.currentEndSeq,
});
}
if (!reconciled.reconciled) {
reconciled = reconcileOverlappingProjectedReasoning({
tail,
@@ -955,6 +994,7 @@ export interface ProcessAgentStreamEventInput {
currentTail: StreamItem[];
currentHead: StreamItem[];
currentCursor: TimelineCursor | undefined;
hasAuthoritativeBaseline?: boolean;
timestamp: Date;
}
@@ -989,6 +1029,7 @@ export interface ProcessAgentStreamEventsInput {
currentTail: StreamItem[];
currentHead: StreamItem[];
currentCursor: TimelineCursor | undefined;
hasAuthoritativeBaseline?: boolean;
}
export type AgentStreamReducerSnapshot = Omit<ProcessAgentStreamEventsInput, "events">;
@@ -1017,8 +1058,9 @@ function processTimelineSequencingGate(input: {
seq: number | undefined;
epoch: string | undefined;
currentCursor: TimelineCursor | undefined;
hasAuthoritativeBaseline: boolean;
}): TimelineSequencingGateResult {
const { event, seq, epoch, currentCursor } = input;
const { event, seq, epoch, currentCursor, hasAuthoritativeBaseline } = input;
const base: TimelineSequencingGateResult = {
shouldApplyStreamEvent: true,
nextTimelineCursor: null,
@@ -1029,6 +1071,9 @@ function processTimelineSequencingGate(input: {
if (event.type !== "timeline" || typeof seq !== "number" || typeof epoch !== "string") {
return base;
}
if (!hasAuthoritativeBaseline) {
return base;
}
const decision = classifySessionTimelineSeq({
cursor: currentCursor ? { epoch: currentCursor.epoch, endSeq: currentCursor.endSeq } : null,
@@ -1085,9 +1130,24 @@ function processTimelineSequencingGate(input: {
export function processAgentStreamEvent(
input: ProcessAgentStreamEventInput,
): ProcessAgentStreamEventOutput {
const { event, seq, epoch, currentTail, currentHead, currentCursor, timestamp } = input;
const {
event,
seq,
epoch,
currentTail,
currentHead,
currentCursor,
timestamp,
hasAuthoritativeBaseline = true,
} = input;
const sequencing = processTimelineSequencingGate({ event, seq, epoch, currentCursor });
const sequencing = processTimelineSequencingGate({
event,
seq,
epoch,
currentCursor,
hasAuthoritativeBaseline,
});
const timelineCursor =
event.type === "timeline" && seq !== undefined && epoch !== undefined
? { epoch, seq }
@@ -1096,30 +1156,49 @@ export function processAgentStreamEvent(
// ------------------------------------------------------------------
// Apply stream event to tail/head
// ------------------------------------------------------------------
const applied = sequencing.shouldApplyStreamEvent
? applyStreamEvent({
tail: sequencing.resetLiveTimeline ? [] : currentTail,
head: sequencing.resetLiveTimeline ? [] : currentHead,
event,
timestamp,
source: "live",
timelineCursor,
})
: {
tail: currentTail,
head: currentHead,
changedTail: false,
changedHead: false,
};
let streamResult: ReturnType<typeof applyStreamEvent>;
if (!sequencing.shouldApplyStreamEvent) {
streamResult = {
tail: currentTail,
head: currentHead,
changedTail: false,
changedHead: false,
};
} else if (!hasAuthoritativeBaseline) {
const overlay = applyStreamEvent({
tail: currentHead,
head: [],
event,
timestamp,
source: "live",
timelineCursor,
});
streamResult = {
tail: currentTail,
head: [...overlay.tail, ...overlay.head],
changedTail: false,
changedHead: overlay.changedTail || overlay.changedHead,
};
} else {
streamResult = applyStreamEvent({
tail: sequencing.resetLiveTimeline ? [] : currentTail,
head: sequencing.resetLiveTimeline ? [] : currentHead,
event,
timestamp,
source: "live",
timelineCursor,
});
}
const { tail, head, changedTail, changedHead } = streamResult;
return {
tail: applied.tail,
head: applied.head,
changedTail: applied.changedTail,
changedHead: applied.changedHead,
tail,
head,
changedTail,
changedHead,
cursor: sequencing.nextTimelineCursor,
cursorChanged: sequencing.cursorChanged,
acknowledgedClientMessageIds: applied.acknowledgedClientMessageIds ?? [],
acknowledgedClientMessageIds: streamResult.acknowledgedClientMessageIds ?? [],
sideEffects: sequencing.sideEffects,
};
}
@@ -1144,6 +1223,7 @@ export function processAgentStreamEvents(
currentTail: tail,
currentHead: head,
currentCursor: cursor,
hasAuthoritativeBaseline: input.hasAuthoritativeBaseline,
timestamp: reducerEvent.timestamp,
});
@@ -1281,10 +1361,12 @@ export function createSessionAgentStreamReducerQueue(
return createAgentStreamReducerQueue({
getSnapshot: (agentId) => {
const session = useSessionStore.getState().sessions[serverId];
const timeline = selectAgentTimelineState(session, agentId);
return {
currentTail: session?.agentStreamTail.get(agentId) ?? [],
currentTail: timeline.status === "cold" ? [] : timeline.items,
currentHead: session?.agentStreamHead.get(agentId) ?? [],
currentCursor: session?.agentTimelineCursor.get(agentId),
currentCursor: timeline.status === "synced" ? (timeline.range ?? undefined) : undefined,
hasAuthoritativeBaseline: timeline.status === "synced",
};
},
commit: (agentId, result, events) => {
@@ -3,7 +3,6 @@ import {
clearAssistantImageMetadataCache,
estimateAssistantMessageHeightFromCache,
extractAssistantImageSources,
getAssistantImageLoadStateFromMetadata,
getAssistantImageMetadata,
setAssistantImageMetadata,
} from "./assistant-image-metadata";
@@ -42,23 +41,6 @@ describe("assistant image metadata", () => {
});
});
it("maps missing metadata to the image loading state", () => {
expect(getAssistantImageLoadStateFromMetadata(null)).toEqual({ status: "loading" });
});
it("maps cached metadata to the image ready state", () => {
expect(
getAssistantImageLoadStateFromMetadata({
width: 900,
height: 1600,
aspectRatio: 9 / 16,
}),
).toEqual({
status: "ready",
aspectRatio: 9 / 16,
});
});
it("estimates assistant message height from cached image metadata", () => {
setAssistantImageMetadata(
{
@@ -8,11 +8,6 @@ export interface AssistantImageMetadata {
aspectRatio: number;
}
export type AssistantImageLoadState =
| { status: "loading" }
| { status: "ready"; aspectRatio: number }
| { status: "error" };
const assistantImageMetadataCache = new Map<string, AssistantImageMetadata>();
const assistantImageParseCache = new Map<string, { sources: string[]; hasNonImageText: boolean }>();
const ASSISTANT_IMAGE_METADATA_CACHE_LIMIT = 500;
@@ -131,15 +126,6 @@ export function getAssistantImageMetadata(input: {
return null;
}
export function getAssistantImageLoadStateFromMetadata(
metadata: AssistantImageMetadata | null,
): AssistantImageLoadState {
if (!metadata) {
return { status: "loading" };
}
return { status: "ready", aspectRatio: metadata.aspectRatio };
}
export function setAssistantImageMetadata(
input: {
source: string;
@@ -36,6 +36,8 @@ export const MOCK_LOAD_TEST_DEFAULT_MODEL_ID = "five-minute-stream";
const MOCK_LOAD_TEST_MODE_ID = "load-test";
const MOCK_LOAD_TEST_DURATION_MS = 5 * 60 * 1000;
const MOCK_LOAD_TEST_INTERVAL_MS = 40;
const ONE_PIXEL_PNG_BASE64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Wl4Kj8AAAAASUVORK5CYII=";
const CAPABILITIES: AgentCapabilityFlags = {
supportsStreaming: true,
@@ -1117,9 +1119,14 @@ export class MockLoadTestAgentSession implements AgentSession {
}),
);
} else {
const imageBytes = Buffer.from(ONE_PIXEL_PNG_BASE64, "base64");
const imagePayload = Buffer.concat([
imageBytes,
Buffer.alloc(Math.max(0, largePayload.bytes - imageBytes.length)),
]).toString("base64");
this.emitTimeline(turn.turnId, {
type: "assistant_message",
text: `data:image/png;base64,${payload}`,
text: `![Synthetic image](data:image/png;base64,${imagePayload})`,
messageId: turn.assistantMessageId,
});
}