import {
createContext,
ReactNode,
Ref,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { Meta, StoryObj } from '@storybook/react-vite';
import {
FlexContainer,
SegmentedButton,
StatusIndicator,
Tag,
VerticalEmptyState,
} from '@pantheon-systems/pds-toolkit-react';
import { Button } from '../_base/Button/Button';
import { ChainOfThought } from '../ChainOfThought/ChainOfThought';
import { ChatAttachment } from '../ChatAttachment/ChatAttachment';
import { ChatInput } from '../ChatInput/ChatInput';
import { ChatMessage } from '../ChatMessage/ChatMessage';
import { ChatMessageList } from '../ChatMessage/ChatMessageList';
import { ChatPanel } from '../ChatPanel/ChatPanel';
import {
CodeDiffSample,
CodeDiffSampleFile,
} from '../_story-helpers/CodeDiffSample';
import { FeedbackBar, FeedbackValue } from '../FeedbackBar/FeedbackBar';
import { Icon } from '../Icon/Icon';
import { IconButton } from '../IconButton/IconButton';
import { IdentityBlock } from '../IdentityBlock/IdentityBlock';
import { MenuButton } from '../_base/MenuButton/MenuButton';
import { Select } from '../Select/Select';
import {
SuggestionCard,
SuggestionCardState,
} from '../SuggestionCard/SuggestionCard';
import { TimelineItem } from '../Timeline/TimelineItem';
import './chat-with-agent-story.css';
const meta: Meta = {
tags: ['new-pattern', 'ready-for-dev'],
title: 'Recipes/Chat with an agent',
parameters: {
docs: {
// The frame is viewport height, so each docs story gets its own iframe.
story: { iframeHeight: 640, inline: false },
description: {
component:
'**Recipe:** a composition of existing proposals, not a component. A chat with an agent in a Chat Panel, from the empty state to a conversation: the chat input moves from the empty state to the footer, the agent works through a Chain of Thought, replies with a Suggestion Card (its changes shown as a Code Diff), and each reply can be rated with the Feedback Bar. Files can be dropped anywhere on the panel.',
},
},
layout: 'fullscreen',
},
};
export default meta;
type Story = StoryObj<typeof meta>;
const EmptyState = ({ fillHeight }: { fillHeight?: boolean }) => (
<VerticalEmptyState
className='chat-with-agent-story__empty'
fillHeight={fillHeight}
heading='Work it out in conversation'
headingLevel='h2'
image={<Icon iconName='messages' size='4xl' />}
imageHeight='auto'
message={
<p className='chat-with-agent-story__empty-message pds-copy-xs'>
Ask for changes, compare directions, and accept proposals inline, all
within your design system.
</p>
}
/>
);
// Helper text under the chat input, hidden for now. Set to true to show it.
const SHOW_HELPER_TEXT = false;
const HELPER_TEXT = 'Enter to send · Shift+Enter for new line';
const MODELS = [
{ label: 'Zappy', value: 'zappy' },
{ label: 'Claude Haiku 5.5', value: 'haiku' },
{ label: 'Claude Sonnet 5.5', value: 'sonnet' },
{ label: 'Claude Opus 5.5', value: 'opus' },
];
// A working Chat Input with its toolbar: type and press Enter (or Send) to
// call `onSend`; Shift+Enter adds a line.
const SampleComposer = ({
aboveField,
autoFocus,
files = [],
id,
inputRef,
isWorking = false,
message,
onFilesDrop,
onRemoveFile,
onSend,
variant,
}: {
aboveField?: ReactNode;
autoFocus?: boolean;
files?: File[];
id: string;
inputRef?: Ref<HTMLTextAreaElement>;
isWorking?: boolean;
message?: string;
onFilesDrop?: (files: File[]) => void;
onRemoveFile?: (file: File) => void;
onSend?: (text: string) => void;
variant?: 'compact' | 'default';
}) => {
const [model, setModel] = useState(MODELS[0].value);
const [value, setValue] = useState('');
const previews = useMemo(
() =>
files.map((file) =>
file.type.startsWith('image/') ? URL.createObjectURL(file) : undefined,
),
[files],
);
const send = () => {
const text = value.trim();
if (!text && !files.length) return;
if (isWorking) return;
onSend?.(text);
setValue('');
};
return (
<ChatInput
aboveField={aboveField}
actionsEnd={
<>
<Select
id={`${id}-model`}
label='Model'
onOptionSelect={(option) => setModel(option.value)}
options={MODELS}
value={model}
variant='bare'
/>
<IconButton ariaLabel='Voice input' iconName='microphoneLines' />
<IconButton
ariaLabel='Send'
disabled={!isWorking && !value.trim() && !files.length}
iconName='paperPlane'
isWorking={isWorking}
onClick={send}
variant='primary'
/>
</>
}
actionsStart={
<>
<MenuButton
iconName='plus'
iconOnly
id={`${id}-add`}
label='Add'
menuItems={[{ label: 'Add photos & files' }]}
size='s'
/>
</>
}
fieldHeader={
files.length ? (
<>
{files.map((file, index) => (
<ChatAttachment
key={`${file.name}-${file.lastModified}`}
fileName={file.name}
fileType={file.type.startsWith('image/') ? 'image' : 'file'}
onRemove={() => onRemoveFile?.(file)}
previewUrl={previews[index]}
removeLabel={`Remove ${file.name}`}
/>
))}
</>
) : undefined
}
dropLabel='Drop files to attach'
id={id}
label='Message'
message={message}
ref={inputRef}
onFilesDrop={onFilesDrop}
onChange={(event) => setValue(event.target.value)}
placeholder='Ask Zappy…'
textareaProps={{
autoFocus,
onKeyDown: (event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
send();
}
},
}}
value={value}
variant={variant}
/>
);
};
const VIEWS = [
{ label: 'Compose', value: 'compose' },
{ label: 'Chat', value: 'chat' },
{ label: 'Review', value: 'review' },
];
const ChatHeader = () => (
<>
<div className='chat-with-agent-story__summary'>
<StatusIndicator label='3 awaiting review' type='warning' />
<Icon iconName='arrowRight' size='s' />
</div>
<div className='chat-with-agent-story__rows'>
<TimelineItem iconName='globe' label='Reading: entire site' size='xs' />
<TimelineItem
iconName='pen'
size='xs'
label={
<span className='chat-with-agent-story__editing'>
<span>Editing:</span>
<Tag
isRemovable
removeLabel='Remove'
size='s'
tagColor='slate'
tagLabel='Home'
/>
<Button
displayType='icon-start'
iconName='plus'
label='Add page'
size='xs'
variant='subtle'
/>
</span>
}
/>
</div>
<div className='chat-with-agent-story__views'>
<SegmentedButton
defaultValue='chat'
id='chat-panel-views'
label='Chat views'
options={VIEWS}
size='m'
/>
</div>
</>
);
const COMPOSER_JSON_DIFF = `diff --git a/composer.json b/composer.json
--- a/composer.json
+++ b/composer.json
@@ -24,7 +24,7 @@
"drupal/pantheon_advanced_page_cache": "^2.3",
- "drupal/pathauto": "^1.8",
+ "drupal/pathauto": "^1.13",
"drupal/redis": "^1.8",`;
const PANTHEON_YML_DIFF = `diff --git a/pantheon.yml b/pantheon.yml
--- a/pantheon.yml
+++ b/pantheon.yml
@@ -1,4 +1,4 @@
api_version: 1
web_docroot: true
-php_version: 8.2
+php_version: 8.3`;
const DIFF_FILES: CodeDiffSampleFile[] = [
{
additions: 1,
deletions: 1,
diff: COMPOSER_JSON_DIFF,
expandButtonLabel: 'Show changes to composer.json',
id: 'composer-json',
name: 'composer.json',
},
{
additions: 1,
deletions: 1,
diff: PANTHEON_YML_DIFF,
expandButtonLabel: 'Show changes to pantheon.yml',
id: 'pantheon-yml',
name: 'pantheon.yml',
},
];
const THANKS_MS = 4500;
// Thumbs for the agent's output. The screen owns the choice and the timing:
// after a choice the thumbs are replaced by a thank-you for a few seconds, then
// come back with the chosen one pressed. Pressing it again clears the choice.
const SampleFeedback = ({ kind }: { kind: 'response' | 'suggestion' }) => {
const [value, setValue] = useState<FeedbackValue | null>(null);
const [showThanks, setShowThanks] = useState(false);
const timer = useRef<ReturnType<typeof setTimeout>>();
useEffect(() => () => clearTimeout(timer.current), []);
const handleChange = (next: FeedbackValue | null) => {
setValue(next);
clearTimeout(timer.current);
setShowThanks(next !== null);
if (next !== null) {
timer.current = setTimeout(() => setShowThanks(false), THANKS_MS);
}
};
return (
<FeedbackBar
downLabel={`Bad ${kind}`}
groupLabel={`Rate this ${kind}`}
onValueChange={handleChange}
statusLabel={showThanks ? 'Thanks for the feedback' : undefined}
upLabel={`Good ${kind}`}
value={value}
/>
);
};
// The agent's proposed changes. Accept marks the card accepted; the screen owns
// that state, as with any Suggestion Card.
interface RefineTarget {
names: string[];
title: string;
}
// Refine hands the card's summary up to the chat, which shows it above the chat
// input as context for the next message.
const RefineContext = createContext<(target: RefineTarget) => void>(() => {});
const SampleSuggestion = ({
description = 'Update pathauto and move the site to PHP 8.3.',
files = DIFF_FILES,
title = '2 changes',
}: {
description?: string;
files?: CodeDiffSampleFile[];
title?: string;
}) => {
const [state, setState] = useState<SuggestionCardState>('pending');
const [wasUndone, setWasUndone] = useState(false);
const onRefine = useContext(RefineContext);
const decide = (next: SuggestionCardState) => {
setWasUndone(false);
setState(next);
};
return (
<SuggestionCard
actionsEnd={
state === 'accepted' ? (
<SampleFeedback kind='suggestion' />
) : state === 'dismissed' ? (
// The Dismiss button is gone, so focus moves to Undo.
<Button
autoFocus
label='Undo'
onClick={() => {
setWasUndone(true);
setState('pending');
}}
size='xs'
variant='subtle'
/>
) : undefined
}
actionsStart={
<>
<Button
displayType='icon-start'
iconName='check'
label='Accept'
onClick={() => decide('accepted')}
variant='primary'
/>
<Button
label='Refine'
onClick={() => onRefine({ names: files.map((f) => f.name), title })}
variant='secondary'
/>
<Button
autoFocus={wasUndone}
label='Dismiss'
onClick={() => decide('dismissed')}
variant='subtle'
/>
</>
}
description={description}
state={state}
statusLabel={state === 'dismissed' ? 'Dismissed' : 'Changes applied'}
title={title}
>
<CodeDiffSample files={files} />
</SuggestionCard>
);
};
interface Step {
label: string;
secondaryText?: string;
}
interface Script {
reply: ReactNode;
// Thumbs under the reply. The reply with the Suggestion Card has its own, in
// the card.
showFeedback?: boolean;
steps: Step[];
}
// What the agent "does" for each message, in turn: the steps shown in the
// chain of thought, then the reply.
const SCRIPTS: Script[] = [
{
steps: [
{ label: 'Read composer.json' },
{ label: 'Checked for module updates', secondaryText: '12 modules' },
{ label: 'Checked PHP support', secondaryText: 'pantheon.yml' },
],
reply: (
<FlexContainer flexDirection='column' gap='l'>
<p>
Two things are out of date: pathauto is several releases behind, and
PHP 8.2 is nearing end of life. Here are the changes I suggest:
</p>
<SampleSuggestion />
</FlexContainer>
),
},
{
showFeedback: true,
steps: [
{ label: 'Confirmed write access' },
{ label: 'Created a branch', secondaryText: 'update-dependencies' },
{ label: 'Committed the changes' },
{ label: 'Ran the tests' },
],
reply: (
<p>
Done. I committed both changes to a new branch, update-dependencies, and
the tests pass.
</p>
),
},
{
showFeedback: true,
steps: [
{ label: 'Created a multidev', secondaryText: 'update-deps' },
{ label: 'Deployed the branch' },
],
reply: <p>The changes are live on the update-deps multidev.</p>,
},
{
steps: [
{ label: 'Read your note' },
{ label: 'Narrowed the suggestion', secondaryText: 'composer.json' },
],
reply: (
<FlexContainer flexDirection='column' gap='l'>
<p>I narrowed the suggestion to composer.json:</p>
<SampleSuggestion
description='Update pathauto only.'
files={[DIFF_FILES[0]]}
title='1 change'
/>
</FlexContainer>
),
},
];
const STEP_MS = 900;
// The scripts that play in order; the last script is the reply to a Refine.
const FLOW_LENGTH = 3;
const REFINE_SCRIPT = 3;
interface Turn {
// The agent's chain of thought: which script, how many steps are done, and
// how long it took once finished.
run?: { isDone: boolean; progress: number; script: number; seconds: number };
// A user message.
text?: string;
id: number;
sender: 'agent' | 'user';
}
// A small chat so the story can be used: the panel starts on the empty state,
// and sending a message starts the conversation: the user's message, then the
// agent's chain of thought working through its steps, then the reply. The
// Chat Panel follows the end of the conversation (`hasScrollToLatest`).
const SampleChat = () => {
const [turns, setTurns] = useState<Turn[]>([]);
const [isWorking, setIsWorking] = useState(false);
const [files, setFiles] = useState<File[]>([]);
const [refine, setRefine] = useState<RefineTarget | null>(null);
const composerRef = useRef<HTMLTextAreaElement>(null);
const runCount = useRef(0);
const timer = useRef<ReturnType<typeof setInterval>>();
useEffect(() => () => clearInterval(timer.current), []);
const addFiles = (dropped: File[]) =>
setFiles((list) => [...list, ...dropped]);
const removeFile = (file: File) =>
setFiles((list) => list.filter((item) => item !== file));
const handleRefine = (target: RefineTarget) => {
setRefine(target);
requestAnimationFrame(() => composerRef.current?.focus());
};
const handleSend = (text: string) => {
const attached = files.length
? `\n(Attached: ${files.map((file) => file.name).join(', ')})`
: '';
const refined = refine ? `\n(Refining: ${refine.title})` : '';
const script = refine ? REFINE_SCRIPT : runCount.current % FLOW_LENGTH;
const total = SCRIPTS[script].steps.length;
const startedAt = Date.now();
if (!refine) runCount.current += 1;
setRefine(null);
setFiles([]);
setIsWorking(true);
let agentId = 0;
setTurns((list) => {
agentId = list.length + 1;
return [
...list,
{
id: list.length,
sender: 'user',
text: `${text}${attached}${refined}`.trim(),
},
{
id: agentId,
run: { isDone: false, progress: 0, script, seconds: 0 },
sender: 'agent',
},
];
});
let ticks = 0;
timer.current = setInterval(() => {
ticks += 1;
setTurns((list) =>
list.map((turn) => {
if (turn.id !== agentId || !turn.run) return turn;
const progress = turn.run.progress + 1;
const isDone = progress >= total;
return {
...turn,
run: {
...turn.run,
isDone,
progress,
seconds: Math.max(1, Math.round((Date.now() - startedAt) / 1000)),
},
};
}),
);
if (ticks >= total) {
clearInterval(timer.current);
setIsWorking(false);
}
}, STEP_MS);
};
const hasTurns = turns.length > 0;
return (
<RefineContext.Provider value={handleRefine}>
<div className='chat-with-agent-story__frame'>
<ChatPanel
className='chat-with-agent-story__panel'
dropLabel='Drop files anywhere to upload'
hasContentFade
hasScrollToLatest
scrollToLatestLabel='Scroll to latest'
header={<ChatHeader />}
headerInset='none'
onFilesDrop={addFiles}
title={
<IdentityBlock
avatarProps={{ label: 'Agent' }}
primaryText='Zappy'
secondaryText='Pantheon AI'
/>
}
footer={
hasTurns ? (
<>
<SampleComposer
aboveField={
refine ? (
<div className='chat-with-agent-story__context pds-copy-xs'>
<Icon iconName='pen' size='s' />
<span>
Refining: {refine.title} ({refine.names.join(', ')})
</span>
<IconButton
ariaLabel='Stop refining'
className='chat-with-agent-story__context-dismiss'
iconName='xmark'
onClick={() => setRefine(null)}
size='xs'
/>
</div>
) : undefined
}
autoFocus
files={files}
id='chat-panel-sample-footer-input'
inputRef={composerRef}
isWorking={isWorking}
message={SHOW_HELPER_TEXT ? HELPER_TEXT : undefined}
onFilesDrop={addFiles}
onRemoveFile={removeFile}
onSend={handleSend}
variant='compact'
/>
<span className='visually-hidden' role='status'>
{refine ? `Refining ${refine.title}` : ''}
</span>
</>
) : undefined
}
>
<>
{hasTurns ? (
<>
<ChatMessageList>
{turns.map((turn) => {
if (!turn.run) {
return (
<ChatMessage key={turn.id} label='You' sender='user'>
{turn.text}
</ChatMessage>
);
}
const { reply, showFeedback, steps } =
SCRIPTS[turn.run.script];
const { isDone, progress, seconds } = turn.run;
return (
<ChatMessage key={turn.id} label='Zappy'>
<ChainOfThought
expandWhenActive
isActive={!isDone}
label={isDone ? 'Thought for' : 'Working...'}
showTimer={isDone}
time={`${seconds}s`}
>
{steps
.slice(0, Math.min(progress + 1, steps.length))
.map((step, index) => (
<TimelineItem
key={step.label}
label={step.label}
secondaryText={step.secondaryText}
status={index < progress ? 'done' : 'active'}
/>
))}
</ChainOfThought>
{isDone && (
<div className='chat-with-agent-story__reply'>
{reply}
{showFeedback && (
<div className='chat-with-agent-story__feedback'>
<SampleFeedback kind='response' />
</div>
)}
</div>
)}
</ChatMessage>
);
})}
</ChatMessageList>
</>
) : (
<div className='chat-with-agent-story__sample'>
<EmptyState />
<SampleComposer
files={files}
id='chat-panel-sample-empty-input'
isWorking={isWorking}
message={SHOW_HELPER_TEXT ? HELPER_TEXT : undefined}
onFilesDrop={addFiles}
onRemoveFile={removeFile}
onSend={handleSend}
/>
</div>
)}
</>
</ChatPanel>
</div>
</RefineContext.Provider>
);
};
export const Interactive: Story = {
parameters: {
docs: {
description: {
story:
'Starts on the empty state with no footer: the default-size chat input is part of the centered empty-state group. Type a message and press Enter to start the conversation: your message, then a Chain of Thought opens and works through its steps (Thinking State and Timeline Items), collapses to "Thought for N seconds", and the reply appears, ending with a Suggestion Card (its changes shown as a Code Diff) you can accept. The input then moves to the footer as the compact bar, and each reply gets thumbs (the Feedback Bar). Drag files anywhere over the panel to attach them: the content area shows a drop overlay, and the files appear as chips in the chat input. The content scrolls while the title row, header and footer stay pinned; the panel follows the newest message only while you are at the end, and a Scroll to latest button jumps back. The Compose / Chat / Review switch is the PDS `SegmentedButton`; the Chat Panel does not know about it.',
},
},
},
render: () => <SampleChat />,
};
Chat with an agent
A recipe, not a component: one interactive story that composes the existing proposals into a chat with an agent, from the empty state to a conversation.
Extends a PDS componentOpen in Storybook