diff --git a/webviews/src/agent-session/acpmux/session-info/SubagentLimitStepper.tsx b/webviews/src/agent-session/acpmux/session-info/SubagentLimitStepper.tsx index 5a71ff783cc5..0025d2abac41 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; @@ -29,56 +30,70 @@ 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. - const [asked, setAsked] = useState<{ chat?: string; over: readonly number[]; value: number }>(); +}): SubagentLimitController { + const [asked, setAsked] = useState(); + const askedRef = useRef(undefined); 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 latestMax = useRef(max); + latestMax.current = max; 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]; - 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 -= 1; - if (inFlight.current === 0) setAsked(undefined); + inFlight.current = Math.max(0, inFlight.current - 1); + setInFlightCount(inFlight.current); + if (inFlight.current === 0) { + askedRef.current = undefined; + 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 +101,46 @@ export function SubagentLimitStepper({ setRefused({ chat, at: Date.now() }); }); }; - const step = (by: number) => set(clampRunning(max + by), max); + return { + max, + ...(pendingMax !== undefined ? { pendingMax } : {}), + ...(refused !== undefined && refused.chat === chatId + ? { state: "failed" as const } + : inFlightCount > 0 + ? { state: "pending" as const } + : {}), + set, + step: (by) => { + const from = latestMax.current; + const value = clampRunning(from + by); + latestMax.current = value; + set(value, from); + }, + 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 +154,7 @@ export function SubagentLimitStepper({ )} - {failed ? t("sessionInfo.subagents.limit.refused") : ""} + {state === "failed" ? t("sessionInfo.subagents.limit.refused") : ""} {onSet && ( @@ -110,7 +164,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/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/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/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/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/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/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/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..269219988294 --- /dev/null +++ b/webviews/src/agent-session/acpmux/session-info/native/subagents.ts @@ -0,0 +1,283 @@ +// 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 { 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"; +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"; +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[]; + /// 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; + /// 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; +}; + +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, input.onStep)); + type SubagentLine = { + key: string; + identity: string; + agent?: Subagent; + name: string; + detail?: string; + status: SubagentStatus; + state: string; + time?: string; + task?: string; + progress?: string; + kind?: string; + harness?: string; + working?: boolean; + 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)), + 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 => { + 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), + ); + 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, 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.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; + 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({ + 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(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, + onStep?: (by: number) => void, +): NativeInfoRow { + const max = pendingMax ?? limits.maxRunning; + const { running, queued } = slotCounts(limits, sessions); + const buttons: NativeInfoButton[] = []; + if (onSet || onStep) { + if (limits.maxRunningSource === "session" && onSet) + 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); + 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", 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", raiseAction), + ...(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..0815d1399643 --- /dev/null +++ b/webviews/src/agent-session/acpmux/session-info/native/tasks.ts @@ -0,0 +1,402 @@ +// 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 { useState } from "react"; +import { formatDuration } from "../../conversation/turns"; +import type { StringKey, Translate } from "../../i18n"; +import { openCommand } from "../../processTerminal"; +import { modelName, phaseAgents, phaseState, 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"; +import { nativeExternalUrl } from "./links"; + +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; + /// 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. + 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.startsWith("tasks.stopWorkflow:") ? `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)]; + 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 buttons: NativeInfoButton[] = []; + 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}`, () => openUrl(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", + ...(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, + }; +} + +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, + 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 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}`, + 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], + ...(live && onStop ? { buttons: [stop(item.id, item.title, onStop, `tasks.stopWorkflow:${item.id}`)] } : {}), + }); + 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 + ? workflowCounts(t, agents).join(" · ") + : counts.total > 0 + ? `${counts.done}/${counts.total}` + : ""; + rows.push({ + id: `phase:${key}`, + ...(agents.length > 0 ? { symbol: open ? "chevron.down" : "chevron.right" } : {}), + title: phase.name || t("workflow.unassigned"), + ...(label ? { trailing: label } : {}), + marks: agents.map((agent) => phaseMark(agent.state)), + indent: 1, + ...(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 model = modelEffort ? t("workflow.model", { model: modelEffort }) : 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: workflowAgentSymbol(agent.state), + title: agent.name, + ...(model ? { detail: model } : {}), + ...(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)), + } + : {}), + }); + } + } + }; + 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; +} 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/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); 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"]); +}); 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); }); 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 }); +});