diff --git a/apps/web/src/components/ChatView.browser.test.tsx b/apps/web/src/components/ChatView.browser.test.tsx
index dc4908867a3e1..45cb00e12b31a 100644
--- a/apps/web/src/components/ChatView.browser.test.tsx
+++ b/apps/web/src/components/ChatView.browser.test.tsx
@@ -1,10 +1,10 @@
import { createMemoryHistory } from "@tanstack/react-router";
-import { scopeProjectRef } from "@t3tools/client-runtime/environment";
-import { ThreadId } from "@t3tools/contracts";
+import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment";
+import { RunId, ThreadId } from "@t3tools/contracts";
import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings";
import type * as Context from "effect/Context";
import { expect, it, vi } from "vite-plus/test";
-import { page } from "vite-plus/test/browser";
+import { cdp, page } from "vite-plus/test/browser";
import { AppRoot } from "~/AppRoot";
// The route loads ChatView lazily, and its first transform (React Compiler over ~4k lines)
@@ -13,14 +13,18 @@ import "~/components/ChatView";
import { DraftId, useComposerDraftStore } from "~/composerDraftStore";
import { __setClientSettingsForTests } from "~/hooks/useSettings";
import { getRouter } from "~/router";
+import { appAtomRegistry } from "~/rpc/atomRegistry";
+import { environmentThreadDetails } from "~/state/threads";
import {
ENVIRONMENT_ID,
PROJECT_ID,
+ assistantItem,
+ emitThreadEvent,
installFakeEnvironment,
threadWithHistory,
type FakeDriver,
} from "../../test/fakeEnvironment";
-import { render } from "../../test/render";
+import { act, render } from "../../test/render";
// The socket is the only fake layer; see test/appScenarios.renders.tsx.
vi.mock("../../../../packages/client-runtime/src/connection/driver.ts", async (importOriginal) => {
@@ -67,6 +71,58 @@ it("a new draft does not load its reserved thread before the server lists it", a
expect(threadRequests).toEqual([]);
});
+it("a mounted thread view lets go of the thread structures it has replaced", async () => {
+ // Items are shared with the fixture, so a reopen's snapshot also carries the earlier updates.
+ const history = threadWithHistory("updated", 3);
+ const items = [...history.items];
+ const updated = { ...history, items };
+ const other = threadWithHistory("other", 3);
+ using _environment = installFakeEnvironment([updated, other]);
+ __setClientSettingsForTests({
+ ...DEFAULT_CLIENT_SETTINGS,
+ onboardingCompletedAt: "2026-01-01T00:00:00.000Z",
+ });
+ const router = getRouter(createMemoryHistory({ initialEntries: ["/"] }));
+ await router.load();
+ await using _app = await render(, { router: false });
+ const open = async (threadId: ThreadId, text: string) => {
+ await act(() =>
+ router.navigate({
+ to: "/$environmentId/$threadId",
+ params: { environmentId: ENVIRONMENT_ID, threadId },
+ }),
+ );
+ await expect.element(page.getByText(text).first()).toBeVisible();
+ };
+ const structureAtom = environmentThreadDetails.quietThreadAtom(
+ scopeThreadRef(ENVIRONMENT_ID, updated.id),
+ );
+
+ // Each round, a new answer arrives while the thread is shown, then the user switches away.
+ const shown: Array> = [];
+ await open(other.id, "Answer 2 for other.");
+ for (let round = 0; round < 6; round++) {
+ await open(updated.id, round === 0 ? "Answer 2 for updated." : `Update ${round - 1}.`);
+ const update = assistantItem(
+ updated.id,
+ RunId.make("updated-run-2"),
+ `updated-update-${round}`,
+ 6 + round,
+ `Update ${round}.`,
+ );
+ items.push(update);
+ await act(() => emitThreadEvent(updated.id, { type: "turn-item.updated", payload: update }));
+ await expect.element(page.getByText(`Update ${round}.`)).toBeVisible();
+ shown.push(new WeakRef(appAtomRegistry.get(structureAtom)!.projection));
+ await open(other.id, "Answer 2 for other.");
+ }
+ await cdp().send("HeapProfiler.collectGarbage");
+
+ // The thread keeps its latest structure, and the timeline may remember the one before it.
+ const kept = shown.slice(0, -2).flatMap((structure, round) => (structure.deref() ? [round] : []));
+ expect(kept).toEqual([]);
+});
+
it("opens the thread actions menu for an older settled thread the shell leaves out", async () => {
const thread = { ...threadWithHistory("older-settled", 1), olderSettled: true as const };
using _environment = installFakeEnvironment([thread]);
diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts
index 7de02cc3941ad..8f2a6b6a8e9d9 100644
--- a/apps/web/src/components/ChatView.logic.test.ts
+++ b/apps/web/src/components/ChatView.logic.test.ts
@@ -31,6 +31,7 @@ import * as DateTime from "effect/DateTime";
import * as Option from "effect/Option";
import { afterEach, describe, expect, it, vi } from "vite-plus/test";
import { Atom } from "effect/reactivity";
+import { rememberTimelinePosition } from "./chat/timelineScrollAnchoring";
import { appAtomRegistry } from "../rpc/atomRegistry";
import { environmentThreadDetails } from "../state/threads";
@@ -41,6 +42,7 @@ import {
resolveComposerProviderSelection,
resolveWorktreeSetupProgress,
peekRememberedThreadTimeline,
+ releaseRememberedThreadTimeline,
rememberReadyThreadTimeline,
resetHeldThreadTimeline,
resolveThreadSwitchTimeline,
@@ -422,6 +424,48 @@ describe("resolveThreadSwitchTimeline", () => {
).toEqual(["a1", "a2"]);
});
+ describe("a released thread", () => {
+ const entries = Array.from({ length: 200 }, (_, index) => `entry-${index}`);
+ const threadKey = "env-1:released";
+
+ it("keeps only the end of its remembered timeline", () => {
+ rememberReadyThreadTimeline({ threadKey, entries });
+ releaseRememberedThreadTimeline(threadKey);
+ const kept = peekRememberedThreadTimeline(threadKey)!;
+ expect(kept.length).toBeLessThan(100);
+ expect(kept).toEqual(entries.slice(-kept.length));
+ expect(
+ resolveThreadSwitchTimeline({ loading: true, activeThreadKey: threadKey, nextEntries: [] }),
+ ).toEqual(kept);
+ });
+
+ it("leaves other threads' remembered timelines whole", () => {
+ rememberReadyThreadTimeline({ threadKey: "env-1:other", entries });
+ rememberReadyThreadTimeline({ threadKey, entries });
+ releaseRememberedThreadTimeline(threadKey);
+ expect(peekRememberedThreadTimeline("env-1:other")).toEqual(entries);
+ });
+
+ it("keeps none when the reader's place is further up the timeline", () => {
+ rememberReadyThreadTimeline({ threadKey, entries });
+ rememberTimelinePosition(
+ threadKey,
+ { rowId: "entry-3", offsetWithinRow: 0, scrollOffset: 0, atEnd: false },
+ false,
+ );
+ releaseRememberedThreadTimeline(threadKey);
+ expect(peekRememberedThreadTimeline(threadKey)).toBeNull();
+ });
+
+ it("still remembers sixteen threads", () => {
+ const keys = Array.from({ length: 18 }, (_, index) => `env-1:thread-${index}`);
+ for (const key of keys) rememberReadyThreadTimeline({ threadKey: key, entries: [key] });
+ expect(keys.map((key) => peekRememberedThreadTimeline(key) !== null)).toEqual(
+ keys.map((_, index) => index >= 2),
+ );
+ });
+ });
+
it("does not paint the last-viewed thread for a destination with nothing remembered", () => {
rememberReadyThreadTimeline(held);
expect(
diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts
index 09a25ee55910d..67a9b43ab41a4 100644
--- a/apps/web/src/components/ChatView.logic.ts
+++ b/apps/web/src/components/ChatView.logic.ts
@@ -71,7 +71,8 @@ import {
} from "../composerDraftStore";
import * as Schema from "effect/Schema";
import { appAtomRegistry } from "../rpc/atomRegistry";
-import { environmentThreadDetails } from "../state/threads";
+import { environmentThreadDetails, onLeftThreadReleased } from "../state/threads";
+import { readTimelinePosition } from "./chat/timelineScrollAnchoring";
import {
filterTerminalContextsWithText,
formatTerminalContextLabel,
@@ -377,6 +378,31 @@ export function peekRememberedThreadTimeline(
return (rememberedThreadTimelines.get(threadKey)?.entries as T | undefined) ?? null;
}
+// Fills the pane at the end of a timeline until the thread is back from the cache.
+const RELEASED_THREAD_TIMELINE_ENTRIES = 60;
+
+/**
+ * These rows point at the thread's items, so a released thread keeps only its last few. A reader's
+ * place further up can only be restored on the whole timeline, so that thread keeps none.
+ */
+export function releaseRememberedThreadTimeline(threadKey: string): void {
+ const held = rememberedThreadTimelines.get(threadKey);
+ if (held === undefined) return;
+ if (readTimelinePosition(threadKey)?.atEnd === false) {
+ rememberedThreadTimelines.delete(threadKey);
+ rememberedThreadTimelineOrder = rememberedThreadTimelineOrder.filter(
+ (key) => key !== threadKey,
+ );
+ return;
+ }
+ rememberedThreadTimelines.set(threadKey, {
+ threadKey,
+ entries: held.entries.slice(-RELEASED_THREAD_TIMELINE_ENTRIES),
+ });
+}
+
+onLeftThreadReleased(releaseRememberedThreadTimeline);
+
export function resetHeldThreadTimeline(): void {
rememberedThreadTimelines = new Map();
rememberedThreadTimelineOrder = [];
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx
index f4561188df0cc..7d021e3935e33 100644
--- a/apps/web/src/components/ChatView.tsx
+++ b/apps/web/src/components/ChatView.tsx
@@ -187,7 +187,6 @@ import { isEditableFocused } from "../lib/editableFocus";
import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings";
import { shortcutLabelForCommand } from "../keybindings";
import { cn } from "~/lib/utils";
-import { isModelPickerOpen } from "../modelPickerVisibility";
import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar";
import { toastManager } from "./ui/toast";
import { projectScriptIdFromCommand } from "~/projectScripts";
@@ -345,6 +344,7 @@ import {
} from "./ChatView.logic";
import { useLocalStorage } from "~/hooks/useLocalStorage";
import { useComposerHandleContext } from "../composerHandleContext";
+import { useComposerFocus } from "./chat/useComposerFocus";
import { clampFileAttachmentUploadBytes } from "@t3tools/client-runtime/state/attachments";
import { appAtomRegistry } from "../rpc/atomRegistry";
import { assetEnvironment } from "../state/assets";
@@ -1795,10 +1795,8 @@ export default function ChatView(props: ChatViewProps) {
sendInFlightRef,
});
- const focusComposer = () => {
- composerRef.current?.focusAtEnd();
- };
- const focusComposerStable = useStableCallback(focusComposer);
+ const { focusComposer: focusComposerStable, scheduleComposerFocus: scheduleComposerFocusStable } =
+ useComposerFocus(composerRef);
const canInterruptRunningThread =
canOperateThread && deriveCanInterruptRunningThread(activeThread !== undefined, activeRuntime);
const onInterrupt = async () => {
@@ -1825,13 +1823,6 @@ export default function ChatView(props: ChatViewProps) {
() => (embedded ? undefined : subscribeSnapShotComposerFocus(focusComposerStable)),
[embedded, focusComposerStable],
);
- const scheduleComposerFocus = () => {
- window.requestAnimationFrame(() => {
- // The model picker stays open through its own changes, so it keeps focus.
- if (!isModelPickerOpen()) focusComposer();
- });
- };
- const scheduleComposerFocusStable = useStableCallback(scheduleComposerFocus);
const insertArtifactTemplate = (template: CodexArtifactTemplate) => {
const composer = composerRef.current;
if (!composer) return;
@@ -1846,7 +1837,7 @@ export default function ChatView(props: ChatViewProps) {
});
return;
}
- scheduleComposerFocus();
+ scheduleComposerFocusStable();
};
const insertArtifactTemplateStable = useStableCallback(insertArtifactTemplate);
const sendAppMessage = async (text: string) => {
@@ -2851,7 +2842,7 @@ export default function ChatView(props: ChatViewProps) {
canOverrideServerThreadEnvMode,
setPendingServerThreadEnvModeEntry,
setPendingServerThreadStartFromOriginByThreadId,
- scheduleComposerFocus,
+ scheduleComposerFocus: scheduleComposerFocusStable,
});
const { onResume, isResuming } = useComposerResume({
environmentId,
diff --git a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx
index 080646fa887cd..74738326153fe 100644
--- a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx
+++ b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx
@@ -1367,6 +1367,55 @@ describe("MessagesTimeline thread find", () => {
expect(top).toBeLessThan(bounds.bottom);
});
});
+
+ it("lets go of a timeline once find has no match in it", async () => {
+ setup();
+ const base = baseProps();
+ await using screen = await renderElement(
+ ,
+ );
+ // In its own function, so nothing in this test's frame holds the entries after the switch.
+ const revealMatch = async () => {
+ const replies = Array.from({ length: 40 }, (_, index) =>
+ message(
+ `${index === 5 ? "Needle here" : `Reply ${index}`}\n\n${"line\n\n".repeat(8)}`,
+ { role: "assistant" },
+ `r-${index}`,
+ ),
+ );
+ await screen.rerender(
+ ,
+ );
+ const viewport = base.listRef.current!.getScrollableNode()!;
+ await vi.waitFor(() => {
+ const row = screen.container.querySelector('[data-timeline-row-id="entry-r-5"]');
+ expect(row).not.toBeNull();
+ const bounds = viewport.getBoundingClientRect();
+ const top = row!.getBoundingClientRect().top;
+ expect(top).toBeGreaterThanOrEqual(bounds.top);
+ expect(top).toBeLessThan(bounds.bottom);
+ });
+ return new WeakRef(replies);
+ };
+ const shown = await revealMatch();
+
+ // Twice, because React keeps the previous render's props until the next render.
+ for (const text of ["Another thread", "Another thread, updated"]) {
+ await screen.rerender();
+ }
+ await cdp().send("HeapProfiler.collectGarbage");
+
+ expect(shown.deref()).toBeUndefined();
+ });
});
describe("compact reasoning math", () => {
diff --git a/apps/web/src/components/chat/ThreadTimeline.tsx b/apps/web/src/components/chat/ThreadTimeline.tsx
index a68d494d8b0a0..02c43dfb486e6 100644
--- a/apps/web/src/components/chat/ThreadTimeline.tsx
+++ b/apps/web/src/components/chat/ThreadTimeline.tsx
@@ -1,14 +1,16 @@
import type { ScopedThreadRef } from "@t3tools/contracts";
-import { type ComponentProps, useLayoutEffect } from "react";
+import { type ComponentProps, use, useLayoutEffect } from "react";
import { useIncrementalTimelineEntries } from "../../hooks/useIncrementalTimelineEntries";
import type { TimelineEntriesInput, TimelineEntry } from "../../session-logic";
import { useTimelineVisibleTurnItems } from "../../state/entities";
+import { markThreadVisited } from "../../state/threads";
import {
peekRememberedThreadTimeline,
rememberReadyThreadTimeline,
resolveThreadSwitchTimeline,
timelineHasEphemeralPreviewUrls,
} from "../ChatView.logic";
+import { BackgroundThreadContext } from "./backgroundThread";
import { MessagesTimeline } from "./MessagesTimeline";
/**
@@ -47,6 +49,12 @@ export function ThreadTimeline({
nextEntries: entries,
rememberedForActive: peekRememberedThreadTimeline(activeThreadKey),
});
+ // A side chat shows its thread beside the one the user navigated to.
+ const backgroundThread = use(BackgroundThreadContext);
+ // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- tells the left-thread budget which thread the user navigated to, once it is on screen
+ useLayoutEffect(() => {
+ if (!backgroundThread && threadDetailRef !== null) markThreadVisited(threadDetailRef);
+ }, [backgroundThread, threadDetailRef]);
// oxlint-disable-next-line t3code/no-unreasoned-state-sync -- stores the committed ready timeline in the module-level cache used during thread switches
useLayoutEffect(() => {
if (detailLoading || entries.length === 0 || timelineHasEphemeralPreviewUrls(entries)) {
diff --git a/apps/web/src/components/chat/useComposerFocus.browser.test.tsx b/apps/web/src/components/chat/useComposerFocus.browser.test.tsx
new file mode 100644
index 0000000000000..e153c2669170e
--- /dev/null
+++ b/apps/web/src/components/chat/useComposerFocus.browser.test.tsx
@@ -0,0 +1,65 @@
+import type { RefObject } from "react";
+import { expect, it, vi } from "vite-plus/test";
+
+import type { ChatComposerHandle } from "./ChatComposer";
+import { registerModelPickerContent } from "../../modelPickerVisibility";
+import { render } from "../../../test/render";
+import { useComposerFocus } from "./useComposerFocus";
+
+type Focus = ReturnType;
+
+function Probe(props: {
+ readonly composerRef: RefObject;
+ readonly onRender: (focus: Focus) => void;
+}) {
+ props.onRender(useComposerFocus(props.composerRef));
+ return null;
+}
+
+const handle = (focusAtEnd: () => void) => ({ focusAtEnd }) as unknown as ChatComposerHandle;
+
+it("focuses the composer's current handle without changing identity", async () => {
+ const composerRef: RefObject = { current: null };
+ const renders: Focus[] = [];
+ const onRender = (focus: Focus) => renders.push(focus);
+ await using screen = await render();
+
+ const replaced = vi.fn();
+ const current = vi.fn();
+ composerRef.current = handle(replaced);
+ // The composer replaces its handle as the thread changes, with no render here in between.
+ composerRef.current = handle(current);
+ renders.at(-1)!.focusComposer();
+ expect(current).toHaveBeenCalledTimes(1);
+
+ await screen.rerender();
+ expect(renders.at(-1)!.focusComposer).toBe(renders[0]!.focusComposer);
+ expect(renders.at(-1)!.scheduleComposerFocus).toBe(renders[0]!.scheduleComposerFocus);
+ expect(replaced).not.toHaveBeenCalled();
+});
+
+it("schedules focus for the next frame unless the model picker is open by then", async () => {
+ const focusAtEnd = vi.fn();
+ const composerRef: RefObject = { current: handle(focusAtEnd) };
+ const renders: Focus[] = [];
+ await using _screen = await render(
+ renders.push(focus)} />,
+ );
+ const { scheduleComposerFocus } = renders.at(-1)!;
+ const frames: FrameRequestCallback[] = [];
+ using _frames = vi.spyOn(window, "requestAnimationFrame").mockImplementation((frame) => {
+ frames.push(frame);
+ return frames.length;
+ });
+
+ scheduleComposerFocus();
+ expect(focusAtEnd).not.toHaveBeenCalled();
+ frames.shift()!(0);
+ expect(focusAtEnd).toHaveBeenCalledTimes(1);
+
+ scheduleComposerFocus();
+ const closeModelPicker = registerModelPickerContent();
+ frames.shift()!(0);
+ closeModelPicker();
+ expect(focusAtEnd).toHaveBeenCalledTimes(1);
+});
diff --git a/apps/web/src/components/chat/useComposerFocus.ts b/apps/web/src/components/chat/useComposerFocus.ts
new file mode 100644
index 0000000000000..38158e26033a3
--- /dev/null
+++ b/apps/web/src/components/chat/useComposerFocus.ts
@@ -0,0 +1,21 @@
+import type { ComposerHandleRef } from "../../composerHandleContext";
+import { useStableCallback } from "../../hooks/useStableCallback";
+import { isModelPickerOpen } from "../../modelPickerVisibility";
+
+/**
+ * Made outside ChatView's render: a memoized closure there keeps that render's scope, thread
+ * projection included, and the scope keeps the previous render's, so every thread copy
+ * ChatView showed stayed in memory until it unmounted.
+ */
+export function useComposerFocus(composerRef: ComposerHandleRef) {
+ const focusComposer = useStableCallback(() => {
+ composerRef.current?.focusAtEnd();
+ });
+ const scheduleComposerFocus = useStableCallback(() => {
+ window.requestAnimationFrame(() => {
+ // The model picker stays open through its own changes, so it keeps focus.
+ if (!isModelPickerOpen()) focusComposer();
+ });
+ });
+ return { focusComposer, scheduleComposerFocus };
+}
diff --git a/apps/web/src/components/chat/useQueuedRunEdit.browser.test.tsx b/apps/web/src/components/chat/useQueuedRunEdit.browser.test.tsx
index 4a58d25e48757..c59534391a0d8 100644
--- a/apps/web/src/components/chat/useQueuedRunEdit.browser.test.tsx
+++ b/apps/web/src/components/chat/useQueuedRunEdit.browser.test.tsx
@@ -64,6 +64,7 @@ vi.mock("../../state/threads", async () => {
environmentThreadShells: {
threadShellAtom: () => Atom.make(null),
},
+ onLeftThreadReleased: () => {},
};
});
diff --git a/apps/web/src/components/chat/useThreadFindNavigation.ts b/apps/web/src/components/chat/useThreadFindNavigation.ts
index 054faa23201e0..419b3f724000a 100644
--- a/apps/web/src/components/chat/useThreadFindNavigation.ts
+++ b/apps/web/src/components/chat/useThreadFindNavigation.ts
@@ -28,6 +28,34 @@ interface RevealState {
positionedEntries: FindEntries | null;
}
+/**
+ * Out of the hook, so the listener it keeps for as long as the container lives cannot pin a render
+ * of the timeline, and the entries of whichever thread that render showed.
+ */
+function trackPagingPosition(
+ container: HTMLElement,
+ stateRef: RefObject,
+ readInputs: () => RevealInputs,
+): () => void {
+ const onScroll = () => {
+ const state = stateRef.current;
+ const position = state.pagingPosition;
+ const range = state.activeRange;
+ if (
+ !position ||
+ position.entries !== readInputs().entries ||
+ !range?.startContainer.isConnected
+ )
+ return;
+ const rect = range.getBoundingClientRect();
+ const viewport = container.getBoundingClientRect();
+ if (rect.bottom < viewport.top || rect.top > viewport.bottom) state.pagingPosition = null;
+ else position.top = rect.top;
+ };
+ container.addEventListener("scroll", onScroll, { passive: true });
+ return () => container.removeEventListener("scroll", onScroll);
+}
+
/**
* Brings the active match into view. Async continuations re-enter with the
* inputs current by then, and stop once the match changes or the list unmounts.
@@ -58,6 +86,7 @@ function revealThreadFindMatch(
state.settledMatch = null;
state.revealedMatch = null;
state.pagingPosition = null;
+ state.positionedEntries = null;
return;
}
const list = listRef.current;
@@ -229,23 +258,7 @@ export function useThreadFindNavigation({
// oxlint-disable-next-line t3code/no-unreasoned-state-sync -- attaches a scroll listener to the timeline container to track the active match position
useEffect(() => {
if (!container) return;
- const onScroll = () => {
- const state = stateRef.current;
- const position = state.pagingPosition;
- const range = state.activeRange;
- if (
- !position ||
- position.entries !== readInputs().entries ||
- !range?.startContainer.isConnected
- )
- return;
- const rect = range.getBoundingClientRect();
- const viewport = container.getBoundingClientRect();
- if (rect.bottom < viewport.top || rect.top > viewport.bottom) state.pagingPosition = null;
- else position.top = rect.top;
- };
- container.addEventListener("scroll", onScroll, { passive: true });
- return () => container.removeEventListener("scroll", onScroll);
+ return trackPagingPosition(container, stateRef, readInputs);
}, [container]);
const requestedPagesRef = useRef<{ key: string | null; pages: Set }>({
key: null,
diff --git a/apps/web/src/components/chat/useTimelineFollow.ts b/apps/web/src/components/chat/useTimelineFollow.ts
index 987713bb77102..7a792b2391cc1 100644
--- a/apps/web/src/components/chat/useTimelineFollow.ts
+++ b/apps/web/src/components/chat/useTimelineFollow.ts
@@ -56,16 +56,25 @@ interface TimelineFollowVisit {
* current visit, so a thread switch leaves the old one behind instead of
* resetting it.
*/
+// Out here so the debouncer it keeps cannot pin a render of the hook, and the timeline of whichever
+// thread that render showed.
function createVisitScrollState(
visit: TimelineFollowVisit,
- showScrollToBottom: (visit: TimelineFollowVisit) => void,
+ stateRef: { readonly current: { readonly visit: TimelineFollowVisit } | null },
+ setPill: (pill: { readonly visit: TimelineFollowVisit; readonly show: boolean }) => void,
) {
return {
visit,
// Debounce *showing* the scroll-to-bottom pill so it doesn't flash during
// thread switches. LegendList fires scroll events with isAtEnd=false while
// initialScrollAtEnd is settling; hiding is always immediate.
- showScrollDebouncer: new Debouncer(() => showScrollToBottom(visit), { wait: 150 }),
+ showScrollDebouncer: new Debouncer(
+ () => {
+ // A debounce that outlived its visit must not overwrite the current one's pill.
+ if (stateRef.current?.visit === visit) setPill({ visit, show: true });
+ },
+ { wait: 150 },
+ ),
positionedAnchor: null as MessageId | null,
settledAnchor: null as MessageId | null,
releaseAnchorScrollHold: null as (() => void) | null,
@@ -221,11 +230,7 @@ export function useTimelineFollow(input: TimelineFollowInput) {
/** Only from handlers: the bookkeeping of the visit they run in. */
const visitScroll = () => {
if (visitScrollRef.current?.visit !== visit) {
- visitScrollRef.current = createVisitScrollState(visit, (shownVisit) => {
- // A debounce that outlived its visit must not overwrite the current one's pill.
- if (visitScrollRef.current?.visit === shownVisit)
- setPill({ visit: shownVisit, show: true });
- });
+ visitScrollRef.current = createVisitScrollState(visit, visitScrollRef, setPill);
}
return visitScrollRef.current;
};
diff --git a/apps/web/src/connection/storage.test.ts b/apps/web/src/connection/storage.test.ts
index 7afe2b1e8573f..b08f2f8763ef2 100644
--- a/apps/web/src/connection/storage.test.ts
+++ b/apps/web/src/connection/storage.test.ts
@@ -2,7 +2,7 @@ import {
ConnectionTransientError,
PrimaryConnectionTarget,
} from "@t3tools/client-runtime/connection";
-import { EnvironmentId, ThreadId } from "@t3tools/contracts";
+import { EnvironmentId, ThreadId, TurnItemId } from "@t3tools/contracts";
import { ConnectionCatalogDocument, Persistence } from "@t3tools/client-runtime/platform";
import { describe, expect, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
@@ -12,6 +12,7 @@ import * as Schema from "effect/Schema";
import * as Stream from "effect/Stream";
import { afterEach, vi } from "vite-plus/test";
+import { makeThreadProjectionFixture } from "../test-fixtures";
import * as ConnectionStorage from "./storage";
const emptyCatalog = {
@@ -197,6 +198,95 @@ describe("IndexedDB connection closed without a close event", () => {
);
});
+describe("thread cache records in IndexedDB", () => {
+ const environmentId = EnvironmentId.make("environment-1");
+ const output = "a command output that should reach the disk once";
+
+ const stubDatabase = (values: Map) => {
+ const settle = (request: T) => {
+ queueMicrotask(() => request.dispatchEvent(new Event("success")));
+ return request;
+ };
+ const database = Object.assign(new EventTarget(), {
+ close: vi.fn(),
+ transaction: () => {
+ const transaction = new EventTarget();
+ return Object.assign(transaction, {
+ error: null,
+ objectStore: () => ({
+ get: (key: IDBValidKey) =>
+ settle(Object.assign(new EventTarget(), { result: values.get(key), error: null })),
+ put: (value: unknown, key: IDBValidKey) => {
+ values.set(key, value);
+ queueMicrotask(() => transaction.dispatchEvent(new Event("complete")));
+ },
+ }),
+ });
+ },
+ }) as unknown as IDBDatabase;
+ vi.stubGlobal("window", {});
+ vi.stubGlobal("indexedDB", {
+ open: () => {
+ const request = Object.assign(new EventTarget(), { result: database, error: null });
+ queueMicrotask(() => request.dispatchEvent(new Event("success")));
+ return request;
+ },
+ });
+ };
+
+ it.effect("saves each item once and loads it as one object", () =>
+ Effect.gen(function* () {
+ const values = new Map();
+ stubDatabase(values);
+ const fixture = makeThreadProjectionFixture();
+ const threadId = fixture.thread.id;
+ const at = fixture.updatedAt;
+ const item = {
+ id: TurnItemId.make("command"),
+ type: "command_execution" as const,
+ threadId,
+ runId: null,
+ nodeId: null,
+ providerThreadId: null,
+ providerTurnId: null,
+ nativeItemRef: null,
+ parentItemId: null,
+ ordinal: 1,
+ status: "completed" as const,
+ title: null,
+ input: "pwd",
+ output,
+ exitCode: 0,
+ startedAt: at,
+ completedAt: at,
+ updatedAt: at,
+ };
+ const projection = {
+ ...fixture,
+ turnItems: [item],
+ visibleTurnItems: [
+ {
+ position: 0,
+ visibility: "local" as const,
+ sourceThreadId: threadId,
+ sourceItemId: item.id,
+ item,
+ },
+ ],
+ };
+ yield* Effect.gen(function* () {
+ const cache = yield* Persistence.EnvironmentCacheStore;
+ yield* cache.saveThread(environmentId, { snapshotSequence: 2, projection });
+ const raw = [...values.values()][0] as string;
+ expect(raw.split(output).length - 1).toBe(1);
+ const loaded = Option.getOrThrow(yield* cache.loadThread(environmentId, threadId));
+ expect(loaded.projection.turnItems).toEqual([item]);
+ expect(loaded.projection.visibleTurnItems[0]!.item).toBe(loaded.projection.turnItems[0]);
+ }).pipe(Effect.provide(ConnectionStorage.layer));
+ }),
+ );
+});
+
describe("browser GitHub routing permissions", () => {
it.effect("revokes across runtimes before storage events and resists stale catalog writes", () =>
Effect.gen(function* () {
diff --git a/apps/web/src/connection/storage.ts b/apps/web/src/connection/storage.ts
index b25abe442ba11..bb27d7ad8e89a 100644
--- a/apps/web/src/connection/storage.ts
+++ b/apps/web/src/connection/storage.ts
@@ -4,8 +4,8 @@ import {
EMPTY_CONNECTION_CATALOG_DOCUMENT,
ORCHESTRATION_CACHE_SCHEMA_VERSION,
StoredOrchestrationShellSnapshot,
- StoredOrchestrationThreadSnapshot,
decodeOrDiscardOrchestrationCache,
+ decodeStoredThreadSnapshot,
putRemoteDpopTokenInCatalog,
registerConnectionInCatalog,
removeCatalogValue,
@@ -64,8 +64,6 @@ const QUERY_PRUNE_BATCH_BYTES = 1024 * 1024;
const CATALOG_KEY = "document";
const StoredShellSnapshot = StoredOrchestrationShellSnapshot;
const StoredShellSnapshotJson = Schema.fromJsonString(StoredShellSnapshot);
-const StoredThreadSnapshot = StoredOrchestrationThreadSnapshot;
-const StoredThreadSnapshotJson = Schema.fromJsonString(StoredThreadSnapshot);
const StoredServerConfig = Schema.Struct({
schemaVersion: Schema.Literal(1),
environmentId: EnvironmentId,
@@ -87,7 +85,6 @@ const ConnectionCatalogDocumentJson = Schema.fromJsonString(ConnectionCatalogDoc
const decodeConnectionCatalogDocument = Schema.decodeUnknownEffect(ConnectionCatalogDocumentJson);
const encodeConnectionCatalogDocument = Schema.encodeEffect(ConnectionCatalogDocumentJson);
const decodeStoredShellSnapshot = Schema.decodeUnknownEffect(StoredShellSnapshotJson);
-const decodeStoredThreadSnapshot = Schema.decodeUnknownEffect(StoredThreadSnapshotJson);
const decodeStoredServerConfig = Schema.decodeUnknownEffect(StoredServerConfigJson);
const encodeStoredServerConfig = Schema.encodeEffect(StoredServerConfigJson);
const decodeStoredVcsRefs = Schema.decodeUnknownEffect(StoredVcsRefsJson);
@@ -724,7 +721,7 @@ export const layer = Layer.effectContext(
yieldToHost: yieldToBrowserTask,
});
const encodeStoredThreadSnapshot = makeStoredThreadSnapshotEncoder({
- format: "full",
+ format: "compact",
yieldToHost: yieldToBrowserTask,
});
const threadWriteOrder = makeThreadCacheWriteOrder();
diff --git a/apps/web/src/state/threads.ts b/apps/web/src/state/threads.ts
index 6d2ce2a07c90b..bc0e27d7622d7 100644
--- a/apps/web/src/state/threads.ts
+++ b/apps/web/src/state/threads.ts
@@ -1,6 +1,7 @@
import { createChildThreadApprovalsAtom } from "@t3tools/client-runtime/state/thread-requests";
import { useAtomValue } from "@effect/atom-react";
import { enabledEnvironmentIds } from "@t3tools/client-runtime/state/connections";
+import { scopedThreadKey } from "@t3tools/client-runtime/environment";
import { arrayElementsEqual } from "@t3tools/client-runtime/state/entities";
import {
createEnvironmentThreadDetailAtoms,
@@ -33,7 +34,24 @@ export const threadEnvironment = createThreadEnvironmentAtoms(
connectionAtomRuntime,
threadFamilies.snapshotAtom,
);
-const environmentThreads = createEnvironmentThreadStateAtoms(connectionAtomRuntime);
+// What left threads may keep in memory. A thread opened from a bounded server window costs a few MB
+// at most, so only threads grown by long streaming or scrollback reach it.
+const LEFT_THREADS_MAX_BYTES = 50 * 1024 * 1024;
+
+let leftThreadReleased: (threadKey: string) => void = () => {};
+
+/** Whoever remembers more of a left thread lets go of it here, together with its state. */
+export function onLeftThreadReleased(listener: (threadKey: string) => void): void {
+ leftThreadReleased = listener;
+}
+
+const environmentThreads = createEnvironmentThreadStateAtoms(connectionAtomRuntime, {
+ leftThreads: {
+ maxBytes: LEFT_THREADS_MAX_BYTES,
+ onRelease: (thread) => leftThreadReleased(scopedThreadKey(thread)),
+ },
+});
+export const markThreadVisited = environmentThreads.markThreadVisited;
export const environmentThreadDetails = createEnvironmentThreadDetailAtoms(
environmentThreads.stateAtom,
);
diff --git a/packages/client-runtime/src/platform/threadCacheEncoding.test.ts b/packages/client-runtime/src/platform/threadCacheEncoding.test.ts
index c62b8fc105152..e571cf6679f1d 100644
--- a/packages/client-runtime/src/platform/threadCacheEncoding.test.ts
+++ b/packages/client-runtime/src/platform/threadCacheEncoding.test.ts
@@ -1,8 +1,10 @@
import { describe, expect, it } from "@effect/vitest";
import {
EnvironmentId,
+ MessageId,
ThreadId,
TurnItemId,
+ type OrchestrationV2ThreadDetailSnapshot,
type OrchestrationV2ThreadProjection,
} from "@t3tools/contracts";
import * as DateTime from "effect/DateTime";
@@ -16,6 +18,7 @@ import {
StoredOrchestrationThreadSnapshot,
} from "./orchestrationCache.ts";
import {
+ COMPACT_THREAD_CACHE_SCHEMA_VERSION,
decodeStoredThreadSnapshot,
makeStoredThreadSnapshotEncoder,
} from "./threadCacheEncoding.ts";
@@ -139,3 +142,154 @@ describe("cooperative thread cache encoding", () => {
}),
);
});
+
+describe("thread cache records", () => {
+ const ENVIRONMENT_ID = EnvironmentId.make("environment-1");
+ const BODY = "a body long enough to count ".repeat(4);
+ const COMMAND_OUTPUT = "only the items carry this output";
+
+ const base = (id: string, ordinal: number) => ({
+ id: TurnItemId.make(id),
+ threadId: v2ThreadId,
+ runId: null,
+ nodeId: null,
+ providerThreadId: null,
+ providerTurnId: null,
+ nativeItemRef: null,
+ parentItemId: null,
+ ordinal,
+ status: "completed" as const,
+ title: null,
+ startedAt: v2Now,
+ completedAt: v2Now,
+ updatedAt: v2Now,
+ });
+
+ const messageId = MessageId.make("message-1");
+ const answer = {
+ ...base("answer", 1),
+ type: "assistant_message" as const,
+ messageId,
+ text: BODY,
+ streaming: false,
+ };
+ const command = {
+ ...base("command", 2),
+ type: "command_execution" as const,
+ input: "pwd",
+ output: COMMAND_OUTPUT,
+ exitCode: 0,
+ };
+ const rowOf = - (item: Item, position: number) => ({
+ position,
+ visibility: "local" as const,
+ sourceThreadId: v2ThreadId,
+ sourceItemId: item.id,
+ item,
+ });
+ const projection: OrchestrationV2ThreadProjection = {
+ ...v2Projection,
+ messages: [
+ {
+ id: messageId,
+ threadId: v2ThreadId,
+ runId: null,
+ nodeId: null,
+ role: "assistant",
+ text: BODY,
+ attachments: [],
+ streaming: false,
+ createdBy: "agent",
+ creationSource: "provider",
+ createdAt: v2Now,
+ updatedAt: v2Now,
+ },
+ ],
+ turnItems: [answer, command],
+ visibleTurnItems: [rowOf(answer, 0), rowOf(command, 1)],
+ };
+ const snapshot: OrchestrationV2ThreadDetailSnapshot = { snapshotSequence: 3, projection };
+ const stored = { environmentId: ENVIRONMENT_ID, threadId: v2ThreadId, snapshot };
+ const encodeFull = Schema.encodeEffect(Schema.fromJsonString(StoredOrchestrationThreadSnapshot));
+ const occurrences = (text: string, part: string) => text.split(part).length - 1;
+
+ const encodeCompactRecord = makeStoredThreadSnapshotEncoder({ format: "compact" });
+ const encodeStoredThreadSnapshot = (value: typeof stored) =>
+ encodeCompactRecord(value).pipe(Effect.map(Option.getOrThrow));
+
+ it.effect("writes each item once", () =>
+ Effect.gen(function* () {
+ const raw = yield* encodeStoredThreadSnapshot(stored);
+ expect(JSON.parse(raw).schemaVersion).toBe(COMPACT_THREAD_CACHE_SCHEMA_VERSION);
+ expect(occurrences(raw, COMMAND_OUTPUT)).toBe(1);
+ }),
+ );
+
+ it.effect("decodes to the same projection, with one object per item", () =>
+ Effect.gen(function* () {
+ const decoded = yield* decodeStoredThreadSnapshot(yield* encodeStoredThreadSnapshot(stored));
+ const { projection: loaded } = decoded.snapshot;
+ expect(loaded).toEqual(projection);
+ expect(loaded.visibleTurnItems[0]!.item).toBe(loaded.turnItems[0]);
+ expect(loaded.visibleTurnItems[1]!.item).toBe(loaded.turnItems[1]);
+ }),
+ );
+
+ it.effect("shares the copies of a record that could not be compacted", () =>
+ Effect.gen(function* () {
+ // A hidden item between two visible ones: `turnItems` no longer starts with the visible rows.
+ const hidden = {
+ ...base("hidden", 3),
+ type: "reasoning" as const,
+ text: "not shown",
+ streaming: false,
+ };
+ const uncompacted = {
+ ...projection,
+ turnItems: [answer, hidden, command],
+ };
+ const raw = yield* encodeStoredThreadSnapshot({
+ ...stored,
+ snapshot: { ...snapshot, projection: uncompacted },
+ });
+ expect(JSON.parse(raw).snapshot.turnItemsOmitLocalVisible).toBe(false);
+ const { projection: loaded } = (yield* decodeStoredThreadSnapshot(raw)).snapshot;
+ expect(loaded).toEqual(uncompacted);
+ expect(loaded.visibleTurnItems[0]!.item).toBe(loaded.turnItems[0]);
+ expect(loaded.visibleTurnItems[1]!.item).toBe(loaded.turnItems[2]);
+ }),
+ );
+
+ it.effect("shares the matching copies of a full record", () =>
+ Effect.gen(function* () {
+ const raw = yield* encodeFull({
+ schemaVersion: ORCHESTRATION_THREAD_CACHE_SCHEMA_VERSION,
+ ...stored,
+ });
+ expect(occurrences(raw, COMMAND_OUTPUT)).toBe(2);
+ const { projection: loaded } = (yield* decodeStoredThreadSnapshot(raw)).snapshot;
+ expect(loaded).toEqual(projection);
+ expect(loaded.visibleTurnItems[1]!.item).toBe(loaded.turnItems[1]);
+ }),
+ );
+
+ it.effect("keeps both copies of a full record when they differ", () =>
+ Effect.gen(function* () {
+ const stale = { ...command, output: "an older output" };
+ const raw = yield* encodeFull({
+ schemaVersion: ORCHESTRATION_THREAD_CACHE_SCHEMA_VERSION,
+ ...stored,
+ snapshot: {
+ ...snapshot,
+ projection: {
+ ...projection,
+ visibleTurnItems: [rowOf(answer, 0), rowOf(stale, 1)],
+ },
+ },
+ });
+ const { projection: loaded } = (yield* decodeStoredThreadSnapshot(raw)).snapshot;
+ expect(loaded.turnItems[1]).toMatchObject({ output: COMMAND_OUTPUT });
+ expect(loaded.visibleTurnItems[1]!.item).toMatchObject({ output: "an older output" });
+ }),
+ );
+});
diff --git a/packages/client-runtime/src/platform/threadCacheEncoding.ts b/packages/client-runtime/src/platform/threadCacheEncoding.ts
index 6466763a4de1b..8f087385d57e0 100644
--- a/packages/client-runtime/src/platform/threadCacheEncoding.ts
+++ b/packages/client-runtime/src/platform/threadCacheEncoding.ts
@@ -1,9 +1,17 @@
-import { OrchestrationV2ThreadProjectionJson } from "@t3tools/contracts";
+import {
+ type OrchestrationV2ThreadProjection,
+ OrchestrationV2ThreadProjectionJson,
+} from "@t3tools/contracts";
import {
omitLocalVisibleTurnItems,
restoreLocalVisibleTurnItems,
} from "@t3tools/shared/orchestrationV2BoundedSnapshot";
+import {
+ dedupeSnapshotBodies,
+ restoreSnapshotBodies,
+} from "@t3tools/shared/orchestrationV2DedupedBodies";
import * as Effect from "effect/Effect";
+import * as Equal from "effect/Equal";
import * as Option from "effect/Option";
import * as Schema from "effect/Schema";
@@ -13,7 +21,8 @@ import {
StoredOrchestrationThreadSnapshot,
} from "./orchestrationCache.ts";
-// Keep the full format for web/desktop; older clients discard compact records.
+// Each item is written once and shared again on decode, so a thread loaded from the cache costs
+// what a thread from the server costs. Older clients discard these records and refetch.
export const COMPACT_THREAD_CACHE_SCHEMA_VERSION = 6 as const;
/**
@@ -41,6 +50,25 @@ class InvalidThreadCache extends Schema.TaggedError()(
{},
) {}
+/** JSON decodes every copy of a body separately: point an item at its record's copy again. */
+function shareBodiesWithRecords(
+ projection: OrchestrationV2ThreadProjection,
+): OrchestrationV2ThreadProjection {
+ return restoreSnapshotBodies(dedupeSnapshotBodies(projection));
+}
+
+/** A full record decodes both copies of each item: keep one object wherever they match. */
+function shareFullRecordItems(
+ projection: OrchestrationV2ThreadProjection,
+): OrchestrationV2ThreadProjection {
+ const items = new Map(projection.turnItems.map((item) => [item.id, item]));
+ const visibleTurnItems = projection.visibleTurnItems.map((row) => {
+ const item = row.sourceThreadId === projection.thread.id ? items.get(row.item.id) : undefined;
+ return item !== undefined && Equal.equals(item, row.item) ? { ...row, item } : row;
+ });
+ return { ...projection, visibleTurnItems };
+}
+
const decodeCompatible = Schema.decodeUnknownEffect(
Schema.fromJsonString(
Schema.Union([StoredCompactThreadSnapshot, StoredOrchestrationThreadSnapshot]),
@@ -222,7 +250,15 @@ export function makeStoredThreadSnapshotEncoder(options: {
export const decodeStoredThreadSnapshot = Effect.fn("ThreadCache.decode")(function* (raw: string) {
const stored = yield* decodeCompatible(raw);
- if (stored.schemaVersion !== COMPACT_THREAD_CACHE_SCHEMA_VERSION) return stored;
+ if (stored.schemaVersion === ORCHESTRATION_THREAD_CACHE_SCHEMA_VERSION) {
+ return {
+ ...stored,
+ snapshot: {
+ ...stored.snapshot,
+ projection: shareBodiesWithRecords(shareFullRecordItems(stored.snapshot.projection)),
+ },
+ };
+ }
const { turnItemsOmitLocalVisible, turnItemCount, visibleTurnItemCount, ...snapshot } =
stored.snapshot;
const { projection } = snapshot;
@@ -245,9 +281,11 @@ export const decodeStoredThreadSnapshot = Effect.fn("ThreadCache.decode")(functi
visibleIds.add(identity);
return valid;
});
+ // A record that could not be compacted carries both copies: a retained request card followed by
+ // newer items is enough, since `turnItems` then no longer starts with the visible rows.
const restored = turnItemsOmitLocalVisible
? restoreLocalVisibleTurnItems(projection)
- : projection;
+ : shareFullRecordItems(projection);
const itemIds = new Set();
const validItems = restored.turnItems.every((item) => {
const valid = item.threadId === threadId && !itemIds.has(item.id);
@@ -266,5 +304,5 @@ export const decodeStoredThreadSnapshot = Effect.fn("ThreadCache.decode")(functi
) {
return yield* new InvalidThreadCache();
}
- return { ...stored, snapshot: { ...snapshot, projection: restored } };
+ return { ...stored, snapshot: { ...snapshot, projection: shareBodiesWithRecords(restored) } };
});
diff --git a/packages/client-runtime/src/state/threadRetention.test.ts b/packages/client-runtime/src/state/threadRetention.test.ts
new file mode 100644
index 0000000000000..507decffbb0df
--- /dev/null
+++ b/packages/client-runtime/src/state/threadRetention.test.ts
@@ -0,0 +1,58 @@
+import type { OrchestrationV2ThreadProjection } from "@t3tools/contracts";
+import { describe, expect, it } from "vite-plus/test";
+
+import { v2Projection } from "./orchestrationV2TestFixtures.ts";
+import { estimateRetainedThreadBytes } from "./threadRetention.ts";
+
+const withItems = (count: number, bytes: number): OrchestrationV2ThreadProjection => {
+ const items = Array.from({ length: count }, (_, index) => ({
+ id: `item-${index}`,
+ threadId: v2Projection.thread.id,
+ type: "system_notice",
+ message: "x".repeat(bytes),
+ }));
+ return {
+ ...v2Projection,
+ turnItems: items,
+ visibleTurnItems: items.map((item, position) => ({
+ position,
+ visibility: "local",
+ sourceThreadId: v2Projection.thread.id,
+ sourceItemId: item.id,
+ item,
+ })),
+ } as unknown as OrchestrationV2ThreadProjection;
+};
+
+describe("estimateRetainedThreadBytes", () => {
+ it("counts text once for an item the timeline shares, and grows with the items", () => {
+ const small = estimateRetainedThreadBytes(withItems(10, 100));
+ const large = estimateRetainedThreadBytes(withItems(10, 100_000));
+ expect(large - small).toBe(10 * (100_000 - 100));
+ expect(estimateRetainedThreadBytes(withItems(20, 100))).toBeGreaterThan(small * 1.9);
+ });
+
+ it("charges an inherited row, which no local item shares", () => {
+ const local = withItems(1, 1_000);
+ const inherited = {
+ ...local,
+ turnItems: [],
+ visibleTurnItems: local.visibleTurnItems.map((row) => ({
+ ...row,
+ visibility: "inherited",
+ sourceThreadId: "parent",
+ })),
+ } as unknown as OrchestrationV2ThreadProjection;
+ expect(estimateRetainedThreadBytes(inherited)).toBeGreaterThan(1_000);
+ });
+
+ it("charges a local row that holds its own copy of an item", () => {
+ const shared = withItems(1, 1_000);
+ const copied = {
+ ...shared,
+ visibleTurnItems: shared.visibleTurnItems.map((row) => ({ ...row, item: { ...row.item } })),
+ } as unknown as OrchestrationV2ThreadProjection;
+ const extra = estimateRetainedThreadBytes(copied) - estimateRetainedThreadBytes(shared);
+ expect(extra).toBeGreaterThan(1_000);
+ });
+});
diff --git a/packages/client-runtime/src/state/threadRetention.ts b/packages/client-runtime/src/state/threadRetention.ts
index 3f2b8a6fd0166..4af6dc5d12e07 100644
--- a/packages/client-runtime/src/state/threadRetention.ts
+++ b/packages/client-runtime/src/state/threadRetention.ts
@@ -1 +1,43 @@
+import type { OrchestrationV2RunStatus, OrchestrationV2ThreadProjection } from "@t3tools/contracts";
+
export const THREAD_SNAPSHOT_IDLE_TTL_MS = 5 * 60_000;
+
+/**
+ * Runs the user is likely to come back to. Preparing, starting and running ones also have newer
+ * state than the persisted cache, which is saved only once they settle.
+ */
+export const UNFINISHED_RUN_STATUSES: ReadonlySet = new Set([
+ "preparing",
+ "queued",
+ "starting",
+ "running",
+ "waiting",
+]);
+
+// What the heap charges a small object beyond its strings.
+const OBJECT_OVERHEAD_BYTES = 64;
+
+function retainedBytes(value: unknown): number {
+ if (typeof value === "string") return value.length;
+ if (typeof value !== "object" || value === null) return 8;
+ let bytes = OBJECT_OVERHEAD_BYTES;
+ for (const field of Object.values(value)) bytes += retainedBytes(field);
+ return bytes;
+}
+
+/**
+ * A cheap estimate of what a thread keeps in the heap, from its items, rows holding an item of their
+ * own and message records, with strings counted by length. It walks one thread when the thread is
+ * left. Timeline rows and parsed markdown come on top, which matters only for small threads.
+ */
+export function estimateRetainedThreadBytes(projection: OrchestrationV2ThreadProjection): number {
+ let bytes = retainedBytes(projection.turnItems) + retainedBytes(projection.messages);
+ const turnItems = new Set