mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-13 10:15:21 +00:00
updated drawer
This commit is contained in:
1 parent
ed804a14e9
commit
20bcd92942
7 files changed
+636
-255
No files matched your search
+18
-6
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user