Keep older chat history and image previews stable (#2490)

* fix(app): keep timeline history and previews stable

Treat persisted timeline replicas as display-only so authoritative pagination always comes from the daemon. Require renewed user intent between older-history pages unless the viewport genuinely remains at the history edge.

Model assistant image acquisition as loading, loaded, or failed so recreating a preview URL cannot be rendered as an error.

* fix(app): keep file image previews current

* fix(app): stabilize history pagination edges

* fix(app): recover transient timeline loads

* fix(app): retain assistant image previews

* Stabilize history pagination lifecycle

* Stabilize history settlement and image previews

* Retry file images after reconnect

* Keep active previews and pagination requests alive

* Replace image hook tests with typed ports

* Integrate timeline hydration with submission authority

* Harden pre-hydration timeline reconciliation

* Make rewind E2E use real scroll intent

* Preserve live timeline state through hydration

* Keep mounted image attachments retained

* Protect preview persistence and lifecycle hydration

* Preserve idless live assistant continuations

* Preserve timeline rows across delayed hydration

* test(app): cover real assistant image files

* Preserve assistant tool ordering during hydration
This commit is contained in:
Mohamed Boudra
2026-07-28 22:29:03 +02:00
committed by GitHub
parent bbf3d0f9cc
commit 504b687f89
55 changed files with 4550 additions and 722 deletions
@@ -1,11 +1,15 @@
import { describe, expect, it } from "vitest";
import {
abandonHistoryStartPaginationRequest,
createHistoryStartPaginationState,
evaluateHistoryStartPagination,
isHistoryStartLoadingOperation,
rearmHistoryStartPagination,
settleHistoryStartPagination,
type HistoryStartPaginationInput,
} from "./history-start-pagination";
const visibleHistoryStart = {
const visibleHistoryStart: HistoryStartPaginationInput = {
distanceFromHistoryStart: 0,
hasOlderHistory: true,
isLoadingOlderHistory: false,
@@ -14,47 +18,140 @@ 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 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("allows another user attempt after a request finishes without progress", () => {
const first = evaluateHistoryStartPagination(
createHistoryStartPaginationState(),
visibleHistoryStart,
);
const inFlight = evaluateHistoryStartPagination(first.state, {
...visibleHistoryStart,
isLoadingOlderHistory: true,
});
const failed = evaluateHistoryStartPagination(inFlight.state, visibleHistoryStart);
const retried = evaluateHistoryStartPagination(
rearmHistoryStartPagination(first.state),
rearmHistoryStartPagination(failed.state),
visibleHistoryStart,
);
expect([first.shouldLoad, retried.shouldLoad]).toEqual([true, true]);
expect(failed.state).toEqual({ status: "latched" });
expect(retried.shouldLoad).toBe(true);
});
it("latches an attempt that becomes invalid before entering flight", () => {
const requested = evaluateHistoryStartPagination(
createHistoryStartPaginationState(),
visibleHistoryStart,
);
expect(abandonHistoryStartPaginationRequest(requested.state, "epoch-1:20")).toEqual({
status: "latched",
});
});
it("does not mistake repeated edge observations for a finished request", () => {
const first = evaluateHistoryStartPagination(
createHistoryStartPaginationState(),
visibleHistoryStart,
);
const repeated = evaluateHistoryStartPagination(first.state, visibleHistoryStart);
expect(repeated.state).toBe(first.state);
expect(repeated.shouldLoad).toBe(false);
});
it("waits while history loading is unavailable or already active", () => {
@@ -1,33 +1,91 @@
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 isHistoryStartLoadingOperation(state: HistoryStartPaginationState): boolean {
return state.status === "loading" || state.status === "settling";
}
export function rearmHistoryStartPagination(
_state: HistoryStartPaginationState,
state: HistoryStartPaginationState,
): HistoryStartPaginationState {
return createHistoryStartPaginationState();
return state.status === "latched" ? { status: "ready" } : state;
}
export function abandonHistoryStartPaginationRequest(
state: HistoryStartPaginationState,
requestedProgressKey: string,
): HistoryStartPaginationState {
if (
state.status !== "loading" ||
state.requestObserved ||
state.requestedProgressKey !== requestedProgressKey
) {
return state;
}
return { status: "latched" };
}
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 +93,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,
};
}
@@ -0,0 +1,45 @@
import { describe, expect, it, vi } from "vitest";
import { createHistoryStartSettleScheduler } from "./history-start-settle-scheduler";
describe("history start settle scheduler", () => {
it("does not restart its countdown when geometry keeps changing", () => {
const frames = new Map<number, () => void>();
let nextFrameId = 1;
const onFrame = vi.fn();
const onSettle = vi.fn();
const scheduler = createHistoryStartSettleScheduler({
settleFrames: 2,
requestFrame(callback) {
const id = nextFrameId;
nextFrameId += 1;
frames.set(id, callback);
return id;
},
cancelFrame: (id) => frames.delete(id),
isSettling: () => true,
isLoading: () => false,
onFrame,
onSettle,
});
const runNextFrame = () => {
const next = frames.entries().next().value as [number, () => void] | undefined;
if (!next) {
throw new Error("Expected a scheduled settlement frame");
}
frames.delete(next[0]);
next[1]();
};
scheduler.schedule();
scheduler.schedule();
runNextFrame();
scheduler.schedule();
runNextFrame();
scheduler.schedule();
runNextFrame();
expect(onSettle).toHaveBeenCalledTimes(1);
expect(onFrame).toHaveBeenCalledTimes(3);
expect(frames.size).toBe(0);
});
});
@@ -0,0 +1,52 @@
export interface HistoryStartSettleScheduler {
schedule(): void;
cancel(): void;
}
export function createHistoryStartSettleScheduler(input: {
settleFrames: number;
requestFrame: (callback: () => void) => number;
cancelFrame: (id: number) => void;
isSettling: () => boolean;
isLoading: () => boolean;
onFrame?: () => void;
onSettle: () => void;
}): HistoryStartSettleScheduler {
let frameId: number | null = null;
let remainingFrames = 0;
const tick = () => {
input.onFrame?.();
if (!input.isSettling()) {
frameId = null;
remainingFrames = 0;
return;
}
if (input.isLoading() || remainingFrames > 0) {
if (!input.isLoading()) {
remainingFrames -= 1;
}
frameId = input.requestFrame(tick);
return;
}
frameId = null;
input.onSettle();
};
return {
schedule() {
if (frameId !== null) {
return;
}
remainingFrames = input.settleFrames;
frameId = input.requestFrame(tick);
},
cancel() {
if (frameId !== null) {
input.cancelFrame(frameId);
}
frameId = null;
remainingFrames = 0;
},
};
}
+103 -19
View File
@@ -32,10 +32,19 @@ import {
resolveBottomAnchorTransportBehavior,
} from "./strategy";
import {
abandonHistoryStartPaginationRequest,
createHistoryStartPaginationState,
evaluateHistoryStartPagination,
isHistoryStartLoadingOperation,
rearmHistoryStartPagination,
settleHistoryStartPagination,
type HistoryStartPaginationInput,
type HistoryStartPaginationTransition,
} from "./history-start-pagination";
import {
createHistoryStartSettleScheduler,
type HistoryStartSettleScheduler,
} from "./history-start-settle-scheduler";
const DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION = Object.freeze({
minIndexForVisible: 0,
@@ -53,6 +62,8 @@ const historyStartSlotStyle: ViewStyle = {
paddingTop: 4,
paddingBottom: 8,
};
const HISTORY_START_SETTLE_FRAMES = 2;
interface HistoryRowDisplayVariants {
regular?: StreamItem;
compact?: StreamItem;
@@ -116,7 +127,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 historyStartSettleSchedulerRef = useRef<HistoryStartSettleScheduler | null>(null);
const historyItems = useMemo(() => {
if (segments.historyVirtualized.length === 0) {
@@ -145,22 +160,74 @@ 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) {
const requestedProgressKey = olderHistoryProgressKey;
if (requestedProgressKey === null) {
return;
}
void (async () => {
const started = await onNearHistoryStart();
if (started === true) {
return;
}
applyHistoryStartPaginationTransition({
state: abandonHistoryStartPaginationRequest(
historyStartPaginationStateRef.current,
requestedProgressKey,
),
shouldLoad: false,
});
})();
}
},
);
const evaluateHistoryStart = useStableEvent(() => {
const transition = evaluateHistoryStartPagination(
historyStartPaginationStateRef.current,
getHistoryStartPaginationInput(),
);
applyHistoryStartPaginationTransition(transition);
});
const scheduleHistoryStartSettle = useStableEvent(() => {
let scheduler = historyStartSettleSchedulerRef.current;
if (!scheduler) {
scheduler = createHistoryStartSettleScheduler({
settleFrames: HISTORY_START_SETTLE_FRAMES,
requestFrame: requestAnimationFrame,
cancelFrame: cancelAnimationFrame,
isSettling: () => historyStartPaginationStateRef.current.status === "settling",
isLoading: () => getHistoryStartPaginationInput().isLoadingOlderHistory,
onSettle: () => {
const transition = settleHistoryStartPagination(
historyStartPaginationStateRef.current,
getHistoryStartPaginationInput(),
);
applyHistoryStartPaginationTransition(transition);
},
});
historyStartSettleSchedulerRef.current = scheduler;
}
scheduler.schedule();
});
const clearNativeViewportSettling = useCallback(() => {
@@ -261,7 +328,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 +338,8 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
return () => {
cancelAnimationFrame(frame);
clearPendingUserScrollEnd();
historyStartSettleSchedulerRef.current?.cancel();
historyStartSettleSchedulerRef.current = null;
};
}, [agentId, clearNativeViewportSettling, clearPendingUserScrollEnd, evaluateHistoryStart]);
@@ -369,16 +440,16 @@ 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();
const rearmed = rearmHistoryStartPagination(historyStartPaginationStateRef.current);
if (rearmed !== historyStartPaginationStateRef.current) {
historyStartPaginationStateRef.current = rearmed;
setHistoryStartPaginationState(rearmed);
evaluateHistoryStart();
}
});
// Defer drag end so momentum can take ownership, but capture the terminal
@@ -454,11 +525,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 +582,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.
@@ -130,7 +130,7 @@ describe("createWebStreamStrategy", () => {
routeBottomAnchorRequest: null,
isAuthoritativeHistoryReady: true,
onNearBottomChange: vi.fn(),
onNearHistoryStart: vi.fn(),
onNearHistoryStart: vi.fn().mockReturnValue(true),
isLoadingOlderHistory: false,
hasOlderHistory: false,
olderHistoryProgressKey: null,
@@ -174,7 +174,7 @@ describe("createWebStreamStrategy", () => {
routeBottomAnchorRequest: null,
isAuthoritativeHistoryReady: true,
onNearBottomChange: vi.fn(),
onNearHistoryStart: vi.fn(),
onNearHistoryStart: vi.fn().mockReturnValue(true),
isLoadingOlderHistory: false,
hasOlderHistory: false,
olderHistoryProgressKey: null,
@@ -230,7 +230,7 @@ describe("createWebStreamStrategy", () => {
routeBottomAnchorRequest: null,
isAuthoritativeHistoryReady: true,
onNearBottomChange: vi.fn(),
onNearHistoryStart: vi.fn(),
onNearHistoryStart: vi.fn().mockReturnValue(true),
isLoadingOlderHistory: false,
hasOlderHistory: false,
olderHistoryProgressKey: null,
@@ -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,
@@ -366,7 +296,7 @@ describe("createWebStreamStrategy", () => {
});
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
const viewportRef = React.createRef<StreamViewportHandle>();
const onNearHistoryStart = vi.fn();
const onNearHistoryStart = vi.fn().mockReturnValue(true);
const renderInput = {
agentId: "agent",
boundary: {
@@ -473,7 +403,7 @@ describe("createWebStreamStrategy", () => {
viewportRef,
routeBottomAnchorRequest,
onNearBottomChange: vi.fn(),
onNearHistoryStart: vi.fn(),
onNearHistoryStart: vi.fn().mockReturnValue(true),
isLoadingOlderHistory: false,
hasOlderHistory: false,
olderHistoryProgressKey: null,
@@ -581,7 +511,7 @@ describe("createWebStreamStrategy", () => {
viewportRef,
routeBottomAnchorRequest,
onNearBottomChange: vi.fn(),
onNearHistoryStart: vi.fn(),
onNearHistoryStart: vi.fn().mockReturnValue(true),
isLoadingOlderHistory: false,
hasOlderHistory: false,
olderHistoryProgressKey: null,
@@ -678,7 +608,7 @@ describe("createWebStreamStrategy", () => {
viewportRef,
routeBottomAnchorRequest,
onNearBottomChange: vi.fn(),
onNearHistoryStart: vi.fn(),
onNearHistoryStart: vi.fn().mockReturnValue(true),
isLoadingOlderHistory: false,
hasOlderHistory: false,
olderHistoryProgressKey: null,
@@ -777,7 +707,7 @@ describe("createWebStreamStrategy", () => {
viewportRef,
routeBottomAnchorRequest: null,
onNearBottomChange: vi.fn(),
onNearHistoryStart: vi.fn(),
onNearHistoryStart: vi.fn().mockReturnValue(true),
isLoadingOlderHistory: false,
hasOlderHistory: false,
olderHistoryProgressKey: null,
+221 -30
View File
@@ -17,15 +17,30 @@ import { estimateStreamItemHeight } from "./web-virtualization";
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy";
import { createStreamStrategy } from "./strategy";
import {
abandonHistoryStartPaginationRequest,
createHistoryStartPaginationState,
evaluateHistoryStartPagination,
isHistoryStartLoadingOperation,
rearmHistoryStartPagination,
settleHistoryStartPagination,
type HistoryStartPaginationInput,
type HistoryStartPaginationTransition,
} from "./history-start-pagination";
import {
createHistoryStartSettleScheduler,
type HistoryStartSettleScheduler,
} from "./history-start-settle-scheduler";
interface CreateWebStreamStrategyInput {
isMobileBreakpoint: boolean;
}
interface HistoryStartPrependAnchor {
progressKey: string;
rowId: string;
viewportOffset: number;
}
type ScrollBehaviorLike = "auto" | "smooth";
const WEB_BOTTOM_SETTLE_TIMEOUT_MS = 200;
@@ -33,12 +48,22 @@ 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) => ({
color: theme.colors.foregroundMuted,
});
function findHistoryRowElement(contentNode: HTMLElement, rowId: string): HTMLElement | null {
for (const element of contentNode.querySelectorAll<HTMLElement>("[data-history-row-id]")) {
if (element.dataset.historyRowId === rowId) {
return element;
}
}
return null;
}
const historyStartSlotStyle: CSSProperties = {
display: "flex",
alignItems: "center",
@@ -146,7 +171,14 @@ 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 [isHistoryStartSlotReserved, setIsHistoryStartSlotReserved] = useState(hasOlderHistory);
const historyStartPaginationStateRef = useRef(historyStartPaginationState);
const historyStartPrependAnchorRef = useRef<HistoryStartPrependAnchor | null>(null);
const historyStartPrependAnchorActiveRef = useRef(false);
const historyStartSettleSchedulerRef = useRef<HistoryStartSettleScheduler | null>(null);
const shouldUseVirtualizer = segments.historyVirtualized.length > 0;
const {
renderHistoryVirtualizedRow,
@@ -176,6 +208,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 +222,162 @@ 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;
const contentNode = contentRef.current;
const anchorRow = segments.historyMounted.at(-1) ?? segments.historyVirtualized.at(-1);
const anchorElement =
contentNode && anchorRow ? findHistoryRowElement(contentNode, anchorRow.id) : null;
if (scrollContainer && anchorRow && anchorElement) {
historyStartPrependAnchorRef.current = {
progressKey: olderHistoryProgressKey,
rowId: anchorRow.id,
viewportOffset:
anchorElement.getBoundingClientRect().top - scrollContainer.getBoundingClientRect().top,
};
} else {
historyStartPrependAnchorRef.current = null;
}
historyStartPrependAnchorActiveRef.current = false;
const requestedProgressKey = olderHistoryProgressKey;
void (async () => {
const started = await onNearHistoryStart();
if (started === true) {
return;
}
applyHistoryStartPaginationTransition({
state: abandonHistoryStartPaginationRequest(
historyStartPaginationStateRef.current,
requestedProgressKey,
),
shouldLoad: false,
});
})();
},
);
const evaluateHistoryStart = useStableEvent(() => {
const input = getHistoryStartPaginationInput();
if (!input) {
return;
}
const transition = evaluateHistoryStartPagination(
historyStartPaginationStateRef.current,
input,
);
applyHistoryStartPaginationTransition(transition);
});
const rearmHistoryStartFromUserIntent = useStableEvent(() => {
const rearmed = rearmHistoryStartPagination(historyStartPaginationStateRef.current);
if (rearmed === historyStartPaginationStateRef.current) {
return;
}
historyStartPaginationStateRef.current = rearmed;
setHistoryStartPaginationState(rearmed);
evaluateHistoryStart();
});
const applyHistoryStartPrependAnchor = useStableEvent(() => {
const scrollContainer = scrollContainerRef.current;
const contentNode = contentRef.current;
const anchor = historyStartPrependAnchorRef.current;
if (
!scrollContainer ||
!contentNode ||
!anchor ||
!historyStartPrependAnchorActiveRef.current
) {
return;
}
const anchorElement = findHistoryRowElement(contentNode, anchor.rowId);
if (!anchorElement) {
return;
}
const viewportOffset =
anchorElement.getBoundingClientRect().top - scrollContainer.getBoundingClientRect().top;
scrollContainer.scrollTop += viewportOffset - anchor.viewportOffset;
lastKnownScrollTopRef.current = scrollContainer.scrollTop;
});
const scheduleHistoryStartPrependSettle = useStableEvent(() => {
let scheduler = historyStartSettleSchedulerRef.current;
if (!scheduler) {
scheduler = createHistoryStartSettleScheduler({
settleFrames: HISTORY_START_SETTLE_FRAMES,
requestFrame: (callback) => window.requestAnimationFrame(callback),
cancelFrame: (frame) => window.cancelAnimationFrame(frame),
isSettling: () => historyStartPaginationStateRef.current.status === "settling",
isLoading: () => {
const input = getHistoryStartPaginationInput();
return (
!input ||
input.isLoadingOlderHistory ||
pendingVirtualRowMeasureFramesRef.current.size > 0
);
},
onFrame: applyHistoryStartPrependAnchor,
onSettle: () => {
const input = getHistoryStartPaginationInput();
if (!input) {
return;
}
historyStartPrependAnchorActiveRef.current = false;
const transition = settleHistoryStartPagination(
historyStartPaginationStateRef.current,
input,
);
historyStartPrependAnchorRef.current = null;
applyHistoryStartPaginationTransition(transition);
},
});
historyStartSettleSchedulerRef.current = scheduler;
}
scheduler.schedule();
});
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 +507,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 +519,8 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
return () => {
window.cancelAnimationFrame(frame);
historyStartReadyRef.current = false;
historyStartSettleSchedulerRef.current?.cancel();
historyStartSettleSchedulerRef.current = null;
};
}, [evaluateHistoryStart, props.agentId]);
@@ -401,11 +579,15 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
useEffect(() => {
updateScrollMetrics();
evaluateHistoryStart();
if (historyStartPaginationStateRef.current.status === "settling") {
scheduleHistoryStartPrependSettle();
}
}, [
evaluateHistoryStart,
hasOlderHistory,
isLoadingOlderHistory,
olderHistoryProgressKey,
scheduleHistoryStartPrependSettle,
segments.historyMounted.length,
segments.historyVirtualized.length,
segments.liveHead.length,
@@ -423,6 +605,12 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
updateScrollMetrics();
evaluateHistoryStart();
const observer = new ResizeObserver(() => {
if (historyStartPrependAnchorActiveRef.current) {
applyHistoryStartPrependAnchor();
}
if (historyStartPaginationStateRef.current.status === "settling") {
scheduleHistoryStartPrependSettle();
}
updateScrollMetrics();
evaluateHistoryStart();
if (!followOutputRef.current) {
@@ -437,7 +625,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 +641,9 @@ 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();
rearmHistoryStartFromUserIntent();
}
};
const handlePointerDown = () => {
@@ -477,14 +666,9 @@ 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();
rearmHistoryStartFromUserIntent();
}
lastTouchClientYRef.current = touch.clientY;
};
@@ -513,7 +697,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
scrollContainer.removeEventListener("touchend", handleTouchEnd);
scrollContainer.removeEventListener("touchcancel", handleTouchEnd);
};
}, [cancelPendingStickToBottom, evaluateHistoryStart, handleDomScroll, isLoadingOlderHistory]);
}, [cancelPendingStickToBottom, handleDomScroll, rearmHistoryStartFromUserIntent]);
useEffect(() => {
const handle: StreamViewportHandle = {
@@ -580,9 +764,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
);
const mountedHistoryRows = useMemo(() => {
return segments.historyMounted.map((item, index) => (
<Fragment key={item.id}>
<div key={item.id} data-history-row-id={item.id}>
{renderHistoryMountedRow(item, index, segments.historyMounted)}
</Fragment>
</div>
));
}, [renderHistoryMountedRow, segments.historyMounted]);
const liveHeadRows = useMemo(() => {
@@ -594,21 +778,27 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
const liveAuxiliary = useMemo(() => {
return renderLiveAuxiliary();
}, [renderLiveAuxiliary]);
useEffect(() => {
if (hasOlderHistory || isHistoryStartLoadingOperation(historyStartPaginationState)) {
setIsHistoryStartSlotReserved(true);
}
}, [hasOlderHistory, historyStartPaginationState]);
const historyStartSlot = useMemo(() => {
if (!hasOlderHistory && !isLoadingOlderHistory) {
const isLoadingOperation = isHistoryStartLoadingOperation(historyStartPaginationState);
if (!isHistoryStartSlotReserved && !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, isHistoryStartSlotReserved]);
const shouldRenderEmpty =
!boundary.hasMountedHistory &&
!boundary.hasVirtualizedHistory &&
@@ -635,6 +825,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
<div
key={virtualRow.key}
data-index={virtualRow.index}
data-history-row-id={item.id}
ref={measureVirtualizedRowElement}
style={renderVirtualRowStyle(virtualRow.start)}
>
+1 -1
View File
@@ -69,7 +69,7 @@ export interface StreamRenderInput {
routeBottomAnchorRequest: BottomAnchorRouteRequest | null;
isAuthoritativeHistoryReady: boolean;
onNearBottomChange: (value: boolean) => void;
onNearHistoryStart: () => void;
onNearHistoryStart: () => boolean | Promise<boolean>;
isLoadingOlderHistory: boolean;
hasOlderHistory: boolean;
olderHistoryProgressKey: string | null;
+4 -2
View File
@@ -77,6 +77,7 @@ import {
type BottomAnchorLocalRequest,
type BottomAnchorRouteRequest,
} from "./bottom-anchor-controller";
import { createAssistantImageOccurrenceKey } from "@/assistant-image/acquisition-cache";
import {
AssistantFileLinkResolverProvider,
normalizeInlinePathTarget,
@@ -250,7 +251,7 @@ export interface AgentStreamViewProps {
hasOlder: boolean;
isLoadingOlder: boolean;
progressKey: string | null;
onLoadOlder: () => void;
onLoadOlder: () => boolean | Promise<boolean>;
};
}
@@ -694,6 +695,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
toast={toast}
>
<AssistantMessage
occurrenceKey={createAssistantImageOccurrenceKey({ agentId, itemId: item.id })}
message={item.text}
timestamp={item.timestamp.getTime()}
workspaceRoot={workspaceRoot}
@@ -704,7 +706,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
</AssistantFileLinkResolverProvider>
);
},
[client, handleInlinePathPress, resolvedServerId, toast, workspaceRoot],
[agentId, client, handleInlinePathPress, resolvedServerId, toast, workspaceRoot],
);
const renderThoughtItem = useCallback(
@@ -0,0 +1,193 @@
import { describe, expect, it } from "vitest";
import {
collectRetainedAttachmentIds,
retainAttachmentForGarbageCollection,
} from "@/attachments/gc-retention";
import {
createAssistantImageAcquisitionCache,
createAssistantImageFileAcquisitionKey,
createAssistantImageFilePreviewAttachmentId,
createAssistantImageOccurrenceKey,
} 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,
});
});
it("reuses an acquired image when the current locator is unavailable", async () => {
const cache = createAssistantImageAcquisitionCache<string>({ capacity: 2 });
let unavailableCalls = 0;
await cache.acquire("message:image", async () => "persisted attachment");
const cached = await cache.acquire("message:image", async () => {
unavailableCalls += 1;
throw new Error("daemon disconnected");
});
expect({ cached, unavailableCalls }).toEqual({
cached: "persisted attachment",
unavailableCalls: 0,
});
expect(cache.peek("message:image")).toBe("persisted attachment");
});
it("scopes file acquisitions to the rendered message occurrence", () => {
const first = createAssistantImageFileAcquisitionKey({
serverId: "server",
occurrenceKey: "message-1:image-1",
cwd: "/workspace",
path: "screenshot.png",
});
const remount = createAssistantImageFileAcquisitionKey({
serverId: "server",
occurrenceKey: "message-1:image-1",
cwd: "/workspace",
path: "screenshot.png",
});
const laterMessage = createAssistantImageFileAcquisitionKey({
serverId: "server",
occurrenceKey: "message-2:image-1",
cwd: "/workspace",
path: "screenshot.png",
});
expect(remount).toBe(first);
expect(laterMessage).not.toBe(first);
});
it("scopes persisted file previews to the rendered message occurrence", () => {
const first = createAssistantImageFilePreviewAttachmentId({
serverId: "server-1",
occurrenceKey: "message-1:image-1",
mimeType: "image/png",
path: "/workspace/screenshot.png",
size: 512,
modifiedAt: "2026-07-27T12:00:00.000Z",
contentLength: 512,
});
const second = createAssistantImageFilePreviewAttachmentId({
serverId: "server-1",
occurrenceKey: "message-2:image-1",
mimeType: "image/png",
path: "/workspace/screenshot.png",
size: 512,
modifiedAt: "2026-07-27T12:00:00.000Z",
contentLength: 512,
});
expect(second).not.toBe(first);
});
it("scopes message occurrences to their agent", () => {
const first = createAssistantImageOccurrenceKey({ agentId: "agent-1", itemId: "message-1" });
const second = createAssistantImageOccurrenceKey({ agentId: "agent-2", itemId: "message-1" });
expect(second).not.toBe(first);
});
it("retains successful values until their cache entry is evicted", async () => {
const retained: string[] = [];
const released: string[] = [];
const cache = createAssistantImageAcquisitionCache<string>({
capacity: 1,
onRetain(value) {
retained.push(value);
return () => released.push(value);
},
});
await cache.acquire("first", async () => "attachment-1");
expect({ retained, released }).toEqual({ retained: ["attachment-1"], released: [] });
await cache.acquire("second", async () => "attachment-2");
expect({ retained, released }).toEqual({
retained: ["attachment-1", "attachment-2"],
released: ["attachment-1"],
});
});
it("does not evict a value while an active consumer retains it", async () => {
const released: string[] = [];
const cache = createAssistantImageAcquisitionCache<string>({
capacity: 1,
onRetain: (value) => () => released.push(value),
});
const first = cache.acquireRetained("first", async () => "attachment-1");
await first.promise;
const second = cache.acquireRetained("second", async () => "attachment-2");
await second.promise;
expect({ released, size: cache.size() }).toEqual({ released: [], size: 2 });
first.release();
expect({ released, size: cache.size() }).toEqual({
released: ["attachment-1"],
size: 1,
});
second.release();
});
it("protects every actively consumed attachment from garbage collection past capacity", async () => {
const cache = createAssistantImageAcquisitionCache<{ id: string }>({
capacity: 1,
onRetain: (attachment) => retainAttachmentForGarbageCollection(attachment.id),
});
const first = cache.acquireRetained("first", async () => ({ id: "mounted-image-1" }));
await first.promise;
const second = cache.acquireRetained("second", async () => ({ id: "mounted-image-2" }));
await second.promise;
expect(collectRetainedAttachmentIds()).toEqual(new Set(["mounted-image-1", "mounted-image-2"]));
first.release();
expect(collectRetainedAttachmentIds()).toEqual(new Set(["mounted-image-2"]));
second.release();
await expect(
cache.acquire("cleanup", async () => {
throw new Error("cleanup");
}),
).rejects.toThrow("cleanup");
expect(collectRetainedAttachmentIds()).toEqual(new Set());
});
});
@@ -0,0 +1,159 @@
import { createPreviewAttachmentId } from "@/attachments/utils";
export interface AssistantImageAcquisitionCache<T> {
acquire(key: string, locate: () => Promise<T>): Promise<T>;
acquireRetained(
key: string,
locate: () => Promise<T>,
): { promise: Promise<T>; value?: T; release: () => void };
peek(key: string): T | undefined;
size(): number;
}
export function createAssistantImageOccurrenceKey(input: {
agentId: string;
itemId: string;
}): string {
return `${input.agentId}:${input.itemId}`;
}
export function createAssistantImageFilePreviewAttachmentId(input: {
serverId?: string;
occurrenceKey: string;
mimeType: string;
path: string;
size: number;
modifiedAt?: string | null;
contentLength: number;
}): string {
return createPreviewAttachmentId({
mimeType: input.mimeType,
path: input.path,
size: input.size,
modifiedAt: input.modifiedAt,
contentLength: input.contentLength,
contentKey: `${input.serverId ?? "unknown-server"}:${input.occurrenceKey}`,
});
}
export function createAssistantImageFileAcquisitionKey(input: {
serverId?: string;
occurrenceKey: string;
cwd: string;
path: string;
}): string {
return `file:${input.serverId ?? "unknown-server"}:${input.occurrenceKey}:${input.cwd}:${input.path}`;
}
export function createAssistantImageAcquisitionCache<T>(input: {
capacity: number;
onRetain?: (value: T) => () => void;
}): AssistantImageAcquisitionCache<T> {
if (!Number.isInteger(input.capacity) || input.capacity < 1) {
throw new Error("Assistant image acquisition cache capacity must be a positive integer.");
}
interface CacheEntry {
pending: Promise<T>;
resolved: boolean;
value?: T;
release: (() => void) | null;
activeConsumers: number;
}
const entries = new Map<string, CacheEntry>();
const evict = (key: string, entry: CacheEntry) => {
if (entries.get(key) === entry) {
entries.delete(key);
}
entry.release?.();
entry.release = null;
};
const enforceCapacity = () => {
while (entries.size > input.capacity) {
let evicted = false;
for (const [key, entry] of entries) {
if (entry.activeConsumers > 0) {
continue;
}
evict(key, entry);
evicted = true;
break;
}
if (!evicted) {
return;
}
}
};
const acquireEntry = (key: string, locate: () => Promise<T>, retain: boolean): CacheEntry => {
const cached = entries.get(key);
if (cached) {
entries.delete(key);
entries.set(key, cached);
if (retain) {
cached.activeConsumers += 1;
}
return cached;
}
const pending = locate();
const entry: CacheEntry = {
pending,
resolved: false,
release: null,
activeConsumers: retain ? 1 : 0,
};
entries.set(key, entry);
enforceCapacity();
void (async () => {
try {
const value = await pending;
const release = input.onRetain?.(value) ?? null;
if (entries.get(key) === entry) {
entry.value = value;
entry.resolved = true;
entry.release = release;
} else {
release?.();
}
} catch {
evict(key, entry);
}
})();
return entry;
};
return {
acquire(key, locate) {
return acquireEntry(key, locate, false).pending;
},
acquireRetained(key, locate) {
const entry = acquireEntry(key, locate, true);
let released = false;
return {
promise: entry.pending,
...(entry.resolved ? { value: entry.value } : {}),
release() {
if (released) {
return;
}
released = true;
entry.activeConsumers = Math.max(0, entry.activeConsumers - 1);
enforceCapacity();
},
};
},
peek(key) {
const entry = entries.get(key);
if (!entry?.resolved) {
return undefined;
}
entries.delete(key);
entries.set(key, entry);
return entry.value;
},
size() {
return entries.size;
},
};
}
@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import type { AttachmentMetadata } from "@/attachments/types";
import {
createAssistantImageFileAcquisition,
type AssistantImageFileAcquisitionPort,
} from "./file-acquisition";
class MemoryFileAcquisitionPort implements AssistantImageFileAcquisitionPort {
readonly reads: Array<{ cwd: string; path: string }> = [];
async readFile(cwd: string, path: string) {
this.reads.push({ cwd, path });
return {
kind: "image" as const,
path,
mime: "image/png",
size: 4,
modifiedAt: "1",
bytes: new Uint8Array([1, 2, 3, 4]),
};
}
async persist(input: { id: string; mimeType: string; fileName: string | null }) {
return {
id: input.id,
mimeType: input.mimeType,
storageType: "web-indexeddb" as const,
storageKey: input.id,
fileName: input.fileName,
byteSize: 4,
createdAt: 1,
} satisfies AttachmentMetadata;
}
}
describe("assistant image file acquisition", () => {
it("recreates the same acquisition with a live port after reconnect", async () => {
const common = {
resolution: { kind: "file_rpc" as const, cwd: "/workspace", path: "reconnect.png" },
serverId: "server",
occurrenceKey: "agent:message:reconnect-image",
unavailableMessage: "Image unavailable",
};
const disconnected = createAssistantImageFileAcquisition({ ...common, port: null });
const connectedPort = new MemoryFileAcquisitionPort();
const connected = createAssistantImageFileAcquisition({ ...common, port: connectedPort });
expect(disconnected?.key).toBe(connected?.key);
await expect(disconnected?.locate()).rejects.toThrow("Image unavailable");
await expect(connected?.locate()).resolves.toMatchObject({ mimeType: "image/png" });
expect(connectedPort.reads).toEqual([{ cwd: "/workspace", path: "reconnect.png" }]);
});
});
@@ -0,0 +1,67 @@
import type { FileReadResult } from "@getpaseo/client/internal/daemon-client";
import type { AttachmentMetadata } from "@/attachments/types";
import { getFileNameFromPath } from "@/attachments/utils";
import type { AssistantImageSourceResolution } from "@/utils/assistant-image-source";
import {
createAssistantImageFileAcquisitionKey,
createAssistantImageFilePreviewAttachmentId,
} from "./acquisition-cache";
export interface AssistantImageFileAcquisitionPort {
readFile(cwd: string, path: string): Promise<FileReadResult>;
persist(input: {
id: string;
bytes: Uint8Array;
mimeType: string;
fileName: string | null;
}): Promise<AttachmentMetadata>;
}
export interface AssistantImageAcquisition {
key: string;
locate: () => Promise<AttachmentMetadata>;
}
export function createAssistantImageFileAcquisition(input: {
port: AssistantImageFileAcquisitionPort | null;
resolution: AssistantImageSourceResolution | null;
serverId?: string;
occurrenceKey: string;
unavailableMessage: string;
}): AssistantImageAcquisition | null {
if (input.resolution?.kind !== "file_rpc") {
return null;
}
const { port, resolution } = input;
return {
key: createAssistantImageFileAcquisitionKey({
serverId: input.serverId,
occurrenceKey: input.occurrenceKey,
cwd: resolution.cwd,
path: resolution.path,
}),
locate: async () => {
if (!port) {
throw new Error(input.unavailableMessage);
}
const file = await port.readFile(resolution.cwd, resolution.path);
if (file.kind !== "image") {
throw new Error(input.unavailableMessage);
}
return await port.persist({
id: createAssistantImageFilePreviewAttachmentId({
serverId: input.serverId,
occurrenceKey: input.occurrenceKey,
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),
});
},
};
}
@@ -0,0 +1,120 @@
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("does not reset a loaded image when the same preview is reported again", () => {
const loading = transitionAssistantImageLifecycle(createAssistantImageLifecycle(), {
type: "preview_created",
uri: "blob:current",
aspectRatio: null,
});
const loaded = transitionAssistantImageLifecycle(loading, {
type: "image_loaded",
uri: "blob:current",
aspectRatio: 1.5,
});
const repeated = transitionAssistantImageLifecycle(loaded, {
type: "preview_created",
uri: "blob:current",
aspectRatio: 1.5,
});
expect(repeated).toBe(loaded);
});
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,46 @@
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") {
if (state.status === "loaded" && state.uri === event.uri) {
return state;
}
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,48 @@
import { describe, expect, it } from "vitest";
import {
type AssistantImageRenderedDimensionsReader,
resolveAssistantImageLoadDimensions,
} from "./load-dimensions";
class MemoryRenderedDimensionsReader implements AssistantImageRenderedDimensionsReader {
constructor(private readonly dimensions: { width: number; height: number } | null) {}
read(): { width: number; height: number } | null {
return this.dimensions;
}
}
describe("assistant image load dimensions", () => {
it("prefers native source dimensions", () => {
const dimensions = resolveAssistantImageLoadDimensions({
source: { width: 640, height: 320 },
target: { naturalWidth: 800, naturalHeight: 400 },
renderedImage: null,
renderedDimensions: new MemoryRenderedDimensionsReader({ width: 900, height: 600 }),
});
expect(dimensions).toEqual({ width: 640, height: 320 });
});
it("uses browser event dimensions when native dimensions are absent", () => {
const dimensions = resolveAssistantImageLoadDimensions({
source: { width: 0, height: 0 },
target: { naturalWidth: 800, naturalHeight: 400 },
renderedImage: null,
renderedDimensions: new MemoryRenderedDimensionsReader({ width: 900, height: 600 }),
});
expect(dimensions).toEqual({ width: 800, height: 400 });
});
it("falls back to the rendered image adapter", () => {
const renderedImage = { id: "rendered-image" };
const dimensions = resolveAssistantImageLoadDimensions({
target: null,
renderedImage,
renderedDimensions: new MemoryRenderedDimensionsReader({ width: 900, height: 600 }),
});
expect(dimensions).toEqual({ width: 900, height: 600 });
});
});
@@ -0,0 +1,45 @@
export interface AssistantImageDimensions {
width: number;
height: number;
}
interface AssistantImageDimensionCandidate {
width?: unknown;
height?: unknown;
}
export interface AssistantImageRenderedDimensionsReader {
read(renderedImage: unknown): AssistantImageDimensions | null;
}
function readPositiveDimensions(
candidate: AssistantImageDimensionCandidate | null | undefined,
): AssistantImageDimensions | null {
if (
typeof candidate?.width !== "number" ||
typeof candidate.height !== "number" ||
candidate.width <= 0 ||
candidate.height <= 0
) {
return null;
}
return { width: candidate.width, height: candidate.height };
}
export function resolveAssistantImageLoadDimensions(input: {
source?: AssistantImageDimensionCandidate | null;
target?: { naturalWidth?: unknown; naturalHeight?: unknown } | null;
renderedImage: unknown;
renderedDimensions: AssistantImageRenderedDimensionsReader;
}): AssistantImageDimensions | null {
const source = readPositiveDimensions(input.source);
if (source) {
return source;
}
const target = readPositiveDimensions({
width: input.target?.naturalWidth,
height: input.target?.naturalHeight,
});
return target ?? input.renderedDimensions.read(input.renderedImage);
}
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import { runAssistantImageOperationWithRetry } from "./retry";
describe("assistant image retry", () => {
it("recovers from transient failures while the image remains mounted", async () => {
const waits: number[] = [];
let attempts = 0;
const result = await runAssistantImageOperationWithRetry({
operation: async () => {
attempts += 1;
if (attempts < 3) {
throw new Error("transient");
}
return "loaded";
},
delaysMs: [10, 20, 30],
wait: async (delayMs) => {
waits.push(delayMs);
},
});
expect({ attempts, waits, result }).toEqual({
attempts: 3,
waits: [10, 20],
result: "loaded",
});
});
it("stops retrying after the image is released", async () => {
let stopped = false;
let attempts = 0;
await expect(
runAssistantImageOperationWithRetry({
operation: async () => {
attempts += 1;
throw new Error("transient");
},
delaysMs: [10, 20],
shouldStop: () => stopped,
wait: async () => {
stopped = true;
},
}),
).rejects.toThrow("transient");
expect(attempts).toBe(1);
});
});
+31
View File
@@ -0,0 +1,31 @@
export const ASSISTANT_IMAGE_RETRY_DELAYS_MS = [100, 400, 1_200] as const;
export async function runAssistantImageOperationWithRetry<T>(input: {
operation: () => Promise<T>;
delaysMs?: readonly number[];
shouldStop?: () => boolean;
wait?: (delayMs: number) => Promise<void>;
}): Promise<T> {
const delays = input.delaysMs ?? ASSISTANT_IMAGE_RETRY_DELAYS_MS;
const shouldStop = input.shouldStop ?? (() => false);
const wait =
input.wait ??
(async (delayMs: number) => {
await new Promise<void>((resolve) => setTimeout(resolve, delayMs));
});
for (let attempt = 0; ; attempt += 1) {
try {
return await input.operation();
} catch (error) {
const delay = delays[attempt];
if (delay === undefined || shouldStop()) {
throw error;
}
await wait(delay);
if (shouldStop()) {
throw error;
}
}
}
}
@@ -0,0 +1,544 @@
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useReducer,
useRef,
useState,
} from "react";
import type { ImageLoadEvent } from "react-native";
import { useTranslation } from "react-i18next";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { AttachmentMetadata } from "@/attachments/types";
import { isWeb } from "@/constants/platform";
import { useStableEvent } from "@/hooks/use-stable-event";
import { retainAttachmentForGarbageCollection } from "@/attachments/gc-retention";
import {
persistAttachmentFromBytes,
persistAttachmentFromDataUrl,
releaseAttachmentPreviewUrl,
resolveAttachmentPreviewUrl,
} from "@/attachments/service";
import { createPreviewAttachmentId, parseImageDataUrl } from "@/attachments/utils";
import {
getAssistantImageMetadata,
setAssistantImageMetadata,
} from "@/utils/assistant-image-metadata";
import { resolveAssistantImageSource } from "@/utils/assistant-image-source";
import { createAssistantImageAcquisitionCache } from "./acquisition-cache";
import {
createAssistantImageFileAcquisition,
type AssistantImageAcquisition,
type AssistantImageFileAcquisitionPort,
} from "./file-acquisition";
import {
createAssistantImageLifecycle,
transitionAssistantImageLifecycle,
type AssistantImageLifecycle,
type AssistantImageLifecycleEvent,
} from "./lifecycle";
import {
type AssistantImageRenderedDimensionsReader,
resolveAssistantImageLoadDimensions,
} from "./load-dimensions";
import { runAssistantImageOperationWithRetry } from "./retry";
interface AssistantImageRenderBinding {
uri: string;
onRef: (instance: unknown) => void;
onLoad: (event: ImageLoadEvent) => void;
onError: () => void;
}
const renderedDimensions: AssistantImageRenderedDimensionsReader = {
read(renderedImage) {
if (!isWeb || !(renderedImage instanceof HTMLElement)) {
return null;
}
const image = renderedImage.querySelector("img");
return image && image.naturalWidth > 0 && image.naturalHeight > 0
? { width: image.naturalWidth, height: image.naturalHeight }
: null;
},
};
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;
occurrenceKey: 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 DataImage {
mimeType: string;
base64: string;
cacheKey: string;
}
const attachmentAcquisitionCache = createAssistantImageAcquisitionCache<AttachmentMetadata>({
capacity: 500,
onRetain: (attachment) => retainAttachmentForGarbageCollection(attachment.id),
});
interface CachedPreviewUrl {
attachment: AttachmentMetadata;
uri: string;
}
const previewUrlCache = createAssistantImageAcquisitionCache<CachedPreviewUrl>({
capacity: 500,
onRetain:
({ attachment, uri }) =>
() => {
void releaseAttachmentPreviewUrl({ attachment, url: uri });
},
});
const LOADED_IMAGE_CACHE_CAPACITY = 500;
const loadedImageCache = new Map<string, number>();
function getLoadedImageAspectRatio(uri: string): number | null {
const aspectRatio = loadedImageCache.get(uri);
if (aspectRatio === undefined) {
return null;
}
loadedImageCache.delete(uri);
loadedImageCache.set(uri, aspectRatio);
return aspectRatio;
}
function rememberLoadedImage(uri: string, aspectRatio: number): void {
loadedImageCache.delete(uri);
loadedImageCache.set(uri, aspectRatio);
if (loadedImageCache.size <= LOADED_IMAGE_CACHE_CAPACITY) {
return;
}
const leastRecentlyUsedUri = loadedImageCache.keys().next().value;
if (leastRecentlyUsedUri !== undefined) {
loadedImageCache.delete(leastRecentlyUsedUri);
}
}
function useAttachmentAcquisition(
acquisition: AssistantImageAcquisition | null,
): AttachmentAcquisitionState {
const acquisitionKey = acquisition?.key ?? null;
const [entry, setEntry] = useState<{
key: string | null;
state: AttachmentAcquisitionState;
}>(() => {
const cached = acquisitionKey ? attachmentAcquisitionCache.peek(acquisitionKey) : undefined;
return {
key: acquisitionKey,
state: cached ? { status: "loaded", attachment: cached } : { status: "waiting" },
};
});
useEffect(() => {
let disposed = false;
let releaseCurrent: (() => void) | null = null;
if (!acquisition || !acquisitionKey) {
setEntry({ key: null, state: { status: "waiting" } });
return;
}
const acquireCurrent = () => {
releaseCurrent?.();
const retained = attachmentAcquisitionCache.acquireRetained(
acquisitionKey,
acquisition.locate,
);
releaseCurrent = retained.release;
return retained;
};
const initial = acquireCurrent();
if (initial.value) {
setEntry({ key: acquisitionKey, state: { status: "loaded", attachment: initial.value } });
return () => {
disposed = true;
releaseCurrent?.();
};
}
setEntry({ key: acquisitionKey, state: { status: "loading" } });
void (async () => {
let firstAttempt: ReturnType<typeof acquireCurrent> | null = initial;
try {
const attachment = await runAssistantImageOperationWithRetry({
operation: async () => {
const retained = firstAttempt ?? acquireCurrent();
firstAttempt = null;
return await retained.promise;
},
shouldStop: () => disposed,
});
if (!disposed) {
setEntry({ key: acquisitionKey, state: { status: "loaded", attachment } });
}
} catch (error) {
if (!disposed) {
setEntry({ key: acquisitionKey, state: { status: "failed", error } });
}
}
})();
return () => {
disposed = true;
releaseCurrent?.();
};
}, [acquisition, acquisitionKey]);
if (!acquisitionKey) {
return { status: "waiting" };
}
const cached = attachmentAcquisitionCache.peek(acquisitionKey);
if (cached) {
return { status: "loaded", attachment: cached };
}
return entry.key === acquisitionKey ? entry.state : { status: "waiting" };
}
function createDataImageAcquisition(input: {
source: string;
dataImage: DataImage | null;
}): AssistantImageAcquisition | 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,
contentKey: dataImage.cacheKey,
}),
dataUrl: source,
mimeType: dataImage.mimeType,
}),
};
}
function usePreviewUrl(attachment: AttachmentMetadata | null | undefined): PreviewUrlState {
const id = attachment?.id;
const storageType = attachment?.storageType;
const storageKey = attachment?.storageKey;
const mimeType = attachment?.mimeType;
const previewKey =
id && storageType && storageKey && mimeType
? `${id}:${storageType}:${storageKey}:${mimeType}`
: null;
const [entry, setEntry] = useState<{ key: string | null; state: PreviewUrlState }>(() => {
return {
key: previewKey,
state: { status: "waiting" },
};
});
const getCurrentAttachment = useStableEvent(() => attachment ?? null);
useLayoutEffect(() => {
let disposed = false;
let releaseCurrent: (() => void) | null = null;
const current = getCurrentAttachment();
if (!current || !previewKey) {
setEntry({ key: null, state: { status: "waiting" } });
return;
}
const acquireCurrent = () => {
releaseCurrent?.();
const retained = previewUrlCache.acquireRetained(previewKey, async () => ({
attachment: current,
uri: await resolveAttachmentPreviewUrl(current),
}));
releaseCurrent = retained.release;
return retained;
};
const initial = acquireCurrent();
if (initial.value) {
setEntry({ key: previewKey, state: { status: "loaded", uri: initial.value.uri } });
return () => {
disposed = true;
releaseCurrent?.();
};
}
setEntry({ key: previewKey, state: { status: "loading" } });
void (async () => {
let firstAttempt: ReturnType<typeof acquireCurrent> | null = initial;
try {
const preview = await runAssistantImageOperationWithRetry({
operation: async () => {
const retained = firstAttempt ?? acquireCurrent();
firstAttempt = null;
return await retained.promise;
},
shouldStop: () => disposed,
});
if (!disposed) {
setEntry({ key: previewKey, state: { status: "loaded", uri: preview.uri } });
}
} catch (error) {
if (!disposed) {
setEntry({ key: previewKey, state: { status: "failed", error } });
}
}
})();
return () => {
disposed = true;
releaseCurrent?.();
};
}, [getCurrentAttachment, previewKey]);
if (!previewKey) {
return { status: "waiting" };
}
return entry.key === previewKey ? entry.state : { status: "waiting" };
}
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,
occurrenceKey,
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(() => {
const port: AssistantImageFileAcquisitionPort | null = client
? {
readFile: async (cwd, path) => await client.readFile(cwd, path),
persist: persistAttachmentFromBytes,
}
: null;
return createAssistantImageFileAcquisition({
port,
resolution,
serverId,
occurrenceKey,
unavailableMessage: t("message.attachments.imagePreviewUnavailable"),
});
}, [client, occurrenceKey, 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 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],
);
const [lifecycle, dispatchLifecycle] = useReducer(
lifecycleReducer,
uri,
(initialUri): AssistantImageLifecycle => {
if (initialUri) {
const aspectRatio = getLoadedImageAspectRatio(initialUri);
if (aspectRatio !== null) {
return { status: "loaded", uri: initialUri, aspectRatio };
}
}
return createAssistantImageLifecycle();
},
);
const dispatch = useCallback((event: AssistantImageLifecycleEvent) => {
if (event.type === "image_loaded") {
rememberLoadedImage(event.uri, event.aspectRatio);
} else if (event.type === "failed" && event.uri) {
loadedImageCache.delete(event.uri);
}
dispatchLifecycle(event);
}, []);
const renderedImageRef = useRef<unknown>(null);
const handleImageRef = useCallback((instance: unknown) => {
renderedImageRef.current = instance;
}, []);
useEffect(() => {
if (!uri) {
dispatch({ type: "preview_released" });
return;
}
dispatch({
type: "preview_created",
uri,
aspectRatio: cachedMetadata?.aspectRatio ?? null,
});
}, [cachedMetadata, dispatch, uri]);
const handleImageError = useCallback(() => {
if (uri) {
dispatch({
type: "failed",
uri,
message: t("message.attachments.imageUnavailable"),
});
}
}, [dispatch, t, uri]);
const handleImageLoad = useCallback(
(event: ImageLoadEvent) => {
if (!uri) {
return;
}
const nativeEvent = event.nativeEvent as ImageLoadEvent["nativeEvent"] & {
target?: { naturalWidth?: unknown; naturalHeight?: unknown };
};
const dimensions = resolveAssistantImageLoadDimensions({
source: nativeEvent.source,
target: nativeEvent.target,
renderedImage: renderedImageRef.current,
renderedDimensions,
});
const metadata = dimensions
? setAssistantImageMetadata({ source, workspaceRoot, serverId }, dimensions)
: null;
const aspectRatio = metadata?.aspectRatio ?? cachedMetadata?.aspectRatio ?? null;
if (!aspectRatio) {
dispatch({
type: "failed",
uri,
message: t("message.attachments.imageUnavailable"),
});
return;
}
dispatch({ type: "image_loaded", uri, aspectRatio });
},
[cachedMetadata, dispatch, 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,
onRef: handleImageRef,
onLoad: handleImageLoad,
onError: handleImageError,
};
}
if (lifecycle.status === "loaded" && lifecycle.uri === uri) {
return {
status: "loaded",
binding: {
uri: lifecycle.uri,
onRef: handleImageRef,
onLoad: handleImageLoad,
onError: handleImageError,
},
aspectRatio: lifecycle.aspectRatio,
};
}
if (lifecycle.status === "failed") {
return lifecycle;
}
return {
status: "loading",
binding,
aspectRatio: hasCurrentLifecycleUri ? lifecycle.aspectRatio : null,
};
}
@@ -0,0 +1,15 @@
import { describe, expect, it } from "vitest";
import { collectRetainedAttachmentIds, retainAttachmentForGarbageCollection } from "./gc-retention";
describe("attachment garbage-collection retention", () => {
it("keeps an attachment referenced until its final owner releases it", () => {
const releaseFirst = retainAttachmentForGarbageCollection("preview-1");
const releaseSecond = retainAttachmentForGarbageCollection("preview-1");
expect(collectRetainedAttachmentIds()).toContain("preview-1");
releaseFirst();
expect(collectRetainedAttachmentIds()).toContain("preview-1");
releaseSecond();
expect(collectRetainedAttachmentIds()).not.toContain("preview-1");
});
});
@@ -0,0 +1,22 @@
const retentionCounts = new Map<string, number>();
export function retainAttachmentForGarbageCollection(attachmentId: string): () => void {
retentionCounts.set(attachmentId, (retentionCounts.get(attachmentId) ?? 0) + 1);
let released = false;
return () => {
if (released) {
return;
}
released = true;
const nextCount = (retentionCounts.get(attachmentId) ?? 1) - 1;
if (nextCount <= 0) {
retentionCounts.delete(attachmentId);
return;
}
retentionCounts.set(attachmentId, nextCount);
};
}
export function collectRetainedAttachmentIds(): ReadonlySet<string> {
return new Set(retentionCounts.keys());
}
+47 -1
View File
@@ -1,7 +1,11 @@
import { afterEach, describe, expect, it } from "vitest";
import type { AttachmentMetadata, AttachmentStore, SaveAttachmentInput } from "@/attachments/types";
import { __setAttachmentStoreForTests } from "./store";
import { encodeAttachmentsForSend, persistAttachmentFromBytes } from "./service";
import {
encodeAttachmentsForSend,
garbageCollectAttachments,
persistAttachmentFromBytes,
} from "./service";
function createAttachment(input: Partial<AttachmentMetadata> = {}): AttachmentMetadata {
return {
@@ -94,4 +98,46 @@ describe("attachment service", () => {
{ data: "att_send:base64", mimeType: "image/jpeg" },
]);
});
it("does not collect an attachment persisted while garbage collection is starting", async () => {
let releaseSave: () => void = () => undefined;
let reportSaveStarted: () => void = () => undefined;
const saveStarted = new Promise<void>((resolve) => {
reportSaveStarted = resolve;
});
const saveGate = new Promise<void>((resolve) => {
releaseSave = resolve;
});
const garbageCollections: string[][] = [];
const store: AttachmentStore = {
...createRecordingStore(),
async save(input) {
reportSaveStarted();
await saveGate;
return createAttachment({ id: input.id });
},
async garbageCollect({ referencedIds }) {
garbageCollections.push([...referencedIds]);
},
};
__setAttachmentStoreForTests(store);
const persist = persistAttachmentFromBytes({
id: "assistant-preview",
bytes: new Uint8Array([1, 2, 3]),
mimeType: "image/png",
});
await saveStarted;
const collect = garbageCollectAttachments({ referencedIds: new Set() });
try {
await Promise.resolve();
expect(garbageCollections).toEqual([]);
} finally {
releaseSave();
await Promise.all([persist, collect]);
}
expect(garbageCollections).toEqual([["assistant-preview"]]);
});
});
+77 -11
View File
@@ -1,5 +1,40 @@
import type { AttachmentMetadata } from "@/attachments/types";
import { collectRetainedAttachmentIds } from "@/attachments/gc-retention";
import { getAttachmentStore } from "@/attachments/store";
import type { AttachmentMetadata, SaveAttachmentInput } from "@/attachments/types";
const activePersistence = new Set<Promise<AttachmentMetadata>>();
const persistedDuringGarbageCollection = new Set<string>();
let pendingGarbageCollections = 0;
let garbageCollectionTail: Promise<void> = Promise.resolve();
let persistenceBarrier: Promise<void> | null = null;
let releasePersistenceBarrier: (() => void) | null = null;
async function waitForPersistenceBarrier(): Promise<void> {
const barrier = persistenceBarrier;
if (!barrier) {
return;
}
await barrier;
await waitForPersistenceBarrier();
}
async function persistAttachment(input: SaveAttachmentInput): Promise<AttachmentMetadata> {
await waitForPersistenceBarrier();
const pending = (async () => {
const store = await getAttachmentStore();
const attachment = await store.save(input);
if (pendingGarbageCollections > 0) {
persistedDuringGarbageCollection.add(attachment.id);
}
return attachment;
})();
activePersistence.add(pending);
try {
return await pending;
} finally {
activePersistence.delete(pending);
}
}
export async function persistAttachmentFromBlob(input: {
blob: Blob;
@@ -7,8 +42,7 @@ export async function persistAttachmentFromBlob(input: {
fileName?: string | null;
id?: string;
}): Promise<AttachmentMetadata> {
const store = await getAttachmentStore();
return await store.save({
return await persistAttachment({
id: input.id,
mimeType: input.mimeType,
fileName: input.fileName,
@@ -22,8 +56,7 @@ export async function persistAttachmentFromDataUrl(input: {
fileName?: string | null;
id?: string;
}): Promise<AttachmentMetadata> {
const store = await getAttachmentStore();
return await store.save({
return await persistAttachment({
id: input.id,
mimeType: input.mimeType,
fileName: input.fileName,
@@ -37,8 +70,7 @@ export async function persistAttachmentFromBytes(input: {
fileName?: string | null;
id?: string;
}): Promise<AttachmentMetadata> {
const store = await getAttachmentStore();
return await store.save({
return await persistAttachment({
id: input.id,
mimeType: input.mimeType,
fileName: input.fileName,
@@ -52,8 +84,7 @@ export async function persistAttachmentFromFileUri(input: {
fileName?: string | null;
id?: string;
}): Promise<AttachmentMetadata> {
const store = await getAttachmentStore();
return await store.save({
return await persistAttachment({
id: input.id,
mimeType: input.mimeType,
fileName: input.fileName,
@@ -133,6 +164,41 @@ export async function deleteAttachments(
export async function garbageCollectAttachments(input: {
referencedIds: ReadonlySet<string>;
}): Promise<void> {
const store = await getAttachmentStore();
await store.garbageCollect({ referencedIds: input.referencedIds });
pendingGarbageCollections += 1;
if (!persistenceBarrier) {
persistenceBarrier = new Promise<void>((resolve) => {
releasePersistenceBarrier = resolve;
});
}
const previousGarbageCollection = garbageCollectionTail;
const currentGarbageCollection = (async () => {
await previousGarbageCollection;
while (activePersistence.size > 0) {
await Promise.allSettled(activePersistence);
}
const referencedIds = new Set(input.referencedIds);
for (const id of collectRetainedAttachmentIds()) {
referencedIds.add(id);
}
for (const id of persistedDuringGarbageCollection) {
referencedIds.add(id);
}
const store = await getAttachmentStore();
await store.garbageCollect({ referencedIds });
})();
garbageCollectionTail = currentGarbageCollection.catch(() => undefined);
try {
await currentGarbageCollection;
} finally {
pendingGarbageCollections -= 1;
if (pendingGarbageCollections === 0) {
persistedDuringGarbageCollection.clear();
const release = releasePersistenceBarrier;
releasePersistenceBarrier = null;
persistenceBarrier = null;
release?.();
}
}
}
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
createImageSourceCacheKey,
createPreviewAttachmentId,
fileUriToPath,
localFileSourceToPath,
parseDataUrl,
@@ -86,4 +87,29 @@ describe("parseImageDataUrl", () => {
it("ignores SVG data URLs", () => {
expect(parseImageDataUrl("data:image/svg+xml;base64,PHN2ZyAvPg==")).toBeNull();
});
it("distinguishes image data that differs only in the middle", () => {
const prefix = "a".repeat(64);
const suffix = "z".repeat(64);
const first = `data:image/png;base64,${prefix}${"b".repeat(256)}${suffix}`;
const second = `data:image/png;base64,${prefix}${"c".repeat(256)}${suffix}`;
expect(createImageSourceCacheKey(first)).not.toBe(createImageSourceCacheKey(second));
});
it("gives equal-length preview content distinct attachment identities", () => {
expect(
createPreviewAttachmentId({
mimeType: "image/png",
contentLength: 512,
contentKey: "first-content",
}),
).not.toBe(
createPreviewAttachmentId({
mimeType: "image/png",
contentLength: 512,
contentKey: "second-content",
}),
);
});
});
+5 -2
View File
@@ -56,7 +56,7 @@ export function parseImageDataUrl(
if (!isRasterImageMimeType(parsed.mimeType)) {
return null;
}
const fingerprint = `${parsed.mimeType}\0${parsed.base64.length}\0${parsed.base64.slice(0, 64)}\0${parsed.base64.slice(-64)}`;
const fingerprint = `${parsed.mimeType}\0${parsed.base64}`;
return {
...parsed,
cacheKey: `data-image:${parsed.mimeType}:${parsed.base64.length}:${hashString(fingerprint)}`,
@@ -87,12 +87,15 @@ export function createPreviewAttachmentId(input: {
size?: number | null;
modifiedAt?: string | null;
contentLength?: number | null;
contentKey?: string | null;
}): string {
const path = input.path?.trim() ?? "";
const size = Number.isFinite(input.size) ? String(input.size) : "";
const modifiedAt = input.modifiedAt?.trim() ?? "";
const contentLength = Number.isFinite(input.contentLength) ? String(input.contentLength) : "";
const hash = hashString(`${input.mimeType}\0${path}\0${size}\0${modifiedAt}\0${contentLength}`);
const contentKey = input.contentKey?.trim() ?? "";
const identity = `${input.mimeType}\0${path}\0${size}\0${modifiedAt}\0${contentLength}`;
const hash = hashString(contentKey ? `${identity}\0${contentKey}` : identity);
return `preview_${size || contentLength || "unknown"}_${hash}`;
}
+62 -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,
@@ -737,6 +723,7 @@ export const LiveElapsed = memo(function LiveElapsed({
});
interface AssistantMessageProps {
occurrenceKey: string;
message: string;
timestamp: number;
workspaceRoot?: string;
@@ -764,11 +751,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,125 +782,9 @@ 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,
occurrenceKey,
alt,
hasLeadingContent,
client,
@@ -911,18 +792,13 @@ function AssistantMarkdownImage({
serverId,
}: {
source: string;
occurrenceKey: string;
alt?: string;
hasLeadingContent: boolean;
client?: DaemonClient | null;
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 +806,31 @@ 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 image = useAssistantImage({
source,
occurrenceKey,
client,
workspaceRoot,
serverId,
});
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 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 +842,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 +858,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} accessibilityRole="image" accessibilityLabel={alt}>
<Image
ref={binding.onRef}
source={imageSource}
style={assistantMessageStylesheet.image}
resizeMode="contain"
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,
@@ -1583,6 +1419,7 @@ function MarkdownListView({ baseStyle, spacing, children }: MarkdownListViewProp
}
export const AssistantMessage = memo(function AssistantMessage({
occurrenceKey,
message,
timestamp: _timestamp,
workspaceRoot,
@@ -1917,6 +1754,7 @@ export const AssistantMessage = memo(function AssistantMessage({
<AssistantMarkdownImage
key={node.key}
source={String(node.attributes?.src ?? "")}
occurrenceKey={`${occurrenceKey}:${node.key}`}
alt={typeof node.attributes?.alt === "string" ? node.attributes.alt : undefined}
hasLeadingContent={hasLeadingContent}
client={client}
@@ -1926,7 +1764,7 @@ export const AssistantMessage = memo(function AssistantMessage({
);
},
};
}, [client, fileLinkActions, markdownParser, serverId, workspaceRoot]);
}, [client, fileLinkActions, markdownParser, occurrenceKey, serverId, workspaceRoot]);
const blocks = useMemo(() => splitMarkdownBlocks(message), [message]);
const keyedBlocks = useMemo(
@@ -1,3 +1,4 @@
import React from "react";
import { ActivityIndicator, type ActivityIndicatorProps } from "react-native";
interface LoadingSpinnerProps {
+31 -114
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);
@@ -644,22 +575,15 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
const isInitializing = session?.initializingAgents.get(agentId) === true;
const activeInitDeferred = getInitDeferred(initKey);
const hasActiveInitDeferred = Boolean(activeInitDeferred);
const currentCursor = session?.agentTimelineCursor.get(agentId);
const currentTail = session?.agentStreamTail.get(agentId) ?? [];
const timeline = selectAgentTimelineState(session, agentId);
const currentCursor =
timeline.status === "synced" ? (timeline.range ?? undefined) : undefined;
const currentTail = timeline.status === "cold" ? [] : timeline.items;
const currentHead = session?.agentStreamHead.get(agentId) ?? [];
const sendingClientMessageIds = getSendingClientMessageIds(
session?.messageSubmissions.get(agentId),
);
setAgentTimelineHasOlder(serverId, (prev) => {
if (prev.get(agentId) === payload.hasOlder) {
return prev;
}
const next = new Map(prev);
next.set(agentId, payload.hasOlder);
return next;
});
// Call pure reducer
const result = processTimelineResponse({
payload,
@@ -670,6 +594,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
hasActiveInitDeferred,
initRequestDirection: activeInitDeferred?.requestDirection ?? "tail",
sendingClientMessageIds,
hasAuthoritativeBaseline: timeline.status === "synced",
});
if (result.error) {
@@ -683,14 +608,13 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
return;
}
applyTimelineStreamPatches({
result,
agentId,
serverId,
currentTail,
currentHead,
setAgentStreamState,
setAgentTimelineCursor,
applyAgentTimelineResponseState(serverId, agentId, {
items: result.tail,
head: result.head,
range: result.cursorChanged ? (result.cursor ?? null) : (currentCursor ?? null),
older: payload.hasOlder ? "available" : "none",
synchronized: shouldMarkAuthoritativeHistoryApplied,
acknowledgedClientMessageIds: result.acknowledgedClientMessageIds,
});
executeTimelineSideEffects({
@@ -706,20 +630,9 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
serverId,
shouldMarkAuthoritativeHistoryApplied,
setInitializingAgents,
setAgentAuthoritativeHistoryApplied,
markAgentHistorySynchronized,
});
},
[
markAgentHistorySynchronized,
recoverTimelineGap,
serverId,
setAgentAuthoritativeHistoryApplied,
setAgentStreamState,
setAgentTimelineCursor,
setAgentTimelineHasOlder,
setInitializingAgents,
],
[applyAgentTimelineResponseState, recoverTimelineGap, serverId, setInitializingAgents],
);
useEffect(() => {
@@ -730,16 +643,20 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
const sync = createViewedTimelineSync({
initialDeliveryMode,
setSubscription: (agentIds) => client.setAgentTimelineSubscription(agentIds),
readCursor: (agentId) =>
useSessionStore.getState().sessions[serverId]?.agentTimelineCursor.get(agentId),
readCursor: (agentId) => {
const timeline = selectAgentTimelineState(
useSessionStore.getState().sessions[serverId],
agentId,
);
return timeline.status === "synced" ? (timeline.range ?? undefined) : undefined;
},
hasAuthoritativeHistory: (agentId) =>
useSessionStore
.getState()
.sessions[serverId]?.agentAuthoritativeHistoryApplied.get(agentId) === true,
selectAgentTimelineState(useSessionStore.getState().sessions[serverId], agentId).status ===
"synced",
fetchPage: async (agentId, request) => {
const session = useSessionStore.getState().sessions[serverId];
const initKey = getInitKey(serverId, agentId);
const shouldInitialize = session?.agentAuthoritativeHistoryApplied.get(agentId) !== true;
const shouldInitialize = selectAgentTimelineState(session, agentId).status !== "synced";
if (shouldInitialize) {
if (!getInitDeferred(initKey)) {
const deferred = createInitDeferred(initKey, request.direction ?? "tail");
@@ -111,6 +111,47 @@ describe("ensureAgentIsInitialized", () => {
expect(getInitDeferred(getInitKey(serverId, agentId))?.requestDirection).toBe("tail");
});
it("requests a bounded projected tail after restoring painted replica items", () => {
const client = new FakeDaemonClient();
const runtime = new FakeTimelineRuntime();
useSessionStore.getState().restoreSessionReplica(serverId, {
agents: new Map(),
workspaces: new Map(),
emptyProjects: new Map(),
timeline: {
agentId,
items: [
{
kind: "assistant_message",
id: "painted-item",
text: "Painted before hydration",
timestamp: new Date("2026-07-27T10:00:00.000Z"),
},
],
},
});
void ensureAgentIsInitialized({
serverId,
agentId,
client: client as never,
runtime,
setAgentInitializing: bindSetAgentInitializing(),
});
expect(runtime.requests).toEqual([
{
serverId,
agentId,
request: {
direction: "tail",
limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "projected",
},
},
]);
});
it("times out initialization after 65 seconds", async () => {
vi.useFakeTimers();
const client = new FakeDaemonClient();
@@ -1,7 +1,7 @@
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { useSessionStore } from "@/stores/session-store";
import { selectAgentTimelineState, useSessionStore } from "@/stores/session-store";
import {
createInitDeferred,
getInitDeferred,
@@ -52,8 +52,9 @@ export function ensureAgentIsInitialized(input: EnsureAgentIsInitializedInput):
}
const session = useSessionStore.getState().sessions[serverId];
const cursor = session?.agentTimelineCursor.get(agentId);
const hasAuthoritativeHistory = session?.agentAuthoritativeHistoryApplied.get(agentId) === true;
const timeline = selectAgentTimelineState(session, agentId);
const cursor = timeline.status === "synced" ? (timeline.range ?? undefined) : undefined;
const hasAuthoritativeHistory = timeline.status === "synced";
const timelineRequest = planInitialAgentTimelineSync({ cursor, hasAuthoritativeHistory });
const deferred = createInitDeferred(key, timelineRequest.direction);
@@ -84,7 +84,7 @@ describe("loadOlderAgentHistory", () => {
const client = createClient();
const inFlight = createInFlight();
await loadOlderAgentHistory(agentId, {
const started = await loadOlderAgentHistory(agentId, {
client,
cursor: undefined,
hasOlder: true,
@@ -94,13 +94,14 @@ describe("loadOlderAgentHistory", () => {
expect(client.calls).toEqual([]);
expect(inFlight.values).toEqual([false]);
expect(started).toBe(false);
});
it("no-ops when the daemon says no older history exists", async () => {
const client = createClient();
const inFlight = createInFlight();
await loadOlderAgentHistory(agentId, {
const started = await loadOlderAgentHistory(agentId, {
client,
cursor: someCursor,
hasOlder: false,
@@ -110,13 +111,14 @@ describe("loadOlderAgentHistory", () => {
expect(client.calls).toEqual([]);
expect(inFlight.values).toEqual([false]);
expect(started).toBe(false);
});
it("no-ops when a request is already in flight", async () => {
const client = createClient();
const inFlight = createInFlight(true);
await loadOlderAgentHistory(agentId, {
const started = await loadOlderAgentHistory(agentId, {
client,
cursor: someCursor,
hasOlder: true,
@@ -126,13 +128,14 @@ describe("loadOlderAgentHistory", () => {
expect(client.calls).toEqual([]);
expect(inFlight.values).toEqual([true]);
expect(started).toBe(true);
});
it("requests the page before the current start cursor and clears in-flight on success", async () => {
const client = createClient();
const inFlight = createInFlight();
await loadOlderAgentHistory(agentId, {
const started = await loadOlderAgentHistory(agentId, {
client,
cursor: someCursor,
hasOlder: true,
@@ -152,6 +155,7 @@ describe("loadOlderAgentHistory", () => {
},
]);
expect(inFlight.values).toEqual([false, true, false]);
expect(started).toBe(true);
});
it("shows a panel toast, warns, and clears in-flight on failure", async () => {
@@ -2,7 +2,11 @@ import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import type { ToastApi } from "@/components/toast-host";
import { i18n } from "@/i18n/i18next";
import { useSessionStore, type AgentTimelineCursorState } from "@/stores/session-store";
import {
selectAgentTimelineState,
useSessionStore,
type AgentTimelineCursorState,
} from "@/stores/session-store";
import { planTimelineOlderFetch } from "@/timeline/timeline-sync-plan";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
@@ -36,11 +40,14 @@ export interface LoadOlderAgentHistoryDeps {
export async function loadOlderAgentHistory(
agentId: string,
deps: LoadOlderAgentHistoryDeps,
): Promise<void> {
): Promise<boolean> {
const { client, cursor, hasOlder, isLoadingOlder, setInFlight, toast, logger, failedMessage } =
deps;
if (!client || !cursor || !hasOlder || isLoadingOlder) {
return;
if (isLoadingOlder) {
return true;
}
if (!client || !cursor || !hasOlder) {
return false;
}
setInFlight(true);
@@ -58,6 +65,7 @@ export async function loadOlderAgentHistory(
} finally {
setInFlight(false);
}
return true;
}
export function useLoadOlderAgentHistory({
@@ -70,15 +78,17 @@ export function useLoadOlderAgentHistory({
toast?: ToastApi | null;
}) {
const { t } = useTranslation();
const hasOlder =
useSessionStore((state) => state.sessions[serverId]?.agentTimelineHasOlder.get(agentId)) ===
true;
const hasOlder = useSessionStore((state) => {
const timeline = selectAgentTimelineState(state.sessions[serverId], agentId);
return timeline.status === "synced" && timeline.older === "available";
});
const isLoadingOlder =
useSessionStore((state) =>
state.sessions[serverId]?.agentTimelineOlderFetchInFlight.get(agentId),
) === true;
const progressKey = useSessionStore((state) => {
const cursor = state.sessions[serverId]?.agentTimelineCursor.get(agentId);
const timeline = selectAgentTimelineState(state.sessions[serverId], agentId);
const cursor = timeline.status === "synced" ? timeline.range : null;
return cursor ? `${cursor.epoch}:${cursor.startSeq}` : null;
});
const setOlderFetchInFlight = useSessionStore(
@@ -99,17 +109,18 @@ export function useLoadOlderAgentHistory({
[agentId, serverId, setOlderFetchInFlight],
);
const loadOlder = useCallback(() => {
const loadOlder = useCallback(async (): Promise<boolean> => {
const session = useSessionStore.getState().sessions[serverId];
void loadOlderAgentHistory(agentId, {
const timeline = selectAgentTimelineState(session, agentId);
return await loadOlderAgentHistory(agentId, {
client: session?.client
? {
fetchAgentTimeline: (timelineAgentId, request) =>
getHostRuntimeStore().fetchAgentTimeline(serverId, timelineAgentId, request),
}
: null,
cursor: session?.agentTimelineCursor.get(agentId),
hasOlder: session?.agentTimelineHasOlder.get(agentId) === true,
cursor: timeline.status === "synced" ? (timeline.range ?? undefined) : undefined,
hasOlder: timeline.status === "synced" && timeline.older === "available",
isLoadingOlder: session?.agentTimelineOlderFetchInFlight.get(agentId) === true,
setInFlight,
toast,
+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,
@@ -104,10 +104,12 @@ function ProviderSubagentPanel() {
.catch(() => undefined);
}, [client, serverId, supported, target.parentAgentId, target.subagentId]);
const loadOlder = useCallback(() => {
if (!client || !supported || isLoadingOlder || !timeline?.hasOlder || !timeline.epoch) return;
const loadOlder = useCallback((): boolean => {
if (!client || !supported || isLoadingOlder || !timeline?.hasOlder || !timeline.epoch) {
return false;
}
const firstSeq = timeline.rows.size ? Math.min(...timeline.rows.keys()) : null;
if (firstSeq === null) return;
if (firstSeq === null) return false;
setIsLoadingOlder(true);
void client
.fetchProviderSubagentTimeline(target.parentAgentId, target.subagentId, {
@@ -121,6 +123,7 @@ function ProviderSubagentPanel() {
})
.catch(() => undefined)
.finally(() => setIsLoadingOlder(false));
return true;
}, [
client,
isLoadingOlder,
@@ -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 = {
@@ -8,6 +8,7 @@ import {
persistAttachmentFromDataUrl,
persistAttachmentFromFileUri,
} from "@/attachments/service";
import { collectRetainedAttachmentIds } from "@/attachments/gc-retention";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import { useSessionStore, type SessionState } from "@/stores/session-store";
import { useWorkspaceAttachmentsStore } from "@/attachments/workspace-attachments-store";
@@ -138,6 +139,9 @@ async function runAttachmentGc(): Promise<void> {
for (const id of useDraftStore.getState().collectActiveAttachmentIds()) {
referencedIds.add(id);
}
for (const id of collectRetainedAttachmentIds()) {
referencedIds.add(id);
}
const pendingByDraftId = useCreateFlowStore.getState().pendingByDraftId;
for (const pendingCreate of Object.values(pendingByDraftId)) {
@@ -5,9 +5,11 @@ import type { WorkspaceDescriptorPayload } from "@getpaseo/protocol/messages";
import {
normalizeWorkspaceDescriptor,
selectAgentTimelineState,
useSessionStore,
type WorkspaceDescriptor,
} from "./session-store";
import type { StreamItem } from "../types/stream";
import { patchWorkspaceScripts } from "../contexts/session-workspace-scripts";
function createWorkspace(
@@ -53,6 +55,54 @@ function getTestSessionReferences() {
};
}
describe("agent timeline state", () => {
it("commits canonical items, range, and older availability as one synced state", () => {
initializeTestSession();
const items: StreamItem[] = [
{
kind: "assistant_message",
id: "canonical-row",
text: "canonical",
timestamp: new Date("2026-07-27T10:00:00.000Z"),
},
];
useSessionStore.getState().applyAgentTimelineResponseState("test-server", "agent-1", {
items,
head: [],
range: { epoch: "epoch-1", startSeq: 51, endSeq: 100 },
older: "available",
synchronized: true,
acknowledgedClientMessageIds: [],
});
expect(
selectAgentTimelineState(useSessionStore.getState().sessions["test-server"], "agent-1"),
).toEqual({
status: "synced",
items,
range: { epoch: "epoch-1", startSeq: 51, endSeq: 100 },
older: "available",
});
});
it("represents an empty authoritative timeline without inventing a range", () => {
initializeTestSession();
useSessionStore.getState().applyAgentTimelineResponseState("test-server", "agent-1", {
items: [],
head: [],
range: null,
older: "none",
synchronized: true,
acknowledgedClientMessageIds: [],
});
expect(
selectAgentTimelineState(useSessionStore.getState().sessions["test-server"], "agent-1"),
).toEqual({ status: "synced", items: [], range: null, older: "none" });
});
});
describe("normalizeWorkspaceDescriptor", () => {
it("normalizes workspace scripts and invalid activity timestamps", () => {
const scripts = [
+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) => {
@@ -173,6 +173,7 @@ const baseTimelineInput: ProcessTimelineResponseInput = {
hasActiveInitDeferred: false,
initRequestDirection: "tail",
sendingClientMessageIds: [],
hasAuthoritativeBaseline: true,
};
const baseStreamInput: ProcessAgentStreamEventInput = {
@@ -362,6 +363,442 @@ describe("processTimelineResponse", () => {
expect(result.head).toEqual([liveThought, liveAssistant]);
});
it("keeps a newer live tool completion when bootstrap contains the running call", () => {
const callId = "toolu_live_completion";
const liveCompletion = hydrateStreamState(
[
{
event: {
type: "timeline",
provider: "claude",
item: makeToolCallTimelineEntry(2, callId, "completed", {
type: "read",
filePath: "/tmp/example.ts",
}).item,
} as AgentStreamEventPayload,
timestamp: new Date(2000),
timelineCursor: { epoch: "epoch-1", seq: 2 },
},
],
{ source: "canonical" },
);
const result = processTimelineResponse({
...baseTimelineInput,
currentHead: liveCompletion,
hasAuthoritativeBaseline: false,
isInitializing: true,
hasActiveInitDeferred: true,
payload: {
...baseTimelineInput.payload,
direction: "tail",
reset: false,
epoch: "epoch-1",
startCursor: { seq: 1 },
endCursor: { seq: 1 },
entries: [
makeToolCallTimelineEntry(1, callId, "running", {
type: "unknown",
input: { file_path: "/tmp/example.ts" },
output: null,
}),
],
},
});
expect(getAgentToolCalls([...result.tail, ...result.head])).toEqual([
expect.objectContaining({
timelineCursor: { epoch: "epoch-1", seq: 2 },
payload: expect.objectContaining({
data: expect.objectContaining({ callId, status: "completed" }),
}),
}),
]);
});
it("keeps one newer todo state when bootstrap contains its older state", () => {
const liveTodo = hydrateStreamState(
[
{
event: {
type: "timeline",
provider: "codex",
item: {
type: "todo",
items: [{ text: "Verify hydration", completed: true }],
},
} as AgentStreamEventPayload,
timestamp: new Date(2000),
timelineCursor: { epoch: "epoch-1", seq: 2 },
},
],
{ source: "canonical" },
);
const result = processTimelineResponse({
...baseTimelineInput,
currentHead: liveTodo,
hasAuthoritativeBaseline: false,
isInitializing: true,
hasActiveInitDeferred: true,
payload: {
...baseTimelineInput.payload,
direction: "tail",
reset: false,
epoch: "epoch-1",
startCursor: { seq: 1 },
endCursor: { seq: 1 },
entries: [
{
seqStart: 1,
seqEnd: 1,
provider: "codex",
item: {
type: "todo",
items: [{ text: "Verify hydration", completed: false }],
},
timestamp: new Date(1000).toISOString(),
},
],
},
});
expect([...result.tail, ...result.head].filter((item) => item.kind === "todo_list")).toEqual([
expect.objectContaining({
timelineCursor: { epoch: "epoch-1", seq: 2 },
items: [{ text: "Verify hydration", completed: true }],
}),
]);
});
it("keeps one completed compaction when bootstrap contains its loading state", () => {
const liveCompaction = hydrateStreamState(
[
{
event: {
type: "timeline",
provider: "codex",
item: { type: "compaction", status: "completed", trigger: "auto", preTokens: 1200 },
} as AgentStreamEventPayload,
timestamp: new Date(2000),
timelineCursor: { epoch: "epoch-1", seq: 2 },
},
],
{ source: "canonical" },
);
const result = processTimelineResponse({
...baseTimelineInput,
currentHead: liveCompaction,
hasAuthoritativeBaseline: false,
isInitializing: true,
hasActiveInitDeferred: true,
payload: {
...baseTimelineInput.payload,
direction: "tail",
reset: false,
epoch: "epoch-1",
startCursor: { seq: 1 },
endCursor: { seq: 1 },
entries: [
{
seqStart: 1,
seqEnd: 1,
provider: "codex",
item: { type: "compaction", status: "loading", trigger: "auto" },
timestamp: new Date(1000).toISOString(),
},
],
},
});
expect([...result.tail, ...result.head].filter((item) => item.kind === "compaction")).toEqual([
expect.objectContaining({
timelineCursor: { epoch: "epoch-1", seq: 2 },
status: "completed",
trigger: "auto",
preTokens: 1200,
}),
]);
});
it("replaces a painted replica with an empty authoritative timeline", () => {
const result = processTimelineResponse({
...baseTimelineInput,
currentTail: [makeAssistantItem("painted replica")],
isInitializing: true,
hasActiveInitDeferred: true,
hasAuthoritativeBaseline: false,
payload: {
...baseTimelineInput.payload,
direction: "tail",
startCursor: null,
endCursor: null,
entries: [],
hasOlder: false,
},
});
expect(result.tail).toEqual([]);
expect(result.cursor).toBeNull();
});
it("keeps a newer live assistant continuation when bootstrap ends on the same message", () => {
const result = processTimelineResponse({
...baseTimelineInput,
currentTail: [makeAssistantItem("painted replica")],
currentHead: [
{
kind: "assistant_message",
id: "assistant-live",
messageId: "assistant-live",
text: " continuation",
timestamp: new Date(2001),
timelineCursor: { epoch: "epoch-1", seq: 101 },
},
],
isInitializing: true,
hasActiveInitDeferred: true,
hasAuthoritativeBaseline: false,
payload: {
...baseTimelineInput.payload,
direction: "tail",
startCursor: { seq: 61 },
endCursor: { seq: 100 },
entries: [
{
...makeTimelineEntry(100, "canonical prefix"),
item: {
type: "assistant_message",
text: "canonical prefix",
messageId: "assistant-live",
},
},
],
},
});
const assistants = [...result.tail, ...result.head].filter(
(item) => item.kind === "assistant_message",
);
expect(assistants).toHaveLength(1);
expect(assistants[0]?.text).toBe("canonical prefix continuation");
expect(assistants[0]?.timelineCursor).toEqual({ epoch: "epoch-1", seq: 101 });
});
it("keeps assistant segments separate when a live tool sits between them", () => {
const messageId = "assistant-with-tool";
const liveTool = hydrateStreamState(
[
{
event: {
type: "timeline",
provider: "claude",
item: makeToolCallTimelineEntry(101, "tool-between-segments", "completed", {
type: "read",
filePath: "/tmp/example.ts",
}).item,
} as AgentStreamEventPayload,
timestamp: new Date(2101),
timelineCursor: { epoch: "epoch-1", seq: 101 },
},
{
event: makeAssistantTimelineEvent("after tool", messageId),
timestamp: new Date(2102),
timelineCursor: { epoch: "epoch-1", seq: 102 },
},
],
{ source: "canonical" },
);
const result = processTimelineResponse({
...baseTimelineInput,
currentTail: [makeAssistantItem("painted replica")],
currentHead: liveTool,
isInitializing: true,
hasActiveInitDeferred: true,
hasAuthoritativeBaseline: false,
payload: {
...baseTimelineInput.payload,
direction: "tail",
startCursor: { seq: 61 },
endCursor: { seq: 100 },
entries: [
{
...makeTimelineEntry(100, "before tool"),
item: {
type: "assistant_message",
text: "before tool",
messageId,
},
},
],
},
});
expect([...result.tail, ...result.head].map((item) => item.kind)).toEqual([
"assistant_message",
"tool_call",
"assistant_message",
]);
expect(getAssistantTexts([...result.tail, ...result.head])).toEqual([
"before tool",
"after tool",
]);
});
it("keeps a newer live assistant continuation without a provider message ID", () => {
const result = processTimelineResponse({
...baseTimelineInput,
currentTail: [makeAssistantItem("painted replica")],
currentHead: [
{
kind: "assistant_message",
id: "assistant-live",
text: "canonical prefix continuation",
timestamp: new Date(2001),
timelineCursor: { epoch: "epoch-1", seq: 101 },
},
],
isInitializing: true,
hasActiveInitDeferred: true,
hasAuthoritativeBaseline: false,
payload: {
...baseTimelineInput.payload,
direction: "tail",
startCursor: { seq: 61 },
endCursor: { seq: 100 },
entries: [makeTimelineEntry(100, "canonical prefix")],
},
});
const assistants = [...result.tail, ...result.head].filter(
(item) => item.kind === "assistant_message",
);
expect(assistants).toHaveLength(1);
expect(assistants[0]?.text).toBe("canonical prefix continuation");
expect(assistants[0]?.timelineCursor).toEqual({ epoch: "epoch-1", seq: 101 });
});
it("keeps a canonical live user row newer than the bootstrap page", () => {
const live = processAgentStreamEvent({
...baseStreamInput,
event: {
type: "timeline",
provider: "claude",
item: {
type: "user_message",
text: "remote live prompt",
clientMessageId: "remote-client-message",
},
},
seq: 101,
epoch: "epoch-1",
currentTail: [makeAssistantItem("painted replica")],
hasAuthoritativeBaseline: false,
});
expect(live.head[0]).toMatchObject({
kind: "user_message",
timelineCursor: { epoch: "epoch-1", seq: 101 },
});
const result = processTimelineResponse({
...baseTimelineInput,
currentTail: live.tail,
currentHead: live.head,
isInitializing: true,
hasActiveInitDeferred: true,
hasAuthoritativeBaseline: false,
payload: {
...baseTimelineInput.payload,
direction: "tail",
startCursor: { seq: 61 },
endCursor: { seq: 100 },
entries: [makeTimelineEntry(100, "canonical answer")],
},
});
expect(getUserTexts(result.head)).toEqual(["remote live prompt"]);
});
it("keeps a provider-acknowledged painted-tail prompt newer than the bootstrap page", () => {
const submitted = makeSubmittedUserMessage("submitted before bootstrap", "client-message-1");
const live = processAgentStreamEvent({
...baseStreamInput,
event: {
type: "timeline",
provider: "claude",
item: {
type: "user_message",
text: "canonical presentation",
clientMessageId: "client-message-1",
messageId: "provider-message-1",
},
},
seq: 51,
epoch: "epoch-1",
currentTail: [makeAssistantItem("painted replica"), submitted],
currentCursor: undefined,
hasAuthoritativeBaseline: false,
});
const result = processTimelineResponse({
...baseTimelineInput,
currentTail: live.tail,
currentHead: live.head,
sendingClientMessageIds: [],
isInitializing: true,
hasActiveInitDeferred: true,
hasAuthoritativeBaseline: false,
payload: {
...baseTimelineInput.payload,
direction: "tail",
startCursor: { seq: 11 },
endCursor: { seq: 50 },
entries: [makeTimelineEntry(50, "canonical answer")],
},
});
expect(getUserTexts([...result.tail, ...result.head])).toEqual(["submitted before bootstrap"]);
expect(result.head[0]).toMatchObject({
kind: "user_message",
clientMessageId: "client-message-1",
messageId: "provider-message-1",
timelineCursor: { epoch: "epoch-1", seq: 51 },
});
});
it("does not duplicate a live row already covered by the bootstrap page", () => {
const live = processAgentStreamEvent({
...baseStreamInput,
event: makeTimelineEvent("thinking", "reasoning"),
seq: 100,
epoch: "epoch-1",
currentTail: [makeAssistantItem("painted replica")],
hasAuthoritativeBaseline: false,
});
const result = processTimelineResponse({
...baseTimelineInput,
currentTail: live.tail,
currentHead: live.head,
isInitializing: true,
hasActiveInitDeferred: true,
hasAuthoritativeBaseline: false,
payload: {
...baseTimelineInput.payload,
direction: "tail",
startCursor: { seq: 61 },
endCursor: { seq: 100 },
entries: [makeTimelineEntry(100, "thinking", "reasoning")],
},
});
expect([...result.tail, ...result.head].filter((item) => item.kind === "thought")).toHaveLength(
1,
);
});
it("uses the timeline entry timestamp as canonical", () => {
const result = processTimelineResponse({
...baseTimelineInput,
@@ -1873,7 +2310,10 @@ describe("processTimelineResponse", () => {
expect(getAssistantTexts(result.tail)).toEqual(["older chunk newer chunk"]);
expect(result.tail[0]).toEqual(
expect.objectContaining({ timelineCursor: { epoch: "epoch-1", seq: 3 } }),
expect.objectContaining({
id: "assistant-newer",
timelineCursor: { epoch: "epoch-1", seq: 3 },
}),
);
expect(result.cursor).toEqual({
epoch: "epoch-1",
@@ -2417,6 +2857,61 @@ describe("processAgentStreamEvent", () => {
endSeq: 1,
});
});
it("renders a live row over painted items without creating an authoritative cursor", () => {
const result = processAgentStreamEvent({
...baseStreamInput,
event: makeTimelineEvent("live before bootstrap", "user_message"),
seq: 51,
epoch: "epoch-1",
currentTail: [makeAssistantItem("painted replica")],
currentCursor: undefined,
hasAuthoritativeBaseline: false,
});
expect(getAssistantTexts(result.tail)).toEqual(["painted replica"]);
expect(getUserTexts(result.head)).toEqual(["live before bootstrap"]);
expect(result.head[0]).toMatchObject({
kind: "user_message",
timelineCursor: { epoch: "epoch-1", seq: 51 },
});
expect(result.cursorChanged).toBe(false);
expect(result.cursor).toBeNull();
expect(result.sideEffects).toEqual([]);
});
it("reconciles a pre-bootstrap echo with its submitted row", () => {
const submitted = makeSubmittedUserMessage("submitted before bootstrap", "client-message-1");
const result = processAgentStreamEvent({
...baseStreamInput,
event: {
type: "timeline",
provider: "claude",
item: {
type: "user_message",
text: "canonical presentation",
clientMessageId: "client-message-1",
messageId: "provider-message-1",
},
},
seq: 51,
epoch: "epoch-1",
currentTail: [makeAssistantItem("painted replica"), submitted],
currentCursor: undefined,
hasAuthoritativeBaseline: false,
});
const users = [...result.tail, ...result.head].filter((item) => item.kind === "user_message");
expect(users).toEqual([
expect.objectContaining({
id: "client-message-1",
clientMessageId: "client-message-1",
messageId: "provider-message-1",
text: "submitted before bootstrap",
timelineCursor: { epoch: "epoch-1", seq: 51 },
}),
]);
});
});
describe("processAgentStreamEvents", () => {
@@ -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,
@@ -89,6 +89,7 @@ export interface ProcessTimelineResponseInput {
hasActiveInitDeferred: boolean;
initRequestDirection: InitRequestDirection;
sendingClientMessageIds: readonly string[];
hasAuthoritativeBaseline: boolean;
}
export interface ProcessTimelineResponseOutput {
@@ -233,6 +234,10 @@ function applyTimelineReplacePath(args: {
previousHead: currentHead,
sendingClientMessageIds,
preserveLiveHead,
canonicalCoverage: {
epoch: payload.epoch,
endSeq: payload.endCursor?.seq ?? null,
},
});
const cursor: TimelineCursor | null =
payload.startCursor && payload.endCursor
@@ -255,7 +260,6 @@ function applyTimelineReplacePath(args: {
acknowledgedClientMessageIds,
};
}
interface IncrementalAcceptResult {
acceptedUnits: TimelineUnit[];
cursor: TimelineCursor | undefined;
@@ -406,16 +410,15 @@ function mergePrependedCanonicalTail(olderTail: StreamItem[], currentTail: Strea
return [...olderTail, ...currentTail];
}
return [
...olderTail.slice(0, -1),
{
...olderLast,
text: `${olderLast.text}${currentFirst.text}`,
timestamp: currentFirst.timestamp,
...(currentFirst.timelineCursor ? { timelineCursor: currentFirst.timelineCursor } : {}),
},
...currentTail.slice(1),
];
const mergedAssistant: AssistantMessageItem = {
...currentFirst,
text: `${olderLast.text}${currentFirst.text}`,
};
if (mergedAssistant.messageId === undefined && olderLast.messageId !== undefined) {
mergedAssistant.messageId = olderLast.messageId;
}
return [...olderTail.slice(0, -1), mergedAssistant, ...currentTail.slice(1)];
}
function replaceLiveAssistantWithProjectedText(params: {
@@ -820,6 +823,7 @@ export function processTimelineResponse(
hasActiveInitDeferred,
initRequestDirection,
sendingClientMessageIds,
hasAuthoritativeBaseline,
} = input;
// ------------------------------------------------------------------
@@ -891,7 +895,8 @@ export function processTimelineResponse(
currentTail,
currentHead,
sendingClientMessageIds,
preserveLiveHead: currentCursor?.epoch === payload.epoch,
preserveLiveHead:
currentCursor?.epoch === payload.epoch || (!payload.reset && !hasAuthoritativeBaseline),
toHydratedEvents,
})
: applyTimelineIncrementalPath({
@@ -955,6 +960,7 @@ export interface ProcessAgentStreamEventInput {
currentTail: StreamItem[];
currentHead: StreamItem[];
currentCursor: TimelineCursor | undefined;
hasAuthoritativeBaseline?: boolean;
timestamp: Date;
}
@@ -989,6 +995,7 @@ export interface ProcessAgentStreamEventsInput {
currentTail: StreamItem[];
currentHead: StreamItem[];
currentCursor: TimelineCursor | undefined;
hasAuthoritativeBaseline?: boolean;
}
export type AgentStreamReducerSnapshot = Omit<ProcessAgentStreamEventsInput, "events">;
@@ -1017,8 +1024,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 +1037,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 +1096,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 +1122,61 @@ export function processAgentStreamEvent(
// ------------------------------------------------------------------
// Apply stream event to tail/head
// ------------------------------------------------------------------
const applied = sequencing.shouldApplyStreamEvent
? applyStreamEvent({
tail: sequencing.resetLiveTimeline ? [] : currentTail,
head: sequencing.resetLiveTimeline ? [] : currentHead,
let streamResult: ReturnType<typeof applyStreamEvent>;
if (!sequencing.shouldApplyStreamEvent) {
streamResult = {
tail: currentTail,
head: currentHead,
changedTail: false,
changedHead: false,
};
} else if (!hasAuthoritativeBaseline) {
if (event.type === "timeline" && event.item.type === "user_message") {
streamResult = applyStreamEvent({
tail: currentTail,
head: currentHead,
event,
timestamp,
source: "live",
timelineCursor,
})
: {
unmatchedUserMessageInsert: "head",
});
} else {
const overlay = applyStreamEvent({
tail: currentHead,
head: [],
event,
timestamp,
source: "live",
timelineCursor,
});
streamResult = {
tail: currentTail,
head: currentHead,
head: [...overlay.tail, ...overlay.head],
changedTail: false,
changedHead: 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 +1201,7 @@ export function processAgentStreamEvents(
currentTail: tail,
currentHead: head,
currentCursor: cursor,
hasAuthoritativeBaseline: input.hasAuthoritativeBaseline,
timestamp: reducerEvent.timestamp,
});
@@ -1281,10 +1339,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) => {
+5 -1
View File
@@ -1350,7 +1350,10 @@ describe("turn lifecycle events", () => {
},
},
new Date("2025-01-01T15:03:11Z"),
{ source: "canonical" },
{
source: "canonical",
timelineCursor: { epoch: "epoch-1", seq: 42 },
},
);
const userMessages = state.filter((item) => item.kind === "user_message");
@@ -1361,6 +1364,7 @@ describe("turn lifecycle events", () => {
assert.strictEqual(userMessage.messageId, "provider-owned-canonical");
assert.strictEqual(userMessage.text, "Analyze this");
assert.strictEqual(userMessage.timestamp.getTime(), submittedTimestamp.getTime());
assert.deepStrictEqual(userMessage.timelineCursor, { epoch: "epoch-1", seq: 42 });
assert.deepStrictEqual(userMessage.images, [image]);
assert.deepStrictEqual(userMessage.attachments, [attachment]);
});
+242 -26
View File
@@ -88,6 +88,7 @@ export interface UserMessageItem {
id: string;
clientMessageId?: string;
messageId?: string;
timelineCursor?: TimelinePosition;
text: string;
timestamp: Date;
images?: UserMessageImageAttachment[];
@@ -98,6 +99,7 @@ export interface UserMessageInput {
id?: string;
clientMessageId?: string;
messageId?: string;
timelineCursor?: TimelinePosition;
text: string;
timestamp: Date;
images?: UserMessageImageAttachment[];
@@ -114,6 +116,7 @@ export function createUserMessage(input: UserMessageInput): UserMessageItem {
id,
...(input.clientMessageId ? { clientMessageId: input.clientMessageId } : {}),
...(input.messageId ? { messageId: input.messageId } : {}),
...(input.timelineCursor ? { timelineCursor: input.timelineCursor } : {}),
text: input.text,
timestamp: input.timestamp,
...(input.images && input.images.length > 0 ? { images: input.images } : {}),
@@ -236,11 +239,13 @@ function produceUserMessage(
...presentation,
clientMessageId: incoming.clientMessageId ?? existing.clientMessageId,
messageId: incoming.messageId ?? existing.messageId,
timelineCursor: incoming.timelineCursor ?? existing.timelineCursor,
});
if (
existing.id === merged.id &&
existing.clientMessageId === merged.clientMessageId &&
existing.messageId === merged.messageId &&
existing.timelineCursor === merged.timelineCursor &&
existing.text === merged.text &&
existing.timestamp === merged.timestamp &&
existing.images === merged.images &&
@@ -392,6 +397,7 @@ export interface CanonicalStreamReplacementInput {
previousHead: StreamItem[];
sendingClientMessageIds: readonly string[];
preserveLiveHead: boolean;
canonicalCoverage: { epoch: string; endSeq: number | null };
}
export interface CanonicalStreamReplacementResult {
@@ -400,44 +406,173 @@ export interface CanonicalStreamReplacementResult {
acknowledgedClientMessageIds: string[];
}
function isAfterCanonicalCoverage(
position: TimelinePosition,
coverage: CanonicalStreamReplacementInput["canonicalCoverage"],
): boolean {
return (
position.epoch === coverage.epoch &&
(coverage.endSeq === null || position.seq > coverage.endSeq)
);
}
function removeUserMessageAt(items: UserMessageItem[], index: number): UserMessageItem[] {
return [...items.slice(0, index), ...items.slice(index + 1)];
}
function mergeRetainedLifecycleItem(tail: StreamItem[], retained: StreamItem): StreamItem[] | null {
if (!retained.timelineCursor) {
return null;
}
if (isAgentToolCallItem(retained)) {
const tailIndex = findExistingAgentToolCallIndex(tail, retained.payload.data.callId);
const existing = tail[tailIndex];
if (tailIndex < 0 || !existing || !isAgentToolCallItem(existing)) {
return null;
}
const next = [...tail];
next[tailIndex] = mergeAgentToolCallItem(
existing,
retained.payload.data,
retained.timestamp,
retained.timelineCursor,
);
return next;
}
if (retained.kind === "todo_list") {
const tailIndex = tail.length - 1;
const existing = tail[tailIndex];
if (!existing || existing.kind !== "todo_list" || existing.provider !== retained.provider) {
return null;
}
const next = [...tail];
next[tailIndex] = {
...existing,
timelineCursor: retained.timelineCursor,
timestamp: retained.timestamp,
items: retained.items,
};
return next;
}
if (retained.kind === "compaction" && retained.status === "completed") {
const tailIndex = tail.findIndex(
(item) => item.kind === "compaction" && item.status === "loading",
);
const existing = tail[tailIndex];
if (tailIndex < 0 || !existing || existing.kind !== "compaction") {
return null;
}
const next = [...tail];
next[tailIndex] = {
...existing,
timelineCursor: retained.timelineCursor,
status: "completed",
trigger: retained.trigger ?? existing.trigger,
preTokens: retained.preTokens ?? existing.preTokens,
};
return next;
}
return null;
}
function reconcileReplacementHeadAgainstTail(
tail: StreamItem[],
retainedHead: StreamItem[],
): { tail: StreamItem[]; head: StreamItem[] } {
let reconciledTail = tail;
const reconciledHeadIndexes = new Set<number>();
for (const [headIndex, item] of retainedHead.entries()) {
const nextTail = mergeRetainedLifecycleItem(reconciledTail, item);
if (!nextTail) {
continue;
}
reconciledTail = nextTail;
reconciledHeadIndexes.add(headIndex);
}
const tailIds = new Set(reconciledTail.map((item) => item.id));
return {
tail: reconciledTail,
head: retainedHead.filter(
(item, index) =>
!reconciledHeadIndexes.has(index) &&
(item.kind === "assistant_message" || !tailIds.has(item.id)),
),
};
}
function preserveReplacementHead(
tail: StreamItem[],
currentHead: StreamItem[],
preserveLiveHead: boolean,
sendingClientMessageIds: ReadonlySet<string>,
canonicalCoverage: CanonicalStreamReplacementInput["canonicalCoverage"],
): CanonicalStreamReplacementResult {
const canonicalTailAssistant = tail.at(-1);
const retainedHead = preserveLiveHead
? currentHead
? currentHead.filter(
(item) =>
!item.timelineCursor ||
isAfterCanonicalCoverage(item.timelineCursor, canonicalCoverage) ||
(item.kind === "assistant_message" &&
canonicalTailAssistant?.kind === "assistant_message" &&
item.text.startsWith(canonicalTailAssistant.text)),
)
: currentHead.filter(
(item) =>
item.kind === "user_message" &&
item.clientMessageId !== undefined &&
sendingClientMessageIds.has(item.clientMessageId),
);
const tailIds = new Set(tail.map((item) => item.id));
const unreconciledHead = retainedHead.filter(
(item) => item.kind === "assistant_message" || !tailIds.has(item.id),
);
const liveAssistantIndex = unreconciledHead.findLastIndex(
(item) => item.kind === "assistant_message",
const { tail: reconciledTail, head: unreconciledHead } = reconcileReplacementHeadAgainstTail(
tail,
retainedHead,
);
const liveAssistantIndex = unreconciledHead[0]?.kind === "assistant_message" ? 0 : -1;
if (liveAssistantIndex < 0) {
return { tail, head: unreconciledHead, acknowledgedClientMessageIds: [] };
return { tail: reconciledTail, head: unreconciledHead, acknowledgedClientMessageIds: [] };
}
const liveAssistant = unreconciledHead[liveAssistantIndex];
const tailAssistant = tail.at(-1);
const tailAssistant = reconciledTail.at(-1);
if (
liveAssistant.kind !== "assistant_message" ||
!tailAssistant ||
tailAssistant.kind !== "assistant_message" ||
!liveAssistant.text.startsWith(tailAssistant.text)
tailAssistant.kind !== "assistant_message"
) {
return { tail, head: unreconciledHead, acknowledgedClientMessageIds: [] };
return { tail: reconciledTail, head: unreconciledHead, acknowledgedClientMessageIds: [] };
}
const hasNewerCursor =
liveAssistant.timelineCursor !== undefined &&
tailAssistant.timelineCursor !== undefined &&
liveAssistant.timelineCursor.epoch === tailAssistant.timelineCursor.epoch &&
liveAssistant.timelineCursor.seq > tailAssistant.timelineCursor.seq;
const hasMatchingProviderMessageId =
liveAssistant.messageId !== undefined && liveAssistant.messageId === tailAssistant.messageId;
const hasIdlessTextContinuation =
liveAssistant.messageId === undefined &&
tailAssistant.messageId === undefined &&
liveAssistant.text.startsWith(tailAssistant.text);
const isNewerContinuation =
hasNewerCursor && (hasMatchingProviderMessageId || hasIdlessTextContinuation);
if (isNewerContinuation) {
const text = liveAssistant.text.startsWith(tailAssistant.text)
? liveAssistant.text
: `${tailAssistant.text}${liveAssistant.text}`;
const head = [
...unreconciledHead.slice(0, liveAssistantIndex),
{ ...liveAssistant, text },
...unreconciledHead.slice(liveAssistantIndex + 1),
];
return {
tail: reconciledTail.slice(0, -1),
head,
acknowledgedClientMessageIds: [],
};
}
if (!liveAssistant.text.startsWith(tailAssistant.text)) {
return { tail: reconciledTail, head: unreconciledHead, acknowledgedClientMessageIds: [] };
}
const head = [
@@ -445,7 +580,11 @@ function preserveReplacementHead(
{ ...liveAssistant, text: tailAssistant.text },
...unreconciledHead.slice(liveAssistantIndex + 1),
];
return { tail: tail.slice(0, -1), head, acknowledgedClientMessageIds: [] };
return {
tail: reconciledTail.slice(0, -1),
head,
acknowledgedClientMessageIds: [],
};
}
export function replaceWithCanonicalStream(
@@ -498,16 +637,25 @@ export function replaceWithCanonicalStream(
nextTail.push(item);
}
const retainedTailMessages: UserMessageItem[] = [];
for (const local of unmatchedTailMessages) {
if (!local.clientMessageId || !sendingClientMessageIds.has(local.clientMessageId)) {
continue;
if (local.clientMessageId && sendingClientMessageIds.has(local.clientMessageId)) {
nextTail.push(local);
} else if (
input.preserveLiveHead &&
local.timelineCursor &&
isAfterCanonicalCoverage(local.timelineCursor, input.canonicalCoverage)
) {
retainedTailMessages.push(local);
}
nextTail.push(local);
}
nextHead = [...retainedTailMessages, ...nextHead];
nextHead = nextHead.filter((item) => {
if (item.kind !== "user_message" || !item.clientMessageId) return true;
return sendingClientMessageIds.has(item.clientMessageId);
if (sendingClientMessageIds.has(item.clientMessageId)) return true;
if (!input.preserveLiveHead || !item.timelineCursor) return false;
return isAfterCanonicalCoverage(item.timelineCursor, input.canonicalCoverage);
});
const replacement = preserveReplacementHead(
@@ -515,6 +663,7 @@ export function replaceWithCanonicalStream(
nextHead,
input.preserveLiveHead,
sendingClientMessageIds,
input.canonicalCoverage,
);
return {
...replacement,
@@ -543,6 +692,7 @@ export type ThoughtStatus = "loading" | "ready";
export interface ThoughtItem {
kind: "thought";
id: string;
timelineCursor?: TimelinePosition;
text: string;
timestamp: Date;
status: ThoughtStatus;
@@ -577,6 +727,7 @@ export type ToolCallPayload =
export interface ToolCallItem {
kind: "tool_call";
id: string;
timelineCursor?: TimelinePosition;
timestamp: Date;
payload: ToolCallPayload;
}
@@ -594,6 +745,7 @@ type ActivityLogType = "system" | "info" | "success" | "error";
export interface ActivityLogItem {
kind: "activity_log";
id: string;
timelineCursor?: TimelinePosition;
timestamp: Date;
activityType: ActivityLogType;
message: string;
@@ -603,6 +755,7 @@ export interface ActivityLogItem {
export interface CompactionItem {
kind: "compaction";
id: string;
timelineCursor?: TimelinePosition;
timestamp: Date;
status: "loading" | "completed";
trigger?: "auto" | "manual";
@@ -617,6 +770,7 @@ export interface TodoEntry {
export interface TodoListItem {
kind: "todo_list";
id: string;
timelineCursor?: TimelinePosition;
timestamp: Date;
provider: AgentProvider;
items: TodoEntry[];
@@ -675,6 +829,7 @@ function appendUserMessage(
_source: StreamUpdateSource,
messageId?: string,
clientMessageId?: string,
timelineCursor?: TimelinePosition,
): StreamItem[] {
const { chunk, hasContent } = normalizeChunk(text);
if (!hasContent) {
@@ -686,6 +841,7 @@ function appendUserMessage(
id: messageId ?? createUniqueTimelineId(state, "user", chunkSeed, timestamp),
clientMessageId,
messageId,
timelineCursor,
text: chunk,
timestamp,
});
@@ -756,7 +912,12 @@ function appendAssistantMessage(
return [...state, item];
}
function appendThought(state: StreamItem[], text: string, timestamp: Date): StreamItem[] {
function appendThought(
state: StreamItem[],
text: string,
timestamp: Date,
timelineCursor?: TimelinePosition,
): StreamItem[] {
const { chunk, hasContent } = normalizeChunk(text);
if (!chunk) {
return state;
@@ -766,6 +927,7 @@ function appendThought(state: StreamItem[], text: string, timestamp: Date): Stre
if (last && last.kind === "thought") {
const updated: ThoughtItem = {
...last,
...(timelineCursor ? { timelineCursor } : {}),
text: `${last.text}${chunk}`,
timestamp,
status: "loading",
@@ -781,6 +943,7 @@ function appendThought(state: StreamItem[], text: string, timestamp: Date): Stre
const item: ThoughtItem = {
kind: "thought",
id: createUniqueTimelineId(state, "thought", idSeed, timestamp),
...(timelineCursor ? { timelineCursor } : {}),
text: chunk,
timestamp,
status: "loading",
@@ -916,6 +1079,7 @@ export function mergeAgentToolCallItem(
existing: AgentToolCallItem,
data: AgentToolCallData,
timestamp: Date,
timelineCursor?: TimelinePosition,
): AgentToolCallItem {
const mergedStatus = mergeAgentToolCallStatus(existing.payload.data.status, data.status);
const mergedError =
@@ -927,6 +1091,7 @@ export function mergeAgentToolCallItem(
return {
...existing,
...(timelineCursor ? { timelineCursor } : {}),
timestamp,
payload: {
source: "agent",
@@ -946,6 +1111,7 @@ function appendAgentToolCall(
state: StreamItem[],
data: AgentToolCallData,
timestamp: Date,
timelineCursor?: TimelinePosition,
): StreamItem[] {
const existingIndex = findExistingAgentToolCallIndex(state, data.callId);
@@ -954,7 +1120,7 @@ function appendAgentToolCall(
if (!existing || !isAgentToolCallItem(existing)) {
return state;
}
const merged = mergeAgentToolCallItem(existing, data, timestamp);
const merged = mergeAgentToolCallItem(existing, data, timestamp, timelineCursor);
if (
merged.payload.data.provider === existing.payload.data.provider &&
@@ -963,7 +1129,8 @@ function appendAgentToolCall(
merged.payload.data.status === existing.payload.data.status &&
merged.payload.data.error === existing.payload.data.error &&
merged.payload.data.detail === existing.payload.data.detail &&
merged.payload.data.metadata === existing.payload.data.metadata
merged.payload.data.metadata === existing.payload.data.metadata &&
merged.timelineCursor === existing.timelineCursor
) {
return state;
}
@@ -976,6 +1143,7 @@ function appendAgentToolCall(
const item: ToolCallItem = {
kind: "tool_call",
id: `agent_tool_${data.callId}`,
...(timelineCursor ? { timelineCursor } : {}),
timestamp,
payload: {
source: "agent",
@@ -1004,6 +1172,7 @@ function appendTodoList(
provider: AgentProvider,
items: TodoEntry[],
timestamp: Date,
timelineCursor?: TimelinePosition,
): StreamItem[] {
const normalizedItems = items.map((item) => ({
text: item.text,
@@ -1015,6 +1184,7 @@ function appendTodoList(
const next = [...state];
const updated: TodoListItem = {
...lastItem,
...(timelineCursor ? { timelineCursor } : {}),
items: normalizedItems,
timestamp,
};
@@ -1028,6 +1198,7 @@ function appendTodoList(
const entry: TodoListItem = {
kind: "todo_list",
id: entryId,
...(timelineCursor ? { timelineCursor } : {}),
timestamp,
provider,
items: normalizedItems,
@@ -1044,6 +1215,7 @@ function reduceTimelineToolCall(
{ type: "tool_call" }
>,
timestamp: Date,
timelineCursor?: TimelinePosition,
): StreamItem[] {
const normalizedToolName = item.name
.trim()
@@ -1066,6 +1238,7 @@ function reduceTimelineToolCall(
event.provider,
tasks.map((entry) => ({ text: entry.text, completed: entry.completed })),
timestamp,
timelineCursor,
);
}
@@ -1076,6 +1249,7 @@ function reduceTimelineToolCall(
event.provider,
tasks.map((entry) => ({ text: entry.text, completed: entry.completed })),
timestamp,
timelineCursor,
);
}
@@ -1091,6 +1265,7 @@ function reduceTimelineToolCall(
metadata: item.metadata,
},
timestamp,
timelineCursor,
);
}
@@ -1101,6 +1276,7 @@ function reduceTimelineCompaction(
{ type: "compaction" }
>,
timestamp: Date,
timelineCursor?: TimelinePosition,
): StreamItem[] {
if (item.status === "completed") {
const loadingIdx = state.findIndex((s) => s.kind === "compaction" && s.status === "loading");
@@ -1108,6 +1284,7 @@ function reduceTimelineCompaction(
if (loadingIdx >= 0 && existing && existing.kind === "compaction") {
const updated: CompactionItem = {
...existing,
...(timelineCursor ? { timelineCursor } : {}),
status: "completed",
trigger: item.trigger ?? existing.trigger,
preTokens: item.preTokens ?? existing.preTokens,
@@ -1121,6 +1298,7 @@ function reduceTimelineCompaction(
const compaction: CompactionItem = {
kind: "compaction",
id: createTimelineId("compaction", item.status, timestamp),
...(timelineCursor ? { timelineCursor } : {}),
timestamp,
status: item.status,
trigger: item.trigger,
@@ -1148,6 +1326,7 @@ function reduceTimelineEvent(
source,
item.messageId,
item.clientMessageId,
timelineCursor,
),
);
case "assistant_message":
@@ -1163,9 +1342,11 @@ function reduceTimelineEvent(
),
);
case "reasoning":
return appendThought(state, item.text, timestamp);
return appendThought(state, item.text, timestamp, timelineCursor);
case "tool_call":
return finalizeActiveThoughts(reduceTimelineToolCall(state, event, item, timestamp));
return finalizeActiveThoughts(
reduceTimelineToolCall(state, event, item, timestamp, timelineCursor),
);
case "todo": {
if (event.provider === "claude") {
return finalizeActiveThoughts(state);
@@ -1174,12 +1355,15 @@ function reduceTimelineEvent(
text: todo.text,
completed: todo.completed,
}));
return finalizeActiveThoughts(appendTodoList(state, event.provider, items, timestamp));
return finalizeActiveThoughts(
appendTodoList(state, event.provider, items, timestamp, timelineCursor),
);
}
case "error": {
const activity: ActivityLogItem = {
kind: "activity_log",
id: createTimelineId("error", item.message ?? "", timestamp),
...(timelineCursor ? { timelineCursor } : {}),
timestamp,
activityType: "error",
message: item.message ?? "Unknown error",
@@ -1187,7 +1371,9 @@ function reduceTimelineEvent(
return finalizeActiveThoughts(appendActivityLog(state, activity));
}
case "compaction":
return finalizeActiveThoughts(reduceTimelineCompaction(state, item, timestamp));
return finalizeActiveThoughts(
reduceTimelineCompaction(state, item, timestamp, timelineCursor),
);
default:
return state;
}
@@ -1510,8 +1696,10 @@ function applyCanonicalUserMessageEvent(params: {
head: StreamItem[];
event: AgentStreamEventPayload;
timestamp: Date;
timelineCursor?: TimelinePosition;
unmatchedInsert?: "tail" | "head";
}): ApplyStreamEventResult | null {
const { tail, head, event, timestamp } = params;
const { tail, head, event, timestamp, timelineCursor, unmatchedInsert = "tail" } = params;
if (event.type !== "timeline" || event.item.type !== "user_message") return null;
const normalized = normalizeChunk(event.item.text);
@@ -1523,9 +1711,29 @@ function applyCanonicalUserMessageEvent(params: {
createUniqueTimelineId([...tail, ...head], "user", normalized.chunk.trim(), timestamp),
messageId: event.item.messageId,
clientMessageId: event.item.clientMessageId,
timelineCursor,
text: normalized.chunk,
timestamp,
});
if (unmatchedInsert === "head") {
const reconciled = upsertUserMessageAcrossStream({
tail,
head,
message: canonical,
insert: normalized.hasContent ? "head" : "none",
presentation: "existing",
});
return {
tail: reconciled.tail,
head: reconciled.head,
changedTail: reconciled.changedTail,
changedHead: reconciled.changedHead,
acknowledgedClientMessageIds:
reconciled.location?.matched && reconciled.location.message.clientMessageId
? [reconciled.location.message.clientMessageId]
: [],
};
}
const reconciled = placeCanonicalUserMessageAtTail(flushedTail, canonical, normalized.hasContent);
return {
tail: reconciled.items,
@@ -1557,9 +1765,17 @@ export function applyStreamEvent(params: {
timestamp: Date;
source?: StreamUpdateSource;
timelineCursor?: TimelinePosition;
unmatchedUserMessageInsert?: "tail" | "head";
}): ApplyStreamEventResult {
const { tail, head, event, timestamp } = params;
const canonicalUserResult = applyCanonicalUserMessageEvent({ tail, head, event, timestamp });
const canonicalUserResult = applyCanonicalUserMessageEvent({
tail,
head,
event,
timestamp,
timelineCursor: params.timelineCursor,
unmatchedInsert: params.unmatchedUserMessageInsert,
});
if (canonicalUserResult) return canonicalUserResult;
const source = params.source ?? "live";
let nextTail = tail;
@@ -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;