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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -29,64 +30,117 @@ 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<unknown>;
}) {
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<SubagentLimitAsk>();
const askedRef = useRef<SubagentLimitAsk | undefined>(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, () => {
answered();
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<unknown>;
}) {
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 (
<div className="acpmux-subagent-limit">
Expand All @@ -100,7 +154,7 @@ export function SubagentLimitStepper({
)}
</span>
<output className="acpmux-subagent-limit__refused">
{failed ? t("sessionInfo.subagents.limit.refused") : ""}
{state === "failed" ? t("sessionInfo.subagents.limit.refused") : ""}
</output>
{onSet && (
<span className="acpmux-subagent-limit__controls">
Expand All @@ -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")}
</button>
Expand Down
Original file line number Diff line number Diff line change
@@ -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. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -254,7 +255,7 @@ export function SubagentsSection({
</li>
);
return (
<div className="acpmux-session-info-subagents">
<div className="acpmux-session-info-subagents" style={SESSION_INFO_WAVE_STYLE}>
{runningLines.length > 0 && (
<ul className="acpmux-summary-list acpmux-session-info-subagents__running">
{shown.map((item, index) => line(item, index, true))}
Expand Down
21 changes: 12 additions & 9 deletions webviews/src/agent-session/acpmux/session-info/native/changes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<PullRequestGlyphState, string> = {
open: "arrow.triangle.pull",
Expand Down Expand Up @@ -41,6 +42,7 @@ const CHECK_LABELS: Record<string, StringKey> = {
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.
Expand All @@ -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);
Expand All @@ -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)),
},
]
: []),
Expand All @@ -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 {
Expand All @@ -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 }) } : {}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 } : {}),
});
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<AcpmuxSnapshot["summary"]>;

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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)),
}
: {}),
});
Expand All @@ -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)),
}
: {}),
});
Expand All @@ -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,
Expand Down
13 changes: 13 additions & 0 deletions webviews/src/agent-session/acpmux/session-info/native/limit.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
33 changes: 33 additions & 0 deletions webviews/src/agent-session/acpmux/session-info/native/links.ts
Original file line number Diff line number Diff line change
@@ -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" });
};
}
Loading
Loading