diff --git a/webviews/src/agent-session/acpmux/App.tsx b/webviews/src/agent-session/acpmux/App.tsx index 1ec38ba70d5e..dcfaaf46892a 100644 --- a/webviews/src/agent-session/acpmux/App.tsx +++ b/webviews/src/agent-session/acpmux/App.tsx @@ -2276,6 +2276,12 @@ function DefaultComposerChips({ return ( { start(); diff --git a/webviews/src/agent-session/acpmux/ComposerPickers.tsx b/webviews/src/agent-session/acpmux/ComposerPickers.tsx index 89883ffe06a9..0ffb689289fa 100644 --- a/webviews/src/agent-session/acpmux/ComposerPickers.tsx +++ b/webviews/src/agent-session/acpmux/ComposerPickers.tsx @@ -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 = { @@ -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); @@ -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 }); @@ -667,15 +688,15 @@ export function ComposerPickers({ ) : ( !reported && pendingEffort && )} - {modes.length === 0 && !hasGrants && startMode && startModeName && ( + {modes.length === 0 && !hasGrants && startMode && startModeName && pendingAccessModes.length === 0 && ( )} {/* 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) && ( 0 ? modes : pendingAccessModes} + current={mode?.id ?? (pendingAccessModes.length > 0 ? startMode?.modeId : undefined)} sessionId={summary?.sessionId} warning={t(PICKER_LABELS.fullAccessWarning)} permissionAccess={permissionAccess} diff --git a/webviews/test/access-chip-pending-warning.test.tsx b/webviews/test/access-chip-pending-warning.test.tsx index 5cbf36c0969d..0d397a76c4f3 100644 --- a/webviews/test/access-chip-pending-warning.test.tsx +++ b/webviews/test/access-chip-pending-warning.test.tsx @@ -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"); @@ -140,6 +140,7 @@ async function render(summary: Summary, cwd?: string) { onEffort={() => undefined} onHarness={() => undefined} showPlan={false} + holdsModePick={holdsModePick} /> ); } @@ -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 () => { @@ -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(".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 }); @@ -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(".acpmux-access-trigger")?.disabled).toBe(false); + await answer(); + expect(access()).toEqual({ label: "Bypass permissions", warning: true }); +}); diff --git a/webviews/test/agent-pane-start-mode-chip.test.ts b/webviews/test/agent-pane-start-mode-chip.test.ts index d2fc2cac12fc..5ccb7872a683 100644 --- a/webviews/test/agent-pane-start-mode-chip.test.ts +++ b/webviews/test/agent-pane-start-mode-chip.test.ts @@ -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));