Skip to content
Merged
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
2 changes: 1 addition & 1 deletion apps/www/src/content/docs/ai-elements/meta.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
"title": "AI Elements",
"pages": ["message", "prompt-input"]
"pages": ["message", "prompt-input", "reasoning"]
}
66 changes: 66 additions & 0 deletions apps/www/src/content/docs/ai-elements/reasoning/demo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
'use client';

export const preview = {
type: 'code',
code: `<div style={{ width: 420 }}>
<Reasoning duration={10} defaultOpen>
<Reasoning.Trigger />
<Reasoning.Content>
<Reasoning.Step label="Gathering project context">
<Text size="small" variant="secondary">Looked at recent issues in Design System 2.</Text>
</Reasoning.Step>
<Reasoning.Step label="Creating the task" />
</Reasoning.Content>
</Reasoning>
</div>`
};

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 (
<Flex direction="column" gap={4} style={{ width: 420 }}>
<Button
size="small"
variant="outline"
color="neutral"
onClick={() => {
setStreaming(true);
timerRef.current = setTimeout(() => setStreaming(false), 3000);
}}
Comment on lines +32 to +35

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Clear the previous simulation timer before restarting.

A second click leaves the first timeout active, so it can set streaming to false before the latest three-second simulation completes.

Proposed fix
         onClick={() => {
+          if (timerRef.current !== null) {
+            clearTimeout(timerRef.current);
+          }
           setStreaming(true);
           timerRef.current = setTimeout(() => setStreaming(false), 3000);
         }}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
onClick={() => {
setStreaming(true);
timerRef.current = setTimeout(() => setStreaming(false), 3000);
}}
onClick={() => {
if (timerRef.current !== null) {
clearTimeout(timerRef.current);
}
setStreaming(true);
timerRef.current = setTimeout(() => setStreaming(false), 3000);
}}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/www/src/content/docs/ai-elements/reasoning/demo.ts` around lines 32 -
35, Update the onClick handler to clear any existing timer through
timerRef.current before starting the new three-second simulation, then assign
the newly created timeout to timerRef.current so only the latest timer can set
streaming to false.

>
Simulate thinking
</Button>
<Reasoning streaming={streaming} duration={3}>
<Reasoning.Trigger />
<Reasoning.Content>
<Reasoning.Step label="Gathering ticket updates">
<Text size="small" variant="secondary">
I'm pulling the active issues assigned to you…
</Text>
</Reasoning.Step>
<Reasoning.Step label="Summarising the changes" />
</Reasoning.Content>
</Reasoning>
</Flex>
);
}`
};

export const customTriggerDemo = {
type: 'code',
code: `<div style={{ width: 420 }}>
<Reasoning duration={7}>
<Reasoning.Trigger>Show the plan</Reasoning.Trigger>
<Reasoning.Content>
<Reasoning.Step label="Outline the migration" />
<Reasoning.Step label="Estimate the blast radius" />
</Reasoning.Content>
</Reasoning>
</div>`
};
74 changes: 74 additions & 0 deletions apps/www/src/content/docs/ai-elements/reasoning/index.mdx
Original file line number Diff line number Diff line change
@@ -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";

<Demo data={preview} />

## Anatomy

```tsx
import { Reasoning } from '@raystack/apsara'

<Reasoning streaming={isThinking} duration={10}>
<Reasoning.Trigger />
<Reasoning.Content>
<Reasoning.Step label="Gathering ticket updates">
{/* indented detail */}
</Reasoning.Step>
</Reasoning.Content>
</Reasoning>
```

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

<auto-type-table path="./props.ts" name="ReasoningProps" />

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.

<auto-type-table path="./props.ts" name="ReasoningStepProps" />

## Examples

### Streaming

Drive `streaming` from your transport; the trigger label and open state
follow along.

<Demo data={streamingDemo} />

### Custom trigger label

<Demo data={customTriggerDemo} />

## 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`.
41 changes: 41 additions & 0 deletions apps/www/src/content/docs/ai-elements/reasoning/props.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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;
}) => (
<Reasoning {...props}>
<Reasoning.Trigger />
<Reasoning.Content>
<Reasoning.Step label='Gathering ticket updates'>
step detail
</Reasoning.Step>
</Reasoning.Content>
</Reasoning>
);

it('labels the trigger with the duration once done', () => {
render(<ReasoningExample duration={11} />);
expect(
screen.getByRole('button', { name: 'Worked for 11 seconds' })
).toBeInTheDocument();
});

it('uses the singular for one second', () => {
render(<ReasoningExample duration={1} />);
expect(
screen.getByRole('button', { name: 'Worked for 1 second' })
).toBeInTheDocument();
});

it('shows the thinking label and opens while streaming', () => {
render(<ReasoningExample streaming />);
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(<ReasoningExample streaming />);
expect(screen.getByRole('button', { name: 'Thinking…' })).toHaveAttribute(
'aria-expanded',
'true'
);

rerender(<ReasoningExample streaming={false} duration={4} />);
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(<ReasoningExample streaming />);
// 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(<ReasoningExample streaming={false} duration={2} />);
expect(
screen.getByRole('button', { name: 'Worked for 2 seconds' })
).toHaveAttribute('aria-expanded', 'false');
});

it('toggles via the trigger when idle', () => {
render(<ReasoningExample duration={3} />);
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(<ReasoningExample defaultOpen />);
expect(screen.getByText('Gathering ticket updates')).toBeInTheDocument();
});
});
5 changes: 5 additions & 0 deletions packages/raystack/components/reasoning/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export {
Reasoning,
type ReasoningProps,
type ReasoningStepProps
} from './reasoning';
Loading
Loading