ai-design-system 0.1.71 → 0.1.72

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,12 +6,43 @@ import { EnhancedDataTable } from "@/components/composites/DataTable"
6
6
  import type { DashboardRow } from "@/components/composites/DataTable"
7
7
  import { Button } from "@/components/primitives/Button"
8
8
  import { Icon } from "@/components/primitives/Icon"
9
+ import { IconButton } from "@/components/composites/IconButton"
10
+
11
+ const CopyButton = ({ text, className }: { text: string; className?: string }) => {
12
+ const [copied, setCopied] = React.useState(false)
13
+
14
+ const handleCopy = React.useCallback(
15
+ async (e: React.MouseEvent) => {
16
+ e.preventDefault()
17
+ e.stopPropagation()
18
+ try {
19
+ await navigator.clipboard.writeText(text)
20
+ setCopied(true)
21
+ setTimeout(() => setCopied(false), 2000)
22
+ } catch (err) {
23
+ console.error("Failed to copy:", err)
24
+ }
25
+ },
26
+ [text]
27
+ )
28
+
29
+ return (
30
+ <IconButton
31
+ icon={copied ? "check" : "copy"}
32
+ size="sm"
33
+ variant="secondary"
34
+ className={className}
35
+ title={copied ? "Copied!" : "Copy"}
36
+ onClick={handleCopy}
37
+ />
38
+ )
39
+ }
9
40
 
10
41
  export interface EvalSessionDetails {
11
42
  id: string
12
43
  date: string
13
44
  goldenEvals: DashboardRow[]
14
- recommendations: Array<{ id: number; name: string; rationale: string }>
45
+ recommendations: Array<{ id: number; change: string; rationale: string }>
15
46
  systemPrompt?: string
16
47
  outputTranscript?: string
17
48
  }
