Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 90 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,15 @@ 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,
setStreamingRowBlocked,
streamingRow,
streamingRowBlocked,
textOnlyRow,
useStreamingRow,
} from "./streamingRow";
import { followTrace } from "./followTrace";
import { ScrollPacing } from "./pacing";
import { AdaptiveRenderRate, reportScrollPacing } from "./renderPacing";
Expand Down Expand Up @@ -2355,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 @@ -2830,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 @@ -2871,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 @@ -2963,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 @@ -6168,6 +6237,7 @@ const NO_PROJECTS: Project[] = [];
function AcpmuxPane({ initialChat }: { initialChat?: InitialChat }) {
// 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 @@ -6468,6 +6538,11 @@ function AcpmuxPane({ initialChat }: { initialChat?: InitialChat }) {
: 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 @@ -9501,6 +9576,9 @@ function AcpmuxPane({ initialChat }: { initialChat?: InitialChat }) {
void callNative(QUICK_MESSAGES.dismiss).catch(() => undefined);
});
const rowsRef = useRef<readonly AcpmuxRow[]>(initialChat?.snapshot.rows ?? []);
/// 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);
/// The newest snapshot, for host requests that read it (pane.context).
const snapshotRef = useRef<AcpmuxSnapshot | undefined>(initialChat?.snapshot);
/// The chat menu shows New side chat: the right-click menu then opens one from any turn that forks.
Expand Down Expand Up @@ -11042,6 +11120,16 @@ function AcpmuxPane({ initialChat }: { initialChat?: InitialChat }) {
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 @@ -13438,6 +13526,7 @@ function AcpmuxPane({ initialChat }: { initialChat?: InitialChat }) {
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 @@ -496,6 +497,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 @@ -3330,9 +3333,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 @@ -6738,6 +6743,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 @@ -6786,58 +6858,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 @@ -6888,7 +6909,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