mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
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:
1 parent
5810c1033b
commit
5d24185309
26 files changed
+715
-94
No files matched your search
@@ -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" },
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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 } };
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -909,6 +909,10 @@ Reasonix 管理的普通 worktree 会话统一列在原项目下,不再为隔
|
||||
移除项目会关闭所属的空闲面板,但保留所有工作目录和会话文件。
|
||||
历史会话无需搬移,重启后直接按原项目归组;Reasonix 管理目录以外的 worktree 仍作为独立项目使用。
|
||||
|
||||
服务端支持分页时,长会话正文先显示最近 200 条消息,向上滚动或选择“加载更早的消息”继续读取。
|
||||
会话内查找会按需读取较早页面,并提示查找尚未完成;清空查找词后停止继续预读。
|
||||
回退、压缩或切换会话后,会重新读取最近内容。分页只影响展示,不会截断模型使用的完整会话。
|
||||
|
||||
项目列表最多保存 32 项,新增第 33 项前需先移除一项。`serve-workspaces.json` 现在使用含 `paths` 和 `launch` 的对象;旧版本只能读取数组,无法保留新格式。排序后不要与旧版本共用同一状态目录。
|
||||
|
||||
## 权限与沙盒
|
||||
|
||||
@@ -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。
|
||||
Reference in new issue
Block a user