feat(site/src/pages/AgentsPage/components): allow disengaging plan mode from Planning badge (#24651)

Adds an inline `X` button to the "Planning" indicator so users can
disengage plan mode directly from the chat input, without reopening the
`+` menu. Reuses the same pattern that already ships on the
attached-workspace and MCP-server badges.

- When `onPlanModeToggle` is provided and plan mode is on, the pill
renders a dismiss `X` next to the label; clicking it calls
`onPlanModeToggle(false)`.
- When no toggle handler is passed, no `X` renders (matches the other
badges).
- Extracts `BadgeDismissButton` inside `AgentChatInput.tsx` now that the
dismiss pattern lives in three places, collapsing ~24 lines of
duplicated markup.
- Storybook coverage: tightened `PlanningIndicator`, new
`DisablePlanModeFromBadge` (click fires `onPlanModeToggle(false)`), new
`PlanningIndicatorWithoutToggle` (no handler, no `X`).

### Demo

![Demo: open + menu, enable Plan first, click X on the Planning pill to
disengage](https://github.com/david-fraley/coder/raw/pr-24651-media/plan-mode-dismiss.gif)

<sub>Higher-quality
[MP4](https://github.com/david-fraley/coder/raw/pr-24651-media/plan-mode-dismiss.mp4)
also available.</sub>

<details>
<summary>Implementation plan</summary>

### Red / Green / Refactor

1. **Red**: Extended `AgentChatInput.stories.tsx` to assert the `X`
button exists in the Planning pill, clicking it fires
`onPlanModeToggle(false)`, and no `X` renders when `onPlanModeToggle` is
absent. Two stories failed as expected.
2. **Green**: Added an inline `<button aria-label="Disable plan mode">`
with `XIcon` to the Planning pill, gated on `onPlanModeToggle`, reusing
the existing `handlePlanModeToggle` handler. All 35 stories pass.
3. **Refactor**: Rule-of-three met with three duplicated dismiss-button
sites (workspace, MCP, planning). Extracted `BadgeDismissButton` with
`onClick` + `ariaLabel` props and replaced all three copies. Stories
still pass.

### Design notes

- `aria-label` is `"Disable plan mode"` (mode toggle, not item removal)
rather than `"Remove planning"` which would be misleading.
- Planning pill stays outside the `badgeContainerRef` overflow container
by design so it never collapses into the `+N` popover.
- No changes to the `Plan first` menu item in the `+` popover or its
behavior.

</details>

---

_This PR was opened by a Coder agent on behalf of @david-fraley._

---------

Co-authored-by: Jaayden Halko <jaayden@coder.com>
This commit is contained in:
david-fraley
2026-04-23 15:19:24 +01:00
committed by GitHub
co-authored by Jaayden Halko
parent f96f7b992f
commit 50dbb3d2cb
2 changed files with 68 additions and 14 deletions
@@ -653,6 +653,39 @@ export const PlanningIndicator: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("Planning")).toBeVisible();
expect(
canvas.getByRole("button", { name: "Disable plan mode" }),
).toBeVisible();
},
};
export const DisablePlanModeFromBadge: Story = {
args: {
planModeEnabled: true,
onPlanModeToggle: fn(),
},
play: async ({ args, canvasElement }) => {
const canvas = within(canvasElement);
const dismiss = canvas.getByRole("button", {
name: "Disable plan mode",
});
await userEvent.click(dismiss);
expect(args.onPlanModeToggle).toHaveBeenCalledTimes(1);
expect(args.onPlanModeToggle).toHaveBeenCalledWith(false);
},
};
export const PlanningIndicatorWithoutToggle: Story = {
args: {
planModeEnabled: true,
onPlanModeToggle: undefined,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("Planning")).toBeVisible();
expect(
canvas.queryByRole("button", { name: "Disable plan mode" }),
).not.toBeInTheDocument();
},
};
@@ -171,6 +171,26 @@ type ToolBadgeData =
| ({ kind: "attached-workspace" } & AttachedWorkspaceInfo)
| { kind: "mcp"; server: TypesGen.MCPServerConfig };
// Small `X` button rendered inside pill-style badges (attached
// workspace, MCP server, planning indicator) to dismiss or disable
// the badge without opening the `+` menu. Callers pass the action
// handler and a descriptive aria-label.
const BadgeDismissButton: FC<{
onClick: () => void;
ariaLabel: string;
isDisabled?: boolean;
}> = ({ onClick, ariaLabel, isDisabled = false }) => (
<button
type="button"
onClick={onClick}
disabled={isDisabled}
className="ml-0.5 inline-flex cursor-pointer items-center justify-center rounded-full border-0 bg-transparent p-0.5 text-content-secondary transition-colors hover:bg-surface-tertiary hover:text-content-primary disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-transparent disabled:hover:text-content-secondary"
aria-label={ariaLabel}
>
<XIcon className="!size-2.5" />
</button>
);
const ToolBadge: FC<{
badge: ToolBadgeData;
onRemoveWorkspace?: () => void;
@@ -210,14 +230,10 @@ const ToolBadge: FC<{
<MonitorIcon className="size-3" />
<span className="truncate">{badge.name}</span>
{onRemoveWorkspace && (
<button
type="button"
<BadgeDismissButton
onClick={onRemoveWorkspace}
className="ml-0.5 inline-flex cursor-pointer items-center justify-center rounded-full border-0 bg-transparent p-0.5 text-content-secondary transition-colors hover:bg-surface-tertiary hover:text-content-primary"
aria-label={`Remove workspace ${badge.name}`}
>
<XIcon className="!size-2.5" />
</button>
ariaLabel={`Remove workspace ${badge.name}`}
/>
)}
</span>
);
@@ -237,14 +253,10 @@ const ToolBadge: FC<{
)}
{badge.server.display_name}
{!isForceOn && onRemoveMcp && (
<button
type="button"
<BadgeDismissButton
onClick={() => onRemoveMcp(badge.server.id)}
className="ml-0.5 inline-flex cursor-pointer items-center justify-center rounded-full border-0 bg-transparent p-0.5 text-content-secondary transition-colors hover:bg-surface-tertiary hover:text-content-primary"
aria-label={`Remove ${badge.server.display_name}`}
>
<XIcon className="!size-2.5" />
</button>
ariaLabel={`Remove ${badge.server.display_name}`}
/>
)}
</span>
);
@@ -445,6 +457,8 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
setPlusMenuOpen(false);
};
const handleDisablePlanMode = () => onPlanModeToggle?.(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const [composerElement, setComposerElement] = useState<HTMLDivElement | null>(
null,
@@ -1065,6 +1079,13 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
<span className="hidden shrink-0 items-center gap-1 rounded-full bg-surface-secondary px-2 py-0.5 text-xs font-medium text-content-secondary md:inline-flex">
<PencilIcon className="size-3" />
Planning
{onPlanModeToggle && (
<BadgeDismissButton
onClick={handleDisablePlanMode}
ariaLabel="Disable plan mode"
isDisabled={isDisabled}
/>
)}
</span>
)}{" "}
{/* Badge row — all badges and the pill always