mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-08 08:04:50 -10:00
fix(desktop): resolve rail turns by identity and bound outline fetching
Review fixes for the turn-outline change. Four findings, all reproduced
against the code before being changed.
Problem 1 (user-visible): a question written in this app session kept its
optimistic `u<seq>` anchor key after the authoritative message settled — the
reducer only attaches `messageId` — while its outline entry is keyed by the
Go record id `m:<uuid>`. `loadedTurnKey` matched mounted keys, so that turn was
marked unloaded even though its body was on screen, and clicking it started a
jump that paged to the beginning of history and then failed. Once a cut was
re-installed mid-session the same turn was also listed twice: once as the
outline's unloaded entry and once as the trailing "not yet in the outline"
item.
Fix: `findLoadedTurn` matches on node identity — `messageId` first, then the
record id — never on the shape of the anchor key. The mark keeps the outline
record id as its stable identity for its whole life, so finishing a load no
longer remounts it, and `anchor.key` carries the DOM target separately. The
rail tracks both emitted identities and consumed nodes so no turn is listed
twice. The jump resolves through the same helper.
Problem 2: `readAll` paged until `done` with no cap, so a hostile or buggy host
answering `{nextOffset: offset+1, done: false}` forever drove an unbounded
request-and-append loop (unbounded renderer memory and network). This broke the
convention the codebase already uses for server-driven loops.
Fix: bound at 64 pages / 20,000 entries, with a regression test that hangs
without the bound.
Problem 3: `settleMounts` was bounded only by animation frames, and a hidden or
occluded window stops delivering them, leaving the mark pulsing until the
window was shown again.
Fix: add a wall-clock ceiling alongside the frame budget, and cancel the
pending frame and timer when it settles. Matches the existing guidance to use a
bounded wall-clock recovery budget rather than frame counts.
Problem 4: rebinding a tab to another session (`resumeSession`,
`openChannelSession`) called `snapshotClient.load` without announcing that the
previous cut was going away, so the rail could briefly describe the previous
session's turns under the same tab id.
Fix: `load` announces the gap before the round trip; the install re-announces
with the new identity.
Also regenerates docs/desktop-migration/INVENTORY.md and inventory.json, which
the two new host commands made stale.
Verification:
- pnpm test:transcript (incl. the DOM test now using the real optimistic
`u<seq>` item shape, which fails without problem 1's fix)
- pnpm test:typecheck, pnpm build, pnpm check:scroll-writer
- go test ./internal/transcript/ ./internal/serve/, go run ./tools/repolint
- go test ./... — the two failures it reports
(TestPortableUnixArchiveMigratesWithFlatCLIName, TestInventoryIsClassifiedAndCurrent)
were checked against untouched origin/main-v2; the first fails there
identically (pre-existing), the second is fixed by the regenerated inventory.
This commit is contained in:
1 parent
306efd7286
commit
23bca69f88
12 files changed
+229
-79
No files matched your search
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
|
||||
import { ChatMountedOrder } from "../lib/chatMountedOrder";
|
||||
import { ChatTurnJump } from "../lib/chatTurnJump";
|
||||
import type { ChatScrollController } from "../lib/chatScrollController";
|
||||
import { alignOutlineEntries, loadedTurnKey } from "../lib/chatTurnRail";
|
||||
import { alignOutlineEntries, findLoadedTurn } from "../lib/chatTurnRail";
|
||||
import type { TranscriptOutlineEntry } from "../lib/transcriptProtocol";
|
||||
|
||||
// Node has no animation frame; the mount-settle path is driven by the mounted
|
||||
@@ -202,20 +202,30 @@ async function main() {
|
||||
}
|
||||
|
||||
{
|
||||
// Identity resolution: a settled question is found by its message ID, and
|
||||
// a question that has not been committed is found by its record ID. Both
|
||||
// must resolve to the node the rail would scroll to.
|
||||
// Identity resolution. The node's own identity decides, never the shape of
|
||||
// its anchor key: a question written in this app session keeps its
|
||||
// optimistic `u<seq>` id after the authoritative message arrives and only
|
||||
// gains a messageId, so a key-shaped match would miss the turns the reader
|
||||
// just wrote — the exact regression this covers.
|
||||
const settled: TranscriptOutlineEntry = { id: "m:abc", messageId: "abc", turn: 1, order: 0, prompt: "", answer: "" };
|
||||
assert.equal(loadedTurnKey(settled, new Set(["m:abc"])), "m:abc", "a settled question resolves by message ID");
|
||||
assert.equal(loadedTurnKey(settled, new Set()), undefined, "an unmounted question has no key");
|
||||
const nodes = new Map([
|
||||
["u7", { id: "u7", messageId: "abc" }],
|
||||
["m:other", { id: "m:other" }],
|
||||
]);
|
||||
const read = (key: string) => nodes.get(key);
|
||||
assert.equal(findLoadedTurn(["u7"], read, settled), "u7", "an optimistically submitted question is found by its message ID");
|
||||
assert.equal(findLoadedTurn([], read, settled), undefined, "an unmounted question has no key");
|
||||
assert.equal(findLoadedTurn(["m:other"], read, settled), undefined, "an unrelated node is not claimed");
|
||||
|
||||
const optimistic: TranscriptOutlineEntry = { id: "m:tmp", turn: 2, order: 2, prompt: "", answer: "" };
|
||||
assert.equal(loadedTurnKey(optimistic, new Set(["m:tmp"])), "m:tmp", "an uncommitted question resolves by record ID");
|
||||
assert.equal(loadedTurnKey(optimistic, new Set(["m:other"])), undefined, "an unrelated node is not claimed");
|
||||
const uncommitted: TranscriptOutlineEntry = { id: "m:tmp", turn: 2, order: 2, prompt: "", answer: "" };
|
||||
nodes.set("m:tmp", { id: "m:tmp" });
|
||||
assert.equal(findLoadedTurn(["m:tmp"], read, uncommitted), "m:tmp", "an uncommitted question resolves by record ID");
|
||||
|
||||
// A message ID wins over a shadowing record ID, so settlement cannot move
|
||||
// a mark that already points at the canonical node.
|
||||
assert.equal(loadedTurnKey(settled, new Set(["m:abc", "m:abc:legacy"])), "m:abc", "message ID outranks the record ID fallback");
|
||||
// A message ID match wins even when a record-ID-only match appears earlier
|
||||
// in the mounted order, so settlement cannot move a mark to a stale node.
|
||||
nodes.set("m:abc:legacy", { id: "m:abc" });
|
||||
assert.equal(findLoadedTurn(["m:abc:legacy", "u7"], read, settled), "u7",
|
||||
"a message ID match outranks an earlier record ID match");
|
||||
|
||||
const aligned = alignOutlineEntries([
|
||||
{ id: "m:b", turn: 2, order: 2, prompt: "", answer: "" },
|
||||
|
||||
@@ -25,11 +25,16 @@ function outlinePage(): TranscriptOutlinePage {
|
||||
}
|
||||
|
||||
/** The loaded body window: the newest turns, oldest first, as the transcript
|
||||
* loads them — an earlier page prepends above this. */
|
||||
* loads them — an earlier page prepends above this.
|
||||
*
|
||||
* The user items carry the runtime shape of a question written in this app
|
||||
* session: the reducer keeps the optimistic `u<seq>` id after the authoritative
|
||||
* message settles and only attaches `messageId`, so matching the rail by the
|
||||
* shape of the anchor key would miss them. */
|
||||
function turnsFrom(start: number): Item[] {
|
||||
const items: Item[] = [];
|
||||
for (let index = start; index <= TOTAL; index += 1) {
|
||||
items.push({ kind: "user", id: `m:u${index}`, text: `loaded prompt ${index}`, historyTurn: index });
|
||||
items.push({ kind: "user", id: `u${index}`, messageId: `u${index}`, text: `loaded prompt ${index}`, historyTurn: index });
|
||||
items.push({ kind: "assistant", id: `a${index}`, text: `loaded answer ${index}`, reasoning: "", streaming: false });
|
||||
}
|
||||
return items;
|
||||
@@ -78,7 +83,16 @@ try {
|
||||
const unloaded = marks().filter(mark => mark.dataset.navUnloaded === "true");
|
||||
assert.deepEqual(unloaded.map(mark => mark.dataset.navTurn), ["m:u1", "m:u2", "m:u3", "m:u4"],
|
||||
"turns without a mounted body are marked unloaded");
|
||||
assert.equal(harness.container.querySelector('[data-chat-anchor-key="m:u1"]'), null, "the oldest turn is not loaded yet");
|
||||
assert.equal(harness.container.querySelector('[data-chat-anchor-key="u1"]'), null, "the oldest turn is not loaded yet");
|
||||
|
||||
// A question written in this app session keeps its optimistic `u<seq>` anchor
|
||||
// key, which does not match its outline record id. It must still be one mark
|
||||
// — matched by identity — and not a duplicate "unloaded" entry beside a
|
||||
// loaded one.
|
||||
const keys = new Set(marks().map(mark => mark.dataset.navTurn));
|
||||
assert.equal(keys.size, TOTAL, "every turn appears exactly once despite mismatched anchor keys");
|
||||
assert.equal(marks().find(mark => mark.dataset.navTurn === "m:u6")?.dataset.navUnloaded, undefined,
|
||||
"a loaded turn with an optimistic anchor key is not marked unloaded");
|
||||
|
||||
// Absolute turn numbering must survive loading an earlier page.
|
||||
const labelsBefore = marks().map(mark => mark.getAttribute("aria-label"));
|
||||
@@ -88,7 +102,7 @@ try {
|
||||
const targetMark = marks().find(mark => mark.dataset.navTurn === "m:u1")!;
|
||||
await act(async () => { targetMark.click(); });
|
||||
await harness.waitFor(
|
||||
() => harness.container.querySelector('[data-chat-anchor-key="m:u1"]') !== null,
|
||||
() => harness.container.querySelector('[data-chat-anchor-key="u1"]') !== null,
|
||||
"the oldest turn's node to mount",
|
||||
);
|
||||
await harness.settle();
|
||||
|
||||
@@ -112,6 +112,35 @@ async function main() {
|
||||
assert.equal(store.getView("tab").mode, "legacy", "a released tab keeps no outline from a late response");
|
||||
}
|
||||
|
||||
{
|
||||
// A hostile or buggy host that never finishes must not drive an unbounded
|
||||
// request-and-append loop. Each page advances the cursor by one and claims
|
||||
// there is more, so only the page cap can stop it.
|
||||
let requests = 0;
|
||||
const store = new TranscriptOutlineStore();
|
||||
store.register("tab", async (_tabId, request) => {
|
||||
requests += 1;
|
||||
const offset = request.offset ?? 0;
|
||||
return page("s1", [entry(offset)], offset + 1, false);
|
||||
});
|
||||
await store.sync("tab", "s1");
|
||||
assert.equal(store.getView("tab").mode, "error", "an endless host ends the read");
|
||||
assert.equal(store.getView("tab").error, "outline is too large");
|
||||
assert.ok(requests <= 64, `endless host issued ${requests} requests`);
|
||||
}
|
||||
|
||||
{
|
||||
// The same bound applies to an oversized-but-finishing host.
|
||||
const store = new TranscriptOutlineStore();
|
||||
store.register("tab", async (_tabId, request) => {
|
||||
const offset = request.offset ?? 0;
|
||||
const entries = Array.from({ length: 1000 }, (_, index) => entry(offset + index));
|
||||
return page("s1", entries, offset + entries.length, false);
|
||||
});
|
||||
await store.sync("tab", "s1");
|
||||
assert.equal(store.getView("tab").mode, "error", "an oversized outline ends the read");
|
||||
}
|
||||
|
||||
{
|
||||
// An unregistered tab never claims the capability.
|
||||
const store = new TranscriptOutlineStore();
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useCallback, useMemo, useSyncExternalStore } from "react";
|
||||
import type { ChatSource } from "../lib/chatViewSource";
|
||||
import type { ChatScrollController } from "../lib/chatScrollController";
|
||||
import type { ChatMountedOrder } from "../lib/chatMountedOrder";
|
||||
import { loadedTurnKey } from "../lib/chatTurnRail";
|
||||
import { findLoadedTurn, type LoadedTurnNode } from "../lib/chatTurnRail";
|
||||
import { getTranscriptOutlineStore, type TranscriptOutlineView } from "../lib/transcriptOutlineStore";
|
||||
import type { TranscriptOutlineEntry } from "../lib/transcriptProtocol";
|
||||
import { useT } from "../lib/i18n";
|
||||
@@ -12,7 +12,7 @@ import "./harness-chat/TurnNavigator.css";
|
||||
|
||||
function Preview({ source, item }: { source: ChatSource; item: TurnRailItem }) {
|
||||
const subscribe = useCallback((notify: () => void) => {
|
||||
const user = item.anchor.kind === "loaded" ? source.subscribeNode(item.turn, notify) : undefined;
|
||||
const user = item.anchor.kind === "loaded" ? source.subscribeNode(item.anchor.key, notify) : undefined;
|
||||
const answer = item.answerKey ? source.subscribeNode(item.answerKey, notify) : undefined;
|
||||
return () => { user?.(); answer?.(); };
|
||||
}, [source, item]);
|
||||
@@ -20,7 +20,7 @@ function Preview({ source, item }: { source: ChatSource; item: TurnRailItem }) {
|
||||
// An unloaded turn has no node to read, so its outline preview is used as
|
||||
// it arrived. Prefer the loaded body when there is one: it carries the
|
||||
// running turn's text that the snapshot could not have seen yet.
|
||||
const user = item.anchor.kind === "loaded" ? source.getNodeSnapshot(item.turn) : undefined;
|
||||
const user = item.anchor.kind === "loaded" ? source.getNodeSnapshot(item.anchor.key) : undefined;
|
||||
const answer = item.answerKey ? source.getNodeSnapshot(item.answerKey) : undefined;
|
||||
const prompt = user?.kind === "user" ? user.item.text.slice(0, 300) : item.prompt;
|
||||
const response = answer?.kind === "assistant" && answer.item.text.trim() ? answer.item.text.slice(0, 500) : item.response;
|
||||
@@ -50,23 +50,23 @@ export default function ChatTurnNavigator({ source, scroll, mounts, tabId, onJum
|
||||
const order = useSyncExternalStore(mounts.subscribe, mounts.getSnapshot, mounts.getSnapshot);
|
||||
const position = useSyncExternalStore(scroll.subscribe, scroll.getSnapshot, scroll.getSnapshot);
|
||||
|
||||
// The mounted set is a Set for lookup and the order array for sequencing.
|
||||
const mountedKeys = useMemo(() => new Set(order), [order]);
|
||||
// Bumped whenever the outline identity changes so the merge re-runs.
|
||||
// A new array identity whenever the outline changes so the merge re-runs.
|
||||
const outlineEntries = outline.entries;
|
||||
|
||||
const items = useMemo(() => {
|
||||
const turns: TurnRailItem[] = [];
|
||||
const byTurn = new Map<string, TurnRailItem>();
|
||||
const identity = new Map<string, LoadedTurnNode>();
|
||||
for (const key of order) {
|
||||
const node = source.getNodeSnapshot(key);
|
||||
if (node?.kind === "user") {
|
||||
const item: TurnRailItem = {
|
||||
turn: key, ordinal: turns.length + 1, prompt: "", response: "",
|
||||
anchor: { kind: "loaded" },
|
||||
anchor: { kind: "loaded", key },
|
||||
};
|
||||
turns.push(item);
|
||||
byTurn.set(key, item);
|
||||
identity.set(key, { id: node.item.id, messageId: node.item.messageId });
|
||||
} else if (node?.kind === "assistant" && turns.length) turns[turns.length - 1].answerKey = key;
|
||||
}
|
||||
if (outline.mode !== "ready") return turns;
|
||||
@@ -74,36 +74,42 @@ export default function ChatTurnNavigator({ source, scroll, mounts, tabId, onJum
|
||||
// The outline is the complete conversation; loaded turns only enrich it.
|
||||
// Ordering and numbering come from the outline so loading an earlier page
|
||||
// never renumbers the rail.
|
||||
const read = (key: string) => identity.get(key);
|
||||
const merged: TurnRailItem[] = [];
|
||||
// Mark identities already emitted, and the mounted nodes they consumed, so
|
||||
// a turn is never listed twice under two different identities.
|
||||
const emitted = new Set<string>();
|
||||
const claimed = new Set<string>();
|
||||
for (const entry of outlineEntries) {
|
||||
const key = loadedTurnKey(entry, mountedKeys);
|
||||
if (key) claimed.add(key);
|
||||
const key = findLoadedTurn(order, read, entry);
|
||||
// The mark keeps the outline's record id as its identity for its whole
|
||||
// life, so finishing a load never remounts it or moves its position.
|
||||
if (emitted.has(entry.id)) continue;
|
||||
emitted.add(entry.id);
|
||||
if (key !== undefined) claimed.add(key);
|
||||
const loaded = key ? byTurn.get(key) : undefined;
|
||||
merged.push({
|
||||
turn: key ?? entry.id,
|
||||
turn: entry.id,
|
||||
ordinal: entry.turn > 0 ? entry.turn : merged.length + 1,
|
||||
prompt: loaded?.prompt || entry.prompt,
|
||||
response: loaded?.response || entry.answer || "",
|
||||
answerKey: loaded?.answerKey,
|
||||
anchor: key ? { kind: "loaded" } : { kind: "unloaded", recordId: entry.id, messageId: entry.messageId },
|
||||
anchor: key ? { kind: "loaded", key } : { kind: "unloaded", recordId: entry.id, messageId: entry.messageId },
|
||||
unloaded: key === undefined,
|
||||
});
|
||||
}
|
||||
// A question submitted while the outline was being read is not in it yet.
|
||||
// Keep it rather than dropping a turn the reader can already see, but never
|
||||
// list one turn twice when its identities disagree.
|
||||
const emitted = new Set(merged.map(item => item.turn));
|
||||
// Keep it rather than dropping a turn the reader can already see.
|
||||
for (const item of turns) {
|
||||
if (claimed.has(item.turn) || emitted.has(item.turn)) continue;
|
||||
emitted.add(item.turn);
|
||||
merged.push({ ...item, ordinal: merged.length + 1, unloaded: false });
|
||||
}
|
||||
return merged;
|
||||
}, [order, mountedKeys, source, outline.mode, outlineEntries]);
|
||||
}, [order, source, outline.mode, outlineEntries]);
|
||||
|
||||
const navigate = useCallback((item: TurnRailItem) => {
|
||||
if (item.anchor.kind === "loaded") { scroll.jump(item.turn); return; }
|
||||
if (item.anchor.kind === "loaded") { scroll.jump(item.anchor.key); return; }
|
||||
onJump?.({
|
||||
id: item.anchor.recordId, messageId: item.anchor.messageId,
|
||||
turn: item.ordinal, order: 0, prompt: item.prompt, answer: item.response,
|
||||
|
||||
@@ -9,7 +9,7 @@ import { ChatScrollController } from "../lib/chatScrollController";
|
||||
import { ChatContentLoader } from "../lib/chatContentLoader";
|
||||
import { ChatMountedOrder } from "../lib/chatMountedOrder";
|
||||
import { ChatTurnJump } from "../lib/chatTurnJump";
|
||||
import { loadedTurnKey } from "../lib/chatTurnRail";
|
||||
import { findLoadedTurn } from "../lib/chatTurnRail";
|
||||
import { addBreadcrumb } from "../lib/breadcrumbs";
|
||||
import { useT } from "../lib/i18n";
|
||||
import { InvocationMetadataContext } from "./Message";
|
||||
@@ -147,10 +147,15 @@ function ChatSession(props: TranscriptProps & { sessionKey: string }) {
|
||||
loadOlder: () => loadOlderRef.current("question-jump"),
|
||||
hasOlder: () => hasOlderRef.current,
|
||||
// Re-read the mounted set on every attempt: the whole point is that the
|
||||
// answer changes as the progressive mount advances.
|
||||
resolveKey: (entry) => loadedTurnKey(entry, new Set(mounts.getSnapshot())),
|
||||
// answer changes as the progressive mount advances. Matching goes through
|
||||
// the node's own identity, so an optimistically submitted question is
|
||||
// found under its `u<seq>` anchor key rather than its eventual message id.
|
||||
resolveKey: (entry) => findLoadedTurn(mounts.getSnapshot(), (key) => {
|
||||
const node = source.getNodeSnapshot(key);
|
||||
return node?.kind === "user" ? { id: node.item.id, messageId: node.item.messageId } : undefined;
|
||||
}, entry),
|
||||
isCurrent: () => lifetimeRef.current === lifetime.current,
|
||||
}), [mounts, scroll]);
|
||||
}), [mounts, scroll, source]);
|
||||
const jumpState = useSyncExternalStore(jump.subscribe, jump.getSnapshot, jump.getSnapshot);
|
||||
useEffect(() => () => jump.dispose(), [jump]);
|
||||
useEffect(() => {
|
||||
|
||||
@@ -6,8 +6,16 @@ import {
|
||||
} from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { useT } from '../../lib/i18n'
|
||||
/** A mark either already has a mounted node, or must page history in first. */
|
||||
export type TurnRailAnchor = { kind: 'loaded' } | { kind: 'unloaded'; recordId: string; messageId?: string }
|
||||
/**
|
||||
* A mark either already has a mounted node — then `key` is the DOM anchor to
|
||||
* scroll to — or must page history in first.
|
||||
*/
|
||||
export type TurnRailAnchor = { kind: 'loaded'; key: string } | { kind: 'unloaded'; recordId: string; messageId?: string }
|
||||
/**
|
||||
* `turn` is the mark's stable identity and React key: it is the outline record
|
||||
* id, so it does not change when that turn finishes loading. `anchor` carries
|
||||
* where to scroll and how to resolve an unloaded target.
|
||||
*/
|
||||
export interface TurnRailItem { turn: string; ordinal: number; prompt: string; response: string; answerKey?: string; anchor: TurnRailAnchor; unloaded?: boolean }
|
||||
import css from './TurnNavigator.styles'
|
||||
|
||||
@@ -200,7 +208,8 @@ function TurnNavigatorRail({ items, activeTurn, busyTurn, onNavigate, renderPrev
|
||||
<div className={css.marks}>
|
||||
{items.slice(firstVisible, lastVisible).map((item, visibleIndex) => {
|
||||
const index = firstVisible + visibleIndex
|
||||
const active = item.turn === activeTurn
|
||||
// The active mark is the mounted node, not the outline identity.
|
||||
const active = item.anchor.kind === 'loaded' && item.anchor.key === activeTurn
|
||||
const showingPreview = item.turn === previewTurn
|
||||
const previewDistance = previewIndex < 0 ? -1 : Math.abs(index - previewIndex)
|
||||
const classes = [css.mark]
|
||||
|
||||
@@ -15,6 +15,8 @@ const IDLE: TurnJumpState = Object.freeze({ turn: null, status: "idle" });
|
||||
|
||||
/** Frames to let the progressive mount advance before re-checking the target. */
|
||||
const MOUNT_SETTLE_FRAMES = 120;
|
||||
/** Wall-clock ceiling for the same wait; frames stop arriving when hidden. */
|
||||
const MOUNT_SETTLE_MS = 2000;
|
||||
/** Pages a single jump may pull before giving up, independent of entry count. */
|
||||
const MAX_JUMP_PAGES = 400;
|
||||
|
||||
@@ -132,13 +134,25 @@ export class ChatTurnJump {
|
||||
return new Promise((resolve) => {
|
||||
let elapsed = 0;
|
||||
let handle = 0;
|
||||
let settled = false;
|
||||
const finish = (): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (handle) cancelAnimationFrame(handle);
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
};
|
||||
const step = (): void => {
|
||||
if (this.deps.mounts.getSnapshot() !== previous || elapsed >= MOUNT_SETTLE_FRAMES) { resolve(); return; }
|
||||
if (settled) return;
|
||||
if (this.deps.mounts.getSnapshot() !== previous || elapsed >= MOUNT_SETTLE_FRAMES) { finish(); return; }
|
||||
elapsed++;
|
||||
handle = requestAnimationFrame(step);
|
||||
};
|
||||
// A hidden or occluded window stops delivering frames, so a frame count
|
||||
// alone can leave the mark pulsing until the window is shown again. Bound
|
||||
// the wait by wall clock as well and let the next attempt re-check.
|
||||
const timer = setTimeout(finish, MOUNT_SETTLE_MS);
|
||||
handle = requestAnimationFrame(step);
|
||||
void handle;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,19 +1,39 @@
|
||||
import type { TranscriptOutlineEntry } from "./transcriptProtocol";
|
||||
|
||||
/** The identities a loaded user turn exposes to the rail. */
|
||||
export interface LoadedTurnNode {
|
||||
/** The item's own id, which is also its DOM anchor key. */
|
||||
readonly id: string;
|
||||
readonly messageId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which mounted node answers for one outline entry, or undefined while that
|
||||
* The mounted node that answers for one outline entry, or undefined while that
|
||||
* turn is still unloaded.
|
||||
*
|
||||
* A message ID is the identity that survives optimistic submission and
|
||||
* settlement, so it is tried first; the record ID is the stable fallback that
|
||||
* also covers a question that has not been committed yet. Shared by the rail
|
||||
* and by the jump transaction so both agree on when a target has really
|
||||
* mounted.
|
||||
* Matches on identity, never on the shape of the node key: a question submitted
|
||||
* in this app session keeps its optimistic `u<seq>` id after the authoritative
|
||||
* message arrives and only gains a `messageId`, so key-shaped comparisons would
|
||||
* miss exactly the turns the reader just wrote. A message ID is the identity
|
||||
* that survives settlement, so it wins; the record ID is the stable fallback
|
||||
* that also covers a question that has not been committed yet.
|
||||
*
|
||||
* Shared by the rail and by the jump transaction so both agree on when a target
|
||||
* has really mounted.
|
||||
*/
|
||||
export function loadedTurnKey(entry: TranscriptOutlineEntry, mounted: ReadonlySet<string>): string | undefined {
|
||||
if (entry.messageId && mounted.has(`m:${entry.messageId}`)) return `m:${entry.messageId}`;
|
||||
if (mounted.has(entry.id)) return entry.id;
|
||||
return undefined;
|
||||
export function findLoadedTurn(
|
||||
order: readonly string[],
|
||||
read: (key: string) => LoadedTurnNode | undefined,
|
||||
entry: TranscriptOutlineEntry,
|
||||
): string | undefined {
|
||||
let fallback: string | undefined;
|
||||
for (const key of order) {
|
||||
const node = read(key);
|
||||
if (node === undefined) continue;
|
||||
if (entry.messageId !== undefined && node.messageId === entry.messageId) return key;
|
||||
if (fallback === undefined && node.id === entry.id) fallback = key;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/** Ordered, de-duplicated outline entries. */
|
||||
|
||||
@@ -30,6 +30,12 @@ export interface TranscriptOutlineView {
|
||||
const EMPTY_ENTRIES: readonly TranscriptOutlineEntry[] = [];
|
||||
const LEGACY: TranscriptOutlineView = Object.freeze({ mode: "legacy", snapshotId: "", entries: EMPTY_ENTRIES });
|
||||
|
||||
// A hostile or buggy host drives this loop, so it is bounded like every other
|
||||
// server-driven read in this codebase (MAX_REPLAY_PAGES, MAX_JUMP_PAGES).
|
||||
// 64 pages of 1000 entries is far beyond any real conversation.
|
||||
const MAX_OUTLINE_PAGES = 64;
|
||||
const MAX_OUTLINE_ENTRIES = 20_000;
|
||||
|
||||
/**
|
||||
* The complete turn index of the installed snapshot, shared by the local
|
||||
* controller and remote sessions. Paging the body changes what is mounted, not
|
||||
@@ -125,7 +131,11 @@ export class TranscriptOutlineStore {
|
||||
const seen = new Set<string>();
|
||||
try {
|
||||
let offset = 0;
|
||||
for (;;) {
|
||||
for (let pages = 0; ; pages++) {
|
||||
if (pages >= MAX_OUTLINE_PAGES) {
|
||||
this.set(tabId, { mode: "error", snapshotId, entries: EMPTY_ENTRIES, error: "outline is too large" });
|
||||
return;
|
||||
}
|
||||
const page = await read(tabId, { snapshotId, offset });
|
||||
if (!current()) return;
|
||||
if (page.stale) {
|
||||
@@ -145,6 +155,10 @@ export class TranscriptOutlineStore {
|
||||
if (seen.has(entry.id)) continue;
|
||||
seen.add(entry.id);
|
||||
entries.push(entry);
|
||||
if (entries.length >= MAX_OUTLINE_ENTRIES) {
|
||||
this.set(tabId, { mode: "error", snapshotId, entries: EMPTY_ENTRIES, error: "outline is too large" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (page.done) break;
|
||||
if (!Number.isSafeInteger(page.nextOffset) || page.nextOffset <= offset) {
|
||||
@@ -195,7 +209,7 @@ export function getTranscriptOutlineStore(): TranscriptOutlineStore {
|
||||
export function localOutlineRead(tabId: string, request: TranscriptOutlineRequest): Promise<TranscriptOutlinePage> {
|
||||
const read = app.TranscriptOutlineForTab;
|
||||
if (typeof read !== "function") return Promise.reject(new OutlineUnsupported("transcript outline is unavailable"));
|
||||
return read(tabId, request);
|
||||
return read(tabId, request).catch((error: unknown) => { throw unavailable(error); });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -206,10 +220,16 @@ export function localOutlineRead(tabId: string, request: TranscriptOutlineReques
|
||||
export function remoteOutlineRead(tabId: string, request: TranscriptOutlineRequest): Promise<TranscriptOutlinePage> {
|
||||
const read = app.RemoteTranscriptOutlineForTab;
|
||||
if (typeof read !== "function") return Promise.reject(new OutlineUnsupported("remote transcript outline is unavailable"));
|
||||
return read(tabId, request).catch((error: unknown) => {
|
||||
if (message(error).toLowerCase().includes("transcript projection is unavailable")) {
|
||||
throw new OutlineUnsupported("remote transcript outline is unavailable");
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
return read(tabId, request).catch((error: unknown) => { throw unavailable(error); });
|
||||
}
|
||||
|
||||
/**
|
||||
* The host answering "this controller has no outline projection" is a
|
||||
* compatibility answer, not a failure: both transports must degrade to the
|
||||
* loaded-turn rail rather than offer a retry that can never succeed.
|
||||
*/
|
||||
function unavailable(error: unknown): unknown {
|
||||
return message(error).toLowerCase().includes("transcript projection is unavailable")
|
||||
? new OutlineUnsupported("transcript outline is unavailable")
|
||||
: error;
|
||||
}
|
||||
@@ -140,6 +140,11 @@ export class TranscriptSnapshotClient {
|
||||
async load(tabId: string, commit: (snapshot: TranscriptSnapshot) => void, current: () => boolean = () => true): Promise<boolean> {
|
||||
const generation = (this.generations.get(tabId) ?? 0) + 1;
|
||||
this.generations.set(tabId, generation);
|
||||
// A load replaces the cut this tab was bound to, and it may be re-binding
|
||||
// the tab to a different session entirely. Announce the gap before the
|
||||
// round trip so nothing keeps describing the previous session's cut under
|
||||
// this tab id; the install below re-announces with the new identity.
|
||||
this.onCutChanged(tabId, undefined);
|
||||
const lease = this.projector.beginSnapshot(tabId);
|
||||
const valid = () => this.generations.get(tabId) === generation && current();
|
||||
try {
|
||||
|
||||
@@ -6,7 +6,7 @@ Every entry carries one class: `keep-business` keeps its Go implementation and c
|
||||
|
||||
| Kind | keep-business | migrate-host | delete-shell | total |
|
||||
| --- | ---: | ---: | ---: | ---: |
|
||||
| command | 603 | 17 | 0 | 620 |
|
||||
| command | 605 | 17 | 0 | 622 |
|
||||
| native-call | 0 | 0 | 0 | 0 |
|
||||
| event | 16 | 0 | 0 | 16 |
|
||||
| frontend-native | 0 | 0 | 0 | 0 |
|
||||
@@ -16,7 +16,7 @@ Every entry carries one class: `keep-business` keeps its Go implementation and c
|
||||
| shell-file | 1 | 39 | 4 | 44 |
|
||||
| artifact | 5 | 0 | 0 | 5 |
|
||||
| ci-job | 25 | 0 | 0 | 25 |
|
||||
| **all** | | | | **741** |
|
||||
| **all** | | | | **743** |
|
||||
|
||||
## Desktop commands (Go `App` methods bound to the UI)
|
||||
|
||||
@@ -348,20 +348,21 @@ Every entry carries one class: `keep-business` keeps its Go implementation and c
|
||||
| `RemoteHosts` | `() ([]RemoteHostView, error)` | desktop/remote_app.go:301 | keep-business (保留业务实现) | hostrpc desktop/invoke |
|
||||
| `RemoteLastWorkspace` | `(hostID string) string` | desktop/remote_prefs.go:262 | keep-business (保留业务实现) | hostrpc desktop/invoke |
|
||||
| `RemoteProjectSessions` | `(hostID string, workspace string) ([]RemoteSessionView, error)` | desktop/remote_listing.go:366 | keep-business (保留业务实现) | hostrpc desktop/invoke |
|
||||
| `RemoteSearchSessionHistoryForTab` | `(tabID string, textQuery string, cursor string, limit int) (session.SearchHistoryPage, error)` | desktop/remote_transcript_api.go:235 | keep-business (保留业务实现) | hostrpc desktop/invoke |
|
||||
| `RemoteSearchSessionHistoryForTab` | `(tabID string, textQuery string, cursor string, limit int) (session.SearchHistoryPage, error)` | desktop/remote_transcript_api.go:257 | keep-business (保留业务实现) | hostrpc desktop/invoke |
|
||||
| `RemoteServerLogs` | `(hostID string, workspace string, tailLines int) (string, error)` | desktop/remote_app.go:857 | keep-business (保留业务实现) | hostrpc desktop/invoke |
|
||||
| `RemoteServerStatus` | `(hostID string, workspace string) (RemoteServerView, error)` | desktop/remote_app.go:849 | keep-business (保留业务实现) | hostrpc desktop/invoke |
|
||||
| `RemoteSessionHistoryContentForTab` | `(tabID string, ref sessioncontent.Ref, offset int64) (SessionHistoryContentChunk, error)` | desktop/remote_transcript_api.go:214 | keep-business (保留业务实现) | hostrpc desktop/invoke |
|
||||
| `RemoteSessionHistoryPageForTab` | `(tabID string, cursor string, limit int) (session.MessageHistoryPage, error)` | desktop/remote_transcript_api.go:196 | keep-business (保留业务实现) | hostrpc desktop/invoke |
|
||||
| `RemoteSessionHistoryContentForTab` | `(tabID string, ref sessioncontent.Ref, offset int64) (SessionHistoryContentChunk, error)` | desktop/remote_transcript_api.go:236 | keep-business (保留业务实现) | hostrpc desktop/invoke |
|
||||
| `RemoteSessionHistoryPageForTab` | `(tabID string, cursor string, limit int) (session.MessageHistoryPage, error)` | desktop/remote_transcript_api.go:218 | keep-business (保留业务实现) | hostrpc desktop/invoke |
|
||||
| `RemoteTabBranches` | `(tabID string) (json.RawMessage, error)` | desktop/remote_tab_commands.go:494 | keep-business (保留业务实现) | hostrpc desktop/invoke |
|
||||
| `RemoteTabMetadata` | `(tabID string) (RemoteTabSnapshot, error)` | desktop/remote_tab_snapshot.go:78 | keep-business (保留业务实现) | hostrpc desktop/invoke |
|
||||
| `RemoteTabSkills` | `(tabID string) (json.RawMessage, error)` | desktop/remote_tab_commands.go:498 | keep-business (保留业务实现) | hostrpc desktop/invoke |
|
||||
| `RemoteTabSnapshot` | `(tabID string) (RemoteTabSnapshot, error)` | desktop/remote_tab_snapshot.go:73 | keep-business (保留业务实现) | hostrpc desktop/invoke |
|
||||
| `RemoteTabStatus` | `(tabID string) (json.RawMessage, error)` | desktop/remote_tab_snapshot.go:168 | keep-business (保留业务实现) | hostrpc desktop/invoke |
|
||||
| `RemoteTranscriptContentForTab` | `(tabID string, req transcript.ContentRequest) (transcript.ContentChunk, error)` | desktop/remote_transcript_api.go:111 | keep-business (保留业务实现) | hostrpc desktop/invoke |
|
||||
| `RemoteTranscriptPageForTab` | `(tabID string, req transcript.PageRequest) (transcript.Snapshot, error)` | desktop/remote_transcript_api.go:102 | keep-business (保留业务实现) | hostrpc desktop/invoke |
|
||||
| `RemoteTranscriptReplayForTab` | `(tabID string, req control.TranscriptReplayRequest) (control.TranscriptReplay, error)` | desktop/remote_transcript_api.go:120 | keep-business (保留业务实现) | hostrpc desktop/invoke |
|
||||
| `RemoteTranscriptSnapshotForTab` | `(tabID string, req transcript.PageRequest) (RemoteTranscriptSnapshot, error)` | desktop/remote_transcript_api.go:93 | keep-business (保留业务实现) | hostrpc desktop/invoke |
|
||||
| `RemoteTranscriptContentForTab` | `(tabID string, req transcript.ContentRequest) (transcript.ContentChunk, error)` | desktop/remote_transcript_api.go:133 | keep-business (保留业务实现) | hostrpc desktop/invoke |
|
||||
| `RemoteTranscriptOutlineForTab` | `(tabID string, req transcript.OutlineRequest) (transcript.OutlinePage, error)` | desktop/remote_transcript_api.go:117 | keep-business (保留业务实现) | hostrpc desktop/invoke |
|
||||
| `RemoteTranscriptPageForTab` | `(tabID string, req transcript.PageRequest) (transcript.Snapshot, error)` | desktop/remote_transcript_api.go:103 | keep-business (保留业务实现) | hostrpc desktop/invoke |
|
||||
| `RemoteTranscriptReplayForTab` | `(tabID string, req control.TranscriptReplayRequest) (control.TranscriptReplay, error)` | desktop/remote_transcript_api.go:142 | keep-business (保留业务实现) | hostrpc desktop/invoke |
|
||||
| `RemoteTranscriptSnapshotForTab` | `(tabID string, req transcript.PageRequest) (RemoteTranscriptSnapshot, error)` | desktop/remote_transcript_api.go:94 | keep-business (保留业务实现) | hostrpc desktop/invoke |
|
||||
| `RemoveMCPServer` | `(name string) error` | desktop/app.go:8680 | keep-business (保留业务实现) | hostrpc desktop/invoke |
|
||||
| `RemovePermissionRule` | `(list string, rule string) error` | desktop/settings_app.go:2989 | keep-business (保留业务实现) | hostrpc desktop/invoke |
|
||||
| `RemovePlugin` | `(name string) error` | desktop/plugin_packages_app.go:227 | keep-business (保留业务实现) | hostrpc desktop/invoke |
|
||||
@@ -611,9 +612,10 @@ Every entry carries one class: `keep-business` keeps its Go implementation and c
|
||||
| `TestProviderModel` | `(p ProviderView, model string, key string) error` | desktop/provider_settings_probe.go:16 | keep-business (保留业务实现) | hostrpc desktop/invoke |
|
||||
| `ToggleMaximiseMainWindow` | `()` | desktop/window_controls.go:12 | migrate-host (迁移宿主实现) | `business in Go; native step through nativeHost host/*` |
|
||||
| `ToolResultForTab` | `(tabID string, toolID string) *control.ToolResultData` | desktop/app.go:2407 | keep-business (保留业务实现) | hostrpc desktop/invoke |
|
||||
| `TranscriptContentForTab` | `(tabID string, req transcript.ContentRequest) (transcript.ContentChunk, error)` | desktop/transcript_api.go:67 | keep-business (保留业务实现) | hostrpc desktop/invoke |
|
||||
| `TranscriptContentForTab` | `(tabID string, req transcript.ContentRequest) (transcript.ContentChunk, error)` | desktop/transcript_api.go:86 | keep-business (保留业务实现) | hostrpc desktop/invoke |
|
||||
| `TranscriptOutlineForTab` | `(tabID string, req transcript.OutlineRequest) (transcript.OutlinePage, error)` | desktop/transcript_api.go:70 | keep-business (保留业务实现) | hostrpc desktop/invoke |
|
||||
| `TranscriptPageForTab` | `(tabID string, req transcript.PageRequest) (transcript.Snapshot, error)` | desktop/transcript_api.go:63 | keep-business (保留业务实现) | hostrpc desktop/invoke |
|
||||
| `TranscriptReplayForTab` | `(tabID string, req control.TranscriptReplayRequest) (control.TranscriptReplay, error)` | desktop/transcript_api.go:79 | keep-business (保留业务实现) | hostrpc desktop/invoke |
|
||||
| `TranscriptReplayForTab` | `(tabID string, req control.TranscriptReplayRequest) (control.TranscriptReplay, error)` | desktop/transcript_api.go:98 | keep-business (保留业务实现) | hostrpc desktop/invoke |
|
||||
| `TranscriptSnapshotForTab` | `(tabID string, req transcript.PageRequest) (transcript.Snapshot, error)` | desktop/transcript_api.go:51 | keep-business (保留业务实现) | hostrpc desktop/invoke |
|
||||
| `TrashTopic` | `(topicID string) error` | desktop/topic_archive.go:30 | keep-business (保留业务实现) | hostrpc desktop/invoke |
|
||||
| `TrustProjectHooks` | `() error` | desktop/hooks_settings_app.go:91 | keep-business (保留业务实现) | hostrpc desktop/invoke |
|
||||
|
||||
@@ -2613,7 +2613,7 @@
|
||||
"kind": "command",
|
||||
"name": "RemoteSearchSessionHistoryForTab",
|
||||
"detail": "(tabID string, textQuery string, cursor string, limit int) (session.SearchHistoryPage, error)",
|
||||
"location": "desktop/remote_transcript_api.go:235",
|
||||
"location": "desktop/remote_transcript_api.go:257",
|
||||
"class": "keep-business",
|
||||
"owner": "hostrpc desktop/invoke"
|
||||
},
|
||||
@@ -2637,7 +2637,7 @@
|
||||
"kind": "command",
|
||||
"name": "RemoteSessionHistoryContentForTab",
|
||||
"detail": "(tabID string, ref sessioncontent.Ref, offset int64) (SessionHistoryContentChunk, error)",
|
||||
"location": "desktop/remote_transcript_api.go:214",
|
||||
"location": "desktop/remote_transcript_api.go:236",
|
||||
"class": "keep-business",
|
||||
"owner": "hostrpc desktop/invoke"
|
||||
},
|
||||
@@ -2645,7 +2645,7 @@
|
||||
"kind": "command",
|
||||
"name": "RemoteSessionHistoryPageForTab",
|
||||
"detail": "(tabID string, cursor string, limit int) (session.MessageHistoryPage, error)",
|
||||
"location": "desktop/remote_transcript_api.go:196",
|
||||
"location": "desktop/remote_transcript_api.go:218",
|
||||
"class": "keep-business",
|
||||
"owner": "hostrpc desktop/invoke"
|
||||
},
|
||||
@@ -2693,7 +2693,15 @@
|
||||
"kind": "command",
|
||||
"name": "RemoteTranscriptContentForTab",
|
||||
"detail": "(tabID string, req transcript.ContentRequest) (transcript.ContentChunk, error)",
|
||||
"location": "desktop/remote_transcript_api.go:111",
|
||||
"location": "desktop/remote_transcript_api.go:133",
|
||||
"class": "keep-business",
|
||||
"owner": "hostrpc desktop/invoke"
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"name": "RemoteTranscriptOutlineForTab",
|
||||
"detail": "(tabID string, req transcript.OutlineRequest) (transcript.OutlinePage, error)",
|
||||
"location": "desktop/remote_transcript_api.go:117",
|
||||
"class": "keep-business",
|
||||
"owner": "hostrpc desktop/invoke"
|
||||
},
|
||||
@@ -2701,7 +2709,7 @@
|
||||
"kind": "command",
|
||||
"name": "RemoteTranscriptPageForTab",
|
||||
"detail": "(tabID string, req transcript.PageRequest) (transcript.Snapshot, error)",
|
||||
"location": "desktop/remote_transcript_api.go:102",
|
||||
"location": "desktop/remote_transcript_api.go:103",
|
||||
"class": "keep-business",
|
||||
"owner": "hostrpc desktop/invoke"
|
||||
},
|
||||
@@ -2709,7 +2717,7 @@
|
||||
"kind": "command",
|
||||
"name": "RemoteTranscriptReplayForTab",
|
||||
"detail": "(tabID string, req control.TranscriptReplayRequest) (control.TranscriptReplay, error)",
|
||||
"location": "desktop/remote_transcript_api.go:120",
|
||||
"location": "desktop/remote_transcript_api.go:142",
|
||||
"class": "keep-business",
|
||||
"owner": "hostrpc desktop/invoke"
|
||||
},
|
||||
@@ -2717,7 +2725,7 @@
|
||||
"kind": "command",
|
||||
"name": "RemoteTranscriptSnapshotForTab",
|
||||
"detail": "(tabID string, req transcript.PageRequest) (RemoteTranscriptSnapshot, error)",
|
||||
"location": "desktop/remote_transcript_api.go:93",
|
||||
"location": "desktop/remote_transcript_api.go:94",
|
||||
"class": "keep-business",
|
||||
"owner": "hostrpc desktop/invoke"
|
||||
},
|
||||
@@ -4717,7 +4725,15 @@
|
||||
"kind": "command",
|
||||
"name": "TranscriptContentForTab",
|
||||
"detail": "(tabID string, req transcript.ContentRequest) (transcript.ContentChunk, error)",
|
||||
"location": "desktop/transcript_api.go:67",
|
||||
"location": "desktop/transcript_api.go:86",
|
||||
"class": "keep-business",
|
||||
"owner": "hostrpc desktop/invoke"
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"name": "TranscriptOutlineForTab",
|
||||
"detail": "(tabID string, req transcript.OutlineRequest) (transcript.OutlinePage, error)",
|
||||
"location": "desktop/transcript_api.go:70",
|
||||
"class": "keep-business",
|
||||
"owner": "hostrpc desktop/invoke"
|
||||
},
|
||||
@@ -4733,7 +4749,7 @@
|
||||
"kind": "command",
|
||||
"name": "TranscriptReplayForTab",
|
||||
"detail": "(tabID string, req control.TranscriptReplayRequest) (control.TranscriptReplay, error)",
|
||||
"location": "desktop/transcript_api.go:79",
|
||||
"location": "desktop/transcript_api.go:98",
|
||||
"class": "keep-business",
|
||||
"owner": "hostrpc desktop/invoke"
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user