@startsimpli/ui 0.4.14 → 0.4.15

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.
Files changed (71) hide show
  1. package/README.md +457 -398
  2. package/package.json +18 -13
  3. package/src/components/__tests__/calendar-view-popup.test.tsx +42 -0
  4. package/src/components/__tests__/chat.test.tsx +129 -0
  5. package/src/components/__tests__/meetings-list.test.tsx +114 -0
  6. package/src/components/__tests__/slide-deck-viewer.test.tsx +82 -0
  7. package/src/components/__tests__/workspace.test.tsx +106 -0
  8. package/src/components/account/__tests__/account.test.tsx +5 -32
  9. package/src/components/account/change-password-form.tsx +1 -28
  10. package/src/components/calendar/calendar-view.tsx +31 -0
  11. package/src/components/calendar/index.ts +7 -0
  12. package/src/components/calendar/meetings-list.tsx +202 -0
  13. package/src/components/calendar/upcoming-meetings.tsx +5 -5
  14. package/src/components/chat/ChatComposer.tsx +113 -0
  15. package/src/components/chat/ChatMessage.tsx +81 -0
  16. package/src/components/chat/ChatThread.tsx +57 -0
  17. package/src/components/chat/index.ts +12 -0
  18. package/src/components/chat/types.ts +20 -0
  19. package/src/components/index.ts +13 -0
  20. package/src/components/slide-deck/SlideCanvas.tsx +68 -0
  21. package/src/components/slide-deck/SlideDeckViewer.tsx +144 -0
  22. package/src/components/slide-deck/SlideFilmstrip.tsx +73 -0
  23. package/src/components/slide-deck/index.ts +7 -0
  24. package/src/components/slide-deck/types.ts +18 -0
  25. package/src/components/team/DomainClaimCard.tsx +170 -0
  26. package/src/components/team/InviteMemberDialog.tsx +182 -0
  27. package/src/components/team/LeaveTeamDialog.tsx +130 -0
  28. package/src/components/team/MembersTable.tsx +138 -0
  29. package/src/components/team/OrgSwitcher.tsx +68 -0
  30. package/src/components/team/PendingInvitationCallout.tsx +106 -0
  31. package/src/components/team/RoleSelector.tsx +68 -0
  32. package/src/components/team/__tests__/team-components.test.tsx +352 -0
  33. package/src/components/team/domain-claim-card-default-class-names.ts +45 -0
  34. package/src/components/team/index.ts +57 -0
  35. package/src/components/team/invite-member-dialog-default-class-names.ts +41 -0
  36. package/src/components/team/leave-team-dialog-default-class-names.ts +33 -0
  37. package/src/components/team/members-table-default-class-names.ts +39 -0
  38. package/src/components/team/org-switcher-default-class-names.ts +13 -0
  39. package/src/components/team/pending-invitation-callout-default-class-names.ts +22 -0
  40. package/src/components/team/role-selector-default-class-names.ts +11 -0
  41. package/src/components/team/types.ts +97 -0
  42. package/src/components/workflows/ExecNodeDetails.tsx +83 -0
  43. package/src/components/workflows/ExecutionTimeline.tsx +146 -0
  44. package/src/components/workflows/NodeInspector.tsx +257 -0
  45. package/src/components/workflows/NodePalette.tsx +119 -0
  46. package/src/components/workflows/WorkflowCanvas.tsx +113 -0
  47. package/src/components/workflows/WorkflowEdge.tsx +65 -0
  48. package/src/components/workflows/WorkflowEditor.tsx +130 -0
  49. package/src/components/workflows/WorkflowNode.tsx +198 -0
  50. package/src/components/workflows/WorkflowRunViewer.tsx +81 -0
  51. package/src/components/workflows/__tests__/ExecutionTimeline.test.tsx +99 -0
  52. package/src/components/workflows/__tests__/NodeInspector.test.tsx +74 -0
  53. package/src/components/workflows/__tests__/NodePalette.test.tsx +46 -0
  54. package/src/components/workflows/__tests__/WorkflowCanvas.test.tsx +59 -0
  55. package/src/components/workflows/__tests__/WorkflowNode.test.tsx +92 -0
  56. package/src/components/workflows/__tests__/WorkflowRunViewer.test.tsx +138 -0
  57. package/src/components/workflows/__tests__/auto-layout.test.ts +107 -0
  58. package/src/components/workflows/__tests__/serialization.test.ts +278 -0
  59. package/src/components/workflows/exec-status.ts +90 -0
  60. package/src/components/workflows/hooks/useCanvasGraph.ts +70 -0
  61. package/src/components/workflows/hooks/useNodeStatusOverlay.ts +47 -0
  62. package/src/components/workflows/index.ts +78 -0
  63. package/src/components/workflows/layout/auto-layout.ts +142 -0
  64. package/src/components/workflows/node-icons.ts +31 -0
  65. package/src/components/workflows/serialization.ts +171 -0
  66. package/src/components/workflows/theme/categories.ts +96 -0
  67. package/src/components/workflows/types.ts +231 -0
  68. package/src/components/workflows/workflows.css +29 -0
  69. package/src/components/workspace/DualPaneWorkspace.tsx +187 -0
  70. package/src/components/workspace/SplitPane.tsx +174 -0
  71. package/src/components/workspace/index.ts +4 -0
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Shared types for the team-management components.
3
+ *
4
+ * Mirrors the @startsimpli/api shapes, but redeclared here so the UI
5
+ * package doesn't pull @startsimpli/api as a peer. Each component accepts
6
+ * the data it actually renders — apps pass through whatever @startsimpli/api
7
+ * gave them. startsim-o7s.
8
+ */
9
+
10
+ import type * as React from 'react'
11
+
12
+ export type TeamRole = 'owner' | 'admin' | 'member' | 'viewer'
13
+
14
+ export interface TeamLite {
15
+ id: string
16
+ slug: string
17
+ name: string
18
+ companyId: string
19
+ }
20
+
21
+ export interface CompanyLite {
22
+ id: string
23
+ slug: string
24
+ name: string
25
+ }
26
+
27
+ /** User shape rendered next to each row. Optional fields stay optional. */
28
+ export interface MemberUserLite {
29
+ id: string
30
+ email: string
31
+ firstName?: string
32
+ lastName?: string
33
+ fullName?: string
34
+ /** Optional URL to an avatar image. */
35
+ avatarUrl?: string
36
+ }
37
+
38
+ export interface MemberRow {
39
+ id: string
40
+ userId: string
41
+ teamId: string
42
+ role: TeamRole
43
+ joinedAt: string
44
+ /** Optionally embedded; when absent, the email + role-only render path is used. */
45
+ user?: MemberUserLite
46
+ }
47
+
48
+ export interface InvitationLite {
49
+ id: string
50
+ email: string
51
+ teamId: string
52
+ role: TeamRole
53
+ /** Only present on the create-response; otherwise the backend redacts. */
54
+ token?: string
55
+ expiresAt: string
56
+ acceptedAt?: string | null
57
+ revokedAt?: string | null
58
+ isExpired: boolean
59
+ isAccepted: boolean
60
+ }
61
+
62
+ export type DomainVerificationMethod = 'dns_txt' | 'email_attestation'
63
+
64
+ export interface DomainClaimLite {
65
+ id: string
66
+ companyId: string
67
+ domain: string
68
+ verified: boolean
69
+ verificationMethod?: DomainVerificationMethod | null
70
+ verificationToken?: string | null
71
+ verifiedAt?: string | null
72
+ createdAt: string
73
+ }
74
+
75
+ /**
76
+ * Helper: render a human-friendly label for a user — fullName wins, then
77
+ * firstName + lastName, then email.
78
+ */
79
+ export function userDisplayName(user: MemberUserLite | undefined): string {
80
+ if (!user) return ''
81
+ if (user.fullName) return user.fullName
82
+ const fl = [user.firstName, user.lastName].filter(Boolean).join(' ').trim()
83
+ return fl || user.email
84
+ }
85
+
86
+ /** Helper: render initials for the avatar fallback. */
87
+ export function userInitials(user: MemberUserLite | undefined): string {
88
+ if (!user) return '?'
89
+ const name = userDisplayName(user)
90
+ const parts = name.split(/\s+/).filter(Boolean)
91
+ if (parts.length === 0) return '?'
92
+ if (parts.length === 1) return parts[0][0].toUpperCase()
93
+ return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase()
94
+ }
95
+
96
+ /** Common React-prop alias used by every team component. */
97
+ export type ChildrenProps = { children?: React.ReactNode }
@@ -0,0 +1,83 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import { cn } from '../../lib/utils';
5
+ import { StatusBadge } from '../badge/StatusBadge';
6
+ import { EmptyState } from '../states/EmptyState';
7
+ import type { NodeExecView } from './types';
8
+ import { NODE_STATUS_BADGE, formatDuration } from './exec-status';
9
+
10
+ export interface ExecNodeDetailsProps {
11
+ /** The selected node execution, or null when nothing is selected. */
12
+ node: NodeExecView | null | undefined;
13
+ className?: string;
14
+ }
15
+
16
+ function JsonBlock({
17
+ label,
18
+ value,
19
+ }: {
20
+ label: string;
21
+ value: Record<string, unknown> | null | undefined;
22
+ }) {
23
+ const isEmpty = value == null || Object.keys(value).length === 0;
24
+ return (
25
+ <div className="flex min-h-0 flex-col">
26
+ <h4 className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
27
+ {label}
28
+ </h4>
29
+ {isEmpty ? (
30
+ <p className="text-xs italic text-muted-foreground">No {label.toLowerCase()}.</p>
31
+ ) : (
32
+ <pre className="overflow-auto rounded-md border border-border bg-muted/40 p-2 text-xs leading-relaxed">
33
+ {JSON.stringify(value, null, 2)}
34
+ </pre>
35
+ )}
36
+ </div>
37
+ );
38
+ }
39
+
40
+ /**
41
+ * Read-only detail panel for a single node execution: name, type, status
42
+ * badge, duration, error, and pretty-printed input/output JSON. Purpose-built
43
+ * for the execution monitor — deliberately NOT the editor's NodeInspector.
44
+ */
45
+ export function ExecNodeDetails({ node, className }: ExecNodeDetailsProps) {
46
+ if (!node) {
47
+ return (
48
+ <EmptyState
49
+ title="No node selected"
50
+ description="Select a node in the timeline to inspect its input and output."
51
+ className={className}
52
+ />
53
+ );
54
+ }
55
+
56
+ const duration = formatDuration(node.executionTimeMs);
57
+
58
+ return (
59
+ <div className={cn('flex h-full min-h-0 flex-col gap-4 overflow-auto p-4', className)}>
60
+ <header className="flex flex-col gap-2">
61
+ <div className="flex items-center gap-2">
62
+ <h3 className="truncate text-base font-semibold text-foreground">
63
+ {node.name ?? node.nodeId}
64
+ </h3>
65
+ <StatusBadge status={node.status} config={NODE_STATUS_BADGE} size="sm" />
66
+ </div>
67
+ <div className="flex items-center gap-3 text-xs text-muted-foreground">
68
+ {node.type && <span>{node.type}</span>}
69
+ {duration && <span className="tabular-nums">{duration}</span>}
70
+ </div>
71
+ </header>
72
+
73
+ {node.status === 'error' && node.error && (
74
+ <p className="break-words rounded-md border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700">
75
+ {node.error}
76
+ </p>
77
+ )}
78
+
79
+ <JsonBlock label="Input" value={node.inputData} />
80
+ <JsonBlock label="Output" value={node.outputData} />
81
+ </div>
82
+ );
83
+ }
@@ -0,0 +1,146 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import { cn } from '../../lib/utils';
5
+ import { StatusBadge } from '../badge/StatusBadge';
6
+ import { EmptyState } from '../states/EmptyState';
7
+ import type { NodeExecView } from './types';
8
+ import { NODE_STATUS_BADGE, formatDuration, isLiveStatus } from './exec-status';
9
+
10
+ export interface ExecutionTimelineProps {
11
+ /** Per-node execution overlays; rendered sorted by `executionOrder`. */
12
+ nodeExecutions: NodeExecView[];
13
+ /** Currently selected node id (row highlighted as active). */
14
+ activeNodeId?: string;
15
+ /** Called with a node id when its row is activated. */
16
+ onSelectNode?: (nodeId: string) => void;
17
+ className?: string;
18
+ }
19
+
20
+ function byExecutionOrder(a: NodeExecView, b: NodeExecView): number {
21
+ const ao = a.executionOrder ?? 0;
22
+ const bo = b.executionOrder ?? 0;
23
+ return ao - bo;
24
+ }
25
+
26
+ /**
27
+ * Vertical execution timeline: one row per node execution (ordered by
28
+ * `executionOrder`), each showing the node name, a status badge, the run
29
+ * duration, and inline error text for failed nodes. Running nodes show a
30
+ * pulsing live indicator; terminal nodes render statically.
31
+ *
32
+ * Read-only and app-agnostic — selection is surfaced via `onSelectNode`.
33
+ */
34
+ export function ExecutionTimeline({
35
+ nodeExecutions,
36
+ activeNodeId,
37
+ onSelectNode,
38
+ className,
39
+ }: ExecutionTimelineProps) {
40
+ const ordered = React.useMemo(
41
+ () => [...nodeExecutions].sort(byExecutionOrder),
42
+ [nodeExecutions]
43
+ );
44
+
45
+ if (ordered.length === 0) {
46
+ return (
47
+ <EmptyState
48
+ title="No node executions"
49
+ description="This run has not produced any node activity yet."
50
+ className={className}
51
+ />
52
+ );
53
+ }
54
+
55
+ return (
56
+ <ol className={cn('flex flex-col', className)} aria-label="Execution timeline">
57
+ {ordered.map((node) => {
58
+ const live = isLiveStatus(node.status);
59
+ const isActive = activeNodeId != null && node.nodeId === activeNodeId;
60
+ const duration = formatDuration(node.executionTimeMs);
61
+ const selectable = onSelectNode != null;
62
+
63
+ return (
64
+ <li
65
+ key={node.nodeId}
66
+ aria-current={isActive ? 'true' : undefined}
67
+ data-node-id={node.nodeId}
68
+ data-status={node.status}
69
+ className={cn(
70
+ 'group flex items-start gap-3 border-b border-border px-3 py-2.5 text-sm last:border-b-0',
71
+ selectable && 'cursor-pointer hover:bg-accent/50',
72
+ isActive && 'bg-accent'
73
+ )}
74
+ onClick={selectable ? () => onSelectNode!(node.nodeId) : undefined}
75
+ onKeyDown={
76
+ selectable
77
+ ? (e) => {
78
+ if (e.key === 'Enter' || e.key === ' ') {
79
+ e.preventDefault();
80
+ onSelectNode!(node.nodeId);
81
+ }
82
+ }
83
+ : undefined
84
+ }
85
+ tabIndex={selectable ? 0 : undefined}
86
+ >
87
+ {/* Status rail dot */}
88
+ <span className="relative mt-1 flex h-2.5 w-2.5 shrink-0 items-center justify-center">
89
+ {live ? (
90
+ <>
91
+ <span
92
+ data-live="true"
93
+ aria-hidden="true"
94
+ className="absolute inline-flex h-full w-full animate-ping rounded-full bg-blue-400 opacity-75"
95
+ />
96
+ <span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-blue-500" />
97
+ </>
98
+ ) : (
99
+ <span
100
+ className={cn(
101
+ 'inline-flex h-2.5 w-2.5 rounded-full',
102
+ node.status === 'success' && 'bg-green-500',
103
+ node.status === 'error' && 'bg-red-500',
104
+ node.status === 'skipped' && 'bg-gray-300',
105
+ node.status === 'pending' && 'bg-amber-400',
106
+ node.status === 'idle' && 'bg-gray-300'
107
+ )}
108
+ />
109
+ )}
110
+ </span>
111
+
112
+ <div className="min-w-0 flex-1">
113
+ <div className="flex items-center gap-2">
114
+ <span className="truncate font-medium text-foreground">
115
+ {node.name ?? node.nodeId}
116
+ </span>
117
+ {duration && (
118
+ <span className="ml-auto shrink-0 tabular-nums text-xs text-muted-foreground">
119
+ {duration}
120
+ </span>
121
+ )}
122
+ </div>
123
+ <div className="mt-1 flex items-center gap-2">
124
+ <StatusBadge
125
+ status={node.status}
126
+ config={NODE_STATUS_BADGE}
127
+ size="sm"
128
+ />
129
+ {node.type && (
130
+ <span className="truncate text-xs text-muted-foreground">
131
+ {node.type}
132
+ </span>
133
+ )}
134
+ </div>
135
+ {node.status === 'error' && node.error && (
136
+ <p className="mt-1.5 break-words text-xs text-red-600">
137
+ {node.error}
138
+ </p>
139
+ )}
140
+ </div>
141
+ </li>
142
+ );
143
+ })}
144
+ </ol>
145
+ );
146
+ }
@@ -0,0 +1,257 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import { cn } from '../../lib/utils';
5
+ import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs';
6
+ import { StatusBadge } from '../badge/StatusBadge';
7
+ import { EmptyState } from '../states/EmptyState';
8
+ import { NODE_STATUS_BADGE } from './exec-status';
9
+ import type { JsonSchema, NodeExecView, NodeTypeDef, WfNode } from './types';
10
+
11
+ export interface NodeInspectorProps {
12
+ /** The selected node, or null when nothing is selected. */
13
+ node: WfNode | null | undefined;
14
+ /** Node-type def supplying the parameter schema. */
15
+ nodeType?: NodeTypeDef;
16
+ /** Emitted with the node id + full next parameter object on any change. */
17
+ onParametersChange?: (nodeId: string, parameters: Record<string, unknown>) => void;
18
+ /** When present, adds read-only Input/Output exec tabs. */
19
+ execution?: NodeExecView;
20
+ className?: string;
21
+ }
22
+
23
+ function fieldId(nodeId: string, key: string): string {
24
+ return `nodeparam-${nodeId}-${key}`;
25
+ }
26
+
27
+ function SchemaField({
28
+ nodeId,
29
+ name,
30
+ schema,
31
+ value,
32
+ onChange,
33
+ }: {
34
+ nodeId: string;
35
+ name: string;
36
+ schema: JsonSchema;
37
+ value: unknown;
38
+ onChange: (next: unknown) => void;
39
+ }) {
40
+ const id = fieldId(nodeId, name);
41
+ const label = schema.title ?? name;
42
+
43
+ if (schema.enum && schema.enum.length > 0) {
44
+ return (
45
+ <div className="flex flex-col gap-1">
46
+ <label htmlFor={id} className="text-xs font-medium text-foreground">
47
+ {label}
48
+ </label>
49
+ <select
50
+ id={id}
51
+ value={String(value ?? '')}
52
+ onChange={(e) => onChange(e.target.value)}
53
+ className="rounded-md border border-border bg-background px-2 py-1.5 text-sm outline-none focus:ring-2 focus:ring-primary"
54
+ >
55
+ {schema.enum.map((opt) => (
56
+ <option key={String(opt)} value={String(opt)}>
57
+ {String(opt)}
58
+ </option>
59
+ ))}
60
+ </select>
61
+ </div>
62
+ );
63
+ }
64
+
65
+ if (schema.type === 'boolean') {
66
+ return (
67
+ <div className="flex items-center gap-2">
68
+ <input
69
+ id={id}
70
+ type="checkbox"
71
+ checked={Boolean(value)}
72
+ onChange={(e) => onChange(e.target.checked)}
73
+ className="h-4 w-4 rounded border-border"
74
+ />
75
+ <label htmlFor={id} className="text-xs font-medium text-foreground">
76
+ {label}
77
+ </label>
78
+ </div>
79
+ );
80
+ }
81
+
82
+ if (schema.type === 'number' || schema.type === 'integer') {
83
+ return (
84
+ <div className="flex flex-col gap-1">
85
+ <label htmlFor={id} className="text-xs font-medium text-foreground">
86
+ {label}
87
+ </label>
88
+ <input
89
+ id={id}
90
+ type="number"
91
+ value={value === undefined || value === null ? '' : Number(value)}
92
+ onChange={(e) =>
93
+ onChange(e.target.value === '' ? undefined : Number(e.target.value))
94
+ }
95
+ className="rounded-md border border-border bg-background px-2 py-1.5 text-sm outline-none focus:ring-2 focus:ring-primary"
96
+ />
97
+ </div>
98
+ );
99
+ }
100
+
101
+ // default: string
102
+ return (
103
+ <div className="flex flex-col gap-1">
104
+ <label htmlFor={id} className="text-xs font-medium text-foreground">
105
+ {label}
106
+ </label>
107
+ <input
108
+ id={id}
109
+ type="text"
110
+ value={value === undefined || value === null ? '' : String(value)}
111
+ onChange={(e) => onChange(e.target.value)}
112
+ className="rounded-md border border-border bg-background px-2 py-1.5 text-sm outline-none focus:ring-2 focus:ring-primary"
113
+ />
114
+ {schema.description && (
115
+ <p className="text-[11px] text-muted-foreground">{schema.description}</p>
116
+ )}
117
+ </div>
118
+ );
119
+ }
120
+
121
+ function JsonBlock({ value }: { value: Record<string, unknown> | null | undefined }) {
122
+ const isEmpty = value == null || Object.keys(value).length === 0;
123
+ if (isEmpty) {
124
+ return <p className="text-xs italic text-muted-foreground">No data.</p>;
125
+ }
126
+ return (
127
+ <pre className="overflow-auto rounded-md border border-border bg-muted/40 p-2 text-xs leading-relaxed">
128
+ {JSON.stringify(value, null, 2)}
129
+ </pre>
130
+ );
131
+ }
132
+
133
+ function ParamForm({
134
+ node,
135
+ schema,
136
+ onParametersChange,
137
+ }: {
138
+ node: WfNode;
139
+ schema: JsonSchema | undefined;
140
+ onParametersChange?: NodeInspectorProps['onParametersChange'];
141
+ }) {
142
+ const params = (node.parameters ?? {}) as Record<string, unknown>;
143
+ const properties = schema?.properties ?? {};
144
+ const keys = Object.keys(properties);
145
+
146
+ const handleChange = (key: string, next: unknown) => {
147
+ const updated = { ...params, [key]: next };
148
+ onParametersChange?.(node.id, updated);
149
+ };
150
+
151
+ if (keys.length === 0) {
152
+ return (
153
+ <p className="text-xs italic text-muted-foreground">
154
+ This node type has no configurable parameters.
155
+ </p>
156
+ );
157
+ }
158
+
159
+ return (
160
+ <div className="flex flex-col gap-3">
161
+ {keys.map((key) => (
162
+ <SchemaField
163
+ key={key}
164
+ nodeId={node.id}
165
+ name={key}
166
+ schema={properties[key]}
167
+ value={params[key]}
168
+ onChange={(next) => handleChange(key, next)}
169
+ />
170
+ ))}
171
+ </div>
172
+ );
173
+ }
174
+
175
+ /**
176
+ * Editor inspector panel for the selected node. Renders a JSON-Schema-driven
177
+ * read/write form from the node-type's `parameterSchema` (string/number/
178
+ * boolean/enum fields), emitting the full next `parameters` object via
179
+ * `onParametersChange`. When an {@link NodeExecView} is supplied, adds
180
+ * read-only Input/Output tabs alongside the parameters tab.
181
+ */
182
+ export function NodeInspector({
183
+ node,
184
+ nodeType,
185
+ onParametersChange,
186
+ execution,
187
+ className,
188
+ }: NodeInspectorProps) {
189
+ if (!node) {
190
+ return (
191
+ <EmptyState
192
+ title="No node selected"
193
+ description="Select a node on the canvas to edit its parameters."
194
+ className={className}
195
+ />
196
+ );
197
+ }
198
+
199
+ const header = (
200
+ <header className="flex flex-col gap-1 border-b border-border px-4 py-3">
201
+ <div className="flex items-center gap-2">
202
+ <h3 className="truncate text-sm font-semibold text-foreground">
203
+ {node.name}
204
+ </h3>
205
+ {execution && (
206
+ <StatusBadge
207
+ status={execution.status}
208
+ config={NODE_STATUS_BADGE}
209
+ size="sm"
210
+ />
211
+ )}
212
+ </div>
213
+ <span className="text-xs text-muted-foreground">{node.type}</span>
214
+ </header>
215
+ );
216
+
217
+ const paramForm = (
218
+ <div className="overflow-auto p-4">
219
+ <ParamForm
220
+ node={node}
221
+ schema={nodeType?.parameterSchema}
222
+ onParametersChange={onParametersChange}
223
+ />
224
+ </div>
225
+ );
226
+
227
+ if (!execution) {
228
+ return (
229
+ <div className={cn('flex h-full min-h-0 flex-col', className)}>
230
+ {header}
231
+ {paramForm}
232
+ </div>
233
+ );
234
+ }
235
+
236
+ return (
237
+ <div className={cn('flex h-full min-h-0 flex-col', className)}>
238
+ {header}
239
+ <Tabs defaultValue="params" className="flex min-h-0 flex-1 flex-col">
240
+ <TabsList className="mx-4 mt-3 self-start">
241
+ <TabsTrigger value="params">Parameters</TabsTrigger>
242
+ <TabsTrigger value="input">Input</TabsTrigger>
243
+ <TabsTrigger value="output">Output</TabsTrigger>
244
+ </TabsList>
245
+ <TabsContent value="params" className="min-h-0 flex-1">
246
+ {paramForm}
247
+ </TabsContent>
248
+ <TabsContent value="input" className="min-h-0 flex-1 overflow-auto p-4">
249
+ <JsonBlock value={execution.inputData} />
250
+ </TabsContent>
251
+ <TabsContent value="output" className="min-h-0 flex-1 overflow-auto p-4">
252
+ <JsonBlock value={execution.outputData} />
253
+ </TabsContent>
254
+ </Tabs>
255
+ </div>
256
+ );
257
+ }
@@ -0,0 +1,119 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import { cn } from '../../lib/utils';
5
+ import { getCategoryToken } from './theme/categories';
6
+ import { getCategoryIcon } from './node-icons';
7
+ import type { NodeTypeDef } from './types';
8
+
9
+ /** MIME-ish key used to carry a node-type slug across HTML5 drag-and-drop. */
10
+ export const NODE_DND_MIME = 'application/x-workflow-node';
11
+
12
+ export interface NodePaletteProps {
13
+ /** The node-type catalog to list. */
14
+ nodeTypes: NodeTypeDef[];
15
+ /** Optional click handler (e.g. add node at canvas center). */
16
+ onAddNode?: (slug: string) => void;
17
+ className?: string;
18
+ }
19
+
20
+ function matches(nt: NodeTypeDef, query: string): boolean {
21
+ if (!query) return true;
22
+ const q = query.toLowerCase();
23
+ return (
24
+ nt.name.toLowerCase().includes(q) ||
25
+ nt.slug.toLowerCase().includes(q) ||
26
+ nt.category.toLowerCase().includes(q) ||
27
+ (nt.description?.toLowerCase().includes(q) ?? false)
28
+ );
29
+ }
30
+
31
+ function groupByCategory(
32
+ nodeTypes: NodeTypeDef[]
33
+ ): { category: string; items: NodeTypeDef[] }[] {
34
+ const groups = new Map<string, NodeTypeDef[]>();
35
+ for (const nt of nodeTypes) {
36
+ const list = groups.get(nt.category) ?? [];
37
+ list.push(nt);
38
+ groups.set(nt.category, list);
39
+ }
40
+ return [...groups.entries()].map(([category, items]) => ({ category, items }));
41
+ }
42
+
43
+ /**
44
+ * Draggable palette of {@link NodeTypeDef}s, grouped by category with a search
45
+ * filter. Each item is HTML5-draggable: dragging sets the node-type slug on
46
+ * `dataTransfer` under {@link NODE_DND_MIME} for the canvas to read on drop.
47
+ */
48
+ export function NodePalette({ nodeTypes, onAddNode, className }: NodePaletteProps) {
49
+ const [query, setQuery] = React.useState('');
50
+
51
+ const groups = React.useMemo(() => {
52
+ const filtered = nodeTypes.filter((nt) => matches(nt, query));
53
+ return groupByCategory(filtered);
54
+ }, [nodeTypes, query]);
55
+
56
+ return (
57
+ <div className={cn('flex h-full min-h-0 flex-col', className)}>
58
+ <div className="shrink-0 border-b border-border p-2">
59
+ <input
60
+ type="search"
61
+ role="searchbox"
62
+ aria-label="Filter node types"
63
+ placeholder="Search nodes…"
64
+ value={query}
65
+ onChange={(e) => setQuery(e.target.value)}
66
+ className="w-full rounded-md border border-border bg-background px-2 py-1.5 text-sm outline-none focus:ring-2 focus:ring-primary"
67
+ />
68
+ </div>
69
+
70
+ <div className="min-h-0 flex-1 overflow-auto p-2">
71
+ {groups.length === 0 ? (
72
+ <p className="px-1 py-4 text-center text-xs text-muted-foreground">
73
+ No matching nodes.
74
+ </p>
75
+ ) : (
76
+ groups.map(({ category, items }) => {
77
+ const token = getCategoryToken(category);
78
+ return (
79
+ <div key={category} className="mb-3">
80
+ <h4 className="mb-1 px-1 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
81
+ {token.label}
82
+ </h4>
83
+ <ul className="flex flex-col gap-1">
84
+ {items.map((nt) => {
85
+ const Icon = getCategoryIcon(token.icon);
86
+ return (
87
+ <li key={nt.slug}>
88
+ <div
89
+ draggable
90
+ onDragStart={(e) => {
91
+ e.dataTransfer.setData(NODE_DND_MIME, nt.slug);
92
+ e.dataTransfer.effectAllowed = 'move';
93
+ }}
94
+ onClick={() => onAddNode?.(nt.slug)}
95
+ className="flex cursor-grab items-center gap-2 rounded-md border border-border bg-card px-2 py-1.5 text-sm transition-colors hover:bg-accent active:cursor-grabbing"
96
+ title={nt.description ?? nt.name}
97
+ >
98
+ <span
99
+ className="flex h-6 w-6 shrink-0 items-center justify-center rounded"
100
+ style={{ background: token.background, color: token.color }}
101
+ >
102
+ <Icon className="h-3.5 w-3.5" aria-hidden="true" />
103
+ </span>
104
+ <span className="min-w-0 flex-1 truncate text-foreground">
105
+ {nt.name}
106
+ </span>
107
+ </div>
108
+ </li>
109
+ );
110
+ })}
111
+ </ul>
112
+ </div>
113
+ );
114
+ })
115
+ )}
116
+ </div>
117
+ </div>
118
+ );
119
+ }