@echothink-ui/task 0.1.0

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 (64) hide show
  1. package/README.md +5 -0
  2. package/dist/components/BackendThinkingChain.d.ts +2 -0
  3. package/dist/components/BlockingReasonPanel.d.ts +2 -0
  4. package/dist/components/DAGEdge.d.ts +2 -0
  5. package/dist/components/DAGLegend.d.ts +2 -0
  6. package/dist/components/DAGNode.d.ts +4 -0
  7. package/dist/components/DecisionRequiredPanel.d.ts +2 -0
  8. package/dist/components/HumanInterventionPanel.d.ts +2 -0
  9. package/dist/components/MobileTaskShell.d.ts +12 -0
  10. package/dist/components/TaskApprovalPanel.d.ts +2 -0
  11. package/dist/components/TaskCard.d.ts +2 -0
  12. package/dist/components/TaskDependencyList.d.ts +2 -0
  13. package/dist/components/TaskDetailPanel.d.ts +2 -0
  14. package/dist/components/TaskHandoffPanel.d.ts +2 -0
  15. package/dist/components/TaskProgressIndicator.d.ts +2 -0
  16. package/dist/components/TaskRetryPanel.d.ts +2 -0
  17. package/dist/components/TaskRunLog.d.ts +2 -0
  18. package/dist/components/TaskStatusBadge.d.ts +2 -0
  19. package/dist/components/TaskTable.d.ts +5 -0
  20. package/dist/components/TaskTimeline.d.ts +2 -0
  21. package/dist/components/TaskWaveDAG.d.ts +2 -0
  22. package/dist/components/TaskWaveHeader.d.ts +2 -0
  23. package/dist/components/TaskWaveTable.d.ts +2 -0
  24. package/dist/components/utils.d.ts +13 -0
  25. package/dist/index.cjs +2434 -0
  26. package/dist/index.cjs.map +1 -0
  27. package/dist/index.css +2402 -0
  28. package/dist/index.css.map +1 -0
  29. package/dist/index.d.ts +27 -0
  30. package/dist/index.js +2388 -0
  31. package/dist/index.js.map +1 -0
  32. package/dist/types.d.ts +249 -0
  33. package/package.json +45 -0
  34. package/src/components/BackendThinkingChain.tsx +129 -0
  35. package/src/components/BlockingReasonPanel.tsx +67 -0
  36. package/src/components/DAGEdge.tsx +97 -0
  37. package/src/components/DAGLegend.tsx +86 -0
  38. package/src/components/DAGNode.tsx +103 -0
  39. package/src/components/DecisionRequiredPanel.tsx +166 -0
  40. package/src/components/HumanInterventionPanel.tsx +82 -0
  41. package/src/components/MobileTaskShell.tsx +52 -0
  42. package/src/components/TaskApprovalPanel.tsx +159 -0
  43. package/src/components/TaskCard.tsx +71 -0
  44. package/src/components/TaskDependencyList.test.tsx +54 -0
  45. package/src/components/TaskDependencyList.tsx +105 -0
  46. package/src/components/TaskDetailPanel.test.tsx +49 -0
  47. package/src/components/TaskDetailPanel.tsx +139 -0
  48. package/src/components/TaskHandoffPanel.tsx +125 -0
  49. package/src/components/TaskProgressIndicator.tsx +70 -0
  50. package/src/components/TaskRetryPanel.test.tsx +29 -0
  51. package/src/components/TaskRetryPanel.tsx +103 -0
  52. package/src/components/TaskRunLog.tsx +156 -0
  53. package/src/components/TaskStatusBadge.tsx +29 -0
  54. package/src/components/TaskTable.tsx +294 -0
  55. package/src/components/TaskTimeline.tsx +98 -0
  56. package/src/components/TaskWaveDAG.tsx +202 -0
  57. package/src/components/TaskWaveHeader.tsx +82 -0
  58. package/src/components/TaskWaveTable.tsx +151 -0
  59. package/src/components/css.d.ts +1 -0
  60. package/src/components/utils.ts +116 -0
  61. package/src/index.test.tsx +316 -0
  62. package/src/index.tsx +90 -0
  63. package/src/styles.css +2889 -0
  64. package/src/types.ts +289 -0
