mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-08 01:48:26 -10:00
Merge pull request #9117 from wuhanwoaini521/codex/sync-jump-bar-with-scroll
fix(desktop): keep complete history navigable / 保持完整历史对话可导航
This commit is contained in:
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 = [];
|
||||
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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) && (
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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} 條訊息",
|
||||
|
||||
@@ -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} 条消息",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user