@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
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@echothink-ui/task",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "type": "module",
6
+ "sideEffects": [
7
+ "**/*.css"
8
+ ],
9
+ "main": "./dist/index.cjs",
10
+ "module": "./dist/index.js",
11
+ "types": "./dist/index.d.ts",
12
+ "exports": {
13
+ ".": {
14
+ "types": "./dist/index.d.ts",
15
+ "import": "./dist/index.js",
16
+ "require": "./dist/index.cjs"
17
+ },
18
+ "./styles.css": "./src/styles.css"
19
+ },
20
+ "files": [
21
+ "dist",
22
+ "src",
23
+ "README.md"
24
+ ],
25
+ "peerDependencies": {
26
+ "react": ">=18.3.0",
27
+ "react-dom": ">=18.3.0"
28
+ },
29
+ "dependencies": {
30
+ "clsx": "^2.1.1",
31
+ "@echothink-ui/core": "0.2.0",
32
+ "@echothink-ui/icons": "0.2.0",
33
+ "@echothink-ui/data": "0.2.0",
34
+ "@echothink-ui/motion": "0.1.0"
35
+ },
36
+ "publishConfig": {
37
+ "access": "public"
38
+ },
39
+ "scripts": {
40
+ "build": "tsup src/index.tsx --format esm,cjs --sourcemap --clean --external react --external react-dom && tsc -p tsconfig.json --declaration --emitDeclarationOnly --noEmit false --outDir dist",
41
+ "typecheck": "tsc -p tsconfig.json --noEmit",
42
+ "test": "vitest run --config ../../vitest.config.ts --passWithNoTests",
43
+ "lint": "eslint src"
44
+ }
45
+ }
@@ -0,0 +1,129 @@
1
+ import * as React from "react";
2
+ import clsx from "clsx";
3
+ import { Badge, EmptyState, StatusDot, Tag } from "@echothink-ui/core";
4
+ import type { BackendThinkingStep, BackendThinkingChainProps } from "../types";
5
+ import { formatDuration, labelForStatus } from "./utils";
6
+
7
+ const STREAMING_STATUSES = new Set<BackendThinkingStep["status"]>(["running", "in-progress"]);
8
+
9
+ function flattenSteps(steps: BackendThinkingStep[]): BackendThinkingStep[] {
10
+ return steps.flatMap((step) => [step, ...flattenSteps(step.children ?? [])]);
11
+ }
12
+
13
+ function currentStreamingStepId(steps: BackendThinkingStep[]) {
14
+ const flattened = flattenSteps(steps);
15
+
16
+ for (let index = flattened.length - 1; index >= 0; index -= 1) {
17
+ if (STREAMING_STATUSES.has(flattened[index].status)) {
18
+ return flattened[index].id;
19
+ }
20
+ }
21
+
22
+ return flattened.at(-1)?.id;
23
+ }
24
+
25
+ function ThinkingStepItem({
26
+ step,
27
+ streamingStepId
28
+ }: {
29
+ step: BackendThinkingStep;
30
+ streamingStepId?: string;
31
+ }) {
32
+ const isStreaming = streamingStepId === step.id;
33
+ const hasChildren = Boolean(step.children?.length);
34
+ const [expanded, setExpanded] = React.useState(true);
35
+
36
+ return (
37
+ <li
38
+ className={clsx(
39
+ "eth-task-thinking-chain__item",
40
+ `eth-task-thinking-chain__item--${step.status}`,
41
+ step.redacted && "eth-task-thinking-chain__item--redacted"
42
+ )}
43
+ >
44
+ <span className="eth-task-thinking-chain__marker" aria-hidden="true" />
45
+ <details
46
+ className="eth-task-thinking-chain__step"
47
+ open={expanded}
48
+ onToggle={(event) => {
49
+ setExpanded(event.currentTarget.open);
50
+ }}
51
+ >
52
+ <summary className="eth-task-thinking-chain__step-header">
53
+ <span className="eth-task-thinking-chain__disclosure-icon" aria-hidden="true" />
54
+ <span className="eth-task-thinking-chain__step-header-inner">
55
+ <span className="eth-task-thinking-chain__step-heading">
56
+ <strong className="eth-task-thinking-chain__step-title">
57
+ {step.redacted ? "Restricted step" : step.title}
58
+ </strong>
59
+ {hasChildren ? (
60
+ <span className="eth-task-thinking-chain__branch-count">
61
+ {step.children?.length} {step.children?.length === 1 ? "substep" : "substeps"}
62
+ </span>
63
+ ) : null}
64
+ {isStreaming ? (
65
+ <span className="eth-task-thinking-chain__streaming" aria-label="Streaming update">
66
+ <span className="eth-task-thinking-chain__pulse" aria-hidden="true" />
67
+ Streaming
68
+ </span>
69
+ ) : null}
70
+ </span>
71
+ <span className="eth-task-thinking-chain__step-meta">
72
+ {step.redacted ? <Badge severity="warning">Redacted</Badge> : null}
73
+ <StatusDot status={step.status} label={labelForStatus(step.status)} />
74
+ {step.toolName ? <Tag>{step.toolName}</Tag> : null}
75
+ {step.durationMs != null ? (
76
+ <span className="eth-task-thinking-chain__duration">
77
+ {formatDuration(step.durationMs)}
78
+ </span>
79
+ ) : null}
80
+ </span>
81
+ </span>
82
+ </summary>
83
+ {step.redacted ? (
84
+ <p className="eth-task-thinking-chain__redacted">Details are hidden by policy.</p>
85
+ ) : step.summary ? (
86
+ <p className="eth-task-thinking-chain__summary">{step.summary}</p>
87
+ ) : null}
88
+ {hasChildren ? (
89
+ <ol className="eth-task-thinking-chain__children">
90
+ {step.children?.map((child) => (
91
+ <ThinkingStepItem key={child.id} step={child} streamingStepId={streamingStepId} />
92
+ ))}
93
+ </ol>
94
+ ) : null}
95
+ </details>
96
+ </li>
97
+ );
98
+ }
99
+
100
+ export function BackendThinkingChain({
101
+ steps = [],
102
+ streaming,
103
+ className,
104
+ ...props
105
+ }: BackendThinkingChainProps) {
106
+ const streamingStepId = React.useMemo(
107
+ () => (streaming ? currentStreamingStepId(steps) : undefined),
108
+ [steps, streaming]
109
+ );
110
+
111
+ return (
112
+ <section
113
+ {...props}
114
+ className={clsx("eth-task-thinking-chain", className)}
115
+ aria-live={streaming ? "polite" : undefined}
116
+ data-eth-component="BackendThinkingChain"
117
+ >
118
+ {steps.length ? (
119
+ <ol className="eth-task-thinking-chain__list">
120
+ {steps.map((step) => (
121
+ <ThinkingStepItem key={step.id} step={step} streamingStepId={streamingStepId} />
122
+ ))}
123
+ </ol>
124
+ ) : (
125
+ <EmptyState title="No backend steps" />
126
+ )}
127
+ </section>
128
+ );
129
+ }
@@ -0,0 +1,67 @@
1
+ import * as React from "react";
2
+ import clsx from "clsx";
3
+ import { ActionGroup, Badge, EmptyState, Surface } from "@echothink-ui/core";
4
+ import type { BlockingReasonPanelProps } from "../types";
5
+
6
+ export function BlockingReasonPanel({
7
+ blockers = [],
8
+ className,
9
+ title = "Blocking reasons",
10
+ description,
11
+ severity,
12
+ status,
13
+ ...props
14
+ }: BlockingReasonPanelProps) {
15
+ const hasBlockers = blockers.length > 0;
16
+
17
+ return (
18
+ <Surface
19
+ {...props}
20
+ className={clsx("eth-task-blocking-reason-panel", className)}
21
+ title={title}
22
+ description={description}
23
+ status={status ?? (hasBlockers ? "blocked" : undefined)}
24
+ severity={severity ?? (hasBlockers ? "danger" : undefined)}
25
+ data-eth-component="BlockingReasonPanel"
26
+ >
27
+ {hasBlockers ? (
28
+ <ul className="eth-task-blocking-reason-panel__list" aria-label="Active blocking reasons">
29
+ {blockers.map((blocker) => {
30
+ const resolveActions = blocker.resolveAction
31
+ ? [
32
+ {
33
+ ...blocker.resolveAction,
34
+ intent: blocker.resolveAction.intent ?? "tertiary"
35
+ }
36
+ ]
37
+ : undefined;
38
+
39
+ return (
40
+ <li key={blocker.id} className="eth-task-blocking-reason-panel__item">
41
+ <div className="eth-task-blocking-reason-panel__content">
42
+ <strong className="eth-task-blocking-reason-panel__reason">
43
+ {blocker.reason}
44
+ </strong>
45
+ <div className="eth-task-blocking-reason-panel__meta">
46
+ <Badge severity="danger">Blocked</Badge>
47
+ {blocker.ownerActor ? <span>Owner: {blocker.ownerActor}</span> : null}
48
+ </div>
49
+ </div>
50
+ {resolveActions ? (
51
+ <div className="eth-task-blocking-reason-panel__actions">
52
+ <ActionGroup actions={resolveActions} />
53
+ </div>
54
+ ) : null}
55
+ </li>
56
+ );
57
+ })}
58
+ </ul>
59
+ ) : (
60
+ <EmptyState
61
+ title="No blocking reasons"
62
+ description="This task can continue without unblock actions."
63
+ />
64
+ )}
65
+ </Surface>
66
+ );
67
+ }
@@ -0,0 +1,97 @@
1
+ import * as React from "react";
2
+ import clsx from "clsx";
3
+ import type { DAGEdgeProps } from "../types";
4
+ import { labelForStatus, statusColor } from "./utils";
5
+
6
+ function markerIdFromUseId(id: string) {
7
+ return `eth-dag-arrow-${id.replace(/[^a-zA-Z0-9_-]/g, "")}`;
8
+ }
9
+
10
+ export function DAGEdge({
11
+ from,
12
+ to,
13
+ status,
14
+ label,
15
+ className,
16
+ "aria-label": ariaLabel,
17
+ ...props
18
+ }: DAGEdgeProps) {
19
+ const reactId = React.useId();
20
+ const markerId = markerIdFromUseId(reactId);
21
+ const horizontal = Math.abs(to.x - from.x) >= Math.abs(to.y - from.y);
22
+ const controlA = horizontal
23
+ ? { x: (from.x + to.x) / 2, y: from.y }
24
+ : { x: from.x, y: (from.y + to.y) / 2 };
25
+ const controlB = horizontal
26
+ ? { x: (from.x + to.x) / 2, y: to.y }
27
+ : { x: to.x, y: (from.y + to.y) / 2 };
28
+ const d = `M ${from.x} ${from.y} C ${controlA.x} ${controlA.y}, ${controlB.x} ${controlB.y}, ${to.x} ${to.y}`;
29
+ const color = statusColor(status);
30
+ const midpoint = { x: (from.x + to.x) / 2, y: (from.y + to.y) / 2 };
31
+ const labelText = label?.trim();
32
+ const labelWidth = labelText ? Math.max(56, labelText.length * 7 + 20) : 0;
33
+ const labelPosition = horizontal
34
+ ? { x: midpoint.x, y: midpoint.y - 16 }
35
+ : { x: midpoint.x + 44, y: midpoint.y };
36
+ const statusText = status ? labelForStatus(status) : "Neutral";
37
+ const accessibleLabel =
38
+ typeof ariaLabel === "string" && ariaLabel.trim()
39
+ ? ariaLabel
40
+ : labelText
41
+ ? `${labelText} dependency edge, ${statusText}`
42
+ : `Dependency edge, ${statusText}`;
43
+
44
+ return (
45
+ <g
46
+ className={clsx("eth-dag-edge", status && `eth-dag-edge--${status}`, className)}
47
+ role="group"
48
+ aria-label={accessibleLabel}
49
+ >
50
+ <defs>
51
+ <marker
52
+ id={markerId}
53
+ markerWidth="10"
54
+ markerHeight="10"
55
+ refX="9"
56
+ refY="3"
57
+ orient="auto"
58
+ markerUnits="strokeWidth"
59
+ >
60
+ <path d="M0,0 L0,6 L9,3 z" fill={color} />
61
+ </marker>
62
+ </defs>
63
+ <path
64
+ {...props}
65
+ d={d}
66
+ fill="none"
67
+ stroke={color}
68
+ strokeLinecap="round"
69
+ strokeLinejoin="round"
70
+ strokeWidth={2}
71
+ markerEnd={`url(#${markerId})`}
72
+ vectorEffect="non-scaling-stroke"
73
+ aria-hidden="true"
74
+ data-eth-component="DAGEdge"
75
+ />
76
+ {labelText ? (
77
+ <g className="eth-dag-edge__label" aria-hidden="true">
78
+ <rect
79
+ x={labelPosition.x - labelWidth / 2}
80
+ y={labelPosition.y - 11}
81
+ width={labelWidth}
82
+ height={22}
83
+ rx={0}
84
+ />
85
+ <text
86
+ x={labelPosition.x}
87
+ y={labelPosition.y}
88
+ textAnchor="middle"
89
+ dominantBaseline="middle"
90
+ >
91
+ {labelText}
92
+ </text>
93
+ </g>
94
+ ) : null}
95
+ </g>
96
+ );
97
+ }
@@ -0,0 +1,86 @@
1
+ import * as React from "react";
2
+ import clsx from "clsx";
3
+ import type { EthOperationalStatus } from "@echothink-ui/core";
4
+ import type { DAGEdgeLegendItem, DAGLegendProps } from "../types";
5
+ import { labelForStatus, statusColor } from "./utils";
6
+
7
+ const defaultStatuses: EthOperationalStatus[] = [
8
+ "not-started",
9
+ "queued",
10
+ "running",
11
+ "blocked",
12
+ "failed",
13
+ "pending-approval",
14
+ "completed"
15
+ ];
16
+
17
+ const defaultEdgeTypes: DAGEdgeLegendItem[] = [
18
+ { id: "dependency", label: "Dependency", variant: "dependency" },
19
+ { id: "critical-path", label: "Critical path", status: "running", variant: "critical" },
20
+ {
21
+ id: "approval-gate",
22
+ label: "Approval gate",
23
+ status: "pending-approval",
24
+ variant: "conditional"
25
+ }
26
+ ];
27
+
28
+ function colorVariable(color: string) {
29
+ return { "--eth-dag-legend-color": color } as React.CSSProperties;
30
+ }
31
+
32
+ export function DAGLegend({
33
+ statuses = defaultStatuses,
34
+ edgeTypes = defaultEdgeTypes,
35
+ className,
36
+ "aria-label": ariaLabel,
37
+ ...props
38
+ }: DAGLegendProps) {
39
+ return (
40
+ <div
41
+ {...props}
42
+ className={clsx("eth-dag-legend", className)}
43
+ data-eth-component="DAGLegend"
44
+ role="group"
45
+ aria-label={ariaLabel ?? "DAG legend"}
46
+ >
47
+ {statuses.length ? (
48
+ <div className="eth-dag-legend__group">
49
+ <span className="eth-dag-legend__heading">Node status</span>
50
+ <ul className="eth-dag-legend__list" aria-label="DAG node status legend">
51
+ {statuses.map((status) => (
52
+ <li key={status} className="eth-dag-legend__item">
53
+ <span
54
+ className={clsx("eth-dag-legend__swatch", `eth-dag-legend__swatch--${status}`)}
55
+ style={colorVariable(statusColor(status))}
56
+ aria-hidden="true"
57
+ />
58
+ <span className="eth-dag-legend__label">{labelForStatus(status)}</span>
59
+ </li>
60
+ ))}
61
+ </ul>
62
+ </div>
63
+ ) : null}
64
+ {edgeTypes.length ? (
65
+ <div className="eth-dag-legend__group eth-dag-legend__group--edges">
66
+ <span className="eth-dag-legend__heading">Edge type</span>
67
+ <ul className="eth-dag-legend__list" aria-label="DAG edge type legend">
68
+ {edgeTypes.map((edgeType) => (
69
+ <li key={edgeType.id} className="eth-dag-legend__item">
70
+ <span
71
+ className={clsx(
72
+ "eth-dag-legend__edge",
73
+ `eth-dag-legend__edge--${edgeType.variant ?? "dependency"}`
74
+ )}
75
+ style={colorVariable(statusColor(edgeType.status))}
76
+ aria-hidden="true"
77
+ />
78
+ <span className="eth-dag-legend__label">{edgeType.label}</span>
79
+ </li>
80
+ ))}
81
+ </ul>
82
+ </div>
83
+ ) : null}
84
+ </div>
85
+ );
86
+ }
@@ -0,0 +1,103 @@
1
+ import * as React from "react";
2
+ import clsx from "clsx";
3
+ import type { DAGNodeProps } from "../types";
4
+ import { labelForStatus, statusColor } from "./utils";
5
+
6
+ const nodeWidth = 156;
7
+ const nodeHeight = 60;
8
+
9
+ function truncateLabel(label: string) {
10
+ return label.length > 20 ? `${label.slice(0, 19)}...` : label;
11
+ }
12
+
13
+ export function DAGNode({ node, selected, onSelect, className, ...props }: DAGNodeProps) {
14
+ const statusLabel = labelForStatus(node.status);
15
+ const x = node.x - nodeWidth / 2;
16
+ const y = node.y - nodeHeight / 2;
17
+ const handleSelect = () => onSelect?.(node);
18
+ const interactive = Boolean(onSelect);
19
+
20
+ return (
21
+ <g
22
+ {...props}
23
+ className={clsx(
24
+ "eth-dag-node",
25
+ `eth-dag-node--${node.status}`,
26
+ interactive && "eth-dag-node--interactive",
27
+ selected && "eth-dag-node--selected",
28
+ className
29
+ )}
30
+ role={interactive ? "button" : "group"}
31
+ tabIndex={interactive ? 0 : undefined}
32
+ aria-label={`${node.label}, ${statusLabel}`}
33
+ aria-pressed={interactive ? Boolean(selected) : undefined}
34
+ onClick={interactive ? handleSelect : undefined}
35
+ onKeyDown={
36
+ interactive
37
+ ? (event) => {
38
+ if (event.key === "Enter" || event.key === " ") {
39
+ event.preventDefault();
40
+ handleSelect();
41
+ }
42
+ }
43
+ : undefined
44
+ }
45
+ data-eth-component="DAGNode"
46
+ >
47
+ <title>{`${node.label} - ${statusLabel}`}</title>
48
+ <rect
49
+ className="eth-dag-node__selection"
50
+ x={x - 3}
51
+ y={y - 3}
52
+ width={nodeWidth + 6}
53
+ height={nodeHeight + 6}
54
+ rx={0}
55
+ />
56
+ <rect
57
+ className="eth-dag-node__body"
58
+ x={x}
59
+ y={y}
60
+ width={nodeWidth}
61
+ height={nodeHeight}
62
+ rx={0}
63
+ fill="var(--eth-color-layer-01)"
64
+ />
65
+ <rect
66
+ className="eth-dag-node__status-strip"
67
+ x={x}
68
+ y={y}
69
+ width={4}
70
+ height={nodeHeight}
71
+ fill={statusColor(node.status)}
72
+ />
73
+ <circle className="eth-dag-node__port" cx={x} cy={node.y} r={3} />
74
+ <circle className="eth-dag-node__port" cx={x + nodeWidth} cy={node.y} r={3} />
75
+ <circle
76
+ className="eth-dag-node__status-dot"
77
+ cx={x + 18}
78
+ cy={y + 18}
79
+ r={3.5}
80
+ fill={statusColor(node.status)}
81
+ />
82
+ <text
83
+ x={x + 28}
84
+ y={y + 22}
85
+ textAnchor="start"
86
+ className="eth-dag-node__label"
87
+ >
88
+ {truncateLabel(node.label)}
89
+ </text>
90
+ <text
91
+ x={x + 28}
92
+ y={y + 44}
93
+ textAnchor="start"
94
+ className="eth-dag-node__status"
95
+ >
96
+ {statusLabel}
97
+ </text>
98
+ </g>
99
+ );
100
+ }
101
+
102
+ export const DAG_NODE_WIDTH = nodeWidth;
103
+ export const DAG_NODE_HEIGHT = nodeHeight;
@@ -0,0 +1,166 @@
1
+ import * as React from "react";
2
+ import clsx from "clsx";
3
+ import { InlineNotification, Surface, Tag, type EthAction } from "@echothink-ui/core";
4
+ import type { DecisionOption, DecisionRequiredPanelProps } from "../types";
5
+ import { severityForRisk } from "./utils";
6
+
7
+ interface ResolvedOption extends DecisionOption {
8
+ action?: EthAction;
9
+ }
10
+
11
+ function optionsFromActions(actions?: EthAction[]): ResolvedOption[] {
12
+ return (
13
+ actions?.map((action) => ({
14
+ id: action.id,
15
+ label: action.label,
16
+ intent: action.intent,
17
+ action
18
+ })) ?? []
19
+ );
20
+ }
21
+
22
+ function riskTitle(riskLevel: DecisionRequiredPanelProps["riskLevel"]) {
23
+ if (!riskLevel) return "Decision required";
24
+ return `${riskLevel.charAt(0).toUpperCase()}${riskLevel.slice(1)} risk decision`;
25
+ }
26
+
27
+ function optionIntent(option: ResolvedOption) {
28
+ return option.intent ?? "secondary";
29
+ }
30
+
31
+ export function DecisionRequiredPanel({
32
+ title,
33
+ summary,
34
+ riskLevel,
35
+ evidence = [],
36
+ options,
37
+ onDecide,
38
+ decidedOptionId,
39
+ className,
40
+ description,
41
+ actions,
42
+ items,
43
+ severity,
44
+ status,
45
+ ...props
46
+ }: DecisionRequiredPanelProps) {
47
+ const titleId = React.useId();
48
+ const evidenceId = React.useId();
49
+ const optionsId = React.useId();
50
+ const resolvedOptions: ResolvedOption[] =
51
+ options?.map((option) => ({ ...option })) ??
52
+ (actions?.length
53
+ ? optionsFromActions(actions)
54
+ : items?.length
55
+ ? items.map((item) => ({
56
+ id: item.id,
57
+ label: String(item.label),
58
+ description: item.description ? String(item.description) : undefined,
59
+ intent: item.status === "active" ? "primary" : "secondary"
60
+ }))
61
+ : []);
62
+ const riskSeverity = riskLevel ? severityForRisk(riskLevel) : (severity ?? "warning");
63
+
64
+ return (
65
+ <Surface
66
+ {...props}
67
+ role="region"
68
+ aria-labelledby={titleId}
69
+ className={clsx(
70
+ "eth-task-decision-panel",
71
+ riskLevel && `eth-task-decision-panel--${riskLevel}`,
72
+ className
73
+ )}
74
+ title={<span id={titleId}>{title}</span>}
75
+ severity={riskSeverity === "neutral" ? severity : riskSeverity}
76
+ status={status ?? "approval-required"}
77
+ data-eth-component="DecisionRequiredPanel"
78
+ >
79
+ <InlineNotification severity={riskSeverity} title={riskTitle(riskLevel)}>
80
+ {description}
81
+ </InlineNotification>
82
+ {summary ? (
83
+ <section className="eth-task-decision-panel__summary" aria-label="Decision context">
84
+ <div className="eth-task-decision-panel__section-label">Decision context</div>
85
+ <div className="eth-task-decision-panel__summary-body">{summary}</div>
86
+ </section>
87
+ ) : null}
88
+
89
+ {evidence.length ? (
90
+ <section className="eth-task-decision-panel__evidence" aria-labelledby={evidenceId}>
91
+ <div className="eth-task-decision-panel__section-header">
92
+ <h3 id={evidenceId}>Evidence</h3>
93
+ <span>
94
+ {evidence.length} source{evidence.length === 1 ? "" : "s"}
95
+ </span>
96
+ </div>
97
+ <ul>
98
+ {evidence.map((item) => (
99
+ <li key={item.id}>
100
+ <div className="eth-task-decision-panel__evidence-label">
101
+ {item.href ? (
102
+ <a className="eth-task-decision-panel__evidence-link" href={item.href}>
103
+ {item.label}
104
+ </a>
105
+ ) : (
106
+ <strong>{item.label}</strong>
107
+ )}
108
+ </div>
109
+ {item.preview ? (
110
+ <div className="eth-task-decision-panel__evidence-preview">{item.preview}</div>
111
+ ) : null}
112
+ </li>
113
+ ))}
114
+ </ul>
115
+ </section>
116
+ ) : null}
117
+
118
+ {resolvedOptions.length ? (
119
+ <section
120
+ className="eth-task-decision-panel__options"
121
+ role="group"
122
+ aria-labelledby={optionsId}
123
+ >
124
+ <div className="eth-task-decision-panel__section-header">
125
+ <h3 id={optionsId}>Decision options</h3>
126
+ <span>{resolvedOptions.length} available</span>
127
+ </div>
128
+ {resolvedOptions.map((option) => (
129
+ <button
130
+ key={option.id}
131
+ type="button"
132
+ className={clsx(
133
+ "eth-task-decision-panel__option",
134
+ `eth-task-decision-panel__option--${optionIntent(option)}`
135
+ )}
136
+ disabled={option.action?.disabled}
137
+ aria-pressed={decidedOptionId === option.id}
138
+ onClick={() => {
139
+ option.action?.onSelect?.();
140
+ onDecide?.(option.id);
141
+ }}
142
+ >
143
+ <span className="eth-task-decision-panel__option-main">
144
+ <span className="eth-task-decision-panel__option-label">{option.label}</span>
145
+ {option.description ? (
146
+ <span className="eth-task-decision-panel__option-description">
147
+ {option.description}
148
+ </span>
149
+ ) : null}
150
+ </span>
151
+ <span className="eth-task-decision-panel__option-state">
152
+ {decidedOptionId === option.id ? "Selected" : "Choose"}
153
+ </span>
154
+ </button>
155
+ ))}
156
+ </section>
157
+ ) : null}
158
+
159
+ {decidedOptionId ? (
160
+ <div className="eth-task-decision-panel__decision">
161
+ <Tag>Decided: {decidedOptionId}</Tag>
162
+ </div>
163
+ ) : null}
164
+ </Surface>
165
+ );
166
+ }