- {/* Main Content - Always Visible */}
-
- {/* Tool Icon */}
+ {/* Result Summary & Links */}
+ {toolCall.result && (
+
+
+
+ )}
+
+ {/* Error */}
+ {toolCall.error && (
+
+ )}
+
+ {/* Raw parameters — revealed together with the box */}
+ {hasParams && (
- {getToolIcon(toolCall.toolName)}
+
+ Parameters
+
+
+ {toolCall.parameters.map((param) => (
+
+
+ {param.name}:
+
+
+ {typeof param.value === 'string'
+ ? param.value
+ : JSON.stringify(param.value)}
+
+
+ ))}
+
+ )}
+
+ );
- {/* Content */}
-
- {/* Friendly Status Line */}
-
-
- {friendlyName}
-
-
- {toolCall.duration && (
-
- {formatDuration(toolCall.duration)}
-
- )}
-
+ const staticPill = (
+
+
+ {pillLabel}
+
+ );
- {/* Parameter Summary (user-friendly) */}
- {paramSummary && toolCall.status !== 'success' && (
-
- {paramSummary}
-
- )}
-
- {/* Result Summary & Links */}
- {toolCall.result && (
-
-
-
- )}
-
- {/* Error */}
- {toolCall.error && (
-
- )}
-
- {/* Show Details Toggle */}
- {collapsible && showParameters && toolCall.parameters.length > 0 && (
-
- )}
-
- {/* Technical Details (collapsed by default) */}
- {showDetails && showParameters && toolCall.parameters.length > 0 && (
-
-
- Parameters
-
-
- {toolCall.parameters.map((param) => (
-
-
- {param.name}:
-
-
- {typeof param.value === 'string'
- ? param.value
- : JSON.stringify(param.value)}
-
-
- ))}
-
-
- )}
+ return (
+
+ {!hasBoxContent ? (
+ staticPill
+ ) : collapsible ? (
+ // Pill click reveals the whole box (result + params) in one go.
+
}
+ density={compact ? 'condensed' : 'standard'}
+ defaultOpen={!effectiveDefaultCollapsed}
+ pillClassName={statusPillClasses[toolCall.status]}
+ title="Toggle details"
+ >
+ {box}
+
+ ) : (
+ // Non-collapsible: pill + box always visible.
+
+ {staticPill}
+ {box}
-
+ )}
);
}
diff --git a/src/components/AI/index.ts b/src/components/AI/index.ts
index ed50346b..dbae79b8 100644
--- a/src/components/AI/index.ts
+++ b/src/components/AI/index.ts
@@ -26,6 +26,9 @@ export {
type SpinnerIconProps,
} from './icons';
+// Collapsible Pill (shared primitive for thinking + tool display)
+export { CollapsiblePill, type CollapsiblePillProps } from './CollapsiblePill';
+
// MCP Tool Call Display
export {
MCPToolCallDisplay,
diff --git a/src/components/PillSelect/PillSelect.stories.tsx b/src/components/PillSelect/PillSelect.stories.tsx
new file mode 100644
index 00000000..a6ce4b2e
--- /dev/null
+++ b/src/components/PillSelect/PillSelect.stories.tsx
@@ -0,0 +1,113 @@
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import * as React from 'react';
+import { PillSelect } from './PillSelect';
+
+const meta: Meta
= {
+ title: 'Components/Navigation/PillSelect',
+ component: PillSelect,
+ tags: ['autodocs'],
+ parameters: { layout: 'centered' },
+ argTypes: {
+ value: { control: 'text' },
+ label: { control: 'text' },
+ disabled: { control: 'boolean' },
+ },
+};
+
+export default meta;
+type Story = StoryObj;
+
+const defaultOptions = [
+ { value: 'option-1', label: 'Option 1' },
+ { value: 'option-2', label: 'Option 2' },
+ { value: 'option-3', label: 'Option 3' },
+ { value: 'option-4', label: 'Option 4' },
+];
+
+/** Default collapsed state — click to expand and pick an option. */
+export const Default: Story = {
+ args: {
+ options: defaultOptions,
+ defaultValue: 'option-2',
+ label: 'Mode',
+ },
+};
+
+/** No label — shows only the selected option value in collapsed state. */
+export const NoLabel: Story = {
+ args: {
+ options: defaultOptions,
+ defaultValue: 'option-1',
+ },
+};
+
+/** Two options. */
+export const TwoOptions: Story = {
+ args: {
+ options: [
+ { value: 'off', label: 'Off' },
+ { value: 'on', label: 'On' },
+ ],
+ defaultValue: 'on',
+ label: 'Feature',
+ },
+};
+
+/** Many options. */
+export const ManyOptions: Story = {
+ args: {
+ options: [
+ { value: '1', label: 'Option 1' },
+ { value: '2', label: 'Option 2' },
+ { value: '3', label: 'Option 3' },
+ { value: '4', label: 'Option 4' },
+ { value: '5', label: 'Option 5' },
+ { value: '6', label: 'Option 6' },
+ ],
+ defaultValue: '3',
+ label: 'Size',
+ },
+};
+
+/** One option disabled. */
+export const WithDisabledOption: Story = {
+ args: {
+ options: [
+ { value: 'option-1', label: 'Option 1' },
+ { value: 'option-2', label: 'Option 2', disabled: true },
+ { value: 'option-3', label: 'Option 3' },
+ ],
+ defaultValue: 'option-1',
+ label: 'Mode',
+ },
+};
+
+function ControlledDemo() {
+ const [value, setValue] = React.useState('option-2');
+ return (
+
+ );
+}
+
+/** Controlled — parent owns the value. */
+export const Controlled: Story = {
+ render: () => ,
+};
+
+/** Disabled — collapsed pill is not clickable. */
+export const Disabled: Story = {
+ args: {
+ options: defaultOptions,
+ defaultValue: 'option-2',
+ label: 'Mode',
+ disabled: true,
+ },
+};
diff --git a/src/components/PillSelect/PillSelect.test.tsx b/src/components/PillSelect/PillSelect.test.tsx
new file mode 100644
index 00000000..18d5f438
--- /dev/null
+++ b/src/components/PillSelect/PillSelect.test.tsx
@@ -0,0 +1,139 @@
+import { describe, expect, it, vi } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { renderWithTheme } from '../../test/test-utils';
+import { PillSelect } from './PillSelect';
+
+const options = [
+ { value: 'small', label: 'Small' },
+ { value: 'medium', label: 'Medium' },
+ { value: 'large', label: 'Large' },
+];
+
+describe('PillSelect', () => {
+ it('renders an accessible fallback when no label or options are provided', () => {
+ renderWithTheme();
+
+ expect(screen.getByRole('button', { name: /no options/i })).toBeDisabled();
+ });
+
+ it('expands and selects an enabled option', async () => {
+ const user = userEvent.setup();
+ const onValueChange = vi.fn();
+
+ renderWithTheme(
+
+ );
+
+ const trigger = screen.getByRole('button', { name: /size: small/i });
+
+ await user.click(trigger);
+ expect(trigger).toBeVisible();
+ expect(
+ screen.getByRole('listbox', { name: /size options/i })
+ ).toBeVisible();
+
+ await user.click(screen.getByRole('option', { name: /medium/i }));
+
+ expect(onValueChange).toHaveBeenCalledWith('medium');
+ expect(screen.getByRole('button', { name: /size: medium/i })).toBeVisible();
+ });
+
+ it('does not select disabled options', async () => {
+ const user = userEvent.setup();
+ const onValueChange = vi.fn();
+
+ renderWithTheme(
+
+ );
+
+ await user.click(screen.getByRole('button', { name: /size: small/i }));
+ expect(screen.getByRole('option', { name: /medium/i })).toBeDisabled();
+ expect(onValueChange).not.toHaveBeenCalled();
+ });
+
+ it('uses the controlled value from props', async () => {
+ const user = userEvent.setup();
+ const onValueChange = vi.fn();
+
+ renderWithTheme(
+
+ );
+
+ expect(screen.getByRole('button', { name: /size: large/i })).toBeVisible();
+ await user.click(screen.getByRole('button', { name: /size: large/i }));
+ await user.click(screen.getByRole('option', { name: /small/i }));
+
+ expect(onValueChange).toHaveBeenCalledWith('small');
+ });
+
+ it('closes expanded options with Escape', async () => {
+ const user = userEvent.setup();
+
+ renderWithTheme(
+
+ );
+
+ await user.click(screen.getByRole('button', { name: /size: small/i }));
+ expect(
+ screen.getByRole('listbox', { name: /size options/i })
+ ).toBeVisible();
+
+ await user.keyboard('{Escape}');
+
+ expect(screen.queryByRole('listbox', { name: /size options/i })).toBeNull();
+ expect(screen.getByRole('button', { name: /size: small/i })).toBeVisible();
+ await waitFor(() =>
+ expect(screen.getByRole('button', { name: /size: small/i })).toHaveFocus()
+ );
+ });
+
+ it('renders options outside the trigger wrapper to avoid layout shift', async () => {
+ const user = userEvent.setup();
+ const { container } = renderWithTheme(
+
+ );
+
+ await user.click(screen.getByRole('button', { name: /size: small/i }));
+
+ const listbox = screen.getByRole('listbox', { name: /size options/i });
+
+ expect(container).not.toContainElement(listbox);
+ expect(screen.getByRole('button', { name: /size: small/i })).toBeVisible();
+ });
+
+ it('does not scroll the page when moving focus on open', async () => {
+ const user = userEvent.setup();
+ const focusSpy = vi.spyOn(HTMLElement.prototype, 'focus');
+
+ renderWithTheme(
+
+ );
+
+ await user.click(screen.getByRole('button', { name: /size: small/i }));
+
+ expect(
+ screen.getByRole('listbox', { name: /size options/i })
+ ).toBeVisible();
+ await waitFor(() =>
+ expect(focusSpy).toHaveBeenCalledWith({ preventScroll: true })
+ );
+
+ focusSpy.mockRestore();
+ });
+});
diff --git a/src/components/PillSelect/PillSelect.tsx b/src/components/PillSelect/PillSelect.tsx
new file mode 100644
index 00000000..703d8797
--- /dev/null
+++ b/src/components/PillSelect/PillSelect.tsx
@@ -0,0 +1,212 @@
+import * as React from 'react';
+import { createPortal } from 'react-dom';
+import { cn } from '../../utils/cn';
+import { useEscapeKey } from '../../hooks/useEscapeKey';
+
+// ============================================================================
+// Types
+// ============================================================================
+
+export interface PillSelectOption {
+ value: string;
+ label: string;
+ disabled?: boolean;
+}
+
+export interface PillSelectProps {
+ options: PillSelectOption[];
+ value?: string;
+ defaultValue?: string;
+ onValueChange?: (value: string) => void;
+ label?: string;
+ disabled?: boolean;
+ className?: string;
+}
+
+// ============================================================================
+// Component
+// ============================================================================
+
+export function PillSelect({
+ options,
+ value: controlledValue,
+ defaultValue,
+ onValueChange,
+ label,
+ disabled = false,
+ className,
+}: PillSelectProps) {
+ const [internalValue, setInternalValue] = React.useState(
+ defaultValue ?? options[0]?.value ?? ''
+ );
+ const [expanded, setExpanded] = React.useState(false);
+ const triggerRef = React.useRef(null);
+ const menuRef = React.useRef(null);
+ const selectedOptionRef = React.useRef(null);
+ const firstEnabledOptionRef = React.useRef(null);
+ const listboxId = React.useId();
+ const [menuStyle, setMenuStyle] = React.useState({});
+
+ const value = controlledValue !== undefined ? controlledValue : internalValue;
+ const selectedOption = options.find((o) => o.value === value);
+ const close = React.useCallback((restoreFocus = false) => {
+ setExpanded(false);
+ if (restoreFocus) {
+ requestAnimationFrame(() =>
+ triggerRef.current?.focus({ preventScroll: true })
+ );
+ }
+ }, []);
+
+ const updateMenuPosition = React.useCallback(() => {
+ const trigger = triggerRef.current;
+ if (!trigger) return;
+
+ const rect = trigger.getBoundingClientRect();
+ const viewportPadding = 8;
+ const menuWidth = Math.max(rect.width, 160);
+ const maxWidth = Math.max(window.innerWidth - viewportPadding * 2, 0);
+ const left = Math.min(
+ Math.max(rect.left, viewportPadding),
+ window.innerWidth - menuWidth - viewportPadding
+ );
+
+ setMenuStyle({
+ position: 'fixed',
+ top: rect.bottom + 4,
+ left,
+ minWidth: rect.width,
+ width: menuWidth,
+ maxWidth,
+ zIndex: 9999,
+ });
+ }, []);
+
+ React.useEffect(() => {
+ if (!expanded) return;
+
+ updateMenuPosition();
+ window.addEventListener('scroll', updateMenuPosition, true);
+ window.addEventListener('resize', updateMenuPosition);
+
+ return () => {
+ window.removeEventListener('scroll', updateMenuPosition, true);
+ window.removeEventListener('resize', updateMenuPosition);
+ };
+ }, [expanded, updateMenuPosition]);
+
+ React.useEffect(() => {
+ if (!expanded) return;
+
+ const handleClickOutside = (event: MouseEvent) => {
+ const target = event.target as Node;
+ if (
+ triggerRef.current?.contains(target) ||
+ menuRef.current?.contains(target)
+ ) {
+ return;
+ }
+
+ close();
+ };
+
+ document.addEventListener('mousedown', handleClickOutside);
+ return () => document.removeEventListener('mousedown', handleClickOutside);
+ }, [close, expanded]);
+
+ React.useEffect(() => {
+ if (!expanded) return;
+
+ requestAnimationFrame(() => {
+ (selectedOptionRef.current ?? firstEnabledOptionRef.current)?.focus({
+ preventScroll: true,
+ });
+ });
+ }, [expanded, value]);
+
+ useEscapeKey(() => close(true), expanded);
+
+ function handleSelect(option: PillSelectOption) {
+ if (disabled || option.disabled) return;
+ if (controlledValue === undefined) setInternalValue(option.value);
+ onValueChange?.(option.value);
+ close(true);
+ }
+
+ const hasOptions = options.length > 0;
+ const valuePart = selectedOption?.label ?? value;
+ const collapsedLabel =
+ label && valuePart
+ ? `${label}: ${valuePart}`
+ : label || valuePart || 'No options';
+ let firstEnabledOptionRefAssigned = false;
+
+ return (
+
+
+
+ {expanded &&
+ createPortal(
+
+ {options.map((option) => {
+ const selected = option.value === value;
+ const optionDisabled = disabled || option.disabled;
+ const isFirstEnabled =
+ !optionDisabled && !firstEnabledOptionRefAssigned;
+ if (isFirstEnabled) firstEnabledOptionRefAssigned = true;
+
+ return (
+
+ );
+ })}
+
,
+ document.body
+ )}
+
+ );
+}
diff --git a/src/components/PillSelect/index.ts b/src/components/PillSelect/index.ts
new file mode 100644
index 00000000..0d573a1a
--- /dev/null
+++ b/src/components/PillSelect/index.ts
@@ -0,0 +1,5 @@
+export {
+ PillSelect,
+ type PillSelectProps,
+ type PillSelectOption,
+} from './PillSelect';
diff --git a/src/index.ts b/src/index.ts
index a39ab906..bed41bab 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -140,6 +140,7 @@ export * from './components/ScheduleCalendar';
export * from './components/SchedulePicker';
export * from './components/ScrollArea';
export * from './components/Select';
+export * from './components/PillSelect';
export * from './components/ServiceAccordion';
export * from './components/ServiceBadge';
export * from './components/ServiceCard';
diff --git a/src/tailwind-preset.ts b/src/tailwind-preset.ts
index 05bd825f..73f3c686 100644
--- a/src/tailwind-preset.ts
+++ b/src/tailwind-preset.ts
@@ -477,6 +477,58 @@ export const miewebUISafelist = [
// injected