Skip to content
113 changes: 112 additions & 1 deletion webviews/src/agent-session/acpmux/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,16 @@ import { createAcpmuxDebug, type AcpmuxDebug } from "./debug";
import { type AutomationCompose, pageAction } from "./automation";
import { acpWire } from "./wire";
import { acpmuxPerf, type MountedSpan } from "./perf";
import {
drawsAsIs,
drewStreamingRow,
onStreamingRowBlocked,
setStreamingRowBlocked,
streamingRow,
streamingRowBlocked,
textOnlyRow,
useStreamingRow,
} from "./streamingRow";
import { followTrace } from "./followTrace";
import { ScrollPacing } from "./pacing";
import { AdaptiveRenderRate, reportScrollPacing } from "./renderPacing";
Expand Down Expand Up @@ -2354,6 +2364,7 @@ const RowFrame = React.memo(function RowFrame({
children: React.ReactNode;
}) {
acpmuxPerf.span("row.renders", 1);
if (acpmuxPerf.enabled) acpmuxPerf.render(`row:${row.id}`);
const t = useT();
const ref = useRef<HTMLElement>(null);
const [entering] = useState(enter);
Expand Down Expand Up @@ -2829,8 +2840,26 @@ function TranscriptContext({
);
}

/// The index of the streaming reply's row when `next` differs from `drawn` only in that row's
/// text (HOT PATH AUDIT area F, row W1), else -1. Every other field compares by identity: the client
/// keeps the same objects while they do not change (direct.ts `publishedSummary`, `NO_CATALOG`,
/// subagentFold.ts `views`), so this is O(fields + rows), with no serialization. Drawn only while
/// the transcript draws that row as the client holds it (`drawsAsIs`) and no view that reads the
/// pane's rows is open (`streamingRowBlocked`).
function streamedOnly(drawn: AcpmuxSnapshot, next: AcpmuxSnapshot): number {
const before = drawn as unknown as Record<string, unknown>;
const after = next as unknown as Record<string, unknown>;
const keys = Object.keys(after);
if (keys.length !== Object.keys(before).length) return -1;
for (const key of keys) if (key !== "rows" && !Object.is(before[key], after[key])) return -1;
if (streamingRowBlocked()) return -1;
const at = textOnlyRow(drawn.rows, next.rows);
return at >= 0 && drawsAsIs(drawn.rows[at]!) ? at : -1;
}

export function VirtualTranscript({
rows,
rows: paneRows,
streams = false,
earlier,
promptCount,
loadThrough,
Expand Down Expand Up @@ -2870,6 +2899,9 @@ export function VirtualTranscript({
topInset = 0,
}: {
rows: readonly AcpmuxRow[];
/// The pane's live transcript: it draws the streaming reply's row ahead of the pane's snapshot
/// (streamingRow.ts), so a streamed chunk re-renders this transcript and that row, not the pane.
streams?: boolean;
/// The chat's prompts older than `rows` (the snapshot's `earlierPrompts`): the thread rail lays
/// them out from the first frame, so it never grows or shifts as history pages in.
earlier?: readonly AcpmuxRow[];
Expand Down Expand Up @@ -2962,6 +2994,44 @@ export function VirtualTranscript({
/// above the composer. 0 for a scroller below its header.
topInset?: number;
}) {
// The streaming reply's newer text, when the pane has not redrawn for it (streamingRow.ts): that
// row only, in place, with its version moved on by the chunks it is ahead.
const live = useStreamingRow(streams);
const rows = useMemo(() => {
if (!streams || !live) return paneRows;
for (let index = paneRows.length - 1; index >= 0; index -= 1) {
const row = paneRows[index]!;
if (row.id !== live.row.id) continue;
if (row.text !== live.drawnText) return paneRows;
const next = paneRows.slice();
const version = row.version + live.ahead;
next[index] = { ...row, text: live.row.text, version };
// The live line under the reply stays quiet while the reply's text moves (turns.ts
// `quietKey` names the reply's id and version): it moves with the newer version here.
const stale = `${row.id}:${row.version}`;
for (let below = index + 1; below < next.length; below += 1) {
const line = next[below]!;
if (line.kind === THINKING && line.quietKey === stale)
next[below] = { ...line, quietKey: `${row.id}:${version}`, version: line.version + live.ahead };
}
return next;
}
return paneRows;
}, [paneRows, live, streams]);
// What this transcript drew of the pane's streaming reply: the pane takes the fast path only
// while that row is drawn as the client holds it.
useLayoutEffect(() => {
if (!streams) return;
for (let index = paneRows.length - 1; index >= 0; index -= 1) {
const row = paneRows[index]!;
if (row.kind === "assistant" && row.streaming) {
drewStreamingRow(row.id, row.text);
return;
}
if (row.kind === "user") break;
}
drewStreamingRow(undefined, undefined);
}, [paneRows, streams]);
const registry = givenRegistry ?? (readOnly ? readOnlyRegistry : defaultRegistry);
const t = useT();
// Debug measurement (acpmuxPerf): off until the first debug call.
Expand Down Expand Up @@ -6119,6 +6189,7 @@ const NO_PROJECTS: Project[] = [];
function AcpmuxPane() {
// Debug measurement (acpmuxPerf): the pane's own render, before its children.
const paneRenderStart = acpmuxPerf.enabled ? performance.now() : 0;
acpmuxPerf.render("pane");
const t = useT();
const { scrollToBottomOnSend, newChatsIn } = useComposerSettings();
const scrollToBottomOnSendRef = useRef(scrollToBottomOnSend);
Expand Down Expand Up @@ -6419,6 +6490,11 @@ function AcpmuxPane() {
: undefined,
[artifact, artifactRows, snapshot.sessionId],
);
// An open artifact follows its fence in the pane's rows: chunks draw through the pane meanwhile.
useLayoutEffect(() => {
setStreamingRowBlocked(artifact !== undefined, "artifact");
return () => setStreamingRowBlocked(false, "artifact");
}, [artifact]);
const liveArtifact = liveDocument?.artifact ?? artifact;
// Render cards by id (cx-ncc.94): a render that names an earlier one's id shows in that card.
const pages = useMemo(() => renderPages(snapshot.rows), [snapshot.rows]);
Expand Down Expand Up @@ -9449,6 +9525,30 @@ function AcpmuxPane() {
void callNative(QUICK_MESSAGES.dismiss).catch(() => undefined);
});
const rowsRef = useRef<readonly AcpmuxRow[]>([]);
/// The direct client's snapshot the pane last drew; a newer one that only grows the streaming
/// reply's text goes to that row instead (streamingRow.ts).
const drawnSnapshot = useRef<AcpmuxSnapshot | undefined>(undefined);
// Find or an artifact opened while a chunk is held (streamingRow.ts): the pane draws the newest
// snapshot the client sent, so that view reads the newest rows at once.
useEffect(
() =>
onStreamingRowBlocked(() => {
const newest = snapshotRef.current;
streamingRow.clear();
if (!newest) return;
drawnSnapshot.current = newest;
// As the client's listener draws it (a hand-off record the pane holds stays).
setSnapshot((previous) =>
newest.canHandoff &&
!newest.handoff?.ready &&
newest.sessionId === previous.sessionId &&
previous.handoff?.record
? { ...newest, handoff: { ...newest.handoff, record: previous.handoff.record } }
: newest,
);
}),
[],
);
/// The newest snapshot, for host requests that read it (pane.context).
const snapshotRef = useRef<AcpmuxSnapshot | undefined>(undefined);
/// The chat menu shows New side chat: the right-click menu then opens one from any turn that forks.
Expand Down Expand Up @@ -11007,6 +11107,16 @@ function AcpmuxPane() {
showsNewTabRef.current,
);
}
// Only the streaming reply's text grew: its row draws it (streamingRow.ts); the pane,
// the composer and Session Info keep the snapshot they drew.
const drawn = drawnSnapshot.current;
const at = drawn ? streamedOnly(drawn, next) : -1;
if (drawn && at >= 0) {
streamingRow.set(next.rows[at]!, drawn.rows[at]!);
return;
}
streamingRow.clear();
drawnSnapshot.current = next;
setSnapshot((previous) => {
if (
next.canHandoff &&
Expand Down Expand Up @@ -13403,6 +13513,7 @@ function AcpmuxPane() {
key={chatPresentation}
topInset={soloUnder ? tabRow.height : 0}
rows={transcriptRows}
streams={!childRows}
earlier={!childRows ? snapshot.earlierPrompts : undefined}
promptCount={!childRows ? snapshot.promptCount : undefined}
loadThrough={
Expand Down
2 changes: 2 additions & 0 deletions webviews/src/agent-session/acpmux/Composer.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { acpmuxPerf } from "./perf";
import React, { useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
// Send's iMessage colors (--cmux-messages-blue); build-agent-pane-web.sh inlines the sheet.
Expand Down Expand Up @@ -492,6 +493,7 @@ export function Composer({
placeholder,
onOpenContext,
}: Props) {
acpmuxPerf.render("composer");
const t = useT();
const chrome = useComposerChrome();
// Off (a queued row's ⋯ menu, queueing.ts), what is sent mid-turn steers on every key.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Find in Chat's state (cx-ncc.86): it lives here, under the pane, so a keystroke in the find bar
// renders the transcript alone, not the composer and header beside it. The pane's page actions
// reach it through `findRef`.
import { setStreamingRowBlocked } from "../streamingRow";
import { type ReactNode, type RefObject, useEffect, useLayoutEffect } from "react";
import type { AcpmuxRow } from "../model";
import { type ChatFind, isFindRow, useChatFind } from "./chatFind";
Expand All @@ -24,6 +25,12 @@ export function ChatFindScope({
}) {
const find = useChatFind(rows);
findRef.current = find;
// While Find is open it searches the pane's rows, so a streamed chunk draws through the pane
// (streamingRow.ts) and new text is found as it comes.
useLayoutEffect(() => {
setStreamingRowBlocked(find.open, "find");
return () => setStreamingRowBlocked(false, "find");
}, [find.open]);
// A view without the transcript (New Tab, replay) has no Find; the page actions then do nothing.
useEffect(
() => () => {
Expand Down
131 changes: 76 additions & 55 deletions webviews/src/agent-session/acpmux/direct.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,8 @@ type Reply = {
};
type Notification = { method: string; params?: any };
type Listener = (snapshot: AcpmuxSnapshot) => void;
/// The snapshot's catalog: one empty array, so equal snapshots compare by identity.
const NO_CATALOG: AcpmuxSnapshot["catalog"] = Object.freeze([]) as unknown as AcpmuxSnapshot["catalog"];

/// A hint that a harness is likely next, so acpmux can warm its adapter (cmux-tui acpmux lane).
export const PREWARM_METHOD = "_acpmux/prewarm";
Expand Down Expand Up @@ -1745,6 +1747,7 @@ export class AcpmuxDirectClient {
private sessionEntriesOf?: { sessions: Session[]; entries: AcpmuxSessionEntry[] };
/// The snapshot summary's `usageStats`, kept while acpmux's numbers are the same (sameUsageStats).
private summaryUsage?: SessionUsageStats;
private publishedSummaryOf?: { summary: unknown; usage: unknown; value: AcpmuxSnapshot["summary"] };
/// The usage read in flight (refreshUsage).
private usageRead?: Promise<void>;
private selectedSessionId?: string;
Expand Down Expand Up @@ -3315,9 +3318,11 @@ export class AcpmuxDirectClient {
}

private receive(raw: string, stale = false): void {
this.wire.received(raw);
// A frame that is not a JSON object is dropped like one that is not JSON (`null` would throw at `in`).
const message = parseJsonObjectFrame(raw) as Reply | Notification | null;
// Parsed once: the wire log takes the parsed frame.
const parsed = parseJsonObjectFrame(raw);
this.wire.received(raw, parsed);
const message = parsed as Reply | Notification | null;
if (!message) return;
if ("id" in message && (typeof message.id === "number" || typeof message.id === "string")) {
const request = this.pending.get(message.id);
Expand Down Expand Up @@ -6723,6 +6728,73 @@ export class AcpmuxDirectClient {
return entries;
}

/// The snapshot's `summary`: the same object while the chat's summary and usage are the same
/// objects (every change assigns a new one; `effort` and `limits` are read from the summary), so
/// the pane compares it by identity. A streamed chunk changes neither (HOT PATH AUDIT area F,
/// row W1; review of #34004: comparing it by its JSON cost O(summary) per chunk).
private publishedSummary(
summary: Record<string, any> | undefined,
effort: any,
limits: ReturnType<typeof subsessionLimits>,
): AcpmuxSnapshot["summary"] {
const kept = this.publishedSummaryOf;
if (kept && kept.summary === summary && kept.usage === this.usage) return kept.value;
const value: AcpmuxSnapshot["summary"] = summary
? {
sessionId: summary.sessionId,
cwd: summary.cwd,
turnCount: summary.turnCount,
usage: this.usage,
contextBreakdown: contextBreakdownOf(summary.contextBreakdown),
usageStats: (this.summaryUsage = sameUsageStats(summary.usageStats, this.summaryUsage)),
host: text(summary.host),
machine: text(summary.machine),
peer: text(summary.peer),
hostKind: hostKind(summary.hostKind),
// acpmux's session detail names the folder's branch `gitBranch` (when the session started).
branch: text(summary.branch) ?? text(summary.gitBranch),
worktree: text(summary.worktree),
title: summary.title,
name: summary.name,
goal: taskGoal(summary.goal),
harness: summary.harness,
family: typeof summary.family === "string" ? summary.family : undefined,
agentInfo:
summary.agentInfo && typeof summary.agentInfo === "object" && !Array.isArray(summary.agentInfo)
? summary.agentInfo
: undefined,
authSource: text(summary.authSource),
baseUrl: text(summary.baseUrl),
route: text(summary.route),
settingsSources: summary.settingsSources,
usageLimits: usageLimitsOf(summary.usageLimits) ?? text(summary.usageLimits),
...(summary.usageLimitsApply === false ? { usageLimitsApply: false } : {}),
// acpmux reports `model: null` for a harness with no model (one known only from its config).
model: text(summary.model),
effort: effort?.currentValue,
// `audio` only while the picked model hears it (takesAudio).
promptCapabilities: summary.agentCapabilities?.promptCapabilities && {
...summary.agentCapabilities.promptCapabilities,
audio: takesAudio(summary.agentCapabilities.promptCapabilities, summary.configOptions),
},
steering: summary.steering === true,
backgroundsWork: summary.backgroundsWork === true,
queuePaused: summary.queuePaused === true,
...(text(summary.watching) ? { watching: text(summary.watching) } : {}),
...(Array.isArray(summary.mcpServers) ? { mcpServers: summary.mcpServers } : {}),
...(limits ? { subsessionLimits: limits } : {}),
status: summary.status,
enforcement: sessionEnforcement(summary.enforcement),
modes: summary.modes,
configOptions: summary.configOptions,
tools: chatTools(summary.tools),
toolsMutable: summary.toolsMutable === true,
}
: undefined;
this.publishedSummaryOf = { summary, usage: this.usage, value };
return value;
}

private emitNow(connection: string): void {
if (this.closed && !this.replayMode && !(this.fedMode && !this.disposed)) return;
if (this.snapshotHold) {
Expand Down Expand Up @@ -6771,58 +6843,7 @@ export class AcpmuxDirectClient {
peerStates: this.peerStates,
peerResources: this.peerResources,
...(this.forkedAtSeq !== undefined ? { forkedAtSeq: this.forkedAtSeq } : {}),
summary: summary
? {
sessionId: summary.sessionId,
cwd: summary.cwd,
turnCount: summary.turnCount,
usage: this.usage,
contextBreakdown: contextBreakdownOf(summary.contextBreakdown),
usageStats: (this.summaryUsage = sameUsageStats(summary.usageStats, this.summaryUsage)),
host: text(summary.host),
machine: text(summary.machine),
peer: text(summary.peer),
hostKind: hostKind(summary.hostKind),
// acpmux's session detail names the folder's branch `gitBranch` (when the session started).
branch: text(summary.branch) ?? text(summary.gitBranch),
worktree: text(summary.worktree),
title: summary.title,
name: summary.name,
goal: taskGoal(summary.goal),
harness: summary.harness,
family: typeof summary.family === "string" ? summary.family : undefined,
agentInfo:
summary.agentInfo && typeof summary.agentInfo === "object" && !Array.isArray(summary.agentInfo)
? summary.agentInfo
: undefined,
authSource: text(summary.authSource),
baseUrl: text(summary.baseUrl),
route: text(summary.route),
settingsSources: summary.settingsSources,
usageLimits: usageLimitsOf(summary.usageLimits) ?? text(summary.usageLimits),
...(summary.usageLimitsApply === false ? { usageLimitsApply: false } : {}),
// acpmux reports `model: null` for a harness with no model (one known only from its config).
model: text(summary.model),
effort: effort?.currentValue,
// `audio` only while the picked model hears it (takesAudio).
promptCapabilities: summary.agentCapabilities?.promptCapabilities && {
...summary.agentCapabilities.promptCapabilities,
audio: takesAudio(summary.agentCapabilities.promptCapabilities, summary.configOptions),
},
steering: summary.steering === true,
backgroundsWork: summary.backgroundsWork === true,
queuePaused: summary.queuePaused === true,
...(text(summary.watching) ? { watching: text(summary.watching) } : {}),
...(Array.isArray(summary.mcpServers) ? { mcpServers: summary.mcpServers } : {}),
...(limits ? { subsessionLimits: limits } : {}),
status: summary.status,
enforcement: sessionEnforcement(summary.enforcement),
modes: summary.modes,
configOptions: summary.configOptions,
tools: chatTools(summary.tools),
toolsMutable: summary.toolsMutable === true,
}
: undefined,
summary: this.publishedSummary(summary, effort, limits),
connection,
origin: this.origin,
...(this.listedSessions ? { listedSessions: true as const } : {}),
Expand Down Expand Up @@ -6873,7 +6894,7 @@ export class AcpmuxDirectClient {
: {}),
secret: this.pendingSecret,
asks: this.asks.list(),
catalog: [],
catalog: NO_CATALOG,
commands: this.commands,
canLoadOlder: this.seqHistoryLeft() || this.nativeBefore() !== undefined,
...(this.shownEarlierPrompts().length ? { earlierPrompts: this.shownEarlierPrompts() } : {}),
Expand Down
Loading
Loading