updated drawer

This commit is contained in:
Pavel committed 2026-09-27 16:53:18 +04:00
1 parent ed804a14e9
commit 20bcd92942
7 files changed
+636 -255

No files matched your search

+18 -6
View File
@@ -291,12 +291,12 @@ question: is it a **thing** you open, move, share or delete as a whole, or a
**place** where you read or edit content? The fill marks objects; the border
marks structure.
| Role | Variant | Examples |
| -------------------- | --------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
| Thing (a tile) | `filled`, on a page or in a modal | agents, folders, sources, tools, custom models, teams, chunks, the shared agent card |
| Place (a panel) | `subtle` on the page, `outline` on a card (modal, drawer) | the agent form's sections, chart panels, stat tiles, log tables, schedule rows, the agent preview |
| Choice (picker tile) | `outline interactive`, or `OptionCard` | the Add tool tiles; selection is drawn by the border, so the border stays |
| Well | `filled padding="sm"` | code, a token to copy, run output, inside a panel (see Code blocks below) |
| Role | Variant | Examples |
| -------------------- | --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
| Thing (a tile) | `filled`, on a page or in a modal | agents, folders, sources, tools, custom models, teams, chunks, the shared agent card |
| Place (a panel) | `subtle` on the page or a right drawer, `outline` on a card (modal, bottom sheet) | the agent form's sections, chart panels, stat tiles, log tables, schedule rows, the agent preview |
| Choice (picker tile) | `outline interactive`, or `OptionCard` | the Add tool tiles; selection is drawn by the border, so the border stays |
| Well | `filled padding="sm"` | code, a token to copy, run output, inside a panel (see Code blocks below) |
- Layers go page → panel (border) → well (fill), or page → tile (fill).
`subtle` and `outline` are one role on two surfaces; in light mode they
@@ -898,6 +898,17 @@ scroller) and the same blurred overlay. Modal, DialogContent and every Sheet sha
`overlayScrim` in `lib/utils.ts` (`bg-black/25 backdrop-blur-xs
dark:bg-black/50`).
A right drawer (`SheetContent side="right"`) is on `bg-background`, like the
page: its content paints no surface of its own, and panels in it are
`subtle` (see Card surfaces). An agent is previewed in one shared drawer,
`agents/components/AgentPreviewSheet`, for workflow and classic agents alike:
`w-full sm:max-w-[600px] md:max-w-[700px] lg:max-w-[800px]`, a `SheetTitle`
and a one-line `SheetDescription` (the agent's name) in a header padded
`pr-12` to clear the close X, an `info` Running badge while it answers, a
`Separator`, then the preview. The workflow preview's Execution details and
Artifacts rows are the answer's step-row recipe (see Alert), each step a
`subtle sm` panel with its output in a `filled sm` well.
Every phone bottom sheet has one shape: `bg-card`, 16px top corners, no top
border, `max-h-sheet` (the visible viewport less the top safe-area inset and a
3rem strip, so the scrim above it can always be tapped to close), and bottom
@@ -1184,6 +1195,7 @@ list stays reviewable.
| `agents/workflow/WorkflowBuilder.tsx` | `shadcn/no-restyle` | The "Learn more" links in the Set state and Condition nodes' 12px hints keep the sentence's size and weight (`text-xs font-normal` on `link inline`). Two disables. |
| `components/MessageInput.tsx` | `shadcn/no-restyle` | The queued-send Cancel is a `link inline` inside the composer's 12px status line, so it takes the line's size (`text-xs`). One disable, beside the send button's. |
| `settings/PersonalAccessTokens.tsx` | `shadcn/no-restyle` | Token scope chips are identifiers, so the `neutral` Badge is set in mono (`font-mono`). One disable. |
| `agents/workflow/WorkflowPreview.tsx` | `shadcn/no-restyle` | A step's state changes (keys and values the app serialised) are `neutral` Badges set in mono (`font-mono`), like token scopes. One disable. |
| `settings/traces/TraceChips.tsx` | `shadcn/no-restyle` | Trace stat chips (durations, counts) use tabular figures so they don't jitter between rows (`tabular-nums` on Badge). One disable. |
| `modals/MCPServerModal.tsx` | `shadcn/no-restyle` | The authorization link inside the test-result Alert keeps the Alert's status colour (`text-current` on `link inline`). One disable (phase 11, 63g). |
| `components/MermaidRenderer.tsx` | `shadcn/no-restyle` | The zoom readout between − and + is a `link inline` Button on the `bg-black/70` overlay; it keeps the overlay's white 12px regular text (`text-xs font-normal text-current`). One disable, beside the two zoom-button ones above (phase 11, 63g). |
@@ -0,0 +1,101 @@
import { act } from 'react';
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
import { createRoot, type Root } from 'react-dom/client';
import AgentPreviewSheet from './AgentPreviewSheet';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
describe('AgentPreviewSheet', () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
});
const render = async () => {
await act(async () => {
root.render(
<AgentPreviewSheet
open
onOpenChange={() => undefined}
title="Preview"
description="Carrier onboarding FAQ"
actions={<span data-testid="action">Running</span>}
>
<div data-testid="body">Chat</div>
</AgentPreviewSheet>,
);
});
};
it('is a right Sheet at the workflow preview widths', async () => {
await render();
const content = document.querySelector<HTMLElement>(
'[data-slot="sheet-content"]',
)!;
expect(content.dataset.side).toBe('right');
expect(content.className).toContain('lg:max-w-[800px]');
expect(content.className).toContain('p-0');
});
it('titles the drawer with SheetTitle and SheetDescription', async () => {
await render();
expect(
document.querySelector('[data-slot="sheet-title"]')?.textContent,
).toBe('Preview');
expect(
document.querySelector('[data-slot="sheet-description"]')?.textContent,
).toBe('Carrier onboarding FAQ');
});
it('keeps header actions clear of the close button', async () => {
await render();
const action = document.querySelector('[data-testid="action"]')!;
const header = action.parentElement!;
// The X sits at top-2 right-2 (32px wide); pr-12 keeps 48px free.
expect(header.className).toContain('pr-12');
expect(
header.contains(document.querySelector('[data-slot="sheet-title"]')),
).toBe(true);
});
// DESIGN maps "running" to info, as schedule runs do (was primary text).
it('shows a running run as an info badge in the header', async () => {
await act(async () => {
root.render(
<AgentPreviewSheet
open
onOpenChange={() => undefined}
title="Preview"
running
>
<div />
</AgentPreviewSheet>,
);
});
const badge = document.querySelector<HTMLElement>('[data-slot="badge"]')!;
expect(badge.dataset.variant).toBe('info');
expect(badge.textContent).toBe('agents.schedules.status.running');
});
it('renders the body under a separator on the sheet surface', async () => {
await render();
const body = document.querySelector('[data-testid="body"]')!;
expect(body.textContent).toBe('Chat');
expect(document.querySelector('[data-slot="separator"]')).not.toBeNull();
const content = document.querySelector('[data-slot="sheet-content"]')!;
expect(content.querySelector('.bg-card')).toBeNull();
});
});
@@ -0,0 +1,81 @@
import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import {
Sheet,
SheetContent,
SheetDescription,
SheetTitle,
} from '@/components/ui/sheet';
type AgentPreviewSheetProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
/** The drawer's title, e.g. "Preview". */
title: string;
/** One muted line under the title: the agent's name. */
description?: string;
/** Shows an info "Running" badge while the preview is answering. */
running?: boolean;
/** Actions at the header's right end (New chat). */
actions?: ReactNode;
/** The preview itself; fills the rest of the drawer. */
children: ReactNode;
};
/**
* The drawer an agent is previewed in, for workflow and classic agents alike:
* a right Sheet with a title row, a rule, then the preview filling the rest.
*
* Args:
* open: Whether the drawer is open.
* onOpenChange: Called with the next open state (the X, Escape, the scrim).
* title: The drawer's title.
* description: A muted line under the title.
* running: Whether the preview is answering; shows a Running badge.
* actions: Nodes at the right end of the header, clear of the close button.
* children: The preview body.
*/
export default function AgentPreviewSheet({
open,
onOpenChange,
title,
description,
running = false,
actions,
children,
}: AgentPreviewSheetProps) {
const { t } = useTranslation();
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="right"
className="w-full max-w-none p-0 sm:max-w-[600px] md:max-w-[700px] lg:max-w-[800px]"
>
<div className="flex min-h-0 flex-1 flex-col">
{/* pr-12 keeps the header clear of the close X at top-2 right-2. */}
<div className="flex items-center gap-3 px-6 pt-6 pr-12 pb-4">
<div className="flex min-w-0 flex-1 flex-col gap-1">
<SheetTitle>{title}</SheetTitle>
{description && (
<SheetDescription className="truncate" title={description}>
{description}
</SheetDescription>
)}
</div>
{running && (
<Badge variant="info">
{t('agents.schedules.status.running')}
</Badge>
)}
{actions}
</div>
<Separator />
<div className="flex min-h-0 flex-1 flex-col">{children}</div>
</div>
</SheetContent>
</Sheet>
);
}
@@ -72,7 +72,6 @@ import {
} from '@/components/ui/select';
import { SectionHeader } from '@/components/ui/section-header';
import { SettingRow } from '@/components/ui/setting-row';
import { Sheet, SheetContent } from '@/components/ui/sheet';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import { cn } from '@/lib/utils';
@@ -90,6 +89,7 @@ import {
import { getToolDisplayName } from '../../utils/toolUtils';
import { agentEditPath, agentsListPath } from '../paths';
import AgentPageHeader from '../AgentPageHeader';
import AgentPreviewSheet from '../components/AgentPreviewSheet';
import { Agent } from '../types';
import { ConditionCase, WorkflowNode } from '../types/workflow';
import {
@@ -122,6 +122,7 @@ import {
StartNode,
} from './nodes';
import WorkflowPreview from './WorkflowPreview';
import { selectWorkflowPreviewStatus } from './workflowPreviewSlice';
import type { Model } from '../../models/types';
@@ -448,6 +449,7 @@ function WorkflowBuilderInner() {
const navigate = useNavigate();
const token = useSelector(selectToken);
const sourceDocs = useSelector(selectSourceDocs);
const previewStatus = useSelector(selectWorkflowPreviewStatus);
const { agentId } = useParams<{ agentId?: string }>();
const [searchParams] = useSearchParams();
const folderId = searchParams.get('folder_id');
@@ -3299,42 +3301,46 @@ function WorkflowBuilderInner() {
</div>
</div>
<Sheet open={showPreview} onOpenChange={setShowPreview}>
<SheetContent
side="right"
className="w-full max-w-none p-0 sm:max-w-[600px] md:max-w-[700px] lg:max-w-[800px]"
>
<WorkflowPreview
workflowId={workflowId}
workflowData={{
name: workflowName,
description: workflowDescription,
nodes: nodes
.filter((n) => n.type !== 'note')
.map((n) => ({
id: n.id,
type: n.type as
'start' | 'end' | 'agent' | 'state' | 'code',
title: n.data.title || n.data.label || n.type,
position: n.position,
data:
n.type === 'code'
? serializeCodeConfig(n.data.config)
: n.type === 'agent'
? n.data.config
: n.data,
})),
edges: edges.map((e) => ({
id: e.id,
source: e.source,
target: e.target,
sourceHandle: e.sourceHandle || undefined,
targetHandle: e.targetHandle || undefined,
<AgentPreviewSheet
open={showPreview}
onOpenChange={setShowPreview}
title={t('agents.form.sections.preview')}
description={
workflowDescription
? `${workflowName} · ${workflowDescription}`
: workflowName
}
running={previewStatus === 'loading'}
>
<WorkflowPreview
workflowId={workflowId}
workflowData={{
name: workflowName,
description: workflowDescription,
nodes: nodes
.filter((n) => n.type !== 'note')
.map((n) => ({
id: n.id,
type: n.type as 'start' | 'end' | 'agent' | 'state' | 'code',
title: n.data.title || n.data.label || n.type,
position: n.position,
data:
n.type === 'code'
? serializeCodeConfig(n.data.config)
: n.type === 'agent'
? n.data.config
: n.data,
})),
}}
/>
</SheetContent>
</Sheet>
edges: edges.map((e) => ({
id: e.id,
source: e.source,
target: e.target,
sourceHandle: e.sourceHandle || undefined,
targetHandle: e.targetHandle || undefined,
})),
}}
/>
</AgentPreviewSheet>
<ConfirmationModal
message={
workflowName
@@ -0,0 +1,134 @@
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
vi.mock('react-redux', () => ({
useSelector: () => null,
useDispatch: () => vi.fn(),
}));
vi.mock('./WorkflowRunArtifacts', () => ({ default: () => null }));
import { ExecutionDetails, RunArtifactsSection } from './WorkflowPreview';
import type { WorkflowNode } from '../types/workflow';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
const nodes = [
{
id: 'n1',
type: 'agent',
title: 'Summarise',
position: { x: 0, y: 0 },
data: {},
},
] as unknown as WorkflowNode[];
const steps = [
{
nodeId: 'n1',
nodeType: 'agent',
nodeTitle: 'Summarise',
status: 'completed',
output: 'Three carriers expire this month.',
stateDelta: { region: 'EU', query: 'ignored' },
},
] as never;
describe('WorkflowPreview execution sections', () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
});
const renderDetails = async (isOpen = true) => {
await act(async () => {
root.render(
<ExecutionDetails
steps={steps}
nodes={nodes}
isOpen={isOpen}
onToggle={() => undefined}
/>,
);
});
};
// The answer's step-row recipe (AnswerFlow): ghost sm at ml-3.5, a muted
// icon, muted text, and a chevron that turns while open.
it('draws the Execution details toggle as an answer step row', async () => {
await renderDetails();
const toggle = container.querySelector<HTMLButtonElement>('button')!;
expect(toggle.dataset.variant).toBe('ghost');
expect(toggle.dataset.size).toBe('sm');
expect(toggle.getAttribute('aria-expanded')).toBe('true');
expect(toggle.className).toContain('ml-3.5');
const icons = toggle.querySelectorAll('svg');
expect(icons[0].getAttribute('class')).toContain('text-muted-foreground');
expect(icons[icons.length - 1].getAttribute('class')).toContain(
'rotate-180',
);
expect(toggle.querySelector('p')).toBeNull();
});
it('draws each step as a subtle panel with its output in a filled well', async () => {
await renderDetails();
const step = container.querySelector<HTMLElement>(
'[data-slot="card"][data-variant="subtle"]',
)!;
expect(step.dataset.padding).toBe('sm');
const well = step.querySelector<HTMLElement>(
'[data-slot="card"][data-variant="filled"]',
)!;
expect(well.dataset.padding).toBe('sm');
expect(well.textContent).toContain('Three carriers expire this month.');
// No hand-rolled muted boxes: a muted box in a muted box disappears.
expect(
container.querySelector(
'div.bg-muted:not([data-slot="card"]), span.bg-muted',
),
).toBeNull();
});
it('shows state changes as mono neutral badges, without query', async () => {
await renderDetails();
const badges = Array.from(
container.querySelectorAll<HTMLElement>('[data-slot="badge"]'),
);
expect(badges).toHaveLength(1);
expect(badges[0].dataset.variant).toBe('neutral');
expect(badges[0].className).toContain('font-mono');
expect(badges[0].textContent).toContain('region');
expect(badges[0].textContent).toContain('EU');
});
it('draws the Artifacts toggle as the same step row', async () => {
await act(async () => {
root.render(
<RunArtifactsSection
workflowRunId="run-1"
isOpen={false}
onToggle={() => undefined}
/>,
);
});
const toggle = container.querySelector<HTMLButtonElement>('button')!;
expect(toggle.dataset.variant).toBe('ghost');
expect(toggle.dataset.size).toBe('sm');
expect(toggle.getAttribute('aria-expanded')).toBe('false');
expect(toggle.className).toContain('ml-3.5');
expect(toggle.querySelector('p')).toBeNull();
});
});
+192 -213
View File
@@ -11,7 +11,6 @@ import {
Flag,
GitBranch,
type LucideIcon,
MessageSquare,
Play,
StickyNote,
Workflow,
@@ -21,9 +20,11 @@ import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { EmptyState } from '@/components/ui/empty-state';
import { SectionHeader } from '@/components/ui/section-header';
import { SheetTitle } from '@/components/ui/sheet';
import { Spinner } from '@/components/ui/spinner';
import {
MessageScroller,
@@ -94,7 +95,64 @@ const NODE_COLORS: Record<string, string> = {
code: 'text-info',
};
function ExecutionDetails({
/**
* A step row under an answer, like Reasoning in chat (AnswerFlow): a ghost
* button whose muted icon sits on the answer's ml-6 column, then the panel it
* opens in place on that column.
*/
function StepDisclosure({
icon: Icon,
label,
count,
isOpen,
onToggle,
children,
}: {
icon: LucideIcon;
label: string;
count?: string;
isOpen: boolean;
onToggle: () => void;
children: React.ReactNode;
}) {
return (
<div className="my-2 flex w-full flex-col">
<Button
type="button"
variant="ghost"
size="sm"
onClick={onToggle}
aria-expanded={isOpen}
// ml-3.5 plus size sm's own has-[>svg]:px-2.5 puts the icon on the
// answer's ml-6 text column.
className="ml-3.5 w-fit max-w-full justify-start"
>
<Icon className="text-muted-foreground" aria-hidden />
<span className="text-muted-foreground min-w-0 truncate">{label}</span>
{count && (
<span className="text-muted-foreground/70 font-normal">{count}</span>
)}
<ChevronDown
aria-hidden
className={cn(
'text-muted-foreground shrink-0 transition-transform duration-200',
isOpen && 'rotate-180',
)}
/>
</Button>
<div
className={cn(
'mr-5 ml-6 grid transition-[grid-template-rows,opacity] duration-300 ease-out',
isOpen ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0',
)}
>
<div className="min-h-0 overflow-hidden">{children}</div>
</div>
</div>
);
}
export function ExecutionDetails({
steps,
nodes,
isOpen,
@@ -122,153 +180,121 @@ function ExecutionDetails({
};
return (
<div className="mb-4 flex w-full flex-col flex-wrap items-start self-start lg:flex-nowrap">
<div className="my-2 flex flex-row items-center justify-center gap-3">
<div className="flex h-[26px] w-[30px] items-center justify-center">
<Workflow className="text-muted-foreground size-5" />
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={onToggle}
className="-ml-2.5"
>
<p className="text-base font-semibold">
{t('agents.workflow.preview.executionDetails')}
<span className="text-muted-foreground ml-1.5 text-sm font-normal">
{t('agents.workflow.preview.stepCount', {
count: completedSteps.length,
})}
</span>
</p>
<ChevronDown
className={cn(
'transition-transform duration-200',
isOpen && 'rotate-180',
)}
/>
</Button>
</div>
<div
className={cn(
'ml-3 grid w-full transition-[grid-template-rows,opacity] duration-300 ease-out',
isOpen ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0',
)}
>
<div className="overflow-hidden">
<div className="flex flex-col gap-2 pr-2">
{completedSteps.map((step, stepIndex) => {
const node = nodes.find((n) => n.id === step.nodeId);
const displayName =
node?.title || node?.data?.title || step.nodeTitle;
const StepIcon = NODE_ICONS[step.nodeType] || Circle;
const stateVars = step.stateDelta
? Object.entries(step.stateDelta).filter(
([key]) => !['query', 'chat_history'].includes(key),
)
: [];
<StepDisclosure
icon={Workflow}
label={t('agents.workflow.preview.executionDetails')}
count={t('agents.workflow.preview.stepCount', {
count: completedSteps.length,
})}
isOpen={isOpen}
onToggle={onToggle}
>
<div className="flex flex-col gap-2 pt-1">
{completedSteps.map((step, stepIndex) => {
const node = nodes.find((n) => n.id === step.nodeId);
const displayName =
node?.title || node?.data?.title || step.nodeTitle;
const StepIcon = NODE_ICONS[step.nodeType] || Circle;
const stateVars = step.stateDelta
? Object.entries(step.stateDelta).filter(
([key]) => !['query', 'chat_history'].includes(key),
)
: [];
const truncateText = (text: string, maxLength: number) => {
if (text.length <= maxLength) return text;
return text.slice(0, maxLength) + '...';
};
const hasOutput =
step.output !== undefined &&
step.output !== null &&
formatValue(step.output) !== '';
const formattedOutput = hasOutput ? formatValue(step.output) : '';
const truncateText = (text: string, maxLength: number) => {
if (text.length <= maxLength) return text;
return text.slice(0, maxLength) + '...';
};
const hasOutput =
step.output !== undefined &&
step.output !== null &&
formatValue(step.output) !== '';
const formattedOutput = hasOutput ? formatValue(step.output) : '';
return (
<div
key={step.nodeId}
ref={(el) => {
if (el && stepRefs) stepRefs.current.set(step.nodeId, el);
}}
className="bg-muted rounded-xl p-3"
>
<div className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground flex size-5 shrink-0 items-center justify-center text-xs font-medium">
{stepIndex + 1}.
</span>
<div
className={cn(
'shrink-0',
NODE_COLORS[step.nodeType] || NODE_COLORS.state,
)}
>
<StepIcon className="size-3" />
</div>
<span className="text-foreground min-w-0 truncate font-medium">
{displayName}
</span>
<div className="ml-auto shrink-0">
{step.status === 'completed' && (
<CircleCheck className="text-success size-4" />
)}
{step.status === 'failed' && (
<CircleX className="text-destructive size-4" />
)}
</div>
</div>
{(hasOutput || step.error || stateVars.length > 0) && (
<div className="mt-3 flex flex-col gap-2 text-sm">
{hasOutput && (
<div className="bg-muted rounded-lg p-2">
<span className="text-muted-foreground font-medium">
{t('agents.workflow.preview.outputLabel')}{' '}
</span>
<span className="text-foreground wrap-break-word whitespace-pre-wrap">
{truncateText(formattedOutput, 300)}
</span>
</div>
)}
{step.error && (
<Alert variant="destructive" role="status">
<CircleAlert />
<AlertDescription>
<span className="font-medium">
{t('agents.workflow.preview.errorLabel')}{' '}
</span>
<span className="wrap-break-word whitespace-pre-wrap">
{step.error}
</span>
</AlertDescription>
</Alert>
)}
{stateVars.length > 0 && (
<div className="flex flex-wrap gap-2">
{stateVars.map(([key, value]) => (
<span
key={key}
className="bg-muted inline-flex items-center rounded-lg px-2 py-1 text-xs"
>
<span className="text-muted-foreground max-w-[100px] truncate font-medium">
{key}:
</span>
<span
className="text-foreground ml-1 max-w-[200px] truncate"
title={formatValue(value)}
>
{truncateText(formatValue(value), 50)}
</span>
</span>
))}
</div>
)}
</div>
// A step is a place you read inside: a subtle panel on the
// drawer's background, with its output in a filled well.
return (
<Card
key={step.nodeId}
ref={(el: HTMLDivElement | null) => {
if (el && stepRefs) stepRefs.current.set(step.nodeId, el);
}}
variant="subtle"
padding="sm"
>
<div className="flex items-center gap-2">
<span className="text-muted-foreground flex size-5 shrink-0 items-center justify-center text-xs font-medium">
{stepIndex + 1}.
</span>
<StepIcon
aria-hidden
className={cn(
'size-3 shrink-0',
NODE_COLORS[step.nodeType] || NODE_COLORS.state,
)}
/>
<span className="text-foreground min-w-0 truncate font-medium">
{displayName}
</span>
<div className="ml-auto shrink-0">
{step.status === 'completed' && (
<CircleCheck className="text-success size-4" />
)}
{step.status === 'failed' && (
<CircleX className="text-destructive size-4" />
)}
</div>
);
})}
</div>
</div>
</div>
{hasOutput && (
<Card variant="filled" padding="sm">
<p className="wrap-break-word whitespace-pre-wrap">
<span className="text-muted-foreground font-medium">
{t('agents.workflow.preview.outputLabel')}{' '}
</span>
<span className="text-foreground">
{truncateText(formattedOutput, 300)}
</span>
</p>
</Card>
)}
{step.error && (
<Alert variant="destructive" role="status">
<CircleAlert />
<AlertDescription>
<span className="font-medium">
{t('agents.workflow.preview.errorLabel')}{' '}
</span>
<span className="wrap-break-word whitespace-pre-wrap">
{step.error}
</span>
</AlertDescription>
</Alert>
)}
{stateVars.length > 0 && (
<div className="flex flex-wrap gap-2">
{stateVars.map(([key, value]) => (
// eslint-disable-next-line shadcn/no-restyle -- state keys and values are serialised by the app, so the chip is set in mono
<Badge key={key} variant="neutral" className="font-mono">
<span className="max-w-[100px] truncate">{key}:</span>
<span
className="text-foreground max-w-[200px] truncate"
title={formatValue(value)}
>
{truncateText(formatValue(value), 50)}
</span>
</Badge>
))}
</div>
)}
</Card>
);
})}
</div>
</div>
</StepDisclosure>
);
}
function RunArtifactsSection({
export function RunArtifactsSection({
workflowRunId,
isOpen,
onToggle,
@@ -281,47 +307,21 @@ function RunArtifactsSection({
}) {
const { t } = useTranslation();
return (
<div className="mb-4 flex w-full flex-col flex-wrap items-start self-start lg:flex-nowrap">
<div className="my-2 flex flex-row items-center justify-center gap-3">
<div className="flex h-[26px] w-[30px] items-center justify-center">
<FileBox className="text-muted-foreground size-5" />
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={onToggle}
className="-ml-2.5"
>
<p className="text-base font-semibold">
{t('agents.workflow.preview.artifacts')}
</p>
<ChevronDown
className={cn(
'transition-transform duration-200',
isOpen && 'rotate-180',
)}
<StepDisclosure
icon={FileBox}
label={t('agents.workflow.preview.artifacts')}
isOpen={isOpen}
onToggle={onToggle}
>
<div className="max-h-[480px] overflow-y-auto pt-1">
{isOpen && (
<WorkflowRunArtifacts
workflowRunId={workflowRunId}
inProgress={runInProgress}
/>
</Button>
</div>
<div
className={cn(
'ml-3 grid w-full transition-[grid-template-rows,opacity] duration-300 ease-out',
isOpen ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0',
)}
>
<div className="overflow-hidden">
<div className="max-h-[480px] overflow-y-auto pr-2">
{isOpen && (
<WorkflowRunArtifacts
workflowRunId={workflowRunId}
inProgress={runInProgress}
/>
)}
</div>
</div>
</div>
</div>
</StepDisclosure>
);
}
@@ -616,28 +616,7 @@ export default function WorkflowPreview({
queries.length > 0 ? queries[queries.length - 1].executionSteps || [] : [];
return (
<div className="bg-card flex h-full flex-col">
<div className="border-border flex h-[77px] items-center justify-between border-b px-6">
<div className="flex items-center gap-3">
<div className="bg-muted text-muted-foreground flex items-center justify-center rounded-full p-3">
<Play className="size-4" />
</div>
<div>
<SheetTitle>{t('agents.form.sections.preview')}</SheetTitle>
<p className="text-muted-foreground max-w-md truncate text-xs">
{workflowData.name}
{workflowData.description && ` - ${workflowData.description}`}
</p>
</div>
</div>
{status === 'loading' && (
<span className="text-primary flex items-center gap-1 text-xs">
<Spinner size="xs" />
{t('agents.schedules.status.running')}
</span>
)}
</div>
<div className="flex h-full min-h-0 flex-col">
<div className="flex min-h-0 flex-1">
<div className="border-border flex w-64 shrink-0 flex-col border-r">
<div className="flex items-center justify-between px-4 py-3">
@@ -662,13 +641,12 @@ export default function WorkflowPreview({
<div className="flex min-w-0 flex-1 flex-col">
<div className="relative min-h-0 flex-1">
{queries.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center px-4">
<div className="bg-muted mb-2 flex size-14 shrink-0 items-center justify-center rounded-xl">
<MessageSquare className="text-muted-foreground size-6" />
</div>
<p className="text-foreground text-xl font-semibold">
{t('agents.workflow.preview.emptyTitle')}
</p>
<div className="flex h-full items-center justify-center px-4">
<EmptyState
size="sm"
illustration="none"
title={t('agents.workflow.preview.emptyTitle')}
/>
</div>
) : (
<MessageScrollerProvider autoScroll>
@@ -788,11 +766,12 @@ export default function WorkflowPreview({
</MessageScrollerProvider>
)}
</div>
<div className="bg-card flex w-full flex-col gap-2 px-4 pt-2 pb-4">
<div className="flex w-full flex-col gap-2 px-4 pt-2 pb-4">
{sendBlockedMessage && (
<p className="text-destructive text-xs" role="alert">
{t(sendBlockedMessage)}
</p>
<Alert variant="destructive">
<CircleAlert />
<AlertDescription>{t(sendBlockedMessage)}</AlertDescription>
</Alert>
)}
<MessageInput
onSubmit={(text) => handleQuestionSubmission(text)}
+68
View File
@@ -173,8 +173,46 @@ import {
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { TimePicker } from '@/components/ui/time-picker';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import AgentPreviewSheet from '../agents/components/AgentPreviewSheet';
import type { WorkflowNode } from '../agents/types/workflow';
import {
ExecutionDetails,
RunArtifactsSection,
} from '../agents/workflow/WorkflowPreview';
import type { WorkflowExecutionStep } from '../agents/workflow/workflowPreviewSlice';
import { useDarkTheme } from '../hooks';
// Sample run for the Agent preview drawer example.
const DEMO_WORKFLOW_NODES = [
{ id: 'd1', type: 'agent', title: 'Find expiring certificates' },
{ id: 'd2', type: 'state', title: 'Set region' },
{ id: 'd3', type: 'agent', title: 'Draft reminders' },
] as unknown as WorkflowNode[];
const DEMO_WORKFLOW_STEPS = [
{
nodeId: 'd1',
nodeType: 'agent',
nodeTitle: 'Find expiring certificates',
status: 'completed',
output: 'Three carriers have certificates expiring before 31 October.',
},
{
nodeId: 'd2',
nodeType: 'state',
nodeTitle: 'Set region',
status: 'completed',
stateDelta: { region: 'EU', carriers: ['Halvorsen', 'Nordline'] },
},
{
nodeId: 'd3',
nodeType: 'agent',
nodeTitle: 'Draft reminders',
status: 'failed',
error: 'The email tool timed out after 30 s.',
},
] as unknown as WorkflowExecutionStep[];
/**
* Dev-only style guide, served at /design. It renders every component in
* src/components/ui with the variants DESIGN.md documents as the defaults,
@@ -422,6 +460,8 @@ export default function DesignSystem() {
const [agentType, setAgentType] = useState<'classic' | 'workflow'>('classic');
const [toolOn, setToolOn] = useState(false);
const [sectionOpen, setSectionOpen] = useState(false);
const [agentPreviewOpen, setAgentPreviewOpen] = useState(false);
const [demoStepsOpen, setDemoStepsOpen] = useState(true);
const [scopes, setScopes] = useState<string[]>(['agents:read']);
const [tokenLimit, setTokenLimit] = useState(true);
const [saving, setSaving] = useState(false);
@@ -2621,6 +2661,34 @@ export default function DesignSystem() {
</SheetHeader>
</SheetContent>
</Sheet>
<Button
variant="outline"
onClick={() => setAgentPreviewOpen(true)}
data-testid="ds-agent-preview"
>
Agent preview
</Button>
<AgentPreviewSheet
open={agentPreviewOpen}
onOpenChange={setAgentPreviewOpen}
title="Preview"
description="Carrier compliance workflow · Checks certificates weekly"
running
>
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto py-4">
<ExecutionDetails
steps={DEMO_WORKFLOW_STEPS}
nodes={DEMO_WORKFLOW_NODES}
isOpen={demoStepsOpen}
onToggle={() => setDemoStepsOpen(!demoStepsOpen)}
/>
<RunArtifactsSection
workflowRunId="demo"
isOpen={false}
onToggle={() => undefined}
/>
</div>
</AgentPreviewSheet>
<Sheet>
<SheetTrigger asChild>
<Button variant="outline">Open bottom sheet</Button>