feat(studio): load transcript pages with stable reading anchors

Rebase the UI portion of #14087 onto current studio, preserving regex/case search and session-scoped navigation. Use optional paging with explicit unsupported-route fallback and retain mounted controls when prepending. Browser verification exposed zero-box display-contents wrappers; share rendered-box geometry with the landing writer and add a failing-before regression. Verification: 37 focused frontend tests, TypeScript checking, production build and wide/narrow light/dark browser renders; full-suite results and backend dependency are documented in the PR.
This commit is contained in:
SivanCola committed 2026-10-24 22:13:10 +08:00
1 parent 5810c1033b
commit 5d24185309
26 files changed
+715 -94

No files matched your search

+1
View File
@@ -702,6 +702,7 @@ export const ACTIONS: UIAction[] = [
{ id: "size.keep", kind: "view", target: "none", proof: "interaction" },
{ id: "size.restore", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "tree.retry", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "history.more", kind: "view", target: "none", proof: "interaction" },
{ id: "update.mode", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "update.restart", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "update.later", kind: "view", target: "none", proof: "interaction" },
+7
View File
@@ -36,6 +36,13 @@ export const EN: Record<string, string> = {
"显示更多": "Show more",
...EN_COMPOSITION,
...EN_DOCUMENT,
"正在读取历史消息…": "Reading history messages…",
"历史加载失败 · 重试": "History could not be loaded · Retry",
"加载更早的消息": "Load earlier messages",
"正在查找较早消息…": "Searching earlier messages…",
"部分历史尚未读取": "Some history has not been read",
"历史内容已改变,正在重新读取最近的消息": "History changed. Reloading the latest messages.",
"当前内核不支持历史分页": "This kernel does not support history paging.",
"此检查点不包含文件快照": "This checkpoint contains no file snapshots",
...EN_SURFACES,
"重命名聊天": "Rename chat",
+2 -2
View File
@@ -570,8 +570,8 @@ export interface AgentPort extends WorkspacePreviewPort {
* could not be read. That one refuses, with a code saying which failure. */
balance(): Promise<WalletReading | null>;
history(): Promise<HistoryMessage[]>;
// The kernel's canonical task list. The plan panel answers to it and does not
// restate it.
historyPage?(cursor?: string): Promise<import("./session").HistoryPage>;
// The plan panel renders this canonical task list without reconstructing it.
todos(): Promise<HostTodo[]>;
// One entry per user turn, oldest first. files is how many the writer tools
// touched that turn — zero is normal and means there is nothing to restore.
+4
View File
@@ -453,6 +453,10 @@ export class SsePort extends SseWorkspace implements AgentPort {
return this.get<HistoryMessage[]>("/history");
}
historyPage(cursor?: string) {
return this.get<import("./session").HistoryPage>("/history/page" + (cursor ? "?cursor=" + encodeURIComponent(cursor) : ""));
}
todos() {
return this.get<HostTodo[]>("/todos");
}
+14
View File
@@ -151,3 +151,17 @@ export function mergeReads(items: Item[]): Item[] {
const next = items.slice();
return foldLastRead(next) ? next : items;
}
export function foldHistoryItems(items: Item[]): Item[] {
const out: Item[] = [];
for (const item of items) {
const prev = out[out.length - 1];
if (lookup(item) && prev?.t === "reads") { prev.tools.push(toolOf(item)); continue; }
if (lookup(item) && lookup(prev) && (toolOf(item).name === "read_file" || toolOf(prev).name === "read_file")) {
out[out.length - 1] = { t: "reads", id: prev.id, tools: [toolOf(prev), toolOf(item)] };
continue;
}
out.push(item.t === "reads" ? { ...item, tools: [...item.tools] } : item);
}
return out;
}
@@ -0,0 +1,34 @@
import { expect, it } from "vitest";
import type { HistoryMessage } from "../port/session";
import { fromHistory, initialState, reduce } from "./session";
const messages: HistoryMessage[] = [
{ role: "system", content: "system", msgIndex: 0 },
{ role: "user", content: "question", msgIndex: 1, createdAt: 123 },
{ role: "assistant", content: "", msgIndex: 2, toolCalls: [{ id: "a", name: "read_file", arguments: '{"path":"a"}' }, { id: "b", name: "read_file", arguments: '{"path":"b"}' }] },
{ role: "tool", content: "A", toolCallId: "a", msgIndex: 3 },
{ role: "tool", content: "B", toolCallId: "b", msgIndex: 4 },
{ role: "assistant", content: "done", msgIndex: 5, workDurationMs: 100 },
];
it("merges a tool result page with its earlier calls without duplicates or lost outputs", () => {
const tail = fromHistory(messages.slice(3), { snapshot: "snapshot", turnStartedAt: 123,
toolContext: messages[2].toolCalls!.map((call, ordinal) => ({ call, ordinal, messageIndex: 2 })) });
const older = fromHistory(messages.slice(0, 3), { snapshot: "snapshot" });
let state = reduce(initialState, { kind: "__restore", ...tail });
state = reduce(state, { kind: "__history_prepend", ...older });
expect(state.items).toEqual(fromHistory(messages, { snapshot: "snapshot" }).items);
expect(state.entranceOwed).toEqual([]);
expect(reduce(state, { kind: "__history_prepend", ...older }).items).toEqual(state.items);
});
it("keeps completed live replies, running output and prompts when prepending history", () => {
const live = { t: "say" as const, id: "live", text: "still writing", done: false };
const completed = { t: "say" as const, id: "completed", text: "newer reply", done: true };
const prompt = { t: "approval" as const, id: "prompt", a: { id: "approval" } as never };
const state = { ...initialState, running: true, items: [completed, live, prompt] };
const result = reduce(state, { kind: "__history_prepend", ...fromHistory(messages, { snapshot: "snapshot" }) });
expect(result.items.slice(-3)).toEqual([completed, live, prompt]);
expect(result.running).toBe(true);
expect(result.metrics).toBe(state.metrics);
});
@@ -0,0 +1,27 @@
import type { HistoryToolCall } from "../port/session";
import type { Item, SessionState } from "./session_types";
import type { Executions } from "./executions";
import { foldHistoryItems } from "./fold";
export const historyToolId = (snapshot: string, message: number, ordinal: number) => `history:${snapshot}:${message}:tool:${ordinal}`;
export function historyToolItem(call: HistoryToolCall, id: string, stable: boolean): Item {
return { t: "tool", id, tool: { id: call.id, name: call.name, args: call.arguments,
argumentNormalization: call.argumentNormalization, readOnly: true,
resolvedName: call.resolvedName, capabilityId: call.capabilityId, ...(stable ? { historyId: id } : {}) }, running: false, children: [] };
}
function expandReads(items: Item[]): Item[] {
return items.flatMap((item): Item[] => item.t === "reads"
? item.tools.map((tool, index) => ({ t: "tool", id: tool.historyId ?? `${item.id}/read/${index}`, tool, running: false, children: [] }))
: [item]);
}
export function prependHistory(state: SessionState, incoming: { items: Item[]; executions: Executions }): SessionState {
const current = expandReads(state.items);
const older = expandReads(incoming.items);
const known = new Map(current.map((item) => [item.id, item]));
const inserted = new Set(older.map((item) => item.id));
const items = [...older.map((item) => known.get(item.id) ?? item), ...current.filter((item) => !inserted.has(item.id))];
return { ...state, items: foldHistoryItems(items), executions: { ...incoming.executions, ...state.executions } };
}
+1 -1
View File
@@ -12,7 +12,7 @@ const WHOLE = new Set(["todo_write", "remember", "update_goal", "read_file", "gr
/** What the page keeps of a tool: the same call with `hidden` counting the
* characters of the middle of its output that it let go of. */
export type StateTool = Tool & { hidden?: number };
export type StateTool = Tool & { hidden?: number; historyId?: string };
export function capToolOutput(tool: StateTool): StateTool {
const out = tool.output;
+28 -30
View File
@@ -1,3 +1,6 @@
import { historyToolId, historyToolItem, prependHistory } from "./history_pages";
import type { HistoryToolContext } from "../port/session";
import { foldHistoryItems } from "./fold";
import { measureInteractions } from "./interactions";
import { foldExtension } from "./extension";
import { surfaceHistory } from "../ui/surface/call";
@@ -25,7 +28,7 @@ import { chipLabel, IDLE, RUNNING } from "./chip";
import { foldStall } from "./stall";
import { nameQueued, withoutPendingRow } from "./queued";
import { capToolOutput } from "./outputcap";
import { dropTool, foldLastRead, foldProgress, foldTool, isSubagentProgress, mergeReads, notePhase, noteRan } from "./fold";
import { dropTool, foldProgress, foldTool, isSubagentProgress, mergeReads, notePhase, noteRan } from "./fold";
import { rebuild } from "./rebuild";
export { chipLabel };
export { quoteAmount };
@@ -208,6 +211,7 @@ function entering(prev: SessionState, next: SessionState, ev: SessionEvent): Par
export type SessionEvent =
| WireEvent
| { kind: "__restore"; items: Item[]; plan?: PlanStep[]; executions: Executions }
| { kind: "__history_prepend"; items: Item[]; executions: Executions }
| { kind: "__extension_snapshot"; surfaces: import("../port/wire").ExtensionSurface[] }
| { kind: "__todos"; plan: PlanStep[] }
| { kind: "__totals"; hit: number; miss: number; cost?: number; currency?: string; coverage?: CostCoverage; incompleteReason?: string }
@@ -305,6 +309,7 @@ function apply(s: SessionState, ev: SessionEvent): SessionState {
// A rebuild re-reads the record while the turn may still be writing it; the
// merge goes by identity, and an open prompt or extension is not in the
// record at all — both belong to this pane until it is rebound.
if (ev.kind === "__history_prepend") return prependHistory(s, ev);
if (ev.kind === "__restore") {
const merged = rebuild(s, ev);
return ev.plan ? { ...merged, plan: livePlan(ev.plan) } : merged;
@@ -687,15 +692,25 @@ export function todoStep(x: { content?: string; status?: string; activeForm?: st
// task list is not rebuilt here and is not derivable here: complete_step
// advances it without writing one, and a refused todo_write writes one the
// kernel does not hold.
export function fromHistory(msgs: HistoryMessage[]): { items: Item[]; executions: Executions } {
export function fromHistory(msgs: HistoryMessage[], options: { snapshot?: string; toolContext?: HistoryToolContext[]; turnStartedAt?: number } = {}): { items: Item[]; executions: Executions } {
let index = 0, part = 0;
const allocate = () => options.snapshot ? `history:${options.snapshot}:${index}:${part++}` : nextId();
const out: Item[] = [];
const calls = new Map<string, number>();
// What /history can say about a call: that it ran, and under what name. It
// carries no error field, so a rebuilt execution has no outcome — which is
// the honest answer, not the answer "succeeded".
const executions: Executions = {};
let turnStart: number | undefined;
let turnStart: number | undefined = options.turnStartedAt;
for (const ref of options.toolContext ?? []) {
if (!ref.call.id) continue;
calls.set(ref.call.id, out.length);
executions[ref.call.id] = { name: ref.call.name };
out.push(historyToolItem(ref.call, historyToolId(options.snapshot ?? "", ref.messageIndex, ref.ordinal), true));
}
for (const m of msgs) {
index = m.msgIndex ?? index + 1;
part = 0;
if (m.role === "system") continue;
if (m.role === "user") {
if (!m.steer && !m.hostAuthored) turnStart = m.createdAt;
@@ -708,7 +723,7 @@ export function fromHistory(msgs: HistoryMessage[]): { items: Item[]; executions
// after an upgrade as well as for newly written history.
const legacyHostRepair = m.content.startsWith("The following tools are unavailable in the current workflow phase:");
const text = m.hostAuthored || legacyHostRepair ? "" : stripControl(m.content);
if (text) out.push({ t: "user", id: nextId(), text, msgIndex: m.msgIndex, steer: m.steer, via: m.via, attachments: m.attachments, at: m.createdAt });
if (text) out.push({ t: "user", id: allocate(), text, msgIndex: m.msgIndex, steer: m.steer, via: m.via, attachments: m.attachments, at: m.createdAt });
continue;
}
if (m.role === "assistant") {
@@ -722,45 +737,33 @@ export function fromHistory(msgs: HistoryMessage[]): { items: Item[]; executions
// Thinking came before the search that follows it, so it cannot ride the
// next text part when the turn opened with a search.
if (reasoning && (parts.length === 0 || parts[0].search)) {
out.push({ t: "say", id: nextId(), text: "", reasoning, done: true, model: m.modelRef, thoughtMs: m.thoughtMs });
out.push({ t: "say", id: allocate(), text: "", reasoning, done: true, model: m.modelRef, thoughtMs: m.thoughtMs });
reasoning = undefined;
}
for (const part of parts) {
if (part.search) {
out.push({
t: "tool",
id: nextId(),
tool: { id: nextId(), name: "web_search", output: part.text, readOnly: true },
id: allocate(),
tool: { id: allocate(), name: "web_search", output: part.text, readOnly: true },
running: false,
children: [],
});
continue;
}
if (!part.text && !reasoning) continue;
out.push({ t: "say", id: nextId(), text: part.text, reasoning, done: true, model: m.modelRef, thoughtMs: reasoning ? m.thoughtMs : undefined });
out.push({ t: "say", id: allocate(), text: part.text, reasoning, done: true, model: m.modelRef, thoughtMs: reasoning ? m.thoughtMs : undefined });
reasoning = undefined;
}
out.push(...worked(m.workDurationMs, turnStart));
out.push(...worked(m.workDurationMs, turnStart).map((item) => options.snapshot ? { ...item, id: allocate() } : item));
}
for (const c of m.toolCalls ?? []) {
for (const [ordinal, c] of (m.toolCalls ?? []).entries()) {
if (c.id) {
calls.set(c.id, out.length);
executions[c.id] = { name: c.name };
}
out.push({
t: "tool",
id: nextId(),
tool: {
id: c.id,
name: c.name,
args: c.arguments, argumentNormalization: c.argumentNormalization,
readOnly: true,
resolvedName: c.resolvedName,
capabilityId: c.capabilityId,
},
running: false,
children: [],
});
const id = options.snapshot ? historyToolId(options.snapshot, index, ordinal) : allocate();
out.push(historyToolItem(c, id, !!options.snapshot));
}
continue;
}
@@ -790,10 +793,5 @@ export function fromHistory(msgs: HistoryMessage[]): { items: Item[]; executions
}
// A reload has to fold reads the same way the live stream does, or the same
// conversation reads differently before and after a reopen.
const merged: Item[] = [];
for (const it of out) {
merged.push(surfaceHistory(it));
foldLastRead(merged);
}
return { items: merged, executions };
return { items: foldHistoryItems(out.map(surfaceHistory)), executions };
}
@@ -1,7 +1,7 @@
// What a session is made of, apart from the reducer that maintains it: the
// rows the transcript draws and the state one turn hands the next.
import type { StateTool } from "./outputcap";
import type { Ask, Approval, Compaction, CostCoverage, ExtensionSurface, Guardian, Receipt, StoredAttachment, Tool, Via, WorkspaceLease } from "../port/wire";
import type { Ask, Approval, Compaction, CostCoverage, ExtensionSurface, Guardian, Receipt, StoredAttachment, Via, WorkspaceLease } from "../port/wire";
import type { Sample } from "../port/tokens";
import type { Executions } from "./executions";
@@ -37,7 +37,7 @@ export type Item =
| { t: "say"; id: string; text: string; reasoning?: string; done: boolean; thoughtMs?: number; source?: string; model?: string }
// omitted counts the characters of a running call's output that the page let go of from the front.
| { t: "tool"; id: string; tool: StateTool; running: boolean; children: StateTool[]; omitted?: number }
| { t: "reads"; id: string; tools: Tool[] }
| { t: "reads"; id: string; tools: StateTool[] }
| { t: "guardian"; id: string; g: Guardian }
// by is who settled it when that was not this screen: the device named on
// the kernel's receipt, or the window when the receipt names none.
+5 -5
View File
@@ -2,11 +2,11 @@ import { useEffect, useRef } from "react";
import { t } from "../i18n";
import type { Finding } from "./usefind";
export function Find({ find }: { find: Finding }) {
return <div className="findhost">{find.open && <Bar find={find} />}</div>;
export function Find({ find, pending = false, failed = false }: { find: Finding; pending?: boolean; failed?: boolean }) {
return <div className="findhost">{find.open && <Bar find={find} pending={pending} failed={failed} />}</div>;
}
function Bar({ find }: { find: Finding }) {
function Bar({ find, pending, failed }: { find: Finding; pending: boolean; failed: boolean }) {
const { query, total, index, capped, focus, regex, caseSensitive, setMode, ask: onQuery, step: onStep, close: onClose } = find;
const box = useRef<HTMLInputElement>(null);
useEffect(() => {
@@ -15,7 +15,7 @@ function Bar({ find }: { find: Finding }) {
}, [focus]);
const asked = query.trim().length > 0;
const count = total > 0 ? `${index + 1} / ${total}${capped ? "+" : ""}` : asked ? t("无结果") : "";
const count = pending && asked ? failed ? t("部分历史尚未读取") : t("正在查找较早消息…") : total > 0 ? `${index + 1} / ${total}${capped ? "+" : ""}` : asked ? t("无结果") : "";
return (
<div className="tfind" role="search">
@@ -68,7 +68,7 @@ function Bar({ find }: { find: Finding }) {
>
.*
</button>
<span className="n" data-empty={asked && total === 0 ? "" : undefined} aria-live="polite">
<span className="n" data-empty={asked && total === 0 && !pending ? "" : undefined} aria-live="polite">
{count}
</span>
<button
@@ -0,0 +1,10 @@
import type { HistoryPaging } from "./useHistoryPages";
import { t } from "../i18n";
export function HistoryPageControl({ paging, load }: { paging?: HistoryPaging; load: () => void }) {
if (!paging || !paging.hasMore && !paging.busy && !paging.failed) return null;
return <button type="button" className="sessmore" data-action="history.more" disabled={paging.busy}
onClick={paging.hasMore ? load : () => { void paging.reload().catch(() => {}); }}>
{paging.busy ? t("正在读取历史消息…") : paging.failed ? t("历史加载失败 · 重试") : t("加载更早的消息")}
</button>;
}
+13 -24
View File
@@ -12,7 +12,7 @@ import { hasPendingDecision, posture, runState } from "./decisions";
import { createPortal } from "react-dom";
import type { Checkpoint, ContextBreakdown, JobEntry, McpEntry, SessionStatus, WorkspaceChanges, WorkspaceGit } from "../port/port";
import type { TrajectoryRead } from "../port/wire";
import { chipLabel, fromHistory, initialState, quoteAmount, reduce } from "../state/session";
import { chipLabel, initialState, quoteAmount, reduce } from "../state/session";
import type { CurrencyAmount } from "../state/session";
import { pairCheckpoints } from "../state/checkpoints";
import { useReplyActions } from "./reply";
@@ -23,6 +23,7 @@ import { ReloadNotice } from "./RestoreNotice";
import { RewindNotice } from "./RewindNotice";
import { initialTraj, reduceTraj } from "../state/trajectory";
import { Transcript } from "./Transcript";
import { useHistoryPages, useHistorySearch } from "./useHistoryPages";
import { useBackgroundDeltas } from "./deltas";
import { PaneShown } from "./shown";
import { Composer } from "./Composer";
@@ -161,6 +162,7 @@ function PaneView({ port, hub, rt, title, active, visible, sideHost, side, onFoc
const { pacer, shown } = useBackgroundDeltas(visible, dispatch, trajDispatch);
const surfaceReplay = useSurfaceReplay(port, dispatch);
const { load: readHistory, paging: historyPaging } = useHistoryPages(port, dispatch);
const reloadMcp = useCallback(() => {
void port.mcp().then((c) => setMcp(c.servers)).catch(() => setMcp([]));
@@ -171,14 +173,8 @@ function PaneView({ port, hub, rt, title, active, visible, sideHost, side, onFoc
// is how a hole gets filled rather than rendered as a quiet turn.
const rebuild = useCallback(() => {
void surfaceReplay.refresh();
port
.history()
.then((msgs) => {
const restored = fromHistory(msgs);
dispatch({ kind: "__restore", ...restored });
})
.catch(() => {});
}, [port, surfaceReplay.refresh]);
void readHistory().catch(() => {});
}, [readHistory, surfaceReplay.refresh]);
// /status is polled four times a second while a turn runs, and most of those
// answers are word-for-word the previous one. Swapping in an equal object
@@ -267,11 +263,7 @@ function PaneView({ port, hub, rt, title, active, visible, sideHost, side, onFoc
// to the network — the provider's wallet endpoint rides it — and pairing the
// two made the conversation wait on a round trip that has nothing to do with
// it. Whichever lands first shows what it knows.
port.history().then((msgs) => {
if (!alive) return;
const restored = fromHistory(msgs);
dispatch({ kind: "__restore", ...restored });
});
void readHistory(() => alive).catch(() => {});
port.status().then((st) => {
if (!alive) return;
applyStatus(st);
@@ -282,7 +274,7 @@ function PaneView({ port, hub, rt, title, active, visible, sideHost, side, onFoc
return () => {
alive = false;
};
}, [port, applyStatus]);
}, [port, applyStatus, readHistory, replayTrajectory]);
useEffect(() => {
if (!running) {
@@ -308,11 +300,7 @@ function PaneView({ port, hub, rt, title, active, visible, sideHost, side, onFoc
port.checkpoints().then((cps) => { if (current()) setCheckpoints(cps); }).catch(() => { if (current()) setCheckpoints([]); });
// Two reads, the same way the first mount takes them: the record does not
// wait behind the numbers over it.
const history = port.history().then((msgs) => {
if (!current()) return;
const r = fromHistory(msgs);
dispatch({ kind: "__restore", ...r });
});
const history = readHistory(current);
port.status().then((st) => {
if (!current()) return;
applyStatus(st);
@@ -322,7 +310,7 @@ function PaneView({ port, hub, rt, title, active, visible, sideHost, side, onFoc
refreshWallet();
onSessionChanged();
return history;
}, [port, applyStatus, statusRead, refreshWallet, onSessionChanged, replayTrajectory, fail, surfaceReplay.refresh]);
}, [port, applyStatus, statusRead, refreshWallet, onSessionChanged, replayTrajectory, fail, surfaceReplay.refresh, readHistory]);
// Both of these read only the user and tool cards, so they key off the
// revision rather than the items array: a streamed answer leaves every card
@@ -434,6 +422,7 @@ function PaneView({ port, hub, rt, title, active, visible, sideHost, side, onFoc
const receiptPref = receiptPrefOf(port);
const find = useFind(s.items, findPulse, active, useCallback(() => showView("flow"), [showView]), receiptPref, findTogglePulse);
useHistorySearch(historyPaging, find.open, find.query);
const onRunDetail = useCallback(() => showView("analysis"), [showView]);
const previewScope = JSON.stringify([rt.id, rt.root, status?.sessionPath ?? rt.sessionPath]);
@@ -503,7 +492,7 @@ function PaneView({ port, hub, rt, title, active, visible, sideHost, side, onFoc
onMouseDownCapture={active ? undefined : onFocus}
onFocusCapture={active ? undefined : onFocus}
>
<Find find={find} />
<Find find={find} pending={historyPaging.hasMore || historyPaging.busy || historyPaging.failed} failed={historyPaging.failed} />
<div className="pbody rewind-notice-host" ref={body} data-dock={tab === "flow" ? "" : undefined} data-full={tab === "browser" ? "" : undefined}>
{column && tab === "flow" && <FlowGrips column={column} />}
@@ -515,7 +504,7 @@ function PaneView({ port, hub, rt, title, active, visible, sideHost, side, onFoc
<PaneShown.Provider value={shown}>
<LiveWork.Provider value={live}>
<NewAnswerAnchor.Provider value={marker ? newAnswerAt : null}>
<Transcript port={port}
<Transcript port={port} historyPaging={historyPaging}
scope={rt.sessionPath || rt.id}
onSettings={onSettings}
brought={brought}
@@ -648,7 +637,7 @@ function PaneView({ port, hub, rt, title, active, visible, sideHost, side, onFoc
{alert && <div className="cmpalert">{alert}</div>}
<RootMissingBanner items={s.items} onPick={onOpenProject} />
{pill && <ChangedFilesPill changes={tree?.changes ?? []} onOpen={openChanges} />}
<Composer worktreeTarget={worktreeTarget} onWorktreePhase={setWorktreePhase} port={port} fresh={ready && !rt.host && s.items.length === 0} status={status} running={running} quote={quote} restore={restored} inject={inserted} focus={askFocus} onSubmit={submit} onChanged={refreshStatus} onError={notify} onSettings={onSettings} side={sideAsk} onSide={() => setSideAsk((on) => !on)} sidePanel={sideAsk ? <SidePanel port={port} onClose={() => setSideAsk(false)} /> : null} changeCount={changeCount} countElsewhere={pill} git={git} onTreeChanged={refreshTree} pulse={pulse} draftKey={draftKey(rt.host ?? "", rt.root, rt.sessionPath || status?.sessionPath || "")} />
<Composer worktreeTarget={worktreeTarget} onWorktreePhase={setWorktreePhase} port={port} fresh={ready && !historyPaging.hasMore && !historyPaging.busy && !historyPaging.failed && !rt.host && s.items.length === 0} status={status} running={running} quote={quote} restore={restored} inject={inserted} focus={askFocus} onSubmit={submit} onChanged={refreshStatus} onError={notify} onSettings={onSettings} side={sideAsk} onSide={() => setSideAsk((on) => !on)} sidePanel={sideAsk ? <SidePanel port={port} onClose={() => setSideAsk(false)} /> : null} changeCount={changeCount} countElsewhere={pill} git={git} onTreeChanged={refreshTree} pulse={pulse} draftKey={draftKey(rt.host ?? "", rt.root, rt.sessionPath || status?.sessionPath || "")} />
<MeterRail
tps={tps} trail={trail} running={running} speed={speed} metrics={s.metrics} ctx={ctx} mcp={mcp} compact={compact} cost={cost} turnCost={s.metrics.coverage === "none" ? NO_COST : s.metrics.turn}
wallet={wallet} hideAmounts={hideAmounts} tasks={rail.tasks} jobs={jobs} onSettings={onSettings}
+25 -25
View File
@@ -31,17 +31,21 @@ import { drawn, foldStats, repeatedSpeakers, sameMembers, transcriptRows, type S
import { BroughtResults } from "./BroughtResults";
import { ReceiptSlot } from "./ReceiptSlot";
import { WorkFold } from "./WorkFold";
import { opensTurn, useBlocks } from "./blocks";
import { opensTurn, useBlocks, useBlockKeys } from "./blocks";
import { useHistoryAnchor, useOlderPageIntent } from "./useHistoryAnchor";
import { HistoryPageControl } from "./HistoryPageControl";
import type { HistoryPaging } from "./useHistoryPages";
import { Rail, type RailMark } from "./Rail";
import { railMarks } from "./railmarks";
import { StudioIcon } from "./StudioIcon";
import { LiveWork, useStartsOpen } from "../state/foldpref";
import { landingBox, useFindLanding, useFindPaint } from "./findland";
import { landingBox, renderedBox, useFindLanding, useFindPaint } from "./findland";
import { ScrollJump } from "./ScrollJump";
import { landing as landingOf, type Direction } from "./jumpreach";
import { chunkText, heldBlocks, type Held } from "./chunk_text";
interface Props {
historyPaging?: HistoryPaging;
items: Item[];
port?: Pick<AgentPort, "workspaceImageURL"> & Partial<Pick<AgentPort, "surfaceAction" | "surfaceActionState" | "cancelQueued" | "engageQuestion" | "stopToolCall" | "refinePrompt" | "toolOutput">>;
revision: number;
@@ -96,7 +100,7 @@ interface Props {
brought?: { ids: string[]; bring: (id: string) => void; dismiss: (id: string) => void };
}
export function Transcript({ items, port, entering, onEntered, brought, revision, waiting, scroll, hidden, focus, find, query, onApprove, onFullAccess, onPlan, onAnswer, onForget, onSettings, onExtInvoke, onExtSubmit, reply, onResend, takeovers = {}, checkpoints, onPrepareRewind, onCommitRewind, onPrepareFileRevert, onCommitFileRevert, needsProject, onOpenProject, onKeepHere, foldWork = false, turnRunning = false, scope = "" }: Props) {
export function Transcript({ items, port, historyPaging, entering, onEntered, brought, revision, waiting, scroll, hidden, focus, find, query, onApprove, onFullAccess, onPlan, onAnswer, onForget, onSettings, onExtInvoke, onExtSubmit, reply, onResend, takeovers = {}, checkpoints, onPrepareRewind, onCommitRewind, onPrepareFileRevert, onCommitFileRevert, needsProject, onOpenProject, onKeepHere, foldWork = false, turnRunning = false, scope = "" }: Props) {
// A block the selection touches must not leave the DOM. Unmounting the node a
// selection is anchored to makes the browser remap that selection onto
// whatever is still mounted — which reads as "I selected up there and the
@@ -125,17 +129,10 @@ export function Transcript({ items, port, entering, onEntered, brought, revision
const wasAt = useRef(0);
// Scroll events before this moment are the browser's, not the reader's.
const quiet = useRef(0);
const landing = useRef(0);
// Scrolls inside the layout pass, not on the next frame. Deferring it left a
// window where the DOM had grown and the scroller had not moved yet, and the
// reveal clock — which appends characters on its own rAF — kept reopening it:
// two loops chasing each other, measured as the distance to the bottom
// swinging 0→142px through the whole stream. That swing is the jitter.
// Writing the scroller's own scrollTop rather than asking an element to bring
// itself into view: scrollIntoView resolves alignment against every scrollable
// ancestor, and a throttled CPU spent 13% of its self time in it — this runs
// twice per streamed delta, from the layout effect and from the size observer.
// The scroller is right here and "the bottom" is one assignment against it.
// Following must settle inside the layout pass. scrollIntoView also moves
// ancestor scrollers, so this owner writes only its own viewport.
const follow = useCallback(() => {
const root = scroll.current;
if (!root) return;
@@ -177,6 +174,7 @@ export function Transcript({ items, port, entering, onEntered, brought, revision
// and a gesture that just left the bottom must not also license going back to
// it. Only a downward one does.
const release = useCallback(() => {
landing.current++;
gesture.current = 0;
if (!at.current) return;
at.current = false;
@@ -187,6 +185,7 @@ export function Transcript({ items, port, entering, onEntered, brought, revision
const root = scroll.current;
if (!root) return;
const mark = () => {
landing.current++;
gesture.current = performance.now();
};
const onWheel = (e: WheelEvent) => (e.deltaY < 0 ? release() : mark());
@@ -310,9 +309,8 @@ export function Transcript({ items, port, entering, onEntered, brought, revision
// pass then puts the message itself under the reader. Marked as a gesture,
// or the follow would read this scroll as the transcript moving and stay
// pinned to the bottom the reader just left.
const landing = useRef(0);
const land = useCallback(
(block: number, into: number, selector: string, clear = 12, then?: (found: HTMLElement, root: HTMLElement) => boolean) => {
(block: number, into: number, selector: string, clear = 12, then?: (found: HTMLElement, root: HTMLElement) => boolean, highlight = true) => {
const root = scroll.current;
const inner = flow.current;
if (!root || !inner) return;
@@ -326,10 +324,11 @@ export function Transcript({ items, port, entering, onEntered, brought, revision
// in the window" — writing that back as scrollTop barely moves anything.
const topOf = (el: HTMLElement) => el.getBoundingClientRect().top - root.getBoundingClientRect().top + root.scrollTop;
// A caller that can place a mounted target itself spares the row-top hop.
const ready = then && inner.querySelector<HTMLElement>(selector);
if (ready && then(ready, root)) return;
const ready = inner.querySelector<HTMLElement>(selector);
if (ready && then?.(ready, root)) return;
const chunk = inner.querySelectorAll<HTMLElement>(".chunk")[block];
if (chunk) root.scrollTop = topOf(chunk) + into * chunk.offsetHeight - clear;
if (ready) root.scrollTop = topOf(renderedBox(ready)) - clear;
else if (chunk) root.scrollTop = topOf(chunk) + into * chunk.offsetHeight - clear;
const settle = (tries: number, last = NaN) => {
if (mine !== landing.current || !inner.isConnected) return;
const found = inner.querySelector<HTMLElement>(selector);
@@ -343,8 +342,7 @@ export function Transcript({ items, port, entering, onEntered, brought, revision
requestAnimationFrame(() => settle(tries - 1, top));
return;
}
el.setAttribute("data-hit", "");
setTimeout(() => el.removeAttribute("data-hit"), 1200);
if (highlight) { el.setAttribute("data-hit", ""); setTimeout(() => el.removeAttribute("data-hit"), 1200); }
then?.(found, root);
return;
}
@@ -409,11 +407,12 @@ export function Transcript({ items, port, entering, onEntered, brought, revision
return () => ro.disconnect();
}, [follow]);
// Only a message still being written can grow; anything else is final the
// moment it lands, so the split is exactly one card wide.
const last = items.length > 0 ? items[items.length - 1] : undefined;
const live = last?.t === "say" && !last.done ? last : undefined;
const blocks = useBlocks(items, live ? items.length - 1 : items.length, revision);
const blockKeys = useBlockKeys(blocks);
const loadOlder = useHistoryAnchor(historyPaging, scroll, blocks, hidden, landing, release, land);
useOlderPageIntent({ scroll, hidden, load: loadOlder, generation: landing });
useFindLanding(find, query ?? "", hidden, blocks, live, land);
@@ -475,13 +474,14 @@ export function Transcript({ items, port, entering, onEntered, brought, revision
<Rail marks={marks} total={items.length} scroll={scroll} flow={flow} onJump={jumpTo} bound={!hidden} scope={scope} />
<div className="flow-edge" aria-hidden="true" />
{/* 空转录是「窗口的空白」,壁纸该在那儿;一有内容它就是内容了。 */}
<div className="flow" ref={flow} data-empty={items.length === 0 ? "" : undefined}>
{items.length === 0 && (
<div className="flow" ref={flow} data-empty={items.length === 0 && !historyPaging?.hasMore && !historyPaging?.busy && !historyPaging?.failed ? "" : undefined}>
<HistoryPageControl paging={historyPaging} load={loadOlder} />
{items.length === 0 && !historyPaging?.hasMore && !historyPaging?.busy && !historyPaging?.failed && (
<Hero needsProject={needsProject} onOpen={onOpenProject} onKeep={onKeepHere} />
)}
{blocks.map((block, i) => (
<Block
key={i}
key={blockKeys[i]}
items={block}
checkpoints={checkpoints}
watch={watch}
@@ -0,0 +1,47 @@
// @vitest-environment jsdom
import { cleanup, renderHook } from "@testing-library/react";
import { afterEach, expect, it } from "vitest";
import type { Item } from "../state/session";
import { useBlocks, useBlockKeys } from "./blocks";
afterEach(cleanup);
const turns = (prefix: string, count: number): Item[] => Array.from({ length: count }, (_, i): Item[] => [
{ t: "user", id: `${prefix}-u${i}`, text: "question" }, { t: "say", id: `${prefix}-a${i}`, text: "answer", done: true },
]).flat();
it("preserves existing mounting units when an older page is prepended", () => {
const items = turns("tail", 60);
const { result, rerender } = renderHook(({ items, revision, cut }) => useBlocks(items, cut, revision), { initialProps: { items, revision: 1, cut: items.length } });
const prior = result.current;
const combined = [...turns("head", 10), ...items];
rerender({ items: combined, revision: 2, cut: combined.length });
for (const block of prior) expect(result.current).toContain(block);
expect(result.current.flat()).toEqual(combined);
rerender({ items: combined, revision: 3, cut: combined.length - 1 });
expect(result.current.flat()).toEqual(combined.slice(0, -1));
});
it("keeps an appended continuation in the same authored-turn block", () => {
const items: Item[] = [{ t: "user", id: "user", text: "question" }, ...Array.from({ length: 50 }, (_, i): Item => ({ t: "say", id: `answer-${i}`, text: "continuation", done: true }))];
const { result, rerender } = renderHook(({ items, revision }) => useBlocks(items, items.length, revision), { initialProps: { items, revision: 1 } });
expect(result.current).toHaveLength(1);
const continued: Item[] = [...items, { t: "say", id: "continued", text: "still the same turn", done: true }];
rerender({ items: continued, revision: 2 });
expect(result.current).toHaveLength(1);
expect(result.current[0]).toEqual(continued);
});
it("retains a live tail's mounting identity when late history joins its block", () => {
const tail: Item[] = [{ t: "say", id: "live", text: "live tail", done: true }];
const { result, rerender } = renderHook(({ blocks }) => useBlockKeys(blocks), { initialProps: { blocks: [tail] } });
const key = result.current[0];
const restored = [[...turns("history", 1), ...tail]];
rerender({ blocks: restored });
expect(result.current).toEqual([key]);
const stable = result.current;
rerender({ blocks: restored });
expect(result.current).toBe(stable);
rerender({ blocks: [turns("older", 1), [...turns("history", 1), ...tail]] });
expect(result.current[1]).toBe(key);
expect(new Set(result.current).size).toBe(result.current.length);
});
+35 -5
View File
@@ -15,19 +15,49 @@ export function useBlocks(items: Item[], cut: number, revision: number): Item[][
if (held.current.at === revision && held.current.cut === cut) return held.current.blocks;
const prev = held.current.blocks;
const previousByFirst = new Map(prev.map((block) => [block[0]?.id, block]));
const blocks: Item[][] = [];
for (let at = 0, end = 0; at < cut; at = end) {
const candidate = previousByFirst.get(items[at].id);
const keep = candidate && at + candidate.length <= cut && candidate.every((item, index) => item === items[at + index]);
const boundary = keep && (at + candidate.length === cut || opensTurn(items[at + candidate.length]));
if (keep && boundary && (candidate !== prev[prev.length - 1] || at + candidate.length === cut || candidate.length >= BLOCK)) {
blocks.push(candidate);
end = at + candidate.length;
continue;
}
end = turnEnd(items, at, cut);
while (end < cut && end - at < BLOCK) end = turnEnd(items, end, cut);
const old = prev[blocks.length];
let same = old !== undefined && old.length === end - at;
for (let i = 0; same && i < end - at; i++) same = old[i] === items[at + i];
blocks.push(same ? old : items.slice(at, end));
while (end < cut && end - at < BLOCK && !previousByFirst.has(items[end].id)) end = turnEnd(items, end, cut);
const old = candidate;
const same = old?.length === end - at && old.every((item, index) => item === items[at + index]);
blocks.push(same && old ? old : items.slice(at, end));
}
held.current = { at: revision, cut, blocks };
return blocks;
}
// A late history read can put older rows before an existing form in its block.
// The surviving rows own that mounting identity, not the new first row.
export function useBlockKeys(blocks: Item[][]): string[] {
const held = useRef<{ blocks: Item[][] | null; keys: string[]; owners: Map<string, string>; next: number }>({ blocks: null, keys: [], owners: new Map(), next: 0 });
if (held.current.blocks === blocks) return held.current.keys;
const used = new Set<string>();
const next = new Map<string, string>();
const keys = blocks.map((block) => {
let retained: string | undefined;
for (const item of block) {
const prior = held.current.owners.get(item.id);
if (prior !== undefined && !used.has(prior)) { retained = prior; break; }
}
const key = retained ?? `block:${++held.current.next}`;
used.add(key);
for (const item of block) next.set(item.id, key);
return key;
});
held.current = { ...held.current, blocks, keys, owners: next };
return keys;
}
// A block ends only where a turn does: transcriptRows hangs work under its
// sentence within one block, so a turn cut in two leaves half its work with no
// sentence to sit under.
@@ -0,0 +1,40 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
import { Find } from "./Find";
import { useFind } from "./usefind";
vi.mock("../i18n", () => ({ t: (text: string) => text }));
afterEach(cleanup);
function Search({ pending, failed = false }: { pending: boolean; failed?: boolean }) {
const find = useFind([], 0, true, () => {});
return <Find find={{ ...find, open: true, query: "earlier message" }} pending={pending} failed={failed} />;
}
it("does not declare no results until all history has been searched", () => {
const { rerender } = render(<Search pending />);
expect(screen.getByText("正在查找较早消息…")).toBeTruthy();
expect(screen.queryByText("无结果")).toBeNull();
rerender(<Search pending failed />);
expect(screen.getByText("部分历史尚未读取")).toBeTruthy();
expect(screen.queryByText("无结果")).toBeNull();
rerender(<Search pending={false} />);
expect(screen.getByText("无结果")).toBeTruthy();
});
it("keeps regex and case controls active while earlier history is loading", () => {
function PagingSearch() {
const find = useFind([], 1, true, () => {});
return <Find find={find} pending />;
}
render(<PagingSearch />);
fireEvent.change(screen.getByRole("searchbox"), { target: { value: "earlier.*message" } });
const regex = screen.getByRole("button", { name: "把查询当作正则表达式" });
const caseSensitive = screen.getByRole("button", { name: "区分大小写" });
fireEvent.click(regex);
fireEvent.click(caseSensitive);
expect(regex.getAttribute("aria-pressed")).toBe("true");
expect(caseSensitive.getAttribute("aria-pressed")).toBe("true");
expect(screen.getByText("正在查找较早消息…")).toBeTruthy();
});
+4
View File
@@ -89,6 +89,10 @@ export function landingBox(el: HTMLElement): HTMLElement {
for (let fold = el.closest("details:not([open])"); fold; fold = fold.parentElement?.closest("details:not([open])") ?? null) {
(fold as HTMLDetailsElement).open = true;
}
return renderedBox(el);
}
export function renderedBox(el: HTMLElement): HTMLElement {
if (el.getClientRects().length > 0) return el;
return (el.firstElementChild as HTMLElement | null) ?? el;
}
@@ -0,0 +1,79 @@
// @vitest-environment jsdom
import { act, cleanup, fireEvent, renderHook } from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
import { useHistoryAnchor, useOlderPageIntent } from "./useHistoryAnchor";
import type { HistoryPaging } from "./useHistoryPages";
import type { Item } from "../state/session";
afterEach(() => { cleanup(); document.body.innerHTML = ""; vi.unstubAllGlobals(); });
function fixture(contents = false) {
vi.stubGlobal("CSS", { escape: (text: string) => text });
const root = document.createElement("div");
const row = document.createElement("div");
row.dataset.item = "anchor";
root.append(row); document.body.append(root);
root.getBoundingClientRect = () => ({ top: 100, bottom: 500, height: 400 }) as DOMRect;
row.getBoundingClientRect = () => ({ top: 120, bottom: 150, height: 30 }) as DOMRect;
if (contents) {
row.style.display = "contents";
const visible = document.createElement("div");
visible.getBoundingClientRect = row.getBoundingClientRect;
row.append(visible);
row.getBoundingClientRect = () => ({ top: 0, bottom: 0, height: 0 }) as DOMRect;
}
const item: Item = { t: "user", id: "anchor", text: "anchor" };
const scroll = { current: root };
const generation = { current: 0 };
const release = vi.fn();
const land = vi.fn();
const paging: HistoryPaging = { snapshot: "one", revision: 1, hasMore: true, busy: false, failed: false, more: vi.fn(async () => {}), reload: vi.fn(async () => {}) };
const hook = renderHook(({ revision, snapshot = "one", hidden = false }) => useHistoryAnchor({ ...paging, revision, snapshot }, scroll, [[item]], hidden, generation, release, land),
{ initialProps: { revision: 1, snapshot: "one", hidden: false } });
return { ...hook, generation, land, paging };
}
it("restores through the existing landing writer only after the new page is committed", () => {
const f = fixture();
act(() => f.result.current());
expect(f.land).not.toHaveBeenCalled();
f.rerender({ revision: 2, snapshot: "one", hidden: false });
expect(f.land).toHaveBeenCalledWith(0, 0, '[data-item="anchor"]', 20, undefined, false);
});
it("anchors display-contents message wrappers using their visible child", () => {
const f = fixture(true);
act(() => f.result.current());
f.rerender({ revision: 2, snapshot: "one", hidden: false });
expect(f.land).toHaveBeenCalledWith(0, 0, '[data-item="anchor"]', 20, undefined, false);
});
it.each(["gesture", "binding", "hidden"])("does not overwrite a newer %s", (change) => {
const f = fixture();
act(() => f.result.current());
if (change === "gesture") f.generation.current++;
f.rerender({ revision: 2, snapshot: change === "binding" ? "two" : "one", hidden: change === "hidden" });
expect(f.land).not.toHaveBeenCalled();
});
it("requests older pages only after reader intent near the top, and stops while hidden", () => {
const root = document.createElement("div");
document.body.append(root);
const load = vi.fn();
const generation = { current: 0 };
const scroll = { current: root };
const { rerender } = renderHook(({ hidden }) => useOlderPageIntent({ scroll, hidden, load, generation }), { initialProps: { hidden: false } });
fireEvent.scroll(root);
fireEvent.wheel(root, { deltaY: 20 });
expect(load).not.toHaveBeenCalled();
root.scrollTop = 300;
fireEvent.wheel(root, { deltaY: -20 });
expect(load).not.toHaveBeenCalled();
root.scrollTop = 30;
fireEvent.scroll(root);
expect(load).toHaveBeenCalledTimes(1);
rerender({ hidden: true });
fireEvent.keyDown(root, { key: "Home" });
expect(load).toHaveBeenCalledTimes(1);
expect(generation.current).toBe(1);
});
@@ -0,0 +1,62 @@
import { useCallback, useEffect, useLayoutEffect, useRef, type RefObject } from "react";
import type { Item } from "../state/session";
import type { HistoryPaging } from "./useHistoryPages";
import { listenAction } from "./listen";
import { renderedBox } from "./findland";
type Landing = (block: number, into: number, selector: string, clear?: number, then?: (found: HTMLElement, root: HTMLElement) => boolean, highlight?: boolean) => void;
interface OlderPageIntent {
scroll: RefObject<HTMLDivElement | null>;
hidden: boolean;
load: () => void;
generation: { current: number };
}
export function useOlderPageIntent({ scroll, hidden, load, generation }: OlderPageIntent) {
useEffect(() => {
if (hidden) { generation.current++; return; }
const root = scroll.current;
if (!root) return;
let intentUntil = 0;
const intent = () => { intentUntil = performance.now() + 500; };
const nearTop = () => { if (performance.now() < intentUntil && root && root.scrollTop < 100 && !document.getSelection()?.toString()) load(); };
const older = (event: WheelEvent) => { if (event.deltaY < 0) { intent(); nearTop(); } };
const key = (event: KeyboardEvent) => { if (["ArrowUp", "PageUp", "Home"].includes(event.key)) { intent(); nearTop(); } };
const offWheel = listenAction(root, "wheel", { action: "history.more", listener: older as EventListener }, { passive: true });
const offPointer = listenAction(root, "pointerdown", { action: "history.more", listener: intent });
const offTouch = listenAction(root, "touchmove", { action: "history.more", listener: intent }, { passive: true });
const offKey = listenAction(root, "keydown", { action: "history.more", listener: key as EventListener });
const offScroll = listenAction(root, "scroll", { action: "history.more", listener: nearTop }, { passive: true });
return () => { offWheel(); offPointer(); offTouch(); offKey(); offScroll(); };
}, [scroll, hidden, load, generation]);
}
export function useHistoryAnchor(paging: HistoryPaging | undefined, scroll: RefObject<HTMLDivElement | null>, blocks: Item[][], hidden: boolean,
generation: { current: number }, release: () => void, land: Landing) {
const pending = useRef<{ generation: number; revision: number; snapshot: string; id: string; offset: number } | null>(null);
const more = paging?.more;
const load = useCallback(() => {
const root = scroll.current;
if (!root || !paging?.hasMore || paging.busy || !more) return;
release();
const mine = ++generation.current;
const top = root.getBoundingClientRect().top;
const anchor = [...root.querySelectorAll<HTMLElement>("[data-item]")].find((node) => {
const rect = renderedBox(node).getBoundingClientRect();
return rect.height > 0 && rect.bottom > top;
});
pending.current = anchor ? { generation: mine, revision: paging.revision, snapshot: paging.snapshot, id: anchor.dataset.item!, offset: renderedBox(anchor).getBoundingClientRect().top - top } : null;
void more().catch(() => {});
}, [scroll, paging?.hasMore, paging?.busy, paging?.revision, paging?.snapshot, more, generation, release]);
useLayoutEffect(() => {
const anchor = pending.current;
if (!anchor || paging?.revision === anchor.revision) return;
pending.current = null;
if (hidden || anchor.generation !== generation.current || anchor.snapshot !== paging?.snapshot) return;
const block = blocks.findIndex((items) => items.some((item) => item.id === anchor.id));
if (block >= 0) land(block, 0, `[data-item="${CSS.escape(anchor.id)}"]`, anchor.offset, undefined, false);
}, [paging?.revision, paging?.snapshot, blocks, hidden, generation, land]);
return load;
}
@@ -0,0 +1,116 @@
// @vitest-environment jsdom
import { act, cleanup, renderHook } from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
import { HttpError } from "../port/port";
import type { HistoryPage } from "../port/session";
import { useHistoryPages, useHistorySearch } from "./useHistoryPages";
afterEach(cleanup);
const page = (snapshot = "one", cursor = "older"): HistoryPage => ({ snapshot, start: cursor ? 1 : 0, end: 2, total: 2, hasMoreBefore: !!cursor, nextCursor: cursor,
messages: [{ role: "user", content: snapshot, msgIndex: 1 }], toolContext: [] });
function deferred<T>() { let resolve!: (value: T) => void; const promise = new Promise<T>((done) => { resolve = done; }); return { promise, resolve }; }
it("loads a bounded tail and coalesces overlapping older-page requests", async () => {
const older = deferred<HistoryPage>();
const port = { history: vi.fn(async () => []), historyPage: vi.fn().mockResolvedValueOnce(page()).mockReturnValueOnce(older.promise) };
const dispatch = vi.fn();
const { result } = renderHook(() => useHistoryPages(port, dispatch));
await act(async () => { await result.current.load(); });
expect(port.history).not.toHaveBeenCalled();
let one!: Promise<void>;
act(() => { one = result.current.paging.more(); expect(result.current.paging.more()).toBe(one); });
expect(port.historyPage).toHaveBeenCalledTimes(2);
await act(async () => { older.resolve({ ...page("one", ""), start: 0, end: 1 }); await one; });
expect(dispatch.mock.calls.at(-1)?.[0].kind).toBe("__history_prepend");
expect(result.current.paging.hasMore).toBe(false);
});
it("falls back only when paging is unsupported, and remembers that capability", async () => {
const port = { history: vi.fn(async () => []), historyPage: vi.fn().mockRejectedValue(new HttpError(404, "missing")) };
const { result } = renderHook(() => useHistoryPages(port, vi.fn()));
await act(async () => { await result.current.load(); });
await act(async () => { await result.current.load(); });
expect(port.historyPage).toHaveBeenCalledTimes(1);
expect(port.history).toHaveBeenCalledTimes(2);
});
it("does not turn a failed page request into an expensive full-history request", async () => {
const port = { history: vi.fn(async () => []), historyPage: vi.fn().mockRejectedValue(new HttpError(503, "unavailable")) };
const { result } = renderHook(() => useHistoryPages(port, vi.fn()));
await act(async () => { await expect(result.current.load()).rejects.toBeInstanceOf(HttpError); });
expect(port.history).not.toHaveBeenCalled();
expect(result.current.paging.failed).toBe(true);
});
it("supports ports without paging and servers explicitly refusing the optional capability", async () => {
const history = vi.fn(async () => [{ role: "user" as const, content: "full history" }]);
const dispatch = vi.fn();
const legacy = renderHook(() => useHistoryPages({ history }, dispatch));
await act(async () => { await legacy.result.current.load(); });
expect(dispatch.mock.calls.at(-1)?.[0].items[0].text).toBe("full history");
expect(legacy.result.current.paging.hasMore).toBe(false);
legacy.unmount();
const historyPage = vi.fn().mockRejectedValue(new HttpError(501, "unsupported", { code: "history.pagination_unsupported" }));
const optional = renderHook(() => useHistoryPages({ history, historyPage }, dispatch));
await act(async () => { await optional.result.current.load(); });
expect(history).toHaveBeenCalledTimes(2);
expect(optional.result.current.paging.failed).toBe(false);
});
it("discards an old binding's late page", async () => {
const old = deferred<HistoryPage>();
const dispatch = vi.fn();
const first = { history: vi.fn(async () => []), historyPage: vi.fn(() => old.promise) };
const second = { history: vi.fn(async () => []), historyPage: vi.fn(async () => page("new", "")) };
const { result, rerender } = renderHook(({ port }) => useHistoryPages(port, dispatch), { initialProps: { port: first } });
let pending!: Promise<void>;
act(() => { pending = result.current.load(); });
rerender({ port: second });
await act(async () => { await result.current.load(); });
await act(async () => { old.resolve(page("old")); await pending; });
expect(dispatch).toHaveBeenCalledTimes(1);
expect(result.current.paging.snapshot).toBe("new");
});
it("reloads a changed snapshot without appending stale rows", async () => {
const port = { history: vi.fn(async () => []), historyPage: vi.fn().mockResolvedValueOnce(page())
.mockRejectedValueOnce(new HttpError(409, "changed", { code: "history.snapshot_changed" })).mockResolvedValueOnce(page("new", "")) };
const dispatch = vi.fn();
const { result } = renderHook(() => useHistoryPages(port, dispatch));
await act(async () => { await result.current.load(); });
await act(async () => { await result.current.paging.more(); });
expect(result.current.paging.snapshot).toBe("new");
expect(dispatch.mock.calls.map(([event]) => event.kind)).toEqual(["__restore", "__restore"]);
});
it("searches beyond the loaded tail and stops requesting pages when the query is cleared", async () => {
const older = deferred<HistoryPage>();
const port = { history: vi.fn(async () => []), historyPage: vi.fn().mockResolvedValueOnce({ ...page(), start: 3, end: 4, total: 4 }).mockReturnValueOnce(older.promise) };
const dispatch = vi.fn();
const { result, rerender } = renderHook(({ query }) => {
const reading = useHistoryPages(port, dispatch);
useHistorySearch(reading.paging, true, query);
return reading;
}, { initialProps: { query: "" } });
await act(async () => { await result.current.load(); });
rerender({ query: "needle" });
expect(port.historyPage).toHaveBeenCalledTimes(2);
rerender({ query: "" });
await act(async () => { older.resolve({ ...page("one", "yet-older"), start: 1, end: 3, total: 4, messages: [{ role: "user", content: "needle", msgIndex: 1 }] }); });
expect(dispatch.mock.calls.at(-1)?.[0].items[0].text).toBe("needle");
expect(port.historyPage).toHaveBeenCalledTimes(2);
});
it("refuses malformed successful responses without falling back to full history", async () => {
const port = { history: vi.fn(async () => []), historyPage: vi.fn().mockResolvedValue(null) };
const { result } = renderHook(() => useHistoryPages(port, vi.fn()));
await act(async () => { await expect(result.current.load()).rejects.toThrow("invalid history page"); });
expect(port.history).not.toHaveBeenCalled();
});
it("does not treat a missing runtime as an unsupported paging route", async () => {
const port = { history: vi.fn(async () => []), historyPage: vi.fn().mockRejectedValue(new HttpError(404, "gone", { code: "runtime.not_found" })) };
const { result } = renderHook(() => useHistoryPages(port, vi.fn()));
await act(async () => { await expect(result.current.load()).rejects.toBeInstanceOf(HttpError); });
expect(port.history).not.toHaveBeenCalled();
});
@@ -0,0 +1,105 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { HttpError, type AgentPort } from "../port/port";
import type { HistoryPage } from "../port/session";
import { fromHistory, type SessionEvent } from "../state/session";
export interface HistoryPaging {
snapshot: string;
revision: number;
hasMore: boolean;
busy: boolean;
failed: boolean;
more: () => Promise<void>;
reload: () => Promise<void>;
}
export function useHistorySearch(paging: HistoryPaging, open: boolean, query: string) {
useEffect(() => {
if (open && query.trim() && paging.hasMore && !paging.busy && !paging.failed) void paging.more().catch(() => {});
}, [open, query, paging.hasMore, paging.busy, paging.failed, paging.more]);
}
const projection = (page: HistoryPage) => fromHistory(Array.isArray(page.messages) ? page.messages : [], {
snapshot: page.snapshot, toolContext: Array.isArray(page.toolContext) ? page.toolContext : [], turnStartedAt: page.turnStartedAt,
});
function checkedPage(page: HistoryPage): HistoryPage {
if (!page || !Array.isArray(page.messages) || typeof page.snapshot !== "string" ||
!Number.isSafeInteger(page.start) || !Number.isSafeInteger(page.end) || !Number.isSafeInteger(page.total) ||
page.start < 0 || page.end < page.start || page.total < page.end ||
page.hasMoreBefore !== (page.start > 0) || page.hasMoreBefore && !page.nextCursor) throw new Error("invalid history page");
return page;
}
function unsupported(error: unknown): boolean {
if (!(error instanceof HttpError)) return false;
const code = error.reason?.code;
return code === "history.pagination_unsupported" || !code && [404, 405, 501].includes(error.status);
}
export function useHistoryPages(port: Pick<AgentPort, "history" | "historyPage">, dispatch: (event: SessionEvent) => void) {
const owner = useMemo(() => ({ epoch: 0, cursor: "", snapshot: "", before: 0, total: 0, paging: !!port.historyPage, more: null as Promise<void> | null }), [port]);
const current = useRef(owner);
current.current = owner;
const [state, setState] = useState({ snapshot: "", revision: 0, hasMore: false, busy: false, failed: false });
useEffect(() => () => { owner.epoch++; }, [owner]);
const load = useCallback(async (applies: () => boolean = () => true) => {
const epoch = ++owner.epoch;
const valid = () => current.current === owner && epoch === owner.epoch && applies();
owner.cursor = "";
owner.more = null;
setState((old) => ({ ...old, hasMore: false, busy: true, failed: false }));
try {
let page: HistoryPage | undefined;
if (owner.paging && port.historyPage) {
try { page = checkedPage(await port.historyPage()); }
catch (error) {
if (!unsupported(error)) throw error;
owner.paging = false;
}
}
const messages = page ? null : await port.history();
if (!valid()) return;
const restored = page ? projection(page) : fromHistory(messages ?? []);
owner.cursor = page?.nextCursor ?? "";
owner.snapshot = page?.snapshot ?? "";
owner.before = page?.start ?? 0;
owner.total = page?.total ?? 0;
dispatch({ kind: "__restore", ...restored });
setState((old) => ({ snapshot: owner.snapshot, revision: old.revision + 1, hasMore: !!owner.cursor, busy: false, failed: false }));
} catch (error) {
if (valid()) setState((old) => ({ ...old, busy: false, failed: true }));
throw error;
}
}, [owner, port, dispatch]);
const more = useCallback((): Promise<void> => {
if (owner.more) return owner.more;
if (!owner.cursor || !port.historyPage) return Promise.resolve();
const epoch = owner.epoch;
const cursor = owner.cursor;
const valid = () => current.current === owner && epoch === owner.epoch;
setState((old) => ({ ...old, busy: true, failed: false }));
const pending = port.historyPage(cursor).then((page) => {
if (!valid()) return;
checkedPage(page);
if (page.snapshot !== owner.snapshot) return load();
if (page.end !== owner.before || page.total !== owner.total || page.start >= page.end) throw new Error("history page did not advance");
owner.cursor = page.nextCursor ?? "";
owner.before = page.start;
dispatch({ kind: "__history_prepend", ...projection(page) });
setState((old) => ({ ...old, revision: old.revision + 1, hasMore: !!owner.cursor, busy: false, failed: false }));
}).catch(async (error) => {
if (!valid()) return;
if (error instanceof HttpError && error.reason?.code === "history.snapshot_changed") { await load(); return; }
setState((old) => ({ ...old, busy: false, failed: true }));
throw error;
}).finally(() => { if (owner.more === pending) owner.more = null; });
owner.more = pending;
return pending;
}, [owner, port, dispatch, load]);
const reload = useCallback(() => load(), [load]);
return { load, paging: { ...state, more, reload } satisfies HistoryPaging };
}
+9
View File
@@ -1032,6 +1032,15 @@ Removing the project closes its idle panes and retains every directory and
transcript. Existing transcripts stay in place and group on restart without migration.
Worktrees added separately outside Reasonix-managed storage remain independent projects.
With a paging-capable server, long conversations initially display the latest 200 messages. Scroll upward or
choose Load earlier messages to read more.
In-conversation find reads earlier pages as needed and indicates incomplete
coverage; clearing the query stops further prefetching. Rewind, compaction and
session changes reload recent content.
Paging affects presentation only, not the full conversation used by the model.
The list holds up to 32 projects; adding another requires removing one first. `serve-workspaces.json` now stores an object with `paths` and `launch`. Older binaries read only arrays and cannot preserve this format: do not share the same state home with an older binary after reordering.
Menus and controls:
+4
View File
@@ -909,6 +909,10 @@ Reasonix 管理的普通 worktree 会话统一列在原项目下,不再为隔
移除项目会关闭所属的空闲面板,但保留所有工作目录和会话文件。
历史会话无需搬移,重启后直接按原项目归组;Reasonix 管理目录以外的 worktree 仍作为独立项目使用。
服务端支持分页时,长会话正文先显示最近 200 条消息,向上滚动或选择“加载更早的消息”继续读取。
会话内查找会按需读取较早页面,并提示查找尚未完成;清空查找词后停止继续预读。
回退、压缩或切换会话后,会重新读取最近内容。分页只影响展示,不会截断模型使用的完整会话。
项目列表最多保存 32 项,新增第 33 项前需先移除一项。`serve-workspaces.json` 现在使用含 `paths` 和 `launch` 的对象;旧版本只能读取数组,无法保留新格式。排序后不要与旧版本共用同一状态目录。
## 权限与沙盒
+39
View File
@@ -0,0 +1,39 @@
---
owner: @SivanCola
backup: @esengine
status: active
reviewed: 2026-10-24
---
# Incremental transcript UI / 正文渐进分页界面
With a paging-capable server, opening a conversation reads the latest 200 raw
messages. Load earlier messages, scrolling upward near the beginning, or searching
requests older pages. Search reports incomplete coverage until older history has
loaded; regex and case-sensitive search remain available.
后端支持分页时,打开会话先读取最近 200 条原始消息。点击“加载较早消息”、向上滚动至
已加载内容起点附近或搜索时读取更早的分页。历史尚未读完时,搜索明确显示覆盖不完整;
正则与大小写搜索仍然可用。
Prepending pages preserves absolute message positions, the reader's visible
anchor and mounted extension controls. New reader intent cancels pending anchor
restoration. Live messages and queued interactions stay owned by the session.
Responses from a previous binding are discarded; invalidated snapshots reload.
前插分页保留全局消息位置、可见阅读锚点和已挂载扩展控件。新的阅读操作取消待执行的
锚点恢复。实时消息和排队交互仍由会话持有;旧绑定的响应被丢弃,失效快照会重新加载。
The separate history API PR is the functional dependency for bounded reads.
This UI also works with an older server: a missing endpoint or the explicit
`history.pagination_unsupported` refusal selects full `/history` for that binding.
Authentication, missing-runtime and server failures remain errors, never a silent
full-history fallback. A port without the optional method uses full history.
Rolling back this UI leaves the additive backend API usable by other clients.
独立的历史接口 PR 是有界读取的功能依赖。界面也兼容旧服务端:接口缺失或明确返回
`history.pagination_unsupported` 时,该绑定使用全量 `/history`。鉴权、运行时丢失和
服务端故障仍作为错误处理,不会静默退回全量读取。不提供可选分页方法的端口使用全量
历史。撤回界面后,新增后端接口仍可供其他客户端使用。
@@ -0,0 +1,2 @@
- Long conversations load recent messages first on paging servers. Older pages preserve reading position and open controls; search reports incomplete history. Refs #14048, #14087.
- 支持分页的服务端优先显示最近消息。读取旧页保留阅读位置和控件,搜索提示历史覆盖不完整;旧服务端保留全量加载。关联 #14048、#14087。