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:
SivanCola committed 2026-09-14 18:02:26 +08:00
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]
+16 -2
View File
@@ -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;
});
}
+30 -10
View File
@@ -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 {
+13 -11
View File
@@ -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 |
+25 -9
View File
@@ -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"
},