From 24b86760938087a0ca73db98843b4e52535392b6 Mon Sep 17 00:00:00 2001 From: BenGSchulz Date: Tue, 13 Oct 2026 16:17:23 -0700 Subject: [PATCH] feat(apollo-wind): add ChatHero and PromptSuggestions chat primitives Adds the first-run and suggestion primitives the Wind chat renderer needs, matched against ApChat's first-run experience and suggestions: - PromptSuggestions: a role="group" of chips named by strings.label. Items are { label, prompt?, id?, icon? }, so AutopilotChatSuggestion passes straight in; onSelect returns the item and the caller decides between sending and prefilling. Vertical by default like ApChat, with a horizontal orientation that scrolls under scroll-fade-x. The default chip keeps the Wind raised look (surface-raised, subtle border, hover fill); density maps ApChat's compact padding and font. showLabel renders the visible "Suggestions" title ApChat shows after a reply; animated slides the group in like ApChat's popUpFromBottom, through a new animate-slide-in-from-bottom utility that only fades under reduced motion. Chips carry data-index for test hooks. - ChatHero: compound layout with ChatHeroHeader, ChatHeroLogo, ChatHeroTitle (h2, asChild) and ChatHeroDescription. layout="docked" bottom-anchors start-aligned copy above a footer composer, as ApChat's Embedded first-run does; layout="centered" is the centered home with the composer inside an 800px column. Empty title or description copy renders nothing (also through asChild) and an empty header hides, since CAS passes "" when an agent has no welcome copy. Removes the custom/chat-first-experience and custom/chat-prompt-suggestions mocks. The Delegate Home story rebuilds the mock on ChatHero with a few class overrides (computed styles match the old mock); the getting-started table and apollo-ai-context list the new components. Chat/Patterns gains Embedded and Fullscreen empty-state stories, and all four panel stories now sit on bg-background. Refs: MST-16381 Co-Authored-By: Claude Opus 5.5 --- packages/apollo-wind/apollo-ai-context.md | 11 +- .../custom/chat-first-experience.stories.tsx | 29 --- .../custom/chat-first-experience.test.tsx | 62 ----- .../custom/chat-first-experience.tsx | 78 ------ .../chat-prompt-suggestions.stories.tsx | 28 --- .../custom/chat-prompt-suggestions.test.tsx | 61 ----- .../custom/chat-prompt-suggestions.tsx | 51 ---- .../components/ui/autopilot-chat.stories.tsx | 201 ++++++++++----- .../src/components/ui/chat-hero.stories.tsx | 160 ++++++++++++ .../src/components/ui/chat-hero.test.tsx | 139 +++++++++++ .../src/components/ui/chat-hero.tsx | 235 ++++++++++++++++++ .../apollo-wind/src/components/ui/index.ts | 2 + .../ui/prompt-suggestions.stories.tsx | 161 ++++++++++++ .../components/ui/prompt-suggestions.test.tsx | 99 ++++++++ .../src/components/ui/prompt-suggestions.tsx | 173 +++++++++++++ .../getting-started.stories.tsx | 12 +- packages/apollo-wind/src/index.ts | 29 +++ .../src/styles/tailwind.utilities.css | 15 ++ .../templates/Delegate/delegate.stories.tsx | 31 ++- 19 files changed, 1191 insertions(+), 386 deletions(-) delete mode 100644 packages/apollo-wind/src/components/custom/chat-first-experience.stories.tsx delete mode 100644 packages/apollo-wind/src/components/custom/chat-first-experience.test.tsx delete mode 100644 packages/apollo-wind/src/components/custom/chat-first-experience.tsx delete mode 100644 packages/apollo-wind/src/components/custom/chat-prompt-suggestions.stories.tsx delete mode 100644 packages/apollo-wind/src/components/custom/chat-prompt-suggestions.test.tsx delete mode 100644 packages/apollo-wind/src/components/custom/chat-prompt-suggestions.tsx create mode 100644 packages/apollo-wind/src/components/ui/chat-hero.stories.tsx create mode 100644 packages/apollo-wind/src/components/ui/chat-hero.test.tsx create mode 100644 packages/apollo-wind/src/components/ui/chat-hero.tsx create mode 100644 packages/apollo-wind/src/components/ui/prompt-suggestions.stories.tsx create mode 100644 packages/apollo-wind/src/components/ui/prompt-suggestions.test.tsx create mode 100644 packages/apollo-wind/src/components/ui/prompt-suggestions.tsx diff --git a/packages/apollo-wind/apollo-ai-context.md b/packages/apollo-wind/apollo-ai-context.md index 6edccc5039..6808663df6 100644 --- a/packages/apollo-wind/apollo-ai-context.md +++ b/packages/apollo-wind/apollo-ai-context.md @@ -354,10 +354,11 @@ Put the control inside `InputGroup`. Apollo's controls detect the group and beco #### Other available components Accordion, Alert, AlertDialog, AspectRatio, Avatar, Breadcrumb, ButtonGroup, -Calendar, Checkbox, Collapsible, Combobox, Command, ContextMenu, DatePicker, -DateTimePicker, Drawer, DropdownMenu, EditableCell, EmptyState, FileTreeView, -FileUpload, HoverCard, JsonTreeView, Layout (Grid/Row/Column), Menubar, -MultiSelect, NavigationMenu, Pagination, Popover, Progress, RadioGroup, +Calendar, ChatHero, Checkbox, Collapsible, Combobox, Command, ContextMenu, +DatePicker, DateTimePicker, Drawer, DropdownMenu, EditableCell, EmptyState, +FileTreeView, FileUpload, HoverCard, JsonTreeView, Layout (Grid/Row/Column), +Menubar, MultiSelect, NavigationMenu, Pagination, Popover, Progress, +PromptSuggestions, RadioGroup, Resizable, ScrollArea, Search, Separator, Sidebar, Skeleton, Slider, Sonner, Spinner, StatsCard, Stepper, Switch, Toggle, ToggleGroup, Tooltip, InfoTooltip, FormField (with FormFieldLabel, FormFieldHeader, FormFieldDescription, FormFieldError), @@ -379,8 +380,6 @@ is imported from `@/components/ui/tree-view`. | DelegatePanel | `panel-delegate` | Delegate navigation panel | | MaestroPanel | `panel-maestro` | Maestro side panels | | MaestroGrid | `grid-maestro` | Maestro content grid | -| ChatFirstExperience | `chat-first-experience` | Empty chat state | -| ChatPromptSuggestions | `chat-prompt-suggestions` | Suggested prompts | | ChatStepsView | `chat-steps-view` | Chat step progress | ### Forms System (`@/components/forms/`) diff --git a/packages/apollo-wind/src/components/custom/chat-first-experience.stories.tsx b/packages/apollo-wind/src/components/custom/chat-first-experience.stories.tsx deleted file mode 100644 index 4990c254a6..0000000000 --- a/packages/apollo-wind/src/components/custom/chat-first-experience.stories.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/react-vite'; -import { ChatFirstExperience } from './chat-first-experience'; - -const meta = { - title: 'Components/UiPath/Chat First Experience', - component: ChatFirstExperience, - parameters: { - layout: 'fullscreen', - }, -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -export const Default: Story = { - render: () => ( -
- -
- ), -}; diff --git a/packages/apollo-wind/src/components/custom/chat-first-experience.test.tsx b/packages/apollo-wind/src/components/custom/chat-first-experience.test.tsx deleted file mode 100644 index 1604634ec4..0000000000 --- a/packages/apollo-wind/src/components/custom/chat-first-experience.test.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { axe } from 'jest-axe'; -import { describe, expect, it, vi } from 'vitest'; -import { ChatFirstExperience } from './chat-first-experience'; - -describe('ChatFirstExperience', () => { - it('renders the default greeting and subtitle', () => { - render(); - expect(screen.getByRole('heading', { name: 'Hello David' })).toBeInTheDocument(); - expect(screen.getByText('What should we work on today?')).toBeInTheDocument(); - }); - - it('renders a custom user name and subtitle', () => { - render(); - expect(screen.getByRole('heading', { name: 'Hello Priya' })).toBeInTheDocument(); - expect(screen.getByText('Ready when you are')).toBeInTheDocument(); - }); - - it('renders the composer with the provided placeholder', () => { - render(); - expect(screen.getByPlaceholderText('Describe your automation')).toBeInTheDocument(); - }); - - it('renders default prompt suggestions', () => { - render(); - expect( - screen.getByRole('button', { name: 'Make a list of affordable apartments in NYC' }) - ).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Find the highest CD rates' })).toBeInTheDocument(); - }); - - it('calls onSuggestionClick with the clicked suggestion', async () => { - const user = userEvent.setup(); - const onSuggestionClick = vi.fn(); - const suggestions = [ - { id: 'a', label: 'Summarize my inbox' }, - { id: 'b', label: 'Build an expense report' }, - ]; - render(); - - await user.click(screen.getByRole('button', { name: 'Build an expense report' })); - expect(onSuggestionClick).toHaveBeenCalledTimes(1); - expect(onSuggestionClick).toHaveBeenCalledWith(suggestions[1]); - }); - - it('calls onSubmit when a message is submitted from the composer', async () => { - const user = userEvent.setup(); - const onSubmit = vi.fn(); - render(); - - await user.type(screen.getByRole('textbox'), 'Automate onboarding'); - await user.click(screen.getByRole('button', { name: 'Send message' })); - expect(onSubmit).toHaveBeenCalledWith('Automate onboarding'); - }); - - it('has no accessibility violations', async () => { - const { container } = render(); - const results = await axe(container); - expect(results).toHaveNoViolations(); - }); -}); diff --git a/packages/apollo-wind/src/components/custom/chat-first-experience.tsx b/packages/apollo-wind/src/components/custom/chat-first-experience.tsx deleted file mode 100644 index a952188a57..0000000000 --- a/packages/apollo-wind/src/components/custom/chat-first-experience.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import { - type PromptSuggestion, - PromptSuggestions, -} from '@/components/custom/chat-prompt-suggestions'; -import { DemoComposer } from '@/components/custom/demo-composer'; -import { cn } from '@/lib'; - -// ============================================================================ -// Types -// ============================================================================ - -export interface ChatFirstExperienceProps { - className?: string; - /** Greeting name displayed in the heading */ - userName?: string; - /** Subtitle below the greeting */ - subtitle?: string; - /** Placeholder text inside the composer */ - composerPlaceholder?: string; - /** Prompt suggestions shown below the composer */ - suggestions?: PromptSuggestion[]; - /** Callback when a suggestion is clicked */ - onSuggestionClick?: (suggestion: PromptSuggestion) => void; - /** Callback when the composer submit button is clicked */ - onSubmit?: (value: string) => void; -} - -// ============================================================================ -// ChatFirstExperience -// ============================================================================ - -/** - * The initial "first run" experience shown in the Canvas when - * a user opens the Delegate Chat page with no active conversation. - * - * Displays a greeting, a composer input, and prompt suggestions, - * all constrained to 800px max-width and centered in the canvas. - */ -export function ChatFirstExperience({ - className, - userName = 'David', - subtitle = 'What should we work on today?', - composerPlaceholder = 'I would like you to automate my', - suggestions = [ - { id: '1', label: 'Make a list of affordable apartments in NYC' }, - { id: '2', label: 'Find the highest CD rates' }, - { id: '3', label: 'Lorem ipsum dolor sit amet' }, - ], - onSuggestionClick, - onSubmit, -}: ChatFirstExperienceProps) { - return ( -
- {/* 800px content container */} -
- {/* Greeting */} -
-

- Hello {userName} -

-

- {subtitle} -

-
- - {/* Composer */} - - - {/* Prompt suggestions */} - -
-
- ); -} diff --git a/packages/apollo-wind/src/components/custom/chat-prompt-suggestions.stories.tsx b/packages/apollo-wind/src/components/custom/chat-prompt-suggestions.stories.tsx deleted file mode 100644 index a001313d88..0000000000 --- a/packages/apollo-wind/src/components/custom/chat-prompt-suggestions.stories.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/react-vite'; -import { PromptSuggestions } from './chat-prompt-suggestions'; - -const meta = { - title: 'Components/UiPath/Prompt Suggestions', - component: PromptSuggestions, - parameters: { - layout: 'centered', - }, -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -export const Default: Story = { - render: () => ( -
- -
- ), -}; diff --git a/packages/apollo-wind/src/components/custom/chat-prompt-suggestions.test.tsx b/packages/apollo-wind/src/components/custom/chat-prompt-suggestions.test.tsx deleted file mode 100644 index 6486d37999..0000000000 --- a/packages/apollo-wind/src/components/custom/chat-prompt-suggestions.test.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { axe } from 'jest-axe'; -import { describe, expect, it, vi } from 'vitest'; -import { PromptSuggestions } from './chat-prompt-suggestions'; - -const suggestions = [ - { id: '1', label: 'Make a list of affordable apartments in NYC' }, - { id: '2', label: 'Find the highest CD rates' }, - { id: '3', label: 'Draft a weekly status report' }, -]; - -describe('PromptSuggestions', () => { - it('renders one button per suggestion', () => { - render(); - expect(screen.getAllByRole('button')).toHaveLength(3); - expect( - screen.getByRole('button', { name: 'Draft a weekly status report' }) - ).toBeInTheDocument(); - }); - - it('renders nothing when there are no suggestions', () => { - const { container } = render(); - expect(container).toBeEmptyDOMElement(); - }); - - it('renders nothing when suggestions are omitted', () => { - const { container } = render(); - expect(container).toBeEmptyDOMElement(); - }); - - it('calls onSelect with the full suggestion object', async () => { - const user = userEvent.setup(); - const onSelect = vi.fn(); - render(); - - await user.click(screen.getByRole('button', { name: 'Find the highest CD rates' })); - expect(onSelect).toHaveBeenCalledTimes(1); - expect(onSelect).toHaveBeenCalledWith(suggestions[1]); - }); - - it('does not throw when clicked without an onSelect handler', async () => { - const user = userEvent.setup(); - render(); - await user.click(screen.getByRole('button', { name: suggestions[0].label })); - expect(screen.getByRole('button', { name: suggestions[0].label })).toBeInTheDocument(); - }); - - it('applies a custom className to the container', () => { - const { container } = render( - - ); - expect(container.firstElementChild).toHaveClass('custom-list'); - }); - - it('has no accessibility violations', async () => { - const { container } = render(); - const results = await axe(container); - expect(results).toHaveNoViolations(); - }); -}); diff --git a/packages/apollo-wind/src/components/custom/chat-prompt-suggestions.tsx b/packages/apollo-wind/src/components/custom/chat-prompt-suggestions.tsx deleted file mode 100644 index 9909ff25a8..0000000000 --- a/packages/apollo-wind/src/components/custom/chat-prompt-suggestions.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { cn } from '@/lib'; - -// ============================================================================ -// Types -// ============================================================================ - -export interface PromptSuggestion { - id: string; - label: string; -} - -export interface PromptSuggestionsProps { - className?: string; - /** List of suggestions to display */ - suggestions?: PromptSuggestion[]; - /** Callback when a suggestion is clicked */ - onSelect?: (suggestion: PromptSuggestion) => void; -} - -// ============================================================================ -// PromptSuggestions -// ============================================================================ - -/** - * A vertical list of clickable prompt suggestion pills. - * - * Styled per the Delegate Figma spec: zinc-800 background with - * zinc-700 border and zinc-50 text. - */ -export function PromptSuggestions({ - className, - suggestions = [], - onSelect, -}: PromptSuggestionsProps) { - if (suggestions.length === 0) return null; - - return ( -
- {suggestions.map((suggestion) => ( - - ))} -
- ); -} diff --git a/packages/apollo-wind/src/components/ui/autopilot-chat.stories.tsx b/packages/apollo-wind/src/components/ui/autopilot-chat.stories.tsx index 0fcff0c8d5..7d7579a6bc 100644 --- a/packages/apollo-wind/src/components/ui/autopilot-chat.stories.tsx +++ b/packages/apollo-wind/src/components/ui/autopilot-chat.stories.tsx @@ -31,11 +31,13 @@ import { Card, CardFooter, CardHeader, CardTitle } from './card'; import { ChatComposer, ChatComposerAttachButton, + type ChatComposerDensity, ChatComposerInputGroup, ChatComposerSubmit, ChatComposerTextarea, ChatComposerToolbar, } from './chat-composer'; +import { ChatHero, ChatHeroDescription, ChatHeroHeader, ChatHeroTitle } from './chat-hero'; import { DropdownMenu, DropdownMenuContent, @@ -54,6 +56,7 @@ import { MessageScrollerProvider, MessageScrollerViewport, } from './message-scroller'; +import { type PromptSuggestionItem, PromptSuggestions } from './prompt-suggestions'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './tooltip'; const meta = { @@ -63,7 +66,7 @@ const meta = { docs: { description: { component: - 'Chat panel pattern for Autopilot, assembled from the chat primitives (MessageScroller, Message, Bubble, Attachment, Marker) plus ChatComposer for the prompt input and Card, EmptyState and DropdownMenu for the panel chrome. The transcript is scripted for the demo: press Send to reveal the next turn. The service-driven version of this panel is being built as @uipath/apollo-react/chat on top of these primitives.', + 'Chat panel pattern for Autopilot, assembled from the chat primitives (MessageScroller, Message, Bubble, Attachment, Marker) plus ChatComposer for the prompt input and Card, EmptyState and DropdownMenu for the panel chrome. The empty-state variants open on ChatHero with PromptSuggestions: docked above the footer composer in Embedded, centered with the composer inside it in Fullscreen. The transcript is scripted for the demo: press Send, or pick a suggestion, to reveal the next turn. The service-driven version of this panel is being built as @uipath/apollo-react/chat on top of these primitives.', }, }, }, @@ -107,6 +110,13 @@ const SCRIPT: ScriptedTurn[] = [ const REPLY_DELAY_MS = 900; +// Every suggestion starts the scripted conversation. +const FIRST_RUN_SUGGESTIONS: PromptSuggestionItem[] = [ + { label: 'Check which invoices are ready for approval' }, + { label: "Summarize this week's invoice batch" }, + { label: 'What can you help me with?' }, +]; + const AGENT_MODES: { label: string; icon: LucideIcon }[] = [ { label: 'Agent', icon: Bot }, { label: 'Plan', icon: SquarePen }, @@ -120,9 +130,18 @@ interface AutopilotChatPanelProps { * and flips its icon and label in Fullscreen. */ variant?: 'standard' | 'embedded' | 'fullscreen'; + /** + * Opens on ChatHero instead of EmptyState: docked above the footer composer, or centered with + * the composer inside it in Fullscreen. + */ + firstRun?: boolean; } -function AutopilotChatPanel({ className, variant = 'standard' }: AutopilotChatPanelProps) { +function AutopilotChatPanel({ + className, + variant = 'standard', + firstRun = false, +}: AutopilotChatPanelProps) { const [revealedCount, setRevealedCount] = React.useState(0); const [isBusy, setIsBusy] = React.useState(false); const [agentMode, setAgentMode] = React.useState(AGENT_MODES[0].label); @@ -161,6 +180,64 @@ function AutopilotChatPanel({ className, variant = 'standard' }: AutopilotChatPa ? 'Autopilot is replying...' : 'No messages queued. Start a new chat to replay the conversation.'; + const showHero = firstRun && turns.length === 0; + const heroHoldsComposer = showHero && variant === 'fullscreen'; + + const renderComposer = (density: ChatComposerDensity) => { + const toolbarSize = density === 'compact' ? '3xs' : '2xs'; + return ( + + + + + + + + + Attach file + + + + + + + + Add resource + + + + + + {agentMode} + + + + {AGENT_MODES.map((mode) => ( + setAgentMode(mode.label)}> + + + {mode.label} + + + ))} + + + + + + + ); + }; + return ( -
- {turns.length === 0 ? ( +
+ {showHero ? ( +
+ + + Morning! + What are we working on today? + + {heroHoldsComposer && renderComposer('comfortable')} + + +
+ ) : turns.length === 0 ? ( } @@ -293,59 +389,9 @@ function AutopilotChatPanel({ className, variant = 'standard' }: AutopilotChatPa )}
- - - - - - - - - - Attach file - - - - - - - - Add resource - - - - - - {agentMode} - - - - {AGENT_MODES.map((mode) => ( - setAgentMode(mode.label)}> - - - {mode.label} - - - ))} - - - - - - - + {!heroHoldsComposer && ( + {renderComposer('compact')} + )} ); @@ -361,7 +407,7 @@ export const Embedded: Story = { name: 'Embedded', parameters: { layout: 'fullscreen' }, render: () => ( -
+
Host application
@@ -383,7 +429,44 @@ export const Fullscreen: Story = {
+
+ ), +}; + +/** + * Embedded before the first message: ChatHero docks above the footer composer, start-aligned, as + * ApChat's first-run experience does today. + */ +export const EmbeddedEmptyState: Story = { + name: 'Embedded, empty state', + parameters: { layout: 'fullscreen' }, + render: () => ( +
+
+ Host application +
+
+ +
+
+ ), +}; + +/** + * Fullscreen before the first message: ChatHero centers the greeting, the composer and the + * suggestions. The composer moves to the footer once the conversation starts. + */ +export const FullscreenEmptyState: Story = { + name: 'Fullscreen, empty state', + parameters: { layout: 'fullscreen' }, + render: () => ( +
+
), diff --git a/packages/apollo-wind/src/components/ui/chat-hero.stories.tsx b/packages/apollo-wind/src/components/ui/chat-hero.stories.tsx new file mode 100644 index 0000000000..edd40a2c88 --- /dev/null +++ b/packages/apollo-wind/src/components/ui/chat-hero.stories.tsx @@ -0,0 +1,160 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { Sparkles } from 'lucide-react'; +import { + ChatComposer, + ChatComposerAttachButton, + type ChatComposerDensity, + ChatComposerInputGroup, + ChatComposerSubmit, + ChatComposerTextarea, + ChatComposerToolbar, +} from './chat-composer'; +import { + ChatHero, + type ChatHeroDensity, + ChatHeroDescription, + ChatHeroHeader, + ChatHeroLogo, + type ChatHeroProps, + ChatHeroTitle, +} from './chat-hero'; +import { type PromptSuggestionItem, PromptSuggestions } from './prompt-suggestions'; + +const SUGGESTIONS: PromptSuggestionItem[] = [ + { label: 'Get started', prompt: 'How do I get started with this application?' }, + { label: 'Show features', prompt: 'What features are available?' }, + { label: 'Find the highest CD rates' }, +]; + +const meta: Meta = { + title: 'Chat/Components/Chat Hero', + component: ChatHero, + tags: ['autodocs'], + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: + 'The first-run state of a chat, before any message. The centered layout is the home screen: greeting, composer and PromptSuggestions in a centered 800px column. The docked layout sits at the bottom of the message area, start-aligned, while the composer stays in the footer, as the Embedded chat does today. ChatHeroTitle and ChatHeroDescription render nothing for empty copy, and ChatHeroHeader hides when both are empty.', + }, + }, + }, +}; + +export default meta; +type Story = StoryObj; + +function Composer({ density }: { density?: ChatComposerDensity }) { + return ( + undefined}> + + + + + + + + + ); +} + +function CenteredDemo({ density, logo = false }: { density?: ChatHeroDensity; logo?: boolean }) { + return ( +
+ + + {logo && ( + + + + + + )} + Hello David + What should we work on today? + + + + +
+ ); +} + +export const Centered: Story = { + render: () => , +}; + +export const CenteredWithLogo: Story = { + name: 'Centered with logo', + render: () => , +}; + +export const CenteredCompact: Story = { + name: 'Centered, compact', + render: () => , +}; + +function DockedDemo({ + density, + title = 'Welcome to Autopilot', + description = 'Ask me anything about your data or how to use this application.', +}: { + density?: ChatHeroDensity; + title?: string; + description?: string; +}) { + return ( +
+
+ + + {title} + {description} + + + +
+
+ +
+
+ ); +} + +/** The composer stays in the panel footer; the hero sits at the bottom of the message area. */ +export const Docked: Story = { + parameters: { layout: 'centered' }, + render: () => , +}; + +export const DockedCompact: Story = { + name: 'Docked, compact', + parameters: { layout: 'centered' }, + render: () => , +}; + +/** A host without welcome copy passes empty strings; only the suggestions show. */ +export const DockedWithoutCopy: Story = { + name: 'Docked, without copy', + parameters: { layout: 'centered' }, + render: () => , +}; + +export const Playground: Story = { + args: { + layout: 'centered', + density: 'comfortable', + }, + render: (args: ChatHeroProps) => ( +
+ + + Hello David + What should we work on today? + + {args.layout !== 'docked' && } + + +
+ ), +}; diff --git a/packages/apollo-wind/src/components/ui/chat-hero.test.tsx b/packages/apollo-wind/src/components/ui/chat-hero.test.tsx new file mode 100644 index 0000000000..3908b81f59 --- /dev/null +++ b/packages/apollo-wind/src/components/ui/chat-hero.test.tsx @@ -0,0 +1,139 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { axe } from 'jest-axe'; +import { describe, expect, it, vi } from 'vitest'; +import { + ChatComposer, + ChatComposerInputGroup, + ChatComposerSubmit, + ChatComposerTextarea, + ChatComposerToolbar, +} from './chat-composer'; +import { + ChatHero, + ChatHeroDescription, + ChatHeroHeader, + ChatHeroLogo, + ChatHeroTitle, +} from './chat-hero'; +import { PromptSuggestions } from './prompt-suggestions'; + +function Example({ + onSubmit, + onSelect, + ...props +}: React.ComponentProps & { + onSubmit?: (value: string) => void; + onSelect?: () => void; +}) { + return ( + + + + + + Hello David + What should we work on today? + + + + + + + + + + + + ); +} + +describe('ChatHero', () => { + it('renders the title as a heading and the description', () => { + render(); + expect(screen.getByRole('heading', { level: 2, name: 'Hello David' })).toBeInTheDocument(); + expect(screen.getByText('What should we work on today?')).toBeInTheDocument(); + }); + + it('submits through the composer inside it', async () => { + const user = userEvent.setup(); + const onSubmit = vi.fn(); + render(); + + await user.type(screen.getByRole('textbox', { name: 'Message' }), 'Automate my invoices'); + await user.click(screen.getByRole('button', { name: 'Send message' })); + expect(onSubmit).toHaveBeenCalledWith('Automate my invoices', { attachments: [] }); + }); + + it('passes suggestion clicks through', async () => { + const user = userEvent.setup(); + const onSelect = vi.fn(); + render(); + + await user.click(screen.getByRole('button', { name: 'Find CD rates' })); + expect(onSelect).toHaveBeenCalledWith({ label: 'Find CD rates' }); + }); + + it('defaults to the centered layout and reflects layout and density', () => { + const { container, rerender } = render(); + const hero = container.querySelector('[data-slot="chat-hero"]'); + expect(hero).toHaveAttribute('data-layout', 'centered'); + expect(hero).toHaveAttribute('data-density', 'comfortable'); + + rerender(); + expect(hero).toHaveAttribute('data-layout', 'docked'); + expect(hero).toHaveAttribute('data-density', 'compact'); + }); + + it('renders nothing for empty title and description', () => { + const { container } = render( + + + {''} + {''} + + + ); + expect(screen.queryByRole('heading')).not.toBeInTheDocument(); + expect(container.querySelector('[data-slot="chat-hero-header"]')).toBeEmptyDOMElement(); + }); + + it('renders nothing for an empty asChild title', () => { + const title: string = ''; + const { container } = render( + + + +

{title}

+
+
+
+ ); + expect(screen.queryByRole('heading')).not.toBeInTheDocument(); + expect(container.querySelector('[data-slot="chat-hero-header"]')).toBeEmptyDOMElement(); + }); + + it('renders the title as its child with asChild', () => { + render( + + +

Welcome

+
+
+ ); + expect(screen.getByRole('heading', { level: 1, name: 'Welcome' })).toHaveAttribute( + 'data-slot', + 'chat-hero-title' + ); + }); + + it('has no accessibility violations in either layout', async () => { + const { container } = render( + <> + + + + ); + expect(await axe(container)).toHaveNoViolations(); + }); +}); diff --git a/packages/apollo-wind/src/components/ui/chat-hero.tsx b/packages/apollo-wind/src/components/ui/chat-hero.tsx new file mode 100644 index 0000000000..5c78cb575f --- /dev/null +++ b/packages/apollo-wind/src/components/ui/chat-hero.tsx @@ -0,0 +1,235 @@ +'use client'; + +import { Slot } from '@radix-ui/react-slot'; +import { cva } from 'class-variance-authority'; +import * as React from 'react'; +import { cn } from '@/lib'; + +/** + * The first-run state of a chat, before any message. `centered` is the home screen: greeting, + * composer and suggestions in a centered column. `docked` sits at the bottom of the message area, + * start-aligned, while the composer stays in the footer. + * + * ```tsx + * + * + * Hello David + * What should we work on today? + * + * ... + * + * + * ``` + */ + +export type ChatHeroLayout = 'centered' | 'docked'; +export type ChatHeroDensity = 'comfortable' | 'compact'; + +interface ChatHeroContextValue { + layout: ChatHeroLayout; + density: ChatHeroDensity; +} + +const ChatHeroContext = React.createContext({ + layout: 'centered', + density: 'comfortable', +}); + +const chatHeroVariants = cva('flex w-full min-w-0 flex-1 flex-col', { + variants: { + layout: { + centered: 'mx-auto max-w-[800px] items-center justify-center', + docked: 'justify-end gap-6 text-start', + }, + density: { + comfortable: '', + compact: '', + }, + }, + compoundVariants: [ + { layout: 'centered', density: 'comfortable', className: 'gap-8' }, + { layout: 'centered', density: 'compact', className: 'gap-6' }, + ], + defaultVariants: { + layout: 'centered', + density: 'comfortable', + }, +}); + +export interface ChatHeroProps extends React.HTMLAttributes { + layout?: ChatHeroLayout; + density?: ChatHeroDensity; +} + +/** Fills its flex parent, so the parent decides the height that `centered` centers within. */ +const ChatHero = React.forwardRef( + ({ className, layout, density, ...props }, ref) => { + const context = React.useMemo( + () => ({ layout: layout ?? 'centered', density: density ?? 'comfortable' }), + [layout, density] + ); + return ( + +
+ + ); + } +); +ChatHero.displayName = 'ChatHero'; + +/** Holds the logo, title and description. Hidden when all of them render nothing. */ +const ChatHeroHeader = React.forwardRef>( + ({ className, ...props }, ref) => { + const { layout } = React.useContext(ChatHeroContext); + return ( +
+ ); + } +); +ChatHeroHeader.displayName = 'ChatHeroHeader'; + +const ChatHeroLogo = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ) +); +ChatHeroLogo.displayName = 'ChatHeroLogo'; + +// A host may pass empty welcome copy; rendering nothing keeps the gaps from stacking up. +function isEmptyContent(children: React.ReactNode): boolean { + return children === undefined || children === null || children === false || children === ''; +} + +// With asChild the copy sits one level down, inside the element passed as the child. +function isEmptyTitle(children: React.ReactNode, asChild: boolean): boolean { + if (isEmptyContent(children)) return true; + if (!asChild || !React.isValidElement<{ children?: React.ReactNode }>(children)) return false; + return isEmptyContent(children.props.children); +} + +const chatHeroTitleVariants = cva('min-w-0 text-foreground', { + variants: { + layout: { + centered: 'font-bold tracking-tight', + docked: 'font-semibold tracking-tight', + }, + density: { + comfortable: '', + compact: '', + }, + }, + compoundVariants: [ + { layout: 'centered', density: 'comfortable', className: 'text-4xl leading-10' }, + { layout: 'centered', density: 'compact', className: 'text-2xl leading-8' }, + { layout: 'docked', className: 'text-xl leading-6' }, + ], + defaultVariants: { + layout: 'centered', + density: 'comfortable', + }, +}); + +export interface ChatHeroTitleProps extends React.HTMLAttributes { + /** + * Renders the child element instead of an `h2`, such as an `h1` on a page of its own. Put + * class overrides on `ChatHeroTitle`, not the child: Slot joins the two without resolving + * conflicts. + */ + asChild?: boolean; +} + +const ChatHeroTitle = React.forwardRef( + ({ className, asChild = false, children, ...props }, ref) => { + const { layout, density } = React.useContext(ChatHeroContext); + if (isEmptyTitle(children, asChild)) return null; + const Comp = asChild ? Slot : 'h2'; + return ( + + {children} + + ); + } +); +ChatHeroTitle.displayName = 'ChatHeroTitle'; + +const chatHeroDescriptionVariants = cva('min-w-0 text-muted-foreground', { + variants: { + layout: { + centered: '', + docked: '', + }, + density: { + comfortable: '', + compact: '', + }, + }, + compoundVariants: [ + { layout: 'centered', density: 'comfortable', className: 'text-base' }, + { layout: 'centered', density: 'compact', className: 'text-sm' }, + { layout: 'docked', density: 'comfortable', className: 'text-sm leading-5' }, + { layout: 'docked', density: 'compact', className: 'text-xs leading-4' }, + ], + defaultVariants: { + layout: 'centered', + density: 'comfortable', + }, +}); + +const ChatHeroDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, children, ...props }, ref) => { + const { layout, density } = React.useContext(ChatHeroContext); + if (isEmptyContent(children)) return null; + return ( +

