diff --git a/apps/www/src/content/docs/ai-elements/meta.json b/apps/www/src/content/docs/ai-elements/meta.json index 7482ceb5c..2a445eae9 100644 --- a/apps/www/src/content/docs/ai-elements/meta.json +++ b/apps/www/src/content/docs/ai-elements/meta.json @@ -1,4 +1,4 @@ { "title": "AI Elements", - "pages": ["message", "prompt-input"] + "pages": ["message", "prompt-input", "reasoning"] } diff --git a/apps/www/src/content/docs/ai-elements/reasoning/demo.ts b/apps/www/src/content/docs/ai-elements/reasoning/demo.ts new file mode 100644 index 000000000..055822de0 --- /dev/null +++ b/apps/www/src/content/docs/ai-elements/reasoning/demo.ts @@ -0,0 +1,66 @@ +'use client'; + +export const preview = { + type: 'code', + code: `
+ + + + + Looked at recent issues in Design System 2. + + + + +
` +}; + +export const streamingDemo = { + type: 'code', + code: `function StreamingReasoning() { + const [streaming, setStreaming] = React.useState(false); + const timerRef = React.useRef(null); + + React.useEffect(() => () => clearTimeout(timerRef.current), []); + + return ( + + + + + + + + I'm pulling the active issues assigned to you… + + + + + + + ); +}` +}; + +export const customTriggerDemo = { + type: 'code', + code: `
+ + Show the plan + + + + + +
` +}; diff --git a/apps/www/src/content/docs/ai-elements/reasoning/index.mdx b/apps/www/src/content/docs/ai-elements/reasoning/index.mdx new file mode 100644 index 000000000..db26b52f7 --- /dev/null +++ b/apps/www/src/content/docs/ai-elements/reasoning/index.mdx @@ -0,0 +1,74 @@ +--- +title: Reasoning +description: Collapsible "thinking" section for AI responses — auto-opens while streaming, auto-collapses on completion, with labelled steps. +source: packages/raystack/components/reasoning +tag: new +--- + +import { preview, streamingDemo, customTriggerDemo } from "./demo.ts"; + + + +## Anatomy + +```tsx +import { Reasoning } from '@raystack/apsara' + + + + + + {/* indented detail */} + + + +``` + +Built on `Collapsible`. Drive `streaming` from your transport; everything +here is presentational and works anywhere — inside a +[Message](/docs/ai-elements/message) body, a `Chat` scroller, or on its own. + +## API Reference + +### Reasoning + + + +The open state auto-follows `streaming` (open while `true`, collapsed once it +flips back) until the user toggles the panel manually — from then on it's +theirs. + +### Reasoning.Trigger + +Renders the default status label (shimmering "Thinking…", then "Worked for N +seconds") plus a chevron; pass children to replace the label. + +### Reasoning.Content + +The collapsible panel holding the steps. + +### Reasoning.Step + +A labelled row with optional indented detail. + + + +## Examples + +### Streaming + +Drive `streaming` from your transport; the trigger label and open state +follow along. + + + +### Custom trigger label + + + +## Accessibility + +- Inherits Collapsible semantics: the trigger exposes `aria-expanded` and + toggles with Enter and Space. +- The "Thinking…" shimmer and the panel's height tween pause under + `prefers-reduced-motion: reduce`. diff --git a/apps/www/src/content/docs/ai-elements/reasoning/props.ts b/apps/www/src/content/docs/ai-elements/reasoning/props.ts new file mode 100644 index 000000000..8ba538410 --- /dev/null +++ b/apps/www/src/content/docs/ai-elements/reasoning/props.ts @@ -0,0 +1,41 @@ +import type React from 'react'; + +export interface ReasoningProps { + /** + * Whether the reasoning is still being produced. While `true` the default + * trigger shows a shimmering "Thinking…" label and the panel auto-opens; + * when it flips back to `false` the panel auto-collapses — unless the user + * has toggled it themselves. + * @defaultValue false + */ + streaming?: boolean; + + /** + * How long the reasoning took, in seconds. Rendered by the default trigger + * label as "Worked for N seconds" once `streaming` is over. + */ + duration?: number; + + /** Whether the panel is open (controlled). */ + open?: boolean; + + /** Whether the panel is initially open. Defaults to `streaming`. */ + defaultOpen?: boolean; + + /** Called when the panel is opened or closed. */ + onOpenChange?: (open: boolean) => void; + + /** Custom CSS class names. */ + className?: string; +} + +export interface ReasoningStepProps { + /** Title row of the step, e.g. "Gathering ticket updates". */ + label?: React.ReactNode; + + /** Indented detail content below the label. */ + children?: React.ReactNode; + + /** Custom CSS class names. */ + className?: string; +} diff --git a/packages/raystack/components/reasoning/__tests__/reasoning.test.tsx b/packages/raystack/components/reasoning/__tests__/reasoning.test.tsx new file mode 100644 index 000000000..f257faeba --- /dev/null +++ b/packages/raystack/components/reasoning/__tests__/reasoning.test.tsx @@ -0,0 +1,84 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { Reasoning } from '../reasoning'; + +describe('Reasoning', () => { + const ReasoningExample = (props: { + streaming?: boolean; + duration?: number; + defaultOpen?: boolean; + }) => ( + + + + + step detail + + + + ); + + it('labels the trigger with the duration once done', () => { + render(); + expect( + screen.getByRole('button', { name: 'Worked for 11 seconds' }) + ).toBeInTheDocument(); + }); + + it('uses the singular for one second', () => { + render(); + expect( + screen.getByRole('button', { name: 'Worked for 1 second' }) + ).toBeInTheDocument(); + }); + + it('shows the thinking label and opens while streaming', () => { + render(); + const trigger = screen.getByRole('button', { name: 'Thinking…' }); + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + expect(screen.getByText('step detail')).toBeInTheDocument(); + }); + + it('auto-collapses when streaming completes', () => { + const { rerender } = render(); + expect(screen.getByRole('button', { name: 'Thinking…' })).toHaveAttribute( + 'aria-expanded', + 'true' + ); + + rerender(); + expect( + screen.getByRole('button', { name: 'Worked for 4 seconds' }) + ).toHaveAttribute('aria-expanded', 'false'); + }); + + it('leaves the panel alone after a manual toggle', () => { + const { rerender } = render(); + // The panel auto-opened; the user closes it deliberately. + const trigger = screen.getByRole('button', { name: 'Thinking…' }); + fireEvent.click(trigger); + expect(trigger).toHaveAttribute('aria-expanded', 'false'); + + // Completing the stream must not re-open or re-close the panel. + rerender(); + expect( + screen.getByRole('button', { name: 'Worked for 2 seconds' }) + ).toHaveAttribute('aria-expanded', 'false'); + }); + + it('toggles via the trigger when idle', () => { + render(); + const trigger = screen.getByRole('button', { + name: 'Worked for 3 seconds' + }); + expect(trigger).toHaveAttribute('aria-expanded', 'false'); + fireEvent.click(trigger); + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + expect(screen.getByText('step detail')).toBeInTheDocument(); + }); + + it('renders step labels', () => { + render(); + expect(screen.getByText('Gathering ticket updates')).toBeInTheDocument(); + }); +}); diff --git a/packages/raystack/components/reasoning/index.tsx b/packages/raystack/components/reasoning/index.tsx new file mode 100644 index 000000000..ff53538d0 --- /dev/null +++ b/packages/raystack/components/reasoning/index.tsx @@ -0,0 +1,5 @@ +export { + Reasoning, + type ReasoningProps, + type ReasoningStepProps +} from './reasoning'; diff --git a/packages/raystack/components/reasoning/reasoning.module.css b/packages/raystack/components/reasoning/reasoning.module.css new file mode 100644 index 000000000..73f9d7316 --- /dev/null +++ b/packages/raystack/components/reasoning/reasoning.module.css @@ -0,0 +1,123 @@ +.reasoning { + min-width: 0; +} + +.reasoning-trigger { + appearance: none; + display: inline-flex; + align-items: center; + gap: var(--rs-space-2); + padding: 0; + border: none; + background: none; + font-family: inherit; + font-size: var(--rs-font-size-small); + line-height: var(--rs-line-height-small); + color: var(--rs-color-foreground-base-secondary); + cursor: pointer; + outline: none; + border-radius: var(--rs-radius-1); +} + +@media (prefers-reduced-motion: no-preference) { + .reasoning-trigger { + transition: var(--rs-transition-interactive); + } +} + +.reasoning-trigger:hover { + color: var(--rs-color-foreground-base-primary); +} + +.reasoning-trigger:focus-visible { + outline: var(--rs-focus-ring); +} + +.reasoning-chevron { + flex-shrink: 0; +} + +@media (prefers-reduced-motion: no-preference) { + .reasoning-chevron { + transition: transform var(--rs-duration-fast) var(--rs-ease-out); + } +} + +.reasoning-trigger[data-panel-open] .reasoning-chevron { + transform: rotate(90deg); +} + +.reasoning-label-streaming { + background: linear-gradient( + 90deg, + var(--rs-color-foreground-base-tertiary) 0%, + var(--rs-color-foreground-base-primary) 50%, + var(--rs-color-foreground-base-tertiary) 100% + ); + background-size: 200% 100%; + -webkit-background-clip: text; + background-clip: text; + color: transparent; +} + +@media (prefers-reduced-motion: no-preference) { + .reasoning-label-streaming { + animation: reasoning-shimmer 2s linear infinite; + } +} + +@keyframes reasoning-shimmer { + from { + background-position: 200% 0; + } + to { + background-position: -200% 0; + } +} + +.reasoning-panel { + height: var(--collapsible-panel-height); + overflow: hidden; +} + +.reasoning-panel[data-starting-style], +.reasoning-panel[data-ending-style] { + height: 0; +} + +@media (prefers-reduced-motion: no-preference) { + .reasoning-panel { + transition: height var(--rs-duration-fast) var(--rs-ease-out); + } +} + +.reasoning-body { + display: flex; + flex-direction: column; + gap: var(--rs-space-3); + padding-top: var(--rs-space-3); +} + +.reasoning-step { + display: flex; + flex-direction: column; + gap: var(--rs-space-2); +} + +.reasoning-step-label { + font-size: var(--rs-font-size-small); + line-height: var(--rs-line-height-small); + color: var(--rs-color-foreground-base-secondary); +} + +.reasoning-step-body { + display: flex; + flex-direction: column; + gap: var(--rs-space-2); + margin-left: var(--rs-space-1); + padding-left: var(--rs-space-4); + border-left: 2px solid var(--rs-color-border-base-primary); + font-size: var(--rs-font-size-small); + line-height: var(--rs-line-height-small); + color: var(--rs-color-foreground-base-secondary); +} diff --git a/packages/raystack/components/reasoning/reasoning.tsx b/packages/raystack/components/reasoning/reasoning.tsx new file mode 100644 index 000000000..0f8c8c4d7 --- /dev/null +++ b/packages/raystack/components/reasoning/reasoning.tsx @@ -0,0 +1,199 @@ +'use client'; + +import { useControlled } from '@base-ui/utils/useControlled'; +import { ChevronRightIcon } from '@radix-ui/react-icons'; +import { cx } from 'class-variance-authority'; +import { + ComponentProps, + createContext, + ReactNode, + useContext, + useEffect, + useMemo, + useRef +} from 'react'; +import { Collapsible } from '../collapsible'; +import styles from './reasoning.module.css'; + +interface ReasoningContextValue { + streaming: boolean; + duration?: number; + open: boolean; +} + +const ReasoningContext = createContext(null); + +function useReasoningContext(part: string): ReasoningContextValue { + const context = useContext(ReasoningContext); + if (!context) { + throw new Error(`Reasoning.${part} must be used within `); + } + return context; +} + +export interface ReasoningProps + extends Omit, 'onOpenChange'> { + /** + * Whether the reasoning is still being produced. While `true` the default + * trigger shows a shimmering "Thinking…" label and the panel auto-opens; + * when it flips back to `false` the panel auto-collapses — unless the user + * has toggled it themselves. + * @defaultValue false + */ + streaming?: boolean; + /** + * How long the reasoning took, in seconds. Rendered by the default trigger + * label as "Worked for N seconds" once `streaming` is over. + */ + duration?: number; + /** Called when the panel is opened or closed. */ + onOpenChange?: (open: boolean) => void; +} + +function ReasoningRoot({ + className, + streaming = false, + duration, + open: openProp, + defaultOpen, + onOpenChange, + children, + ...props +}: ReasoningProps) { + const [open, setOpenUnwrapped] = useControlled({ + controlled: openProp, + default: defaultOpen ?? streaming, + name: 'Reasoning', + state: 'open' + }); + + const userToggledRef = useRef(false); + const onOpenChangeRef = useRef(onOpenChange); + onOpenChangeRef.current = onOpenChange; + + const setOpen = (next: boolean) => { + setOpenUnwrapped(next); + onOpenChangeRef.current?.(next); + }; + + // Auto-open while streaming and auto-collapse on completion, but hand the + // panel over to the user as soon as they toggle it manually. + const prevStreamingRef = useRef(streaming); + const setOpenRef = useRef(setOpen); + setOpenRef.current = setOpen; + useEffect(() => { + if (prevStreamingRef.current === streaming) return; + prevStreamingRef.current = streaming; + if (userToggledRef.current) return; + setOpenRef.current(streaming); + }, [streaming]); + + const contextValue = useMemo( + () => ({ streaming, duration, open }), + [streaming, duration, open] + ); + + return ( + + { + userToggledRef.current = true; + setOpen(next); + }} + {...props} + > + {children} + + + ); +} + +ReasoningRoot.displayName = 'Reasoning'; + +export interface ReasoningTriggerProps + extends ComponentProps {} + +function defaultTriggerLabel(streaming: boolean, duration?: number): ReactNode { + if (streaming) { + return ( + Thinking… + ); + } + if (duration != null) { + return `Worked for ${duration} ${duration === 1 ? 'second' : 'seconds'}`; + } + return 'Reasoning'; +} + +export function ReasoningTrigger({ + className, + children, + ...props +}: ReasoningTriggerProps) { + const { streaming, duration } = useReasoningContext('Trigger'); + return ( + + {children ?? defaultTriggerLabel(streaming, duration)} + + ); +} + +ReasoningTrigger.displayName = 'Reasoning.Trigger'; + +export interface ReasoningContentProps + extends ComponentProps {} + +export function ReasoningContent({ + className, + children, + ...props +}: ReasoningContentProps) { + return ( + +
{children}
+
+ ); +} + +ReasoningContent.displayName = 'Reasoning.Content'; + +export interface ReasoningStepProps extends ComponentProps<'div'> { + /** Title row of the step, e.g. "Gathering ticket updates". */ + label?: ReactNode; +} + +export function ReasoningStep({ + className, + label, + children, + ...props +}: ReasoningStepProps) { + return ( +
+ {label &&
{label}
} + {children && ( +
{children}
+ )} +
+ ); +} + +ReasoningStep.displayName = 'Reasoning.Step'; + +export const Reasoning = Object.assign(ReasoningRoot, { + Trigger: ReasoningTrigger, + Content: ReasoningContent, + Step: ReasoningStep +}); diff --git a/packages/raystack/index.tsx b/packages/raystack/index.tsx index b500e4dbf..7d18c673f 100644 --- a/packages/raystack/index.tsx +++ b/packages/raystack/index.tsx @@ -88,6 +88,7 @@ export { type PromptInputStatus } from './components/prompt-input'; export { Radio } from './components/radio'; +export { Reasoning } from './components/reasoning'; export { ScrollArea } from './components/scroll-area'; export { Search } from './components/search'; export { Select } from './components/select';