Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 47 additions & 3 deletions src/components/AI/AIMessage.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -165,11 +165,31 @@ export const StreamingMessage: Story = {
},
};

/** A collapsible reasoning ("thinking") block ahead of the final answer. */
export const ThinkingBlock: Story = {
/** Thinking pill while the model is actively reasoning — pulsing dot, "Thinking" label. */
export const ThinkingActive: Story = {
render: () => {
const message: AIMessage = {
id: '5',
id: '5a',
role: 'assistant',
content: [
{
type: 'thinking',
text: 'The user wants to add a new patient. I should use the create_patient tool with the provided information. Validating date format and required fields...',
collapsed: false,
},
],
timestamp: new Date(),
status: 'streaming',
};
return <AIMessageDisplay message={message} />;
},
};

/** Thinking pill after the model finished — "Thought" label, no dot, collapsed by default. */
export const ThinkingComplete: Story = {
render: () => {
const message: AIMessage = {
id: '5b',
role: 'assistant',
content: [
{
Expand All @@ -189,6 +209,30 @@ export const ThinkingBlock: Story = {
},
};

/** Thinking pill expanded — user clicked to reveal reasoning content. */
export const ThinkingExpanded: Story = {
render: () => {
const message: AIMessage = {
id: '5c',
role: 'assistant',
content: [
{
type: 'thinking',
text: 'The user wants to add a new patient. I should use the create_patient tool with the provided information. I need to validate the date format and ensure all required fields are present.',
collapsed: false,
},
{
type: 'text',
text: "I'll create a new patient record for John Smith.",
},
],
timestamp: new Date(),
status: 'complete',
};
return <AIMessageDisplay message={message} />;
},
};

/**
* Demonstrates the `renderTextContent` extension point. This trivial demo turns
* `**bold**` into `<strong>`; in a real app you would plug in a full Markdown
Expand Down
103 changes: 60 additions & 43 deletions src/components/AI/AIMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ import type {
MCPResourceLink,
} from './types';
import { MCPToolCallDisplay } from './MCPToolCall';
import { SparklesIcon, ChevronIcon } from './icons';
import { SparklesIcon } from './icons';
import { CollapsiblePill } from './CollapsiblePill';
import { AudioPlayer } from '../AudioPlayer';

// ============================================================================
Expand Down Expand Up @@ -161,6 +162,59 @@ function AITypingIndicator({ className }: { className?: string }) {
);
}

// ============================================================================
// Thinking Block
// ============================================================================

function ThinkingBlock({
text,
streaming,
defaultCollapsed = false,
}: {
text: string;
streaming: boolean;
defaultCollapsed?: boolean;
}) {
const startedAt = React.useRef(Date.now());
const [elapsed, setElapsed] = React.useState<number | null>(null);
const prevStreaming = React.useRef(streaming);

React.useEffect(() => {
if (prevStreaming.current && !streaming) {
setElapsed(Math.round((Date.now() - startedAt.current) / 1000));
}
prevStreaming.current = streaming;
}, [streaming]);

const label = streaming
? 'Thinking'
: elapsed !== null && elapsed > 0
? `Thought for ${elapsed}s`
: 'Thought';

const dot = streaming ? (
<span
aria-hidden="true"
className="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-violet-500 dark:bg-violet-400"
/>
) : null;

return (
<div data-slot="ai-message-thinking">
<CollapsiblePill
label={label}
leadingIcon={dot}
defaultOpen={!defaultCollapsed}
pillClassName="bg-violet-50 border-violet-200 text-violet-600 hover:bg-violet-100 dark:bg-violet-950/30 dark:border-violet-800 dark:text-violet-400 dark:hover:bg-violet-950/50 focus-visible:ring-violet-500"
>
<div className="border-l-2 border-violet-200 pl-3 text-[13px] leading-relaxed text-neutral-600 italic dark:border-violet-700 dark:text-neutral-400">
{text}
Comment thread
aditya-damerla128 marked this conversation as resolved.
</div>
</CollapsiblePill>
</div>
);
}

// ============================================================================
// Content Block Renderer
// ============================================================================
Expand All @@ -182,10 +236,6 @@ function ContentBlock({
role,
renderTextContent,
}: ContentBlockProps) {
const [isCollapsed, setIsCollapsed] = React.useState(
content.collapsed ?? false
);

if (content.type === 'text' && content.text) {
if (renderTextContent) {
return (
Expand Down Expand Up @@ -213,44 +263,11 @@ function ContentBlock({

if (content.type === 'thinking' && content.text) {
return (
<div
data-slot="ai-message-thinking"
className="rounded-lg border border-neutral-200 bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-800/50"
>
<button
onClick={() => setIsCollapsed(!isCollapsed)}
className="flex w-full items-center justify-between px-3 py-2 text-left"
>
<span className="text-muted-foreground flex items-center gap-2 text-sm">
<svg
aria-hidden="true"
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth="1.5"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456zM16.894 20.567L16.5 21.75l-.394-1.183a2.25 2.25 0 00-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 001.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 001.423 1.423l1.183.394-1.183.394a2.25 2.25 0 00-1.423 1.423z"
/>
</svg>
Thinking...
</span>
<ChevronIcon
direction={isCollapsed ? 'right' : 'down'}
className="text-neutral-400"
/>
</button>
{!isCollapsed && (
<div className="border-t border-neutral-200 px-3 py-2 dark:border-neutral-700">
<p className="text-sm text-neutral-600 italic dark:text-neutral-400">
{content.text}
</p>
</div>
)}
</div>
<ThinkingBlock
text={content.text}
streaming={streaming}
defaultCollapsed={content.collapsed ?? false}
/>
);
}

Expand Down
40 changes: 40 additions & 0 deletions src/components/AI/CollapsiblePill.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest';
import { screen } from '@testing-library/react';
import { renderWithTheme } from '../../test/test-utils';
import { CollapsiblePill } from './CollapsiblePill';

describe('CollapsiblePill', () => {
it('renders as a plain pill when there is no content to expand', () => {
renderWithTheme(<CollapsiblePill label="Tool" />);

const button = screen.getByRole('button', { name: /tool/i });
expect(button).toBeDisabled();
expect(button).not.toHaveAttribute('aria-expanded');
expect(button).not.toHaveAttribute('aria-controls');
expect(button.querySelector('svg')).not.toBeInTheDocument();
});

it('syncs open state when defaultOpen changes', () => {
const { rerender } = renderWithTheme(
<CollapsiblePill label="Tool" defaultOpen={false}>
<div>Tool details</div>
</CollapsiblePill>
);

const button = screen.getByRole('button', { name: /tool/i });
const content = document.getElementById(
button.getAttribute('aria-controls') ?? ''
);
expect(button).toHaveAttribute('aria-expanded', 'false');
expect(content).toHaveAttribute('aria-hidden', 'true');

rerender(
<CollapsiblePill label="Tool" defaultOpen>
<div>Tool details</div>
</CollapsiblePill>
);

expect(button).toHaveAttribute('aria-expanded', 'true');
expect(content).toHaveAttribute('aria-hidden', 'false');
});
});
86 changes: 86 additions & 0 deletions src/components/AI/CollapsiblePill.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import * as React from 'react';
import { cn } from '../../utils/cn';
import { ChevronIcon } from './icons';

export interface CollapsiblePillProps {
label: string;
leadingIcon?: React.ReactNode;
density?: 'standard' | 'condensed';
defaultOpen?: boolean;
pillClassName?: string;
className?: string;
/** Native tooltip shown on hover (e.g. "Show details") */
title?: string;
children?: React.ReactNode;
}

export function CollapsiblePill({
label,
leadingIcon,
density = 'standard',
defaultOpen = false,
pillClassName,
className,
title,
children,
}: CollapsiblePillProps) {
const hasContent = Boolean(children);
const [isOpen, setIsOpen] = React.useState(defaultOpen);
const contentId = React.useId();

React.useEffect(() => {
setIsOpen(defaultOpen);
}, [defaultOpen]);

const toggle = () => {
setIsOpen((open) => !open);
};

return (
<div data-slot="collapsible-pill" className={className}>
<button
type="button"
onClick={hasContent ? toggle : undefined}
aria-expanded={hasContent ? isOpen : undefined}
aria-controls={hasContent ? contentId : undefined}
title={title}
disabled={!hasContent}
className={cn(
'focus-visible:ring-ring inline-flex items-center gap-1.5 border font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-1',
density === 'condensed'
? 'rounded-[8px] px-2 py-0.5 text-[10px]'
: 'rounded-[10px] px-[11px] py-1 text-[11px]',
pillClassName
)}
>
{leadingIcon}
<span>{label}</span>
{hasContent && (
<ChevronIcon
direction={isOpen ? 'down' : 'right'}
className={
density === 'condensed'
? 'h-2.5 w-2.5 opacity-60'
: 'h-3 w-3 opacity-60'
}
/>
)}
</button>
{hasContent && (
<div
id={contentId}
aria-hidden={!isOpen}
inert={!isOpen || undefined}
className={cn(
'overflow-hidden transition-[max-height,opacity] duration-300 ease-in-out',
isOpen
? 'max-h-[500px] overflow-y-auto opacity-100'
: 'max-h-0 opacity-0'
)}
>
<div className="mt-1.5">{children}</div>
</div>
)}
</div>
);
}
Loading