diff --git a/apps/docs/app/globals.css b/apps/docs/app/globals.css index a864e02..e393a87 100644 --- a/apps/docs/app/globals.css +++ b/apps/docs/app/globals.css @@ -3,6 +3,7 @@ @import "fumadocs-ui/css/preset.css"; @import "../node_modules/tw-shimmer/src/index.css"; @source "../node_modules/@assistant-ui/chords/dist"; +@source inline("shimmer"); /* Safelist color utilities for playground dynamic class inputs. Tailwind 4 @source inline() scans for literal class names — no brace expansion. */ diff --git a/apps/docs/app/page.tsx b/apps/docs/app/page.tsx index d81481c..ab5929d 100644 --- a/apps/docs/app/page.tsx +++ b/apps/docs/app/page.tsx @@ -44,6 +44,12 @@ const majorChords = [ description: "File and image attachments for composer and messages.", href: "/docs/major-chords/attachment", }, + { + name: "ReasoningAccordion", + description: + "Collapsible accordion for AI reasoning with auto-expand during streaming.", + href: "/docs/major-chords/reasoning-accordion", + }, ]; const minorChords = [ diff --git a/apps/docs/components/demo/reasoning-accordion-demo.tsx b/apps/docs/components/demo/reasoning-accordion-demo.tsx new file mode 100644 index 0000000..a7b791f --- /dev/null +++ b/apps/docs/components/demo/reasoning-accordion-demo.tsx @@ -0,0 +1,108 @@ +"use client"; + +import { + AssistantRuntimeProvider, + MessagePrimitive, + ThreadPrimitive, + useLocalRuntime, +} from "@assistant-ui/react"; +import { ReasoningAccordion } from "@assistant-ui/chords"; +import { DemoWrapper } from "./demo-wrapper"; +import type { ChatModelAdapter } from "@assistant-ui/react"; +import { FC } from "react"; + +const REASONING = + "Let me think about this step by step. First, I need to consider the quantum mechanical principles involved. Entanglement occurs when particles become correlated in such a way that the quantum state of each particle cannot be described independently of the others."; + +const TEXT = + "Quantum entanglement is a phenomenon where two particles become linked, so measuring one instantly affects the other — no matter the distance between them. Einstein famously called it 'spooky action at a distance.'"; + +const demoAdapter: ChatModelAdapter = { + async *run({ abortSignal }) { + for (let i = 0; i < REASONING.length; i++) { + if (abortSignal.aborted) return; + await new Promise((r) => setTimeout(r, 8)); + yield { + content: [ + { type: "reasoning" as const, text: REASONING.slice(0, i + 1) }, + ], + }; + } + + for (let i = 0; i < TEXT.length; i++) { + if (abortSignal.aborted) return; + await new Promise((r) => setTimeout(r, 12)); + yield { + content: [ + { type: "reasoning" as const, text: REASONING }, + { type: "text" as const, text: TEXT.slice(0, i + 1) }, + ], + }; + } + }, +}; + +export function ReasoningAccordionDemo() { + const runtime = useLocalRuntime(demoAdapter); + + return ( + + + +
+ + + +
+
+ + + Send a message to trigger the reasoning demo + + +
+
+
+
+ ); +} + +const DemoUserMessage: FC = () => ( + +
+ +
+
+); + +function DemoAssistantMessage() { + return ( + +
+ A +
+
+ ( + + {text} + + ), + Reasoning: ({ text }) => {text}, + ReasoningGroup: ReasoningAccordion, + }} + /> +
+
+ ); +} diff --git a/apps/docs/components/playground/code-generators/generate-code.ts b/apps/docs/components/playground/code-generators/generate-code.ts index c986937..954d39e 100644 --- a/apps/docs/components/playground/code-generators/generate-code.ts +++ b/apps/docs/components/playground/code-generators/generate-code.ts @@ -140,6 +140,20 @@ const generators: Record CodeGenResult> = { `, }; }, + "reasoning-accordion": (config) => { + const defaults = chordRegistry["reasoning-accordion"].defaultConfig; + const props = buildPropsString(config, defaults); + return { + imports: `import { ReasoningAccordion } from "@assistant-ui/chords";`, + jsx: ` {text}, + Reasoning: ({ text }) => {text}, + ReasoningGroup: ReasoningAccordion, + }} +/>`, + }; + }, "scroll-to-bottom": (config) => { const defaults = chordRegistry["scroll-to-bottom"].defaultConfig; const props = buildPropsString(config, defaults); diff --git a/apps/docs/components/playground/controls-panel.tsx b/apps/docs/components/playground/controls-panel.tsx index d1673ad..ad0d405 100644 --- a/apps/docs/components/playground/controls-panel.tsx +++ b/apps/docs/components/playground/controls-panel.tsx @@ -12,6 +12,7 @@ import { SuggestionChipsControls } from "./controls/suggestion-chips-controls"; import { ThreadEmptyControls } from "./controls/thread-empty-controls"; import { AttachmentControls } from "./controls/attachment-controls"; import { ScrollToBottomControls } from "./controls/scroll-to-bottom-controls"; +import { ReasoningAccordionControls } from "./controls/reasoning-accordion-controls"; type ControlsPanelProps = { chordId: ChordId; @@ -33,6 +34,7 @@ const controlsMap: Partial< "thread-empty": ThreadEmptyControls, attachment: AttachmentControls, "scroll-to-bottom": ScrollToBottomControls, + "reasoning-accordion": ReasoningAccordionControls, }; export function ControlsPanel({ chordId, config, onChange }: ControlsPanelProps) { diff --git a/apps/docs/components/playground/controls/reasoning-accordion-controls.tsx b/apps/docs/components/playground/controls/reasoning-accordion-controls.tsx new file mode 100644 index 0000000..1cb226d --- /dev/null +++ b/apps/docs/components/playground/controls/reasoning-accordion-controls.tsx @@ -0,0 +1,48 @@ +"use client"; + +import type { ChordConfig } from "@/lib/playground/types"; +import { TextInput, StyleBuilder } from "./shared"; + +export function ReasoningAccordionControls({ + config, + onChange, +}: { + config: ChordConfig; + onChange: (c: ChordConfig) => void; +}) { + return ( +
+ onChange({ ...config, label: v || undefined })} + placeholder="Reasoning" + /> + onChange({ ...config, className: v || undefined })} + controls={["bg", "text", "rounded", "p"]} + /> + + onChange({ ...config, triggerClassName: v || undefined }) + } + controls={["bg", "text"]} + /> + + onChange({ ...config, contentClassName: v || undefined }) + } + controls={["bg", "text"]} + /> +
+ ); +} diff --git a/apps/docs/components/playground/preview-panel.tsx b/apps/docs/components/playground/preview-panel.tsx index 4012cff..6affc96 100644 --- a/apps/docs/components/playground/preview-panel.tsx +++ b/apps/docs/components/playground/preview-panel.tsx @@ -17,6 +17,7 @@ import { CopyButtonPreview } from "./previews/copy-button-preview"; import { SuggestionChipsPreview } from "./previews/suggestion-chips-preview"; import { ThreadEmptyPreview } from "./previews/thread-empty-preview"; import { ScrollToBottomPreview } from "./previews/scroll-to-bottom-preview"; +import { ReasoningAccordionPreview } from "./previews/reasoning-accordion-preview"; type PreviewPanelProps = { chordId: ChordId; @@ -36,6 +37,7 @@ const previewMap: Record> = { "suggestion-chips": SuggestionChipsPreview, "thread-empty": ThreadEmptyPreview, "scroll-to-bottom": ScrollToBottomPreview, + "reasoning-accordion": ReasoningAccordionPreview, }; export function PreviewPanel({ chordId, config }: PreviewPanelProps) { diff --git a/apps/docs/components/playground/previews/reasoning-accordion-preview.tsx b/apps/docs/components/playground/previews/reasoning-accordion-preview.tsx new file mode 100644 index 0000000..1ae41e6 --- /dev/null +++ b/apps/docs/components/playground/previews/reasoning-accordion-preview.tsx @@ -0,0 +1,91 @@ +"use client"; + +import { + AssistantRuntimeProvider, + ThreadPrimitive, + MessagePrimitive, + useLocalRuntime, +} from "@assistant-ui/react"; +import { ReasoningAccordion } from "@assistant-ui/chords"; +import type { ChordConfig } from "@/lib/playground/types"; +import { createReasoningAdapter } from "@/lib/playground/mock-runtime"; +import { useRef } from "react"; + +const UserMessage = () => ( + +
+ +
+
+); + +const AssistantMessage = ({ config }: { config: ChordConfig }) => ( + +
+ A +
+
+ ( + + {text} + + ), + Reasoning: ({ text }) => {text}, + ReasoningGroup: ((props: any) => ( + + )) as any, + }} + /> +
+
+); + +export function ReasoningAccordionPreview({ + config, +}: { + config: ChordConfig; +}) { + const adapter = useRef(createReasoningAdapter()); + const runtime = useLocalRuntime(adapter.current); + + return ( + + +
+ + , + }} + /> + +
+
+ + + Click to trigger reasoning demo + + +
+
+
+ ); +} diff --git a/apps/docs/content/docs/major-chords/meta.json b/apps/docs/content/docs/major-chords/meta.json index 8244bd0..7a7f837 100644 --- a/apps/docs/content/docs/major-chords/meta.json +++ b/apps/docs/content/docs/major-chords/meta.json @@ -8,6 +8,7 @@ "edit-composer", "follow-up-suggestions", "attachment", - "tool-call-renderer" + "tool-call-renderer", + "reasoning-accordion" ] } diff --git a/apps/docs/content/docs/major-chords/reasoning-accordion.mdx b/apps/docs/content/docs/major-chords/reasoning-accordion.mdx new file mode 100644 index 0000000..18f60e0 --- /dev/null +++ b/apps/docs/content/docs/major-chords/reasoning-accordion.mdx @@ -0,0 +1,87 @@ +--- +title: ReasoningAccordion +description: Collapsible accordion that displays AI reasoning/thinking content with auto-expand during streaming. +--- + +## Overview + +`ReasoningAccordion` is a drop-in component that displays AI reasoning or thinking content in a collapsible accordion. It automatically expands while reasoning is streaming and collapses when done. The trigger label animates with a shimmer effect during active streaming. + +## Demo + + + +## When to use + +- Show "thinking" or "reasoning" content from models like Claude or o1 +- Auto-expand reasoning while it streams, auto-collapse when complete +- Provide a clean, collapsible UI for verbose chain-of-thought output + +## Features + +- **Auto-expand/collapse**: Opens when reasoning streams in, closes when done +- **User override**: Manual toggle prevents auto-collapse so users can keep reading +- **Shimmer animation**: Trigger label shimmers during active streaming via `tw-shimmer` +- **Scroll lock**: Smooth collapse animation without jarring scroll jumps +- **Customizable**: Override classes, label text, trigger, and icon renderers + +## Props + +`ReasoningAccordion` implements the `ReasoningGroupComponent` interface from `@assistant-ui/react`. The `children`, `startIndex`, and `endIndex` props are passed automatically by `MessagePrimitive.Parts`. + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `className` | string | — | Root container class | +| `triggerClassName` | string | — | Trigger button class | +| `contentClassName` | string | — | Collapsible content area class | +| `textClassName` | string | — | Inner text wrapper class | +| `label` | string | `"Reasoning"` | Trigger label text | +| `renderTrigger` | `(props: { active: boolean; label: string }) => ReactNode` | — | Custom trigger renderer | +| `renderIcon` | `() => ReactNode` | — | Custom icon renderer (default: brain SVG) | + +## Basic Usage + +```tsx +import { ReasoningAccordion } from "@assistant-ui/chords"; + +export function AssistantMessage() { + return ( + + {text}, + Reasoning: ({ text }) => {text}, + ReasoningGroup: ReasoningAccordion, + }} + /> + + ); +} +``` + +## Custom Label + +```tsx + {text}, + Reasoning: ({ text }) => {text}, + ReasoningGroup: (props) => ( + + ), + }} +/> +``` + +## Underlying Primitives + +`ReasoningAccordion` uses `useAuiState` to detect when reasoning parts are actively streaming, and `useScrollLock` from `@assistant-ui/react` for smooth collapse animations. It requires both a `Reasoning` component (to render the text) and a `ReasoningGroup` component (the accordion wrapper) in `MessagePrimitive.Parts`. + +## Dependencies + +Requires `tw-shimmer` CSS for the shimmer animation. Add to your CSS: + +```css +@import "tw-shimmer/src/index.css"; +@source inline("shimmer"); +``` diff --git a/apps/docs/lib/playground/chord-registry.ts b/apps/docs/lib/playground/chord-registry.ts index c90bf26..dea5fc3 100644 --- a/apps/docs/lib/playground/chord-registry.ts +++ b/apps/docs/lib/playground/chord-registry.ts @@ -103,6 +103,15 @@ export const chordRegistry: Record = { icon: "U", }, }, + "reasoning-accordion": { + id: "reasoning-accordion", + name: "ReasoningAccordion", + category: "major", + description: "Collapsible accordion for AI reasoning/thinking with auto-expand during streaming", + defaultConfig: { + label: "Reasoning", + }, + }, "scroll-to-bottom": { id: "scroll-to-bottom", name: "ScrollToBottom", diff --git a/apps/docs/lib/playground/mock-runtime.ts b/apps/docs/lib/playground/mock-runtime.ts index be2c6d0..f766b42 100644 --- a/apps/docs/lib/playground/mock-runtime.ts +++ b/apps/docs/lib/playground/mock-runtime.ts @@ -24,6 +24,49 @@ export function createPlaygroundAdapter(): ChatModelAdapter { }; } +export function createReasoningAdapter(): ChatModelAdapter { + return { + async *run({ abortSignal }) { + const reasoning = + "Let me think about this step by step. First, I need to consider the quantum mechanical principles involved. Entanglement occurs when particles become correlated in such a way that the quantum state of each particle cannot be described independently."; + const text = + "Quantum entanglement is a phenomenon where two particles become linked, so measuring one instantly affects the other — no matter the distance between them."; + + // Stream reasoning parts + for (let i = 0; i < reasoning.length; i++) { + if (abortSignal.aborted) return; + await new Promise((r) => setTimeout(r, 8)); + yield { + content: [ + { + type: "reasoning" as const, + text: reasoning.slice(0, i + 1), + }, + ], + }; + } + + // Stream text response with reasoning preserved + for (let i = 0; i < text.length; i++) { + if (abortSignal.aborted) return; + await new Promise((r) => setTimeout(r, 12)); + yield { + content: [ + { + type: "reasoning" as const, + text: reasoning, + }, + { + type: "text" as const, + text: text.slice(0, i + 1), + }, + ], + }; + } + }, + }; +} + export function createToolCallAdapter(): ChatModelAdapter { let callCount = 0; return { diff --git a/apps/docs/lib/playground/types.ts b/apps/docs/lib/playground/types.ts index 59082ae..d8235e7 100644 --- a/apps/docs/lib/playground/types.ts +++ b/apps/docs/lib/playground/types.ts @@ -11,6 +11,7 @@ export const CHORD_IDS = [ "suggestion-chips", "thread-empty", "scroll-to-bottom", + "reasoning-accordion", ] as const; export type ChordId = (typeof CHORD_IDS)[number]; diff --git a/apps/docs/mdx-components.tsx b/apps/docs/mdx-components.tsx index dc0c5cd..ecda148 100644 --- a/apps/docs/mdx-components.tsx +++ b/apps/docs/mdx-components.tsx @@ -26,6 +26,7 @@ import { EditComposerDemo } from "@/components/demo/edit-composer-demo"; import { FollowUpSuggestionsDemo } from "@/components/demo/follow-up-suggestions-demo"; import { AttachmentDemo } from "@/components/demo/attachment-demo"; import { ToolCallRendererDemo } from "@/components/demo/tool-call-renderer-demo"; +import { ReasoningAccordionDemo } from "@/components/demo/reasoning-accordion-demo"; type Components = Record>; @@ -57,6 +58,7 @@ export function getMDXComponents(components?: Components): Components { FollowUpSuggestionsDemo, AttachmentDemo, ToolCallRendererDemo, + ReasoningAccordionDemo, blockquote: (props: any) => {props.children}, ...components, }; diff --git a/apps/example/app/components/Assistant.tsx b/apps/example/app/components/Assistant.tsx index 1f4ad89..426a44b 100644 --- a/apps/example/app/components/Assistant.tsx +++ b/apps/example/app/components/Assistant.tsx @@ -19,6 +19,7 @@ import { MessageActionBar, MessageAttachments, MessageStatus, + ReasoningAccordion, ScrollToBottom, ThreadEmpty, ToolCallRenderer, @@ -145,6 +146,8 @@ const AssistantMessage: FC = () => { {text}, + Reasoning: ({ text }) => {text}, + ReasoningGroup: ReasoningAccordion, tools: { Fallback: ToolCallRenderer }, }} /> diff --git a/apps/example/app/globals.css b/apps/example/app/globals.css index 2abde2f..10927a5 100644 --- a/apps/example/app/globals.css +++ b/apps/example/app/globals.css @@ -1,5 +1,7 @@ @import "tailwindcss"; +@import "../node_modules/tw-shimmer/src/index.css"; @source "../node_modules/@assistant-ui/chords/dist"; +@source inline("shimmer"); @custom-variant dark (&:where(.dark, .dark *)); diff --git a/apps/example/app/lib/mock-model-adapter.ts b/apps/example/app/lib/mock-model-adapter.ts index 5299f42..1945b23 100644 --- a/apps/example/app/lib/mock-model-adapter.ts +++ b/apps/example/app/lib/mock-model-adapter.ts @@ -57,12 +57,42 @@ export const mockModelAdapter: ChatModelAdapter = { const text = RESPONSES[idx]!; + // Every 3rd text message: include reasoning before the response + if (idx % 3 === 1) { + const reasoning = + "Let me think about this step by step. First, I need to consider the key aspects of the question. Then I'll organize my thoughts into a clear structure. This involves weighing multiple perspectives and finding the most helpful way to respond."; + + for (let i = 0; i < reasoning.length; i++) { + if (abortSignal.aborted) return; + await new Promise((r) => setTimeout(r, 8)); + yield { + content: [ + { type: "reasoning" as const, text: reasoning.slice(0, i + 1) }, + ], + }; + } + + await new Promise((r) => setTimeout(r, 300)); + if (abortSignal.aborted) return; + } + // Simulate streaming by yielding one character at a time for (let i = 0; i < text.length; i++) { if (abortSignal.aborted) return; await new Promise((r) => setTimeout(r, 15)); yield { - content: [{ type: "text" as const, text: text.slice(0, i + 1) }], + content: [ + // Keep reasoning part if it was emitted + ...(idx % 3 === 1 + ? [ + { + type: "reasoning" as const, + text: "Let me think about this step by step. First, I need to consider the key aspects of the question. Then I'll organize my thoughts into a clear structure. This involves weighing multiple perspectives and finding the most helpful way to respond.", + }, + ] + : []), + { type: "text" as const, text: text.slice(0, i + 1) }, + ], }; } }, diff --git a/apps/example/package.json b/apps/example/package.json index 91f1a02..97cd07a 100644 --- a/apps/example/package.json +++ b/apps/example/package.json @@ -15,7 +15,8 @@ "@assistant-ui/chords": "workspace:*", "@assistant-ui/styles": "^0.1.4", "clsx": "^2.1.1", - "tailwind-merge": "^3.4.0" + "tailwind-merge": "^3.4.0", + "tw-shimmer": "^0.4.6" }, "devDependencies": { "@tailwindcss/postcss": "^4", diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 1e643ff..0d19791 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -10,3 +10,4 @@ export * from "./primitives/edit-composer"; export * from "./primitives/follow-up-suggestions"; export * from "./primitives/attachment"; export * from "./primitives/tool-call-renderer"; +export * from "./primitives/reasoning-accordion"; diff --git a/packages/core/src/primitives/reasoning-accordion/ReasoningAccordion.tsx b/packages/core/src/primitives/reasoning-accordion/ReasoningAccordion.tsx new file mode 100644 index 0000000..9a8a912 --- /dev/null +++ b/packages/core/src/primitives/reasoning-accordion/ReasoningAccordion.tsx @@ -0,0 +1,200 @@ +"use client"; + +import { useState, useEffect, useRef, useCallback } from "react"; +import { + useAuiState, + useScrollLock, + type ReasoningGroupComponent, +} from "@assistant-ui/react"; +import { + DEFAULT_ROOT_CLASSNAME, + DEFAULT_TRIGGER_CLASSNAME, + DEFAULT_CONTENT_CLASSNAME, + DEFAULT_TEXT_CLASSNAME, + DEFAULT_ICON_CLASSNAME, + DEFAULT_CHEVRON_CLASSNAME, +} from "./defaults"; + +const ANIMATION_DURATION = 200; + +/** + * Displays AI reasoning/thinking content in a collapsible accordion. + * + * Auto-expands while reasoning is streaming, collapses when done. + * Shows a trigger label with shimmer animation during streaming. + * + * Used as a `ReasoningGroupComponent` with `MessagePrimitive.Parts`: + * + * ```tsx + * + * ``` + * + * Must be rendered inside a message context. + */ +export const ReasoningAccordion: ReasoningGroupComponent = ({ + children, + startIndex, + endIndex, + className, + triggerClassName, + contentClassName, + textClassName, + label = "Reasoning", + renderTrigger, + renderIcon, +}: React.PropsWithChildren<{ + startIndex: number; + endIndex: number; + /** Root container class. */ + className?: string; + /** Trigger row class. */ + triggerClassName?: string; + /** Collapsible content area class. */ + contentClassName?: string; + /** Inner text wrapper class. */ + textClassName?: string; + /** Trigger label text. @default "Reasoning" */ + label?: string; + /** Custom trigger renderer. */ + renderTrigger?: (props: { active: boolean; label: string }) => React.ReactNode; + /** Custom icon renderer. @default brain SVG */ + renderIcon?: () => React.ReactNode; +}>) => { + const isActive = useAuiState((s) => { + if (s.message.status?.type !== "running") return false; + const lastIndex = s.message.parts.length - 1; + if (lastIndex < 0) return false; + const lastType = s.message.parts[lastIndex]?.type; + if (lastType !== "reasoning") return false; + return lastIndex >= startIndex && lastIndex <= endIndex; + }); + + const [open, setOpen] = useState(isActive); + const [userToggled, setUserToggled] = useState(false); + const contentRef = useRef(null); + const lockScroll = useScrollLock(contentRef, ANIMATION_DURATION); + + // Auto-open when streaming starts, auto-close when it ends + // (unless the user has manually toggled) + useEffect(() => { + if (isActive) { + setOpen(true); + setUserToggled(false); + } else if (!userToggled) { + setOpen(false); + } + }, [isActive, userToggled]); + + const handleToggle = useCallback(() => { + setUserToggled(true); + setOpen((prev) => { + if (prev) lockScroll(); + return !prev; + }); + }, [lockScroll]); + + const triggerContent = renderTrigger ? ( + renderTrigger({ active: isActive, label }) + ) : ( + <> + {renderIcon ? ( + renderIcon() + ) : ( + + )} + + {label} + {isActive && ( + + {label} + + )} + + + + ); + + return ( +
+ +
+
+ {children} +
+
+
+ ); +}; + +ReasoningAccordion.displayName = "ReasoningAccordion"; + +// --- Inline SVG icons (no lucide dependency) --- + +function BrainIcon({ className }: { className?: string }) { + return ( + + + + + + + + + + + + ); +} + +function ChevronIcon({ + className, + style, +}: { + className?: string; + style?: React.CSSProperties; +}) { + return ( + + + + ); +} diff --git a/packages/core/src/primitives/reasoning-accordion/__tests__/ReasoningAccordion.test.tsx b/packages/core/src/primitives/reasoning-accordion/__tests__/ReasoningAccordion.test.tsx new file mode 100644 index 0000000..86041bc --- /dev/null +++ b/packages/core/src/primitives/reasoning-accordion/__tests__/ReasoningAccordion.test.tsx @@ -0,0 +1,177 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; + +// Track the selector passed to useAuiState so tests can control the return value +let mockIsActive = false; + +vi.mock("@assistant-ui/react", () => ({ + useAuiState: (selector: any) => mockIsActive, + useScrollLock: () => vi.fn(), +})); + +import { ReasoningAccordion } from "../ReasoningAccordion"; +import { DEFAULT_ROOT_CLASSNAME, DEFAULT_TRIGGER_CLASSNAME } from "../defaults"; + +describe("ReasoningAccordion", () => { + beforeEach(() => { + mockIsActive = false; + }); + + it("renders with default classes", () => { + const { container } = render( + +

Thinking...

+
, + ); + const root = container.firstElementChild; + expect(root?.className).toContain("mb-4"); + }); + + it("renders with custom className", () => { + const { container } = render( + +

Thinking...

+
, + ); + const root = container.firstElementChild; + expect(root?.className).toBe("custom-root"); + }); + + it("hides children when closed", () => { + render( + +

Thinking content

+
, + ); + expect(screen.queryByText("Thinking content")).not.toBeVisible(); + }); + + it("shows children when toggled open", () => { + render( + +

Thinking content

+
, + ); + fireEvent.click(screen.getByRole("button")); + expect(screen.getByText("Thinking content")).toBeVisible(); + }); + + it("toggles on trigger click", () => { + render( + +

Content

+
, + ); + const trigger = screen.getByRole("button"); + expect(trigger).toHaveAttribute("aria-expanded", "false"); + + fireEvent.click(trigger); + expect(trigger).toHaveAttribute("aria-expanded", "true"); + + fireEvent.click(trigger); + expect(trigger).toHaveAttribute("aria-expanded", "false"); + }); + + it("shows default label", () => { + render( + +

Content

+
, + ); + expect(screen.getByText("Reasoning")).toBeInTheDocument(); + }); + + it("shows custom label", () => { + render( + +

Content

+
, + ); + expect(screen.getByText("Thinking")).toBeInTheDocument(); + }); + + it("sets aria-busy when active", () => { + mockIsActive = true; + render( + +

Content

+
, + ); + const trigger = screen.getByRole("button"); + expect(trigger).toHaveAttribute("aria-busy", "true"); + }); + + it("does not set aria-busy when inactive", () => { + render( + +

Content

+
, + ); + const trigger = screen.getByRole("button"); + expect(trigger).toHaveAttribute("aria-busy", "false"); + }); + + it("shows shimmer when active", () => { + mockIsActive = true; + const { container } = render( + +

Content

+
, + ); + const shimmer = container.querySelector("[aria-hidden]"); + expect(shimmer).toBeInTheDocument(); + }); + + it("does not show shimmer when inactive", () => { + const { container } = render( + +

Content

+
, + ); + const shimmer = container.querySelector("[aria-hidden]"); + expect(shimmer).not.toBeInTheDocument(); + }); + + it("uses custom renderTrigger", () => { + render( + ( + + {label} - {active ? "active" : "idle"} + + )} + > +

Content

+
, + ); + expect(screen.getByTestId("custom-trigger")).toHaveTextContent( + "Reasoning - idle", + ); + }); + + it("uses custom renderIcon", () => { + render( + 🧠} + > +

Content

+
, + ); + expect(screen.getByTestId("custom-icon")).toBeInTheDocument(); + }); + + it("auto-opens when streaming starts", () => { + mockIsActive = true; + render( + +

Content

+
, + ); + expect(screen.getByRole("button")).toHaveAttribute("aria-expanded", "true"); + expect(screen.getByText("Content")).toBeVisible(); + }); +}); diff --git a/packages/core/src/primitives/reasoning-accordion/defaults.ts b/packages/core/src/primitives/reasoning-accordion/defaults.ts new file mode 100644 index 0000000..00c6692 --- /dev/null +++ b/packages/core/src/primitives/reasoning-accordion/defaults.ts @@ -0,0 +1,16 @@ +export const DEFAULT_ROOT_CLASSNAME = + "mb-4 w-full rounded-lg border border-zinc-200 px-3 py-2 dark:border-zinc-800"; + +export const DEFAULT_TRIGGER_CLASSNAME = + "flex w-full items-center gap-2 py-1 text-sm text-zinc-500 transition-colors hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100"; + +export const DEFAULT_CONTENT_CLASSNAME = + "overflow-hidden text-sm text-zinc-500 dark:text-zinc-400"; + +export const DEFAULT_TEXT_CLASSNAME = + "max-h-64 overflow-y-auto pb-2 pl-6 pt-2 leading-relaxed"; + +export const DEFAULT_ICON_CLASSNAME = "size-4 shrink-0"; + +export const DEFAULT_CHEVRON_CLASSNAME = + "ml-auto size-4 shrink-0 transition-transform duration-200"; diff --git a/packages/core/src/primitives/reasoning-accordion/index.ts b/packages/core/src/primitives/reasoning-accordion/index.ts new file mode 100644 index 0000000..50358b8 --- /dev/null +++ b/packages/core/src/primitives/reasoning-accordion/index.ts @@ -0,0 +1 @@ +export { ReasoningAccordion } from "./ReasoningAccordion"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eced473..97127da 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -102,6 +102,9 @@ importers: tailwind-merge: specifier: ^3.4.0 version: 3.4.0 + tw-shimmer: + specifier: ^0.4.6 + version: 0.4.6(tailwindcss@4.1.18) devDependencies: '@tailwindcss/postcss': specifier: ^4