mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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  <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:
co-authored by
Jaayden Halko
parent
f96f7b992f
commit
50dbb3d2cb
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user