@@ -19,7 +50,7 @@ export interface EvalSessionDetails {
19
50
  export interface EvalSessionDetailsPanelProps {
20
51
  sessionDetails: EvalSessionDetails
21
52
  activeTab: string
22
- actionHandlers?: { onTriggerEvaluation?: () => void }
53
+ actionHandlers?: { onTriggerEvaluation?: () => void; onDownloadPrompt?: () => Promise<import('@/components/composites/FileTreeExplorer').FileDownloadResult | undefined> }
23
54
  workflowContent?: React.ReactNode
24
55
  }
25
56
 
@@ -31,12 +62,12 @@ export const EvalTriggerButton = ({ onClick, loading }: { onClick: () => void; l
31
62
 
32
63
  const recommendationTableSchema = dynamicTableSchema.parse({
33
64
  schemaVersion: DYNAMIC_TABLE_SCHEMA_VERSION,
34
- rowKey: "name",
65
+ rowKey: "change",
35
66
  enableFiltering: true,
36
67
  enablePagination: false,
37
68
  enableRowSelection: false,
38
69
  columns: [
39
- { key: "name", label: "Recommendation", renderType: "text" },
70
+ { key: "change", label: "Recommendation", renderType: "text" },
40
71
  { key: "rationale", label: "Rationale", renderType: "text" },
41
72
  ],
42
73
  })
@@ -56,7 +87,7 @@ const goldenEvalTableSchema = dynamicTableSchema.parse({
56
87
  })
57
88
 
58
89
  export const EvalSessionDetailsPanel = React.memo<EvalSessionDetailsPanelProps>(
59
- ({ sessionDetails, activeTab, actionHandlers: _actionHandlers, workflowContent }) => {
90
+ ({ sessionDetails, activeTab, workflowContent }) => {
60
91
  return (
61
92
  <div className="flex flex-col h-full bg-background">
62
93
  <Tabs value={activeTab} className="flex-1 flex flex-col min-h-0">
@@ -89,7 +120,15 @@ export const EvalSessionDetailsPanel = React.memo<EvalSessionDetailsPanelProps>(
89
120
 
90
121
  <TabsContent value="prompts" className="absolute inset-0 m-0 p-0">
91
122
  <ScrollArea className="h-full">
92
- <div className="p-4">
123
+ <div className="p-4 relative group">
124
+ {sessionDetails.systemPrompt && (
125
+ <div className="absolute top-6 right-6 opacity-0 group-hover:opacity-100 transition-opacity">
126
+ <CopyButton
127
+ text={sessionDetails.systemPrompt}
128
+ className="h-8 w-8 p-0"
129
+ />
130
+ </div>
131
+ )}
93
132
  <pre className="text-xs font-mono bg-muted p-4 rounded whitespace-pre-wrap">
94
133
  {sessionDetails.systemPrompt || "No system prompt available."}
95
134
  </pre>
@@ -99,7 +138,15 @@ export const EvalSessionDetailsPanel = React.memo<EvalSessionDetailsPanelProps>(
99
138
 
100
139
  <TabsContent value="outputs" className="absolute inset-0 m-0 p-0">
101
140
  <ScrollArea className="h-full">
102
- <div className="p-4">
141
+ <div className="p-4 relative group">
142
+ {sessionDetails.outputTranscript && (
143
+ <div className="absolute top-6 right-6 opacity-0 group-hover:opacity-100 transition-opacity">
144
+ <CopyButton
145
+ text={sessionDetails.outputTranscript}
146
+ className="h-8 w-8 p-0"
147
+ />
148
+ </div>
149
+ )}
103
150
  <pre className="text-xs font-mono bg-muted p-4 rounded whitespace-pre-wrap">
104
151
  {sessionDetails.outputTranscript || "No transcript available."}
105
152
  </pre>
@@ -1,14 +1,18 @@
1
1
  "use client";
2
2
 
3
- import type { NodeProps } from "@xyflow/react";
3
+ import { NodeToolbar, Position, type NodeProps } from "@xyflow/react";
4
4
  import { memo } from "react";
5
5
  import { Icon } from "@/components/primitives/Icon";
6
+ import { ButtonGroup } from "@/components/primitives/ButtonGroup";
7
+ import { Button } from "@/components/primitives/Button";
8
+ import { DefaultSwitcher } from "@/components/composites/DefaultSwitcher";
6
9
  import {
7
10
  Node,
8
11
  NodeDescription,
9
12
  NodeTitle,
10
13
  } from "@/components/ai-elements/node";
11
14
  import { cn } from "@/lib/utils";
15
+ import type { ToolbarAction } from "@/components/composites/WorkflowToolbar";
12
16
 
13
17
  export type StateNodeData = {
14
18
  label: string;
@@ -18,6 +22,7 @@ export type StateNodeData = {
18
22
  isTerminal?: boolean;
19
23
  status?: "idle" | "running" | "success" | "error";
20
24
  enabled?: boolean;
25
+ actions?: ToolbarAction[];
21
26
  };
22
27
 
23
28
  type StateNodeProps = NodeProps & {
@@ -63,7 +68,6 @@ export const StateNode = memo(({ data, selected, id }: StateNodeProps) => {
63
68
  const displayDescription = data.description;
64
69
  const status = data.status;
65
70
  const isDisabled = data.enabled === false;
66
- const isTerminal = data.isTerminal === true;
67
71
 
68
72
  return (
69
73
  <Node
@@ -99,6 +103,51 @@ export const StateNode = memo(({ data, selected, id }: StateNodeProps) => {
99
103
  )}
100
104
  </div>
101
105
  </div>
106
+ {data.actions && data.actions.length > 0 && (
107
+ <NodeToolbar isVisible={true} position={Position.Right} offset={12}>
108
+ {(() => {
109
+ const switcherActions = data.actions.filter((a: ToolbarAction) => a.switcher);
110
+ const buttonActions = data.actions.filter((a: ToolbarAction) => !a.switcher);
111
+ return (
112
+ <>
113
+ {buttonActions.length > 0 && (
114
+ <ButtonGroup orientation="horizontal" className="shadow-md bg-secondary/80 backdrop-blur border-border/50">
115
+ {buttonActions.map((action: ToolbarAction) => (
116
+ <Button
117
+ key={action.id}
118
+ size="icon"
119
+ variant="secondary"
120
+ className="h-8 w-8 hover:bg-primary/10"
121
+ onClick={(e) => {
122
+ e.stopPropagation();
123
+ action.onClick?.();
124
+ }}
125
+ title={action.title}
126
+ >
127
+ {action.icon ? (
128
+ <Icon name={action.icon as string} size="sm" className={action.icon === 'check' ? 'text-green-500' : action.icon === 'x' ? 'text-red-500' : 'text-primary'} />
129
+ ) : (
130
+ <Icon name="play" size="sm" className="text-primary" />
131
+ )}
132
+ </Button>
133
+ ))}
134
+ </ButtonGroup>
135
+ )}
136
+ {switcherActions.map((action: ToolbarAction) => (
137
+ <div key={action.id} className="shadow-md bg-secondary/80 backdrop-blur border-border/50 rounded-md">
138
+ <DefaultSwitcher
139
+ themes={action.switcher!.items}
140
+ value={action.switcher!.value}
141
+ onValueChange={action.switcher!.onValueChange}
142
+ placeholder={action.switcher!.placeholder}
143
+ />
144
+ </div>
145
+ ))}
146
+ </>
147
+ );
148
+ })()}
149
+ </NodeToolbar>
150
+ )}
102
151
  </Node>
103
152
  );
104
153
  });
@@ -1,14 +1,18 @@
1
1
  "use client";
2
2
 
3
- import type { NodeProps } from "@xyflow/react";
3
+ import { NodeToolbar, Position, type NodeProps } from "@xyflow/react";
4
4
  import { memo } from "react";
5
5
  import { Icon } from "@/components/primitives/Icon";
6
+ import { ButtonGroup } from "@/components/primitives/ButtonGroup";
7
+ import { Button } from "@/components/primitives/Button";
8
+ import { DefaultSwitcher } from "@/components/composites/DefaultSwitcher";
6
9
  import {
7
10
  Node,
8
11
  NodeDescription,
9
12
  NodeTitle,
10
13
  } from "@/components/ai-elements/node";
11
14
  import { cn } from "@/lib/utils";
15
+ import type { ToolbarAction } from "@/components/composites/WorkflowToolbar";
12
16
 
13
17
  export type TransitionNodeConfig = {
14
18
  /** For conditional transitions: form field comparison e.g. "{{record.orderValue}} > {{record.acceptedRange}}" */
@@ -33,6 +37,7 @@ export type TransitionNodeData = {
33
37
  config?: TransitionNodeConfig;
34
38
  status?: "idle" | "running" | "success" | "error";
35
39
  enabled?: boolean;
40
+ actions?: ToolbarAction[];
36
41
  };
37
42
 
38
43
  type TransitionNodeProps = NodeProps & {
@@ -91,6 +96,51 @@ export const TransitionNode = memo(
91
96
  )}
92
97
  </div>
93
98
  </div>
99
+ {data.actions && data.actions.length > 0 && (
100
+ <NodeToolbar isVisible={true} position={Position.Right} offset={12}>
101
+ {(() => {
102
+ const switcherActions = data.actions.filter((a: ToolbarAction) => a.switcher);
103
+ const buttonActions = data.actions.filter((a: ToolbarAction) => !a.switcher);
104
+ return (
105
+ <>
106
+ {buttonActions.length > 0 && (
107
+ <ButtonGroup orientation="horizontal" className="shadow-md bg-secondary/80 backdrop-blur border-border/50">
108
+ {buttonActions.map((action: ToolbarAction) => (
109
+ <Button
110
+ key={action.id}
111
+ size="icon"
112
+ variant="secondary"
113
+ className="h-8 w-8 hover:bg-primary/10"
114
+ onClick={(e) => {
115
+ e.stopPropagation();
116
+ action.onClick?.();
117
+ }}
118
+ title={action.title}
119
+ >
120
+ {action.icon ? (
121
+ <Icon name={action.icon as string} size="sm" className={action.icon === 'check' ? 'text-green-500' : action.icon === 'x' ? 'text-red-500' : 'text-primary'} />
122
+ ) : (
123
+ <Icon name="play" size="sm" className="text-primary" />
124
+ )}
125
+ </Button>
126
+ ))}
127
+ </ButtonGroup>
128
+ )}
129
+ {switcherActions.map((action: ToolbarAction) => (
130
+ <div key={action.id} className="shadow-md bg-secondary/80 backdrop-blur border-border/50 rounded-md">
131
+ <DefaultSwitcher
132
+ themes={action.switcher!.items}
133
+ value={action.switcher!.value}
134
+ onValueChange={action.switcher!.onValueChange}
135
+ placeholder={action.switcher!.placeholder}
136
+ />
137
+ </div>
138
+ ))}
139
+ </>
140
+ );
141
+ })()}
142
+ </NodeToolbar>
143
+ )}
94
144
  </Node>
95
145
  );
96
146
  }
@@ -5,6 +5,19 @@ export interface WorkflowVersion {
5
5
  label: string;
6
6
  }
7
7
 
8
+ export interface ToolbarActionSwitcherItem {
9
+ label: string;
10
+ value: string;
11
+ }
12
+
13
+ export interface ToolbarActionSwitcher {
14
+ items: ToolbarActionSwitcherItem[];
15
+ value?: string;
16
+ onValueChange: (value: string) => void;
17
+ placeholder?: string;
18
+ disabled?: boolean;
19
+ }
20
+
8
21
  export interface ToolbarAction {
9
22
  id: string;
10
23
  icon: string | React.ReactNode;
@@ -13,6 +26,7 @@ export interface ToolbarAction {
13
26
  disabled?: boolean;
14
27
  loading?: boolean;
15
28
  indicator?: boolean;
29
+ switcher?: ToolbarActionSwitcher;
16
30
  }
17
31
 
18
32
  export interface WorkflowToolbarActionsProps {
@@ -5,6 +5,7 @@ import { DashboardChart } from "@/components/composites/DashboardChart"
5
5
  import { EvalSessionDetailsPanel, EvalTriggerButton } from "@/components/blocks/EvalSessionDetailsPanel"
6
6
  import type { DashboardRow } from "@/components/composites/DataTable"
7
7
  import { IconButton } from "@/components/composites/IconButton"
8
+ import type { FileDownloadResult } from "@/components/composites/FileTreeExplorer"
8
9
  import { cn } from "@/lib/utils"
9
10
  import type {
10
11
  EvalDashboardFeatureInboxState,
@@ -25,6 +26,27 @@ export const EvalDashboardFeature = React.memo<EvalDashboardFeatureProps>(
25
26
  ({ inbox, data, actionHandlers, className, workflowContent }) => {
26
27
  const [showInbox, setShowInbox] = React.useState(true)
27
28
  const [activeTab, setActiveTab] = React.useState("golden-evals")
29
+ const [isDownloading, setIsDownloading] = React.useState(false)
30
+ const downloadRef = React.useRef<HTMLAnchorElement>(null)
31
+
32
+ const handleDownloadClick = React.useCallback(async () => {
33
+ if (!actionHandlers?.onDownloadPrompt || isDownloading) return
34
+ setIsDownloading(true)
35
+ try {
36
+ const result: FileDownloadResult | undefined = await actionHandlers.onDownloadPrompt()
37
+ if (!result) return
38
+ const url = URL.createObjectURL(result.blob)
39
+ const anchor = downloadRef.current
40
+ if (anchor) {
41
+ anchor.href = url
42
+ anchor.download = result.filename
43
+ anchor.click()
44
+ }
45
+ URL.revokeObjectURL(url)
46
+ } finally {
47
+ setIsDownloading(false)
48
+ }
49
+ }, [actionHandlers?.onDownloadPrompt, isDownloading])
28
50
 
29
51
  const selectedSession = React.useMemo(() =>
30
52
  inbox.items.find(s => s.id === inbox.selectedItemId) || null
@@ -72,6 +94,7 @@ export const EvalDashboardFeature = React.memo<EvalDashboardFeatureProps>(
72
94
  } : null
73
95
 
74
96
  return (
97
+ <>
75
98
  <div className={cn("h-full w-full overflow-hidden bg-background", className)}>
76
99
  <SectionLayout
77
100
  dragHandleColor="border"
@@ -169,7 +192,7 @@ export const EvalDashboardFeature = React.memo<EvalDashboardFeatureProps>(
169
192
  <div className="flex items-center gap-2">
170
193
  <EvalTriggerButton onClick={actionHandlers.onTriggerEvaluation} loading={actionHandlers.isTriggering} />
171
194
  {actionHandlers.onDownloadPrompt && (
172
- <IconButton onClick={actionHandlers.onDownloadPrompt} variant="outline" size="sm" className="h-8 w-8 p-0" title="Download Prompt" icon="download" />
195
+ <IconButton onClick={handleDownloadClick} variant="outline" size="sm" className="h-8 w-8 p-0" title={isDownloading ? 'Preparing...' : 'Download Prompt'} icon={isDownloading ? 'loader-2' : 'download'} iconClassName={`w-4 h-4${isDownloading ? ' animate-spin' : ''}`} />
173
196
  )}
174
197
  </div>
175
198
  ) : undefined,
@@ -194,9 +217,11 @@ export const EvalDashboardFeature = React.memo<EvalDashboardFeatureProps>(
194
217
  }
195
218
  ]}
196
219
  />
197
- </div>
220
+ </div>
221
+ <a ref={downloadRef} style={{ display: 'none' }} />
222
+ </>
198
223
  )
199
224
  }
200
225
  )
201
-
226
+
202
227
  EvalDashboardFeature.displayName = "EvalDashboardFeature"
@@ -25,8 +25,10 @@ export interface EvalDashboardFeatureInboxState {
25
25
  }
26
26
 
27
27
  export interface EvalRecommendation {
28
- name: string;
28
+ change: string;
29
29
  rationale: string;
30
+ priority?: number;
31
+ expected_metrics?: number[];
30
32
  }
31
33
 
32
34
  export interface EvalDashboardFeatureData {
@@ -43,9 +45,11 @@ export interface EvalDashboardFeatureData {
43
45
  }>;
44
46
  }
45
47
 
48
+ import type { FileDownloadResult } from '@/components/composites/FileTreeExplorer';
49
+
46
50
  export interface EvalDashboardFeatureActionHandlers {
47
51
  onTriggerEvaluation?: () => void;
48
- onDownloadPrompt?: () => void;
52
+ onDownloadPrompt?: () => Promise<FileDownloadResult | undefined>;
49
53
  isTriggering?: boolean;
50
54
  timeRange?: string;
51
55
  onTimeRangeChange?: (range: string) => void;
@@ -1,7 +1,10 @@
1
+ import React from "react";
1
2
  import type { Meta, StoryObj } from "@storybook/nextjs-vite";
2
3
  import { NodeEditor } from "./NodeEditor";
3
4
  import { useNodeEditorMock } from "./useNodeEditor.mock";
4
5
  import { mockVersions, mockNodes, mockEdges } from "./NodeEditor.mocks";
6
+ import type { WorkflowNode, WorkflowEdge } from "@/components/blocks/WorkflowCanvas";
7
+ import type { ToolbarAction } from "@/components/composites/WorkflowToolbar";
5
8
 
6
9
  /**
7
10
  * NodeEditor Feature
@@ -139,3 +142,60 @@ export const Saving: Story = {
139
142
  canRedo: false,
140
143
  },
141
144
  };
145
+
146
+ /**
147
+ * WithHITLApproval — single HITL transition node with a floating DefaultSwitcher
148
+ * containing workflow-defined approval options beside the node.
149
+ */
150
+ function NodeEditorWithHITLApprovalStory() {
151
+ const [selectedValue, setSelectedValue] = React.useState("");
152
+
153
+ const hitlNode: WorkflowNode = {
154
+ id: "hitl-1",
155
+ type: "transition",
156
+ position: { x: 300, y: 100 },
157
+ data: {
158
+ label: "Human Approval",
159
+ description: "Manager review required",
160
+ type: "transition",
161
+ status: "idle",
162
+ },
163
+ };
164
+
165
+ const nodeActions: Record<string, ToolbarAction[]> = {
166
+ "hitl-1": [
167
+ {
168
+ id: "switcher-hitl-1",
169
+ icon: "chevrons-up-down",
170
+ title: "Select approval decision",
171
+ switcher: {
172
+ items: [
173
+ { label: "Approve", value: "approve" },
174
+ { label: "Reject", value: "reject" },
175
+ ],
176
+ value: selectedValue,
177
+ onValueChange: setSelectedValue,
178
+ placeholder: "Do you approve this evaluation?",
179
+ },
180
+ },
181
+ ],
182
+ };
183
+
184
+ return (
185
+ <NodeEditor
186
+ workflowName="HITL Approval"
187
+ versions={[{ id: "v1", label: "v1" }]}
188
+ currentVersionId="v1"
189
+ nodes={[hitlNode]}
190
+ edges={[]}
191
+ nodeActions={nodeActions}
192
+ interactive={false}
193
+ hideDefaultActions={true}
194
+ showMinimap={false}
195
+ />
196
+ );
197
+ }
198
+
199
+ export const WithHITLApproval: Story = {
200
+ render: () => <NodeEditorWithHITLApprovalStory />,
201
+ };
@@ -15,6 +15,11 @@ const GLOW: Record<string, string> = {
15
15
  error: "0 0 14px 5px rgba(239, 68, 68, 0.9)",
16
16
  };
17
17
 
18
+ export interface NodeStatusEntry {
19
+ nodeId: string;
20
+ status: string;
21
+ }
22
+
18
23
  export interface NodeEditorProps {
19
24
  // Toolbar — left
20
25
  workflowName?: string;
@@ -48,8 +53,22 @@ export interface NodeEditorProps {
48
53
  hideDefaultActions?: boolean;
49
54
  className?: string;
50
55
 
51
- /** Per-node highlight states: { [nodeId]: 'active' | 'pending' | 'done' | 'error' } */
52
- nodeHighlights?: Record<string, string>;
56
+ /** Runtime node statuses from the API. NodeEditor derives highlights internally. */
57
+ nodeStatuses?: NodeStatusEntry[];
58
+ /** Per-node actions */
59
+ nodeActions?: Record<string, ToolbarAction[]>;
60
+ }
61
+
62
+ function statusToGlow(status: string): string | null {
63
+ switch (status) {
64
+ case 'running': return 'active';
65
+ case 'pending_hitl': return 'pending';
66
+ case 'success':
67
+ case 'completed': return 'done';
68
+ case 'error':
69
+ case 'failed': return 'error';
70
+ default: return null;
71
+ }
53
72
  }
54
73
 
55
74
  export function NodeEditor({
@@ -76,19 +95,37 @@ export function NodeEditor({
76
95
  interactive = false,
77
96
  hideDefaultActions = false,
78
97
  className,
79
- nodeHighlights,
98
+ nodeStatuses,
99
+ nodeActions,
80
100
  }: NodeEditorProps) {
81
101
  const highlightedNodes = useMemo(
82
- () =>
83
- nodes.map((n: WorkflowNode) => {
84
- const glow = nodeHighlights?.[n.id];
85
- if (!glow) return n;
102
+ () => {
103
+ const statusGlows: Record<string, string> = {};
104
+ for (const ns of nodeStatuses ?? []) {
105
+ const g = statusToGlow(ns.status);
106
+ if (g) statusGlows[ns.nodeId] = g;
107
+ }
108
+
109
+ return nodes.map((n: WorkflowNode) => {
110
+ const glow =
111
+ statusGlows[n.id] ??
112
+ (nodeActions?.[n.id]?.some((a) => a.switcher) ? 'pending' : undefined) ??
113
+ statusToGlow(n.data?.status ?? '');
114
+
115
+ const actions = nodeActions?.[n.id];
116
+
117
+ if (!glow && !actions) return n;
86
118
  return {
87
119
  ...n,
88
- style: { ...(n.style ?? {}), boxShadow: GLOW[glow] },
120
+ data: {
121
+ ...n.data,
122
+ actions,
123
+ },
124
+ style: { ...(n.style ?? {}), boxShadow: glow ? GLOW[glow] : undefined },
89
125
  };
90
- }),
91
- [nodes, nodeHighlights],
126
+ });
127
+ },
128
+ [nodes, nodeStatuses, nodeActions],
92
129
  );
93
130
  const defaultActionGroups: ToolbarAction[][] = [
94
131
  [