Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 29 additions & 5 deletions src/bootSnapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ export function saveBootSnapshot(snap: Omit<BootSnapshot, "savedAt">): void {
*/
export function loadCachedThreadDetail(id: string | null): ThreadDetail | null {
if (!id) return null;
if (pendingDetail?.thread.id === id) return pendingDetail;
try {
const raw = window.localStorage.getItem(DETAIL_KEY);
if (!raw || raw.length > MAX_SNAPSHOT_CHARS) return null;
Expand All @@ -99,11 +100,34 @@ export function loadCachedThreadDetail(id: string | null): ThreadDetail | null {
}
}

/** Detail waiting for an idle slot; undefined when no write is scheduled. */
let pendingDetail: ThreadDetail | null | undefined;
/** Longest an idle write may wait on a renderer that never goes idle. */
const IDLE_WRITE_TIMEOUT_MS = 2000;

/**
* Stringifying a big transcript costs ~17 ms (#1475), so the write waits for
* an idle slot. The key holds one thread, so saves before that slot coalesce
* into one write of the latest detail.
*/
export function saveCachedThreadDetail(d: ThreadDetail | null): void {
try {
if (d == null) window.localStorage.removeItem(DETAIL_KEY);
else window.localStorage.setItem(DETAIL_KEY, JSON.stringify(d));
} catch {
// Quota/private mode: a switch just shows the empty pane until the fetch.
const scheduled = pendingDetail !== undefined;
pendingDetail = d;
if (scheduled) return;
const write = () => {
const next = pendingDetail;
pendingDetail = undefined;
try {
if (next == null) window.localStorage.removeItem(DETAIL_KEY);
else window.localStorage.setItem(DETAIL_KEY, JSON.stringify(next));
} catch {
// Quota/private mode: a switch just shows the empty pane until the fetch.
}
};
// Safari (web mode) has no requestIdleCallback.
if (typeof window.requestIdleCallback === "function") {
window.requestIdleCallback(write, { timeout: IDLE_WRITE_TIMEOUT_MS });
} else {
window.setTimeout(write, 0);
}
}
18 changes: 11 additions & 7 deletions src/components/ThreadView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ import {
import {
clampWindowStart,
ensureVisibleStart,
initialWindowStart,
tailWindowStart,
} from "../transcriptWindow";
import { lastUserMessage } from "../retryTurn";
import {
Expand Down Expand Up @@ -985,12 +985,14 @@ export const ThreadView = memo(function ThreadView({

/**
* One integer: index of the first mounted timeline entry. Thread switches
* reset to the tail window; streaming appends leave it alone so the top
* does not creep; Show earlier / revealMessageId only move it down.
* reset to the tail window. Appends while stuck to the bottom advance it so
* the mounted tail stays bounded (#1475); scrolled up, it stays put so the
* content being read does not move. Show earlier / revealMessageId only
* move it down.
*/
const [windowThreadId, setWindowThreadId] = useState(threadId);
const [windowStart, setWindowStart] = useState(() =>
initialWindowStart(timeline.length),
tailWindowStart(timeline),
);
const revealTargetId = revealMessageId ?? jumpMessageId;
const revealIndex = useMemo(() => {
Expand All @@ -1004,8 +1006,10 @@ export const ThreadView = memo(function ThreadView({
const start = clampWindowStart(
ensureVisibleStart(
threadId !== windowThreadId
? initialWindowStart(timeline.length)
: windowStart,
? tailWindowStart(timeline)
: stickToBottom.current
? Math.max(windowStart, tailWindowStart(timeline))
: windowStart,
revealIndex,
),
timeline.length,
Expand All @@ -1014,7 +1018,7 @@ export const ThreadView = memo(function ThreadView({
setWindowThreadId(threadId);
setWindowStart(start);
setJumpMessageId(null);
} else if (start < windowStart) {
} else if (start !== windowStart) {
setWindowStart(start);
}

Expand Down
11 changes: 7 additions & 4 deletions src/components/thread/useStickToBottom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,12 +85,15 @@ export function useStickToBottom({

/**
* Pin before paint so a remounted body (thread switch) and a newly
* inserted permission card never flash at the wrong scrollTop. #408's
* ResizeObserver still covers post-paint growth.
* inserted permission card never flash at the wrong scrollTop. Plain
* appends skip this: reading scrollHeight here forces a synchronous
* layout on every streamed push (#1475), and #408's ResizeObserver pins
* them once the browser has laid out anyway.
*/
useLayoutEffect(() => {
const id = detail?.thread.id ?? null;
if (id !== prevLayoutThreadId.current) {
const opened = id !== prevLayoutThreadId.current;
if (opened) {
const switching =
prevLayoutThreadId.current !== null &&
id !== null &&
Expand All @@ -114,7 +117,7 @@ export function useStickToBottom({
forceStick.current = true;
}
prevPermReq.current = req;
pinIfStuck();
if (opened || forceStick.current) pinIfStuck();
}, [
timeline,
isWorking,
Expand Down
31 changes: 29 additions & 2 deletions src/transcriptWindow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,38 @@
* at the bottom, so we mount the last N timeline entries and grow upward
* on demand. State is one integer (the first visible index).
*/
import type { TimelineEntry } from "./timeline";

/** Last N entries on first paint. Covers several screens of a typical thread. */
export const TRANSCRIPT_WINDOW = 120;

/**
* Message text the tail window keeps mounted (#1475). A count alone let six
* streamed 45 KB answers grow one open transcript to 62k DOM nodes; rendered
* markdown runs ~140 nodes per KB, so this caps the window near 15k nodes.
* Tool output is not counted: tool cards mount collapsed.
*/
export const TRANSCRIPT_CHAR_BUDGET = 100_000;

/**
* First index of the tail window: at most `windowSize` entries and at most
* `budget` characters of message text, but always the last entry.
*/
export function tailWindowStart(
timeline: readonly TimelineEntry[],
windowSize = TRANSCRIPT_WINDOW,
budget = TRANSCRIPT_CHAR_BUDGET,
): number {
const min = initialWindowStart(timeline.length, windowSize);
let chars = 0;
for (let i = timeline.length - 1; i >= min; i--) {
const entry = timeline[i]!;
if (entry.kind === "message") chars += entry.message.text.length;
if (chars > budget && i < timeline.length - 1) return i + 1;
}
return min;
}

/** First index to mount so the tail window is N entries (0 when it all fits). */
export function initialWindowStart(
length: number,
Expand All @@ -26,8 +54,7 @@ export function extendWindowStart(
}

/**
* Raise the window so `index` is included. Streaming appends do not call
* this — they keep the start index and grow the tail.
* Lower the window start so `index` is included.
*/
export function ensureVisibleStart(start: number, index: number): number {
if (!Number.isFinite(index) || index < 0) return start;
Expand Down
45 changes: 45 additions & 0 deletions test/renderFirstBoot.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -304,3 +304,48 @@ describe("render-first boot (#364)", () => {
}
});
});

describe("cached thread detail write (#1475)", () => {
it("writes on idle, coalescing saves into one write of the latest detail", async () => {
const shell = await mount(<div />);
const w = window as unknown as {
requestIdleCallback?: (cb: () => void, opts?: { timeout?: number }) => number;
};
const prevIdle = w.requestIdleCallback;
const idle: Array<{ cb: () => void; timeout?: number }> = [];
w.requestIdleCallback = (cb, opts) => idle.push({ cb, timeout: opts?.timeout });
// Assigning onto the Storage instance would just store an item.
const store = Object.getPrototypeOf(window.localStorage) as Storage;
const prevSet = store.setItem;
const writes: string[] = [];
store.setItem = function (key: string, value: string) {
if (key === DETAIL_KEY) writes.push(value);
return prevSet.call(this, key, value);
};
try {
const { saveCachedThreadDetail, loadCachedThreadDetail } = await import(
"../src/bootSnapshot"
);
const first = detail({ thread: thread({ id: "t-idle" }), messages: [marker("FIRST")] });
const second = detail({ thread: thread({ id: "t-idle" }), messages: [marker("SECOND")] });
saveCachedThreadDetail(first);
saveCachedThreadDetail(second);
assert.equal(writes.length, 0, "no synchronous setItem on the save path");
assert.equal(idle.length, 1, "saves before the idle slot share one callback");
assert.ok(idle[0]!.timeout! > 0, "the idle write has a timeout fallback");
assert.equal(
loadCachedThreadDetail("t-idle")?.messages[0]?.text,
"SECOND",
"a load before the write sees the pending detail",
);
idle[0]!.cb();
assert.equal(writes.length, 1);
assert.ok(writes[0]!.includes("SECOND") && !writes[0]!.includes("FIRST"));
} finally {
store.setItem = prevSet;
if (prevIdle) w.requestIdleCallback = prevIdle;
else delete w.requestIdleCallback;
shell.unmount();
}
});
});
Loading
Loading