feat: expandable pills and tool workflow styling from Ozwell widget - #294
feat: expandable pills and tool workflow styling from Ozwell widget#294aditya-damerla128 wants to merge 13 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
This PR brings Ozwell-style “pill” UI patterns into @mieweb/ui by introducing a reusable collapsible pill primitive, a new expandable PillSelect component, and updating AI message/tool-call UI to use pill-based presentation consistent with the widget.
Changes:
- Added
PillSelectcomponent (with Storybook stories) and exported it from the package. - Introduced
CollapsiblePillprimitive and exported it from the AI barrel. - Restyled
MCPToolCallDisplayand replaced the AI “thinking” block UI with a pill-basedThinkingBlock.
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| src/index.ts | Exports the new PillSelect component from the package entrypoint. |
| src/components/PillSelect/PillSelect.tsx | Adds the expandable pill selector component with click-outside/Escape collapse behavior. |
| src/components/PillSelect/PillSelect.stories.tsx | Adds Storybook coverage for default/controlled/disabled/many-options states. |
| src/components/PillSelect/index.ts | Adds the barrel export for PillSelect types + component. |
| src/components/AI/MCPToolCall.tsx | Refactors tool-call UI into a status pill header + details box with animated parameter reveal. |
| src/components/AI/index.ts | Exports the new CollapsiblePill primitive from the AI module. |
| src/components/AI/CollapsiblePill.tsx | Introduces a shared collapsible pill primitive for thinking/tool displays. |
| src/components/AI/AIMessage.tsx | Adds the new ThinkingBlock pill behavior and swaps out the old reasoning block UI. |
| src/components/AI/AIMessage.stories.tsx | Updates stories to cover thinking pill states (active/complete/expanded). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
23736c9 to
45407e5
Compare
45407e5 to
be25404
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 11 out of 11 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (1)
src/components/PillSelect/PillSelect.tsx:49
- When the collapsed trigger is clicked, the focused
<button>is unmounted and replaced by the expanded option buttons. Without moving focus, keyboard users can lose focus (often ending up ondocument.body) and have to tab around to find the options. Add a small effect to focus the selected (or first enabled) option when expanding.
useClickOutside(ref, () => setExpanded(false), expanded);
useEscapeKey(() => setExpanded(false), expanded);
4ec868f to
d7f5718
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 11 out of 11 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (1)
src/components/PillSelect/PillSelect.tsx:50
- When the collapsed trigger button is activated, it’s immediately removed from the DOM and replaced with the expanded button list. This causes focus to be lost (especially for keyboard users), making the control hard to use without a mouse. Consider moving focus into the expanded options on open, and restoring focus to the collapsed trigger on close (without focusing anything on initial mount).
const [expanded, setExpanded] = React.useState(false);
const ref = React.useRef<HTMLDivElement>(null);
const value = controlledValue !== undefined ? controlledValue : internalValue;
const selectedOption = options.find((o) => o.value === value);
useClickOutside(ref, () => setExpanded(false), expanded);
useEscapeKey(() => setExpanded(false), expanded);
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 13 out of 13 changed files in this pull request and generated no new comments.
Suppressed comments (2)
src/components/PillSelect/PillSelect.tsx:73
- When
expandedflips from false to true, the collapsed trigger<button>unmounts and is replaced by the option buttons. This typically causes focus to be lost (focus was on a DOM node that no longer exists), which is a keyboard accessibility issue. Consider explicitly moving focus into the expanded option set when opening, and restoring focus to the collapsed trigger when closing (Escape/selection), similar to howSelectreturns focus to its trigger.
return (
<div ref={ref} className={cn('inline-flex', className)}>
{expanded ? (
<div
role="group"
aria-label={label ? `${label} options` : 'Options'}
className="border-border bg-muted inline-flex items-center rounded-full border p-0.5"
>
src/components/AI/MCPToolCall.tsx:673
defaultCollapsedis documented as status-based (completed tool calls start expanded), butisCompletedcurrently excludescancelled, so cancelled tool calls will default to collapsed even though they are also terminal/completed states. This creates a mismatch between the prop documentation and runtime behavior.
const hasParams = showParameters && toolCall.parameters.length > 0;
const hasBoxContent = Boolean(toolCall.result || toolCall.error || hasParams);
const isCompleted =
toolCall.status === 'success' || toolCall.status === 'error';
const effectiveDefaultCollapsed = defaultCollapsed ?? !isCompleted;
|
@aditya-damerla128 @aaronc-mie Is there an example of where PillSelect will be used? It's exported but nothing in the repo consumes it yet, so it's hard to evaluate the UX in context. My concern is content shift: expanding in-place grows the element from ~113px to ~481px wide (with the ManyOptions story), which reflows everything around it. In a chat composer or toolbar that jump would be pretty disruptive. I'd like to see this take a dropdown approach instead: keep the pill as the trigger, but open the options in an anchored popover so the surrounding content stays put. The library already has floating primitives this could build on, so it should be a natural fit. |
|
Hey @garrity-miepub, i implemented your suggestion. lmk if this aligns closer to your understanding of it |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 13 out of 13 changed files in this pull request and generated no new comments.
Suppressed comments (1)
src/components/PillSelect/PillSelect.tsx:82
leftis clamped usingmenuWidth, which can make the computed left position negative when the trigger (or preferred menu width) is wider than the viewport. In that casewindow.innerWidth - menuWidth - viewportPaddingbecomes < 0,Math.min(...)picks the negative value, and the menu can render off-screen. Clamp using the actual rendered width (respectingmaxWidth) and ensure the max-left bound is never smaller thanviewportPadding.
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
8dffc3b to
7d4bd11
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 13 out of 13 changed files in this pull request and generated no new comments.
Suppressed comments (3)
src/components/AI/CollapsiblePill.tsx:13
defaultOpenis documented as a “default” value, but the component synchronizesisOpenwheneverdefaultOpenchanges. In this repo,defaultOpentypically means “initial uncontrolled state only” (seecomponents/Collapsible, which separatesopenvsdefaultOpen). At minimum, the prop should be documented as state-syncing to avoid surprising consumers.
density?: 'standard' | 'condensed';
defaultOpen?: boolean;
pillClassName?: string;
className?: string;
/** Native tooltip shown on hover (e.g. "Show details") */
title?: string;
src/components/PillSelect/PillSelect.tsx:51
- Uncontrolled
internalValueis only initialized fromoptions[0]on the first render. Ifoptionsis empty initially (common with async data) and later becomes non-empty, the component keepsvalue === ''(or a value no longer present inoptions), which makes the collapsed label ambiguous and can leave the selector in an invalid state. Consider syncing the uncontrolled value whenoptionschanges so it always points at an available option (without overriding a still-valid user selection).
const [internalValue, setInternalValue] = React.useState(
defaultValue ?? options[0]?.value ?? ''
);
const [expanded, setExpanded] = React.useState(false);
const triggerRef = React.useRef<HTMLButtonElement>(null);
const menuRef = React.useRef<HTMLDivElement>(null);
const selectedOptionRef = React.useRef<HTMLButtonElement>(null);
const firstEnabledOptionRef = React.useRef<HTMLButtonElement>(null);
const listboxId = React.useId();
const [menuStyle, setMenuStyle] = React.useState<React.CSSProperties>({});
const value = controlledValue !== undefined ? controlledValue : internalValue;
const selectedOption = options.find((o) => o.value === value);
src/components/PillSelect/PillSelect.test.tsx:63
- This test asserts the option is disabled but never attempts to click it, so it doesn’t actually verify the “does not select disabled options” behavior (i.e. that clicks don’t call
onValueChange/ don’t change the trigger label).
await user.click(screen.getByRole('button', { name: /size: small/i }));
expect(screen.getByRole('option', { name: /medium/i })).toBeDisabled();
expect(onValueChange).not.toHaveBeenCalled();
});
garrity-miepub
left a comment
There was a problem hiding this comment.
This update looks fantastic! Thank you for reworking it! I think this will make the component reusable in many different contexts.
|
@garrity-miepub perfect, so i will rebase it and it should be good to merge after that |
Addresses mieweb#290. Brings the Ozwell chat widget's pill patterns into the UI package as themeable components: - PillSelect: collapsible option selector (the reasoning-mode capsule -> segmented control). Any number of options, fully themeable via tokens. - ThinkingBlock: reasoning pill that shows "Thinking" with a pulsing dot while streaming, then "Thought for Xs" when done. Click to expand/collapse. - MCPToolCall: status pill header (green success / red error kept the same) with friendly tool names (underscores stripped, tense-shifted), result and resource links in a capped-width box, and toggleable raw params with a smooth expand/collapse animation. - CollapsiblePill: shared primitive behind the thinking + tool pills.
- MCPToolCall: default shows only the status pill; clicking it reveals the
whole box (result, links, raw params) in one click with a "Show details"
tooltip. Input summary now rides in the pill while running
("Creating patient · John Smith"); success shows duration instead.
- Add `hidden` prop to turn the tool-call display off entirely (mirrors the
Ozwell widget debug flag).
- tailwind-preset: safelist the AI pill arbitrary utilities, the violet
thinking-pill classes, and the green/red status pill colors so Tailwind 3
consumers get them.
- CollapsiblePill: don't mutate internal state when controlled; add optional
title (tooltip).
- PillSelect: no trailing colon when empty; disable the pill when there are
no options.
- Stories: add PillOnly and Hidden; refresh control docs.
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
7d4bd11 to
8f154a6
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 13 out of 13 changed files in this pull request and generated no new comments.
Suppressed comments (2)
src/components/PillSelect/PillSelect.tsx:80
- Menu positioning can compute a negative
leftwhenmenuWidthexceeds the available viewport width.leftis clamped usingmenuWidth, but CSS later appliesmaxWidth, so the actual rendered width can be smaller than the value used for clamping, causing the popup to shift off-screen on narrow viewports.
Clamp using the effective width (min(menuWidth, maxWidth)) and also clamp minWidth so it never exceeds maxWidth.
const maxWidth = Math.max(window.innerWidth - viewportPadding * 2, 0);
const left = Math.min(
Math.max(rect.left, viewportPadding),
window.innerWidth - menuWidth - viewportPadding
);
src/components/PillSelect/PillSelect.tsx:172
PillSelectexposesrole="listbox"/role="option"semantics, but it doesn't implement expected listbox keyboard interactions (ArrowUp/ArrowDown/Home/End navigation). Without this, assistive tech users can get a listbox that only works via Tab, which is inconsistent with the ARIA listbox pattern.
Either remove the listbox roles and treat these as plain buttons, or add minimal arrow-key navigation that moves focus among enabled options.
<div
ref={menuRef}
id={listboxId}
role="listbox"
aria-label={label ? `${label} options` : 'Options'}
|
@garrity-miepub i have rebased it as well, the only thing it requires is 2 approving reviewers, and i do not have access to add another reviewer, so if you can add someone to review we can move towards merging this in |

Closes #290.
Brings the Ozwell chat widget's pill patterns into the UI package as themeable components.
What's added
src/components/PillSelect/) — the expandable option selector (the reasoning-mode capsule that expands into a segmented control). Collapsed pill → click → options → pick → collapses back. Any number of options, fully themeable via design tokens. Closes on click-outside / Escape.AIMessage) — reasoning pill showing "Thinking" with a pulsing dot while streaming, then "Thought for Xs" once done. Click to expand/collapse the reasoning text.create_patient→ "Creating patient" → "Created patient").src/components/AI/CollapsiblePill.tsx) — shared primitive behind the thinking + tool pills.Checks
All components use mieweb/ui design tokens (light + dark mode).