Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
fcd49ce
fix(cmux-next): make pending access picker interactive
teamleaderleo Oct 17, 2026
9418736
fix(cmux-next): make remembered access mode clickable
teamleaderleo Oct 17, 2026
6cb1a1d
fix(cmux-next): expose harness full-access start mode
teamleaderleo Oct 17, 2026
d36894f
fix(cmux-next): flatten access menu and starter controls
teamleaderleo Oct 17, 2026
617e6b8
fix(cmux-next): remove picker selection gutters
teamleaderleo Oct 17, 2026
95f958e
fix(cmux-next): ratchet removed composer font size
teamleaderleo Oct 17, 2026
83b74e5
chore(acpmux): refresh generated mail protocol types
teamleaderleo Oct 17, 2026
cf9f7c7
fix(cmux-next): align pending access test with menu
teamleaderleo Oct 17, 2026
9cd66b5
test(cmux-next): assert hidden permission checkmarks
teamleaderleo Oct 17, 2026
d20b2b8
Merge remote-tracking branch 'origin/feat-cmux-next' into fix/compose…
teamleaderleo Oct 19, 2026
a4cb6b5
test(cmux-next): a pending chat's access menu must name the start mod…
teamleaderleo Oct 19, 2026
6c8be33
fix(cmux-next): pending access menu checks acpmux's start mode, never…
teamleaderleo Oct 19, 2026
f59ccd9
test(cmux-next): a pending access pick must have somewhere to go; Pla…
teamleaderleo Oct 19, 2026
057272b
fix(cmux-next): pending access menu only where a pick is held; Plan s…
teamleaderleo Oct 19, 2026
2ada973
fix(cmux-next): a New Tab holds a pending mode pick only once acpmux …
teamleaderleo Oct 19, 2026
f43d315
chore(next): PR autofix at 2ada9730f286
Oct 19, 2026
73740a8
Merge remote-tracking branch 'origin/feat-cmux-next' into fix/compose…
teamleaderleo Oct 19, 2026
19e438a
Merge remote-tracking branch 'origin/feat-cmux-next' into fix/compose…
teamleaderleo Oct 19, 2026
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
6 changes: 6 additions & 0 deletions webviews/src/agent-session/acpmux/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2276,6 +2276,12 @@ function DefaultComposerChips({
return (
<ComposerPickers
snapshot={startsChat ? newTabChipSnapshot(snapshot, picker.catalog) : snapshot}
// A New Tab pick is held once acpmux's actions are connected and the chat has a harness to
// start (start() sends chat.new first); before that a pick would reach nothing.
holdsModePick={
snapshot.switching !== undefined ||
(startsChat && !!snapshot.summary?.harness && window.cmuxAcpmuxActions?.["chat.new"] !== undefined)
}
permissionAccess={permissionAccess}
onModel={(modelId) => {
start();
Expand Down
29 changes: 25 additions & 4 deletions webviews/src/agent-session/acpmux/ComposerPickers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,9 @@ type Props = {
catalogRefresh?: CatalogRefreshState;
/** Joined host catalog supplied by the app root; direct tests keep using the daemon catalog. */
pickerCatalog?: PickerCatalog;
/** A mode pick before the chat has a session is held (a New Tab's start, a harness switch
* running): only then is a pending chat's start mode a live menu, else an inert chip. */
holdsModePick?: boolean;
};

export type ComposerPermissionGrant = {
Expand Down Expand Up @@ -178,6 +181,7 @@ export function ComposerPickers({
showPlan = true,
catalogRefresh,
pickerCatalog,
holdsModePick = false,
}: Props) {
const t = useT();
// Context usage rides on the model chip's tooltip, unless the person hid it (composerSettings.ts);
Expand Down Expand Up @@ -546,6 +550,23 @@ export function ComposerPickers({
?.name ??
startMode.modeId)
: undefined;
// Until the chat reports its modes, the start mode acpmux answered is a live menu (not an inert
// chip) where a pick is held (`holdsModePick`): the harness's last mode catalog for the rows,
// checked at the start mode, never at the mode its last chat showed, and named as acpmux named
// it. A start in Plan keeps the inert chip: Plan is never an access row.
const pendingAccessModes: Choice[] = (() => {
if (!holdsModePick || !startMode || startMode.borrowed || isPlan(startMode.modeId)) return [];
const known = (harnessProfiles.get(chipHarness)?.modes?.availableModes ?? [])
.filter((choice) => !isPlan(choice.id))
.map((choice) => ({
id: choice.id,
name: (choice.id === startMode.modeId ? startMode.name : undefined) || choice.name || choice.id,
description: choice.description,
}));
if (known.some((choice) => choice.id === startMode.modeId)) return known;
const description = unrestricted(startMode.modeId) ? t(PICKER_LABELS.fullAccessWarning) : undefined;
return [{ id: startMode.modeId, name: startModeName ?? startMode.modeId, description }, ...known];
})();
useEffect(() => {
if (!awaiting && chipHarness && summary?.sessionId && effortShown)
rememberLastChips(chipHarness, { effort: effortShown });
Expand Down Expand Up @@ -667,15 +688,15 @@ export function ComposerPickers({
) : (
!reported && pendingEffort && <PendingChip kind="effort" label={pendingEffort} />
)}
{modes.length === 0 && !hasGrants && startMode && startModeName && (
{modes.length === 0 && !hasGrants && startMode && startModeName && pendingAccessModes.length === 0 && (
<PendingChip kind="mode" label={startModeName} modeId={startMode.borrowed ? undefined : startMode.modeId} />
)}
{/* Granted folder trust and chat allowance stay revocable when the harness offers no modes. */}
{(modes.length > 0 || hasGrants) && (
{(modes.length > 0 || hasGrants || pendingAccessModes.length > 0) && (
<AccessMenu
label={t(PICKER_LABELS.mode)}
modes={modes}
current={mode?.id}
modes={modes.length > 0 ? modes : pendingAccessModes}
current={mode?.id ?? (pendingAccessModes.length > 0 ? startMode?.modeId : undefined)}
sessionId={summary?.sessionId}
warning={t(PICKER_LABELS.fullAccessWarning)}
permissionAccess={permissionAccess}
Expand Down
49 changes: 45 additions & 4 deletions webviews/test/access-chip-pending-warning.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ function reported(sessionId: string, currentModeId: string): Summary {
}

/// Renders the composer's chips for `summary` (undefined: a new chat whose session has not reported).
async function render(summary: Summary, cwd?: string) {
async function render(summary: Summary, cwd?: string, holdsModePick = true) {
const { createRoot } = await import("react-dom/client");
const { QueryClient, QueryClientProvider } = await import("@tanstack/react-query");
const { ComposerPickers } = await import("../src/agent-session/acpmux/ComposerPickers");
Expand Down Expand Up @@ -140,6 +140,7 @@ async function render(summary: Summary, cwd?: string) {
onEffort={() => undefined}
onHarness={() => undefined}
showPlan={false}
holdsModePick={holdsModePick}
/>
);
}
Expand Down Expand Up @@ -190,11 +191,11 @@ async function kept(answer: Answer) {
rememberStartMode("claude", undefined, answer);
}

async function fresh(cwd?: string) {
async function fresh(cwd?: string, holdsModePick = true) {
act(() => root?.unmount());
root = undefined;
host.replaceChildren();
await render(undefined, cwd);
await render(undefined, cwd, holdsModePick);
}

test("a new chat acpmux starts in full access draws its warning chip on the first frame", async () => {
Expand All @@ -205,7 +206,11 @@ test("a new chat acpmux starts in full access draws its warning chip on the firs

// A new chat, before acpmux answers again: its kept answer, warning included.
await fresh();
expect(host.querySelector(".acpmux-chip-pending.acpmux-mode")).not.toBeNull();
// The remembered mode is a live access menu while the new session is pending, so the person
// can change it before the harness answers. It is no longer an inert pending chip.
const pendingAccess = host.querySelector<HTMLButtonElement>(".acpmux-access-trigger");
expect(pendingAccess).not.toBeNull();
expect(pendingAccess?.disabled).toBe(false);
expect(access()).toEqual({ label: "Bypass permissions", warning: true });
await answer();
expect(access()).toEqual({ label: "Bypass permissions", warning: true });
Expand Down Expand Up @@ -286,3 +291,39 @@ test("a harness switch never draws another folder's start mode as the chat's cur
rememberStartMode("claude", "/unanswered", { modeId: "bypassPermissions", name: "Bypass permissions" });
expect(applySwitch(raw, view, [], profiles).summary?.modes?.currentModeId).toBe("bypassPermissions");
});

test("a pending chat whose pane cannot hold a mode pick keeps the inert chip", async () => {
// No New Tab start and no harness switch running: a pick would reach no session, so the chip
// names the start mode without offering a menu.
await kept({ modeId: "bypassPermissions", name: "Bypass permissions" });
const answer = await acpmux({ modeId: "bypassPermissions", name: "Bypass permissions" });
await fresh(undefined, false);
await answer();
expect(host.querySelector(".acpmux-chip-pending.acpmux-mode")).not.toBeNull();
expect(host.querySelector("button.acpmux-access-trigger")).toBeNull();
expect(access()).toEqual({ label: "Bypass permissions", warning: true });
});

test("a pending chat that starts in Plan keeps the inert chip: Plan is never an access row", async () => {
await kept({ modeId: "plan", name: "Plan" });
const answer = await acpmux({ modeId: "plan", name: "Plan" });
await fresh();
await answer();
expect(host.querySelector(".acpmux-chip-pending.acpmux-mode")).not.toBeNull();
});

// Last: it fills the shared profile store (`harnessProfiles` keeps no reset), which every
// test above relies on being empty.
test("a pending chat's access menu names acpmux's start mode, never the mode this harness's last chat showed", async () => {
const { harnessProfiles } = await import("../src/agent-session/acpmux/harnessProfiles");
type Snapshot = import("../src/agent-session/acpmux/model").AcpmuxSnapshot;
// The last Claude chat sat in "Ask before edits"; new chats start in full access.
harnessProfiles.observe(reported("earlier", "default") as Snapshot["summary"]);
await kept({ modeId: "bypassPermissions", name: "Bypass permissions" });
const answer = await acpmux({ modeId: "bypassPermissions", name: "Bypass permissions" });
await fresh();
expect(access()).toEqual({ label: "Bypass permissions", warning: true });
expect(host.querySelector<HTMLButtonElement>(".acpmux-access-trigger")?.disabled).toBe(false);
await answer();
expect(access()).toEqual({ label: "Bypass permissions", warning: true });
});
12 changes: 12 additions & 0 deletions webviews/test/agent-pane-start-mode-chip.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,18 @@ await requireBrowserLane("agent-pane-start-mode-chip.test.ts", async () => {
});
await other.getByText("Docs updated.").waitFor({ state: "detached" });
expect(await settledChip(other)).toBe("Full access");
// A sessionless pending chat still exposes the harness-specific full-access mode as a real
// menu, rather than a disabled placeholder. The same path works for Claude and Codex mode ids.
const pendingAccess = other.locator(".acpmux-access-trigger");
expect(await pendingAccess.isEnabled()).toBe(true);
await pendingAccess.click();
await other.locator(".acpmux-access-menu .acpmux-access-item", { hasText: "Full access" }).waitFor();
expect(
await other
.locator(".acpmux-access-menu .acpmux-access-item", { hasText: "Full access" })
.getAttribute("aria-checked"),
).toBe("true");
await other.keyboard.press("Escape");
// Then the person picks Normal in another pane: the pending chat names Normal before any send.
const picked = await context.newPage();
await picked.goto(url(PICKED));
Expand Down