+ {children} +

+ ); +}); +ChatHeroDescription.displayName = 'ChatHeroDescription'; + +export { + ChatHero, + ChatHeroDescription, + ChatHeroHeader, + ChatHeroLogo, + ChatHeroTitle, + chatHeroDescriptionVariants, + chatHeroTitleVariants, + chatHeroVariants, +}; diff --git a/packages/apollo-wind/src/components/ui/index.ts b/packages/apollo-wind/src/components/ui/index.ts index a00759c049..77ab25aa62 100644 --- a/packages/apollo-wind/src/components/ui/index.ts +++ b/packages/apollo-wind/src/components/ui/index.ts @@ -14,6 +14,7 @@ export * from './calendar'; export * from './card'; export * from './chart'; export * from './chat-composer'; +export * from './chat-hero'; export * from './checkbox'; export * from './citation'; export * from './code-block'; @@ -57,6 +58,7 @@ export * from './popover'; export * from './portal-container'; export * from './progress'; export * from './prompt-editor'; +export * from './prompt-suggestions'; export * from './prompt-value-control'; export * from './quick-form-field'; export * from './radio-group'; diff --git a/packages/apollo-wind/src/components/ui/prompt-suggestions.stories.tsx b/packages/apollo-wind/src/components/ui/prompt-suggestions.stories.tsx new file mode 100644 index 0000000000..1389371418 --- /dev/null +++ b/packages/apollo-wind/src/components/ui/prompt-suggestions.stories.tsx @@ -0,0 +1,161 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { ChartLine, FileText, Mail, Search } from 'lucide-react'; +import * as React from 'react'; +import { + ChatComposer, + ChatComposerInputGroup, + ChatComposerSubmit, + ChatComposerTextarea, + ChatComposerToolbar, +} from './chat-composer'; +import { Label } from './label'; +import { type PromptSuggestionItem, PromptSuggestions } from './prompt-suggestions'; +import { Switch } from './switch'; + +const SUGGESTIONS: PromptSuggestionItem[] = [ + { label: 'Generate report', prompt: 'Create a quarterly sales report for Q3' }, + { label: 'Data analysis', prompt: 'Analyze customer engagement metrics' }, + { label: 'How can I use this feature?' }, +]; + +const meta: Meta = { + title: 'Chat/Components/Prompt Suggestions', + component: PromptSuggestions, + tags: ['autodocs'], + parameters: { + docs: { + description: { + component: + 'Prompts the user can pick instead of typing. Each suggestion has a label and, when the text to send differs, a prompt, so a chat service suggestion passes straight in. onSelect receives the suggestion. Sending it or putting it in the composer is up to the caller. Chips stack vertically by default. The horizontal orientation keeps one row that scrolls with faded edges when it overflows.', + }, + }, + }, + args: { + suggestions: SUGGESTIONS, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +/** The row scrolls once the chips outgrow the width. */ +export const Horizontal: Story = { + args: { + orientation: 'horizontal', + suggestions: [ + ...SUGGESTIONS, + { label: 'Summarize my inbox' }, + { label: 'Draft a status update' }, + ], + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export const WithIcons: Story = { + args: { + suggestions: [ + { label: 'Search the knowledge base', icon: }, + { label: 'Summarize this document', icon: }, + { label: 'Chart last month', icon: }, + { label: 'Draft a reply', icon: }, + ], + }, +}; + +export const Compact: Story = { + args: { + density: 'compact', + }, +}; + +/** After a reply, a chat titles the group and slides it in. */ +export const AfterAReply: Story = { + args: { + showLabel: true, + animated: true, + }, +}; + +/** Long labels wrap inside their chip. */ +export const LongLabels: Story = { + args: { + suggestions: [ + { + label: + 'Compare the open invoices from Acme Corp against the purchase orders and flag mismatches', + }, + { label: 'Find the highest CD rates' }, + ], + }, +}; + +function SendOrPrefillDemo() { + const [sendOnClick, setSendOnClick] = React.useState(false); + const [draft, setDraft] = React.useState(''); + const [sent, setSent] = React.useState([]); + const textareaRef = React.useRef(null); + + const send = (value: string) => { + setSent((log) => [...log, value]); + setDraft(''); + }; + + return ( +
+
+ + +
+ {sent.length > 0 && ( +
    + {sent.map((line, index) => ( +
  • Sent: {line}
  • + ))} +
+ )} + { + const text = prompt ?? label; + if (sendOnClick) { + send(text); + } else { + setDraft(text); + } + textareaRef.current?.focus(); + }} + /> + + + + + + + + +
+ ); +} + +/** + * The two behaviours a chat offers: send the prompt at once, or put it in the composer to edit. + * Either way focus moves to the composer. + */ +export const SendOrPrefill: Story = { + render: () => , +}; diff --git a/packages/apollo-wind/src/components/ui/prompt-suggestions.test.tsx b/packages/apollo-wind/src/components/ui/prompt-suggestions.test.tsx new file mode 100644 index 0000000000..8b49d35426 --- /dev/null +++ b/packages/apollo-wind/src/components/ui/prompt-suggestions.test.tsx @@ -0,0 +1,99 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { axe } from 'jest-axe'; +import { Sparkles } from 'lucide-react'; +import { describe, expect, it, vi } from 'vitest'; +import { type PromptSuggestionItem, PromptSuggestions } from './prompt-suggestions'; + +const SUGGESTIONS: PromptSuggestionItem[] = [ + { label: 'Get started', prompt: 'How do I get started with this application?' }, + { label: 'Show features', prompt: 'What features are available?' }, +]; + +describe('PromptSuggestions', () => { + it('calls onSelect with the picked suggestion', async () => { + const user = userEvent.setup(); + const onSelect = vi.fn(); + render(); + + await user.click(screen.getByRole('button', { name: 'Show features' })); + expect(onSelect).toHaveBeenCalledTimes(1); + expect(onSelect).toHaveBeenCalledWith(SUGGESTIONS[1]); + }); + + it('selects with the keyboard', async () => { + const user = userEvent.setup(); + const onSelect = vi.fn(); + render(); + + await user.tab(); + await user.keyboard('{Enter}'); + await user.tab(); + await user.keyboard(' '); + expect(onSelect.mock.calls).toEqual([[SUGGESTIONS[0]], [SUGGESTIONS[1]]]); + }); + + it('names the group from strings', () => { + const { rerender } = render(); + expect(screen.getByRole('group', { name: 'Suggestions' })).toBeInTheDocument(); + + rerender(); + expect(screen.getByRole('group', { name: 'Vorschläge' })).toBeInTheDocument(); + }); + + it('shows the label as a visible title with showLabel', () => { + render(); + const group = screen.getByRole('group', { name: 'Suggestions' }); + expect(group).not.toHaveAttribute('aria-label'); + expect(screen.getByText('Suggestions')).toBeVisible(); + }); + + it('renders nothing without suggestions', () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it('disables every chip', async () => { + const user = userEvent.setup(); + const onSelect = vi.fn(); + render(); + + for (const chip of screen.getAllByRole('button')) expect(chip).toBeDisabled(); + await user.click(screen.getByRole('button', { name: 'Get started' })); + expect(onSelect).not.toHaveBeenCalled(); + }); + + it('reflects orientation and density, and indexes the chips', () => { + render( + + ); + const group = screen.getByRole('group'); + expect(group).toHaveAttribute('data-orientation', 'horizontal'); + expect(group).toHaveAttribute('data-density', 'compact'); + expect(screen.getByRole('button', { name: 'Show features' })).toHaveAttribute( + 'data-index', + '1' + ); + }); + + it('renders an icon per suggestion', () => { + render( + }]} + /> + ); + expect(screen.getByRole('button', { name: 'Ideas' })).toContainElement( + screen.getByTestId('icon') + ); + }); + + it('has no accessibility violations', async () => { + const { container } = render( + <> + + + + ); + expect(await axe(container)).toHaveNoViolations(); + }); +}); diff --git a/packages/apollo-wind/src/components/ui/prompt-suggestions.tsx b/packages/apollo-wind/src/components/ui/prompt-suggestions.tsx new file mode 100644 index 0000000000..0436c6bbe0 --- /dev/null +++ b/packages/apollo-wind/src/components/ui/prompt-suggestions.tsx @@ -0,0 +1,173 @@ +'use client'; + +import { cva } from 'class-variance-authority'; +import * as React from 'react'; +import { cn } from '@/lib'; + +/** + * Prompts the user can pick instead of typing, as a group of chips. `onSelect` receives the + * suggestion; whether that sends it or puts it in the composer is the caller's call: + * + * ```tsx + * send(prompt ?? label)} + * /> + * ``` + */ + +export interface PromptSuggestionsStrings { + /** Accessible name of the group, and its visible title with `showLabel`. */ + label: string; +} + +export const DEFAULT_PROMPT_SUGGESTIONS_STRINGS: PromptSuggestionsStrings = { + label: 'Suggestions', +}; + +/** One chip. Shaped so a chat service's `{ label, prompt }` suggestions pass straight in. */ +export interface PromptSuggestionItem { + label: string; + /** The text to send or prefill, when it differs from the label. */ + prompt?: string; + /** Keys the chip. Defaults to `label`. */ + id?: string; + /** Leading icon. */ + icon?: React.ReactNode; +} + +export type PromptSuggestionsDensity = 'comfortable' | 'compact'; + +const promptSuggestionsListVariants = cva('flex min-w-0 gap-2', { + variants: { + orientation: { + vertical: 'flex-col items-start', + horizontal: + // The inline padding keeps the end chips' focus rings inside the scroll clip. + '-mx-1 scroll-fade-x snap-x snap-mandatory scroll-px-1 scrollbar-none overflow-x-auto overscroll-x-contain px-1 py-1', + }, + }, + defaultVariants: { + orientation: 'vertical', + }, +}); + +const promptSuggestionVariants = cva( + 'inline-flex max-w-full cursor-pointer items-center border border-border-subtle bg-surface-raised text-left font-normal text-foreground transition-colors hover:border-border hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0', + { + variants: { + density: { + comfortable: 'gap-2 px-4 py-2 text-sm leading-5 [&_svg]:size-4 rounded-xl', + compact: 'gap-1.5 px-2 py-1 text-xs leading-4 [&_svg]:size-3.5 rounded-lg', + }, + orientation: { + vertical: 'whitespace-normal', + horizontal: 'flex-none snap-start whitespace-nowrap', + }, + }, + defaultVariants: { + density: 'comfortable', + orientation: 'vertical', + }, + } +); + +export type PromptSuggestionsOrientation = 'vertical' | 'horizontal'; + +export interface PromptSuggestionsProps + extends Omit, 'onSelect' | 'children'> { + suggestions: PromptSuggestionItem[]; + onSelect?: (suggestion: PromptSuggestionItem) => void; + orientation?: PromptSuggestionsOrientation; + density?: PromptSuggestionsDensity; + /** Shows `strings.label` as a title above the chips, as a chat does after a reply. */ + showLabel?: boolean; + /** Slides the group in from below when it mounts. Only fades under reduced motion. */ + animated?: boolean; + disabled?: boolean; + /** Overrides for any subset of the English strings. */ + strings?: Partial; +} + +/** Stacks vertically by default; `horizontal` keeps one row that scrolls when it overflows. */ +const PromptSuggestions = React.forwardRef( + ( + { + className, + suggestions, + onSelect, + orientation, + density, + showLabel = false, + animated = false, + disabled = false, + strings, + ...props + }, + ref + ) => { + const labelId = React.useId(); + const resolvedOrientation = orientation ?? 'vertical'; + const resolvedDensity = density ?? 'comfortable'; + const text = { ...DEFAULT_PROMPT_SUGGESTIONS_STRINGS, ...strings }; + + if (suggestions.length === 0) return null; + + return ( + // biome-ignore lint/a11y/useSemanticElements: role="group" names the chips without fieldset chrome +
+ {showLabel && ( +
+ {text.label} +
+ )} +
+ {suggestions.map((suggestion, index) => ( + + ))} +
+
+ ); + } +); +PromptSuggestions.displayName = 'PromptSuggestions'; + +export { PromptSuggestions, promptSuggestionsListVariants, promptSuggestionVariants }; diff --git a/packages/apollo-wind/src/getting-started/getting-started.stories.tsx b/packages/apollo-wind/src/getting-started/getting-started.stories.tsx index 230662adcd..221a0ba8fc 100644 --- a/packages/apollo-wind/src/getting-started/getting-started.stories.tsx +++ b/packages/apollo-wind/src/getting-started/getting-started.stories.tsx @@ -390,6 +390,7 @@ const components = [ description: 'Drag-and-drop file input with per-file errors', }, { category: 'Forms', name: 'ChatComposer', description: 'AI chat prompt input with attachments' }, + { category: 'Forms', name: 'PromptSuggestions', description: 'Suggested prompt chips' }, { category: 'Forms', name: 'Stepper', description: 'Multi-step form progress' }, { category: 'Date & Time', name: 'Calendar', description: 'Month calendar picker' }, { category: 'Date & Time', name: 'DatePicker', description: 'Date input with popover' }, @@ -418,6 +419,7 @@ const components = [ { category: 'Data Display', name: 'Alert', description: 'Inline status message' }, { category: 'Data Display', name: 'StatsCard', description: 'KPI metric card' }, { category: 'Data Display', name: 'EmptyState', description: 'Zero-state placeholder' }, + { category: 'Data Display', name: 'ChatHero', description: 'Chat first-run state' }, { category: 'Data Display', name: 'Skeleton', description: 'Loading placeholder' }, { category: 'Data Display', name: 'Progress', description: 'Progress bar' }, { category: 'Data Display', name: 'Spinner', description: 'Loading indicator' }, @@ -434,16 +436,6 @@ const components = [ { category: 'Custom Apollo', name: 'PanelMaestro', description: 'Maestro dashboard panel' }, { category: 'Custom Apollo', name: 'GridMaestro', description: 'Maestro-style data grid' }, { category: 'Custom Apollo', name: 'ChatStepsView', description: 'AI thinking steps display' }, - { - category: 'Custom Apollo', - name: 'ChatFirstExperience', - description: 'Empty chat onboarding state', - }, - { - category: 'Custom Apollo', - name: 'ChatPromptSuggestions', - description: 'Suggested prompt chips', - }, ] as const; function ComponentsTab() { diff --git a/packages/apollo-wind/src/index.ts b/packages/apollo-wind/src/index.ts index 6b39f4ea65..22eea2ad40 100644 --- a/packages/apollo-wind/src/index.ts +++ b/packages/apollo-wind/src/index.ts @@ -255,6 +255,22 @@ export { DEFAULT_CHAT_COMPOSER_STRINGS, useChatComposer, } from './components/ui/chat-composer'; +export type { + ChatHeroDensity, + ChatHeroLayout, + ChatHeroProps, + ChatHeroTitleProps, +} from './components/ui/chat-hero'; +export { + ChatHero, + ChatHeroDescription, + ChatHeroHeader, + ChatHeroLogo, + ChatHeroTitle, + chatHeroDescriptionVariants, + chatHeroTitleVariants, + chatHeroVariants, +} from './components/ui/chat-hero'; export type { CheckboxProps } from './components/ui/checkbox'; export { Checkbox } from './components/ui/checkbox'; export type { CitationData, CitationProps, CitationStrings } from './components/ui/citation'; @@ -744,6 +760,19 @@ export { VARIABLE_PATH_REGEX, WORD_JOINER, } from './components/ui/prompt-editor'; +export type { + PromptSuggestionItem, + PromptSuggestionsDensity, + PromptSuggestionsOrientation, + PromptSuggestionsProps, + PromptSuggestionsStrings, +} from './components/ui/prompt-suggestions'; +export { + DEFAULT_PROMPT_SUGGESTIONS_STRINGS, + PromptSuggestions, + promptSuggestionsListVariants, + promptSuggestionVariants, +} from './components/ui/prompt-suggestions'; export type { PromptValueControlProps } from './components/ui/prompt-value-control'; export { PromptValueControl } from './components/ui/prompt-value-control'; export type { diff --git a/packages/apollo-wind/src/styles/tailwind.utilities.css b/packages/apollo-wind/src/styles/tailwind.utilities.css index 9de671fb35..5ba424837e 100644 --- a/packages/apollo-wind/src/styles/tailwind.utilities.css +++ b/packages/apollo-wind/src/styles/tailwind.utilities.css @@ -69,6 +69,8 @@ * element in while it travels 20px to its resting position. Intended for * hierarchical panels where forward navigation enters from the right and * going back enters from the left (canvas Toolbox, nested pickers). + * `animate-slide-in-from-bottom` is the slower rise of chat content that + * appears below a reply (PromptSuggestions). * * Two keyframes rather than one parameterized by a custom property: the * offset is the only difference, and inlining it keeps `var()` out of the @@ -85,6 +87,11 @@ to { opacity: 1; transform: translateX(0); } } +@keyframes apollo-slide-in-from-bottom { + from { opacity: 0; transform: translateY(1.25rem); } + to { opacity: 1; transform: translateY(0); } +} + @utility animate-slide-in-from-right { animation: apollo-slide-in-from-right 150ms ease-out; } @@ -93,11 +100,19 @@ animation: apollo-slide-in-from-left 150ms ease-out; } +@utility animate-slide-in-from-bottom { + animation: apollo-slide-in-from-bottom 300ms ease-out; +} + @media (prefers-reduced-motion: reduce) { .animate-slide-in-from-right, .animate-slide-in-from-left { animation: apollo-fade-in 150ms ease-out; } + + .animate-slide-in-from-bottom { + animation: apollo-fade-in 300ms ease-out; + } } /* ============================================================================ diff --git a/packages/apollo-wind/src/templates/Delegate/delegate.stories.tsx b/packages/apollo-wind/src/templates/Delegate/delegate.stories.tsx index 0f15d9e076..6db2f4260c 100644 --- a/packages/apollo-wind/src/templates/Delegate/delegate.stories.tsx +++ b/packages/apollo-wind/src/templates/Delegate/delegate.stories.tsx @@ -17,11 +17,17 @@ import { Workflow, } from 'lucide-react'; import * as React from 'react'; -import { ChatFirstExperience } from '@/components/custom/chat-first-experience'; import { StepsView } from '@/components/custom/chat-steps-view'; import { DemoComposer } from '@/components/custom/demo-composer'; +import { + ChatHero, + ChatHeroDescription, + ChatHeroHeader, + ChatHeroTitle, +} from '@/components/ui/chat-hero'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; +import { type PromptSuggestionItem, PromptSuggestions } from '@/components/ui/prompt-suggestions'; import type { NavItem } from './template-delegate'; import { DelegateTemplate } from './template-delegate'; @@ -80,6 +86,16 @@ export const Blank: Story = { ), }; +const homeSuggestions: PromptSuggestionItem[] = [ + { id: '1', label: 'Make a list of affordable apartments in NYC' }, + { id: '2', label: 'Find the highest CD rates' }, + { id: '3', label: 'Lorem ipsum dolor sit amet' }, +]; + +// The Delegate spec's taller chips with secondary text. +const delegateChipClasses = + '**:data-[slot=prompt-suggestion]:h-10 **:data-[slot=prompt-suggestion]:py-2.5 **:data-[slot=prompt-suggestion]:text-foreground-secondary **:data-[slot=prompt-suggestion]:hover:border-border-hover **:data-[slot=prompt-suggestion]:hover:text-foreground'; + export const Home: Story = { render: (_, { globals }) => ( - + + + +

Hello David

+
+ + What should we work on today? + +
+ + +
), };