@@ -0,0 +1,294 @@
1
+ import * as React from "react";
2
+ import clsx from "clsx";
3
+ import {
4
+ ActionGroup,
5
+ Badge,
6
+ EmptyState,
7
+ Select,
8
+ StatusDot,
9
+ type EthOperationalStatus,
10
+ type SelectOption
11
+ } from "@echothink-ui/core";
12
+ import { BulkActionTable, DataTable, type DataColumn } from "@echothink-ui/data";
13
+ import type { Task, TaskPriority, TaskTableProps } from "../types";
14
+ import { TaskStatusBadge } from "./TaskStatusBadge";
15
+ import { formatDateTime, labelForStatus, severityForPriority } from "./utils";
16
+
17
+ type TaskStatusFilter = EthOperationalStatus | "all";
18
+
19
+ const statusFilterOrder: EthOperationalStatus[] = [
20
+ "not-started",
21
+ "queued",
22
+ "in-progress",
23
+ "running",
24
+ "approval-required",
25
+ "pending-approval",
26
+ "blocked",
27
+ "failed",
28
+ "completed",
29
+ "succeeded",
30
+ "synced"
31
+ ];
32
+
33
+ const priorityLabels: Record<TaskPriority, string> = {
34
+ critical: "Critical",
35
+ high: "High",
36
+ low: "Low",
37
+ medium: "Medium"
38
+ };
39
+
40
+ function taskRowsFromItems(items: TaskTableProps["items"]): Task[] {
41
+ return (
42
+ items?.map((item) => ({
43
+ id: item.id,
44
+ title: String(item.label),
45
+ status: item.status ?? "not-started"
46
+ })) ?? []
47
+ );
48
+ }
49
+
50
+ function taskTableLabel(title?: React.ReactNode) {
51
+ return typeof title === "string" && title.trim() ? `${title} table` : "Task table";
52
+ }
53
+
54
+ function priorityCell(priority?: TaskPriority) {
55
+ if (!priority) return <span className="eth-task-table__empty-value">-</span>;
56
+
57
+ return (
58
+ <Badge
59
+ severity={severityForPriority(priority)}
60
+ className={`eth-task-table__priority eth-task-table__priority--${priority}`}
61
+ >
62
+ {priorityLabels[priority]}
63
+ </Badge>
64
+ );
65
+ }
66
+
67
+ function emptyCell(value?: React.ReactNode) {
68
+ if (value === undefined || value === null || value === "") {
69
+ return <span className="eth-task-table__empty-value">-</span>;
70
+ }
71
+
72
+ return value;
73
+ }
74
+
75
+ function taskStatusOptions(rows: Task[], activeStatus: TaskStatusFilter): SelectOption[] {
76
+ const counts = new Map<EthOperationalStatus, number>();
77
+ for (const task of rows) {
78
+ counts.set(task.status, (counts.get(task.status) ?? 0) + 1);
79
+ }
80
+
81
+ const activeOperationalStatus = activeStatus === "all" ? null : activeStatus;
82
+ const activeStatusWithoutRows = Boolean(
83
+ activeOperationalStatus && !counts.has(activeOperationalStatus)
84
+ );
85
+ const extraStatuses =
86
+ activeOperationalStatus &&
87
+ activeStatusWithoutRows &&
88
+ !statusFilterOrder.includes(activeOperationalStatus)
89
+ ? [activeOperationalStatus]
90
+ : [];
91
+ const sortedStatuses = [
92
+ ...statusFilterOrder.filter(
93
+ (status) =>
94
+ counts.has(status) || (activeStatusWithoutRows && status === activeOperationalStatus)
95
+ ),
96
+ ...Array.from(counts.keys()).filter((status) => !statusFilterOrder.includes(status)),
97
+ ...extraStatuses
98
+ ];
99
+
100
+ return [
101
+ { value: "all", label: `All statuses (${rows.length})` },
102
+ ...sortedStatuses.map((status) => ({
103
+ value: status,
104
+ label: `${labelForStatus(status)} (${counts.get(status) ?? 0})`
105
+ }))
106
+ ];
107
+ }
108
+
109
+ const defaultColumns: DataColumn<Task>[] = [
110
+ { key: "id", header: "ID", width: "5rem", sortable: true },
111
+ {
112
+ key: "title",
113
+ header: "Task",
114
+ width: "18rem",
115
+ sortable: true,
116
+ render: (task) => (
117
+ <span className="eth-task-table__task">
118
+ <strong className="eth-task-table__task-title">{task.title}</strong>
119
+ {task.description ? (
120
+ <span className="eth-task-table__task-description">{task.description}</span>
121
+ ) : null}
122
+ </span>
123
+ )
124
+ },
125
+ {
126
+ key: "status",
127
+ header: "Status",
128
+ width: "12rem",
129
+ sortable: true,
130
+ render: (task) => <TaskStatusBadge status={task.status} />
131
+ },
132
+ {
133
+ key: "priority",
134
+ header: "Priority",
135
+ width: "8rem",
136
+ sortable: true,
137
+ render: (task) => priorityCell(task.priority)
138
+ },
139
+ {
140
+ key: "assignee",
141
+ header: "Assignee",
142
+ width: "9rem",
143
+ render: (task) => emptyCell(task.assignee)
144
+ },
145
+ {
146
+ key: "dueAt",
147
+ header: "Due",
148
+ width: "13rem",
149
+ sortable: true,
150
+ render: (task) => (
151
+ <span className={task.dueAt ? "eth-task-table__date" : "eth-task-table__empty-value"}>
152
+ {formatDateTime(task.dueAt)}
153
+ </span>
154
+ )
155
+ }
156
+ ];
157
+
158
+ export function TaskTable({
159
+ tasks,
160
+ items,
161
+ density = "default",
162
+ selectable,
163
+ selectedRows,
164
+ onSelectionChange,
165
+ rowActions,
166
+ bulkActions,
167
+ columns,
168
+ showFilters,
169
+ statusFilter,
170
+ onStatusFilterChange,
171
+ className,
172
+ title,
173
+ description,
174
+ actions,
175
+ subtitle: _subtitle,
176
+ eyebrow: _eyebrow,
177
+ status: _status,
178
+ severity: _severity,
179
+ loading: _loading,
180
+ empty: _empty,
181
+ error: _error,
182
+ metadata: _metadata,
183
+ footer: _footer,
184
+ "aria-label": ariaLabel,
185
+ ...sectionProps
186
+ }: TaskTableProps) {
187
+ const rows = tasks ?? taskRowsFromItems(items);
188
+ const [internalStatusFilter, setInternalStatusFilter] =
189
+ React.useState<TaskStatusFilter>("all");
190
+ const effectiveStatusFilter = statusFilter ?? internalStatusFilter;
191
+ const filterVisible =
192
+ Boolean(showFilters) || statusFilter !== undefined || Boolean(onStatusFilterChange);
193
+ const filteredRows =
194
+ filterVisible && effectiveStatusFilter !== "all"
195
+ ? rows.filter((task) => task.status === effectiveStatusFilter)
196
+ : rows;
197
+ const statusOptions = React.useMemo(
198
+ () => taskStatusOptions(rows, effectiveStatusFilter),
199
+ [effectiveStatusFilter, rows]
200
+ );
201
+ const resolvedColumns = columns ?? defaultColumns;
202
+ const label = ariaLabel ?? taskTableLabel(title);
203
+ const emptyTitle = rows.length && filterVisible ? "No matching tasks" : (title ?? "No tasks");
204
+ const emptyDescription =
205
+ rows.length && filterVisible
206
+ ? "No tasks match the selected status filter."
207
+ : (description ?? "There are no tasks to display.");
208
+ const selectEnabled = selectable ?? Boolean(bulkActions?.length);
209
+ const handleStatusFilterChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
210
+ const nextStatus = event.currentTarget.value as TaskStatusFilter;
211
+ if (statusFilter === undefined) setInternalStatusFilter(nextStatus);
212
+ onStatusFilterChange?.(nextStatus);
213
+ };
214
+
215
+ const table = (
216
+ bulkActions?.length ? (
217
+ <BulkActionTable<Task>
218
+ className={clsx("eth-task-table", className)}
219
+ rows={filteredRows}
220
+ columns={resolvedColumns}
221
+ rowKey="id"
222
+ density={density}
223
+ selectable={selectEnabled}
224
+ selectedRows={selectedRows}
225
+ onSelectionChange={onSelectionChange}
226
+ rowActions={rowActions}
227
+ bulkActions={bulkActions}
228
+ emptyState={<EmptyState title={emptyTitle} description={emptyDescription} />}
229
+ aria-label={label}
230
+ data-eth-component="TaskTable"
231
+ />
232
+ ) : (
233
+ <DataTable<Task>
234
+ className={clsx("eth-task-table", className)}
235
+ rows={filteredRows}
236
+ columns={resolvedColumns}
237
+ rowKey="id"
238
+ density={density}
239
+ selectable={selectable}
240
+ selectedRows={selectedRows}
241
+ onSelectionChange={onSelectionChange}
242
+ rowActions={rowActions}
243
+ emptyState={<EmptyState title={emptyTitle} description={emptyDescription} />}
244
+ aria-label={label}
245
+ data-eth-component="TaskTable"
246
+ />
247
+ )
248
+ );
249
+ const hasHeader = Boolean(title || description || actions?.length);
250
+
251
+ if (!hasHeader && !filterVisible) return table;
252
+
253
+ return (
254
+ <section
255
+ {...sectionProps}
256
+ className="eth-task-table-shell"
257
+ aria-label={typeof title === "string" ? title : "Tasks"}
258
+ >
259
+ {hasHeader ? (
260
+ <header className="eth-task-table-shell__header">
261
+ <div className="eth-task-table-shell__heading">
262
+ {title ? <h2>{title}</h2> : null}
263
+ {description ? <p>{description}</p> : null}
264
+ </div>
265
+ <ActionGroup actions={actions} />
266
+ </header>
267
+ ) : null}
268
+ {filterVisible ? (
269
+ <div className="eth-task-table-shell__toolbar" aria-label="Task table filters">
270
+ <div className="eth-task-table-shell__filters">
271
+ <Select
272
+ className="eth-task-table-shell__filter"
273
+ density="compact"
274
+ labelText="Status"
275
+ options={statusOptions}
276
+ value={effectiveStatusFilter}
277
+ onChange={handleStatusFilterChange}
278
+ />
279
+ </div>
280
+ <span className="eth-task-table-shell__summary">
281
+ {filteredRows.length === rows.length
282
+ ? `${rows.length} ${rows.length === 1 ? "task" : "tasks"} shown`
283
+ : `${filteredRows.length} of ${rows.length} tasks shown`}
284
+ </span>
285
+ </div>
286
+ ) : null}
287
+ {table}
288
+ </section>
289
+ );
290
+ }
291
+
292
+ export function StatusCell({ task }: { task: Task }) {
293
+ return <StatusDot status={task.status} label={labelForStatus(task.status)} />;
294
+ }
@@ -0,0 +1,98 @@
1
+ import * as React from "react";
2
+ import clsx from "clsx";
3
+ import { Badge, EmptyState, type EthSeverity } from "@echothink-ui/core";
4
+ import type { TaskTimelineEvent, TaskTimelineProps } from "../types";
5
+ import { formatDateTime } from "./utils";
6
+
7
+ const eventKindMeta: Record<TaskTimelineEvent["kind"], { label: string; tone: EthSeverity }> = {
8
+ status_change: { label: "Status", tone: "info" },
9
+ comment: { label: "Comment", tone: "neutral" },
10
+ assignment: { label: "Assignment", tone: "info" },
11
+ attachment: { label: "Attachment", tone: "success" },
12
+ system: { label: "System", tone: "neutral" }
13
+ };
14
+
15
+ function timestampMeta(value: string | Date) {
16
+ const date = value instanceof Date ? value : new Date(value);
17
+ if (Number.isNaN(date.valueOf())) {
18
+ const fallback = String(value);
19
+ return { dateTime: fallback, label: fallback };
20
+ }
21
+
22
+ return {
23
+ dateTime: date.toISOString(),
24
+ label: formatDateTime(date)
25
+ };
26
+ }
27
+
28
+ export function TaskTimeline({
29
+ events = [],
30
+ className,
31
+ role,
32
+ "aria-label": ariaLabel,
33
+ ...props
34
+ }: TaskTimelineProps) {
35
+ if (!events.length) {
36
+ return (
37
+ <div
38
+ {...props}
39
+ className={clsx("eth-task-timeline", className)}
40
+ data-eth-component="TaskTimeline"
41
+ role={role ?? "region"}
42
+ aria-label={ariaLabel ?? "Task timeline"}
43
+ >
44
+ <EmptyState
45
+ title="No task history"
46
+ description="Task activity and state changes appear here."
47
+ />
48
+ </div>
49
+ );
50
+ }
51
+
52
+ return (
53
+ <div
54
+ {...props}
55
+ className={clsx("eth-task-timeline", className)}
56
+ data-eth-component="TaskTimeline"
57
+ role={role ?? "region"}
58
+ aria-label={ariaLabel ?? "Task timeline"}
59
+ >
60
+ <ol className="eth-task-timeline__list">
61
+ {events.map((event) => {
62
+ const kind = eventKindMeta[event.kind];
63
+ const time = timestampMeta(event.timestamp);
64
+
65
+ return (
66
+ <li
67
+ key={event.id}
68
+ className={clsx(
69
+ "eth-task-timeline__event",
70
+ `eth-task-timeline__event--${event.kind}`
71
+ )}
72
+ data-kind={event.kind}
73
+ >
74
+ <time className="eth-task-timeline__time" dateTime={time.dateTime}>
75
+ {time.label}
76
+ </time>
77
+ <div className="eth-task-timeline__rail" aria-hidden>
78
+ <span className="eth-task-timeline__marker" />
79
+ </div>
80
+ <article className="eth-task-timeline__body">
81
+ <header className="eth-task-timeline__event-header">
82
+ <Badge severity={kind.tone}>{kind.label}</Badge>
83
+ {event.actor ? (
84
+ <span className="eth-task-timeline__actor">By {event.actor}</span>
85
+ ) : null}
86
+ </header>
87
+ <strong className="eth-task-timeline__summary">{event.summary}</strong>
88
+ {event.details ? (
89
+ <p className="eth-task-timeline__details">{event.details}</p>
90
+ ) : null}
91
+ </article>
92
+ </li>
93
+ );
94
+ })}
95
+ </ol>
96
+ </div>
97
+ );
98
+ }
@@ -0,0 +1,202 @@
1
+ import * as React from "react";
2
+ import clsx from "clsx";
3
+ import { EmptyState, Surface } from "@echothink-ui/core";
4
+ import type {
5
+ DAGEdgeModel,
6
+ DAGNodeModel,
7
+ PositionedDAGNode,
8
+ TaskWaveDAGProps
9
+ } from "../types";
10
+ import { DAGEdge } from "./DAGEdge";
11
+ import { DAGLegend } from "./DAGLegend";
12
+ import { DAGNode, DAG_NODE_HEIGHT, DAG_NODE_WIDTH } from "./DAGNode";
13
+
14
+ const rankSpacing = DAG_NODE_WIDTH + 76;
15
+ const rowSpacing = DAG_NODE_HEIGHT + 48;
16
+ const originX = DAG_NODE_WIDTH / 2 + 28;
17
+ const originY = DAG_NODE_HEIGHT / 2 + 48;
18
+ const framePaddingX = 32;
19
+ const framePaddingY = 28;
20
+
21
+ interface DAGFrame {
22
+ x: number;
23
+ y: number;
24
+ width: number;
25
+ height: number;
26
+ viewBox: string;
27
+ }
28
+
29
+ function layoutNodes(
30
+ nodes: DAGNodeModel[],
31
+ edges: DAGEdgeModel[],
32
+ layout: "lr" | "tb"
33
+ ): PositionedDAGNode[] {
34
+ const ids = new Set(nodes.map((node) => node.id));
35
+ const ranks = new Map(nodes.map((node) => [node.id, 0]));
36
+ const validEdges = edges.filter((edge) => ids.has(edge.from) && ids.has(edge.to));
37
+
38
+ for (let pass = 0; pass < nodes.length; pass += 1) {
39
+ for (const edge of validEdges) {
40
+ const fromRank = ranks.get(edge.from) ?? 0;
41
+ const toRank = ranks.get(edge.to) ?? 0;
42
+ if (toRank < fromRank + 1) ranks.set(edge.to, fromRank + 1);
43
+ }
44
+ }
45
+
46
+ const groups = new Map<number, DAGNodeModel[]>();
47
+ for (const node of nodes) {
48
+ const rank = ranks.get(node.id) ?? 0;
49
+ groups.set(rank, [...(groups.get(rank) ?? []), node]);
50
+ }
51
+
52
+ return nodes.map((node) => {
53
+ if (typeof node.x === "number" && typeof node.y === "number") {
54
+ return { ...node, x: node.x, y: node.y };
55
+ }
56
+ const rank = ranks.get(node.id) ?? 0;
57
+ const group = groups.get(rank) ?? [];
58
+ const index = group.findIndex((candidate) => candidate.id === node.id);
59
+ const spread = Math.max(0, index) * rowSpacing + originY;
60
+ const ranked = rank * rankSpacing + originX;
61
+ return layout === "lr"
62
+ ? { ...node, x: ranked, y: spread }
63
+ : { ...node, x: spread, y: ranked };
64
+ });
65
+ }
66
+
67
+ function frameFor(nodes: PositionedDAGNode[]): DAGFrame {
68
+ if (!nodes.length) {
69
+ return { x: 0, y: 0, width: 640, height: 180, viewBox: "0 0 640 180" };
70
+ }
71
+
72
+ const minX =
73
+ Math.min(...nodes.map((node) => node.x - DAG_NODE_WIDTH / 2)) - framePaddingX;
74
+ const minY =
75
+ Math.min(...nodes.map((node) => node.y - DAG_NODE_HEIGHT / 2)) - framePaddingY;
76
+ const maxX =
77
+ Math.max(...nodes.map((node) => node.x + DAG_NODE_WIDTH / 2)) + framePaddingX;
78
+ const maxY =
79
+ Math.max(...nodes.map((node) => node.y + DAG_NODE_HEIGHT / 2)) + framePaddingY;
80
+ const width = Math.max(360, maxX - minX);
81
+ const height = Math.max(120, maxY - minY);
82
+
83
+ return { x: minX, y: minY, width, height, viewBox: `${minX} ${minY} ${width} ${height}` };
84
+ }
85
+
86
+ function edgeEndpoints(
87
+ from: PositionedDAGNode,
88
+ to: PositionedDAGNode,
89
+ layout: "lr" | "tb"
90
+ ) {
91
+ if (layout === "lr") {
92
+ return {
93
+ from: { x: from.x + DAG_NODE_WIDTH / 2, y: from.y },
94
+ to: { x: to.x - DAG_NODE_WIDTH / 2, y: to.y }
95
+ };
96
+ }
97
+ return {
98
+ from: { x: from.x, y: from.y + DAG_NODE_HEIGHT / 2 },
99
+ to: { x: to.x, y: to.y - DAG_NODE_HEIGHT / 2 }
100
+ };
101
+ }
102
+
103
+ export function TaskWaveDAG({
104
+ nodes = [],
105
+ edges = [],
106
+ layout = "lr",
107
+ onNodeSelect,
108
+ selectedNodeId,
109
+ className,
110
+ title,
111
+ description,
112
+ ...props
113
+ }: TaskWaveDAGProps) {
114
+ const positionedNodes = React.useMemo(
115
+ () => layoutNodes(nodes, edges, layout),
116
+ [nodes, edges, layout]
117
+ );
118
+ const nodeMap = React.useMemo(
119
+ () => new Map(positionedNodes.map((node) => [node.id, node])),
120
+ [positionedNodes]
121
+ );
122
+ const visibleStatuses = React.useMemo(
123
+ () => Array.from(new Set(positionedNodes.map((node) => node.status))),
124
+ [positionedNodes]
125
+ );
126
+ const graphFrame = React.useMemo(() => frameFor(positionedNodes), [positionedNodes]);
127
+ const graphTitleId = React.useId();
128
+ const graphDescriptionId = React.useId();
129
+ const graphTitle =
130
+ typeof title === "string" && title.trim() ? title : "Task wave dependency graph";
131
+ const graphDescription = `${positionedNodes.length} nodes and ${edges.length} dependencies. Statuses: ${
132
+ visibleStatuses.map((status) => status.replace(/-/g, " ")).join(", ") || "none"
133
+ }.`;
134
+
135
+ return (
136
+ <Surface
137
+ {...props}
138
+ className={clsx("eth-task-wave-dag", className)}
139
+ title={title ?? "Wave DAG"}
140
+ description={description}
141
+ data-eth-component="TaskWaveDAG"
142
+ >
143
+ {positionedNodes.length ? (
144
+ <>
145
+ <div className="eth-task-wave-dag__viewport">
146
+ <svg
147
+ className="eth-dag"
148
+ role="img"
149
+ aria-labelledby={graphTitleId}
150
+ aria-describedby={graphDescriptionId}
151
+ viewBox={graphFrame.viewBox}
152
+ >
153
+ <title id={graphTitleId}>{graphTitle}</title>
154
+ <desc id={graphDescriptionId}>{graphDescription}</desc>
155
+ <rect
156
+ className="eth-dag__canvas"
157
+ x={graphFrame.x}
158
+ y={graphFrame.y}
159
+ width={graphFrame.width}
160
+ height={graphFrame.height}
161
+ aria-hidden="true"
162
+ />
163
+ {edges.map((edge, index) => {
164
+ const from = nodeMap.get(edge.from);
165
+ const to = nodeMap.get(edge.to);
166
+ if (!from || !to) return null;
167
+ const endpoints = edgeEndpoints(from, to, layout);
168
+ return (
169
+ <DAGEdge
170
+ key={`${edge.from}-${edge.to}-${index}`}
171
+ from={endpoints.from}
172
+ to={endpoints.to}
173
+ status={edge.status ?? to.status}
174
+ label={edge.label}
175
+ />
176
+ );
177
+ })}
178
+ {positionedNodes.map((node) => (
179
+ <DAGNode
180
+ key={node.id}
181
+ node={node}
182
+ selected={node.id === selectedNodeId}
183
+ onSelect={onNodeSelect}
184
+ />
185
+ ))}
186
+ </svg>
187
+ </div>
188
+ <DAGLegend statuses={visibleStatuses.length ? visibleStatuses : undefined} />
189
+ <ol className="eth-task-wave-dag__fallback">
190
+ {positionedNodes.map((node) => (
191
+ <li key={node.id}>
192
+ {node.label}, {node.status.replace(/-/g, " ")}
193
+ </li>
194
+ ))}
195
+ </ol>
196
+ </>
197
+ ) : (
198
+ <EmptyState title="No DAG nodes" description="Add nodes and edges to render a wave graph." />
199
+ )}
200
+ </Surface>
201
+ );
202
+ }
@@ -0,0 +1,82 @@
1
+ import * as React from "react";
2
+ import clsx from "clsx";
3
+ import { Badge, Surface } from "@echothink-ui/core";
4
+ import type { EthOperationalStatus } from "@echothink-ui/core";
5
+ import type { TaskWaveHeaderProps } from "../types";
6
+ import { TaskProgressIndicator } from "./TaskProgressIndicator";
7
+ import {
8
+ formatDateTime,
9
+ labelForStatus,
10
+ severityForStatus,
11
+ waveProgress
12
+ } from "./utils";
13
+
14
+ const statusOrder: EthOperationalStatus[] = [
15
+ "queued",
16
+ "running",
17
+ "in-progress",
18
+ "pending-approval",
19
+ "approval-required",
20
+ "blocked",
21
+ "failed",
22
+ "completed",
23
+ "succeeded"
24
+ ];
25
+
26
+ export function TaskWaveHeader({
27
+ wave,
28
+ actions,
29
+ className,
30
+ title,
31
+ description,
32
+ ...props
33
+ }: TaskWaveHeaderProps) {
34
+ const progress = waveProgress(wave);
35
+
36
+ return (
37
+ <Surface
38
+ {...props}
39
+ className={clsx("eth-task-wave-header", className)}
40
+ title={wave?.label ?? title ?? "Task wave"}
41
+ subtitle={wave?.id}
42
+ description={description}
43
+ actions={actions}
44
+ data-eth-component="TaskWaveHeader"
45
+ >
46
+ {wave ? (
47
+ <>
48
+ <div className="eth-task-wave-header__summary">
49
+ <strong>{wave.totalTasks} tasks</strong>
50
+ {wave.startedAt ? <span>Started {formatDateTime(wave.startedAt)}</span> : null}
51
+ {wave.eta ? <span>ETA {formatDateTime(wave.eta)}</span> : null}
52
+ </div>
53
+ <TaskProgressIndicator value={progress} label="Wave progress" />
54
+ <div className="eth-task-wave-header__counts" aria-label="Task status counts">
55
+ {statusOrder
56
+ .filter((status) => wave.statuses[status])
57
+ .map((status) => {
58
+ const label = labelForStatus(status);
59
+ const count = wave.statuses[status] ?? 0;
60
+
61
+ return (
62
+ <Badge
63
+ key={status}
64
+ severity={severityForStatus(status)}
65
+ className={clsx(
66
+ "eth-task-wave-header__count",
67
+ `eth-task-wave-header__count--${status}`
68
+ )}
69
+ aria-label={`${label}: ${count}`}
70
+ >
71
+ {label}: <span className="eth-task-wave-header__count-value">{count}</span>
72
+ </Badge>
73
+ );
74
+ })}
75
+ </div>
76
+ </>
77
+ ) : (
78
+ <TaskProgressIndicator value={0} label="Wave progress" />
79
+ )}
80
+ </Surface>
81
+ );
82
+ }