- {/* 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..e42f347b
--- /dev/null
+++ b/src/components/PillSelect/PillSelect.test.tsx
@@ -0,0 +1,93 @@
+import { describe, expect, it, vi } from 'vitest';
+import { screen } 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(
+
+ );
+
+ await user.click(screen.getByRole('button', { name: /size: small/i }));
+ await user.click(screen.getByRole('button', { 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('button', { 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('button', { 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('group', { name: /size options/i })).toBeVisible();
+
+ await user.keyboard('{Escape}');
+
+ expect(screen.queryByRole('group', { name: /size options/i })).toBeNull();
+ expect(screen.getByRole('button', { name: /size: small/i })).toBeVisible();
+ });
+});
diff --git a/src/components/PillSelect/PillSelect.tsx b/src/components/PillSelect/PillSelect.tsx
new file mode 100644
index 00000000..b9f85f6a
--- /dev/null
+++ b/src/components/PillSelect/PillSelect.tsx
@@ -0,0 +1,113 @@
+import * as React from 'react';
+import { cn } from '../../utils/cn';
+import { useClickOutside } from '../../hooks/useClickOutside';
+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 ref = React.useRef(null);
+
+ const value = controlledValue !== undefined ? controlledValue : internalValue;
+ const selectedOption = options.find((o) => o.value === value);
+ const close = React.useCallback(() => setExpanded(false), []);
+
+ useClickOutside(ref, close, expanded);
+ useEscapeKey(close, expanded);
+
+ function handleSelect(option: PillSelectOption) {
+ if (disabled || option.disabled) return;
+ if (controlledValue === undefined) setInternalValue(option.value);
+ onValueChange?.(option.value);
+ setExpanded(false);
+ }
+
+ const hasOptions = options.length > 0;
+ const valuePart = selectedOption?.label ?? value;
+ const collapsedLabel =
+ label && valuePart
+ ? `${label}: ${valuePart}`
+ : label || valuePart || 'No options';
+
+ return (
+
+ {expanded ? (
+
+ {options.map((option) => {
+ const selected = option.value === value;
+ const optionDisabled = disabled || option.disabled;
+ return (
+
+ );
+ })}
+
+ ) : (
+
+ )}
+
+ );
+}
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 bf9ba28c..2fb8c2fe 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -139,6 +139,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 e494e4bb..2b588952 100644
--- a/src/tailwind-preset.ts
+++ b/src/tailwind-preset.ts
@@ -406,6 +406,58 @@ export const miewebUISafelist = [
// injected