From 5c41849e1e2a24f355d9d6e7aea6593d9dce94be Mon Sep 17 00:00:00 2001 From: Leo Li Date: Mon, 2 Nov 2026 05:57:37 +0000 Subject: [PATCH 01/10] cmux NEXT: build native Session Info section models Agent-Session: https://cmux.com/deeplink/agent?id=aid-dfff57bacb4f6c721f45 --- .../acpmux/session-info/SourcesSection.tsx | 45 +++- .../acpmux/session-info/native/changes.ts | 203 ++++++++++++++ .../acpmux/session-info/native/connectors.ts | 102 ++++++++ .../acpmux/session-info/native/context.ts | 178 +++++++++++++ .../acpmux/session-info/native/environment.ts | 226 ++++++++++++++++ .../acpmux/session-info/native/outputs.ts | 142 ++++++++++ .../acpmux/session-info/native/sources.ts | 102 ++++++++ .../acpmux/session-info/native/usage.ts | 247 ++++++++++++++++++ 8 files changed, 1235 insertions(+), 10 deletions(-) create mode 100644 webviews/src/agent-session/acpmux/session-info/native/changes.ts create mode 100644 webviews/src/agent-session/acpmux/session-info/native/connectors.ts create mode 100644 webviews/src/agent-session/acpmux/session-info/native/context.ts create mode 100644 webviews/src/agent-session/acpmux/session-info/native/environment.ts create mode 100644 webviews/src/agent-session/acpmux/session-info/native/outputs.ts create mode 100644 webviews/src/agent-session/acpmux/session-info/native/sources.ts create mode 100644 webviews/src/agent-session/acpmux/session-info/native/usage.ts diff --git a/webviews/src/agent-session/acpmux/session-info/SourcesSection.tsx b/webviews/src/agent-session/acpmux/session-info/SourcesSection.tsx index 5d88d82a9bc6..9bd5e5d9ab97 100644 --- a/webviews/src/agent-session/acpmux/session-info/SourcesSection.tsx +++ b/webviews/src/agent-session/acpmux/session-info/SourcesSection.tsx @@ -21,13 +21,18 @@ export async function openSourceFile( try { return await native("file.open", { path, where: "tab" }); } catch (error) { - if (!(error instanceof NativeError) || error.code !== "transport.path_invalid") throw error; + if ( + !(error instanceof NativeError) || + error.code !== "transport.path_invalid" + ) + throw error; return native("file.open", { path, where: "editor" }); } } -function scopedPath(path: string, cwd?: string): string | undefined { - if (!cwd?.startsWith("/") || path.startsWith("~") || path.includes("\0")) return; +export function scopedPath(path: string, cwd?: string): string | undefined { + if (!cwd?.startsWith("/") || path.startsWith("~") || path.includes("\0")) + return; const normalize = (value: string) => { const parts: string[] = []; for (const part of value.split("/")) @@ -37,7 +42,9 @@ function scopedPath(path: string, cwd?: string): string | undefined { }; const root = normalize(cwd), resolved = normalize(path.startsWith("/") ? path : `${root}/${path}`); - return resolved.startsWith(root === "/" ? root : `${root}/`) ? resolved : undefined; + return resolved.startsWith(root === "/" ? root : `${root}/`) + ? resolved + : undefined; } export function SourcesSection({ @@ -57,7 +64,11 @@ export function SourcesSection({ <> {folds && ( @@ -67,7 +78,9 @@ export function SourcesSection({ aria-expanded={expanded} onClick={() => onExpandedChange(!expanded)} > - {expanded ? t("summary.showFewer") : t("summary.viewAll", { n: sources.length })} + {expanded + ? t("summary.showFewer") + : t("summary.viewAll", { n: sources.length })} )} @@ -95,7 +108,8 @@ function SourceRow({ source, onOpenBeside, onOpenFile, cwd }: RowProps) { {source.label} ); - const path = source.kind === "file" ? scopedPath(source.label, cwd) : undefined; + const path = + source.kind === "file" ? scopedPath(source.label, cwd) : undefined; let target = content; if (path && onOpenFile) target = ( @@ -106,7 +120,13 @@ function SourceRow({ source, onOpenBeside, onOpenFile, cwd }: RowProps) { setFailure(undefined); Promise.resolve() .then(() => onOpenFile(path)) - .catch((error: unknown) => setFailure(error instanceof Error ? error.message : t("changes.openFailed"))); + .catch((error: unknown) => + setFailure( + error instanceof Error + ? error.message + : t("changes.openFailed"), + ), + ); }} > {content} @@ -118,7 +138,8 @@ function SourceRow({ source, onOpenBeside, onOpenFile, cwd }: RowProps) { className="acpmux-session-info-source__link" href={source.url} onClick={(event) => { - if (!onOpenBeside || event.metaKey || event.ctrlKey || event.shiftKey) return; + if (!onOpenBeside || event.metaKey || event.ctrlKey || event.shiftKey) + return; event.preventDefault(); onOpenBeside(source.url!); }} @@ -126,5 +147,9 @@ function SourceRow({ source, onOpenBeside, onOpenFile, cwd }: RowProps) { {content} ); - return
  • {failure ? {failure} : target}
  • ; + return ( +
  • + {failure ? {failure} : target} +
  • + ); } diff --git a/webviews/src/agent-session/acpmux/session-info/native/changes.ts b/webviews/src/agent-session/acpmux/session-info/native/changes.ts new file mode 100644 index 000000000000..04eaf431c60f --- /dev/null +++ b/webviews/src/agent-session/acpmux/session-info/native/changes.ts @@ -0,0 +1,203 @@ +// Session Info's Changes, drawn by the App (ChangesSection.tsx): every pull request the chat opened +// or changed, open ones first (changesOrder), five then Show more. A row is its title (its owner +// beside it), then `repo #N`, its lines and comments; its failing checks or its checks' state at +// the end; its state the glyph and tone alone. A click opens it beside the chat. +import type { StringKey, Translate } from "../../i18n"; +import type { SummaryPullRequest } from "../../summary/sessionSummary"; +import { + CHANGES_SHOWN, + changesOrder, + glyphState, + type SessionInfoPullRequestStatus, +} from "../ChangesSection"; +import type { PullRequestGlyphState } from "../PullRequestGlyph"; +import type { + NativeInfoActions, + NativeInfoRow, + NativeInfoSection, + NativeInfoTone, +} from "./model"; + +const SYMBOLS: Record = { + open: "arrow.triangle.pull", + draft: "arrow.triangle.pull", + conflicting: "arrow.triangle.pull", + merged: "arrow.triangle.merge", + closed: "xmark.circle", +}; +const TONES: Record = { + open: "done", + draft: "muted", + conflicting: "failed", + merged: "accent", + closed: "muted", +}; +/// The state glyph's label (ChangesSection's screen-reader label): the row's tooltip here. +const STATE_LABELS: Record = { + open: "sessionInfo.changes.open", + draft: "sessionInfo.changes.draft", + merged: "header.prBadge.merged", + closed: "header.prBadge.closed", + conflicting: "header.prConflicts", +}; +const CHECK_LABELS: Record = { + passing: "rail.checksPassing", + pending: "rail.checksPending", +}; + +export type ChangesInput = { + /// The pull requests Changes lists (ChatInspector's `visibleChanges`), in any order. + pullRequests: readonly SummaryPullRequest[]; + statuses: Readonly>; + /// Opens a pull request in the browser beside the chat. + onOpenBeside?: (url: string) => void; + onCopyText?: (text: string) => void; + onDismiss?: (url: string) => void; + /// The section is unfolded (`sections.changes`). + open: boolean; + /// Every row shows, past the first five (ChangesSection's own `all`, held by the caller). + expanded?: boolean; + onExpandedChange?: (expanded: boolean) => void; +}; + +export function changesSection( + input: ChangesInput, + actions: NativeInfoActions, + t: Translate, +): NativeInfoSection | undefined { + const { pullRequests, statuses, onOpenBeside, onExpandedChange } = input; + if (pullRequests.length === 0) return undefined; + const expanded = input.expanded === true; + const ordered = changesOrder(pullRequests, statuses); + const folds = ordered.length > CHANGES_SHOWN; + const shown = folds && !expanded ? ordered.slice(0, CHANGES_SHOWN) : ordered; + const rows = shown.map((pr): NativeInfoRow => { + const live = statuses[pr.url] ?? {}; + const state = glyphState(pr, live); + const ended = state === "merged" || state === "closed"; + const repoName = pr.repo.split("/").pop() ?? pr.repo; + const checks = ended ? undefined : live.checks; + const failing = ended + ? 0 + : (live.failing ?? (checks === "failing" ? -1 : 0)); + const onBase = Math.min( + Math.max(0, live.failingOnBase ?? 0), + Math.max(0, failing), + ); + const comments = live.comments ?? 0; + const line = [`${repoName} #${pr.number}`]; + if (live.additions !== undefined && live.deletions !== undefined) + line.push(`+${live.additions} -${live.deletions}`); + if (comments > 0) + line.push(t("sessionInfo.changes.comments", { count: comments })); + let trailing: string | undefined; + if (failing !== 0) { + trailing = + failing > 0 + ? t("sessionInfo.changes.failing", { count: failing }) + : t("rail.checksFailing"); + if (onBase > 0) + trailing += ` ${t("sessionInfo.changes.failingOnBase", { count: onBase })}`; + } else if (checks && CHECK_LABELS[checks]) + trailing = t(CHECK_LABELS[checks]!); + const reference = `${pr.repo}#${pr.number}`; + const menu = [ + ...(onOpenBeside + ? [ + { + label: t("sessionInfo.changes.openBeside"), + action: actions.add(`changes.openMenu:${pr.url}`, () => + onOpenBeside(pr.url), + ), + }, + ] + : []), + ...(input.onCopyText + ? [ + { + label: t("sessionInfo.changes.copyLink"), + action: actions.add(`changes.copyLink:${pr.url}`, () => + input.onCopyText!(pr.url), + ), + }, + { + label: t("sessionInfo.changes.copyReference"), + action: actions.add(`changes.copyReference:${pr.url}`, () => + input.onCopyText!(reference), + ), + }, + ] + : []), + ...(input.onDismiss + ? [ + { + label: t("prDock.dismiss", { number: pr.number }), + action: actions.add(`changes.dismiss:${pr.url}`, () => + input.onDismiss!(pr.url), + ), + }, + ] + : []), + ]; + const breakdown = live.breakdown; + const hoverDetail = breakdown + ? Object.entries(breakdown) + .map( + ([kind, totals]) => + `${t(`sessionInfo.changes.${kind}` as StringKey)} +${totals?.additions ?? 0} -${totals?.deletions ?? 0}`, + ) + .join(" · ") + : undefined; + return { + id: pr.url, + symbol: SYMBOLS[state], + title: (live.title ?? pr.title) || `#${pr.number}`, + ...(live.owner ? { detail: live.owner } : {}), + subtitle: line.join(" "), + ...(trailing ? { trailing } : {}), + tone: failing !== 0 ? "failed" : TONES[state], + ...(state === "conflicting" + ? { symbolColor: "red" } + : state === "merged" + ? { symbolColor: "green" } + : {}), + help: `${t(STATE_LABELS[state])} · ${pr.url}`, + hover: { + title: (live.title ?? pr.title) || `#${pr.number}`, + ...(hoverDetail ? { detail: hoverDetail } : {}), + }, + ...(onOpenBeside + ? { + action: actions.add(`changes.open:${pr.url}`, () => + onOpenBeside(pr.url), + ), + } + : {}), + ...(menu.length ? { menu } : {}), + }; + }); + return { + id: "changes", + group: "made", + title: t("header.changes"), + meta: String(pullRequests.length), + open: input.open, + foldable: true, + reorderable: true, + rows, + ...(folds && onExpandedChange + ? { + more: { + label: t( + expanded + ? "sessionInfo.changes.showLess" + : "sessionInfo.changes.showMore", + ), + action: actions.add("changes.more", () => + onExpandedChange(!expanded), + ), + }, + } + : {}), + }; +} diff --git a/webviews/src/agent-session/acpmux/session-info/native/connectors.ts b/webviews/src/agent-session/acpmux/session-info/native/connectors.ts new file mode 100644 index 000000000000..09f0327f7ecf --- /dev/null +++ b/webviews/src/agent-session/acpmux/session-info/native/connectors.ts @@ -0,0 +1,102 @@ +// Session Info's Connectors, drawn by the App (ConnectorsSection.tsx): one row per MCP server the +// chat's harness loaded with its state; a failed one has Reconnect where acpmux reconnects one. +import type { AcpmuxSnapshot } from "../../model"; +import type { Translate } from "../../i18n"; +import type { + NativeInfoActions, + NativeInfoRow, + NativeInfoSection, + NativeInfoTone, +} from "./model"; + +type McpServer = NonNullable< + NonNullable["mcpServers"] +>[number]; + +export const CONNECTORS_SYMBOL = "puzzlepiece.extension"; + +const TONES: Record = { + failed: "failed", + connected: "done", + pending: "waiting", +}; + +export type ConnectorsInput = { + /// `summary.mcpServers`. + servers: readonly McpServer[]; + /// `_acpmux/mcp/reconnect` of the server by its name. + onReconnect?: (server: string) => Promise; + /// The section is unfolded (`sections.connectors`). + open: boolean; + /// A Reconnect under way or failed, by server (ReconnectButton's own state, held by the caller). + reconnecting?: Readonly>; +}; + +export function connectorsSection( + input: ConnectorsInput, + actions: NativeInfoActions, + t: Translate, +): NativeInfoSection | undefined { + const { servers, onReconnect } = input; + if (servers.length === 0) return undefined; + const reconnect = servers.filter( + (server) => server.status === "failed", + ).length; + const rows = servers.map((server): NativeInfoRow => { + const row: NativeInfoRow = { + id: server.name, + symbol: CONNECTORS_SYMBOL, + title: server.name, + tone: TONES[server.status], + }; + if (server.status === "failed" && onReconnect) { + const state = input.reconnecting?.[server.name]; + return { + ...row, + buttons: [ + { + label: t( + state === "reconnecting" + ? "sessionInfo.connectors.reconnecting" + : state === "failed" + ? "sessionInfo.connectors.retry" + : "sessionInfo.connectors.reconnectAction", + ), + disabled: state === "reconnecting", + action: actions.add(`connectors.reconnect:${server.name}`, () => + onReconnect(server.name), + ), + }, + ], + }; + } + return { + ...row, + trailing: t( + server.status === "failed" + ? "sessionInfo.connectors.needsReconnect" + : server.status === "pending" + ? "rail.connectorPending" + : "composer.envConnected", + ), + }; + }); + return { + id: "connectors", + group: "about", + title: t("rail.connectors"), + symbol: CONNECTORS_SYMBOL, + // As Claude Code desktop's: the servers to reconnect when any failed, else the connected ones. + meta: + reconnect > 0 + ? t("sessionInfo.connectors.reconnect", { count: reconnect }) + : t("rail.connected", { + count: servers.filter((server) => server.status === "connected") + .length, + }), + open: input.open, + foldable: true, + reorderable: true, + rows, + }; +} diff --git a/webviews/src/agent-session/acpmux/session-info/native/context.ts b/webviews/src/agent-session/acpmux/session-info/native/context.ts new file mode 100644 index 000000000000..000660d8d3e8 --- /dev/null +++ b/webviews/src/agent-session/acpmux/session-info/native/context.ts @@ -0,0 +1,178 @@ +// Session Info's Context, drawn by the App (ChatInspector's `context`, ContextSection.tsx): the share +// of the window used beside the title with its meter, then the window's total and a row per part +// (Claude Code's /context breakdown when acpmux kept one, else used and free). A subagent's panel +// with no usage shows what it has spent (subagentProgress) as the title's value alone. +import type { PaneLanguage, Translate } from "../../i18n"; +import type { Subagent } from "../../subagents/subagentFold"; +import { subagentProgress } from "../../subagents/subagentProgress"; +import { contextBreakdown, contextParts } from "../ContextSection"; +import type { + NativeInfoActions, + NativeInfoRow, + NativeInfoSection, +} from "./model"; + +export const CONTEXT_SYMBOL = "gauge.with.dots.needle.33percent"; + +const LIST_LABEL = { + mcpTools: "sessionInfo.context.mcpTools", + memoryFiles: "sessionInfo.context.memoryFiles", + skills: "sessionInfo.context.skills", +} as const; + +export type ContextInput = { + /// `summary.usage`. + usage?: { used: number; size: number }; + /// Claude Code's /context, as acpmux kept it (`summary.contextBreakdown`). + breakdown?: unknown; + /// The subagent this panel lists: its progress stands in when no usage is known. + agent?: Subagent; + language: PaneLanguage; + /// The section is unfolded (`sections.context`). + open: boolean; + /// The breakdown's folds that are open (ContextBreakdownView's own state, held by the caller). + openLists?: ReadonlySet; + /// Opens or closes one of the breakdown's folds; without it the folds show their counts only. + onToggleList?: (id: "mcpTools" | "memoryFiles" | "skills") => void; + onCompact?: () => void; +}; + +export function contextSection( + input: ContextInput, + actions: NativeInfoActions, + t: Translate, +): NativeInfoSection | undefined { + const { usage } = input; + const base = { + id: "context", + group: "about", + title: t("rail.context"), + symbol: CONTEXT_SYMBOL, + } as const; + if (usage === undefined || usage.size <= 0) { + const progress = input.agent + ? subagentProgress(t, input.language, input.agent) + : []; + if (progress.length === 0) return undefined; + return { + ...base, + meta: progress.join(" · "), + open: true, + foldable: false, + rows: [], + }; + } + // As the composer's context ring reads it (ComposerPickers ContextRing). + const fraction = Math.min(1, Math.max(0, usage.used / usage.size)); + const percent = Math.round(fraction * 100); + const tokens = new Intl.NumberFormat(undefined, { + notation: "compact", + maximumFractionDigits: 1, + }); + const share = new Intl.NumberFormat(undefined, { + style: "percent", + maximumFractionDigits: 1, + }); + const rows: NativeInfoRow[] = [ + { + id: "total", + title: t("context.percent", { percent }), + trailing: `${tokens.format(usage.used)} / ${tokens.format(usage.size)}`, + ...(input.onCompact + ? { + buttons: [ + { + label: t("sessionInfo.context.compactSession"), + action: actions.add("context.compact", input.onCompact), + }, + ], + } + : {}), + }, + ]; + const breakdown = contextBreakdown(input.breakdown); + let segments: { share: number; color: string }[] | undefined; + if (breakdown) { + segments = breakdown.rows + .filter( + (row) => + (row.kind === "used" || row.kind === "buffer") && + (row.share ?? 0) > 0, + ) + .map((row) => ({ + share: row.share ?? 0, + color: row.kind === "buffer" ? "buffer" : `context${row.tone ?? 0}`, + })); + breakdown.rows.forEach((row, index) => { + const drawn = + (row.kind === "used" || row.kind === "buffer") && (row.share ?? 0) > 0; + rows.push({ + id: `category:${index}:${row.name}`, + title: row.name, + trailing: `${tokens.format(row.tokens)} · ${row.share === undefined ? "—" : share.format(row.share)}`, + tone: row.kind === "used" ? undefined : "muted", + ...(row.kind === "used" && row.tone !== undefined + ? { symbolColor: `context${row.tone}` } + : {}), + ...(drawn ? { meter: row.share } : {}), + }); + }); + for (const list of breakdown.lists) { + const open = input.openLists?.has(list.id) === true; + const toggle = input.onToggleList; + rows.push({ + id: `list:${list.id}`, + symbol: open ? "chevron.down" : "chevron.right", + title: t(LIST_LABEL[list.id]), + trailing: String(list.items.length), + ...(toggle + ? { + action: actions.add(`context.toggle:${list.id}`, () => + toggle(list.id), + ), + } + : {}), + }); + if (open) + list.items.forEach((item, index) => + rows.push({ + id: `list:${list.id}:${index}:${item.name}`, + title: item.name, + ...(item.detail + ? { + detail: item.detail, + help: item.detail, + hover: { title: item.name, detail: item.detail }, + } + : {}), + trailing: tokens.format(item.tokens), + indent: 1, + }), + ); + } + } else { + for (const part of contextParts(usage)) + rows.push({ + id: part.id, + title: t( + part.id === "used" + ? "sessionInfo.context.used" + : "sessionInfo.context.free", + ), + trailing: `${tokens.format(part.tokens)} · ${share.format(part.share)}`, + ...(part.id === "used" + ? { meter: part.share } + : { tone: "muted" as const }), + }); + } + return { + ...base, + meta: t("rail.percent", { percent }), + meter: percent / 100, + ...(segments ? { segments } : {}), + open: input.open, + foldable: true, + reorderable: true, + rows, + }; +} diff --git a/webviews/src/agent-session/acpmux/session-info/native/environment.ts b/webviews/src/agent-session/acpmux/session-info/native/environment.ts new file mode 100644 index 000000000000..52ec735c83eb --- /dev/null +++ b/webviews/src/agent-session/acpmux/session-info/native/environment.ts @@ -0,0 +1,226 @@ +// Session Info's Environment, drawn by the App (EnvironmentSection.tsx): where the chat runs, +// its project, repository, branch, Remote Control, and active access grants. +// It heads the panel bare: rows only, always open. +import type { Translate } from "../../i18n"; +import type { AcpmuxSnapshot } from "../../model"; +import { homePath } from "../../sessionList"; +import { chatProject } from "../chatFolders"; +import { machineHue } from "../../machineColor"; +import { machineOf, type ProjectRowLook } from "../EnvironmentSection"; +import type { + AttachedCloudRepo, + PendingCloudRepo, +} from "../CloudRepositoryPicker"; +import type { NativeInfoRect } from "./model"; +import type { + NativeInfoActions, + NativeInfoRow, + NativeInfoSection, +} from "./model"; + +type Summary = NonNullable; + +const MACHINE_SYMBOLS = { + local: "desktopcomputer", + ssh: "server.rack", + cloud: "cloud", +} as const; + +export type EnvironmentInput = { + summary?: Summary; + machineName?: string; + peerKinds?: Readonly>; + /// The chat folder's GitHub repository, `owner/name`. + repository?: string; + remoteControl?: { on: boolean; set: (on: boolean) => void }; + grants?: readonly { id: string; label: string; onRemove: () => void }[]; + cloudRepositoryAvailable?: boolean; + attachedCloudRepos?: readonly AttachedCloudRepo[]; + pendingCloudRepos?: readonly PendingCloudRepo[]; + onOpenCloudEnvironment?: () => void; + onAddCloudRepository?: (rect?: NativeInfoRect) => void; + onOpenFolder?: (path: string) => void; + projectLook?: ProjectRowLook; + /// Opens the repository's and the branch's GitHub links (the web rows' `href`). + onOpenUrl?: (url: string) => void; +}; + +export function environmentSection( + input: EnvironmentInput, + actions: NativeInfoActions, + t: Translate, +): NativeInfoSection | undefined { + const { + summary, + repository, + remoteControl, + onOpenFolder, + onOpenUrl, + projectLook = "name", + grants = [], + } = input; + const machine = machineOf(summary, input.machineName, input.peerKinds); + const branch = summary?.branch; + const project = chatProject(summary, repository); + const repositoryUrl = repository + ? `https://github.com/${repository}` + : undefined; + const branchUrl = + repositoryUrl && branch + ? `${repositoryUrl}/tree/${branch.split("/").map(encodeURIComponent).join("/")}` + : undefined; + const rows: NativeInfoRow[] = []; + rows.push({ + id: "machine", + symbol: MACHINE_SYMBOLS[machine.kind], + title: machine.name ?? "—", + trailing: t( + machine.kind === "local" + ? "composer.envLocal" + : machine.kind === "ssh" + ? "composer.envSSH" + : "composer.cloud", + ), + ...(machine.kind !== "local" && machine.name + ? { symbolColor: machineHue(machine.name) } + : {}), + }); + const cloudSession = machine.kind === "cloud" && summary?.sessionId; + if (cloudSession && input.cloudRepositoryAvailable) { + rows.push({ + id: "cloud-default", + symbol: "cloud", + title: t("cloudRepository.default"), + ...(input.onOpenCloudEnvironment + ? { + action: actions.add( + "environment.cloudSettings", + input.onOpenCloudEnvironment, + ), + } + : {}), + }); + if (input.onAddCloudRepository) { + rows.push({ + id: "cloud-add-repository", + symbol: "folder.badge.plus", + title: t("cloudRepository.add"), + action: actions.add( + "environment.cloudAddRepository", + input.onAddCloudRepository, + ), + }); + } + for (const repo of input.attachedCloudRepos ?? []) { + if (repo.repo === repository) continue; + rows.push({ + id: `cloud-repository:${repo.repo}`, + symbol: "chevron.left.forwardslash.chevron.right", + title: repo.repo, + detail: repo.folder, + }); + } + for (const repo of input.pendingCloudRepos ?? []) { + rows.push({ + id: `cloud-pending:${repo.repo}`, + symbol: "arrow.triangle.branch", + title: repo.repo, + trailing: repo.phase ?? repo.state ?? t("cloudRepository.pending"), + tone: "waiting", + ...(repo.progress?.total_objects + ? { + subtitle: `${repo.progress.objects ?? 0}/${repo.progress.total_objects}`, + } + : {}), + }); + } + } + if (project) { + const parent = homePath( + project.path.slice(0, project.path.lastIndexOf("/")) || "/", + ); + const detail = + projectLook === "parent" && parent + ? parent + : projectLook === "branch" && branch + ? branch + : undefined; + rows.push({ + id: "project", + symbol: project.worktree ? "rectangle.stack" : "folder", + title: project.name, + ...(detail ? { detail } : {}), + help: project.path, + ...(onOpenFolder + ? { + action: actions.add("environment.openFolder", () => + onOpenFolder(project.path), + ), + } + : {}), + }); + } + if (repository) + rows.push({ + id: "repository", + symbol: "chevron.left.forwardslash.chevron.right", + title: repository, + ...(onOpenUrl && repositoryUrl + ? { + action: actions.add("environment.openRepository", () => + onOpenUrl(repositoryUrl), + ), + } + : {}), + }); + if (branch && !(projectLook === "branch" && project)) + rows.push({ + id: "branch", + symbol: "arrow.triangle.branch", + title: branch, + ...(onOpenUrl && branchUrl + ? { + action: actions.add("environment.openBranch", () => + onOpenUrl(branchUrl), + ), + } + : {}), + }); + if (remoteControl) { + const label = t("sessionInfo.environment.remoteControl"); + // The web's switch: a checkbox button that flips it. + const toggle = actions.add("environment.remoteControl", () => + remoteControl.set(!remoteControl.on), + ); + rows.push({ + id: "remoteControl", + symbol: "cursorarrow.click", + title: label, + tone: remoteControl.on ? "accent" : undefined, + action: toggle, + toggle: remoteControl.on, + }); + } + for (const grant of grants) { + const revoke = actions.add( + `environment.revoke:${grant.id}`, + grant.onRemove, + ); + rows.push({ + id: `grant:${grant.id}`, + symbol: "lock", + title: grant.label, + buttons: [{ label: t("permission.revoke"), action: revoke }], + }); + } + return { + id: "environment", + group: "about", + title: t("rail.environment"), + bare: true, + open: true, + foldable: false, + reorderable: true, + rows, + }; +} diff --git a/webviews/src/agent-session/acpmux/session-info/native/outputs.ts b/webviews/src/agent-session/acpmux/session-info/native/outputs.ts new file mode 100644 index 000000000000..479ad1b25890 --- /dev/null +++ b/webviews/src/agent-session/acpmux/session-info/native/outputs.ts @@ -0,0 +1,142 @@ +// Session Info's Outputs, drawn by the App (OutputsSection.tsx): the chat's documents (open in the +// side panel), its gallery, its files (each opens its diff) and the web links its replies gave. +import type { Translate } from "../../i18n"; +import type { Artifact } from "../../conversation/artifactPreview"; +import type { TurnFile } from "../../diff"; +import { + OUTPUTS_SHOWN, + type OutputArtifact, + type OutputLink, +} from "../OutputsSection"; +import type { + NativeInfoActions, + NativeInfoRow, + NativeInfoSection, +} from "./model"; + +export type OutputsInput = { + files: readonly TurnFile[]; + links: readonly OutputLink[]; + /// How many images and renders the gallery holds. + artifacts: number; + /// The documents the chat produced (`outputArtifacts`). + documents?: readonly OutputArtifact[]; + onOpenFile?: (path: string) => void; + onOpenArtifacts?: () => void; + onOpenDocument?: (artifact: Artifact) => void; + /// Opens a link (the web rows' `href`). + onOpenUrl?: (url: string) => void; + /// The section is unfolded (`sections.outputs`). + open: boolean; + expanded?: boolean; + onExpandedChange?: (expanded: boolean) => void; +}; + +export function outputsSection( + input: OutputsInput, + actions: NativeInfoActions, + t: Translate, +): NativeInfoSection | undefined { + const { + files, + links, + artifacts, + documents = [], + onOpenFile, + onOpenArtifacts, + onOpenDocument, + onOpenUrl, + } = input; + // As ChatInspector shows it: any file, link, gallery item, or document it can open. + if ( + files.length + + links.length + + artifacts + + (onOpenDocument ? documents.length : 0) === + 0 + ) + return undefined; + const rows: NativeInfoRow[] = []; + if (onOpenDocument) + for (const document of documents) + rows.push({ + id: `document:${document.key}`, + symbol: + document.artifact.kind === "markdown" + ? "doc.text" + : "chevron.left.forwardslash.chevron.right", + title: document.title ?? t("render.untitled"), + trailing: t("sessionInfo.outputs.artifact"), + action: actions.add(`outputs.openDocument:${document.key}`, () => + onOpenDocument(document.artifact), + ), + }); + if (artifacts > 0 && onOpenArtifacts) + rows.push({ + id: "gallery", + symbol: "square.grid.2x2", + title: t("summary.gallery"), + trailing: String(artifacts), + action: actions.add("outputs.openGallery", onOpenArtifacts), + }); + for (const file of files) { + const cut = file.displayPath.lastIndexOf("/"); + const folder = cut > 0 ? file.displayPath.slice(0, cut) : undefined; + rows.push({ + id: `file:${file.path}`, + symbol: file.created ? "doc.badge.plus" : "doc.text", + title: cut >= 0 ? file.displayPath.slice(cut + 1) : file.displayPath, + ...(folder ? { detail: folder } : {}), + ...(file.binary + ? {} + : { trailing: `+${file.additions} -${file.deletions}` }), + help: file.path, + ...(onOpenFile + ? { + action: actions.add(`outputs.openFile:${file.path}`, () => + onOpenFile(file.path), + ), + } + : {}), + }); + } + for (const link of links) + rows.push({ + id: `link:${link.url}`, + symbol: "link", + title: link.label, + trailing: t("sessionInfo.outputs.link"), + help: link.url, + ...(onOpenUrl + ? { + action: actions.add(`outputs.openLink:${link.url}`, () => + onOpenUrl(link.url), + ), + } + : {}), + }); + const expanded = input.expanded === true; + const folds = rows.length > OUTPUTS_SHOWN; + const shown = folds && !expanded ? rows.slice(0, OUTPUTS_SHOWN) : rows; + return { + id: "outputs", + group: "made", + title: t("rail.outputs"), + open: input.open, + foldable: true, + reorderable: true, + rows: shown, + ...(folds && input.onExpandedChange + ? { + more: { + label: expanded + ? t("summary.showFewer") + : t("summary.viewAll", { n: rows.length }), + action: actions.add("outputs.more", () => + input.onExpandedChange!(!expanded), + ), + }, + } + : {}), + }; +} diff --git a/webviews/src/agent-session/acpmux/session-info/native/sources.ts b/webviews/src/agent-session/acpmux/session-info/native/sources.ts new file mode 100644 index 000000000000..52823f0fdf59 --- /dev/null +++ b/webviews/src/agent-session/acpmux/session-info/native/sources.ts @@ -0,0 +1,102 @@ +// Session Info's Sources, drawn by the App (SourcesSection.tsx): what the chat read, five then View +// all N. A file in the chat's folder opens in a tab; a link opens beside the chat. +import type { Translate } from "../../i18n"; +import type { + SummarySource, + SummarySourceKind, +} from "../../summary/sessionSummary"; +import { SOURCES_SHOWN, scopedPath } from "../SourcesSection"; +import type { + NativeInfoActions, + NativeInfoRow, + NativeInfoSection, +} from "./model"; + +const SYMBOLS: Record = { + file: "doc.text", + mcp: "puzzlepiece.extension", + pullRequest: "arrow.triangle.pull", + issue: "arrow.triangle.pull", + url: "link", +}; + +export type SourcesInput = { + sources: readonly SummarySource[]; + /// The chat's folder: a file outside it does not open. + cwd?: string; + onOpenFile?: (path: string) => unknown; + onOpenBeside?: (url: string) => void; + failures?: Readonly>; + /// Every source shows (`inspectorState.sourcesAll`). + expanded: boolean; + onExpandedChange: (expanded: boolean) => void; + /// The section is unfolded (`sections.sources`). + open: boolean; +}; + +export function sourcesSection( + input: SourcesInput, + actions: NativeInfoActions, + t: Translate, +): NativeInfoSection | undefined { + const { sources, cwd, onOpenFile, onOpenBeside, expanded, onExpandedChange } = + input; + if (sources.length === 0) return undefined; + const folds = sources.length > SOURCES_SHOWN; + const shown = folds && !expanded ? sources.slice(0, SOURCES_SHOWN) : sources; + const rows = shown.map((source): NativeInfoRow => { + const path = + source.kind === "file" ? scopedPath(source.label, cwd) : undefined; + const failure = path ? input.failures?.[path] : undefined; + const row: NativeInfoRow = { + id: `${source.kind}:${source.url ?? source.label}`, + symbol: SYMBOLS[source.kind ?? "url"], + title: source.label, + ...(failure + ? { subtitle: failure, tone: "failed" as const, help: failure } + : {}), + }; + if (path && onOpenFile) + return { + ...row, + ...(failure ? {} : { help: path }), + action: actions.add(`sources.openFile:${path}`, () => onOpenFile(path)), + }; + const url = source.url; + if (source.kind !== "file" && url) + return { + ...row, + help: url, + ...(onOpenBeside + ? { + action: actions.add(`sources.open:${url}`, () => + onOpenBeside(url), + ), + } + : {}), + }; + return row; + }); + return { + id: "sources", + group: "made", + title: t("summary.sources"), + meta: String(sources.length), + open: input.open, + foldable: true, + reorderable: true, + rows, + ...(folds + ? { + more: { + label: expanded + ? t("summary.showFewer") + : t("summary.viewAll", { n: sources.length }), + action: actions.add("sources.more", () => + onExpandedChange(!expanded), + ), + }, + } + : {}), + }; +} diff --git a/webviews/src/agent-session/acpmux/session-info/native/usage.ts b/webviews/src/agent-session/acpmux/session-info/native/usage.ts new file mode 100644 index 000000000000..8c30fa63ed44 --- /dev/null +++ b/webviews/src/agent-session/acpmux/session-info/native/usage.ts @@ -0,0 +1,247 @@ +// Session Info's Usage model, derived from the same usageStats source as UsageSection.tsx. +// The page formats and orders values; the App only draws rows and forwards actions. +import type { Translate } from "../../i18n"; +import type { AcpmuxSessionEntry } from "../../sessionList"; +import { + byUsage, + type SessionUsageStats, + UNKNOWN, + usageCost, + usageTokens, +} from "../../usageStats"; +import type { + NativeInfoActions, + NativeInfoRow, + NativeInfoSection, +} from "./model"; + +export const USAGE_SYMBOL = "waveform.path"; +export const USAGE_SHOWN = 5; + +type OwnAction = "compact" | "subagents"; + +const known = (value: T | undefined, format: (value: T) => string) => + value === undefined ? UNKNOWN : format(value); + +function ownRows( + stats: SessionUsageStats, + t: Translate, + locale: string | undefined, + actions: NativeInfoActions, + onCompact?: () => void, + onShowSubagents?: () => void, +): NativeInfoRow[] { + const tokens = (value: number) => usageTokens(value, locale); + const subagents = [ + stats.subagentsLive, + stats.subagents1h, + stats.subagents24h, + ]; + const rows: Array<{ + id: string; + name: string; + value: string; + action?: OwnAction; + }> = [ + { + id: "cost", + name: t("sessionInfo.usage.cost24h"), + value: known(stats.costUsd24h, (cost) => usageCost(cost, locale)), + }, + { + id: "tokens", + name: t("sessionInfo.usage.tokens24h"), + value: known(stats.tokens24h, tokens), + }, + { + id: "context", + name: t("sessionInfo.usage.perCall"), + value: + stats.contextTokens !== undefined && stats.contextWindow + ? `${tokens(stats.contextTokens)} / ${tokens(stats.contextWindow)}` + : known(stats.contextTokens, tokens), + ...(onCompact ? { action: "compact" as const } : {}), + }, + { + id: "cap", + name: t("sessionInfo.usage.autocompact"), + value: + stats.autocompact === "none" + ? t("sessionInfo.usage.noCap") + : stats.autocompact === "capped" + ? known(stats.autocompactCap, tokens) + : UNKNOWN, + }, + { + id: "compactions", + name: t("sessionInfo.usage.compactions24h"), + value: known(stats.compactions24h, String), + }, + { + id: "subagents", + name: t("sessionInfo.usage.subagents"), + value: subagents.every((count) => count === undefined) + ? UNKNOWN + : t("sessionInfo.usage.subagentsValue", { + live: known(stats.subagentsLive, String), + hour: known(stats.subagents1h, String), + day: known(stats.subagents24h, String), + }), + ...(onShowSubagents ? { action: "subagents" as const } : {}), + }, + ]; + if (stats.launcher) + rows.push({ + id: "launcher", + name: t("sessionInfo.usage.launcher"), + value: stats.launcher, + }); + return rows.map(({ id, name, value, action }): NativeInfoRow => ({ + id: `own:${id}`, + title: name, + trailing: value, + ...(action === "compact" && onCompact + ? { + buttons: [ + { + label: t("context.compact"), + action: actions.add("usage.compact", onCompact), + }, + ], + } + : {}), + ...(action === "subagents" && onShowSubagents + ? { + buttons: [ + { + label: t("sessionInfo.usage.showSubagents"), + action: actions.add("usage.subagents", onShowSubagents), + }, + ], + } + : {}), + })); +} + +export type UsageInput = { + own?: SessionUsageStats; + sessions: readonly AcpmuxSessionEntry[]; + chatId?: string; + onOpen?: (sessionId: string) => void; + onCompact?: () => void; + onShowSubagents?: () => void; + locale?: string; + sort?: "cost" | "context"; + onSortChange?: (sort: "cost" | "context") => void; + expanded?: boolean; + onExpandedChange?: (expanded: boolean) => void; + open: boolean; +}; + +export function usageSection( + input: UsageInput, + actions: NativeInfoActions, + t: Translate, +): NativeInfoSection | undefined { + const ranked = byUsage(input.sessions, input.sort ?? "cost"); + if (!input.own && ranked.length === 0) return undefined; + const expanded = input.expanded === true; + const sort = input.sort ?? "cost"; + const folds = ranked.length > USAGE_SHOWN; + const shown = folds && !expanded ? ranked.slice(0, USAGE_SHOWN) : ranked; + const rows: NativeInfoRow[] = []; + if (input.onSortChange) { + rows.push({ + id: "sort", + title: t("sessionInfo.usage.sortBy"), + buttons: [ + { + label: t("sessionInfo.usage.sortCost"), + action: actions.add("usage.sort:cost", () => + input.onSortChange!("cost"), + ), + disabled: sort === "cost", + }, + { + label: t("sessionInfo.usage.sortContext"), + action: actions.add("usage.sort:context", () => + input.onSortChange!("context"), + ), + disabled: sort === "context", + }, + ], + }); + } + if (input.own) + rows.push( + ...ownRows( + input.own, + t, + input.locale, + actions, + input.onCompact, + input.onShowSubagents, + ), + ); + for (const entry of shown) { + const stats = entry.usageStats; + if (!stats) continue; + const place = entry.peer ?? entry.host; + const named = + entry.displayTitle || entry.title || entry.name || entry.sessionId; + const title = + entry.peer && named.startsWith(`${entry.peer}/`) + ? named.slice(entry.peer.length + 1) + : named; + const value = sort === "context" ? stats.contextTokens : stats.costUsd24h; + rows.push({ + id: `session:${entry.sessionId}`, + symbol: "bubble.left", + title, + ...(place ? { detail: place } : {}), + trailing: + value === undefined + ? UNKNOWN + : sort === "context" + ? usageTokens(value, input.locale) + : usageCost(value, input.locale), + ...(entry.sessionId !== input.chatId && input.onOpen + ? { + action: actions.add(`usage.open:${entry.sessionId}`, () => + input.onOpen!(entry.sessionId), + ), + } + : {}), + }); + } + return { + id: "usage", + group: "about", + title: t("sessionInfo.usage"), + symbol: USAGE_SYMBOL, + meta: input.own + ? t("sessionInfo.usage.cost24hValue", { + cost: + input.own.costUsd24h === undefined + ? UNKNOWN + : usageCost(input.own.costUsd24h, input.locale), + }) + : undefined, + open: input.open, + foldable: true, + reorderable: true, + rows, + ...(folds && input.onExpandedChange + ? { + more: { + label: expanded + ? t("summary.showFewer") + : t("summary.viewAll", { n: ranked.length }), + action: actions.add("usage.more", () => + input.onExpandedChange!(!expanded), + ), + }, + } + : {}), + }; +} From 2dee6ec89d494a0991ea26bedb1e12dce49ae149 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Mon, 2 Nov 2026 06:19:01 +0000 Subject: [PATCH 02/10] cmux NEXT: align native Session Info section models Agent-Session: https://cmux.com/deeplink/agent?id=aid-dfff57bacb4f6c721f45 --- .../acpmux/session-info/SourcesSection.tsx | 43 +--- .../acpmux/session-info/native/changes.ts | 84 +++---- .../acpmux/session-info/native/connectors.ts | 22 +- .../acpmux/session-info/native/context.ts | 42 +--- .../acpmux/session-info/native/environment.ts | 76 ++----- .../acpmux/session-info/native/host.ts | 16 +- .../acpmux/session-info/native/model.ts | 29 ++- .../acpmux/session-info/native/outputs.ts | 63 +----- .../acpmux/session-info/native/sources.ts | 37 +--- .../acpmux/session-info/native/usage.ts | 104 ++++----- .../test/native-session-info-bridge.test.ts | 18 ++ .../test/native-session-info-builders.test.ts | 95 ++++++++ .../test/native-session-info-sections.test.ts | 209 ++++++++++++++++++ 13 files changed, 492 insertions(+), 346 deletions(-) create mode 100644 webviews/test/native-session-info-builders.test.ts create mode 100644 webviews/test/native-session-info-sections.test.ts diff --git a/webviews/src/agent-session/acpmux/session-info/SourcesSection.tsx b/webviews/src/agent-session/acpmux/session-info/SourcesSection.tsx index 9bd5e5d9ab97..210f7a69f6e6 100644 --- a/webviews/src/agent-session/acpmux/session-info/SourcesSection.tsx +++ b/webviews/src/agent-session/acpmux/session-info/SourcesSection.tsx @@ -21,18 +21,13 @@ export async function openSourceFile( try { return await native("file.open", { path, where: "tab" }); } catch (error) { - if ( - !(error instanceof NativeError) || - error.code !== "transport.path_invalid" - ) - throw error; + if (!(error instanceof NativeError) || error.code !== "transport.path_invalid") throw error; return native("file.open", { path, where: "editor" }); } } export function scopedPath(path: string, cwd?: string): string | undefined { - if (!cwd?.startsWith("/") || path.startsWith("~") || path.includes("\0")) - return; + if (!cwd?.startsWith("/") || path.startsWith("~") || path.includes("\0")) return; const normalize = (value: string) => { const parts: string[] = []; for (const part of value.split("/")) @@ -42,9 +37,7 @@ export function scopedPath(path: string, cwd?: string): string | undefined { }; const root = normalize(cwd), resolved = normalize(path.startsWith("/") ? path : `${root}/${path}`); - return resolved.startsWith(root === "/" ? root : `${root}/`) - ? resolved - : undefined; + return resolved.startsWith(root === "/" ? root : `${root}/`) ? resolved : undefined; } export function SourcesSection({ @@ -64,11 +57,7 @@ export function SourcesSection({ <>
      {shown.map((source) => ( - + ))}
    {folds && ( @@ -78,9 +67,7 @@ export function SourcesSection({ aria-expanded={expanded} onClick={() => onExpandedChange(!expanded)} > - {expanded - ? t("summary.showFewer") - : t("summary.viewAll", { n: sources.length })} + {expanded ? t("summary.showFewer") : t("summary.viewAll", { n: sources.length })} )} @@ -108,8 +95,7 @@ function SourceRow({ source, onOpenBeside, onOpenFile, cwd }: RowProps) { {source.label} ); - const path = - source.kind === "file" ? scopedPath(source.label, cwd) : undefined; + const path = source.kind === "file" ? scopedPath(source.label, cwd) : undefined; let target = content; if (path && onOpenFile) target = ( @@ -120,13 +106,7 @@ function SourceRow({ source, onOpenBeside, onOpenFile, cwd }: RowProps) { setFailure(undefined); Promise.resolve() .then(() => onOpenFile(path)) - .catch((error: unknown) => - setFailure( - error instanceof Error - ? error.message - : t("changes.openFailed"), - ), - ); + .catch((error: unknown) => setFailure(error instanceof Error ? error.message : t("changes.openFailed"))); }} > {content} @@ -138,8 +118,7 @@ function SourceRow({ source, onOpenBeside, onOpenFile, cwd }: RowProps) { className="acpmux-session-info-source__link" href={source.url} onClick={(event) => { - if (!onOpenBeside || event.metaKey || event.ctrlKey || event.shiftKey) - return; + if (!onOpenBeside || event.metaKey || event.ctrlKey || event.shiftKey) return; event.preventDefault(); onOpenBeside(source.url!); }} @@ -147,9 +126,5 @@ function SourceRow({ source, onOpenBeside, onOpenFile, cwd }: RowProps) { {content} ); - return ( -
  • - {failure ? {failure} : target} -
  • - ); + return
  • {failure ? {failure} : target}
  • ; } diff --git a/webviews/src/agent-session/acpmux/session-info/native/changes.ts b/webviews/src/agent-session/acpmux/session-info/native/changes.ts index 04eaf431c60f..a0e857c5f8f6 100644 --- a/webviews/src/agent-session/acpmux/session-info/native/changes.ts +++ b/webviews/src/agent-session/acpmux/session-info/native/changes.ts @@ -4,19 +4,9 @@ // the end; its state the glyph and tone alone. A click opens it beside the chat. import type { StringKey, Translate } from "../../i18n"; import type { SummaryPullRequest } from "../../summary/sessionSummary"; -import { - CHANGES_SHOWN, - changesOrder, - glyphState, - type SessionInfoPullRequestStatus, -} from "../ChangesSection"; +import { CHANGES_SHOWN, changesOrder, glyphState, type SessionInfoPullRequestStatus } from "../ChangesSection"; import type { PullRequestGlyphState } from "../PullRequestGlyph"; -import type { - NativeInfoActions, - NativeInfoRow, - NativeInfoSection, - NativeInfoTone, -} from "./model"; +import type { NativeInfoActions, NativeInfoRow, NativeInfoSection, NativeInfoTone } from "./model"; const SYMBOLS: Record = { open: "arrow.triangle.pull", @@ -32,6 +22,13 @@ const TONES: Record = { merged: "accent", closed: "muted", }; +const SYMBOL_COLORS: Record = { + open: "green", + merged: "purple", + draft: "yellow", + closed: "red", + conflicting: "red", +}; /// The state glyph's label (ChangesSection's screen-reader label): the row's tooltip here. const STATE_LABELS: Record = { open: "sessionInfo.changes.open", @@ -46,7 +43,7 @@ const CHECK_LABELS: Record = { }; export type ChangesInput = { - /// The pull requests Changes lists (ChatInspector's `visibleChanges`), in any order. + /// The pull requests Changes lists (SessionInfo's `visibleChanges`), in any order. pullRequests: readonly SummaryPullRequest[]; statuses: Readonly>; /// Opens a pull request in the browser beside the chat. @@ -77,38 +74,25 @@ export function changesSection( const ended = state === "merged" || state === "closed"; const repoName = pr.repo.split("/").pop() ?? pr.repo; const checks = ended ? undefined : live.checks; - const failing = ended - ? 0 - : (live.failing ?? (checks === "failing" ? -1 : 0)); - const onBase = Math.min( - Math.max(0, live.failingOnBase ?? 0), - Math.max(0, failing), - ); + const failing = ended ? 0 : (live.failing ?? (checks === "failing" ? -1 : 0)); + const onBase = Math.min(Math.max(0, live.failingOnBase ?? 0), Math.max(0, failing)); const comments = live.comments ?? 0; const line = [`${repoName} #${pr.number}`]; if (live.additions !== undefined && live.deletions !== undefined) line.push(`+${live.additions} -${live.deletions}`); - if (comments > 0) - line.push(t("sessionInfo.changes.comments", { count: comments })); + if (comments > 0) line.push(t("sessionInfo.changes.comments", { count: comments })); let trailing: string | undefined; if (failing !== 0) { - trailing = - failing > 0 - ? t("sessionInfo.changes.failing", { count: failing }) - : t("rail.checksFailing"); - if (onBase > 0) - trailing += ` ${t("sessionInfo.changes.failingOnBase", { count: onBase })}`; - } else if (checks && CHECK_LABELS[checks]) - trailing = t(CHECK_LABELS[checks]!); + trailing = failing > 0 ? t("sessionInfo.changes.failing", { count: failing }) : t("rail.checksFailing"); + if (onBase > 0) trailing += ` ${t("sessionInfo.changes.failingOnBase", { count: onBase })}`; + } else if (checks && CHECK_LABELS[checks]) trailing = t(CHECK_LABELS[checks]!); const reference = `${pr.repo}#${pr.number}`; const menu = [ ...(onOpenBeside ? [ { label: t("sessionInfo.changes.openBeside"), - action: actions.add(`changes.openMenu:${pr.url}`, () => - onOpenBeside(pr.url), - ), + action: actions.add(`changes.openMenu:${pr.url}`, () => onOpenBeside(pr.url)), }, ] : []), @@ -116,15 +100,11 @@ export function changesSection( ? [ { label: t("sessionInfo.changes.copyLink"), - action: actions.add(`changes.copyLink:${pr.url}`, () => - input.onCopyText!(pr.url), - ), + action: actions.add(`changes.copyLink:${pr.url}`, () => input.onCopyText!(pr.url)), }, { label: t("sessionInfo.changes.copyReference"), - action: actions.add(`changes.copyReference:${pr.url}`, () => - input.onCopyText!(reference), - ), + action: actions.add(`changes.copyReference:${pr.url}`, () => input.onCopyText!(reference)), }, ] : []), @@ -132,9 +112,7 @@ export function changesSection( ? [ { label: t("prDock.dismiss", { number: pr.number }), - action: actions.add(`changes.dismiss:${pr.url}`, () => - input.onDismiss!(pr.url), - ), + action: actions.add(`changes.dismiss:${pr.url}`, () => input.onDismiss!(pr.url)), }, ] : []), @@ -156,11 +134,7 @@ export function changesSection( subtitle: line.join(" "), ...(trailing ? { trailing } : {}), tone: failing !== 0 ? "failed" : TONES[state], - ...(state === "conflicting" - ? { symbolColor: "red" } - : state === "merged" - ? { symbolColor: "green" } - : {}), + symbolColor: SYMBOL_COLORS[state], help: `${t(STATE_LABELS[state])} · ${pr.url}`, hover: { title: (live.title ?? pr.title) || `#${pr.number}`, @@ -168,12 +142,10 @@ export function changesSection( }, ...(onOpenBeside ? { - action: actions.add(`changes.open:${pr.url}`, () => - onOpenBeside(pr.url), - ), + action: actions.add(`changes.open:${pr.url}`, () => onOpenBeside(pr.url)), } : {}), - ...(menu.length ? { menu } : {}), + ...(menu.length ? { menu, menuLabel: t("sessionInfo.changes.more", { reference }) } : {}), }; }); return { @@ -188,14 +160,8 @@ export function changesSection( ...(folds && onExpandedChange ? { more: { - label: t( - expanded - ? "sessionInfo.changes.showLess" - : "sessionInfo.changes.showMore", - ), - action: actions.add("changes.more", () => - onExpandedChange(!expanded), - ), + label: t(expanded ? "sessionInfo.changes.showLess" : "sessionInfo.changes.showMore"), + action: actions.add("changes.more", () => onExpandedChange(!expanded)), }, } : {}), diff --git a/webviews/src/agent-session/acpmux/session-info/native/connectors.ts b/webviews/src/agent-session/acpmux/session-info/native/connectors.ts index 09f0327f7ecf..bffc2e64f0ed 100644 --- a/webviews/src/agent-session/acpmux/session-info/native/connectors.ts +++ b/webviews/src/agent-session/acpmux/session-info/native/connectors.ts @@ -2,16 +2,9 @@ // chat's harness loaded with its state; a failed one has Reconnect where acpmux reconnects one. import type { AcpmuxSnapshot } from "../../model"; import type { Translate } from "../../i18n"; -import type { - NativeInfoActions, - NativeInfoRow, - NativeInfoSection, - NativeInfoTone, -} from "./model"; +import type { NativeInfoActions, NativeInfoRow, NativeInfoSection, NativeInfoTone } from "./model"; -type McpServer = NonNullable< - NonNullable["mcpServers"] ->[number]; +type McpServer = NonNullable["mcpServers"]>[number]; export const CONNECTORS_SYMBOL = "puzzlepiece.extension"; @@ -39,9 +32,7 @@ export function connectorsSection( ): NativeInfoSection | undefined { const { servers, onReconnect } = input; if (servers.length === 0) return undefined; - const reconnect = servers.filter( - (server) => server.status === "failed", - ).length; + const reconnect = servers.filter((server) => server.status === "failed").length; const rows = servers.map((server): NativeInfoRow => { const row: NativeInfoRow = { id: server.name, @@ -63,9 +54,7 @@ export function connectorsSection( : "sessionInfo.connectors.reconnectAction", ), disabled: state === "reconnecting", - action: actions.add(`connectors.reconnect:${server.name}`, () => - onReconnect(server.name), - ), + action: actions.add(`connectors.reconnect:${server.name}`, () => onReconnect(server.name)), }, ], }; @@ -91,8 +80,7 @@ export function connectorsSection( reconnect > 0 ? t("sessionInfo.connectors.reconnect", { count: reconnect }) : t("rail.connected", { - count: servers.filter((server) => server.status === "connected") - .length, + count: servers.filter((server) => server.status === "connected").length, }), open: input.open, foldable: true, diff --git a/webviews/src/agent-session/acpmux/session-info/native/context.ts b/webviews/src/agent-session/acpmux/session-info/native/context.ts index 000660d8d3e8..15b6799374de 100644 --- a/webviews/src/agent-session/acpmux/session-info/native/context.ts +++ b/webviews/src/agent-session/acpmux/session-info/native/context.ts @@ -1,4 +1,4 @@ -// Session Info's Context, drawn by the App (ChatInspector's `context`, ContextSection.tsx): the share +// Session Info's Context, drawn by the App (SessionInfo's `context`, ContextSection.tsx): the share // of the window used beside the title with its meter, then the window's total and a row per part // (Claude Code's /context breakdown when acpmux kept one, else used and free). A subagent's panel // with no usage shows what it has spent (subagentProgress) as the title's value alone. @@ -6,11 +6,7 @@ import type { PaneLanguage, Translate } from "../../i18n"; import type { Subagent } from "../../subagents/subagentFold"; import { subagentProgress } from "../../subagents/subagentProgress"; import { contextBreakdown, contextParts } from "../ContextSection"; -import type { - NativeInfoActions, - NativeInfoRow, - NativeInfoSection, -} from "./model"; +import type { NativeInfoActions, NativeInfoRow, NativeInfoSection } from "./model"; export const CONTEXT_SYMBOL = "gauge.with.dots.needle.33percent"; @@ -50,9 +46,7 @@ export function contextSection( symbol: CONTEXT_SYMBOL, } as const; if (usage === undefined || usage.size <= 0) { - const progress = input.agent - ? subagentProgress(t, input.language, input.agent) - : []; + const progress = input.agent ? subagentProgress(t, input.language, input.agent) : []; if (progress.length === 0) return undefined; return { ...base, @@ -94,26 +88,20 @@ export function contextSection( let segments: { share: number; color: string }[] | undefined; if (breakdown) { segments = breakdown.rows - .filter( - (row) => - (row.kind === "used" || row.kind === "buffer") && - (row.share ?? 0) > 0, - ) + .filter((row) => (row.kind === "used" || row.kind === "buffer") && (row.share ?? 0) > 0) .map((row) => ({ share: row.share ?? 0, color: row.kind === "buffer" ? "buffer" : `context${row.tone ?? 0}`, })); breakdown.rows.forEach((row, index) => { - const drawn = - (row.kind === "used" || row.kind === "buffer") && (row.share ?? 0) > 0; + const drawn = (row.kind === "used" || row.kind === "buffer") && (row.share ?? 0) > 0; rows.push({ id: `category:${index}:${row.name}`, + symbol: "square.fill", title: row.name, trailing: `${tokens.format(row.tokens)} · ${row.share === undefined ? "—" : share.format(row.share)}`, tone: row.kind === "used" ? undefined : "muted", - ...(row.kind === "used" && row.tone !== undefined - ? { symbolColor: `context${row.tone}` } - : {}), + symbolColor: row.kind === "used" ? `context${row.tone ?? 0}` : row.kind === "buffer" ? "buffer" : "free", ...(drawn ? { meter: row.share } : {}), }); }); @@ -127,9 +115,7 @@ export function contextSection( trailing: String(list.items.length), ...(toggle ? { - action: actions.add(`context.toggle:${list.id}`, () => - toggle(list.id), - ), + action: actions.add(`context.toggle:${list.id}`, () => toggle(list.id)), } : {}), }); @@ -154,15 +140,11 @@ export function contextSection( for (const part of contextParts(usage)) rows.push({ id: part.id, - title: t( - part.id === "used" - ? "sessionInfo.context.used" - : "sessionInfo.context.free", - ), + symbol: "square.fill", + symbolColor: part.id === "used" ? "accent" : "free", + title: t(part.id === "used" ? "sessionInfo.context.used" : "sessionInfo.context.free"), trailing: `${tokens.format(part.tokens)} · ${share.format(part.share)}`, - ...(part.id === "used" - ? { meter: part.share } - : { tone: "muted" as const }), + ...(part.id === "used" ? { meter: part.share } : { tone: "muted" as const }), }); } return { diff --git a/webviews/src/agent-session/acpmux/session-info/native/environment.ts b/webviews/src/agent-session/acpmux/session-info/native/environment.ts index 52ec735c83eb..ad1c7a249bb4 100644 --- a/webviews/src/agent-session/acpmux/session-info/native/environment.ts +++ b/webviews/src/agent-session/acpmux/session-info/native/environment.ts @@ -7,16 +7,9 @@ import { homePath } from "../../sessionList"; import { chatProject } from "../chatFolders"; import { machineHue } from "../../machineColor"; import { machineOf, type ProjectRowLook } from "../EnvironmentSection"; -import type { - AttachedCloudRepo, - PendingCloudRepo, -} from "../CloudRepositoryPicker"; +import type { AttachedCloudRepo, PendingCloudRepo } from "../CloudRepositoryPicker"; import type { NativeInfoRect } from "./model"; -import type { - NativeInfoActions, - NativeInfoRow, - NativeInfoSection, -} from "./model"; +import type { NativeInfoActions, NativeInfoRow, NativeInfoSection } from "./model"; type Summary = NonNullable; @@ -50,21 +43,11 @@ export function environmentSection( actions: NativeInfoActions, t: Translate, ): NativeInfoSection | undefined { - const { - summary, - repository, - remoteControl, - onOpenFolder, - onOpenUrl, - projectLook = "name", - grants = [], - } = input; + const { summary, repository, remoteControl, onOpenFolder, onOpenUrl, projectLook = "name", grants = [] } = input; const machine = machineOf(summary, input.machineName, input.peerKinds); const branch = summary?.branch; const project = chatProject(summary, repository); - const repositoryUrl = repository - ? `https://github.com/${repository}` - : undefined; + const repositoryUrl = repository ? `https://github.com/${repository}` : undefined; const branchUrl = repositoryUrl && branch ? `${repositoryUrl}/tree/${branch.split("/").map(encodeURIComponent).join("/")}` @@ -75,15 +58,9 @@ export function environmentSection( symbol: MACHINE_SYMBOLS[machine.kind], title: machine.name ?? "—", trailing: t( - machine.kind === "local" - ? "composer.envLocal" - : machine.kind === "ssh" - ? "composer.envSSH" - : "composer.cloud", + machine.kind === "local" ? "composer.envLocal" : machine.kind === "ssh" ? "composer.envSSH" : "composer.cloud", ), - ...(machine.kind !== "local" && machine.name - ? { symbolColor: machineHue(machine.name) } - : {}), + ...(machine.kind !== "local" && machine.name ? { symbolColor: machineHue(machine.name) } : {}), }); const cloudSession = machine.kind === "cloud" && summary?.sessionId; if (cloudSession && input.cloudRepositoryAvailable) { @@ -93,10 +70,7 @@ export function environmentSection( title: t("cloudRepository.default"), ...(input.onOpenCloudEnvironment ? { - action: actions.add( - "environment.cloudSettings", - input.onOpenCloudEnvironment, - ), + action: actions.add("environment.cloudSettings", input.onOpenCloudEnvironment), } : {}), }); @@ -105,10 +79,7 @@ export function environmentSection( id: "cloud-add-repository", symbol: "folder.badge.plus", title: t("cloudRepository.add"), - action: actions.add( - "environment.cloudAddRepository", - input.onAddCloudRepository, - ), + action: actions.add("environment.cloudAddRepository", input.onAddCloudRepository), }); } for (const repo of input.attachedCloudRepos ?? []) { @@ -136,15 +107,9 @@ export function environmentSection( } } if (project) { - const parent = homePath( - project.path.slice(0, project.path.lastIndexOf("/")) || "/", - ); + const parent = homePath(project.path.slice(0, project.path.lastIndexOf("/")) || "/"); const detail = - projectLook === "parent" && parent - ? parent - : projectLook === "branch" && branch - ? branch - : undefined; + projectLook === "parent" && parent ? parent : projectLook === "branch" && branch ? branch : undefined; rows.push({ id: "project", symbol: project.worktree ? "rectangle.stack" : "folder", @@ -153,9 +118,7 @@ export function environmentSection( help: project.path, ...(onOpenFolder ? { - action: actions.add("environment.openFolder", () => - onOpenFolder(project.path), - ), + action: actions.add("environment.openFolder", () => onOpenFolder(project.path)), } : {}), }); @@ -167,9 +130,7 @@ export function environmentSection( title: repository, ...(onOpenUrl && repositoryUrl ? { - action: actions.add("environment.openRepository", () => - onOpenUrl(repositoryUrl), - ), + action: actions.add("environment.openRepository", () => onOpenUrl(repositoryUrl)), } : {}), }); @@ -180,18 +141,14 @@ export function environmentSection( title: branch, ...(onOpenUrl && branchUrl ? { - action: actions.add("environment.openBranch", () => - onOpenUrl(branchUrl), - ), + action: actions.add("environment.openBranch", () => onOpenUrl(branchUrl)), } : {}), }); if (remoteControl) { const label = t("sessionInfo.environment.remoteControl"); // The web's switch: a checkbox button that flips it. - const toggle = actions.add("environment.remoteControl", () => - remoteControl.set(!remoteControl.on), - ); + const toggle = actions.add("environment.remoteControl", () => remoteControl.set(!remoteControl.on)); rows.push({ id: "remoteControl", symbol: "cursorarrow.click", @@ -202,10 +159,7 @@ export function environmentSection( }); } for (const grant of grants) { - const revoke = actions.add( - `environment.revoke:${grant.id}`, - grant.onRemove, - ); + const revoke = actions.add(`environment.revoke:${grant.id}`, grant.onRemove); rows.push({ id: `grant:${grant.id}`, symbol: "lock", diff --git a/webviews/src/agent-session/acpmux/session-info/native/host.ts b/webviews/src/agent-session/acpmux/session-info/native/host.ts index 1e07f046e3e2..8bdfec8e3712 100644 --- a/webviews/src/agent-session/acpmux/session-info/native/host.ts +++ b/webviews/src/agent-session/acpmux/session-info/native/host.ts @@ -1,7 +1,7 @@ // Whether the App draws Session Info for this page (the host's `sessionInfoHost` event, model.ts), and // the clicks it hands back (`sessionInfoAction`): each runs the action the page's last model named. import { useSyncExternalStore } from "react"; -import { NATIVE_ACTION_ID_MAX, type NativeInfoActions } from "./model"; +import { NATIVE_ACTION_ID_MAX, type NativeInfoActions, type NativeInfoRect } from "./model"; let drawn = false; const listeners = new Set<() => void>(); @@ -43,9 +43,19 @@ export function setNativeSessionInfoActions( } /// A click in the App's panel: the page's row action by `id`, else the panel's own. -export function runNativeSessionInfoAction(id: unknown): void { +export function runNativeSessionInfoAction(value: unknown): void { + const event = value && typeof value === "object" ? (value as { id?: unknown; rect?: unknown }) : undefined; + const id = event ? event.id : value; if (typeof id !== "string" || id.length === 0 || id.length > NATIVE_ACTION_ID_MAX) return; - if (actions?.run(id)) return; + let rect: NativeInfoRect | undefined; + if (event?.rect !== undefined) { + const box = event.rect as Partial | null; + if (!box || ![box.left, box.top, box.right, box.bottom].every((n) => typeof n === "number" && Number.isFinite(n))) + return; + rect = box as NativeInfoRect; + if (rect.right < rect.left || rect.bottom < rect.top) return; + } + if (actions?.run(id, rect)) return; own?.(id); } diff --git a/webviews/src/agent-session/acpmux/session-info/native/model.ts b/webviews/src/agent-session/acpmux/session-info/native/model.ts index a56a955b2eae..d0ee52d875f0 100644 --- a/webviews/src/agent-session/acpmux/session-info/native/model.ts +++ b/webviews/src/agent-session/acpmux/session-info/native/model.ts @@ -10,6 +10,9 @@ import type { SessionInfoSectionId } from "../sessionInfoState"; /// How a row or a section's value reads: running rows draw a spinner, failed ones the error ink. export type NativeInfoTone = "running" | "waiting" | "failed" | "done" | "muted" | "accent"; +/// A clicked native row in page CSS pixels, used by existing page popovers. +export type NativeInfoRect = { left: number; top: number; right: number; bottom: number }; + /// A button at a row's trailing edge (Stop, Reconnect, the limit's − and +). export type NativeInfoButton = { /// Its accessible name and tooltip; shown as text when it has no symbol. @@ -25,10 +28,14 @@ export type NativeInfoRow = { /// An SF Symbol name. symbol?: string; title: string; + /// The row's existing harness mark, when it is an agent. + harness?: string; /// Dimmer text after the title on the same line (a command's folder, a subagent's prompt). detail?: string; /// A second line under the title. subtitle?: string; + /// Per-agent state marks in a workflow phase, in the page's current order. + marks?: readonly NativeInfoTone[]; /// Right-aligned text (a time, a count, a state). trailing?: string; tone?: NativeInfoTone; @@ -41,6 +48,14 @@ export type NativeInfoRow = { buttons?: readonly NativeInfoButton[]; /// A native switch; the row's action requests the opposite value in the page. toggle?: boolean; + /// Terminal-theme color token, resolved by the native view's palette. + symbolColor?: string; + /// The context window's colored parts, already measured by the page. + segments?: readonly { share: number; color: string }[]; + /// The row's overflow menu, with the same actions as the web row. + menu?: readonly NativeInfoButton[]; + menuLabel?: string; + hover?: { title: string; detail?: string }; /// The row's tooltip (a full path, a full prompt). help?: string; }; @@ -56,6 +71,8 @@ export type NativeInfoSection = { metaTone?: NativeInfoTone; /// 0...1: the meter beside the title (Context). meter?: number; + /// The context window's measured segments, drawn in its header's meter. + segments?: readonly { share: number; color: string }[]; /// No title row: the rows alone (Environment heads the panel bare). bare?: boolean; /// Unfolded; a folded section shows its title row only. @@ -64,6 +81,7 @@ export type NativeInfoSection = { foldable: boolean; /// The section's grip may send `move::` (or `end`). The page owns the order. reorderable?: boolean; + reorderLabel?: string; rows: readonly NativeInfoRow[]; /// A last row that lists more or fewer (Sources' View all N / Show fewer). more?: { label: string; action: string }; @@ -88,6 +106,9 @@ export type NativeSessionInfo = { title?: string; /// A subagent breadcrumb's back action. back?: string; + backLabel?: string; + /// Scroll the named section into view once for this request. + reveal?: { section: string; at: number }; /// In the person's order (sectionOrder.ts), blocks first: where it runs, what it made, what it runs. sections: readonly NativeInfoSection[]; }; @@ -116,19 +137,19 @@ function boundedId(id: string): string { /// stable across updates, e.g. `tasks.stop:`) and returns the id the row sends, bounded so /// the App can always hand it back. export class NativeInfoActions { - private readonly runs = new Map unknown>(); - add(id: string, run: () => unknown): string { + private readonly runs = new Map unknown>(); + add(id: string, run: (rect?: NativeInfoRect) => unknown): string { const key = boundedId(id); this.runs.set(key, run); return key; } /// Runs the action the App's click named; false when this model has none by that id (an update /// raced the click). - run(id: string): boolean { + run(id: string, rect?: NativeInfoRect): boolean { const run = this.runs.get(id); if (!run) return false; void Promise.resolve() - .then(run) + .then(() => run(rect)) .catch(() => undefined); return true; } diff --git a/webviews/src/agent-session/acpmux/session-info/native/outputs.ts b/webviews/src/agent-session/acpmux/session-info/native/outputs.ts index 479ad1b25890..13acf92a433d 100644 --- a/webviews/src/agent-session/acpmux/session-info/native/outputs.ts +++ b/webviews/src/agent-session/acpmux/session-info/native/outputs.ts @@ -3,16 +3,8 @@ import type { Translate } from "../../i18n"; import type { Artifact } from "../../conversation/artifactPreview"; import type { TurnFile } from "../../diff"; -import { - OUTPUTS_SHOWN, - type OutputArtifact, - type OutputLink, -} from "../OutputsSection"; -import type { - NativeInfoActions, - NativeInfoRow, - NativeInfoSection, -} from "./model"; +import { OUTPUTS_SHOWN, type OutputArtifact, type OutputLink } from "../OutputsSection"; +import type { NativeInfoActions, NativeInfoRow, NativeInfoSection } from "./model"; export type OutputsInput = { files: readonly TurnFile[]; @@ -37,39 +29,18 @@ export function outputsSection( actions: NativeInfoActions, t: Translate, ): NativeInfoSection | undefined { - const { - files, - links, - artifacts, - documents = [], - onOpenFile, - onOpenArtifacts, - onOpenDocument, - onOpenUrl, - } = input; - // As ChatInspector shows it: any file, link, gallery item, or document it can open. - if ( - files.length + - links.length + - artifacts + - (onOpenDocument ? documents.length : 0) === - 0 - ) - return undefined; + const { files, links, artifacts, documents = [], onOpenFile, onOpenArtifacts, onOpenDocument, onOpenUrl } = input; + // As SessionInfo shows it: any file, link, gallery item, or document it can open. + if (files.length + links.length + artifacts + (onOpenDocument ? documents.length : 0) === 0) return undefined; const rows: NativeInfoRow[] = []; if (onOpenDocument) for (const document of documents) rows.push({ id: `document:${document.key}`, - symbol: - document.artifact.kind === "markdown" - ? "doc.text" - : "chevron.left.forwardslash.chevron.right", + symbol: document.artifact.kind === "markdown" ? "doc.text" : "chevron.left.forwardslash.chevron.right", title: document.title ?? t("render.untitled"), trailing: t("sessionInfo.outputs.artifact"), - action: actions.add(`outputs.openDocument:${document.key}`, () => - onOpenDocument(document.artifact), - ), + action: actions.add(`outputs.openDocument:${document.key}`, () => onOpenDocument(document.artifact)), }); if (artifacts > 0 && onOpenArtifacts) rows.push({ @@ -87,15 +58,11 @@ export function outputsSection( symbol: file.created ? "doc.badge.plus" : "doc.text", title: cut >= 0 ? file.displayPath.slice(cut + 1) : file.displayPath, ...(folder ? { detail: folder } : {}), - ...(file.binary - ? {} - : { trailing: `+${file.additions} -${file.deletions}` }), + ...(file.binary ? {} : { trailing: `+${file.additions} -${file.deletions}` }), help: file.path, ...(onOpenFile ? { - action: actions.add(`outputs.openFile:${file.path}`, () => - onOpenFile(file.path), - ), + action: actions.add(`outputs.openFile:${file.path}`, () => onOpenFile(file.path)), } : {}), }); @@ -109,9 +76,7 @@ export function outputsSection( help: link.url, ...(onOpenUrl ? { - action: actions.add(`outputs.openLink:${link.url}`, () => - onOpenUrl(link.url), - ), + action: actions.add(`outputs.openLink:${link.url}`, () => onOpenUrl(link.url)), } : {}), }); @@ -129,12 +94,8 @@ export function outputsSection( ...(folds && input.onExpandedChange ? { more: { - label: expanded - ? t("summary.showFewer") - : t("summary.viewAll", { n: rows.length }), - action: actions.add("outputs.more", () => - input.onExpandedChange!(!expanded), - ), + label: expanded ? t("summary.showFewer") : t("summary.viewAll", { n: rows.length }), + action: actions.add("outputs.more", () => input.onExpandedChange!(!expanded)), }, } : {}), diff --git a/webviews/src/agent-session/acpmux/session-info/native/sources.ts b/webviews/src/agent-session/acpmux/session-info/native/sources.ts index 52823f0fdf59..8c25b20a302b 100644 --- a/webviews/src/agent-session/acpmux/session-info/native/sources.ts +++ b/webviews/src/agent-session/acpmux/session-info/native/sources.ts @@ -1,16 +1,9 @@ // Session Info's Sources, drawn by the App (SourcesSection.tsx): what the chat read, five then View // all N. A file in the chat's folder opens in a tab; a link opens beside the chat. import type { Translate } from "../../i18n"; -import type { - SummarySource, - SummarySourceKind, -} from "../../summary/sessionSummary"; +import type { SummarySource, SummarySourceKind } from "../../summary/sessionSummary"; import { SOURCES_SHOWN, scopedPath } from "../SourcesSection"; -import type { - NativeInfoActions, - NativeInfoRow, - NativeInfoSection, -} from "./model"; +import type { NativeInfoActions, NativeInfoRow, NativeInfoSection } from "./model"; const SYMBOLS: Record = { file: "doc.text", @@ -27,7 +20,7 @@ export type SourcesInput = { onOpenFile?: (path: string) => unknown; onOpenBeside?: (url: string) => void; failures?: Readonly>; - /// Every source shows (`inspectorState.sourcesAll`). + /// Every source shows (`sessionInfoState.sourcesAll`). expanded: boolean; onExpandedChange: (expanded: boolean) => void; /// The section is unfolded (`sections.sources`). @@ -39,24 +32,20 @@ export function sourcesSection( actions: NativeInfoActions, t: Translate, ): NativeInfoSection | undefined { - const { sources, cwd, onOpenFile, onOpenBeside, expanded, onExpandedChange } = - input; + const { sources, cwd, onOpenFile, onOpenBeside, expanded, onExpandedChange } = input; if (sources.length === 0) return undefined; const folds = sources.length > SOURCES_SHOWN; const shown = folds && !expanded ? sources.slice(0, SOURCES_SHOWN) : sources; const rows = shown.map((source): NativeInfoRow => { - const path = - source.kind === "file" ? scopedPath(source.label, cwd) : undefined; + const path = source.kind === "file" ? scopedPath(source.label, cwd) : undefined; const failure = path ? input.failures?.[path] : undefined; const row: NativeInfoRow = { id: `${source.kind}:${source.url ?? source.label}`, symbol: SYMBOLS[source.kind ?? "url"], title: source.label, - ...(failure - ? { subtitle: failure, tone: "failed" as const, help: failure } - : {}), + ...(failure ? { subtitle: failure, tone: "failed" as const, help: failure } : {}), }; - if (path && onOpenFile) + if (path && onOpenFile && !failure) return { ...row, ...(failure ? {} : { help: path }), @@ -69,9 +58,7 @@ export function sourcesSection( help: url, ...(onOpenBeside ? { - action: actions.add(`sources.open:${url}`, () => - onOpenBeside(url), - ), + action: actions.add(`sources.open:${url}`, () => onOpenBeside(url)), } : {}), }; @@ -89,12 +76,8 @@ export function sourcesSection( ...(folds ? { more: { - label: expanded - ? t("summary.showFewer") - : t("summary.viewAll", { n: sources.length }), - action: actions.add("sources.more", () => - onExpandedChange(!expanded), - ), + label: expanded ? t("summary.showFewer") : t("summary.viewAll", { n: sources.length }), + action: actions.add("sources.more", () => onExpandedChange(!expanded)), }, } : {}), diff --git a/webviews/src/agent-session/acpmux/session-info/native/usage.ts b/webviews/src/agent-session/acpmux/session-info/native/usage.ts index 8c30fa63ed44..499e332f86d8 100644 --- a/webviews/src/agent-session/acpmux/session-info/native/usage.ts +++ b/webviews/src/agent-session/acpmux/session-info/native/usage.ts @@ -2,23 +2,13 @@ // The page formats and orders values; the App only draws rows and forwards actions. import type { Translate } from "../../i18n"; import type { AcpmuxSessionEntry } from "../../sessionList"; -import { - byUsage, - type SessionUsageStats, - UNKNOWN, - usageCost, - usageTokens, -} from "../../usageStats"; -import type { - NativeInfoActions, - NativeInfoRow, - NativeInfoSection, -} from "./model"; +import { byUsage, type SessionUsageStats, UNKNOWN, usageCost, usageTokens } from "../../usageStats"; +import type { NativeInfoActions, NativeInfoRow, NativeInfoSection } from "./model"; export const USAGE_SYMBOL = "waveform.path"; export const USAGE_SHOWN = 5; -type OwnAction = "compact" | "subagents"; +type OwnAction = "compact" | "subagents" | "cap"; const known = (value: T | undefined, format: (value: T) => string) => value === undefined ? UNKNOWN : format(value); @@ -32,11 +22,7 @@ function ownRows( onShowSubagents?: () => void, ): NativeInfoRow[] { const tokens = (value: number) => usageTokens(value, locale); - const subagents = [ - stats.subagentsLive, - stats.subagents1h, - stats.subagents24h, - ]; + const subagents = [stats.subagentsLive, stats.subagents1h, stats.subagents24h]; const rows: Array<{ id: string; name: string; @@ -71,6 +57,7 @@ function ownRows( : stats.autocompact === "capped" ? known(stats.autocompactCap, tokens) : UNKNOWN, + ...(stats.autocompact === "none" ? { action: "cap" as const } : {}), }, { id: "compactions", @@ -120,12 +107,28 @@ function ownRows( ], } : {}), + ...(action === "cap" + ? { + buttons: [ + { + label: t("sessionInfo.usage.restartWithCap"), + action: actions.add("usage.restartWithCap", () => undefined), + disabled: true, + }, + ], + help: t("sessionInfo.usage.restartWithCapSoon"), + } + : {}), })); } export type UsageInput = { own?: SessionUsageStats; sessions: readonly AcpmuxSessionEntry[]; + /// Parent may provide a memoized ranking; when absent, ranking only happens while open. + ranked?: readonly AcpmuxSessionEntry[]; + /// Parent's memoized existence check keeps a folded section visible without sorting its rows. + hasSessionUsage?: boolean; chatId?: string; onOpen?: (sessionId: string) => void; onCompact?: () => void; @@ -143,60 +146,44 @@ export function usageSection( actions: NativeInfoActions, t: Translate, ): NativeInfoSection | undefined { - const ranked = byUsage(input.sessions, input.sort ?? "cost"); - if (!input.own && ranked.length === 0) return undefined; + const hasSessionUsage = input.hasSessionUsage ?? input.sessions.some((entry) => Boolean(entry.usageStats)); + if (!input.own && !hasSessionUsage) return undefined; + const ranked = input.ranked ?? (input.open ? byUsage(input.sessions, input.sort ?? "cost") : []); const expanded = input.expanded === true; const sort = input.sort ?? "cost"; const folds = ranked.length > USAGE_SHOWN; const shown = folds && !expanded ? ranked.slice(0, USAGE_SHOWN) : ranked; const rows: NativeInfoRow[] = []; - if (input.onSortChange) { + if (input.own) rows.push(...ownRows(input.own, t, input.locale, actions, input.onCompact, input.onShowSubagents)); + if (ranked.length && input.onSortChange) { rows.push({ id: "sort", - title: t("sessionInfo.usage.sortBy"), + title: t("sessionInfo.usage.allSessions"), buttons: [ { label: t("sessionInfo.usage.sortCost"), - action: actions.add("usage.sort:cost", () => - input.onSortChange!("cost"), - ), + action: actions.add("usage.sort:cost", () => input.onSortChange!("cost")), disabled: sort === "cost", }, { label: t("sessionInfo.usage.sortContext"), - action: actions.add("usage.sort:context", () => - input.onSortChange!("context"), - ), + action: actions.add("usage.sort:context", () => input.onSortChange!("context")), disabled: sort === "context", }, ], }); } - if (input.own) - rows.push( - ...ownRows( - input.own, - t, - input.locale, - actions, - input.onCompact, - input.onShowSubagents, - ), - ); for (const entry of shown) { const stats = entry.usageStats; if (!stats) continue; const place = entry.peer ?? entry.host; - const named = - entry.displayTitle || entry.title || entry.name || entry.sessionId; - const title = - entry.peer && named.startsWith(`${entry.peer}/`) - ? named.slice(entry.peer.length + 1) - : named; + const named = entry.displayTitle || entry.title || entry.name || entry.sessionId; + const title = entry.peer && named.startsWith(`${entry.peer}/`) ? named.slice(entry.peer.length + 1) : named; const value = sort === "context" ? stats.contextTokens : stats.costUsd24h; rows.push({ id: `session:${entry.sessionId}`, symbol: "bubble.left", + ...(entry.harness ? { harness: entry.harness } : {}), title, ...(place ? { detail: place } : {}), trailing: @@ -207,9 +194,7 @@ export function usageSection( : usageCost(value, input.locale), ...(entry.sessionId !== input.chatId && input.onOpen ? { - action: actions.add(`usage.open:${entry.sessionId}`, () => - input.onOpen!(entry.sessionId), - ), + action: actions.add(`usage.open:${entry.sessionId}`, () => input.onOpen!(entry.sessionId)), } : {}), }); @@ -220,12 +205,15 @@ export function usageSection( title: t("sessionInfo.usage"), symbol: USAGE_SYMBOL, meta: input.own - ? t("sessionInfo.usage.cost24hValue", { - cost: - input.own.costUsd24h === undefined - ? UNKNOWN - : usageCost(input.own.costUsd24h, input.locale), - }) + ? input.own.costUsd24h !== undefined + ? t("sessionInfo.usage.cost24hValue", { + cost: usageCost(input.own.costUsd24h, input.locale), + }) + : input.own.tokens24h !== undefined + ? t("sessionInfo.usage.tokens24hValue", { + tokens: usageTokens(input.own.tokens24h, input.locale), + }) + : undefined : undefined, open: input.open, foldable: true, @@ -234,12 +222,8 @@ export function usageSection( ...(folds && input.onExpandedChange ? { more: { - label: expanded - ? t("summary.showFewer") - : t("summary.viewAll", { n: ranked.length }), - action: actions.add("usage.more", () => - input.onExpandedChange!(!expanded), - ), + label: expanded ? t("summary.showFewer") : t("summary.viewAll", { n: ranked.length }), + action: actions.add("usage.more", () => input.onExpandedChange!(!expanded)), }, } : {}), diff --git a/webviews/test/native-session-info-bridge.test.ts b/webviews/test/native-session-info-bridge.test.ts index dd23d7684382..b1ae8384cf43 100644 --- a/webviews/test/native-session-info-bridge.test.ts +++ b/webviews/test/native-session-info-bridge.test.ts @@ -67,3 +67,21 @@ test("malformed events are ignored and only the current model can dispatch actio runNativeSessionInfoAction("close"); expect(received).toEqual(["close"]); }); + +test("a native row's page rectangle reaches its popover handler", async () => { + const actions = new NativeInfoActions(); + let anchor: unknown; + const id = actions.add("environment.addRepository", (rect) => { + anchor = rect; + }); + setNativeSessionInfoActions(actions); + const rect = { left: 640, top: 100, right: 920, bottom: 126 }; + runNativeSessionInfoAction({ id, rect }); + await Promise.resolve(); + expect(anchor).toEqual(rect); + anchor = undefined; + runNativeSessionInfoAction({ id, rect: { ...rect, right: NaN } }); + runNativeSessionInfoAction({ id, rect: { ...rect, bottom: 20 } }); + await Promise.resolve(); + expect(anchor).toBeUndefined(); +}); diff --git a/webviews/test/native-session-info-builders.test.ts b/webviews/test/native-session-info-builders.test.ts new file mode 100644 index 000000000000..91dd48bc17be --- /dev/null +++ b/webviews/test/native-session-info-builders.test.ts @@ -0,0 +1,95 @@ +// l10n-allow-file: assertions use the localized builder output, not shipped copy. +import { expect, test } from "bun:test"; +import { translate, type Translate } from "../src/agent-session/acpmux/i18n"; +import { NativeInfoActions } from "../src/agent-session/acpmux/session-info/native/model"; +import { usageSection } from "../src/agent-session/acpmux/session-info/native/usage"; +import type { SessionUsageStats } from "../src/agent-session/acpmux/usageStats"; +import type { AcpmuxSessionEntry } from "../src/agent-session/acpmux/sessionList"; + +const t: Translate = (key, values) => translate(key, values, "en"); +const stats = (overrides: Partial = {}): SessionUsageStats => ({ + autocompact: "unknown", + updatedAt: 1, + ...overrides, +}); +const session = ( + sessionId: string, + usageStats: SessionUsageStats, + extra: Partial = {}, +): AcpmuxSessionEntry => ({ + sessionId, + usageStats, + title: sessionId, + ...extra, +}); + +test("usage keeps the token fallback and disabled restart-with-cap affordance", () => { + const own = stats({ tokens24h: 1200, autocompact: "none" }); + const section = usageSection({ own, sessions: [], open: true }, new NativeInfoActions(), t)!; + expect(section.meta).toBe(t("sessionInfo.usage.tokens24hValue", { tokens: "1.2K" })); + const cap = section.rows.find((row) => row.id === "own:cap")!; + expect(cap.trailing).toBe(t("sessionInfo.usage.noCap")); + expect(cap.buttons?.[0]?.disabled).toBe(true); + expect(cap.help).toBe(t("sessionInfo.usage.restartWithCapSoon")); +}); + +test("usage preserves harness marks, ranks sessions, and exposes sort and View all actions", async () => { + const sessions = Array.from({ length: 6 }, (_, index) => + session(`s${index}`, stats({ costUsd24h: index + 1, contextTokens: (index + 1) * 100 }), { + harness: index === 5 ? "claude" : undefined, + host: index === 0 ? "cmux" : undefined, + }), + ); + let nextSort: string | undefined; + let expanded: boolean | undefined; + const section = usageSection( + { + sessions, + open: true, + sort: "cost", + onSortChange: (sort) => { + nextSort = sort; + }, + onExpandedChange: (value) => { + expanded = value; + }, + }, + new NativeInfoActions(), + t, + )!; + expect(section.rows.find((row) => row.id === "session:s5")?.trailing).toBe("$6.00"); + expect(section.rows.find((row) => row.id === "session:s5")?.harness).toBe("claude"); + const sort = section.rows.find((row) => row.id === "sort")!; + expect(sort.buttons).toHaveLength(2); + const actions = new NativeInfoActions(); + // Rebuild with a shared actions object to exercise the page-owned callbacks. + const rebuilt = usageSection( + { + sessions, + open: true, + sort: "cost", + onSortChange: (value) => { + nextSort = value; + }, + onExpandedChange: (value) => { + expanded = value; + }, + }, + actions, + t, + )!; + expect(actions.run(rebuilt.rows.find((row) => row.id === "sort")!.buttons![1]!.action)).toBe(true); + await Promise.resolve(); + expect(nextSort).toBe("context"); + expect(rebuilt.more).toBeDefined(); + expect(actions.run(rebuilt.more!.action)).toBe(true); + await Promise.resolve(); + expect(expanded).toBe(true); +}); + +test("a folded usage section stays present without ranking until it opens", () => { + const sessions = [session("s1", stats({ costUsd24h: 1 }))]; + const section = usageSection({ sessions, hasSessionUsage: true, open: false }, new NativeInfoActions(), t)!; + expect(section).toBeDefined(); + expect(section.rows).toEqual([]); +}); diff --git a/webviews/test/native-session-info-sections.test.ts b/webviews/test/native-session-info-sections.test.ts new file mode 100644 index 000000000000..7adab4d29c08 --- /dev/null +++ b/webviews/test/native-session-info-sections.test.ts @@ -0,0 +1,209 @@ +// l10n-allow-file: deterministic session, repository, file and action fixtures. +import { expect, test } from "bun:test"; +import { translate, type Translate } from "../src/agent-session/acpmux/i18n"; +import type { SummaryPullRequest } from "../src/agent-session/acpmux/summary/sessionSummary"; +import { changesSection } from "../src/agent-session/acpmux/session-info/native/changes"; +import { contextSection } from "../src/agent-session/acpmux/session-info/native/context"; +import { environmentSection } from "../src/agent-session/acpmux/session-info/native/environment"; +import { NativeInfoActions } from "../src/agent-session/acpmux/session-info/native/model"; +import { outputsSection } from "../src/agent-session/acpmux/session-info/native/outputs"; +import { sourcesSection } from "../src/agent-session/acpmux/session-info/native/sources"; + +const t: Translate = (key, values) => translate(key, values, "en"); + +test("Cloud environment actions retain anchors, grants and one Remote Control switch", async () => { + const actions = new NativeInfoActions(); + let anchored: unknown; + let revoked = false; + let remote = true; + const section = environmentSection( + { + summary: { sessionId: "cloud-chat", hostKind: "cloud", host: "Maple", cwd: "/repo" }, + cloudRepositoryAvailable: true, + attachedCloudRepos: [{ repo: "example/current" }, { repo: "example/extra" }], + pendingCloudRepos: [{ repo: "example/pending", phase: "Cloning" }], + repository: "example/current", + onAddCloudRepository: (rect) => { + anchored = rect; + }, + remoteControl: { + on: remote, + set: (on) => { + remote = on; + }, + }, + grants: [ + { + id: "files", + label: "Folder access", + onRemove: () => { + revoked = true; + }, + }, + ], + }, + actions, + t, + )!; + expect(section.bare).toBe(true); + expect(section.rows.find((row) => row.id === "machine")!.trailing).toBe(t("composer.cloud")); + const control = section.rows.find((row) => row.id === "remoteControl")!; + expect(control).toMatchObject({ toggle: true, symbol: "cursorarrow.click" }); + expect(control.buttons).toBeUndefined(); + const rect = { left: 10, top: 20, right: 90, bottom: 46 }; + actions.run(section.rows.find((row) => row.id === "cloud-add-repository")!.action!, rect); + actions.run(control.action!); + actions.run(section.rows.find((row) => row.id === "grant:files")!.buttons![0]!.action); + await Promise.resolve(); + expect(anchored).toEqual(rect); + expect(remote).toBe(false); + expect(revoked).toBe(true); + expect(section.rows.some((row) => row.id === "cloud-repository:example/extra")).toBe(true); + expect(section.rows.some((row) => row.id === "cloud-pending:example/pending")).toBe(true); +}); + +test("Context keeps measured colored segments, expandable lists and Compact", async () => { + let compacted = false; + let opened: unknown; + const actions = new NativeInfoActions(); + const section = contextSection( + { + usage: { used: 300, size: 1000 }, + language: "en", + open: true, + breakdown: { + categories: [ + { name: "Messages", tokens: 300, kind: "used" }, + { name: "Buffer", tokens: 100, kind: "buffer" }, + ], + maxTokens: 1000, + mcpTools: [{ name: "mcp__files__read", serverName: "files", tokens: 20 }], + }, + onCompact: () => { + compacted = true; + }, + onToggleList: (id) => { + opened = id; + }, + }, + actions, + t, + )!; + expect(section.segments).toEqual([ + { share: 0.3, color: "context0" }, + { share: 0.1, color: "buffer" }, + ]); + expect(section.rows.find((row) => row.title === "Messages")).toMatchObject({ + symbol: "square.fill", + symbolColor: "context0", + }); + actions.run(section.rows[0]!.buttons![0]!.action); + actions.run(section.rows.find((row) => row.id === "list:mcpTools")!.action!); + await Promise.resolve(); + expect(compacted).toBe(true); + expect(opened).toBe("mcpTools"); +}); + +test("Outputs folds the combined list to five and View all invokes the page choice", async () => { + const actions = new NativeInfoActions(); + let expanded = false; + const section = outputsSection( + { + files: [], + artifacts: 0, + links: Array.from({ length: 7 }, (_, i) => ({ url: `https://example.com/${i}`, label: `Result ${i}` })), + open: true, + onExpandedChange: (next) => { + expanded = next; + }, + }, + actions, + t, + )!; + expect(section.rows).toHaveLength(5); + actions.run(section.more!.action); + await Promise.resolve(); + expect(expanded).toBe(true); +}); + +test("Changes exposes current overflow actions and line breakdown", async () => { + const actions = new NativeInfoActions(); + const copied: string[] = []; + const url = "https://github.com/example/project/pull/7"; + const section = changesSection( + { + pullRequests: [{ url, repo: "example/project", number: 7, title: "Update panel", state: "open", opened: true }], + statuses: { + [url]: { + additions: 10, + deletions: 2, + breakdown: { code: { additions: 8, deletions: 2 }, tests: { additions: 2, deletions: 0 } }, + }, + }, + open: true, + onCopyText: (text) => { + copied.push(text); + }, + }, + actions, + t, + )!; + const row = section.rows[0]!; + expect(row.hover!.detail).toContain("+8 -2"); + expect(row.menuLabel).toBe(t("sessionInfo.changes.more", { reference: "example/project#7" })); + expect(row.symbolColor).toBe("green"); + for (const item of row.menu!) actions.run(item.action); + await Promise.resolve(); + expect(copied).toEqual([url, "example/project#7"]); +}); + +test("Changes uses the state color for every pull-request glyph", () => { + const states = ["open", "merged", "draft", "closed", "conflicting"] as const; + const pullRequests: SummaryPullRequest[] = states.map((state, index) => ({ + url: `https://github.com/example/project/pull/${index + 1}`, + repo: "example/project", + number: index + 1, + title: `${state} change`, + state: state === "merged" ? "merged" : state === "closed" ? "closed" : "open", + opened: true, + })); + const statuses = Object.fromEntries( + states.map((state, index) => [ + pullRequests[index]!.url, + state === "conflicting" ? { mergeable: "conflicting" } : state === "open" ? {} : { state }, + ]), + ); + const section = changesSection({ pullRequests, statuses, open: true }, new NativeInfoActions(), t)!; + const colors = new Map(section.rows.map((row) => [row.title.split(" ")[0], row.symbolColor])); + expect(colors).toEqual( + new Map([ + ["open", "green"], + ["merged", "purple"], + ["draft", "yellow"], + ["closed", "red"], + ["conflicting", "red"], + ]), + ); +}); + +test("Sources preserves scoped paths and renders refused opens", () => { + const section = sourcesSection( + { + sources: [ + { kind: "file", label: "src/main.ts" }, + { kind: "file", label: "../secret" }, + ], + cwd: "/repo", + failures: { "/repo/src/main.ts": "Could not open" }, + onOpenFile: () => {}, + open: true, + expanded: false, + onExpandedChange: () => {}, + }, + new NativeInfoActions(), + t, + )!; + expect(section.rows[0]).toMatchObject({ tone: "failed", subtitle: "Could not open" }); + expect(section.rows[0]!.action).toBeUndefined(); + expect(section.rows[1]!.action).toBeUndefined(); +}); From 6e60b306ebda5a926c201565a82325be2064e341 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Mon, 2 Nov 2026 05:58:45 +0000 Subject: [PATCH 03/10] cmux NEXT: model native Session Info activity rows Agent-Session: https://cmux.com/deeplink/agent?id=aid-c3c087851466d8d44f66 --- .../session-info/SubagentLimitStepper.tsx | 93 ++++-- .../acpmux/session-info/native/subagents.ts | 234 +++++++++++++ .../acpmux/session-info/native/tasks.ts | 307 ++++++++++++++++++ 3 files changed, 606 insertions(+), 28 deletions(-) create mode 100644 webviews/src/agent-session/acpmux/session-info/native/subagents.ts create mode 100644 webviews/src/agent-session/acpmux/session-info/native/tasks.ts diff --git a/webviews/src/agent-session/acpmux/session-info/SubagentLimitStepper.tsx b/webviews/src/agent-session/acpmux/session-info/SubagentLimitStepper.tsx index 5a71ff783cc5..9427f3ff35d2 100644 --- a/webviews/src/agent-session/acpmux/session-info/SubagentLimitStepper.tsx +++ b/webviews/src/agent-session/acpmux/session-info/SubagentLimitStepper.tsx @@ -29,56 +29,63 @@ function Glyph({ d }: { d: string }) { ); } -export function SubagentLimitStepper({ +export type SubagentLimitController = { + /// The effective value to display, including the latest optimistic request. + max: number; + /// The requested value while one or more calls are in flight for this chat. + pendingMax?: number; + /// `pending` while a request is in flight, `failed` for the short refusal message. + state?: "pending" | "failed"; + set(value: number | null, from?: number): void; + step(by: number): void; + reset(): void; +}; + +/** + * Keeps the running-limit interaction optimistic while preserving out-of-order replies. + * + * Each request remembers the limit values it was based on. A reply only removes the optimistic + * value after the last request settles; an older reply therefore cannot overwrite a newer click. + * A rejection is surfaced for the same four seconds as the visible stepper. + */ +export function useSubagentLimitController({ chatId, limits, - sessions = [], onSet, }: { - /// The chat whose limit this is: a value asked for another chat never shows here. chatId?: string; limits?: SubsessionLimits; - /// The chats its agent started, for the live counts. - sessions?: readonly StartedSession[]; - /// `_acpmux/subsession/limits` for this chat: a number sets its own limit, `null` drops it. A step - /// names the limit it stepped `from`, so a limit that moved meanwhile (an ancestor's, the - /// workspace's or the settings', which reach this chat as no record) is stepped from instead. onSet?: (maxRunning: number | null, from?: number) => Promise; -}) { - const t = useT(); - // The value a click asked for, until acpmux's answer: kept with the chat and the limits it may - // still stand over (the one the first click saw, then each value asked since), so another chat, - // or a limit from acpmux that is none of those (another client, a refusal), replaces it. An - // earlier click's reply landing (16 → 17 while 18 is still asked) keeps the newer ask. +}): SubagentLimitController { const [asked, setAsked] = useState<{ chat?: string; over: readonly number[]; value: number }>(); const [refused, setRefused] = useState<{ chat?: string; at: number }>(); - // Clicks acpmux has not answered yet: the asked value stands until the last one is answered. + const [inFlightCount, setInFlightCount] = useState(0); const inFlight = useRef(0); useEffect(() => { if (!refused) return; const timer = setTimeout(() => setRefused(undefined), REFUSAL_MS); return () => clearTimeout(timer); }, [refused]); - if (!limits) return null; - const standing = asked !== undefined && asked.chat === chatId && asked.over.includes(limits.maxRunning); - const pending = standing && asked ? asked.value : undefined; - const max = pending ?? limits.maxRunning; - const failed = refused !== undefined && refused.chat === chatId; - const { running, queued } = slotCounts(limits, sessions); + + const current = limits?.maxRunning ?? MAX_RUNNING.min; + const standing = asked !== undefined && asked.chat === chatId && asked.over.includes(current); + const pendingMax = standing ? asked?.value : undefined; + const max = pendingMax ?? current; const set = (value: number | null, from?: number) => { - if (!onSet) return; + if (!onSet || !limits) return; const chat = chatId; setRefused(undefined); if (value !== null) { - const over = standing && asked ? [...asked.over, asked.value] : [limits.maxRunning]; + const over = standing && asked ? [...asked.over, asked.value] : [current]; setAsked({ chat, over, value }); } inFlight.current += 1; + setInFlightCount(inFlight.current); const answered = () => { - inFlight.current -= 1; + inFlight.current = Math.max(0, inFlight.current - 1); + setInFlightCount(inFlight.current); if (inFlight.current === 0) setAsked(undefined); }; - // A handler that returns no promise, or throws, still settles the line (never an unhandled error). Promise.resolve() .then(() => onSet(value, from)) .then(answered, () => { @@ -86,7 +93,37 @@ export function SubagentLimitStepper({ setRefused({ chat, at: Date.now() }); }); }; - const step = (by: number) => set(clampRunning(max + by), max); + return { + max, + ...(pendingMax !== undefined ? { pendingMax } : {}), + ...(refused?.chat === chatId ? { state: "failed" as const } : inFlightCount > 0 ? { state: "pending" as const } : {}), + set, + step: (by) => set(clampRunning(max + by), max), + reset: () => set(null), + }; +} + +export function SubagentLimitStepper({ + chatId, + limits, + sessions = [], + onSet, +}: { + /// The chat whose limit this is: a value asked for another chat never shows here. + chatId?: string; + limits?: SubsessionLimits; + /// The chats its agent started, for the live counts. + sessions?: readonly StartedSession[]; + /// `_acpmux/subsession/limits` for this chat: a number sets its own limit, `null` drops it. A step + /// names the limit it stepped `from`, so a limit that moved meanwhile (an ancestor's, the + /// workspace's or the settings', which reach this chat as no record) is stepped from instead. + onSet?: (maxRunning: number | null, from?: number) => Promise; +}) { + const t = useT(); + const controller = useSubagentLimitController({ chatId, limits, onSet }); + if (!limits) return null; + const { max, state, step } = controller; + const { running, queued } = slotCounts(limits, sessions); const own = limits.maxRunningSource === "session"; return (
    @@ -100,7 +137,7 @@ export function SubagentLimitStepper({ )} - {failed ? t("sessionInfo.subagents.limit.refused") : ""} + {state === "failed" ? t("sessionInfo.subagents.limit.refused") : ""} {onSet && ( @@ -110,7 +147,7 @@ export function SubagentLimitStepper({ className="acpmux-subagent-limit__reset" aria-label={t("sessionInfo.subagents.limit.resetLabel")} title={t("sessionInfo.subagents.limit.resetLabel")} - onClick={() => set(null)} + onClick={() => controller.reset()} > {t("sessionInfo.subagents.limit.reset")} diff --git a/webviews/src/agent-session/acpmux/session-info/native/subagents.ts b/webviews/src/agent-session/acpmux/session-info/native/subagents.ts new file mode 100644 index 000000000000..6b0be7eb9831 --- /dev/null +++ b/webviews/src/agent-session/acpmux/session-info/native/subagents.ts @@ -0,0 +1,234 @@ +// Session Info's Subagents as the App draws it (model.ts): what Session Info's `subagentsSection`, +// SubagentLimitStepper.tsx and SubagentsSection.tsx (with ChildSessionRows.tsx's started chats) show, +// as plain rows. The running limit's line first ("Running 8 / 16 max", − and +), then the running +// subagents and started chats (a nested subagent under its parent), past five a "Show more" row, then +// the ended ones' row ("94 done · 1 failed"), which opens into their lines. The lists, their order and +// their words come from the page's own helpers, so both panels say the same thing. +import { formatDuration } from "../../conversation/turns"; +import type { Translate } from "../../i18n"; +import { nestAgents } from "../../subagents/AgentsPill"; +import { elapsed } from "../../subagents/SubagentGroup"; +import { subagentStateText, subagentStatus, type SubagentStatus } from "../../summary/SubagentStatusList"; +import { orderedSessions, sessionStatus, sessionStateText } from "../ChildSessionRows"; +import type { SubsessionLimits } from "../../model"; +import type { StartedSession } from "../../started/startedSessions"; +import type { Subagent } from "../../subagents/subagentFold"; +import { clampRunning, MAX_RUNNING, slotCounts } from "../../subsessionLimits"; +import { SUBAGENTS_SHOWN, subagentCounts } from "../SubagentsSection"; +import type { NativeInfoActions, NativeInfoButton, NativeInfoRow, NativeInfoSection, NativeInfoTone } from "./model"; + +export type SubagentsSectionInput = { + /// The section is unfolded (Session Info's `state.subagents`). + open: boolean; + /// The chat's subagents; a workflow Background tasks lists (`workflows`) is left out, as there. + agents: readonly Subagent[]; + workflows?: readonly { id: string }[]; + waiting: ReadonlySet; + /// The chats its agent started. + sessions: readonly StartedSession[]; + /// acpmux's running limit (summary `subsessionLimits`); without it no limit line. + limits?: SubsessionLimits; + /// Sets or drops the chat's own limit (Session Info's `setRunningLimit`, already bound to the chat); + /// without it the limit only reads. + onSet?: (maxRunning: number | null, from?: number) => Promise; + onOpen?: (id: string) => void; + onOpenSession?: (sessionId: string) => void; + /// The clock the elapsed times read (the page's `useNow`). + now: number; + /// Every running line shows, not only the first five; `onShowAll` flips it. + showAll?: boolean; + onShowAll?: (showAll: boolean) => void; + /// The ended lines show under their row; `onEndedOpen` flips it. + endedOpen?: boolean; + onEndedOpen?: (open: boolean) => void; + /// Optimistic limit state owned by the page while acpmux answers a limit change. + limitState?: "pending" | "failed"; + pendingMax?: number; +}; + +const TONE: Record = { + running: "running", + waiting: "waiting", + done: "done", + failed: "failed", + stopped: "muted", +}; + +/// Session Info's Subagents, or undefined when the chat never had subagents or started chats and +/// nobody set its running limit (Session Info's condition). +export function subagentsSection( + input: SubagentsSectionInput, + actions: NativeInfoActions, + t: Translate, +): NativeInfoSection | undefined { + const { limits, sessions, waiting } = input; + const workflowIds = new Set((input.workflows ?? []).map((workflow) => workflow.id)); + const agents = workflowIds.size > 0 ? input.agents.filter((agent) => !workflowIds.has(agent.id)) : input.agents; + const limitSet = limits?.maxRunningSource !== undefined && limits.maxRunningSource !== "default"; + if (agents.length + sessions.length === 0 && !limitSet) return undefined; + const rows: NativeInfoRow[] = []; + if (limits) rows.push(limitRow(limits, sessions, input.onSet, actions, t, input.limitState, input.pendingMax)); + type SubagentLine = { + key: string; + identity: string; + agent?: Subagent; + name: string; + detail?: string; + status: SubagentStatus; + state: string; + time?: string; + task?: string; + harness?: string; + depth: number; + at: number; + open?: () => void; + }; + const live = (status: SubagentStatus) => status === "running" || status === "waiting"; + const waitsFirst = (a: Subagent, b: Subagent) => + Number(subagentStatus(b, waiting) === "waiting") - Number(subagentStatus(a, waiting) === "waiting") || + a.startedAt - b.startedAt; + const agentLine = (agent: Subagent, depth: number): SubagentLine => { + const status = subagentStatus(agent, waiting); + return { + key: `agent:${agent.id}`, + identity: agent.id, + agent, + name: agent.name, + detail: live(status) ? agent.action : undefined, + status, + state: subagentStateText(t, agent, waiting), + time: formatDuration(elapsed(agent, input.now)), + task: agent.task, + harness: agent.harness, + depth, + at: agent.endedAt ?? agent.startedAt, + open: input.onOpen && (() => input.onOpen!(agent.id)), + }; + }; + const sessionLine = (child: StartedSession): SubagentLine => ({ + key: `session:${child.sessionId}`, + identity: child.sessionId, + name: child.title, + status: sessionStatus(child), + state: sessionStateText(t, child).text, + harness: child.harness, + time: undefined, + depth: 0, + at: child.createdAt ?? 0, + open: input.onOpenSession && (() => input.onOpenSession!(child.sessionId)), + }); + const runningAgents = nestAgents(agents.filter((agent) => live(subagentStatus(agent, waiting))).sort(waitsFirst)).map(({ agent, depth }) => agentLine(agent, depth)); + const children = orderedSessions(sessions); + const running: SubagentLine[] = [ + ...children.filter((child) => sessionStatus(child) === "waiting").map(sessionLine), + ...runningAgents, + ...children.filter((child) => sessionStatus(child) === "running").map(sessionLine), + ]; + const ended: SubagentLine[] = [ + ...agents.filter((agent) => !live(subagentStatus(agent, waiting))).map((agent) => agentLine(agent, 0)), + ...children.filter((child) => !live(sessionStatus(child))).map(sessionLine), + ].sort((a, b) => ({ failed: 0, stopped: 1, done: 2, running: 3, waiting: 3 } as Record)[a.status] - ({ failed: 0, stopped: 1, done: 2, running: 3, waiting: 3 } as Record)[b.status] || b.at - a.at); + const endedCounts = (lines: readonly SubagentLine[]): string => { + const count = (status: SubagentStatus) => lines.filter((line) => line.status === status).length; + return [count("done") > 0 && t("summary.subagents.done", { n: count("done") }), count("failed") > 0 && t("summary.subagents.failed", { n: count("failed") }), count("stopped") > 0 && t("summary.subagents.stopped", { n: count("stopped") })].filter(Boolean).join(" · "); + }; + const line = (item: SubagentLine): NativeInfoRow => ({ + id: item.key, + symbol: item.agent ? "bubble.left" : "bubble.left.and.bubble.right", + title: item.name, + ...(item.detail && item.detail !== item.name ? { detail: item.detail } : {}), + trailing: [item.time, item.state].filter(Boolean).join(" · "), + tone: TONE[item.status], + ...(item.depth > 0 ? { indent: Math.min(item.depth, 3) } : {}), + ...(item.open ? { action: actions.add(`subagents.open:${item.key}`, item.open) } : {}), + ...(item.task && item.task !== item.name ? { help: item.task } : {}), + ...(item.harness ? { harness: item.harness } : {}), + }); + const showAll = input.showAll ?? false; + rows.push(...(showAll ? running : running.slice(0, SUBAGENTS_SHOWN)).map(line)); + if (running.length > SUBAGENTS_SHOWN) { + const onShowAll = input.onShowAll; + rows.push({ + id: "more", + symbol: showAll ? "chevron.up" : "chevron.down", + title: showAll + ? t("sessionInfo.subagents.less") + : t("sessionInfo.subagents.more", { n: running.length - SUBAGENTS_SHOWN }), + tone: "muted", + ...(onShowAll ? { action: actions.add("subagents.more", () => onShowAll(!showAll)) } : {}), + }); + } + if (ended.length > 0) { + const endedOpen = input.endedOpen ?? false; + const onEndedOpen = input.onEndedOpen; + rows.push({ + id: "ended", + symbol: endedOpen ? "chevron.down" : "chevron.right", + title: endedCounts(t, ended), + ...(onEndedOpen ? { action: actions.add("subagents.ended", () => onEndedOpen(!endedOpen)) } : {}), + }); + if (endedOpen) rows.push(...ended.map(line)); + } + const meta = subagentCounts(t, agents, waiting, sessions); + return { + id: "subagents", + group: "running", + title: t("summary.subagents"), + ...(meta ? { meta } : {}), + open: input.open, + foldable: true, + rows, + }; +} + +/// SubagentLimitStepper's line: "Running 8 / 16 max", "3 queued" while some wait, Reset when the +/// chat set its own, − and + one lower or higher (named `from` the limit they step). +function limitRow( + limits: SubsessionLimits, + sessions: readonly StartedSession[], + onSet: SubagentsSectionInput["onSet"], + actions: NativeInfoActions, + t: Translate, + state?: "pending" | "failed", + pendingMax?: number, +): NativeInfoRow { + const max = pendingMax ?? limits.maxRunning; + const { running, queued } = slotCounts(limits, sessions); + const buttons: NativeInfoButton[] = []; + if (onSet) { + if (limits.maxRunningSource === "session") + buttons.push({ + label: t("sessionInfo.subagents.limit.resetLabel"), + symbol: "arrow.uturn.backward", + action: actions.add("subagents.limitReset", () => onSet(null)), + }); + const lower = clampRunning(max - 1); + const raise = clampRunning(max + 1); + buttons.push( + { + label: t("sessionInfo.subagents.limit.lower", { n: lower }), + symbol: "minus", + action: actions.add("subagents.limitLower", () => onSet(lower, max)), + ...(max <= MAX_RUNNING.min ? { disabled: true } : {}), + }, + { + label: t("sessionInfo.subagents.limit.raise", { n: raise }), + symbol: "plus", + action: actions.add("subagents.limitRaise", () => onSet(raise, max)), + ...(max >= MAX_RUNNING.max ? { disabled: true } : {}), + }, + ); + } + return { + id: "limit", + symbol: "gauge.with.dots.needle.33percent", + title: t("sessionInfo.subagents.limit.running", { running, max }), + ...(state === "failed" + ? { detail: t("sessionInfo.subagents.limit.refused") } + : queued > 0 + ? { detail: t("sessionInfo.subagents.limit.queued", { n: queued }) } + : {}), + trailing: String(max), + ...(buttons.length > 0 ? { buttons } : {}), + }; +} diff --git a/webviews/src/agent-session/acpmux/session-info/native/tasks.ts b/webviews/src/agent-session/acpmux/session-info/native/tasks.ts new file mode 100644 index 000000000000..360bcaf2d579 --- /dev/null +++ b/webviews/src/agent-session/acpmux/session-info/native/tasks.ts @@ -0,0 +1,307 @@ +// Session Info's Background tasks as the App draws it (model.ts): what Session Info's `tasks`, +// WorkflowTasks.tsx, ServersSection.tsx and TasksSection.tsx show, as plain rows. The chat's Claude +// workflows first (running, then finished; each with its phases and, in an open phase, its agents), +// then the dev servers, then the running commands and the scheduled wakeups, then "N finished", +// which opens into the ended commands. The lists, their order and their words come from the page's +// own helpers, so both panels say the same thing. +import { formatDuration } from "../../conversation/turns"; +import type { StringKey, Translate } from "../../i18n"; +import { openCommand } from "../../processTerminal"; +import { modelName, phaseAgents, tally, workflowDetailRequest } from "../../summary/foldWorkflows"; +import type { SummaryBackgroundTask, SummaryWakeup } from "../../summary/sessionSummary"; +import type { WorkflowAgent, WorkflowSummary } from "../../summary/workflow"; +import { + arrivalKey, + sessionInfoLists, + sendLabel, + splitCommands, + type BackgroundCommand, + type SessionInfoProcess, +} from "../processes"; +import { COMMAND_STATE } from "../TasksSection"; +import { compactTokens } from "../../conversation/CompactionNote"; +import type { NativeInfoActions, NativeInfoButton, NativeInfoRow, NativeInfoSection, NativeInfoTone } from "./model"; + +export type TasksSectionInput = { + /// The section is unfolded (Session Info's `state.tasks`). + open: boolean; + /// The registry's processes, the transcript's background tasks and whether the registry listed the + /// chat: split into servers and commands as Session Info does (`sessionInfoLists`). + processes: readonly SessionInfoProcess[]; + transcriptTasks: readonly SummaryBackgroundTask[]; + listed: boolean; + /// The wakeups and cron jobs the agent scheduled (sessionSummary `scheduled`). + scheduled: readonly SummaryWakeup[]; + /// The chat's Claude workflows (Labs). + workflows: readonly WorkflowSummary[]; + /// The chat runs on this Mac (processTerminal.ts `opensProcessTerminal`): a command's terminal opens. + local: boolean; + /// The tool calls the loaded transcript holds (Session Info's `calls`): a row whose call is among + /// them can show it. + calls?: ReadonlySet; + onShowCall?: (toolCallId: string) => boolean; + onStopProcess?: (id: string) => Promise; + onStopWorkflow?: (id: string) => Promise; + onOpenWorkflowAgent?: (agent: WorkflowAgent) => void; + onOpenUrl?: (url: string) => void; + /// The clock the elapsed times read (the page's `useNow`). + now: number; + /// The finished commands show under "N finished"; `onFinishedOpen` flips it. + finishedOpen?: boolean; + onFinishedOpen?: (open: boolean) => void; + /// A workflow phase the person opened or closed, by `/`; one not named opens + /// on its own while its agents run (WorkflowTasks `phaseOpensAlone`). `onPhase` flips one. + phases?: Readonly>; + onPhase?: (key: string, open: boolean) => void; + /// Optimistic status for stop operations; parent clears on process/workflow snapshots. + operations?: Readonly>; +}; + +const COMMAND_TONE: Record = { + running: "running", + completed: "done", + failed: "failed", + stopped: "muted", + lost: "muted", +}; + +const WORKFLOW_TONE: Record = { + running: "running", + paused: "waiting", + waiting: "waiting", + pending: "waiting", + completed: "done", + failed: "failed", + cancelled: "muted", + unknown: "muted", +}; + +/// Session Info's Background tasks, or undefined when the chat has no workflow, server, command or +/// scheduled task (Session Info's condition). +export function tasksSection( + input: TasksSectionInput, + actions: NativeInfoActions, + t: Translate, +): NativeInfoSection | undefined { + const { servers, tasks: commands } = sessionInfoLists(input.processes, input.transcriptTasks, input.listed); + const { workflows, scheduled, now } = input; + if (servers.length + commands.length + scheduled.length + workflows.length === 0) return undefined; + const stop = (id: string, name: string, run: (id: string) => Promise, key: string): NativeInfoButton => { + const operationKey = key.includes("Workflow") ? `workflow:${id}` : `process:${id}`; + const state = input.operations?.[operationKey]; + return { + label: t(state === "failed" ? "subagents.stopFailed" : "subagents.stop", { name }), + symbol: "stop.fill", + action: actions.add(key, () => run(id)), + ...(state === "pending" ? { disabled: true } : {}), + }; + }; + const rows: NativeInfoRow[] = [...workflowRows(input, actions, t, stop)]; + + // Servers, newest first: the command, the ports it names (the first opens on a click, the others + // by their buttons) and how long it has run. + for (const server of [...servers].sort((a, b) => b.startedAt - a.startedAt)) { + const urls = server.ports.map((port) => `http://localhost:${port}`); + const onOpenUrl = input.onOpenUrl; + const buttons: NativeInfoButton[] = []; + if (onOpenUrl) + for (const url of urls.slice(1)) + buttons.push({ + label: t("sessionInfo.servers.open", { url }), + symbol: "link", + action: actions.add(`tasks.openUrl:${server.id}:${url}`, () => onOpenUrl(url)), + }); + if (input.onStopProcess) + buttons.push(stop(server.id, server.command, input.onStopProcess, `tasks.stop:${server.id}`)); + rows.push({ + id: `server:${server.id}`, + symbol: "server.rack", + title: server.command, + detail: formatDuration(Math.max(0, now - server.startedAt)), + ...(server.ports.length > 0 ? { trailing: server.ports.map((port) => `:${port}`).join(" ") } : {}), + tone: "running", + ...(onOpenUrl && urls[0] + ? { + action: actions.add(`tasks.openUrl:${server.id}`, () => onOpenUrl(urls[0]!)), + help: t("sessionInfo.servers.open", { url: urls[0] }), + } + : {}), + ...(buttons.length > 0 ? { buttons } : {}), + }); + } + + const { running, finished } = splitCommands(commands); + const command = (item: BackgroundCommand, trailing: string | undefined): NativeInfoRow => { + const key = arrivalKey(item); + const why: StringKey | undefined = !item.toolCallId + ? "sessionInfo.tasks.noTerminal.noCall" + : input.local || (input.calls?.has(item.toolCallId) ?? false) + ? undefined + : "sessionInfo.tasks.noTerminal.remote"; + const onStop = input.onStopProcess; + return { + id: `command:${key}`, + symbol: "terminal.prompt", + title: sendLabel(t, item) ?? item.command, + ...(trailing ? { trailing } : {}), + tone: COMMAND_TONE[item.state], + ...(why === undefined + ? { + action: actions.add(`tasks.open:${key}`, () => + openCommand(item.toolCallId, { local: input.local, showCall: input.onShowCall }), + ), + } + : {}), + help: why ? t(why) : item.command, + ...(item.state === "running" && item.processId && onStop + ? { buttons: [stop(item.processId, item.command, onStop, `tasks.stop:${item.processId}`)] } + : {}), + }; + }; + for (const item of running) + rows.push( + command( + item, + // A task only the transcript knows may not say when it started. + item.startedAt !== undefined ? formatDuration(Math.max(0, now - item.startedAt)) : t(COMMAND_STATE.running), + ), + ); + for (const wakeup of scheduled) + rows.push({ + id: `scheduled:${wakeup.id}`, + symbol: "clock", + title: wakeup.text, + ...(wakeup.cron ? { trailing: wakeup.cron } : {}), + }); + if (finished.length > 0) { + const finishedOpen = input.finishedOpen ?? false; + const onFinishedOpen = input.onFinishedOpen; + rows.push({ + id: "finished", + symbol: finishedOpen ? "chevron.down" : "chevron.right", + title: t("rail.finished", { count: finished.length }), + tone: "muted", + ...(onFinishedOpen ? { action: actions.add("tasks.finished", () => onFinishedOpen(!finishedOpen)) } : {}), + }); + if (finishedOpen) + for (const item of finished) { + const time = + item.endedAt !== undefined && item.startedAt !== undefined + ? formatDuration(Math.max(0, item.endedAt - item.startedAt)) + : undefined; + rows.push(command(item, [time, t(COMMAND_STATE[item.state])].filter(Boolean).join(" · "))); + } + } + + const runningCount = + running.length + servers.length + workflows.filter((workflow) => workflow.state === "running").length; + return { + id: "tasks", + group: "running", + title: t("summary.backgroundTasks"), + ...(runningCount > 0 ? { meta: t("rail.running", { count: runningCount }), metaTone: "running" as const } : {}), + open: input.open, + foldable: true, + rows, + }; +} + +/// WorkflowTasks' rows: "N running" and "N finished" heads when both show, each workflow (its time, +/// Stop while it runs, its counts under it), its phases (indent 1, a click folds one) and an open +/// phase's agents (indent 2), then "Up next". +function workflowRows( + input: TasksSectionInput, + actions: NativeInfoActions, + t: Translate, + stop: (id: string, name: string, run: (id: string) => Promise, key: string) => NativeInfoButton, +): NativeInfoRow[] { + const rows: NativeInfoRow[] = []; + const isRunning = (workflow: WorkflowSummary) => workflow.state === "running"; + const running = input.workflows.filter(isRunning); + const finished = input.workflows.filter((workflow) => !isRunning(workflow)); + const both = running.length > 0 && finished.length > 0; + const workflow = (item: WorkflowSummary) => { + const live = isRunning(item); + const elapsed = item.elapsedMs ?? (item.startedAt !== undefined ? Math.max(0, input.now - item.startedAt) : undefined); + const counts = tally(item.agents); + const countLine = [ + counts.running > 0 && t("summary.subagents.running", { n: counts.running }), + counts.queued > 0 && t("workflow.queued", { n: counts.queued }), + counts.done > 0 && t("summary.subagents.done", { n: counts.done }), + counts.failed > 0 && t("summary.subagents.failed", { n: counts.failed }), + ].filter((part): part is string => typeof part === "string"); + const meta = item.state === "running" + ? [...countLine, ...(item.tokens !== undefined ? [t("subagents.tokens", { n: item.tokens, tokens: compactTokens(item.tokens) })] : [])] + : [t("workflow.kind"), item.state === "completed" ? t("bgTask.state.completed") : item.state === "failed" ? t("subagents.state.failed") : item.state === "cancelled" ? t("subagents.state.cancelled") : t("inspector.unknown"), t("subagents.pill", { n: counts.total }), ...(item.tokens !== undefined ? [t("subagents.tokens", { n: item.tokens, tokens: compactTokens(item.tokens) })] )]; + const onStop = input.onStopWorkflow; + rows.push({ + id: `workflow:${item.id}`, + symbol: "point.3.connected.trianglepath.dotted", + title: item.title, + ...(meta.length > 0 ? { subtitle: meta.join(" · ") } : {}), + ...(elapsed !== undefined ? { trailing: formatDuration(elapsed) } : {}), + tone: WORKFLOW_TONE[item.state], + ...(item.description ? { help: item.description } : {}), + ...(live && onStop ? { buttons: [stop(item.id, item.title, onStop, `tasks.stopWorkflow:${item.id}`)] } : {}), + }); + const next = live ? item.phases.filter((phase) => phase.state === "pending") : []; + const listed = live ? item.phases.filter((phase) => !next.includes(phase)) : item.phases; + for (const phase of listed) { + const agents = phaseAgents(item, phase); + const counts = tally(agents); + const key = `${item.id}/${phase.id}`; + const open = input.phases?.[key] ?? (live && counts.running > 0); + const onPhase = input.onPhase; + const label = live + ? [counts.running > 0 && t("summary.subagents.running", { n: counts.running }), counts.queued > 0 && t("workflow.queued", { n: counts.queued }), counts.done > 0 && t("summary.subagents.done", { n: counts.done }), counts.failed > 0 && t("summary.subagents.failed", { n: counts.failed })].filter(Boolean).join(" · ") + : counts.total > 0 ? `${counts.done}/${counts.total}` : ""; + rows.push({ + id: `phase:${key}`, + symbol: open ? "chevron.down" : "chevron.right", + title: phase.name || t("workflow.unassigned"), + ...(label ? { trailing: label } : {}), + tone: WORKFLOW_TONE[phase.state], + indent: 1, + ...(onPhase ? { action: actions.add(`tasks.phase:${key}`, () => onPhase(key, !open)) } : {}), + }); + if (!open) continue; + for (const agent of agents) { + const modelEffort = [agent.model ? modelName(agent.model) : undefined, agent.effort].filter((value): value is string => Boolean(value)).join(" · "); + const model = modelEffort ? t("workflow.model", { model: modelEffort }) : undefined; + const message = agent.lastMessage ?? agent.doing; + const elapsed = agent.elapsedMs ?? (agent.state === "running" && agent.startedAt !== undefined ? Math.max(0, input.now - agent.startedAt) : undefined); + const onOpen = input.onOpenWorkflowAgent; + const openable = Boolean(onOpen && workflowDetailRequest(item, agent)); + rows.push({ + id: `workflowAgent:${item.id}/${agent.id}`, + symbol: "bubble.left", + title: agent.name, + ...(model ? { detail: model } : {}), + ...(message ? { subtitle: message, help: message } : {}), + trailing: [ + agent.state === "running" ? t("subagents.state.running") : agent.state === "completed" ? t("subagents.state.completed") : agent.state === "failed" ? t("subagents.state.failed") : agent.state === "cancelled" ? t("subagents.state.cancelled") : agent.state === "paused" ? t("goal.paused") : t("workflow.agentQueued"), + agent.state === "waiting" || agent.state === "pending" || elapsed === undefined ? undefined : formatDuration(elapsed), + ].filter(Boolean).join(" · "), + tone: WORKFLOW_TONE[agent.state], + indent: 2, + ...(openable + ? { action: actions.add(`tasks.openWorkflowAgent:${item.id}/${agent.id}`, () => onOpen!(agent)) } + : {}), + }); + } + } + if (next.length > 0) + rows.push({ + id: `upNext:${item.id}`, + title: t("workflow.upNext", { phases: next.map((phase) => phase.name).join(", ") }), + tone: "muted", + indent: 1, + }); + }; + if (both) rows.push({ id: "workflows.running", title: t("rail.running", { count: running.length }), tone: "muted" }); + running.forEach(workflow); + if (both) + rows.push({ id: "workflows.finished", title: t("rail.finished", { count: finished.length }), tone: "muted" }); + finished.forEach(workflow); + return rows; +} From 64debd7693e587d331308fca4d45affbc031eb10 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Mon, 2 Nov 2026 06:03:04 +0000 Subject: [PATCH 04/10] cmux NEXT: preserve native workflow section behavior Agent-Session: https://cmux.com/deeplink/agent?id=aid-99a6b691def0fe60b10e --- .../acpmux/session-info/native/tasks.ts | 188 ++++++++++++++---- .../native-session-info-workflows.test.ts | 159 +++++++++++++++ 2 files changed, 303 insertions(+), 44 deletions(-) create mode 100644 webviews/test/native-session-info-workflows.test.ts diff --git a/webviews/src/agent-session/acpmux/session-info/native/tasks.ts b/webviews/src/agent-session/acpmux/session-info/native/tasks.ts index 360bcaf2d579..28dcbf693e95 100644 --- a/webviews/src/agent-session/acpmux/session-info/native/tasks.ts +++ b/webviews/src/agent-session/acpmux/session-info/native/tasks.ts @@ -4,10 +4,11 @@ // then the dev servers, then the running commands and the scheduled wakeups, then "N finished", // which opens into the ended commands. The lists, their order and their words come from the page's // own helpers, so both panels say the same thing. +import { useState } from "react"; import { formatDuration } from "../../conversation/turns"; import type { StringKey, Translate } from "../../i18n"; import { openCommand } from "../../processTerminal"; -import { modelName, phaseAgents, tally, workflowDetailRequest } from "../../summary/foldWorkflows"; +import { modelName, phaseAgents, phaseState, tally, workflowDetailRequest } from "../../summary/foldWorkflows"; import type { SummaryBackgroundTask, SummaryWakeup } from "../../summary/sessionSummary"; import type { WorkflowAgent, WorkflowSummary } from "../../summary/workflow"; import { @@ -49,8 +50,7 @@ export type TasksSectionInput = { /// The finished commands show under "N finished"; `onFinishedOpen` flips it. finishedOpen?: boolean; onFinishedOpen?: (open: boolean) => void; - /// A workflow phase the person opened or closed, by `/`; one not named opens - /// on its own while its agents run (WorkflowTasks `phaseOpensAlone`). `onPhase` flips one. + /// The page-owned phase choices, by `/` (useNativeWorkflowPhases). phases?: Readonly>; onPhase?: (key: string, open: boolean) => void; /// Optimistic status for stop operations; parent clears on process/workflow snapshots. @@ -206,9 +206,85 @@ export function tasksSection( }; } -/// WorkflowTasks' rows: "N running" and "N finished" heads when both show, each workflow (its time, -/// Stop while it runs, its counts under it), its phases (indent 1, a click folds one) and an open -/// phase's agents (indent 2), then "Up next". +type PhaseChoice = { started: boolean; open: boolean }; +export type NativeWorkflowPhaseChoices = Readonly>; + +/// The same mounted-phase lifetime as WorkflowPhaseRow: pending rows start closed, and the first +/// agent starting opens a running phase. Later agent/count changes keep the person's fold choice. +export function reconcileNativeWorkflowPhases( + workflows: readonly WorkflowSummary[], + previous: NativeWorkflowPhaseChoices, +): NativeWorkflowPhaseChoices { + const next: Record = {}; + let changed = false; + for (const workflow of workflows) { + for (const phase of workflow.phases) { + const key = `${workflow.id}/${phase.id}`; + const agents = phaseAgents(workflow, phase); + const started = phaseState(agents) !== "pending"; + const was = previous[key]; + const opens = workflow.state === "running" && tally(agents).running > 0; + next[key] = !was + ? { started, open: opens } + : was.started !== started + ? { started, open: started ? opens : was.open } + : was; + if (next[key] !== was) changed = true; + } + } + return changed || Object.keys(next).length !== Object.keys(previous).length ? next : previous; +} + +/// Keep folds in the page; Swift only sends the phase's action id back. Pass no workflows when the +/// section body unmounts, matching the web section's phase children being removed. +export function useNativeWorkflowPhases(workflows: readonly WorkflowSummary[]) { + const [choices, setChoices] = useState({}); + const next = reconcileNativeWorkflowPhases(workflows, choices); + if (next !== choices) setChoices(next); + return { + phases: Object.fromEntries(Object.entries(next).map(([key, choice]) => [key, choice.open])), + onPhase: (key: string, open: boolean) => + setChoices((current) => { + const choice = current[key]; + return choice && choice.open !== open ? { ...current, [key]: { ...choice, open } } : current; + }), + }; +} + +const workflowCounts = (t: Translate, agents: readonly WorkflowAgent[]) => { + const counts = tally(agents); + return [ + counts.running > 0 && t("summary.subagents.running", { n: counts.running }), + counts.queued > 0 && t("workflow.queued", { n: counts.queued }), + counts.done > 0 && t("summary.subagents.done", { n: counts.done }), + counts.failed > 0 && t("summary.subagents.failed", { n: counts.failed }), + ].filter((part): part is string => typeof part === "string"); +}; + +const workflowStateWord = (t: Translate, state: WorkflowAgent["state"]): string => { + if (state === "running") return t("subagents.state.running"); + if (state === "completed") return t("subagents.state.completed"); + if (state === "failed") return t("subagents.state.failed"); + if (state === "cancelled") return t("subagents.state.cancelled"); + if (state === "waiting" || state === "pending") return t("workflow.agentQueued"); + if (state === "paused") return t("goal.paused"); + return t("inspector.unknown"); +}; + +const workflowAgentSymbol = (state: WorkflowAgent["state"]) => + state === "completed" ? "checkmark" : state === "failed" || state === "cancelled" ? "xmark" : "circle"; + +const phaseMark = (state: WorkflowAgent["state"]): NativeInfoTone => + state === "running" || state === "paused" + ? "accent" + : state === "completed" + ? "done" + : state === "failed" || state === "cancelled" + ? "failed" + : "muted"; + +/// WorkflowTasks' rows: running and finished groups, visible descriptions, every phase in source +/// order (including pending ones), and an open phase's agents. Empty phases have nothing to open. function workflowRows( input: TasksSectionInput, actions: NativeInfoActions, @@ -222,17 +298,19 @@ function workflowRows( const both = running.length > 0 && finished.length > 0; const workflow = (item: WorkflowSummary) => { const live = isRunning(item); - const elapsed = item.elapsedMs ?? (item.startedAt !== undefined ? Math.max(0, input.now - item.startedAt) : undefined); + const elapsed = + item.elapsedMs ?? (item.startedAt !== undefined ? Math.max(0, input.now - item.startedAt) : undefined); const counts = tally(item.agents); - const countLine = [ - counts.running > 0 && t("summary.subagents.running", { n: counts.running }), - counts.queued > 0 && t("workflow.queued", { n: counts.queued }), - counts.done > 0 && t("summary.subagents.done", { n: counts.done }), - counts.failed > 0 && t("summary.subagents.failed", { n: counts.failed }), - ].filter((part): part is string => typeof part === "string"); - const meta = item.state === "running" - ? [...countLine, ...(item.tokens !== undefined ? [t("subagents.tokens", { n: item.tokens, tokens: compactTokens(item.tokens) })] : [])] - : [t("workflow.kind"), item.state === "completed" ? t("bgTask.state.completed") : item.state === "failed" ? t("subagents.state.failed") : item.state === "cancelled" ? t("subagents.state.cancelled") : t("inspector.unknown"), t("subagents.pill", { n: counts.total }), ...(item.tokens !== undefined ? [t("subagents.tokens", { n: item.tokens, tokens: compactTokens(item.tokens) })] )]; + const tokens = + item.tokens !== undefined ? [t("subagents.tokens", { n: item.tokens, tokens: compactTokens(item.tokens) })] : []; + const meta = live + ? [...workflowCounts(t, item.agents), ...tokens] + : [ + t("workflow.kind"), + item.state === "completed" ? t("bgTask.state.completed") : workflowStateWord(t, item.state), + t("subagents.pill", { n: counts.total }), + ...tokens, + ]; const onStop = input.onStopWorkflow; rows.push({ id: `workflow:${item.id}`, @@ -241,67 +319,89 @@ function workflowRows( ...(meta.length > 0 ? { subtitle: meta.join(" · ") } : {}), ...(elapsed !== undefined ? { trailing: formatDuration(elapsed) } : {}), tone: WORKFLOW_TONE[item.state], - ...(item.description ? { help: item.description } : {}), ...(live && onStop ? { buttons: [stop(item.id, item.title, onStop, `tasks.stopWorkflow:${item.id}`)] } : {}), }); - const next = live ? item.phases.filter((phase) => phase.state === "pending") : []; - const listed = live ? item.phases.filter((phase) => !next.includes(phase)) : item.phases; - for (const phase of listed) { + if (item.description) + rows.push({ + id: `workflowDescription:${item.id}`, + title: item.description, + help: item.description, + tone: "muted", + indent: 1, + }); + for (const phase of item.phases) { const agents = phaseAgents(item, phase); const counts = tally(agents); const key = `${item.id}/${phase.id}`; const open = input.phases?.[key] ?? (live && counts.running > 0); const onPhase = input.onPhase; const label = live - ? [counts.running > 0 && t("summary.subagents.running", { n: counts.running }), counts.queued > 0 && t("workflow.queued", { n: counts.queued }), counts.done > 0 && t("summary.subagents.done", { n: counts.done }), counts.failed > 0 && t("summary.subagents.failed", { n: counts.failed })].filter(Boolean).join(" · ") - : counts.total > 0 ? `${counts.done}/${counts.total}` : ""; + ? workflowCounts(t, agents).join(" · ") + : counts.total > 0 + ? `${counts.done}/${counts.total}` + : ""; rows.push({ id: `phase:${key}`, - symbol: open ? "chevron.down" : "chevron.right", + ...(agents.length > 0 ? { symbol: open ? "chevron.down" : "chevron.right" } : {}), title: phase.name || t("workflow.unassigned"), ...(label ? { trailing: label } : {}), - tone: WORKFLOW_TONE[phase.state], + marks: agents.map((agent) => phaseMark(agent.state)), indent: 1, - ...(onPhase ? { action: actions.add(`tasks.phase:${key}`, () => onPhase(key, !open)) } : {}), + ...(agents.length > 0 && onPhase + ? { action: actions.add(`tasks.phase:${key}`, () => onPhase(key, !open)) } + : {}), }); if (!open) continue; for (const agent of agents) { - const modelEffort = [agent.model ? modelName(agent.model) : undefined, agent.effort].filter((value): value is string => Boolean(value)).join(" · "); + const modelEffort = [agent.model ? modelName(agent.model) : undefined, agent.effort] + .filter((value): value is string => Boolean(value)) + .join(" · "); const model = modelEffort ? t("workflow.model", { model: modelEffort }) : undefined; - const message = agent.lastMessage ?? agent.doing; - const elapsed = agent.elapsedMs ?? (agent.state === "running" && agent.startedAt !== undefined ? Math.max(0, input.now - agent.startedAt) : undefined); + const message = (agent.state === "failed" ? agent.error : undefined) ?? agent.lastMessage ?? agent.doing; + const elapsed = + agent.elapsedMs ?? + (agent.state === "running" && agent.startedAt !== undefined + ? Math.max(0, input.now - agent.startedAt) + : undefined); const onOpen = input.onOpenWorkflowAgent; const openable = Boolean(onOpen && workflowDetailRequest(item, agent)); + const time = + agent.state === "waiting" || agent.state === "pending" || elapsed === undefined + ? undefined + : formatDuration(elapsed); + const state = workflowStateWord(t, agent.state); rows.push({ id: `workflowAgent:${item.id}/${agent.id}`, - symbol: "bubble.left", + symbol: workflowAgentSymbol(agent.state), title: agent.name, ...(model ? { detail: model } : {}), - ...(message ? { subtitle: message, help: message } : {}), - trailing: [ - agent.state === "running" ? t("subagents.state.running") : agent.state === "completed" ? t("subagents.state.completed") : agent.state === "failed" ? t("subagents.state.failed") : agent.state === "cancelled" ? t("subagents.state.cancelled") : agent.state === "paused" ? t("goal.paused") : t("workflow.agentQueued"), - agent.state === "waiting" || agent.state === "pending" || elapsed === undefined ? undefined : formatDuration(elapsed), - ].filter(Boolean).join(" · "), + ...(message ? { subtitle: message } : {}), + trailing: [state, time].filter(Boolean).join(" · "), + help: [agent.name, model, message, state, time].filter(Boolean).join(", "), tone: WORKFLOW_TONE[agent.state], indent: 2, ...(openable - ? { action: actions.add(`tasks.openWorkflowAgent:${item.id}/${agent.id}`, () => onOpen!(agent)) } + ? { + action: actions.add(`tasks.openWorkflowAgent:${item.id}/${agent.id}`, () => onOpen!(agent)), + } : {}), }); } } - if (next.length > 0) - rows.push({ - id: `upNext:${item.id}`, - title: t("workflow.upNext", { phases: next.map((phase) => phase.name).join(", ") }), - tone: "muted", - indent: 1, - }); }; - if (both) rows.push({ id: "workflows.running", title: t("rail.running", { count: running.length }), tone: "muted" }); + if (both) + rows.push({ + id: "workflows.running", + title: t("rail.running", { count: running.length }), + tone: "muted", + }); running.forEach(workflow); if (both) - rows.push({ id: "workflows.finished", title: t("rail.finished", { count: finished.length }), tone: "muted" }); + rows.push({ + id: "workflows.finished", + title: t("rail.finished", { count: finished.length }), + tone: "muted", + }); finished.forEach(workflow); return rows; } diff --git a/webviews/test/native-session-info-workflows.test.ts b/webviews/test/native-session-info-workflows.test.ts new file mode 100644 index 000000000000..28c5bf5324b8 --- /dev/null +++ b/webviews/test/native-session-info-workflows.test.ts @@ -0,0 +1,159 @@ +// l10n-allow-file: deterministic workflow names and failure messages in behavioral fixtures. +import { expect, test } from "bun:test"; +import { translate } from "../src/agent-session/acpmux/i18n"; +import { NativeInfoActions } from "../src/agent-session/acpmux/session-info/native/model"; +import { + reconcileNativeWorkflowPhases, + tasksSection, + type NativeWorkflowPhaseChoices, + type TasksSectionInput, +} from "../src/agent-session/acpmux/session-info/native/tasks"; +import { createWorkflowAdapter } from "../src/agent-session/acpmux/summary/foldWorkflows"; +import type { WorkflowAgent, WorkflowSummary } from "../src/agent-session/acpmux/summary/workflow"; +import { workflowFixture } from "../src/agent-session/acpmux/summary/workflowFixture"; + +const NOW = 1_800_000_000_000; +const summaries = (stage: "running" | "finished") => createWorkflowAdapter()(workflowFixture(stage, NOW), "s1"); +const base = (workflows: readonly WorkflowSummary[]): TasksSectionInput => ({ + open: true, + processes: [], + transcriptTasks: [], + listed: true, + scheduled: [], + workflows, + local: true, + now: NOW, +}); +const model = (input: TasksSectionInput, actions = new NativeInfoActions()) => tasksSection(input, actions, translate)!; +const fixture = (agents: WorkflowAgent[], state: WorkflowSummary["state"] = "running"): WorkflowSummary => ({ + id: "work", + title: "Research", + state, + sessionId: "chat", + runId: "run", + agents, + phases: [{ id: "draft", name: "Draft", state: "pending", agentIds: agents.map((agent) => agent.id) }], +}); + +test("all phases stay in source order, with descriptions and inert empty pending rows", () => { + const [workflow] = summaries("running"); + const section = model({ ...base([workflow!]), onPhase: () => {} }); + const phases = section.rows.filter((row) => row.id.startsWith("phase:")); + expect(phases.map((row) => row.title)).toEqual(workflow!.phases.map((phase) => phase.name)); + expect(phases).toHaveLength(3); + expect(section.rows.find((row) => row.id === `workflowDescription:${workflow!.id}`)!.title).toBe( + workflow!.description!, + ); + expect(section.rows.map((row) => row.title).join(" ")).not.toContain("Up next"); + const pending = phases.at(-1)!; + expect(pending.title).toBe("Judge"); + expect(pending.action).toBeUndefined(); + expect(pending.symbol).toBeUndefined(); + expect(pending.marks).toEqual([]); + expect(phases[0]!.marks).toEqual(["done", "done", "done"]); + expect(phases[1]!.marks).toEqual(["accent", "muted", "muted"]); +}); + +test("phase actions fold the named phase without making an empty phase clickable", async () => { + const workflow = fixture([{ id: "queued", name: "Queued writer", state: "waiting" }]); + workflow.phases.push({ id: "empty", name: "Publish", state: "pending", agentIds: [] }); + const actions = new NativeInfoActions(); + const folded: [string, boolean][] = []; + const section = model({ ...base([workflow]), onPhase: (key, open) => folded.push([key, open]) }, actions); + const phase = section.rows.find((row) => row.id === "phase:work/draft")!; + expect(section.rows.some((row) => row.id.startsWith("workflowAgent:"))).toBe(false); + expect(actions.run(phase.action!)).toBe(true); + await Promise.resolve(); + expect(folded).toEqual([["work/draft", true]]); + expect(section.rows.find((row) => row.id === "phase:work/empty")!.action).toBeUndefined(); +}); + +test("agent rows preserve failure reason, model, queued time, and transcript bridge gating", async () => { + const failed: WorkflowAgent = { + id: "failed", + agentId: "agent-failed", + name: "Writer", + state: "failed", + model: "claude-opus-5-5", + effort: "medium", + error: "Provider refused the request", + lastMessage: "Older message", + doing: "Reading", + elapsedMs: 12_000, + }; + const queued: WorkflowAgent = { + id: "queued", + name: "Editor", + state: "pending", + elapsedMs: 99_000, + }; + const workflow = fixture([failed, queued]); + const opened: WorkflowAgent[] = []; + const actions = new NativeInfoActions(); + const input = { + ...base([workflow]), + phases: { "work/draft": true }, + onOpenWorkflowAgent: (agent: WorkflowAgent) => opened.push(agent), + }; + const rows = model(input, actions).rows; + const writer = rows.find((row) => row.id === "workflowAgent:work/failed")!; + const editor = rows.find((row) => row.id === "workflowAgent:work/queued")!; + expect(writer.detail).toBe("Model: Opus 5.5 · medium"); + expect(writer.subtitle).toBe(failed.error); + expect(writer.trailing).toContain("12s"); + expect(writer.symbol).toBe("xmark"); + expect(editor.trailing).toBe(translate("workflow.agentQueued")); + expect(editor.action).toBeUndefined(); + expect(actions.run(writer.action!)).toBe(true); + await Promise.resolve(); + expect(opened).toEqual([failed]); + expect( + model({ ...input, workflows: [{ ...workflow, runId: undefined }] }).rows.find((row) => row.id === writer.id)! + .action, + ).toBeUndefined(); +}); + +test("a phase opens on its first start, then keeps the person's fold choice across updates", () => { + const pending = fixture([{ id: "a", name: "Writer", state: "pending" }]); + let choices = reconcileNativeWorkflowPhases([pending], {}); + expect(choices["work/draft"]).toEqual({ started: false, open: false }); + const running = { ...pending, agents: [{ ...pending.agents[0]!, state: "running" as const }] }; + choices = reconcileNativeWorkflowPhases([running], choices); + expect(choices["work/draft"]).toEqual({ started: true, open: true }); + const closed: NativeWorkflowPhaseChoices = { + ...choices, + "work/draft": { ...choices["work/draft"]!, open: false }, + }; + expect(reconcileNativeWorkflowPhases([{ ...running, tokens: 40_000 }], closed)).toBe(closed); + expect(reconcileNativeWorkflowPhases([], closed)).toEqual({}); +}); + +test("running workflows sort ahead of finished ones and Stop reflects pending or failed operations", async () => { + const running = { ...fixture([]), tokens: 1200, startedAt: NOW - 20_000 }; + const paused = { ...fixture([], "paused"), id: "paused", elapsedMs: 40_000 }; + const actions = new NativeInfoActions(); + const stopped: string[] = []; + const input = { + ...base([paused, running]), + onStopWorkflow: async (id: string) => { + stopped.push(id); + }, + }; + const rows = model({ ...input, operations: { "workflow:work": "pending" } }, actions).rows; + expect(rows.filter((row) => row.id.startsWith("workflow:")).map((row) => row.id)).toEqual([ + "workflow:work", + "workflow:paused", + ]); + expect(rows.find((row) => row.id === "workflow:paused")!.subtitle).toContain(translate("goal.paused")); + expect(rows.find((row) => row.id === "workflow:paused")!.buttons).toBeUndefined(); + const pending = rows.find((row) => row.id === "workflow:work")!.buttons![0]!; + expect(pending.disabled).toBe(true); + const failed = model({ ...input, operations: { "workflow:work": "failed" } }, actions).rows.find( + (row) => row.id === "workflow:work", + )!.buttons![0]!; + expect(failed.disabled).not.toBe(true); + expect(failed.label).toBe(translate("subagents.stopFailed", { name: running.title })); + expect(actions.run(failed.action)).toBe(true); + await Promise.resolve(); + expect(stopped).toEqual(["work"]); +}); From 7e0be2fbda307d2361ab5d9bae3f033f139f7336 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Mon, 2 Nov 2026 06:04:02 +0000 Subject: [PATCH 05/10] cmux NEXT: preserve rapid native Session Info limit steps Agent-Session: https://cmux.com/deeplink/agent?id=aid-c3c087851466d8d44f66 --- .../session-info/SubagentLimitStepper.tsx | 30 +++++++++++++++---- .../acpmux/session-info/native/limit.ts | 13 ++++++++ .../acpmux/session-info/native/subagents.ts | 11 ++++--- .../session-info-limit-controller.test.ts | 14 +++++++++ 4 files changed, 58 insertions(+), 10 deletions(-) create mode 100644 webviews/src/agent-session/acpmux/session-info/native/limit.ts create mode 100644 webviews/test/session-info-limit-controller.test.ts diff --git a/webviews/src/agent-session/acpmux/session-info/SubagentLimitStepper.tsx b/webviews/src/agent-session/acpmux/session-info/SubagentLimitStepper.tsx index 9427f3ff35d2..c5c302167382 100644 --- a/webviews/src/agent-session/acpmux/session-info/SubagentLimitStepper.tsx +++ b/webviews/src/agent-session/acpmux/session-info/SubagentLimitStepper.tsx @@ -13,6 +13,7 @@ import type { SubsessionLimits } from "../model"; import type { StartedSession } from "../started/startedSessions"; import { clampRunning, MAX_RUNNING, slotCounts } from "../subsessionLimits"; import "./SubagentLimitStepper.css"; +import { nextSubagentLimitAsk, type SubagentLimitAsk } from "./native/limit"; /// How long a refused change says so. const REFUSAL_MS = 4_000; @@ -41,6 +42,7 @@ export type SubagentLimitController = { reset(): void; }; + /** * Keeps the running-limit interaction optimistic while preserving out-of-order replies. * @@ -57,7 +59,8 @@ export function useSubagentLimitController({ limits?: SubsessionLimits; onSet?: (maxRunning: number | null, from?: number) => Promise; }): SubagentLimitController { - const [asked, setAsked] = useState<{ chat?: string; over: readonly number[]; value: number }>(); + const [asked, setAsked] = useState(); + const askedRef = useRef(); const [refused, setRefused] = useState<{ chat?: string; at: number }>(); const [inFlightCount, setInFlightCount] = useState(0); const inFlight = useRef(0); @@ -71,20 +74,26 @@ export function useSubagentLimitController({ const standing = asked !== undefined && asked.chat === chatId && asked.over.includes(current); const pendingMax = standing ? asked?.value : undefined; const max = pendingMax ?? current; + const latestMax = useRef(max); + latestMax.current = max; const set = (value: number | null, from?: number) => { if (!onSet || !limits) return; const chat = chatId; setRefused(undefined); if (value !== null) { - const over = standing && asked ? [...asked.over, asked.value] : [current]; - setAsked({ chat, over, value }); + const next = nextSubagentLimitAsk(askedRef.current, chat, current, value); + askedRef.current = next; + setAsked(next); } inFlight.current += 1; setInFlightCount(inFlight.current); const answered = () => { inFlight.current = Math.max(0, inFlight.current - 1); setInFlightCount(inFlight.current); - if (inFlight.current === 0) setAsked(undefined); + if (inFlight.current === 0) { + askedRef.current = undefined; + setAsked(undefined); + } }; Promise.resolve() .then(() => onSet(value, from)) @@ -96,9 +105,18 @@ export function useSubagentLimitController({ return { max, ...(pendingMax !== undefined ? { pendingMax } : {}), - ...(refused?.chat === chatId ? { state: "failed" as const } : inFlightCount > 0 ? { state: "pending" as const } : {}), + ...(refused !== undefined && refused.chat === chatId + ? { state: "failed" as const } + : inFlightCount > 0 + ? { state: "pending" as const } + : {}), set, - step: (by) => set(clampRunning(max + by), max), + step: (by) => { + const from = latestMax.current; + const value = clampRunning(from + by); + latestMax.current = value; + set(value, from); + }, reset: () => set(null), }; } diff --git a/webviews/src/agent-session/acpmux/session-info/native/limit.ts b/webviews/src/agent-session/acpmux/session-info/native/limit.ts new file mode 100644 index 000000000000..4e3e18792725 --- /dev/null +++ b/webviews/src/agent-session/acpmux/session-info/native/limit.ts @@ -0,0 +1,13 @@ +/// Pure optimistic running-limit history shared by the web stepper and the native Session Info +/// model. A second click may happen before React paints the first one. +export type SubagentLimitAsk = { chat?: string; over: readonly number[]; value: number }; + +export function nextSubagentLimitAsk( + asked: SubagentLimitAsk | undefined, + chatId: string | undefined, + current: number, + value: number, +): SubagentLimitAsk { + const standing = asked !== undefined && asked.chat === chatId && asked.over.includes(current); + return { chat: chatId, over: standing && asked ? [...asked.over, asked.value] : [current], value }; +} diff --git a/webviews/src/agent-session/acpmux/session-info/native/subagents.ts b/webviews/src/agent-session/acpmux/session-info/native/subagents.ts index 6b0be7eb9831..fccf9c7a8c56 100644 --- a/webviews/src/agent-session/acpmux/session-info/native/subagents.ts +++ b/webviews/src/agent-session/acpmux/session-info/native/subagents.ts @@ -41,6 +41,8 @@ export type SubagentsSectionInput = { /// The ended lines show under their row; `onEndedOpen` flips it. endedOpen?: boolean; onEndedOpen?: (open: boolean) => void; + /// Optional controller step keeps rapid native clicks on the latest optimistic value. + onStep?: (by: number) => void; /// Optimistic limit state owned by the page while acpmux answers a limit change. limitState?: "pending" | "failed"; pendingMax?: number; @@ -67,7 +69,7 @@ export function subagentsSection( const limitSet = limits?.maxRunningSource !== undefined && limits.maxRunningSource !== "default"; if (agents.length + sessions.length === 0 && !limitSet) return undefined; const rows: NativeInfoRow[] = []; - if (limits) rows.push(limitRow(limits, sessions, input.onSet, actions, t, input.limitState, input.pendingMax)); + if (limits) rows.push(limitRow(limits, sessions, input.onSet, actions, t, input.limitState, input.pendingMax, input.onStep)); type SubagentLine = { key: string; identity: string; @@ -164,7 +166,7 @@ export function subagentsSection( rows.push({ id: "ended", symbol: endedOpen ? "chevron.down" : "chevron.right", - title: endedCounts(t, ended), + title: endedCounts(ended), ...(onEndedOpen ? { action: actions.add("subagents.ended", () => onEndedOpen(!endedOpen)) } : {}), }); if (endedOpen) rows.push(...ended.map(line)); @@ -191,6 +193,7 @@ function limitRow( t: Translate, state?: "pending" | "failed", pendingMax?: number, + onStep?: (by: number) => void, ): NativeInfoRow { const max = pendingMax ?? limits.maxRunning; const { running, queued } = slotCounts(limits, sessions); @@ -208,13 +211,13 @@ function limitRow( { label: t("sessionInfo.subagents.limit.lower", { n: lower }), symbol: "minus", - action: actions.add("subagents.limitLower", () => onSet(lower, max)), + action: actions.add("subagents.limitLower", () => onStep ? onStep(-1) : onSet(lower, max)), ...(max <= MAX_RUNNING.min ? { disabled: true } : {}), }, { label: t("sessionInfo.subagents.limit.raise", { n: raise }), symbol: "plus", - action: actions.add("subagents.limitRaise", () => onSet(raise, max)), + action: actions.add("subagents.limitRaise", () => onStep ? onStep(1) : onSet(raise, max)), ...(max >= MAX_RUNNING.max ? { disabled: true } : {}), }, ); diff --git a/webviews/test/session-info-limit-controller.test.ts b/webviews/test/session-info-limit-controller.test.ts new file mode 100644 index 000000000000..7b52e44fe6a5 --- /dev/null +++ b/webviews/test/session-info-limit-controller.test.ts @@ -0,0 +1,14 @@ +// The native Session Info stepper and the page stepper share this optimistic request history. +// Two clicks can arrive before React paints the first one; the second must use the requested value, +// while a newer daemon value starts a new history from that value. +import { expect, test } from "bun:test"; +import { nextSubagentLimitAsk } from "../src/agent-session/acpmux/session-info/native/limit"; + +test("rapid limit steps retain the optimistic value until the daemon advances", () => { + const first = nextSubagentLimitAsk(undefined, "chat", 8, 9); + const second = nextSubagentLimitAsk(first, "chat", 8, 10); + expect(second).toEqual({ chat: "chat", over: [8, 9], value: 10 }); + + const afterDaemon = nextSubagentLimitAsk(second, "chat", 10, 11); + expect(afterDaemon).toEqual({ chat: "chat", over: [10], value: 11 }); +}); From 1878557c45b44dc3fd656bfbc3a8a13d26b00c91 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Mon, 2 Nov 2026 06:05:16 +0000 Subject: [PATCH 06/10] cmux NEXT: key native workflow stop state precisely Agent-Session: https://cmux.com/deeplink/agent?id=aid-c3c087851466d8d44f66 --- webviews/src/agent-session/acpmux/session-info/native/tasks.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webviews/src/agent-session/acpmux/session-info/native/tasks.ts b/webviews/src/agent-session/acpmux/session-info/native/tasks.ts index 28dcbf693e95..85b4f42c1539 100644 --- a/webviews/src/agent-session/acpmux/session-info/native/tasks.ts +++ b/webviews/src/agent-session/acpmux/session-info/native/tasks.ts @@ -87,7 +87,7 @@ export function tasksSection( const { workflows, scheduled, now } = input; if (servers.length + commands.length + scheduled.length + workflows.length === 0) return undefined; const stop = (id: string, name: string, run: (id: string) => Promise, key: string): NativeInfoButton => { - const operationKey = key.includes("Workflow") ? `workflow:${id}` : `process:${id}`; + const operationKey = key.startsWith("tasks.stopWorkflow:") ? `workflow:${id}` : `process:${id}`; const state = input.operations?.[operationKey]; return { label: t(state === "failed" ? "subagents.stopFailed" : "subagents.stop", { name }), From b2328903b276276f7588da36388133074c1deddc Mon Sep 17 00:00:00 2001 From: Leo Li Date: Mon, 2 Nov 2026 06:32:59 +0000 Subject: [PATCH 07/10] Keep activity model extraction type-safe and formatted Agent-Session: https://cmux.com/deeplink/agent?id=aid-109d06b293c167d1bda3 --- .../session-info/SubagentLimitStepper.tsx | 3 +-- .../acpmux/session-info/native/subagents.ts | 26 ++++++++++++++----- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/webviews/src/agent-session/acpmux/session-info/SubagentLimitStepper.tsx b/webviews/src/agent-session/acpmux/session-info/SubagentLimitStepper.tsx index c5c302167382..0025d2abac41 100644 --- a/webviews/src/agent-session/acpmux/session-info/SubagentLimitStepper.tsx +++ b/webviews/src/agent-session/acpmux/session-info/SubagentLimitStepper.tsx @@ -42,7 +42,6 @@ export type SubagentLimitController = { reset(): void; }; - /** * Keeps the running-limit interaction optimistic while preserving out-of-order replies. * @@ -60,7 +59,7 @@ export function useSubagentLimitController({ onSet?: (maxRunning: number | null, from?: number) => Promise; }): SubagentLimitController { const [asked, setAsked] = useState(); - const askedRef = useRef(); + const askedRef = useRef(undefined); const [refused, setRefused] = useState<{ chat?: string; at: number }>(); const [inFlightCount, setInFlightCount] = useState(0); const inFlight = useRef(0); diff --git a/webviews/src/agent-session/acpmux/session-info/native/subagents.ts b/webviews/src/agent-session/acpmux/session-info/native/subagents.ts index fccf9c7a8c56..0035ee067ddb 100644 --- a/webviews/src/agent-session/acpmux/session-info/native/subagents.ts +++ b/webviews/src/agent-session/acpmux/session-info/native/subagents.ts @@ -69,7 +69,8 @@ export function subagentsSection( const limitSet = limits?.maxRunningSource !== undefined && limits.maxRunningSource !== "default"; if (agents.length + sessions.length === 0 && !limitSet) return undefined; const rows: NativeInfoRow[] = []; - if (limits) rows.push(limitRow(limits, sessions, input.onSet, actions, t, input.limitState, input.pendingMax, input.onStep)); + if (limits) + rows.push(limitRow(limits, sessions, input.onSet, actions, t, input.limitState, input.pendingMax, input.onStep)); type SubagentLine = { key: string; identity: string; @@ -119,7 +120,9 @@ export function subagentsSection( at: child.createdAt ?? 0, open: input.onOpenSession && (() => input.onOpenSession!(child.sessionId)), }); - const runningAgents = nestAgents(agents.filter((agent) => live(subagentStatus(agent, waiting))).sort(waitsFirst)).map(({ agent, depth }) => agentLine(agent, depth)); + const runningAgents = nestAgents(agents.filter((agent) => live(subagentStatus(agent, waiting))).sort(waitsFirst)).map( + ({ agent, depth }) => agentLine(agent, depth), + ); const children = orderedSessions(sessions); const running: SubagentLine[] = [ ...children.filter((child) => sessionStatus(child) === "waiting").map(sessionLine), @@ -129,10 +132,21 @@ export function subagentsSection( const ended: SubagentLine[] = [ ...agents.filter((agent) => !live(subagentStatus(agent, waiting))).map((agent) => agentLine(agent, 0)), ...children.filter((child) => !live(sessionStatus(child))).map(sessionLine), - ].sort((a, b) => ({ failed: 0, stopped: 1, done: 2, running: 3, waiting: 3 } as Record)[a.status] - ({ failed: 0, stopped: 1, done: 2, running: 3, waiting: 3 } as Record)[b.status] || b.at - a.at); + ].sort( + (a, b) => + ({ failed: 0, stopped: 1, done: 2, running: 3, waiting: 3 } as Record)[a.status] - + ({ failed: 0, stopped: 1, done: 2, running: 3, waiting: 3 } as Record)[b.status] || + b.at - a.at, + ); const endedCounts = (lines: readonly SubagentLine[]): string => { const count = (status: SubagentStatus) => lines.filter((line) => line.status === status).length; - return [count("done") > 0 && t("summary.subagents.done", { n: count("done") }), count("failed") > 0 && t("summary.subagents.failed", { n: count("failed") }), count("stopped") > 0 && t("summary.subagents.stopped", { n: count("stopped") })].filter(Boolean).join(" · "); + return [ + count("done") > 0 && t("summary.subagents.done", { n: count("done") }), + count("failed") > 0 && t("summary.subagents.failed", { n: count("failed") }), + count("stopped") > 0 && t("summary.subagents.stopped", { n: count("stopped") }), + ] + .filter(Boolean) + .join(" · "); }; const line = (item: SubagentLine): NativeInfoRow => ({ id: item.key, @@ -211,13 +225,13 @@ function limitRow( { label: t("sessionInfo.subagents.limit.lower", { n: lower }), symbol: "minus", - action: actions.add("subagents.limitLower", () => onStep ? onStep(-1) : onSet(lower, max)), + action: actions.add("subagents.limitLower", () => (onStep ? onStep(-1) : onSet(lower, max))), ...(max <= MAX_RUNNING.min ? { disabled: true } : {}), }, { label: t("sessionInfo.subagents.limit.raise", { n: raise }), symbol: "plus", - action: actions.add("subagents.limitRaise", () => onStep ? onStep(1) : onSet(raise, max)), + action: actions.add("subagents.limitRaise", () => (onStep ? onStep(1) : onSet(raise, max))), ...(max >= MAX_RUNNING.max ? { disabled: true } : {}), }, ); From 029fdf75b196316ef21ca8b2f26cfd864aafbd1e Mon Sep 17 00:00:00 2001 From: Leo Li Date: Mon, 2 Nov 2026 06:43:00 +0000 Subject: [PATCH 08/10] cmux NEXT: preserve native Session Info activity parity Agent-Session: https://cmux.com/deeplink/agent?id=aid-c3c087851466d8d44f66 --- .../acpmux/session-info/SubagentsSection.css | 9 +- .../acpmux/session-info/SubagentsSection.tsx | 3 +- .../acpmux/session-info/native/model.ts | 7 + .../acpmux/session-info/native/subagents.ts | 88 +++++++++---- .../acpmux/session-info/native/tasks.ts | 12 +- .../acpmux/session-info/native/wave.ts | 25 ++++ .../test/native-session-info-activity.test.ts | 124 ++++++++++++++++++ webviews/test/running-wave.test.tsx | 25 ++-- 8 files changed, 231 insertions(+), 62 deletions(-) create mode 100644 webviews/src/agent-session/acpmux/session-info/native/wave.ts create mode 100644 webviews/test/native-session-info-activity.test.ts diff --git a/webviews/src/agent-session/acpmux/session-info/SubagentsSection.css b/webviews/src/agent-session/acpmux/session-info/SubagentsSection.css index 6101378ed388..8343516c88f4 100644 --- a/webviews/src/agent-session/acpmux/session-info/SubagentsSection.css +++ b/webviews/src/agent-session/acpmux/session-info/SubagentsSection.css @@ -1,10 +1,5 @@ -/* Session Info's running wave (ui/wave.css) sweeps slower than the transcript's: the same shape, - every time half again as long (Leo 2026-10-28: "I think it could maybe be a bit slower on the - session info region"). The period and step scale together, so the crest still spans the rows. */ -.acpmux-session-info-subagents { - --cmux-wave-period: 2.4s; - --cmux-wave-step: 180ms; -} +/* Session Info's running wave (ui/wave.css) sweeps slower than the transcript's. Its period and + step are set by native/wave.ts on the root so the native model and web rows share one source. */ /* A few completed agents beside the count, as in the Codex Session Info reference. The row opens the named list; the decorative preview never adds motion or separate targets. */ diff --git a/webviews/src/agent-session/acpmux/session-info/SubagentsSection.tsx b/webviews/src/agent-session/acpmux/session-info/SubagentsSection.tsx index 0ce2ff253962..b302a4b8add6 100644 --- a/webviews/src/agent-session/acpmux/session-info/SubagentsSection.tsx +++ b/webviews/src/agent-session/acpmux/session-info/SubagentsSection.tsx @@ -33,6 +33,7 @@ import "./SubagentsSection.css"; import { SessionInfoFold } from "./SessionInfoSection"; import { AgentMark } from "../../shared/AgentMark"; import { TonedIcon } from "../subagents/TonedIcon"; +import { SESSION_INFO_WAVE_STYLE } from "./native/wave"; /// Running lines shown before "Show more", as Claude Code desktop's panel caps its list. export const SUBAGENTS_SHOWN = 5; @@ -254,7 +255,7 @@ export function SubagentsSection({ ); return ( -
    +
    {runningLines.length > 0 && (
      {shown.map((item, index) => line(item, index, true))} diff --git a/webviews/src/agent-session/acpmux/session-info/native/model.ts b/webviews/src/agent-session/acpmux/session-info/native/model.ts index d0ee52d875f0..239a9093bb1e 100644 --- a/webviews/src/agent-session/acpmux/session-info/native/model.ts +++ b/webviews/src/agent-session/acpmux/session-info/native/model.ts @@ -6,6 +6,7 @@ // click comes back as `sessionInfoAction {id}` and runs the action the page registered for that id. import type { SessionInfoGroupId } from "../sectionOrder"; import type { SessionInfoSectionId } from "../sessionInfoState"; +import type { NativeInfoWave } from "./wave"; /// How a row or a section's value reads: running rows draw a spinner, failed ones the error ink. export type NativeInfoTone = "running" | "waiting" | "failed" | "done" | "muted" | "accent"; @@ -30,10 +31,16 @@ export type NativeInfoRow = { title: string; /// The row's existing harness mark, when it is an agent. harness?: string; + /// A retrying agent uses the native spinner instead of its harness glyph. + working?: boolean; + /// A regular running row's shared Session Info wave position and cadence. + wave?: NativeInfoWave; /// Dimmer text after the title on the same line (a command's folder, a subagent's prompt). detail?: string; /// A second line under the title. subtitle?: string; + /// Progress parts such as workflow counts and queued/failed agents. + progress?: string; /// Per-agent state marks in a workflow phase, in the page's current order. marks?: readonly NativeInfoTone[]; /// Right-aligned text (a time, a count, a state). diff --git a/webviews/src/agent-session/acpmux/session-info/native/subagents.ts b/webviews/src/agent-session/acpmux/session-info/native/subagents.ts index 0035ee067ddb..269219988294 100644 --- a/webviews/src/agent-session/acpmux/session-info/native/subagents.ts +++ b/webviews/src/agent-session/acpmux/session-info/native/subagents.ts @@ -5,9 +5,12 @@ // the ended ones' row ("94 done · 1 failed"), which opens into their lines. The lists, their order and // their words come from the page's own helpers, so both panels say the same thing. import { formatDuration } from "../../conversation/turns"; -import type { Translate } from "../../i18n"; +import type { PaneLanguage, Translate } from "../../i18n"; +import { isRepeatedAgentKind } from "../../subagents/AgentIdentity"; import { nestAgents } from "../../subagents/AgentsPill"; import { elapsed } from "../../subagents/SubagentGroup"; +import { subagentProgress } from "../../subagents/subagentProgress"; +import { retrying } from "../../subagents/retryState"; import { subagentStateText, subagentStatus, type SubagentStatus } from "../../summary/SubagentStatusList"; import { orderedSessions, sessionStatus, sessionStateText } from "../ChildSessionRows"; import type { SubsessionLimits } from "../../model"; @@ -16,13 +19,18 @@ import type { Subagent } from "../../subagents/subagentFold"; import { clampRunning, MAX_RUNNING, slotCounts } from "../../subsessionLimits"; import { SUBAGENTS_SHOWN, subagentCounts } from "../SubagentsSection"; import type { NativeInfoActions, NativeInfoButton, NativeInfoRow, NativeInfoSection, NativeInfoTone } from "./model"; +import { nativeInfoWave } from "./wave"; export type SubagentsSectionInput = { /// The section is unfolded (Session Info's `state.subagents`). open: boolean; /// The chat's subagents; a workflow Background tasks lists (`workflows`) is left out, as there. agents: readonly Subagent[]; + /// Parent harness supplies ordinary child agents that do not name their own. + harness?: string; workflows?: readonly { id: string }[]; + /// The pane language used by the shared progress formatter. + language?: PaneLanguage; waiting: ReadonlySet; /// The chats its agent started. sessions: readonly StartedSession[]; @@ -81,7 +89,10 @@ export function subagentsSection( state: string; time?: string; task?: string; + progress?: string; + kind?: string; harness?: string; + working?: boolean; depth: number; at: number; open?: () => void; @@ -101,25 +112,32 @@ export function subagentsSection( status, state: subagentStateText(t, agent, waiting), time: formatDuration(elapsed(agent, input.now)), - task: agent.task, - harness: agent.harness, + progress: subagentProgress(t, input.language ?? "en", agent, { usage: false }).join(" · ") || undefined, + task: agent.task && agent.task !== agent.name ? agent.task : undefined, + kind: agent.kind && agent.kind !== agent.name && !isRepeatedAgentKind(agent.kind) ? agent.kind : undefined, + harness: agent.harness ?? input.harness, + working: Boolean(retrying(agent)), depth, at: agent.endedAt ?? agent.startedAt, open: input.onOpen && (() => input.onOpen!(agent.id)), }; }; - const sessionLine = (child: StartedSession): SubagentLine => ({ - key: `session:${child.sessionId}`, - identity: child.sessionId, - name: child.title, - status: sessionStatus(child), - state: sessionStateText(t, child).text, - harness: child.harness, - time: undefined, - depth: 0, - at: child.createdAt ?? 0, - open: input.onOpenSession && (() => input.onOpenSession!(child.sessionId)), - }); + const sessionLine = (child: StartedSession): SubagentLine => { + const state = sessionStateText(t, child); + return { + key: `session:${child.sessionId}`, + identity: child.sessionId, + name: child.title, + status: sessionStatus(child), + state: state.text, + harness: child.harness, + working: state.working, + time: undefined, + depth: 0, + at: child.createdAt ?? 0, + open: input.onOpenSession && (() => input.onOpenSession!(child.sessionId)), + }; + }; const runningAgents = nestAgents(agents.filter((agent) => live(subagentStatus(agent, waiting))).sort(waitsFirst)).map( ({ agent, depth }) => agentLine(agent, depth), ); @@ -148,20 +166,32 @@ export function subagentsSection( .filter(Boolean) .join(" · "); }; - const line = (item: SubagentLine): NativeInfoRow => ({ + const line = (item: SubagentLine, waveIndex?: number): NativeInfoRow => ({ id: item.key, symbol: item.agent ? "bubble.left" : "bubble.left.and.bubble.right", title: item.name, ...(item.detail && item.detail !== item.name ? { detail: item.detail } : {}), + ...(item.progress ? { progress: item.progress } : {}), trailing: [item.time, item.state].filter(Boolean).join(" · "), tone: TONE[item.status], + ...(item.working ? { working: true } : {}), + ...(waveIndex !== undefined ? { wave: nativeInfoWave(waveIndex) } : {}), ...(item.depth > 0 ? { indent: Math.min(item.depth, 3) } : {}), ...(item.open ? { action: actions.add(`subagents.open:${item.key}`, item.open) } : {}), - ...(item.task && item.task !== item.name ? { help: item.task } : {}), + ...([item.kind, item.task].filter(Boolean).length > 0 + ? { help: [item.kind, item.task].filter(Boolean).join("\n") } + : {}), ...(item.harness ? { harness: item.harness } : {}), }); const showAll = input.showAll ?? false; - rows.push(...(showAll ? running : running.slice(0, SUBAGENTS_SHOWN)).map(line)); + const shown = showAll ? running : running.slice(0, SUBAGENTS_SHOWN); + let waveIndex = 0; + rows.push( + ...shown.map((item) => { + const index = live(item.status) ? waveIndex++ : undefined; + return line(item, index); + }), + ); if (running.length > SUBAGENTS_SHOWN) { const onShowAll = input.onShowAll; rows.push({ @@ -212,8 +242,8 @@ function limitRow( const max = pendingMax ?? limits.maxRunning; const { running, queued } = slotCounts(limits, sessions); const buttons: NativeInfoButton[] = []; - if (onSet) { - if (limits.maxRunningSource === "session") + if (onSet || onStep) { + if (limits.maxRunningSource === "session" && onSet) buttons.push({ label: t("sessionInfo.subagents.limit.resetLabel"), symbol: "arrow.uturn.backward", @@ -221,20 +251,22 @@ function limitRow( }); const lower = clampRunning(max - 1); const raise = clampRunning(max + 1); - buttons.push( - { + const lowerAction = onStep ? () => onStep(-1) : onSet ? () => onSet(lower, max) : undefined; + const raiseAction = onStep ? () => onStep(1) : onSet ? () => onSet(raise, max) : undefined; + if (lowerAction) + buttons.push({ label: t("sessionInfo.subagents.limit.lower", { n: lower }), symbol: "minus", - action: actions.add("subagents.limitLower", () => (onStep ? onStep(-1) : onSet(lower, max))), + action: actions.add("subagents.limitLower", lowerAction), ...(max <= MAX_RUNNING.min ? { disabled: true } : {}), - }, - { + }); + if (raiseAction) + buttons.push({ label: t("sessionInfo.subagents.limit.raise", { n: raise }), symbol: "plus", - action: actions.add("subagents.limitRaise", () => (onStep ? onStep(1) : onSet(raise, max))), + action: actions.add("subagents.limitRaise", raiseAction), ...(max >= MAX_RUNNING.max ? { disabled: true } : {}), - }, - ); + }); } return { id: "limit", diff --git a/webviews/src/agent-session/acpmux/session-info/native/tasks.ts b/webviews/src/agent-session/acpmux/session-info/native/tasks.ts index 85b4f42c1539..27c0f8ecc0b5 100644 --- a/webviews/src/agent-session/acpmux/session-info/native/tasks.ts +++ b/webviews/src/agent-session/acpmux/session-info/native/tasks.ts @@ -98,14 +98,14 @@ export function tasksSection( }; const rows: NativeInfoRow[] = [...workflowRows(input, actions, t, stop)]; - // Servers, newest first: the command, the ports it names (the first opens on a click, the others - // by their buttons) and how long it has run. + // Servers, newest first: the command, the ports it names (each explicit port is its own button) + // and how long it has run. The row itself stays plain, like ServersSection.tsx. for (const server of [...servers].sort((a, b) => b.startedAt - a.startedAt)) { const urls = server.ports.map((port) => `http://localhost:${port}`); const onOpenUrl = input.onOpenUrl; const buttons: NativeInfoButton[] = []; if (onOpenUrl) - for (const url of urls.slice(1)) + for (const url of urls) buttons.push({ label: t("sessionInfo.servers.open", { url }), symbol: "link", @@ -120,12 +120,6 @@ export function tasksSection( detail: formatDuration(Math.max(0, now - server.startedAt)), ...(server.ports.length > 0 ? { trailing: server.ports.map((port) => `:${port}`).join(" ") } : {}), tone: "running", - ...(onOpenUrl && urls[0] - ? { - action: actions.add(`tasks.openUrl:${server.id}`, () => onOpenUrl(urls[0]!)), - help: t("sessionInfo.servers.open", { url: urls[0] }), - } - : {}), ...(buttons.length > 0 ? { buttons } : {}), }); } diff --git a/webviews/src/agent-session/acpmux/session-info/native/wave.ts b/webviews/src/agent-session/acpmux/session-info/native/wave.ts new file mode 100644 index 000000000000..968ce352b7c3 --- /dev/null +++ b/webviews/src/agent-session/acpmux/session-info/native/wave.ts @@ -0,0 +1,25 @@ +import type { CSSProperties } from "react"; + +/// Session Info's running wave is deliberately slower than the transcript's wave. Keep the +/// numbers here so the web row style and the native AppKit model cannot drift apart. +export const SESSION_INFO_WAVE_PERIOD_MS = 2_400; +export const SESSION_INFO_WAVE_STEP_MS = 180; + +/// The CSS variables consumed by `ui/wave.css`. The native panel receives the same values through +/// each row's `wave` payload below. +export const SESSION_INFO_WAVE_STYLE: CSSProperties & Record<"--cmux-wave-period" | "--cmux-wave-step", string> = { + "--cmux-wave-period": `${SESSION_INFO_WAVE_PERIOD_MS}ms`, + "--cmux-wave-step": `${SESSION_INFO_WAVE_STEP_MS}ms`, +}; + +export type NativeInfoWave = { + index: number; + periodMs: number; + stepMs: number; +}; + +export const nativeInfoWave = (index: number): NativeInfoWave => ({ + index, + periodMs: SESSION_INFO_WAVE_PERIOD_MS, + stepMs: SESSION_INFO_WAVE_STEP_MS, +}); diff --git a/webviews/test/native-session-info-activity.test.ts b/webviews/test/native-session-info-activity.test.ts new file mode 100644 index 000000000000..a983bda44028 --- /dev/null +++ b/webviews/test/native-session-info-activity.test.ts @@ -0,0 +1,124 @@ +// l10n-allow-file: deterministic Session Info activity fixtures. +import { expect, test } from "bun:test"; +import { translate } from "../src/agent-session/acpmux/i18n"; +import { NativeInfoActions } from "../src/agent-session/acpmux/session-info/native/model"; +import { subagentsSection } from "../src/agent-session/acpmux/session-info/native/subagents"; +import { tasksSection } from "../src/agent-session/acpmux/session-info/native/tasks"; +import { + nativeInfoWave, + SESSION_INFO_WAVE_PERIOD_MS, + SESSION_INFO_WAVE_STEP_MS, +} from "../src/agent-session/acpmux/session-info/native/wave"; +import type { Subagent } from "../src/agent-session/acpmux/subagents/subagentFold"; + +const t = (key: Parameters[0], values?: Parameters[1]) => + translate(key, values, "en"); + +test("native subagent rows retain progress, kind/task help, retry spinner and the shared wave", () => { + const agents: Subagent[] = [ + { + id: "retry", + parent: null, + name: "Retry", + kind: "Explore", + task: "Retry the request", + state: "failed", + startedAt: 1_000, + retry: { attempt: 2, of: 4, reason: "capacity" }, + }, + { + id: "run", + parent: null, + name: "Run", + kind: "Explore", + task: "Inspect the project", + state: "running", + startedAt: 1_000, + agents: { done: 2, started: 3, queued: 1 }, + }, + ]; + const section = subagentsSection( + { + open: true, + agents, + waiting: new Set(), + sessions: [], + language: "en", + now: 2_000, + }, + new NativeInfoActions(), + t, + )!; + const retry = section.rows.find((row) => row.id === "agent:retry")!; + const run = section.rows.find((row) => row.id === "agent:run")!; + expect(retry.working).toBe(true); + expect(retry.wave).toEqual(nativeInfoWave(0)); + expect(retry.help).toBe("Explore\nRetry the request"); + expect(run.working).toBeUndefined(); + expect(run.wave).toEqual({ index: 1, periodMs: SESSION_INFO_WAVE_PERIOD_MS, stepMs: SESSION_INFO_WAVE_STEP_MS }); + expect(run.progress).toContain("2"); + expect(run.progress).toContain("3"); + expect(run.help).toBe("Explore\nInspect the project"); +}); + +test("native server rows keep the command plain and expose every named port", async () => { + const opened: string[] = []; + const section = tasksSection( + { + open: true, + processes: [ + { + id: "server", + command: "npm run dev -- --port 3000 --port=4000", + startedAt: 1_000, + state: "running", + }, + ], + transcriptTasks: [], + listed: true, + scheduled: [], + workflows: [], + local: true, + now: 2_000, + onOpenUrl: (url) => opened.push(url), + }, + new NativeInfoActions(), + t, + )!; + const row = section.rows.find((item) => item.id === "server:server")!; + expect(row.action).toBeUndefined(); + expect(row.buttons?.map((button) => button.label)).toEqual([ + t("sessionInfo.servers.open", { url: "http://localhost:3000" }), + t("sessionInfo.servers.open", { url: "http://localhost:4000" }), + ]); + for (const button of row.buttons ?? []) { + const actions = new NativeInfoActions(); + const action = tasksSection( + { + open: true, + processes: [ + { + id: "server", + command: "npm run dev -- --port 3000 --port=4000", + startedAt: 1_000, + state: "running", + }, + ], + transcriptTasks: [], + listed: true, + scheduled: [], + workflows: [], + local: true, + now: 2_000, + onOpenUrl: (url) => opened.push(url), + }, + actions, + t, + )! + .rows.find((item) => item.id === "server:server")! + .buttons!.find((item) => item.label === button.label)!.action; + actions.run(action); + } + await Promise.resolve(); + expect(opened).toEqual(["http://localhost:3000", "http://localhost:4000"]); +}); diff --git a/webviews/test/running-wave.test.tsx b/webviews/test/running-wave.test.tsx index d587277bb637..eaad2a64f963 100644 --- a/webviews/test/running-wave.test.tsx +++ b/webviews/test/running-wave.test.tsx @@ -12,6 +12,10 @@ import { SubagentsSection } from "../src/agent-session/acpmux/session-info/Subag import type { Subagent } from "../src/agent-session/acpmux/subagents/subagentFold"; import type { SpinnerAnimation } from "../src/ui/spinnerClock"; import { alignWave, WAVE_CLASS, WAVE_KEYFRAMES, waveIndices, waveProps } from "../src/ui/wave"; +import { + SESSION_INFO_WAVE_PERIOD_MS, + SESSION_INFO_WAVE_STEP_MS, +} from "../src/agent-session/acpmux/session-info/native/wave"; type Item = { id: string; state: "running" | "done" | "failed" }; const live = (item: Item) => item.state === "running"; @@ -125,21 +129,8 @@ test("the wave runs only with motion: Reduce Motion, the motion gate, static ind test("Session Info's wave sweeps slower than the shared one, period and step scaled together", () => { // Leo 2026-10-28: "I think it could maybe be a bit slower on the session info region". const shared = readFileSync(path.resolve(import.meta.dir, "../src/ui/wave.css"), "utf8"); - const css = readFileSync( - path.resolve(import.meta.dir, "../src/agent-session/acpmux/session-info/SubagentsSection.css"), - "utf8", - ); - const at = css.indexOf(".acpmux-session-info-subagents {"); - expect(at).toBeGreaterThanOrEqual(0); - const rule = css.slice(at, css.indexOf("}", at)); - const ms = (text: string, name: string) => { - const match = text.match(new RegExp(`${name}:\\s*([\\d.]+)(m?s);`))!; - return Number(match[1]) * (match[2] === "s" ? 1000 : 1); - }; - const period = ms(rule, "--cmux-wave-period"); - expect(period).toBeGreaterThan(ms(shared, "--cmux-wave-period")); - expect(period / ms(rule, "--cmux-wave-step")).toBeCloseTo( - ms(shared, "--cmux-wave-period") / ms(shared, "--cmux-wave-step"), - 6, - ); + const sharedPeriod = Number(shared.match(/--cmux-wave-period:\s*([\d.]+)s;/)![1]) * 1000; + const sharedStep = Number(shared.match(/--cmux-wave-step:\s*([\d.]+)ms;/)![1]); + expect(SESSION_INFO_WAVE_PERIOD_MS).toBeGreaterThan(sharedPeriod); + expect(SESSION_INFO_WAVE_PERIOD_MS / SESSION_INFO_WAVE_STEP_MS).toBeCloseTo(sharedPeriod / sharedStep, 6); }); From 467e05a708122e124bcce7a6cf221809d14c80d5 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Mon, 2 Nov 2026 07:00:53 +0000 Subject: [PATCH 09/10] cmux NEXT: preserve native Session Info links and context hint Agent-Session: https://cmux.com/deeplink/agent?id=aid-dfff57bacb4f6c721f45 --- .../acpmux/session-info/native/changes.ts | 21 +-- .../acpmux/session-info/native/context.ts | 6 + .../acpmux/session-info/native/environment.ts | 11 +- .../acpmux/session-info/native/links.ts | 33 +++++ .../acpmux/session-info/native/outputs.ts | 8 +- .../acpmux/session-info/native/sources.ts | 10 +- .../test/native-session-info-sections.test.ts | 130 +++++++++++++++++- 7 files changed, 194 insertions(+), 25 deletions(-) create mode 100644 webviews/src/agent-session/acpmux/session-info/native/links.ts diff --git a/webviews/src/agent-session/acpmux/session-info/native/changes.ts b/webviews/src/agent-session/acpmux/session-info/native/changes.ts index a0e857c5f8f6..915463dba2e3 100644 --- a/webviews/src/agent-session/acpmux/session-info/native/changes.ts +++ b/webviews/src/agent-session/acpmux/session-info/native/changes.ts @@ -7,6 +7,7 @@ import type { SummaryPullRequest } from "../../summary/sessionSummary"; import { CHANGES_SHOWN, changesOrder, glyphState, type SessionInfoPullRequestStatus } from "../ChangesSection"; import type { PullRequestGlyphState } from "../PullRequestGlyph"; import type { NativeInfoActions, NativeInfoRow, NativeInfoSection, NativeInfoTone } from "./model"; +import { nativeExternalUrl, safeExternalUrl } from "./links"; const SYMBOLS: Record = { open: "arrow.triangle.pull", @@ -41,6 +42,7 @@ const CHECK_LABELS: Record = { passing: "rail.checksPassing", pending: "rail.checksPending", }; +const CHANGE_KINDS = ["code", "tests", "generated", "docs"] as const; export type ChangesInput = { /// The pull requests Changes lists (SessionInfo's `visibleChanges`), in any order. @@ -63,6 +65,7 @@ export function changesSection( t: Translate, ): NativeInfoSection | undefined { const { pullRequests, statuses, onOpenBeside, onExpandedChange } = input; + const openUrl = nativeExternalUrl(onOpenBeside); if (pullRequests.length === 0) return undefined; const expanded = input.expanded === true; const ordered = changesOrder(pullRequests, statuses); @@ -88,11 +91,11 @@ export function changesSection( } else if (checks && CHECK_LABELS[checks]) trailing = t(CHECK_LABELS[checks]!); const reference = `${pr.repo}#${pr.number}`; const menu = [ - ...(onOpenBeside + ...(safeExternalUrl(pr.url) ? [ { label: t("sessionInfo.changes.openBeside"), - action: actions.add(`changes.openMenu:${pr.url}`, () => onOpenBeside(pr.url)), + action: actions.add(`changes.openMenu:${pr.url}`, () => openUrl(pr.url)), }, ] : []), @@ -119,11 +122,11 @@ export function changesSection( ]; const breakdown = live.breakdown; const hoverDetail = breakdown - ? Object.entries(breakdown) - .map( - ([kind, totals]) => - `${t(`sessionInfo.changes.${kind}` as StringKey)} +${totals?.additions ?? 0} -${totals?.deletions ?? 0}`, - ) + ? CHANGE_KINDS.filter((kind) => breakdown[kind]) + .map((kind) => { + const totals = breakdown[kind]!; + return `${t(`sessionInfo.changes.${kind}` as StringKey)} +${totals.additions} -${totals.deletions}`; + }) .join(" · ") : undefined; return { @@ -140,9 +143,9 @@ export function changesSection( title: (live.title ?? pr.title) || `#${pr.number}`, ...(hoverDetail ? { detail: hoverDetail } : {}), }, - ...(onOpenBeside + ...(safeExternalUrl(pr.url) ? { - action: actions.add(`changes.open:${pr.url}`, () => onOpenBeside(pr.url)), + action: actions.add(`changes.open:${pr.url}`, () => openUrl(pr.url)), } : {}), ...(menu.length ? { menu, menuLabel: t("sessionInfo.changes.more", { reference }) } : {}), diff --git a/webviews/src/agent-session/acpmux/session-info/native/context.ts b/webviews/src/agent-session/acpmux/session-info/native/context.ts index 15b6799374de..dab278bf2f02 100644 --- a/webviews/src/agent-session/acpmux/session-info/native/context.ts +++ b/webviews/src/agent-session/acpmux/session-info/native/context.ts @@ -85,6 +85,9 @@ export function contextSection( }, ]; const breakdown = contextBreakdown(input.breakdown); + const free = breakdown?.rows.some((row) => row.kind === "buffer") + ? breakdown.rows.find((row) => row.kind === "free") + : undefined; let segments: { share: number; color: string }[] | undefined; if (breakdown) { segments = breakdown.rows @@ -102,6 +105,9 @@ export function contextSection( trailing: `${tokens.format(row.tokens)} · ${row.share === undefined ? "—" : share.format(row.share)}`, tone: row.kind === "used" ? undefined : "muted", symbolColor: row.kind === "used" ? `context${row.tone ?? 0}` : row.kind === "buffer" ? "buffer" : "free", + ...(row.kind === "free" && free && input.onCompact + ? { subtitle: t("sessionInfo.context.untilAutoCompact", { tokens: tokens.format(free.tokens) }) } + : {}), ...(drawn ? { meter: row.share } : {}), }); }); diff --git a/webviews/src/agent-session/acpmux/session-info/native/environment.ts b/webviews/src/agent-session/acpmux/session-info/native/environment.ts index ad1c7a249bb4..9c891e2658fb 100644 --- a/webviews/src/agent-session/acpmux/session-info/native/environment.ts +++ b/webviews/src/agent-session/acpmux/session-info/native/environment.ts @@ -10,6 +10,7 @@ import { machineOf, type ProjectRowLook } from "../EnvironmentSection"; import type { AttachedCloudRepo, PendingCloudRepo } from "../CloudRepositoryPicker"; import type { NativeInfoRect } from "./model"; import type { NativeInfoActions, NativeInfoRow, NativeInfoSection } from "./model"; +import { nativeExternalUrl } from "./links"; type Summary = NonNullable; @@ -44,6 +45,7 @@ export function environmentSection( t: Translate, ): NativeInfoSection | undefined { const { summary, repository, remoteControl, onOpenFolder, onOpenUrl, projectLook = "name", grants = [] } = input; + const openUrl = nativeExternalUrl(onOpenUrl); const machine = machineOf(summary, input.machineName, input.peerKinds); const branch = summary?.branch; const project = chatProject(summary, repository); @@ -128,9 +130,9 @@ export function environmentSection( id: "repository", symbol: "chevron.left.forwardslash.chevron.right", title: repository, - ...(onOpenUrl && repositoryUrl + ...(repositoryUrl ? { - action: actions.add("environment.openRepository", () => onOpenUrl(repositoryUrl)), + action: actions.add("environment.openRepository", () => openUrl(repositoryUrl)), } : {}), }); @@ -139,9 +141,9 @@ export function environmentSection( id: "branch", symbol: "arrow.triangle.branch", title: branch, - ...(onOpenUrl && branchUrl + ...(branchUrl ? { - action: actions.add("environment.openBranch", () => onOpenUrl(branchUrl)), + action: actions.add("environment.openBranch", () => openUrl(branchUrl)), } : {}), }); @@ -153,6 +155,7 @@ export function environmentSection( id: "remoteControl", symbol: "cursorarrow.click", title: label, + help: t("sessionInfo.environment.remoteControl.help"), tone: remoteControl.on ? "accent" : undefined, action: toggle, toggle: remoteControl.on, diff --git a/webviews/src/agent-session/acpmux/session-info/native/links.ts b/webviews/src/agent-session/acpmux/session-info/native/links.ts new file mode 100644 index 000000000000..6e024167ad8d --- /dev/null +++ b/webviews/src/agent-session/acpmux/session-info/native/links.ts @@ -0,0 +1,33 @@ +// External Session Info links use the page's existing preview callback when one is available. +// Quick Chat has no side panel, so its native rows route a safe web URL through the same chip host +// used by transcript link cards (`browser.openIn`, browserId `cmux`). +import { callChipHost } from "../../chips/host"; + +export type NativeExternalUrlOpener = (url: string) => void; + +/// Returns an absolute web URL the native panel may open. Credentials and non-web schemes stay out +/// of native actions even when a transcript source happened to contain them. +export function safeExternalUrl(value: string): string | undefined { + try { + const url = new URL(value); + if ((url.protocol !== "http:" && url.protocol !== "https:") || !url.hostname || url.username || url.password) + return undefined; + return url.toString(); + } catch { + return undefined; + } +} + +/// Keeps the existing preview action when supplied; otherwise opens the URL in cmux's browser via +/// the established chip host route. The host owns gesture and outside-app policy. +export function nativeExternalUrl(onOpenUrl?: NativeExternalUrlOpener): NativeExternalUrlOpener { + return (value) => { + const url = safeExternalUrl(value); + if (!url) return; + if (onOpenUrl) { + onOpenUrl(value); + return; + } + void callChipHost("browser.openIn", { url, browserId: "cmux" }); + }; +} diff --git a/webviews/src/agent-session/acpmux/session-info/native/outputs.ts b/webviews/src/agent-session/acpmux/session-info/native/outputs.ts index 13acf92a433d..97b02403230b 100644 --- a/webviews/src/agent-session/acpmux/session-info/native/outputs.ts +++ b/webviews/src/agent-session/acpmux/session-info/native/outputs.ts @@ -5,6 +5,7 @@ import type { Artifact } from "../../conversation/artifactPreview"; import type { TurnFile } from "../../diff"; import { OUTPUTS_SHOWN, type OutputArtifact, type OutputLink } from "../OutputsSection"; import type { NativeInfoActions, NativeInfoRow, NativeInfoSection } from "./model"; +import { nativeExternalUrl, safeExternalUrl } from "./links"; export type OutputsInput = { files: readonly TurnFile[]; @@ -30,6 +31,7 @@ export function outputsSection( t: Translate, ): NativeInfoSection | undefined { const { files, links, artifacts, documents = [], onOpenFile, onOpenArtifacts, onOpenDocument, onOpenUrl } = input; + const openUrl = nativeExternalUrl(onOpenUrl); // As SessionInfo shows it: any file, link, gallery item, or document it can open. if (files.length + links.length + artifacts + (onOpenDocument ? documents.length : 0) === 0) return undefined; const rows: NativeInfoRow[] = []; @@ -74,10 +76,8 @@ export function outputsSection( title: link.label, trailing: t("sessionInfo.outputs.link"), help: link.url, - ...(onOpenUrl - ? { - action: actions.add(`outputs.openLink:${link.url}`, () => onOpenUrl(link.url)), - } + ...(safeExternalUrl(link.url) + ? { action: actions.add(`outputs.openLink:${link.url}`, () => openUrl(link.url)) } : {}), }); const expanded = input.expanded === true; diff --git a/webviews/src/agent-session/acpmux/session-info/native/sources.ts b/webviews/src/agent-session/acpmux/session-info/native/sources.ts index 8c25b20a302b..30fe3185fd8a 100644 --- a/webviews/src/agent-session/acpmux/session-info/native/sources.ts +++ b/webviews/src/agent-session/acpmux/session-info/native/sources.ts @@ -4,6 +4,7 @@ import type { Translate } from "../../i18n"; import type { SummarySource, SummarySourceKind } from "../../summary/sessionSummary"; import { SOURCES_SHOWN, scopedPath } from "../SourcesSection"; import type { NativeInfoActions, NativeInfoRow, NativeInfoSection } from "./model"; +import { nativeExternalUrl, safeExternalUrl } from "./links"; const SYMBOLS: Record = { file: "doc.text", @@ -33,6 +34,7 @@ export function sourcesSection( t: Translate, ): NativeInfoSection | undefined { const { sources, cwd, onOpenFile, onOpenBeside, expanded, onExpandedChange } = input; + const openUrl = nativeExternalUrl(onOpenBeside); if (sources.length === 0) return undefined; const folds = sources.length > SOURCES_SHOWN; const shown = folds && !expanded ? sources.slice(0, SOURCES_SHOWN) : sources; @@ -52,15 +54,11 @@ export function sourcesSection( action: actions.add(`sources.openFile:${path}`, () => onOpenFile(path)), }; const url = source.url; - if (source.kind !== "file" && url) + if (source.kind !== "file" && url && safeExternalUrl(url)) return { ...row, help: url, - ...(onOpenBeside - ? { - action: actions.add(`sources.open:${url}`, () => onOpenBeside(url)), - } - : {}), + action: actions.add(`sources.open:${url}`, () => openUrl(url)), }; return row; }); diff --git a/webviews/test/native-session-info-sections.test.ts b/webviews/test/native-session-info-sections.test.ts index 7adab4d29c08..b9a8ffd08fee 100644 --- a/webviews/test/native-session-info-sections.test.ts +++ b/webviews/test/native-session-info-sections.test.ts @@ -1,5 +1,6 @@ // l10n-allow-file: deterministic session, repository, file and action fixtures. import { expect, test } from "bun:test"; +import { setChipHost } from "../src/agent-session/acpmux/chips/host"; import { translate, type Translate } from "../src/agent-session/acpmux/i18n"; import type { SummaryPullRequest } from "../src/agent-session/acpmux/summary/sessionSummary"; import { changesSection } from "../src/agent-session/acpmux/session-info/native/changes"; @@ -8,6 +9,7 @@ import { environmentSection } from "../src/agent-session/acpmux/session-info/nat import { NativeInfoActions } from "../src/agent-session/acpmux/session-info/native/model"; import { outputsSection } from "../src/agent-session/acpmux/session-info/native/outputs"; import { sourcesSection } from "../src/agent-session/acpmux/session-info/native/sources"; +import { safeExternalUrl } from "../src/agent-session/acpmux/session-info/native/links"; const t: Translate = (key, values) => translate(key, values, "en"); @@ -48,7 +50,11 @@ test("Cloud environment actions retain anchors, grants and one Remote Control sw expect(section.bare).toBe(true); expect(section.rows.find((row) => row.id === "machine")!.trailing).toBe(t("composer.cloud")); const control = section.rows.find((row) => row.id === "remoteControl")!; - expect(control).toMatchObject({ toggle: true, symbol: "cursorarrow.click" }); + expect(control).toMatchObject({ + toggle: true, + symbol: "cursorarrow.click", + help: t("sessionInfo.environment.remoteControl.help"), + }); expect(control.buttons).toBeUndefined(); const rect = { left: 10, top: 20, right: 90, bottom: 46 }; actions.run(section.rows.find((row) => row.id === "cloud-add-repository")!.action!, rect); @@ -62,6 +68,102 @@ test("Cloud environment actions retain anchors, grants and one Remote Control sw expect(section.rows.some((row) => row.id === "cloud-pending:example/pending")).toBe(true); }); +test("native external links preserve preview callbacks and use the chip host fallback", async () => { + const opened: string[] = []; + const environment = environmentSection( + { + summary: { sessionId: "links", hostKind: "local", host: "Maple", cwd: "/repo", branch: "main" }, + repository: "example/project", + onOpenUrl: (url) => opened.push(url), + }, + new NativeInfoActions(), + t, + )!; + const environmentActions = new NativeInfoActions(); + const rebuilt = environmentSection( + { + summary: { sessionId: "links", hostKind: "local", host: "Maple", cwd: "/repo", branch: "main" }, + repository: "example/project", + onOpenUrl: (url) => opened.push(url), + }, + environmentActions, + t, + )!; + environmentActions.run(rebuilt.rows.find((row) => row.id === "repository")!.action!); + environmentActions.run(rebuilt.rows.find((row) => row.id === "branch")!.action!); + await Promise.resolve(); + expect(opened).toEqual(["https://github.com/example/project", "https://github.com/example/project/tree/main"]); + expect(environment.rows.find((row) => row.id === "repository")?.action).toBeDefined(); + expect(environment.rows.find((row) => row.id === "branch")?.action).toBeDefined(); + + const changesActions = new NativeInfoActions(); + const changes = changesSection( + { + pullRequests: [ + { + url: "https://github.com/example/project/pull/4", + repo: "example/project", + number: 4, + title: "Links", + state: "open", + opened: true, + }, + ], + statuses: {}, + onOpenBeside: (url) => opened.push(url), + open: true, + }, + changesActions, + t, + )!; + changesActions.run(changes.rows[0]!.action!); + const sourcesActions = new NativeInfoActions(); + const sources = sourcesSection( + { + sources: [{ kind: "url", url: "https://example.com/source", label: "Source" }], + onOpenBeside: (url) => opened.push(url), + expanded: false, + onExpandedChange: () => {}, + open: true, + }, + sourcesActions, + t, + )!; + sourcesActions.run(sources.rows[0]!.action!); + await Promise.resolve(); + expect(opened).toEqual([ + "https://github.com/example/project", + "https://github.com/example/project/tree/main", + "https://github.com/example/project/pull/4", + "https://example.com/source", + ]); + + const calls: { method: string; params: Record }[] = []; + setChipHost(async (method, params) => { + calls.push({ method, params }); + return undefined; + }); + const actions = new NativeInfoActions(); + const outputs = outputsSection( + { + files: [], + artifacts: 0, + links: [{ url: "https://example.com/result", label: "Result" }], + open: true, + }, + actions, + t, + )!; + actions.run(outputs.rows[0]!.action!); + await Promise.resolve(); + await Promise.resolve(); + setChipHost(undefined); + expect(calls).toEqual([ + { method: "browser.openIn", params: { url: "https://example.com/result", browserId: "cmux" } }, + ]); + expect(safeExternalUrl("javascript:alert(1)")).toBeUndefined(); +}); + test("Context keeps measured colored segments, expandable lists and Compact", async () => { let compacted = false; let opened: unknown; @@ -75,6 +177,7 @@ test("Context keeps measured colored segments, expandable lists and Compact", as categories: [ { name: "Messages", tokens: 300, kind: "used" }, { name: "Buffer", tokens: 100, kind: "buffer" }, + { name: "Free", tokens: 600, kind: "free" }, ], maxTokens: 1000, mcpTools: [{ name: "mcp__files__read", serverName: "files", tokens: 20 }], @@ -97,11 +200,33 @@ test("Context keeps measured colored segments, expandable lists and Compact", as symbol: "square.fill", symbolColor: "context0", }); + expect(section.rows.find((row) => row.title === "Free")?.subtitle).toBe( + t("sessionInfo.context.untilAutoCompact", { tokens: "600" }), + ); actions.run(section.rows[0]!.buttons![0]!.action); actions.run(section.rows.find((row) => row.id === "list:mcpTools")!.action!); await Promise.resolve(); expect(compacted).toBe(true); expect(opened).toBe("mcpTools"); + + const withoutCompact = contextSection( + { + usage: { used: 300, size: 1000 }, + language: "en", + open: true, + breakdown: { + categories: [ + { name: "Messages", tokens: 300, kind: "used" }, + { name: "Buffer", tokens: 100, kind: "buffer" }, + { name: "Free", tokens: 600, kind: "free" }, + ], + maxTokens: 1000, + }, + }, + new NativeInfoActions(), + t, + )!; + expect(withoutCompact.rows.find((row) => row.title === "Free")?.subtitle).toBeUndefined(); }); test("Outputs folds the combined list to five and View all invokes the page choice", async () => { @@ -137,7 +262,7 @@ test("Changes exposes current overflow actions and line breakdown", async () => [url]: { additions: 10, deletions: 2, - breakdown: { code: { additions: 8, deletions: 2 }, tests: { additions: 2, deletions: 0 } }, + breakdown: { tests: { additions: 2, deletions: 0 }, code: { additions: 8, deletions: 2 }, docs: undefined }, }, }, open: true, @@ -150,6 +275,7 @@ test("Changes exposes current overflow actions and line breakdown", async () => )!; const row = section.rows[0]!; expect(row.hover!.detail).toContain("+8 -2"); + expect(row.hover!.detail).toContain(`${t("sessionInfo.changes.code")} +8 -2`); expect(row.menuLabel).toBe(t("sessionInfo.changes.more", { reference: "example/project#7" })); expect(row.symbolColor).toBe("green"); for (const item of row.menu!) actions.run(item.action); From d0468e003ed91f0860e4ad5de9dc84dd29efa154 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Mon, 2 Nov 2026 07:44:42 +0000 Subject: [PATCH 10/10] fix native task links and activity row types Agent-Session: https://cmux.com/deeplink/agent?id=aid-109d06b293c167d1bda3 --- .../src/agent-session/acpmux/session-info/native/model.ts | 7 +++++++ .../src/agent-session/acpmux/session-info/native/tasks.ts | 7 ++++--- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/webviews/src/agent-session/acpmux/session-info/native/model.ts b/webviews/src/agent-session/acpmux/session-info/native/model.ts index d0ee52d875f0..239a9093bb1e 100644 --- a/webviews/src/agent-session/acpmux/session-info/native/model.ts +++ b/webviews/src/agent-session/acpmux/session-info/native/model.ts @@ -6,6 +6,7 @@ // click comes back as `sessionInfoAction {id}` and runs the action the page registered for that id. import type { SessionInfoGroupId } from "../sectionOrder"; import type { SessionInfoSectionId } from "../sessionInfoState"; +import type { NativeInfoWave } from "./wave"; /// How a row or a section's value reads: running rows draw a spinner, failed ones the error ink. export type NativeInfoTone = "running" | "waiting" | "failed" | "done" | "muted" | "accent"; @@ -30,10 +31,16 @@ export type NativeInfoRow = { title: string; /// The row's existing harness mark, when it is an agent. harness?: string; + /// A retrying agent uses the native spinner instead of its harness glyph. + working?: boolean; + /// A regular running row's shared Session Info wave position and cadence. + wave?: NativeInfoWave; /// Dimmer text after the title on the same line (a command's folder, a subagent's prompt). detail?: string; /// A second line under the title. subtitle?: string; + /// Progress parts such as workflow counts and queued/failed agents. + progress?: string; /// Per-agent state marks in a workflow phase, in the page's current order. marks?: readonly NativeInfoTone[]; /// Right-aligned text (a time, a count, a state). diff --git a/webviews/src/agent-session/acpmux/session-info/native/tasks.ts b/webviews/src/agent-session/acpmux/session-info/native/tasks.ts index 27c0f8ecc0b5..0815d1399643 100644 --- a/webviews/src/agent-session/acpmux/session-info/native/tasks.ts +++ b/webviews/src/agent-session/acpmux/session-info/native/tasks.ts @@ -22,6 +22,7 @@ import { import { COMMAND_STATE } from "../TasksSection"; import { compactTokens } from "../../conversation/CompactionNote"; import type { NativeInfoActions, NativeInfoButton, NativeInfoRow, NativeInfoSection, NativeInfoTone } from "./model"; +import { nativeExternalUrl } from "./links"; export type TasksSectionInput = { /// The section is unfolded (Session Info's `state.tasks`). @@ -97,19 +98,19 @@ export function tasksSection( }; }; const rows: NativeInfoRow[] = [...workflowRows(input, actions, t, stop)]; + const openUrl = nativeExternalUrl(input.onOpenUrl); // Servers, newest first: the command, the ports it names (each explicit port is its own button) // and how long it has run. The row itself stays plain, like ServersSection.tsx. for (const server of [...servers].sort((a, b) => b.startedAt - a.startedAt)) { const urls = server.ports.map((port) => `http://localhost:${port}`); - const onOpenUrl = input.onOpenUrl; const buttons: NativeInfoButton[] = []; - if (onOpenUrl) + if (urls.length > 0) for (const url of urls) buttons.push({ label: t("sessionInfo.servers.open", { url }), symbol: "link", - action: actions.add(`tasks.openUrl:${server.id}:${url}`, () => onOpenUrl(url)), + action: actions.add(`tasks.openUrl:${server.id}:${url}`, () => openUrl(url)), }); if (input.onStopProcess) buttons.push(stop(server.id, server.command, input.onStopProcess, `tasks.stop:${server.id}`));