Merge pull request #9117 from wuhanwoaini521/codex/sync-jump-bar-with-scroll

fix(desktop): keep complete history navigable / 保持完整历史对话可导航
This commit is contained in:
SivanCola authored and GitHub committed 2026-08-20 01:18:16 +08:00
commit 0d80a69dc4
26 files changed
+1185 -291

No files matched your search

@@ -698,8 +698,8 @@ try {
// A native scrollbar thumb drag owns the browser's scroll range. Keep
// Virtuoso's estimated size tree fixed until pointer release so newly
// visited variable-height rows cannot resize the thumb under the pointer.
// This is deliberately pointer-gutter-specific; the wheel assertions above
// continue to exercise ordinary chat-content scrolling and live measuring.
// Browser themes clamp the held thumb only after the pointer crosses the
// track end, so the target below deliberately overshoots the visible gutter.
await transcript.evaluate((element) => {
element.scrollTop = 0;
element.dispatchEvent(new Event("scroll"));
@@ -715,7 +715,7 @@ try {
return {
x: Math.min(rect.right - 1, contentRight + Math.max(1, (rect.right - contentRight) / 2)),
y: rect.top + 5,
bottomY: rect.bottom - 5,
bottomY: Math.min(window.innerHeight - 1, rect.bottom + Math.max(24, rect.height * 0.1)),
knownSize: Number.parseFloat(row.dataset.knownSize || "0"),
gutter: rect.right - contentRight,
scrollHeight: element.scrollHeight,
@@ -1131,16 +1131,26 @@ try {
`reduced-motion tail rests on the physical bottom (${reducedIdle.finalDistance}px)`,
);
// The #9089 gesture: wheel up, wheel back to the bottom, release, idle.
await moveToOuterReaderGutter(reducedPage, reducedPage.locator(".transcript"));
const reducedTranscript = reducedPage.locator(".transcript");
const reducedBottomTop = await reducedTranscript.evaluate((element) => element.scrollTop);
await moveToOuterReaderGutter(reducedPage, reducedTranscript);
await reducedPage.mouse.wheel(0, -900);
await reducedPage.waitForTimeout(120);
await reducedPage.mouse.wheel(0, 100_000);
await reducedPage.waitForFunction(() => {
await reducedPage.waitForFunction((bottomTop) => {
const element = document.querySelector(".transcript");
return element instanceof HTMLElement
&& element.dataset.scrollMode === "tail-follow"
&& element.scrollHeight - element.scrollTop - element.clientHeight <= 4;
}, undefined, { timeout: 10_000 });
&& element.dataset.scrollMode === "manual"
&& element.scrollTop < bottomTop - 1
&& element.scrollHeight - element.scrollTop - element.clientHeight > 4;
}, reducedBottomTop, { timeout: 5_000 });
let reducedReachedBottom = false;
for (let attempt = 0; attempt < 20 && !reducedReachedBottom; attempt += 1) {
await reducedPage.mouse.wheel(0, 640);
await reducedPage.waitForTimeout(50);
reducedReachedBottom = await reducedTranscript.evaluate((element) =>
element.dataset.scrollMode === "tail-follow"
&& element.scrollHeight - element.scrollTop - element.clientHeight <= 4);
}
assert(reducedReachedBottom, "reduced-motion repeated downward wheels return to the physical bottom (#9089)");
const reducedReturn = await reducedPage.evaluate(() => new Promise((resolve) => {
const element = document.querySelector(".transcript");
const tops = [];
+1 -1
View File
@@ -22,7 +22,7 @@
"test:stream": "tsx src/__tests__/raf-batch.test.ts && tsx src/__tests__/stream-delta-batch.test.ts && tsx src/__tests__/use-controller-stream-progress.test.ts",
"test:motion": "node scripts/check-motion-ci-contract.mjs && node scripts/check-waapi-contract.mjs --self-test && tsx src/__tests__/native-motion.test.tsx && tsx src/__tests__/approval-animation.test.tsx",
"test:motion-browser": "PLAYWRIGHT_BROWSERS_PATH=.pw-browsers node bench/approval-animation.mjs",
"test:transcript": "tsx src/__tests__/transcript-virtuoso-index.test.ts && tsx src/__tests__/transcript-measured-sizes.test.ts && tsx src/__tests__/transcript-state-snapshot.test.ts && tsx src/__tests__/transcript-layout-recovery.test.ts && tsx src/__tests__/transcript-reader-extent-stability.test.ts && tsx src/__tests__/transcript-reader-extent-race.test.tsx && tsx src/__tests__/transcript-recovery-race.test.tsx && tsx src/__tests__/transcript-scroll-release.test.ts && tsx src/__tests__/transcript-scroll-diagnostics.test.ts && tsx src/__tests__/transcript-native-scrollbar.test.ts && tsx src/__tests__/nested-scroll-handoff.test.ts && tsx src/__tests__/creation-transcript-scrollbar.test.ts && tsx src/__tests__/question-jump-bar.test.tsx && tsx src/__tests__/markdown-table-virtual.test.tsx && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/transcript-selection-retention.test.tsx && tsx src/__tests__/transcript-logical-selection.test.ts && tsx src/__tests__/transcript-selection-overlay.test.tsx && tsx src/__tests__/markdown-pipeline.test.tsx && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/transcript-selection-menu.test.tsx && tsx src/__tests__/transcript-store.test.ts && tsx src/__tests__/transcript-virtualization.test.tsx && tsx src/__tests__/transcript-question-jump.test.tsx",
"test:transcript": "tsx src/__tests__/transcript-virtuoso-index.test.ts && tsx src/__tests__/transcript-measured-sizes.test.ts && tsx src/__tests__/transcript-state-snapshot.test.ts && tsx src/__tests__/transcript-layout-recovery.test.ts && tsx src/__tests__/transcript-reader-extent-stability.test.ts && tsx src/__tests__/transcript-reader-extent-race.test.tsx && tsx src/__tests__/transcript-recovery-race.test.tsx && tsx src/__tests__/transcript-scroll-release.test.ts && tsx src/__tests__/transcript-scroll-diagnostics.test.ts && tsx src/__tests__/transcript-native-scrollbar.test.ts && tsx src/__tests__/nested-scroll-handoff.test.ts && tsx src/__tests__/creation-transcript-scrollbar.test.ts && tsx src/__tests__/question-jump-bar.test.tsx && tsx src/__tests__/transcript-question-nav.test.ts && tsx src/__tests__/transcript-question-nav-integration.test.ts && tsx src/__tests__/markdown-table-virtual.test.tsx && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/transcript-selection-retention.test.tsx && tsx src/__tests__/transcript-logical-selection.test.ts && tsx src/__tests__/transcript-selection-overlay.test.tsx && tsx src/__tests__/markdown-pipeline.test.tsx && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/transcript-selection-menu.test.tsx && tsx src/__tests__/transcript-store.test.ts && tsx src/__tests__/transcript-virtualization.test.tsx && tsx src/__tests__/transcript-question-jump.test.tsx",
"test:transcript-browser": "PLAYWRIGHT_BROWSERS_PATH=.pw-browsers node bench/transcript-selection.mjs && PLAYWRIGHT_BROWSERS_PATH=.pw-browsers node bench/transcript-scroll-stability.mjs",
"pretest": "pnpm test:terminal && pnpm test:task-monitor && tsx src/__tests__/rich-composer-selection.test.tsx && tsx src/__tests__/context-center-contract.test.ts && tsx src/__tests__/provider-model-cache.test.ts && tsx src/__tests__/format-tokens.test.ts && pnpm test:usage-stats && pnpm test:settings-responsive && pnpm test:composer-menu-viewport && pnpm test:transcript",
"test:composer-menu-viewport": "tsx src/__tests__/composer-menu-viewport.test.ts && tsx src/__tests__/virtual-menu-identity.test.tsx",
@@ -77,9 +77,11 @@ console.log("\nbundle budgets");
// Reader extent stabilization adds 1.2 KiB gzip (0.28%) in production for its
// bounded input, collapse, rebound, and ownership transaction. Retain it with
// a 1.5 KiB (0.35%) ratchet instead of weakening the Windows scroll invariant.
// Test diagnostics add 1.0 KiB gzip (0.23%) and 4.0 KiB raw (0.17%); keep that
// raw allowance channel-specific so the production ceiling does not widen.
const initialJSBudgetKiB = process.env.REASONIX_CHANNEL === "test" ? 428.0 : 427.0;
// Complete-history navigation adds 0.3 KiB gzip (0.070%) to that production
// path while keeping its 1.68 KiB question rail lazy-loaded. Test diagnostics
// plus the navigation owner add 0.7 KiB gzip (0.164%) over the merged test gate.
// Keep about 100 B of measured build-SHA headroom in each channel.
const initialJSBudgetKiB = process.env.REASONIX_CHANNEL === "test" ? 428.7 : 427.3;
assertBudget("initial JavaScript gzip", initialJSGzip, initialJSBudgetKiB * 1024);
assertBudget("largest initial JavaScript chunk gzip", largestInitialJS, 280 * 1024);
assertBudget("render-blocking CSS gzip", initialCSSGzip, 4 * 1024);
@@ -111,7 +113,8 @@ for (const path of localeChunks) {
const rawInitialBytes = [...initialJS, ...initialCSS, ...appShellCSS]
.reduce((total, path) => total + statSync(path).size, 0);
// The maintained Virtuoso engine adds 49.1 KiB raw (2.2%) over the previous
// 2268.7 KiB gate. Retain 1% headroom to bound hash/minifier drift.
const rawInitialBudgetKiB = process.env.REASONIX_CHANNEL === "test" ? 2_344 : 2_341;
// 2268.7 KiB gate. Navigation remains inside the 2341 KiB production ceiling;
// its combined diagnostic wiring adds 2.2 KiB (0.094%) to the test channel.
const rawInitialBudgetKiB = process.env.REASONIX_CHANNEL === "test" ? 2_346.2 : 2_341;
assertBudget("initial raw JavaScript and CSS", rawInitialBytes, rawInitialBudgetKiB * 1024);
assertBudget("largest initial JavaScript chunk raw", largestInitialJSRaw, 1_000 * 1024);
+7 -2
View File
@@ -3417,6 +3417,9 @@ export default function App() {
!transcriptHydrating &&
!hydratePlaceholderActive;
const transcriptItems = hydratePlaceholderActive ? state.hydratePlaceholderItems! : state.items;
const handleLoadOlderHistory = useCallback((targetTurn?: number) => (
activeTabId ? loadOlderHistory(activeTabId, targetTurn) : Promise.resolve(false)
), [activeTabId, loadOlderHistory]);
// Display items: backend history is authoritative after immediate commit.
// rewindState only drives the undo banner, not optimistic truncation.
@@ -4887,9 +4890,11 @@ export default function App() {
revealSignal={transcriptRevealSignal}
hydrating={runtimeTransitioning || transcriptHydrating}
hasOlderHistory={!runtimeTransitioning && state.historyHasOlder && !rewindState}
olderHistoryCount={state.historyStartTurn}
historyStartTurn={state.historyStartTurn}
historyTotalTurns={state.historyTotalTurns}
loadingOlderHistory={state.historyOlderLoading}
onLoadOlderHistory={() => activeTabId && loadOlderHistory(activeTabId)}
olderHistoryError={state.historyOlderError}
onLoadOlderHistory={handleLoadOlderHistory}
invocationMetadata={activeTabId ? invocationMetadataByTab[activeTabId] : undefined}
/>
{!runtimeTransitioning && state.hydrateError ? <div className="history-load-error" role="alert"><span>{state.hydrateError}</span><button type="button" className="btn btn--small" onClick={() => void retrySessionHistory(activeTabId)}>{t("common.retry")}</button></div> : null}
@@ -0,0 +1,113 @@
import { act } from "react";
import { getTranscriptStore } from "../lib/transcriptStore";
import type { HistorySlice } from "../lib/types";
import { historySliceFromMessages } from "./mockHistorySlice";
type DeferredHistory = {
promise: Promise<HistorySlice>;
resolve: (value: HistorySlice) => void;
};
type OlderHistoryState = {
items: Array<{ kind: string; text?: string }>;
historyOlderLoading: boolean;
historyOlderError?: string;
};
export async function verifyStaleHistoryFingerprint({
olderPage,
loadOlderHistory,
historyCalls,
waitFor,
flushPromises,
equal,
getState,
}: {
olderPage: DeferredHistory;
loadOlderHistory: () => Promise<boolean> | undefined;
historyCalls: () => number;
waitFor: (label: string, predicate: () => boolean) => Promise<void>;
flushPromises: () => Promise<void>;
equal: (actual: unknown, expected: unknown, label: string) => void;
getState: () => OlderHistoryState | undefined;
}) {
let olderLoad: Promise<boolean> | undefined;
await act(async () => {
olderLoad = loadOlderHistory();
await flushPromises();
});
await waitFor("tab-l older page request", () => historyCalls() === 2);
olderPage.resolve({
...historySliceFromMessages(
"tab-l",
[{ role: "user", content: "stale older L" }],
{ cursor: "", turns: 12 },
{ revision: 1, digest: "digest-l-v1" },
),
hasOlder: true,
nextCursor: btoa(JSON.stringify({ v: 1, before: 2 })),
});
await act(async () => {
await olderLoad;
await flushPromises();
});
const state = getState();
equal(state?.items.some((item) => item.kind === "user" && item.text === "stale older L") ?? false, false, "stale older page is discarded after session fingerprint changes");
equal(state?.historyOlderLoading, false, "stale older page releases its loading state");
equal(state?.historyOlderError, "history identity changed", "stale older page enters the explicit retry state instead of silently auto-retrying");
}
export async function verifyDeferredHistoryCloseRace({
olderPage,
loadOlderHistory,
closeTab,
historyCalls,
waitFor,
flushPromises,
equal,
sessionPath,
}: {
olderPage: DeferredHistory;
loadOlderHistory: () => Promise<boolean> | undefined;
closeTab: () => Promise<boolean>;
historyCalls: () => number;
waitFor: (label: string, predicate: () => boolean) => Promise<void>;
flushPromises: () => Promise<void>;
equal: (actual: unknown, expected: unknown, label: string) => void;
sessionPath: string;
}) {
let closingOlderLoad: Promise<boolean> | undefined;
await act(async () => {
closingOlderLoad = loadOlderHistory();
await flushPromises();
});
await waitFor("tab-l closing older page request", () => historyCalls() === 3);
const transcriptStore = getTranscriptStore();
const originalSetPinned = transcriptStore.setPinned.bind(transcriptStore);
let releasedTabStateCommits = 0;
transcriptStore.setPinned = (tabId: string, pinned: boolean) => {
if (tabId === "tab-l") releasedTabStateCommits += 1;
originalSetPinned(tabId, pinned);
};
let tabClosed = false;
await act(async () => {
tabClosed = await closeTab();
await flushPromises();
});
equal(tabClosed, true, "tab-l closes while its older page is deferred");
olderPage.resolve(historySliceFromMessages(
"tab-l",
[{ role: "user", content: "late page after close" }],
{ cursor: "", turns: 12 },
{ revision: 2, digest: "digest-l-v2" },
));
await act(async () => {
await closingOlderLoad;
await flushPromises();
});
transcriptStore.setPinned = originalSetPinned;
equal(releasedTabStateCommits, 0, "late older-page completion cannot recreate the released tab state");
equal(transcriptStore.isResident("tab-l", sessionPath), false, "late older-page completion cannot restore the evicted transcript");
}
@@ -3,7 +3,7 @@
import { JSDOM } from "jsdom";
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { QuestionJumpBar } from "../components/QuestionJumpBar";
import { QUESTION_JUMP_MAX_MARKERS, QuestionJumpBar, questionTurnFromRailY, sampledQuestionTurns } from "../components/QuestionJumpBar";
import { LocaleProvider } from "../lib/i18n";
import type { QuestionAnchor } from "../lib/transcriptGrouping";
@@ -24,6 +24,10 @@ function flushTimers(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0));
}
function rect(top: number, height: number, width: number): DOMRect {
return { top, bottom: top + height, left: 0, right: width, height, width } as DOMRect;
}
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
pretendToBeVisual: true,
url: "http://localhost/",
@@ -37,49 +41,106 @@ globalThis.Element = dom.window.Element;
globalThis.HTMLElement = dom.window.HTMLElement;
globalThis.Event = dom.window.Event;
globalThis.MouseEvent = dom.window.MouseEvent;
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
globalThis.localStorage = dom.window.localStorage;
Object.defineProperty(dom.window.HTMLElement.prototype, "scrollIntoView", {
configurable: true,
value() {},
});
const questions: QuestionAnchor[] = [
{ id: "u1", text: "first question", turn: 0 },
{ id: "u2", text: "second question", turn: 1 },
{ id: "u3", text: "third question", turn: 2 },
{ id: "u1", text: "first question", turn: 0, loaded: true },
{ id: "u2", text: "second question", turn: 1, loaded: true },
{ id: "u3", text: "third question", turn: 2, loaded: true },
];
const jumps: QuestionAnchor[] = [];
const root = createRoot(document.getElementById("root")!);
function ControlledJumpBar({
loadedQuestions,
totalQuestions,
}: {
loadedQuestions: QuestionAnchor[];
totalQuestions: number;
}) {
const [activeTurn, setActiveTurn] = React.useState<number | null>(totalQuestions - 1);
return (
<QuestionJumpBar
loadedQuestions={loadedQuestions}
totalQuestions={totalQuestions}
activeTurn={activeTurn}
onJump={(question) => {
jumps.push(question);
setActiveTurn(question.turn);
}}
/>
);
}
console.log("\nquestion jump bar");
await act(async () => {
root.render(
<LocaleProvider>
<QuestionJumpBar questions={questions} onJump={(question) => jumps.push(question)} />
<ControlledJumpBar key="short" loadedQuestions={questions} totalQuestions={3} />
</LocaleProvider>,
);
await flushTimers();
});
const rail = document.querySelector(".jump-scroll") as HTMLElement;
let bar = document.querySelector(".jump-bar") as HTMLElement;
let rail = document.querySelector(".jump-scroll") as HTMLElement;
bar.getBoundingClientRect = () => rect(0, 240, 56);
rail.getBoundingClientRect = () => rect(0, 240, 32);
await act(async () => {
// A real mouse activation emits both events. The rail must claim the
// gesture only once or the second navigation can overwrite the first.
rail.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, clientY: 0 }));
rail.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, clientY: 0, detail: 1 }));
// A real mouse activation emits both events. Only mousedown owns the jump.
rail.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, button: 0, clientY: 1 }));
rail.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, button: 0, clientY: 1, detail: 1 }));
await flushTimers();
});
eq(jumps.length, 1, "one physical rail click emits one question jump");
eq(jumps[0]?.id, "u1", "the rail keeps the question selected on pointer down");
eq(jumps[0]?.id, "u1", "the rail maps its top directly to the first question");
const second = document.querySelector('[data-turn="1"]') as HTMLButtonElement;
await act(async () => {
second.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, detail: 0 }));
rail.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, cancelable: true, key: "ArrowDown" }));
await flushTimers();
});
eq(jumps.length, 2, "keyboard activation still emits one question jump");
eq(jumps[1]?.id, "u2", "keyboard activation jumps to the focused question");
eq(jumps.length, 2, "one slider keypress emits one question jump");
eq(jumps[1]?.id, "u2", "slider keyboard navigation advances by one exact question");
eq(rail.getAttribute("aria-valuenow"), "2", "the slider exposes its active absolute question");
const sampled = sampledQuestionTurns(10_000, 6_789);
eq(sampled.length, QUESTION_JUMP_MAX_MARKERS, "very long sessions keep a fixed marker count");
eq(sampled[0], 0, "aggregated markers retain the first question");
eq(sampled.at(-1), 9_999, "aggregated markers retain the last question");
eq(sampled.includes(6_789), true, "aggregated markers retain the exact active question");
eq(questionTurnFromRailY(120, 0, 240, 10_000), 5_000, "rail geometry maps to an absolute turn without reading marker layout");
const longQuestions: QuestionAnchor[] = [questions[0], { id: "u10000", text: "last loaded question", turn: 9_999, loaded: true }];
await act(async () => {
root.render(
<LocaleProvider>
<ControlledJumpBar key="long" loadedQuestions={longQuestions} totalQuestions={10_000} />
</LocaleProvider>,
);
await flushTimers();
});
bar = document.querySelector(".jump-bar") as HTMLElement;
rail = document.querySelector(".jump-scroll") as HTMLElement;
bar.getBoundingClientRect = () => rect(0, 240, 56);
rail.getBoundingClientRect = () => rect(0, 240, 32);
const markers = Array.from(document.querySelectorAll<HTMLElement>(".jump-item"));
eq(markers.length, QUESTION_JUMP_MAX_MARKERS, "10,000 questions render only the bounded marker set");
for (const marker of markers) {
marker.getBoundingClientRect = () => { throw new Error("marker geometry must not be read"); };
}
const unloadedTurn = 6_789;
await act(async () => {
const clientY = ((unloadedTurn + 0.5) / 10_000) * 240;
rail.dispatchEvent(new MouseEvent("mousemove", { bubbles: true, clientY }));
rail.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, button: 0, clientY }));
await flushTimers();
});
eq(jumps.at(-1)?.turn, unloadedTurn, "a long aggregated rail still targets the exact unloaded question");
eq(jumps.at(-1)?.loaded, false, "an unloaded aggregate target keeps the lazy-load contract");
await act(async () => root.unmount());
dom.window.close();
@@ -5,6 +5,7 @@ import React, { act } from "react";
import { createRoot } from "react-dom/client";
import type { AppBindings } from "../lib/bridge";
import { useController } from "../lib/useController";
import { verifyDeferredHistoryCloseRace, verifyStaleHistoryFingerprint } from "./deferred-history-close-race";
import { historySliceFromMessages } from "./mockHistorySlice";
import type { BalanceInfo, CheckpointMeta, ContextInfo, EffortInfo, HistoryMessage, HistorySlice, HistorySliceRequest, JobView, Meta, TabMeta, TopicActivationEvent, TopicActivationRequest, WireEvent } from "../lib/types";
@@ -381,6 +382,11 @@ window.go = {
cancelCalls.push(tabID);
runningTabs.delete(tabID);
},
CloseTabWithPolicy: async (tabID: string) => {
tabsById.delete(tabID);
runningTabs.delete(tabID);
if (backendActiveId === tabID) backendActiveId = "tab-a";
},
SubmitToTab: async (tabID: string) => {
runningTabs.add(tabID);
if (tabID === "tab-c") await submitTabCGate.promise;
@@ -904,24 +910,18 @@ await act(async () => {
});
await waitFor("tab-l metadata advances", () => controller?.state.meta?.sessionRevision === 2);
historyLOlder = deferred<HistorySlice>();
let olderLoad: Promise<void> | undefined;
await act(async () => {
olderLoad = controller?.loadOlderHistory("tab-l");
await flushPromises();
await verifyStaleHistoryFingerprint({
olderPage: historyLOlder, loadOlderHistory: () => controller?.loadOlderHistory("tab-l"),
historyCalls: () => historyLCalls, waitFor, flushPromises, equal: eq,
getState: () => controller?.state,
});
await waitFor("tab-l older page request", () => historyLCalls === 2);
historyLOlder.resolve(historySliceFromMessages(
"tab-l",
[userMessage("stale older L")],
{ cursor: "", turns: 12 },
{ revision: 1, digest: "digest-l-v1" },
));
await act(async () => {
await olderLoad;
await flushPromises();
historyLOlder = deferred<HistorySlice>();
await verifyDeferredHistoryCloseRace({
olderPage: historyLOlder, loadOlderHistory: () => controller?.loadOlderHistory("tab-l"),
closeTab: async () => Boolean(await controller?.closeTab("tab-l", "stop_and_close")),
historyCalls: () => historyLCalls, waitFor, flushPromises, equal: eq, sessionPath: tabL.sessionPath,
});
ok(!(controller?.state.items.some((item) => item.kind === "user" && item.text === "stale older L") ?? false), "stale older page is discarded after session fingerprint changes");
eq(controller?.state.historyOlderLoading, false, "stale older page releases its loading state");
// The backend transcript and sidecar are separate durable files. If a save
// advances between those reads, hydration must reconcile the pair instead of
@@ -163,7 +163,7 @@ function estimateFor(store: ReturnType<typeof createTranscriptMeasuredSizes>, ro
]]);
const options = {
folds,
foldPreference: "collapsed" as const,
foldPreference: "auto" as const,
hasOlderHistory: false,
turnForUser: () => 0,
};
@@ -185,7 +185,7 @@ function estimateFor(store: ReturnType<typeof createTranscriptMeasuredSizes>, ro
const before = { ...initialState, items: [preview] };
const beforeRows = buildTranscriptRows(buildTurnModels(before.items), {
folds: EMPTY_FOLDS,
foldPreference: "collapsed",
foldPreference: "auto",
hasOlderHistory: false,
creationMode: false,
turnForUser: () => undefined,
@@ -200,7 +200,7 @@ function estimateFor(store: ReturnType<typeof createTranscriptMeasuredSizes>, ro
});
const afterRows = buildTranscriptRows(buildTurnModels(after.items), {
folds: EMPTY_FOLDS,
foldPreference: "collapsed",
foldPreference: "auto",
hasOlderHistory: false,
creationMode: false,
turnForUser: () => undefined,
@@ -80,7 +80,7 @@ deepEqual(measurementEvents, [{
passed += 1;
delete row.dataset.knownSize;
noteTranscriptRowMeasurement(row, "offsetHeight", 420);
deepEqual(measurementEvents.at(-1), {
deepEqual(measurementEvents[measurementEvents.length - 1], {
type: "row-measure",
fields: {
rowIndex: 44,
@@ -7,6 +7,7 @@
// actually mounted in the viewport, not just that a jump was dispatched.
import { createTranscriptHarness } from "./transcript-dom-harness";
import { QUESTION_JUMP_MAX_MARKERS } from "../components/QuestionJumpBar";
import type { Item } from "../lib/useController";
import { act } from "react";
@@ -32,6 +33,14 @@ function turns(count: number, prefix = ""): Item[] {
return items;
}
function historyTurns(start: number, end: number): Item[] {
const items: Item[] = [];
for (let turn = start; turn <= end; turn += 1) {
items.push({ kind: "user", id: `history-u${turn}`, text: `history question ${turn}`, historyTurn: turn });
}
return items;
}
function dispatchScroll(el: HTMLElement) {
el.dispatchEvent(new Event("scroll"));
}
@@ -41,14 +50,16 @@ function stubRailGeometry(container: HTMLElement, count: number) {
const jumpScroll = container.querySelector<HTMLElement>(".jump-scroll");
if (!jumpBar || !jumpScroll) throw new Error("question jump bar is not mounted");
jumpBar.getBoundingClientRect = () => ({ top: 0, bottom: 240, left: 0, right: 56, height: 240, width: 56 } as DOMRect);
const items = jumpScroll.querySelectorAll<HTMLButtonElement>(".jump-item");
items.forEach((item, index) => {
item.getBoundingClientRect = () => ({ top: index * 20, bottom: index * 20 + 14, left: 0, right: 32, height: 14, width: 32 } as DOMRect);
});
jumpScroll.getBoundingClientRect = () => ({ top: 0, bottom: 240, left: 0, right: 32, height: 240, width: 32 } as DOMRect);
const items = jumpScroll.querySelectorAll<HTMLElement>(".jump-item");
if (items.length !== count) throw new Error(`expected ${count} jump markers, found ${items.length}`);
return jumpScroll;
}
function railClientY(turn: number, total: number): number {
return ((turn + 0.5) / total) * 240;
}
function visibleRows(container: HTMLElement): string[] {
const el = container.querySelector<HTMLElement>(".transcript");
if (!el) return [];
@@ -99,7 +110,7 @@ console.log("\ntranscript question jump landing");
const targetIndices = [0, 31, 3, 36, 12, 28, 39];
for (const targetIndex of targetIndices) {
const clientY = targetIndex * 20 + 7;
const clientY = railClientY(targetIndex, 40);
await act(async () => {
jumpScroll.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, button: 0, clientY }));
jumpScroll.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, button: 0, clientY, detail: 1 }));
@@ -148,8 +159,8 @@ console.log("\ntranscript question jump landing");
const targetIndex = 4;
await act(async () => {
jumpScroll.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, button: 0, clientY: targetIndex * 20 + 7 }));
jumpScroll.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, button: 0, clientY: targetIndex * 20 + 7, detail: 1 }));
jumpScroll.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, button: 0, clientY: railClientY(targetIndex, 20) }));
jumpScroll.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, button: 0, clientY: railClientY(targetIndex, 20), detail: 1 }));
dispatchScroll(el);
});
await harness.waitFor(
@@ -211,8 +222,8 @@ console.log("\ntranscript question jump landing");
const targetIndex = 9; // old-u1 sits at marker 3; pick a post-prepend question
await act(async () => {
jumpScroll.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, button: 0, clientY: targetIndex * 20 + 7 }));
jumpScroll.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, button: 0, clientY: targetIndex * 20 + 7, detail: 1 }));
jumpScroll.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, button: 0, clientY: railClientY(targetIndex, 18) }));
jumpScroll.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, button: 0, clientY: railClientY(targetIndex, 18), detail: 1 }));
dispatchScroll(el);
});
await harness.waitFor(
@@ -227,5 +238,144 @@ console.log("\ntranscript question jump landing");
}
}
// ── A one-turn remainder auto-loads without restoring the old fold button ───
{
const harness = await createTranscriptHarness({ viewportHeight: 200, rowHeight: 100 });
try {
HTMLElement.prototype.scrollIntoView = () => {};
let loads = 0;
await harness.render(historyTurns(2, 61), {
running: false,
questionNavigator: true,
hasOlderHistory: true,
historyStartTurn: 2,
historyTotalTurns: 61,
onLoadOlderHistory: async () => {
loads += 1;
return true;
},
});
await harness.waitFor(() => loads > 0, "the final missing history turn to auto-load");
ok(harness.container.querySelectorAll(".jump-item").length === 61, "the first page renders a marker for every session question");
ok(!harness.container.querySelector(".transcript__older"), "the ordinary show-earlier fold button is absent");
} finally {
await harness.unmount();
await harness.close();
}
}
// ── Extreme sessions keep a bounded rail and exact lazy-load targeting ──────
{
const harness = await createTranscriptHarness({ viewportHeight: 200, rowHeight: 100 });
try {
HTMLElement.prototype.scrollIntoView = () => {};
const requestedTurns: Array<number | undefined> = [];
await harness.render(historyTurns(9_941, 10_000), {
running: false,
questionNavigator: true,
hasOlderHistory: true,
historyStartTurn: 9_941,
historyTotalTurns: 10_000,
olderHistoryError: "pause automatic loading for this rail-only fixture",
onLoadOlderHistory: async (targetTurn) => {
requestedTurns.push(targetTurn);
return true;
},
});
await harness.settle();
const jumpScroll = stubRailGeometry(harness.container, QUESTION_JUMP_MAX_MARKERS);
ok(harness.container.querySelectorAll(".jump-item").length === QUESTION_JUMP_MAX_MARKERS, "10,000 turns keep a fixed-size question rail");
const targetTurn = 1_234;
await act(async () => {
jumpScroll.dispatchEvent(new MouseEvent("mousedown", {
bubbles: true,
cancelable: true,
button: 0,
clientY: railClientY(targetTurn, 10_000),
}));
});
await harness.waitFor(() => requestedTurns.includes(targetTurn + 1), "the aggregate rail to request the exact absolute turn");
ok(requestedTurns.includes(targetTurn + 1), "the bounded rail preserves exact unloaded-question targeting");
} finally {
await harness.unmount();
await harness.close();
}
}
// ── Clicking an unloaded complete-rail marker loads and lands on that turn ──
{
const harness = await createTranscriptHarness({ viewportHeight: 200, rowHeight: 100 });
try {
HTMLElement.prototype.scrollIntoView = () => {};
const requestedTurns: Array<number | undefined> = [];
const onLoadOlderHistory = async (targetTurn?: number) => {
requestedTurns.push(targetTurn);
return true;
};
await harness.render(historyTurns(81, 100), {
running: false,
questionNavigator: true,
hasOlderHistory: true,
historyStartTurn: 81,
historyTotalTurns: 100,
onLoadOlderHistory,
});
await harness.settle();
const jumpScroll = stubRailGeometry(harness.container, 100);
await act(async () => {
jumpScroll.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, button: 0, clientY: railClientY(10, 100) }));
});
await harness.waitFor(() => requestedTurns.includes(11), "the unloaded marker to request its absolute turn");
await harness.render(historyTurns(11, 100), {
running: false,
questionNavigator: true,
hasOlderHistory: false,
historyStartTurn: 11,
historyTotalTurns: 100,
onLoadOlderHistory,
});
await harness.waitFor(
() => Boolean(harness.container.querySelector("#question-anchor-history-u11")),
"the newly loaded question to land in the viewport",
);
ok(Boolean(harness.container.querySelector("#question-anchor-history-u11")), "an unloaded marker loads and lands on the selected question");
} finally {
await harness.unmount();
await harness.close();
}
}
// ── Failed automatic loading stops and exposes a compact retry state ─────────
{
const harness = await createTranscriptHarness({ viewportHeight: 200, rowHeight: 100 });
try {
HTMLElement.prototype.scrollIntoView = () => {};
let retries = 0;
await harness.render(historyTurns(81, 100), {
running: false,
questionNavigator: true,
hasOlderHistory: true,
historyStartTurn: 81,
historyTotalTurns: 100,
olderHistoryError: "backend detail stays out of the UI",
onLoadOlderHistory: async () => {
retries += 1;
return true;
},
});
const retry = Array.from(harness.container.querySelectorAll<HTMLButtonElement>("button"))
.find((button) => button.textContent?.trim() === "Retry");
ok(Boolean(retry), "a failed older-history load exposes a retry action");
ok(harness.container.textContent?.includes("Earlier conversation could not be loaded") ?? false, "the failure uses a path-free user-facing message");
await act(async () => retry?.click());
await harness.waitFor(() => retries === 1, "the older-history retry to run once");
} finally {
await harness.unmount();
await harness.close();
}
}
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
@@ -0,0 +1,72 @@
// Run: node --import tsx src/__tests__/transcript-question-nav-integration.test.ts
import { createTranscriptHarness } from "./transcript-dom-harness";
import type { Item } from "../lib/useController";
import { act } from "react";
let passed = 0;
let failed = 0;
function ok(condition: unknown, label: string) {
if (condition) {
process.stdout.write(` PASS ${label}\n`);
passed += 1;
} else {
process.stdout.write(` FAIL ${label}\n`);
failed += 1;
}
}
function turns(count: number): Item[] {
const items: Item[] = [];
for (let i = 0; i < count; i += 1) {
items.push({
kind: "user",
id: `u${i}`,
text: `question ${i}`,
historyTurn: i + 1,
checkpointTurn: 1_000 + i,
});
}
return items;
}
console.log("\ntranscript question navigation integration");
const harness = await createTranscriptHarness({ viewportHeight: 200, rowHeight: 20 });
try {
HTMLElement.prototype.scrollIntoView = () => {};
await harness.render(turns(8), { running: false, questionNavigator: true });
await harness.settle();
const transcript = harness.scrollElement();
transcript.getBoundingClientRect = () => ({ top: 100, bottom: 300, left: 0, right: 800, height: 200, width: 800 } as DOMRect);
const setAnchorPositions = (activeTurn: number) => {
harness.container.querySelectorAll<HTMLElement>("[data-question-anchor]").forEach((anchor, turn) => {
const top = turn <= activeTurn ? -20 - (activeTurn - turn) * 20 : 40 + (turn - activeTurn) * 20;
anchor.getBoundingClientRect = () => ({ top: 100 + top, bottom: 120 + top, left: 0, right: 400, height: 20, width: 400 } as DOMRect);
});
};
const dots = () => Array.from(harness.container.querySelectorAll<HTMLElement>(".jump-dot"));
ok(dots().length === 8, "question navigator renders one marker per question");
setAnchorPositions(5);
await act(async () => {
transcript.dispatchEvent(new Event("scroll"));
await new Promise((resolve) => setTimeout(resolve, 30));
});
ok(dots()[5]?.style.width === "18px", "manual scroll moves the active marker to the visible question");
ok(dots()[7]?.style.width === "12px", "the old tail marker is no longer active after manual scroll");
ok(
harness.container.querySelector<HTMLElement>('.jump-item[data-turn="5"] .jump-dot')?.style.width === "18px",
"scroll sync uses the absolute question index instead of the unrelated checkpoint turn",
);
await harness.settle();
} finally {
await harness.unmount();
await harness.close();
}
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
@@ -0,0 +1,48 @@
// Run: node --import tsx src/__tests__/transcript-question-nav.test.ts
import { activeQuestionTurn, type QuestionAnchorPosition } from "../lib/transcriptGrouping";
let passed = 0;
let failed = 0;
function equal(actual: unknown, expected: unknown, label: string) {
if (actual === expected) {
process.stdout.write(` PASS ${label}\n`);
passed += 1;
} else {
process.stdout.write(` FAIL ${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}\n`);
failed += 1;
}
}
console.log("\ntranscript question navigation");
equal(
activeQuestionTurn([
{ turn: 0, top: -520 },
{ turn: 1, top: -36 },
{ turn: 2, top: 240 },
] satisfies QuestionAnchorPosition[], 0),
1,
"active question follows the last anchor above the viewport",
);
equal(
activeQuestionTurn([
{ turn: 1, top: 28 },
{ turn: 2, top: 300 },
] satisfies QuestionAnchorPosition[], 0),
1,
"active question falls back to the first mounted anchor at the top boundary",
);
equal(
activeQuestionTurn([{ turn: 2, top: -12 }] satisfies QuestionAnchorPosition[], 0),
2,
"virtualized scrolling can update from the mounted question window",
);
equal(activeQuestionTurn([], 0), undefined, "missing anchors leave the active question unchanged");
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
@@ -6,6 +6,7 @@
// refs, and the markdown cache budget.
import { TranscriptStore } from "../lib/transcriptStore";
import { historyPageRequestBudget } from "../lib/historyPaging";
import { historyMessagesToItems, type Item } from "../lib/useController";
import type {
HistoryContentChunk,
@@ -104,7 +105,8 @@ class FakeBackend {
};
}
// Turn-budgeted windowing, mirroring the Go slice semantics.
// Turn- and entry-budgeted windowing, mirroring the Go slice semantics for
// the compact stress fixtures used below.
async HistorySliceForTab(_tabID: string, req: HistorySliceRequest): Promise<HistorySlice> {
this.sliceCalls.push(req);
if (this.sliceGate) {
@@ -135,6 +137,8 @@ class FakeBackend {
if (turnsOf[i] >= oldestTurn) { lo = i; break; }
}
}
const entries = Math.max(1, Math.floor(req.entries || 120));
lo = Math.max(lo, before - entries);
return this.slice(lo, before);
}
@@ -181,6 +185,34 @@ function bigTranscript(turns: number): HistoryMessage[] {
return messages;
}
function longArchivedTranscript(turns: number): HistoryMessage[] {
const messages: HistoryMessage[] = [];
for (let turn = 1; turn <= turns; turn += 1) {
const callId = `archived-${turn}`;
messages.push({ role: "user", content: `prompt ${turn}` });
messages.push({
role: "assistant",
content: `answer ${turn}`,
toolCalls: [{
id: callId,
name: "bash",
arguments: "",
argumentsArchived: true,
subject: `command ${turn}`,
summary: "1 line",
}],
});
messages.push({
role: "tool",
toolCallId: callId,
toolName: "bash",
content: "",
toolResultArchived: true,
});
}
return messages;
}
// Canonical shape for cross-scheme equality (ids are scheme-dependent and
// verified separately).
function canon(items: Item[]): unknown[] {
@@ -219,6 +251,11 @@ console.log("\ntranscript store");
const first = await store.loadLatest("tab-1", "/s/one.jsonl", { turns: 12 });
ok(!!first && first.items.length > 0, "latest page projects items");
eq(first?.hasOlder, true, "latest page reports older history");
const projectedTurns = (first?.items ?? [])
.filter((item): item is Extract<Item, { kind: "user" }> => item.kind === "user")
.map((item) => item.historyTurn);
eq(projectedTurns[projectedTurns.length - 1], 46, "history user items retain their absolute turn for complete-session navigation");
ok(projectedTurns.every((turn) => Number.isInteger(turn) && (turn ?? 0) > 0), "every paged user item carries an absolute history turn");
const firstIds = (first?.items ?? []).map((item) => item.id);
await drainOlder(store, "tab-1", "/s/one.jsonl", 12);
const full = store.peek("tab-1", "/s/one.jsonl");
@@ -230,6 +267,36 @@ console.log("\ntranscript store");
eq(unique.size, fullIds.length, "item ids are unique across the full projection");
}
// ── 10,000-turn targeted paging stays bounded ──────────────────────────────
{
const messages = longArchivedTranscript(10_000);
const backend = new FakeBackend(messages, new Map(), "stress");
const store = new TranscriptStore(backend);
const startedAt = performance.now();
let projection = await store.loadLatest("tab-stress", "/s/stress.jsonl", { turns: 60 });
let pages = 1;
while (projection?.hasOlder && pages <= 40) {
const budget = historyPageRequestBudget(projection.startTurn, projection.totalTurns, 1);
const older = await store.loadOlder("tab-stress", "/s/stress.jsonl", budget);
if (!older) break;
projection = older;
pages += 1;
}
const elapsedMs = performance.now() - startedAt;
const users = (projection?.items ?? []).filter((item): item is Extract<Item, { kind: "user" }> => item.kind === "user");
eq(projection?.hasOlder, false, "10,000-turn target paging reaches the first page");
eq(pages, 32, "10,000-turn target paging respects both turn and production entry bounds");
eq(backend.sliceCalls.length, 32, "10,000-turn target paging performs the expected bounded backend calls");
ok(backend.sliceCalls.slice(1).every((request) => request.entries === 1000), "targeted pages use the backend's bounded 1000-entry capacity");
eq(users.length, 10_000, "10,000-turn target paging preserves every question");
eq(users[0]?.historyTurn, 1, "10,000-turn target paging lands on absolute turn one");
eq(users[users.length - 1]?.historyTurn, 10_000, "10,000-turn target paging keeps the tail coordinate");
ok(new Set(projection?.items.map((item) => item.id)).size === projection?.items.length, "10,000-turn target paging keeps item ids unique");
const stats = store.stats();
ok(stats.bodyBytes <= stats.bodyBudgetBytes, "10,000-turn transcript stays within the production history body budget");
ok(elapsedMs < 10_000, `10,000-turn targeted paging completes within 10s (${elapsedMs.toFixed(1)}ms)`);
}
// ── cross-page tool call/result merge ───────────────────────────────────────
{
const messages: HistoryMessage[] = [
@@ -1,6 +1,7 @@
// Run: tsx src/__tests__/use-controller-meta.test.ts
import { currentTurnWaitMs, effortSwitchNoticeText, foregroundRunningFromRuntimeMeta, historyMessagesToItems, initialState, localizedBackendNoticeText, localizedNoticeText, metaFromTab, modelSwitchNoticeText, reducer, sameMeta, type Item } from "../lib/useController";
import { historyPageRequestBudget, historyTurnsToLoad } from "../lib/historyPaging";
import { shouldReconcileStaleTurn } from "../lib/useStaleTurnWatchdog";
import { parseTodos } from "../lib/tools";
import { resolveTodoPanelTodos } from "../lib/todoVisibility";
@@ -273,6 +274,12 @@ console.log("\nuse controller meta");
}
{
eq(historyTurnsToLoad(941, 1_000, 1), 500, "a distant question jump uses the bounded 500-turn history window");
eq(historyTurnsToLoad(441, 1_000, 1), 440, "the follow-up jump page reaches the requested turn without overfetching");
eq(historyTurnsToLoad(2, 61), 60, "ordinary automatic history loading keeps the standard page size");
eq(JSON.stringify(historyPageRequestBudget(941, 1_000, 1)), JSON.stringify({ turns: 500, entries: 1000 }), "a distant jump uses the backend's bounded entry capacity");
eq(JSON.stringify(historyPageRequestBudget(2, 61)), JSON.stringify({ turns: 60 }), "ordinary history loading keeps the default entry and byte budgets");
let s = reducer(initialState, {
type: "event",
e: { kind: "notice", level: "warn", code: "session_recovery_depth_cap", text: "reworded recovery maintenance" },
@@ -720,12 +727,17 @@ eq(sameMeta(meta({ collaborationMode: "normal" }), meta({ collaborationMode: "pl
},
});
eq(s.items.some((item) => item.kind === "user" && item.text === "recent prompt"), true, "history page replace renders the latest window");
eq(s.historyStartTurn, 60, "history page stores the older cursor");
eq(s.historyStartTurn, 61, "legacy history page converts its zero-based cursor to the first absolute turn");
eq(s.historyHasOlder, true, "history page records older availability");
const recentUser = s.items.find((item) => item.kind === "user" && item.text === "recent prompt");
eq(recentUser?.kind === "user" && recentUser.checkpointTurn, 1060, "paged history hydrates its authoritative checkpoint turn");
eq(recentUser?.kind === "user" && recentUser.historyTurn, 61, "legacy history page preserves the absolute question turn");
s = reducer(s, { type: "history_older_start" });
eq(s.historyOlderLoading, true, "older history request marks loading");
s = reducer(s, { type: "history_older_error", error: "read failed" });
eq(s.historyOlderError, "read failed", "older history failures remain available to the retry UI");
s = reducer(s, { type: "history_older_start" });
eq(s.historyOlderError, undefined, "retrying older history clears the previous failure");
s = reducer(s, {
type: "history_page",
mode: "prepend",
@@ -743,6 +755,8 @@ eq(sameMeta(meta({ collaborationMode: "normal" }), meta({ collaborationMode: "pl
const users = s.items.filter((item) => item.kind === "user");
eq(users[0]?.kind === "user" && users[0].text, "older prompt", "older history prepends before the current window");
eq(users[1]?.kind === "user" && users[1].text, "recent prompt", "older history keeps the current window");
eq(users[0]?.kind === "user" && users[0].historyTurn, 1, "legacy prepend starts at absolute turn one");
eq(users[1]?.kind === "user" && users[1].historyTurn, 61, "legacy prepend keeps the recent page's absolute turn");
eq(s.historyHasOlder, false, "older history clears hasOlder when all pages are loaded");
eq(s.historyOlderLoading, false, "older history clears loading");
}
@@ -1,86 +1,146 @@
// QuestionJumpBar: the question navigator rail along the transcript edge.
import { useEffect, useRef, useState, type CSSProperties, type MouseEvent as ReactMouseEvent } from "react";
import { useMemo, useRef, useState, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent } from "react";
import { useT } from "../lib/i18n";
import type { QuestionAnchor } from "../lib/transcriptGrouping";
export function QuestionJumpBar({ questions, onJump }: { questions: QuestionAnchor[]; onJump: (question: QuestionAnchor) => void }) {
export const QUESTION_JUMP_MAX_MARKERS = 120;
export function sampledQuestionTurns(totalQuestions: number, activeTurn: number | null, limit = QUESTION_JUMP_MAX_MARKERS): number[] {
const total = Math.max(0, Math.floor(totalQuestions));
const maxMarkers = Math.max(2, Math.floor(limit));
if (total <= maxMarkers) return Array.from({ length: total }, (_, turn) => turn);
const turns = Array.from({ length: maxMarkers }, (_, index) => (
Math.round(index * (total - 1) / (maxMarkers - 1))
));
if (maxMarkers <= 2 || activeTurn == null || activeTurn <= 0 || activeTurn >= total - 1 || turns.includes(activeTurn)) return turns;
let replaceIndex = 1;
let closestDistance = Number.POSITIVE_INFINITY;
for (let index = 1; index < turns.length - 1; index += 1) {
const distance = Math.abs(turns[index] - activeTurn);
if (distance < closestDistance) {
closestDistance = distance;
replaceIndex = index;
}
}
turns[replaceIndex] = activeTurn;
turns.sort((left, right) => left - right);
return turns;
}
export function questionTurnFromRailY(clientY: number, top: number, height: number, totalQuestions: number): number | null {
const total = Math.max(0, Math.floor(totalQuestions));
if (total === 0 || !Number.isFinite(height) || height <= 0) return null;
const ratio = Math.max(0, Math.min(1, (clientY - top) / height));
return Math.min(total - 1, Math.floor(ratio * total));
}
export function QuestionJumpBar({
loadedQuestions,
totalQuestions,
activeTurn,
onJump,
}: {
loadedQuestions: QuestionAnchor[];
totalQuestions: number;
activeTurn: number | null;
onJump: (question: QuestionAnchor) => void;
}) {
const t = useT();
const total = Math.max(0, Math.floor(totalQuestions));
const [hovered, setHovered] = useState<number | null>(null);
const [active, setActive] = useState<number | null>(null);
const barRef = useRef<HTMLDivElement>(null);
const barRef = useRef<HTMLElement>(null);
const railRef = useRef<HTMLDivElement>(null);
const previewTop = useRef(0);
const [showPreview, setShowPreview] = useState(false);
useEffect(() => {
if (questions.length === 0) return;
setActive(questions[questions.length - 1]?.turn ?? null);
}, [questions]);
const loadedByTurn = useMemo(() => {
const loaded = new Map<number, QuestionAnchor>();
for (const question of loadedQuestions) loaded.set(question.turn, question);
return loaded;
}, [loadedQuestions]);
const active = activeTurn != null && activeTurn >= 0 && activeTurn < total
? activeTurn
: total > 0 ? total - 1 : null;
useEffect(() => {
if (active === null) return;
const el = barRef.current?.querySelector(`[data-turn="${active}"]`);
el?.scrollIntoView({ block: "nearest" });
}, [active]);
const markerTurns = useMemo(() => sampledQuestionTurns(total, active), [active, total]);
const hoverIdx = hovered === null
? -1
: markerTurns.reduce((closest, turn, index) => (
closest < 0 || Math.abs(turn - hovered) < Math.abs(markerTurns[closest] - hovered) ? index : closest
), -1);
const hoverIdx = hovered !== null ? questions.findIndex((question) => question.turn === hovered) : -1;
const hoveredQuestion = hovered !== null ? questions.find((question) => question.turn === hovered) : undefined;
const closestQuestionFromY = (clientY: number): { question: QuestionAnchor; previewY: number } | null => {
const el = barRef.current;
if (!el) return null;
const markers = el.querySelectorAll<HTMLElement>(".jump-item");
const barRect = el.getBoundingClientRect();
let closest = -1;
let closestDist = Infinity;
let closestY = 0;
markers.forEach((item, index) => {
const rect = item.getBoundingClientRect();
const midY = rect.top + rect.height / 2;
const dist = Math.abs(clientY - midY);
if (dist < closestDist) {
closestDist = dist;
closest = index;
closestY = midY - barRect.top;
}
});
const question = questions[closest];
if (!question) return null;
return { question, previewY: closestY };
const questionAt = (turn: number): QuestionAnchor => loadedByTurn.get(turn) ?? {
id: `history-question-${turn + 1}`,
text: t("questionNav.notLoaded", { n: turn + 1 }),
turn,
loaded: false,
};
const hoveredQuestion = hovered === null ? undefined : questionAt(hovered);
const onMove = (e: ReactMouseEvent<HTMLDivElement>) => {
const closest = closestQuestionFromY(e.clientY);
if (!closest) return;
previewTop.current = closest.previewY;
setHovered(closest.question.turn);
setShowPreview(true);
const questionFromY = (clientY: number): { question: QuestionAnchor; previewY: number } | null => {
const rail = railRef.current;
const bar = barRef.current;
if (!rail || !bar) return null;
const railRect = rail.getBoundingClientRect();
const turn = questionTurnFromRailY(clientY, railRect.top, railRect.height, total);
if (turn === null) return null;
const barRect = bar.getBoundingClientRect();
const turnCenter = railRect.top - barRect.top + ((turn + 0.5) / total) * railRect.height;
return {
question: questionAt(turn),
previewY: Math.max(0, Math.min(barRect.height, turnCenter)),
};
};
const scrollTo = (question: QuestionAnchor) => {
setActive(question.turn);
onJump(question);
};
const onRailMouseDown = (e: ReactMouseEvent<HTMLDivElement>) => {
const closest = closestQuestionFromY(e.clientY);
if (!closest) return;
e.preventDefault();
previewTop.current = closest.previewY;
setHovered(closest.question.turn);
const onMove = (event: ReactMouseEvent<HTMLDivElement>) => {
const target = questionFromY(event.clientY);
if (!target) return;
previewTop.current = target.previewY;
setHovered(target.question.turn);
setShowPreview(true);
scrollTo(closest.question);
};
const onItemMouseDown = (e: ReactMouseEvent<HTMLButtonElement>, question: QuestionAnchor) => {
e.preventDefault();
scrollTo(question);
const onRailMouseDown = (event: ReactMouseEvent<HTMLDivElement>) => {
if (event.button !== 0) return;
const target = questionFromY(event.clientY);
if (!target) return;
event.preventDefault();
previewTop.current = target.previewY;
setHovered(target.question.turn);
setShowPreview(true);
scrollTo(target.question);
};
const dotProps = (
idx: number,
turn: number,
): { style: CSSProperties; "data-d"?: string } => {
const onRailKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
if (total === 0) return;
const current = active ?? Math.max(0, total - 1);
const page = Math.max(1, Math.round(total / 10));
let next: number | null = null;
switch (event.key) {
case "ArrowUp":
case "ArrowLeft": next = current - 1; break;
case "ArrowDown":
case "ArrowRight": next = current + 1; break;
case "PageUp": next = current - page; break;
case "PageDown": next = current + page; break;
case "Home": next = 0; break;
case "End": next = total - 1; break;
case "Enter":
case " ": next = current; break;
default: return;
}
event.preventDefault();
scrollTo(questionAt(Math.max(0, Math.min(total - 1, next))));
};
const dotProps = (idx: number, turn: number): { style: CSSProperties; "data-d"?: string } => {
const isActive = active === turn;
if (hoverIdx < 0) {
return { style: { width: isActive ? 18 : 12, background: isActive ? "var(--accent)" : undefined } };
@@ -89,40 +149,51 @@ export function QuestionJumpBar({ questions, onJump }: { questions: QuestionAnch
const width = d === 0 ? 32 : d === 1 ? 20 : d === 2 ? 14 : isActive ? 18 : 12;
const background = d <= 2 ? undefined : isActive ? "var(--accent)" : undefined;
return {
style: { width, transitionDelay: `${d * 20}ms`, background },
style: { width, transitionDelay: `${Math.min(d, 3) * 20}ms`, background },
"data-d": d <= 2 ? String(d) : undefined,
};
};
const density = markerTurns.length > 80 ? "packed" : markerTurns.length > 40 ? "compact" : "normal";
const activeValue = active ?? Math.max(0, total - 1);
return (
<nav
className="jump-bar"
ref={barRef}
aria-label={t("questionNav.label")}
onMouseMove={onMove}
onMouseLeave={() => {
setHovered(null);
setShowPreview(false);
}}
>
{/* Pointer activation is owned by mousedown. Handling the following click
as well can issue a second jump after the active marker has moved. */}
<div className="jump-scroll" onMouseDown={onRailMouseDown}>
{questions.map((question, index) => (
<button
<div
className="jump-scroll"
ref={railRef}
role="slider"
tabIndex={0}
aria-label={t("questionNav.label")}
aria-orientation="vertical"
aria-valuemin={1}
aria-valuemax={Math.max(1, total)}
aria-valuenow={activeValue + 1}
aria-valuetext={t("questionNav.progress", { current: activeValue + 1, total })}
data-density={density}
onMouseMove={onMove}
onMouseDown={onRailMouseDown}
onKeyDown={onRailKeyDown}
>
{markerTurns.map((turn, index) => (
<span
className="jump-item"
key={question.id}
type="button"
data-turn={question.turn}
aria-label={t("questionNav.jump", { n: question.turn + 1 })}
onMouseDown={(e) => onItemMouseDown(e, question)}
onClick={(e) => {
e.stopPropagation();
if (e.detail === 0) scrollTo(question);
}}
key={turn}
data-turn={turn}
data-loaded={loadedByTurn.has(turn) ? "true" : "false"}
aria-hidden="true"
style={{ top: `${((turn + 0.5) / total) * 100}%` }}
>
<span className="jump-dot" {...dotProps(index, question.turn)} />
</button>
<span className="jump-dot" {...dotProps(index, turn)} />
</span>
))}
</div>
{showPreview && hoveredQuestion && (
@@ -133,3 +204,5 @@ export function QuestionJumpBar({ questions, onJump }: { questions: QuestionAnch
</nav>
);
}
export default QuestionJumpBar;
+81 -91
View File
@@ -7,7 +7,7 @@ import { useT } from "../lib/i18n";
import { AssistantMessage, InvocationMetadataContext, TurnActions, UserMessage } from "./Message";
import { ToolCard } from "./ToolCard";
import { ExtensionCard } from "./ExtensionCard";
import { ArrowDown, Loader2 } from "lucide-react";
import { ArrowDown, Loader2, RotateCcw } from "lucide-react";
import { Welcome } from "./Welcome";
import { ReadOnlyBatch } from "./ReadOnlyBatch";
import { ToolGroup } from "./ToolGroup";
@@ -15,7 +15,9 @@ import { getProcessFoldPreference, onProcessFoldPreferenceChange, type ProcessFo
import { isSteerNoticeText } from "../lib/useController";
import { useTranscriptEntranceAnimation } from "../lib/useEntranceAnimation";
import { useTranscriptSelectionRetention } from "../lib/useTranscriptSelectionRetention";
import { compactQuestionText, lastQuestionTurn, questionAnchorId, questionTurnsById, type QuestionAnchor } from "../lib/transcriptGrouping";
import {
questionAnchorId,
} from "../lib/transcriptGrouping";
import {
buildTranscriptRows,
buildTurnModels,
@@ -25,7 +27,6 @@ import {
historyEntryIdForRow,
reconcileFoldEntries,
splitTranscriptLiveRows,
userRowKey,
EMPTY_FOLDS,
NO_LIVE,
type AssistantItem,
@@ -43,7 +44,6 @@ import { useReasoningDisplayMode } from "../lib/reasoningDisplayPreference";
import { InlineAssistantReasoning } from "./InlineAssistantReasoning";
import { LiveTurnRegion } from "./LiveTurnRegion";
import { ProcessFoldHeader } from "./ProcessFoldHeader";
import { QuestionJumpBar } from "./QuestionJumpBar";
import { CompactionCard, NoticeCard, PhaseCard, SteerCard } from "./TranscriptCards";
import { LiveStreamContext } from "./LiveStreamContext";
import { useTranscriptSelectableRows } from "../lib/useTranscriptSelectableRows";
@@ -55,6 +55,7 @@ import { useTranscriptLayoutIntegrity } from "../lib/useTranscriptLayoutIntegrit
import { TranscriptLayoutIntentProvider } from "./TranscriptLayoutIntentContext";
import { MarkdownImageTabContext } from "./MarkdownImageContext";
import { recordTranscriptScrollDiagnostic } from "../lib/transcriptScrollProbe";
import { useTranscriptQuestionJump, useTranscriptQuestions } from "../lib/useTranscriptQuestionNavigation";
// NoticeCard lives with the other row cards; keep the historical export path.
export { NoticeCard } from "./TranscriptCards";
@@ -64,6 +65,7 @@ type AssistantReasoningDisplay = "normal" | "hide";
const EMPTY_CHECKPOINTS: CheckpointMeta[] = [];
const EMPTY_INVOCATION_METADATA: InvocationMetadataMap = {};
const NO_HELD_ROWS: readonly TranscriptRow[] = [];
const QuestionJumpBar = lazy(() => import("./QuestionJumpBar"));
const SHOW_SCROLL_DIAGNOSTICS = typeof __BUILD_CHANNEL__ === "undefined" || __BUILD_CHANNEL__ === "test" || import.meta.env.DEV;
const ScrollDiagnosticPanel = SHOW_SCROLL_DIAGNOSTICS ? lazy(() => import("./ScrollDiagnosticPanel")) : null;
@@ -131,8 +133,8 @@ type TranscriptVirtuosoContext = {
};
olderHistory: null | {
loading: boolean;
label: string;
onLoad?: () => void;
error?: string;
onRetry: () => void;
};
};
@@ -193,17 +195,27 @@ const TranscriptVirtuosoList = forwardRef<HTMLDivElement, ListProps & { context:
);
function TranscriptVirtuosoHeader({ context }: { context: TranscriptVirtuosoContext }) {
if (!context.olderHistory) return null;
const t = useT();
const older = context.olderHistory;
if (!older) return null;
return (
<div className="transcript__header">
<button
type="button"
className="warm-collapse transcript__older"
onClick={context.olderHistory.onLoad}
disabled={context.olderHistory.loading}
>
{context.olderHistory.label}
</button>
<div className="transcript__older-status" role={older.error ? "alert" : "status"}>
{older.loading ? (
<>
<Loader2 className="transcript__older-spinner" size={14} aria-hidden="true" />
<span>{t("common.loading")}</span>
</>
) : (
<>
<span>{older.error}</span>
<button type="button" className="btn btn--small" onClick={older.onRetry}>
<RotateCcw size={14} aria-hidden="true" />
<span>{t("common.retry")}</span>
</button>
</>
)}
</div>
</div>
);
}
@@ -270,8 +282,10 @@ export function Transcript({
revealSignal = 0,
hydrating = false,
hasOlderHistory = false,
olderHistoryCount = 0,
historyStartTurn = 0,
historyTotalTurns = 0,
loadingOlderHistory = false,
olderHistoryError,
onLoadOlderHistory,
turnStartAt,
contentRevision = 0,
@@ -301,9 +315,11 @@ export function Transcript({
revealSignal?: number;
hydrating?: boolean;
hasOlderHistory?: boolean;
olderHistoryCount?: number;
historyStartTurn?: number;
historyTotalTurns?: number;
loadingOlderHistory?: boolean;
onLoadOlderHistory?: () => void;
olderHistoryError?: string;
onLoadOlderHistory?: (targetTurn?: number) => boolean | Promise<boolean>;
turnStartAt?: number;
contentRevision?: number;
invocationMetadata?: InvocationMetadataMap;
@@ -358,7 +374,6 @@ export function Transcript({
onItemMeasured: measuredSizes.record,
});
const virtuosoReadyRef = useRef(false);
const entranceRef = useTranscriptEntranceAnimation<HTMLDivElement>(tabId, revealSignal, items);
// Lease the markdown parse worker for as long as a transcript surface is
@@ -390,28 +405,17 @@ export function Transcript({
finishProgrammaticScroll,
});
const questions = useMemo<QuestionAnchor[]>(() => {
const anchors: QuestionAnchor[] = [];
let turn = 0;
for (const it of items) {
if (it.kind !== "user") continue;
anchors.push({ id: it.id, text: compactQuestionText(it.text), turn, checkpointTurn: it.checkpointTurn });
turn += 1;
}
return anchors;
}, [items]);
const showQuestionNav = questionNavigator && questions.length >= QUESTION_NAV_MIN_COUNT;
// A new local question is an explicit request to reveal the tail. Prepending
// older history keeps the same last id and is left entirely to Virtuoso's
// firstItemIndex anchor contract.
const questionTailRef = useRef({ length: 0, lastId: "" });
useEffect(() => {
const lastId = questions[questions.length - 1]?.id ?? "";
const prev = questionTailRef.current;
questionTailRef.current = { length: questions.length, lastId };
if (prev.length > 0 && questions.length > prev.length && lastId !== prev.lastId) scrollToBottom();
}, [questions, scrollToBottom]);
const [
questions,
loadedByTurn,
totalQuestions,
activeQuestion,
setActiveQuestion,
scheduleActiveQuestionSync,
turnForUser,
lastTurn,
] = useTranscriptQuestions(items, historyStartTurn, historyTotalTurns, scrollElement, scrollToBottom);
const showQuestionNav = questionNavigator && totalQuestions >= QUESTION_NAV_MIN_COUNT;
// Reset the auto-scroll pin when switching tabs so the new session always
// starts at the bottom. Without this, stick.current from the previous tab
@@ -520,12 +524,8 @@ export function Transcript({
return () => document.removeEventListener("mousedown", onDown);
}, [openAction]);
const userTurn = useMemo(() => questionTurnsById(questions), [questions]);
const lastTurn = useMemo(() => lastQuestionTurn(questions, userTurn), [questions, userTurn]);
const checkpointsByTurn = useMemo(() => new Map(checkpoints.map((checkpoint) => [checkpoint.turn, checkpoint])), [checkpoints]);
const hasCheckpointForTurn = useCallback((turn: number) => checkpointsByTurn.has(turn), [checkpointsByTurn]);
const turnForUser = useCallback((item: Extract<Item, { kind: "user" }>) => userTurn.get(item.id), [userTurn]);
const rows = useMemo(
() => buildTranscriptRows(turnModels, { folds, foldPreference, hasOlderHistory, creationMode, turnForUser, hasCheckpointForTurn }),
[turnModels, folds, foldPreference, hasOlderHistory, creationMode, turnForUser, hasCheckpointForTurn],
@@ -660,20 +660,25 @@ export function Transcript({
deliverScroll();
noteScrollActivity();
if (creationMode) handleCreationScroll();
scheduleActiveQuestionSync();
scheduleBlankViewportCheck();
}, [creationMode, deliverScroll, handleCreationScroll, noteScrollActivity, scheduleBlankViewportCheck]);
// ── JumpBar integration ───────────────────────────────────────────────────
const handleJumpToQuestion = useCallback((question: QuestionAnchor) => {
const index = rowIndexByKey.get(String(userRowKey(question.id)));
if (index == null) return;
// WebView2 can lose the pointerup that ends a transcript text-selection
// gesture. An explicit navigator click owns the next viewport position,
// so clear that stale selection before asking the scroll arbiter to jump.
document.getSelection()?.removeAllRanges();
clearTranscriptSelection("question-navigation");
invalidateAnchors();
scrollToDataIndex(index, "smooth");
}, [clearTranscriptSelection, invalidateAnchors, rowIndexByKey, scrollToDataIndex]);
}, [creationMode, deliverScroll, handleCreationScroll, noteScrollActivity, scheduleActiveQuestionSync, scheduleBlankViewportCheck]);
const [handleJumpToQuestion, handleEarlierHistoryReached, retryOlderHistory] = useTranscriptQuestionJump({
questions,
loadedByTurn,
layoutSurfaceKey,
rowIndexByKey,
hasOlderHistory,
loadingOlderHistory,
olderHistoryError,
running,
onLoadOlderHistory,
clearTranscriptSelection,
invalidateAnchors,
scrollToDataIndex,
setActiveQuestion,
rewindSignal,
});
// The jump-bottom click is explicit user intent: it outranks any in-flight
// recovery anchor restore and ends a stale selection gesture whose
@@ -684,18 +689,6 @@ export function Transcript({
scrollToBottom();
};
// After a non-fork rewind, scroll to the last user message (the
// rewound-to point) so the user knows where they are.
useEffect(() => {
if (rewindSignal <= 0 || questions.length === 0) return;
const lastQ = questions[questions.length - 1];
const index = rowIndexByKey.get(String(userRowKey(lastQ.id)));
if (index == null) return;
invalidateAnchors();
scrollToDataIndex(index);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rewindSignal]);
const empty = items.length === 0;
// ── Row rendering ─────────────────────────────────────────────────────────
@@ -705,16 +698,7 @@ export function Transcript({
const renderRow = useCallback((row: TranscriptRow): ReactNode => {
switch (row.kind) {
case "older-history":
return (
<button
type="button"
className="warm-collapse transcript__older"
onClick={onLoadOlderHistory}
disabled={loadingOlderHistory}
>
{loadingOlderHistory ? t("common.loading") : t("transcript.showEarlierHistory", { n: olderHistoryCount })}
</button>
);
return null;
case "user": {
const user = row.item;
const checkpoint = row.turn == null ? undefined : checkpointsByTurn.get(row.turn);
@@ -841,12 +825,9 @@ export function Transcript({
handleFoldToggle,
handleReasoningManualOpen,
lastTurn,
loadingOlderHistory,
olderHistoryCount,
onDeliveryContinue,
onAcceptDelivery,
onEditPrompt,
onLoadOlderHistory,
onOpenChanges,
onOpenVerification,
onPrompt,
@@ -919,6 +900,7 @@ export function Transcript({
noteTranscriptRowCounts(rendered.length, virtualRows.length);
selectionRetention.reconcileLogicalFocus();
handleRecoveryItemsRendered(rendered.length);
scheduleActiveQuestionSync();
if (holdingLiveRegion) {
const held = heldLiveRowsRef.current;
const lastKey = held.length > 0 ? String(held[held.length - 1].key) : null;
@@ -927,7 +909,7 @@ export function Transcript({
setHoldingLiveRegion(false);
}
}
}, [handleRecoveryItemsRendered, holdingLiveRegion, selectionRetention.reconcileLogicalFocus, virtualRows.length]);
}, [handleRecoveryItemsRendered, holdingLiveRegion, scheduleActiveQuestionSync, selectionRetention.reconcileLogicalFocus, virtualRows.length]);
const handleTotalListHeightChanged = useCallback((height: number) => {
if (SHOW_SCROLL_DIAGNOSTICS) recordTranscriptScrollDiagnostic("list-height", { listHeight: height });
@@ -949,11 +931,11 @@ export function Transcript({
onPointerDownCapture: selectionRetention.onPointerDownCapture,
}
: null,
olderHistory: hasOlderHistory
olderHistory: hasOlderHistory && (loadingOlderHistory || Boolean(olderHistoryError))
? {
loading: loadingOlderHistory,
label: loadingOlderHistory ? t("common.loading") : t("transcript.showEarlierHistory", { n: olderHistoryCount }),
onLoad: onLoadOlderHistory,
error: olderHistoryError ? t("transcript.loadEarlierFailed") : undefined,
onRetry: retryOlderHistory,
}
: null,
}), [
@@ -965,10 +947,10 @@ export function Transcript({
loadingOlderHistory,
contentRevision,
nativeScrollbarDragging,
olderHistoryCount,
onLoadOlderHistory,
olderHistoryError,
overlayRevision,
renderRow,
retryOlderHistory,
scrollElement,
selectionRetention.onPointerDownCapture,
showLiveRegion,
@@ -1005,7 +987,7 @@ export function Transcript({
data-transcript-row-count={virtualRows.length}
data={virtualRows}
context={virtuosoContext}
components={hasOlderHistory ? TRANSCRIPT_VIRTUOSO_COMPONENTS_WITH_HEADER : TRANSCRIPT_VIRTUOSO_COMPONENTS}
components={virtuosoContext.olderHistory ? TRANSCRIPT_VIRTUOSO_COMPONENTS_WITH_HEADER : TRANSCRIPT_VIRTUOSO_COMPONENTS}
computeItemKey={(_index, row) => `${tabId ?? ""}:${String(row.key)}`}
firstItemIndex={firstItemIndex}
// A captured state snapshot (measured tree + scrollTop) restores
@@ -1028,6 +1010,7 @@ export function Transcript({
: { top: 480, bottom: 480 }}
scrollerRef={handleScrollerRef}
itemsRendered={handleItemsRendered}
startReached={handleEarlierHistoryReached}
totalListHeightChanged={handleTotalListHeightChanged}
itemContent={renderVirtuosoRow}
onScroll={handleTranscriptScroll}
@@ -1057,7 +1040,14 @@ export function Transcript({
)}
{!empty && showQuestionNav && (
<QuestionJumpBar questions={questions} onJump={handleJumpToQuestion} />
<Suspense fallback={null}>
<QuestionJumpBar
loadedQuestions={questions}
totalQuestions={totalQuestions}
activeTurn={activeQuestion}
onJump={handleJumpToQuestion}
/>
</Suspense>
)}
{!empty && !isAtBottom && scrollElement && hasTranscriptScrollableRange(scrollElement) && (
+22
View File
@@ -0,0 +1,22 @@
const HISTORY_PAGE_TURNS = 60;
const HISTORY_JUMP_MAX_TURNS = 500;
const HISTORY_JUMP_MAX_ENTRIES = 1000;
export function historyTurnsToLoad(startTurn: number, totalTurns: number, targetTurn?: number): number {
const normalizedTarget = Number.isInteger(targetTurn) && (targetTurn ?? 0) > 0
? Math.min(Math.max(1, totalTurns), targetTurn as number)
: undefined;
const missingTurns = normalizedTarget === undefined
? HISTORY_PAGE_TURNS
: Math.max(HISTORY_PAGE_TURNS, startTurn - normalizedTarget);
return Math.min(HISTORY_JUMP_MAX_TURNS, missingTurns);
}
export function historyPageRequestBudget(
startTurn: number,
totalTurns: number,
targetTurn?: number,
): { turns: number; entries?: number } {
const turns = historyTurnsToLoad(startTurn, totalTurns, targetTurn);
return targetTurn === undefined ? { turns } : { turns, entries: HISTORY_JUMP_MAX_ENTRIES };
}
+14 -1
View File
@@ -1,7 +1,20 @@
import { replaceAttachmentRefsForDisplay } from "./attachmentDisplay";
import type { Item } from "./useController";
export type QuestionAnchor = { id: string; text: string; turn: number; checkpointTurn?: number };
export type QuestionAnchor = { id: string; text: string; turn: number; checkpointTurn?: number; loaded?: boolean };
export type QuestionAnchorPosition = { turn: number; top: number };
export function activeQuestionTurn(
positions: readonly QuestionAnchorPosition[],
viewportTop = 0,
): number | undefined {
let active = positions[0];
for (const position of positions) {
if (position.top > viewportTop) break;
active = position;
}
return active?.turn;
}
export interface TurnGroup {
userItem: Item;
+7 -7
View File
@@ -273,7 +273,7 @@ function convertRecord(
}
if (m.role === "user") {
if (m.content.trim() !== "") {
items.push({ kind: "user", id, text: m.content, submitText: m.submitText, createdAt: m.createdAt, checkpointTurn: m.checkpointTurn });
items.push({ kind: "user", id, text: m.content, submitText: m.submitText, createdAt: m.createdAt, checkpointTurn: m.checkpointTurn, historyTurn: rec.turn > 0 ? rec.turn : undefined });
}
return { items, claims, unresolvedIds, pendingPositional, matches };
}
@@ -784,7 +784,7 @@ export class TranscriptStore {
async loadLatest(
tabId: string,
sessionPath: string,
options: { turns?: number; preferResident?: boolean; expectedRevision?: number; expectedDigest?: string } = {},
options: { turns?: number; entries?: number; bytes?: number; preferResident?: boolean; expectedRevision?: number; expectedDigest?: string } = {},
): Promise<TranscriptProjection | undefined> {
const key = sessionKeyFor(tabId, sessionPath);
const existing = this.sessions.get(key);
@@ -802,13 +802,13 @@ export class TranscriptStore {
this.sessions.set(key, session);
this.touch(session);
const turns = options.turns;
let slice = await this.fetchSlice(tabId, { cursor: "", turns });
const { turns, entries, bytes } = options;
let slice = await this.fetchSlice(tabId, { cursor: "", turns, entries, bytes });
if (this.sessions.get(key) !== session || session.generation !== generation) return undefined;
if (slice.stale) {
// cursor "" cannot bind a stale identity, but a concurrent rewrite may
// still report one — retry once against the settled revision.
slice = await this.fetchSlice(tabId, { cursor: "", turns });
slice = await this.fetchSlice(tabId, { cursor: "", turns, entries, bytes });
if (this.sessions.get(key) !== session || session.generation !== generation) return undefined;
}
this.replaceRecords(session, asArray<HistoryEntry>(slice.entries));
@@ -833,7 +833,7 @@ export class TranscriptStore {
async loadOlder(
tabId: string,
sessionPath: string,
options: { turns?: number } = {},
options: { turns?: number; entries?: number; bytes?: number } = {},
): Promise<LoadOlderResult | undefined> {
const key = sessionKeyFor(tabId, sessionPath);
const session = this.sessions.get(key);
@@ -847,7 +847,7 @@ export class TranscriptStore {
session.olderInFlight = true;
const generation = session.generation;
try {
const slice = await this.fetchSlice(tabId, { cursor: session.nextCursor, turns: options.turns });
const slice = await this.fetchSlice(tabId, { cursor: session.nextCursor, ...options });
if (this.sessions.get(key) !== session || session.generation !== generation) return undefined;
if (slice.stale) {
const projection = await this.loadLatest(tabId, sessionPath, options);
+53 -19
View File
@@ -25,6 +25,7 @@ import { applyHydrateErrorState, hydratePlaceholderItems as resolveHydratePlaceh
import { isHostRecoveryGuidance } from "./hostRecoverySteer";
import { activeTabHydrationPlan, canAdoptUnboundLiveSurface, duplicateLiveItemIds, hasCachedLiveTurn, hasReusableCachedTranscript, hydratedHistoryApplyMode, sameSessionHydrateIdentity, sameSessionPlaceholderItems, shouldPreferResidentHistory, type HydrateSurfacePolicy } from "./hydrateHistoryApply";
import { hydrateIdentityCurrent } from "./sessionIdentity";
import { historyPageRequestBudget } from "./historyPaging";
import { sameStringList, sameTodoList } from "./todoVisibility";
import { resolveSnapshotTurnStartedAt, resolveTurnStartedAt, snapshotPredatesTurnLifecycle } from "./turnTiming";
import { useStaleTurnWatchdog } from "./useStaleTurnWatchdog";
@@ -235,7 +236,7 @@ const HISTORY_PAGE_TURNS = 60;
export type TurnPhaseName = "working" | "checking" | "verifying" | "reviewing" | string;
export type Item =
| { kind: "user"; id: string; submissionId?: string; text: string; submitText?: string; failed?: boolean; createdAt?: number; checkpointTurn?: number }
| { kind: "user"; id: string; submissionId?: string; text: string; submitText?: string; failed?: boolean; createdAt?: number; checkpointTurn?: number; historyTurn?: number }
| { kind: "assistant"; id: string; text: string; reasoning: string; streaming: boolean; reasoningComplete?: boolean; reasoningDurationMs?: number; workDurationMs?: number; memoryCitations?: MemoryCitation[]; searchSources?: SearchSource[] }
| { kind: "phase"; id: string; text: string }
| { kind: "notice"; id: string; level: "info" | "warn"; text: string; detail?: string; title?: string; variant?: "delivery" | "completion"; action?: "continue_delivery" | "open_changes"; completionSummary?: WireCompletionSummary; decisionReceipt?: WireDecisionReceipt; missing?: string[]; inboxItemId?: string }
@@ -363,6 +364,7 @@ interface State {
historyTotalTurns: number;
historyHasOlder: boolean;
historyOlderLoading: boolean;
historyOlderError?: string;
historyRevision?: number;
historyDigest?: string;
/** Bumped when lazy history content can change already-estimated row sizes. */
@@ -789,7 +791,7 @@ type Action =
| { type: "history_prepend"; items: Item[]; removeIds: string[]; startTurn: number; totalTurns: number; hasOlder: boolean; revision?: number; digest?: string }
| { type: "history_items_patch"; patches: Record<string, Item> }
| { type: "history_older_start" }
| { type: "history_older_error" }
| { type: "history_older_error"; error?: string }
| { type: "local_notice"; level: "info" | "warn"; text: string; preserveRuntime?: boolean }
| { type: "clearApproval" }
| { type: "clearAsk" }
@@ -963,8 +965,22 @@ function applyTurnCheckpoint(items: Item[], submissionId: string | undefined, tu
return changed ? next : items;
}
function historyPageItems(page: HistoryPage): { items: Item[]; seq: number } {
return historyMessagesToItems(asArray(page.messages), `h${page.startTurn}-`, 0);
function historyPageItems(page: HistoryPage): { items: Item[]; seq: number; firstTurn: number } {
const converted = historyMessagesToItems(asArray(page.messages), `h${page.startTurn}-`, 0);
let historyTurn = page.startTurn + 1;
const items = converted.items.map((item) => {
if (item.kind !== "user") return item;
const user = { ...item, historyTurn };
historyTurn += 1;
return user;
});
return {
items,
seq: converted.seq,
// Legacy HistoryPage is 0-based while HistorySlice is 1-based. State uses
// the HistorySlice coordinate so every transcript consumer sees one model.
firstTurn: page.totalTurns > 0 ? page.startTurn + 1 : 0,
};
}
function positionalToolResults(messages: HistoryMessage[]): Map<string, { message: HistoryMessage; index: number }> {
@@ -2044,10 +2060,10 @@ export function reducer(s: State, a: Action): State {
case "message_action_done": return { ...s, messageAction: undefined };
case "history": {
const { items, seq } = historyMessagesToItems(a.messages, "h", s.seq);
return { ...s, items: compactArchivedToolItems(items), pendingSubmissionId: undefined, seq, hydrateHistoryLoaded: true, hydratePlaceholderItems: undefined, historyStartTurn: 0, historyTotalTurns: 0, historyHasOlder: false, historyOlderLoading: false, historyRevision: undefined, historyDigest: undefined };
return { ...s, items: compactArchivedToolItems(items), pendingSubmissionId: undefined, seq, hydrateHistoryLoaded: true, hydratePlaceholderItems: undefined, historyStartTurn: 0, historyTotalTurns: 0, historyHasOlder: false, historyOlderLoading: false, historyOlderError: undefined, historyRevision: undefined, historyDigest: undefined };
}
case "history_page": {
const { items, seq } = historyPageItems(a.page);
const { items, seq, firstTurn } = historyPageItems(a.page);
const nextItems = a.mode === "prepend" ? [...items, ...s.items] : items;
return {
...s,
@@ -2056,16 +2072,17 @@ export function reducer(s: State, a: Action): State {
seq: Math.max(s.seq, seq),
hydrateHistoryLoaded: true,
hydratePlaceholderItems: undefined,
historyStartTurn: a.page.startTurn,
historyStartTurn: firstTurn,
historyTotalTurns: a.page.totalTurns,
historyHasOlder: a.page.hasOlder,
historyOlderLoading: false,
historyOlderError: undefined,
historyRevision: a.page.revision,
historyDigest: a.page.digest,
};
}
case "history_older_start": return s.historyOlderLoading ? s : { ...s, historyOlderLoading: true };
case "history_older_error": return s.historyOlderLoading ? { ...s, historyOlderLoading: false } : s;
case "history_older_start": return s.historyOlderLoading && !s.historyOlderError ? s : { ...s, historyOlderLoading: true, historyOlderError: undefined };
case "history_older_error": return { ...s, historyOlderLoading: false, historyOlderError: a.error };
case "history_replace":
return {
...s,
@@ -2077,6 +2094,7 @@ export function reducer(s: State, a: Action): State {
historyTotalTurns: a.totalTurns,
historyHasOlder: a.hasOlder,
historyOlderLoading: false,
historyOlderError: undefined,
historyRevision: a.revision,
historyDigest: a.digest,
};
@@ -2092,6 +2110,7 @@ export function reducer(s: State, a: Action): State {
historyTotalTurns: a.totalTurns,
historyHasOlder: a.hasOlder,
historyOlderLoading: false,
historyOlderError: undefined,
historyRevision: a.revision,
historyDigest: a.digest,
};
@@ -2770,6 +2789,7 @@ export function useController() {
const checkpointRefreshSeq = useRef(new Map<string, number>());
const metaRefreshSeq = useRef(new Map<string, number>());
const sessionLoadSeq = useRef(new Map<string, number>());
const historyOlderSeq = useRef(new Map<string, number>());
const cancelHydrateSeq = useRef(new Map<string, number>());
const sessionLoadInFlight = useRef(new Map<string, { sessionPath: string; revision?: number; digest?: string; promise: Promise<void> }>());
const transcriptSubscriptions = useRef(new Map<string, () => void>());
@@ -2784,6 +2804,7 @@ export function useController() {
const bumpSessionLoadSeq = useCallback((tabId: string): number => {
bumpMetaRefreshSeq(tabId);
invalidateSharedQuery("MetaForTab", [tabId]);
historyOlderSeq.current.set(tabId, (historyOlderSeq.current.get(tabId) ?? 0) + 1);
const seq = (sessionLoadSeq.current.get(tabId) ?? 0) + 1;
sessionLoadSeq.current.set(tabId, seq);
return seq;
@@ -2800,6 +2821,10 @@ export function useController() {
transcriptSubscriptions.current.set(tabId, unsubscribe);
}, [dispatchTo]);
const releaseTranscriptState = useCallback((tabId: string) => {
// A released tab can still have an older-page request awaiting Wails. Keep
// a tombstone generation so a later tab reusing the same id cannot make
// that completion current again.
historyOlderSeq.current.set(tabId, (historyOlderSeq.current.get(tabId) ?? 0) + 1);
transcriptSubscriptions.current.get(tabId)?.();
transcriptSubscriptions.current.delete(tabId);
getTranscriptStore().evictTab(tabId);
@@ -3101,20 +3126,25 @@ export function useController() {
return getTranscriptStore().requestFullContent(tabId, entryId, field);
}, [ensureTranscriptSubscription]);
const loadOlderHistory = useCallback(async (tabId?: string): Promise<void> => {
const loadOlderHistory = useCallback(async (tabId?: string, targetTurn?: number): Promise<boolean> => {
const targetTabId = tabId || activeTabIdRef.current;
if (!targetTabId) return;
if (!targetTabId) return false;
const state = statesRef.current.get(targetTabId);
if (!state?.historyHasOlder || state.historyOlderLoading || state.running) return;
if (!state?.historyHasOlder || state.historyOlderLoading || state.running) return false;
const sessionPath = state.meta?.sessionPath ?? "";
const sessionRevision = state.meta?.sessionRevision ?? state.historyRevision;
const sessionDigest = state.meta?.sessionDigest ?? state.historyDigest;
const pageBudget = historyPageRequestBudget(state.historyStartTurn, state.historyTotalTurns, targetTurn);
const requestSeq = (historyOlderSeq.current.get(targetTabId) ?? 0) + 1;
historyOlderSeq.current.set(targetTabId, requestSeq);
ensureTranscriptSubscription(targetTabId);
dispatchTo(targetTabId, { type: "history_older_start" });
const startedAt = Date.now();
try {
const result = await getTranscriptStore().loadOlder(targetTabId, sessionPath, { turns: HISTORY_PAGE_TURNS });
const result = await getTranscriptStore().loadOlder(targetTabId, sessionPath, pageBudget);
if (historyOlderSeq.current.get(targetTabId) !== requestSeq) return false;
const current = statesRef.current.get(targetTabId);
if (!current) return false;
const currentRevision = current?.meta?.sessionRevision ?? current?.historyRevision;
const currentDigest = current?.meta?.sessionDigest ?? current?.historyDigest;
const fingerprintMatches = (expected: number | undefined, actual: number | undefined) =>
@@ -3124,17 +3154,17 @@ export function useController() {
// A replace-level hydrate while the page was in flight clears
// historyOlderLoading; a metadata or canonical-identity change also
// makes the page belong to a different transcript generation.
if (!current || !current.historyOlderLoading || (current.meta?.sessionPath ?? "") !== sessionPath ||
if (!current.historyOlderLoading || (current.meta?.sessionPath ?? "") !== sessionPath ||
!fingerprintMatches(sessionRevision, currentRevision) || !digestMatches(sessionDigest, currentDigest) ||
(result !== undefined && (!fingerprintMatches(sessionRevision, result.revisionKnown ? result.revision : undefined) ||
!digestMatches(sessionDigest, result.digest)))) {
dispatchTo(targetTabId, { type: "history_older_error" });
return;
dispatchTo(targetTabId, { type: "history_older_error", error: "history identity changed" });
return false;
}
if (!result) {
// Superseded (generation moved) or nothing older left.
dispatchTo(targetTabId, { type: "history_older_error" });
return;
dispatchTo(targetTabId, { type: "history_older_error", error: "history page unavailable" });
return false;
}
if (result.kind === "reload") {
// The cursor went stale (session rewritten): the store reloaded the
@@ -3164,9 +3194,13 @@ export function useController() {
"tab.hydrate",
`history older ${targetTabId} kind=${result.kind} items=${result.kind === "prepend" ? result.prependItems.length : result.items.length} turns=${result.startTurn}-${result.endTurn}/${result.totalTurns} ms=${Date.now() - startedAt}`,
);
return true;
} catch (err) {
dispatchTo(targetTabId, { type: "history_older_error" });
if (historyOlderSeq.current.get(targetTabId) !== requestSeq) return false;
if (!statesRef.current.has(targetTabId)) return false;
dispatchTo(targetTabId, { type: "history_older_error", error: errorMessage(err) });
addBreadcrumb("tab.hydrate", `history older failed ${targetTabId}: ${errorMessage(err)}`);
return false;
}
}, [dispatchTo, ensureTranscriptSubscription]);
@@ -0,0 +1,205 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { Item } from "./useController";
import {
activeQuestionTurn,
compactQuestionText,
lastQuestionTurn,
questionAnchorId,
questionTurnsById,
type QuestionAnchor,
type QuestionAnchorPosition,
} from "./transcriptGrouping";
import { userRowKey } from "./transcriptRows";
const HISTORY_AUTO_COMPLETE_TURNS = 60;
export function useTranscriptQuestions(
items: Item[],
historyStartTurn: number,
historyTotalTurns: number,
scrollElement: HTMLElement | null,
scrollToBottom: () => void,
) {
const [questions, loadedByTurn, totalQuestions] = useMemo(() => {
const loadedByTurn = new Map<number, QuestionAnchor>();
let nextTurn = historyStartTurn > 0 ? historyStartTurn - 1 : 0;
for (const item of items) {
if (item.kind !== "user") continue;
const turn = item.historyTurn != null && item.historyTurn > 0 ? item.historyTurn - 1 : nextTurn;
loadedByTurn.set(turn, {
id: item.id,
text: compactQuestionText(item.text),
turn,
checkpointTurn: item.checkpointTurn,
});
nextTurn = Math.max(nextTurn, turn + 1);
}
return [
Array.from(loadedByTurn.values()).sort((left, right) => left.turn - right.turn),
loadedByTurn,
Math.max(historyTotalTurns, nextTurn),
] as const;
}, [historyStartTurn, historyTotalTurns, items]);
const [activeQuestion, setActiveQuestion] = useState<number | null>(null);
const activeQuestionFrame = useRef<number | null>(null);
const questionTurnByAnchorId = useMemo(() => new Map(
questions.map((question) => [questionAnchorId(question.id), question.turn]),
), [questions]);
const syncActiveQuestion = useCallback(() => {
if (!scrollElement || questions.length === 0) return;
const scrollerTop = scrollElement.getBoundingClientRect().top;
const positions: QuestionAnchorPosition[] = [];
scrollElement.querySelectorAll<HTMLElement>("[data-question-anchor]").forEach((anchor) => {
const turn = questionTurnByAnchorId.get(anchor.id);
if (turn != null) positions.push({ turn, top: anchor.getBoundingClientRect().top - scrollerTop });
});
const next = activeQuestionTurn(positions);
if (next != null) setActiveQuestion(next);
}, [questionTurnByAnchorId, questions.length, scrollElement]);
const scheduleActiveQuestionSync = useCallback(() => {
if (activeQuestionFrame.current != null) return;
activeQuestionFrame.current = requestAnimationFrame(() => {
activeQuestionFrame.current = null;
syncActiveQuestion();
});
}, [syncActiveQuestion]);
useEffect(() => () => {
if (activeQuestionFrame.current != null) cancelAnimationFrame(activeQuestionFrame.current);
}, []);
useEffect(() => {
setActiveQuestion(questions[questions.length - 1]?.turn ?? null);
scheduleActiveQuestionSync();
}, [questions, scheduleActiveQuestionSync]);
// A local question changes the tail id. Prepended history keeps it stable and
// leaves viewport preservation to Virtuoso's firstItemIndex contract.
const questionTailRef = useRef({ total: 0, lastId: "" });
useEffect(() => {
const lastId = questions[questions.length - 1]?.id ?? "";
const previous = questionTailRef.current;
questionTailRef.current = { total: totalQuestions, lastId };
if (previous.total > 0 && totalQuestions > previous.total && lastId !== previous.lastId) scrollToBottom();
}, [questions, scrollToBottom, totalQuestions]);
const userTurn = useMemo(() => questionTurnsById(questions), [questions]);
const turnForUser = useCallback((item: Extract<Item, { kind: "user" }>) => userTurn.get(item.id), [userTurn]);
const lastTurn = useMemo(() => lastQuestionTurn(questions, userTurn), [questions, userTurn]);
return [
questions,
loadedByTurn,
totalQuestions,
activeQuestion,
setActiveQuestion,
scheduleActiveQuestionSync,
turnForUser,
lastTurn,
] as const;
}
export function useTranscriptQuestionJump({
questions,
loadedByTurn,
layoutSurfaceKey,
rowIndexByKey,
hasOlderHistory,
loadingOlderHistory,
olderHistoryError,
running,
onLoadOlderHistory,
clearTranscriptSelection,
invalidateAnchors,
scrollToDataIndex,
setActiveQuestion,
rewindSignal,
}: {
questions: QuestionAnchor[];
loadedByTurn: Map<number, QuestionAnchor>;
layoutSurfaceKey: string;
rowIndexByKey: Map<string, number>;
hasOlderHistory: boolean;
loadingOlderHistory: boolean;
olderHistoryError?: string;
running: boolean;
onLoadOlderHistory?: (targetTurn?: number) => boolean | Promise<boolean>;
clearTranscriptSelection: (reason?: string) => void;
invalidateAnchors: () => void;
scrollToDataIndex: (index: number, behavior?: "auto" | "smooth") => void;
setActiveQuestion: (turn: number | null) => void;
rewindSignal: number;
}) {
const [pendingQuestion, setPendingQuestion] = useState<{ surfaceKey: string; turn: number } | null>(null);
const olderRequestInFlightRef = useRef<string | null>(null);
const requestOlderHistory = useCallback(async (targetTurn?: number, retry = false): Promise<boolean> => {
if (!hasOlderHistory || loadingOlderHistory || running || !onLoadOlderHistory || (!retry && olderHistoryError)) return false;
if (olderRequestInFlightRef.current === layoutSurfaceKey) return false;
olderRequestInFlightRef.current = layoutSurfaceKey;
try {
return onLoadOlderHistory(targetTurn);
} finally {
if (olderRequestInFlightRef.current === layoutSurfaceKey) olderRequestInFlightRef.current = null;
}
}, [hasOlderHistory, layoutSurfaceKey, loadingOlderHistory, olderHistoryError, onLoadOlderHistory, running]);
const jumpToLoadedQuestion = useCallback((question: QuestionAnchor) => {
const index = rowIndexByKey.get(userRowKey(question.id));
if (index == null) return;
document.getSelection()?.removeAllRanges();
clearTranscriptSelection("question-navigation");
invalidateAnchors();
setActiveQuestion(question.turn);
scrollToDataIndex(index, "smooth");
}, [clearTranscriptSelection, invalidateAnchors, rowIndexByKey, scrollToDataIndex, setActiveQuestion]);
const handleJumpToQuestion = useCallback((question: QuestionAnchor) => {
setActiveQuestion(question.turn);
if (question.loaded !== false) {
setPendingQuestion(null);
jumpToLoadedQuestion(question);
return;
}
document.getSelection()?.removeAllRanges();
clearTranscriptSelection("question-navigation");
setPendingQuestion({ surfaceKey: layoutSurfaceKey, turn: question.turn });
void requestOlderHistory(question.turn + 1, true);
}, [clearTranscriptSelection, jumpToLoadedQuestion, layoutSurfaceKey, requestOlderHistory, setActiveQuestion]);
useEffect(() => {
if (!pendingQuestion || pendingQuestion.surfaceKey !== layoutSurfaceKey) return;
const question = loadedByTurn.get(pendingQuestion.turn);
if (question) {
setPendingQuestion(null);
jumpToLoadedQuestion(question);
} else if (!loadingOlderHistory && !olderHistoryError) {
void requestOlderHistory(pendingQuestion.turn + 1);
}
}, [jumpToLoadedQuestion, layoutSurfaceKey, loadedByTurn, loadingOlderHistory, olderHistoryError, pendingQuestion, requestOlderHistory]);
useEffect(() => {
setPendingQuestion(null);
if (olderRequestInFlightRef.current !== layoutSurfaceKey) olderRequestInFlightRef.current = null;
}, [layoutSurfaceKey]);
const earlierTurnsRemaining = questions[0]?.turn ?? 0;
useEffect(() => {
if (earlierTurnsRemaining > 0 && earlierTurnsRemaining < HISTORY_AUTO_COMPLETE_TURNS) void requestOlderHistory();
}, [earlierTurnsRemaining, requestOlderHistory]);
const handleEarlierHistoryReached = useCallback(() => void requestOlderHistory(), [requestOlderHistory]);
const retryOlderHistory = useCallback(() => {
const targetTurn = pendingQuestion?.surfaceKey === layoutSurfaceKey ? pendingQuestion.turn + 1 : undefined;
void requestOlderHistory(targetTurn, true);
}, [layoutSurfaceKey, pendingQuestion, requestOlderHistory]);
useEffect(() => {
if (rewindSignal <= 0 || questions.length === 0) return;
const index = rowIndexByKey.get(userRowKey(questions[questions.length - 1].id));
if (index == null) return;
invalidateAnchors();
scrollToDataIndex(index);
// Rewind is an edge-triggered signal; other values are read at that event.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rewindSignal]);
return [handleJumpToQuestion, handleEarlierHistoryReached, retryOlderHistory] as const;
}
+2 -1
View File
@@ -2891,7 +2891,7 @@ export const en = {
"transcript.jumpToBottom": "Jump to bottom",
"markdown.tableExpandAll": "Expand all {n} rows",
"markdown.tableCollapse": "Collapse table",
"transcript.showEarlierHistory": "Show {n} earlier turns",
"transcript.loadEarlierFailed": "Earlier conversation could not be loaded",
"transcript.toolCount": "{n} tools",
"transcript.thoughtCount": "{n} thoughts",
"transcript.steer": "Mid-turn steer",
@@ -2993,6 +2993,7 @@ export const en = {
"questionNav.progress": "Question {current} / {total}",
"questionNav.jump": "Jump to question {n}",
"questionNav.hint": "Click to jump to this question",
"questionNav.notLoaded": "Question {n} (click to load)",
"compaction.working": "Compacting conversation…",
"compaction.title": "Context compacted",
"compaction.messages": "{n} messages",
+2 -1
View File
@@ -2003,7 +2003,7 @@ export const zhTW: Record<DictKey, string> = {
"transcript.jumpToBottom": "跳到底部",
"markdown.tableExpandAll": "展開全部 {n} 行",
"markdown.tableCollapse": "收起表格",
"transcript.showEarlierHistory": "展開前 {n} 輪對話",
"transcript.loadEarlierFailed": "較早的對話載入失敗",
"transcript.toolCount": "{n} 個工具",
"transcript.thoughtCount": "{n} 段思考",
"transcript.steer": "中途引導",
@@ -2105,6 +2105,7 @@ export const zhTW: Record<DictKey, string> = {
"questionNav.progress": "問題 {current} / {total}",
"questionNav.jump": "跳轉到問題 {n}",
"questionNav.hint": "點擊跳轉到這次提問",
"questionNav.notLoaded": "第 {n} 個問題(點擊載入)",
"compaction.working": "正在壓縮對話…",
"compaction.title": "上下文已壓縮",
"compaction.messages": "{n} 條訊息",
+2 -1
View File
@@ -2894,7 +2894,7 @@ export const zh: Record<DictKey, string> = {
"transcript.jumpToBottom": "跳到底部",
"markdown.tableExpandAll": "展开全部 {n} 行",
"markdown.tableCollapse": "收起表格",
"transcript.showEarlierHistory": "展开前 {n} 轮对话",
"transcript.loadEarlierFailed": "较早的对话加载失败",
"transcript.toolCount": "{n} 个工具",
"transcript.thoughtCount": "{n} 段思考",
"transcript.steer": "中途引导",
@@ -2996,6 +2996,7 @@ export const zh: Record<DictKey, string> = {
"questionNav.progress": "问题 {current} / {total}",
"questionNav.jump": "跳转到问题 {n}",
"questionNav.hint": "点击跳转到这次提问",
"questionNav.notLoaded": "第 {n} 个问题(点击加载)",
"compaction.working": "正在压缩对话…",
"compaction.title": "上下文已压缩",
"compaction.messages": "{n} 条消息",
+35 -24
View File
@@ -3477,6 +3477,22 @@ a[href] {
margin-left: auto;
margin-right: auto;
}
.transcript__older-status {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 36px;
color: var(--fg-muted);
font-size: 12px;
}
.transcript__older-status .btn {
flex-shrink: 0;
}
.transcript__older-spinner {
flex-shrink: 0;
animation: spin 0.8s linear infinite;
}
.transcript-selection-overlay {
position: absolute;
inset: 0;
@@ -3881,40 +3897,26 @@ a[href] {
animation: jump-bar-in 0.18s ease-out 0.08s forwards;
}
.jump-scroll {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
max-height: 100%;
padding: 8px 0;
overflow-y: auto;
position: relative;
width: 32px;
height: 100%;
cursor: pointer;
pointer-events: auto;
scrollbar-width: none;
-webkit-mask-image: linear-gradient(to bottom, transparent 0%, #000 16%, #000 84%, transparent 100%);
mask-image: linear-gradient(to bottom, transparent 0%, #000 16%, #000 84%, transparent 100%);
}
.jump-scroll::-webkit-scrollbar {
display: none;
outline: none;
}
.jump-item {
position: relative;
position: absolute;
left: 0;
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: flex-end;
width: 32px;
min-height: 14px;
padding: 0;
border: 0;
background: transparent;
cursor: pointer;
height: 3px;
transform: translateY(-50%);
pointer-events: none;
}
.jump-item:focus {
outline: none;
}
.jump-item:focus-visible .jump-dot {
.jump-scroll:focus-visible {
border-radius: 4px;
box-shadow: 0 0 0 3px var(--accent-soft);
}
.jump-dot {
@@ -3927,6 +3929,15 @@ a[href] {
width 400ms cubic-bezier(0.34, 1.56, 0.64, 1),
box-shadow 150ms;
}
.jump-scroll[data-density="compact"] .jump-dot {
height: 2px;
}
.jump-scroll[data-density="packed"] .jump-dot {
height: 1px;
}
.jump-item[data-loaded="false"] .jump-dot {
background: color-mix(in srgb, var(--border) 55%, transparent);
}
.jump-dot[data-d="0"] {
background: var(--accent);
}
+2 -2
View File
@@ -67,7 +67,7 @@
"essay": 2
},
"desktop/frontend/src/App.tsx": {
"file-size": 4678
"file-size": 4683
},
"desktop/frontend/src/__tests__/app-chrome-tabs.test.ts": {
"test-file-size": 19
@@ -163,7 +163,7 @@
"file-size": 1581
},
"desktop/frontend/src/lib/useController.ts": {
"file-size": 3994
"file-size": 4035
},
"desktop/heartbeat.go": {
"essay": 12,