@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.
- package/README.md +5 -0
- package/dist/components/BackendThinkingChain.d.ts +2 -0
- package/dist/components/BlockingReasonPanel.d.ts +2 -0
- package/dist/components/DAGEdge.d.ts +2 -0
- package/dist/components/DAGLegend.d.ts +2 -0
- package/dist/components/DAGNode.d.ts +4 -0
- package/dist/components/DecisionRequiredPanel.d.ts +2 -0
- package/dist/components/HumanInterventionPanel.d.ts +2 -0
- package/dist/components/MobileTaskShell.d.ts +12 -0
- package/dist/components/TaskApprovalPanel.d.ts +2 -0
- package/dist/components/TaskCard.d.ts +2 -0
- package/dist/components/TaskDependencyList.d.ts +2 -0
- package/dist/components/TaskDetailPanel.d.ts +2 -0
- package/dist/components/TaskHandoffPanel.d.ts +2 -0
- package/dist/components/TaskProgressIndicator.d.ts +2 -0
- package/dist/components/TaskRetryPanel.d.ts +2 -0
- package/dist/components/TaskRunLog.d.ts +2 -0
- package/dist/components/TaskStatusBadge.d.ts +2 -0
- package/dist/components/TaskTable.d.ts +5 -0
- package/dist/components/TaskTimeline.d.ts +2 -0
- package/dist/components/TaskWaveDAG.d.ts +2 -0
- package/dist/components/TaskWaveHeader.d.ts +2 -0
- package/dist/components/TaskWaveTable.d.ts +2 -0
- package/dist/components/utils.d.ts +13 -0
- package/dist/index.cjs +2434 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.css +2402 -0
- package/dist/index.css.map +1 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.js +2388 -0
- package/dist/index.js.map +1 -0
- package/dist/types.d.ts +249 -0
- package/package.json +45 -0
- package/src/components/BackendThinkingChain.tsx +129 -0
- package/src/components/BlockingReasonPanel.tsx +67 -0
- package/src/components/DAGEdge.tsx +97 -0
- package/src/components/DAGLegend.tsx +86 -0
- package/src/components/DAGNode.tsx +103 -0
- package/src/components/DecisionRequiredPanel.tsx +166 -0
- package/src/components/HumanInterventionPanel.tsx +82 -0
- package/src/components/MobileTaskShell.tsx +52 -0
- package/src/components/TaskApprovalPanel.tsx +159 -0
- package/src/components/TaskCard.tsx +71 -0
- package/src/components/TaskDependencyList.test.tsx +54 -0
- package/src/components/TaskDependencyList.tsx +105 -0
- package/src/components/TaskDetailPanel.test.tsx +49 -0
- package/src/components/TaskDetailPanel.tsx +139 -0
- package/src/components/TaskHandoffPanel.tsx +125 -0
- package/src/components/TaskProgressIndicator.tsx +70 -0
- package/src/components/TaskRetryPanel.test.tsx +29 -0
- package/src/components/TaskRetryPanel.tsx +103 -0
- package/src/components/TaskRunLog.tsx +156 -0
- package/src/components/TaskStatusBadge.tsx +29 -0
- package/src/components/TaskTable.tsx +294 -0
- package/src/components/TaskTimeline.tsx +98 -0
- package/src/components/TaskWaveDAG.tsx +202 -0
- package/src/components/TaskWaveHeader.tsx +82 -0
- package/src/components/TaskWaveTable.tsx +151 -0
- package/src/components/css.d.ts +1 -0
- package/src/components/utils.ts +116 -0
- package/src/index.test.tsx +316 -0
- package/src/index.tsx +90 -0
- package/src/styles.css +2889 -0
- package/src/types.ts +289 -0
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import clsx from "clsx";
|
|
3
|
+
import { Badge, EmptyState, StatusDot, Surface } from "@echothink-ui/core";
|
|
4
|
+
import { DataTable, type DataColumn } from "@echothink-ui/data";
|
|
5
|
+
import type { Task, TaskWaveTableProps } from "../types";
|
|
6
|
+
import { formatDuration, labelForStatus } from "./utils";
|
|
7
|
+
|
|
8
|
+
function taskRowsFromItems(items: TaskWaveTableProps["items"]): Task[] {
|
|
9
|
+
return (
|
|
10
|
+
items?.map((item, index) => ({
|
|
11
|
+
id: item.id,
|
|
12
|
+
title: String(item.label),
|
|
13
|
+
status: item.status ?? "not-started",
|
|
14
|
+
order: index + 1
|
|
15
|
+
})) ?? []
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function dependencyCount(task: Task) {
|
|
20
|
+
if (typeof task.dependencyCount === "number") return task.dependencyCount;
|
|
21
|
+
if (task.dependencyIds?.length) return task.dependencyIds.length;
|
|
22
|
+
return task.dependencies?.length ?? 0;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function EmptyCell() {
|
|
26
|
+
return <span className="eth-task-wave-table__empty">-</span>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function TaskWaveTable({
|
|
30
|
+
wave,
|
|
31
|
+
tasks,
|
|
32
|
+
items,
|
|
33
|
+
onTaskSelect,
|
|
34
|
+
rowActions,
|
|
35
|
+
className,
|
|
36
|
+
title,
|
|
37
|
+
description,
|
|
38
|
+
..._surfaceProps
|
|
39
|
+
}: TaskWaveTableProps) {
|
|
40
|
+
const rows = tasks ?? taskRowsFromItems(items);
|
|
41
|
+
const columns = React.useMemo<DataColumn<Task>[]>(
|
|
42
|
+
() => [
|
|
43
|
+
{
|
|
44
|
+
key: "order",
|
|
45
|
+
header: "Order",
|
|
46
|
+
width: "4.5rem",
|
|
47
|
+
render: (task, index) => (
|
|
48
|
+
<span className="eth-task-wave-table__order">{task.order ?? index + 1}</span>
|
|
49
|
+
)
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
key: "title",
|
|
53
|
+
header: "Task",
|
|
54
|
+
width: "22rem",
|
|
55
|
+
render: (task) =>
|
|
56
|
+
onTaskSelect ? (
|
|
57
|
+
<button
|
|
58
|
+
className="eth-task-wave-table__task-button"
|
|
59
|
+
type="button"
|
|
60
|
+
onClick={() => onTaskSelect(task)}
|
|
61
|
+
>
|
|
62
|
+
<span className="eth-task-wave-table__task-title">{task.title}</span>
|
|
63
|
+
</button>
|
|
64
|
+
) : (
|
|
65
|
+
<span className="eth-task-wave-table__task-title">{task.title}</span>
|
|
66
|
+
)
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
key: "status",
|
|
70
|
+
header: "Status",
|
|
71
|
+
width: "12rem",
|
|
72
|
+
render: (task) => (
|
|
73
|
+
<StatusDot
|
|
74
|
+
className="eth-task-wave-table__status"
|
|
75
|
+
status={task.status}
|
|
76
|
+
label={labelForStatus(task.status)}
|
|
77
|
+
/>
|
|
78
|
+
)
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
key: "assignee",
|
|
82
|
+
header: "Assignee",
|
|
83
|
+
width: "8rem",
|
|
84
|
+
render: (task) =>
|
|
85
|
+
task.assignee ? (
|
|
86
|
+
<span className="eth-task-wave-table__assignee">{task.assignee}</span>
|
|
87
|
+
) : (
|
|
88
|
+
<EmptyCell />
|
|
89
|
+
)
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
key: "dependencies",
|
|
93
|
+
header: "Dependencies",
|
|
94
|
+
width: "7rem",
|
|
95
|
+
align: "center",
|
|
96
|
+
render: (task) => {
|
|
97
|
+
const count = dependencyCount(task);
|
|
98
|
+
return (
|
|
99
|
+
<Badge
|
|
100
|
+
className="eth-task-wave-table__dependency-count"
|
|
101
|
+
severity={count > 0 ? "info" : "neutral"}
|
|
102
|
+
>
|
|
103
|
+
{count}
|
|
104
|
+
</Badge>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
key: "durationMs",
|
|
110
|
+
header: "Duration",
|
|
111
|
+
width: "7rem",
|
|
112
|
+
render: (task) => (
|
|
113
|
+
<span
|
|
114
|
+
className={clsx(
|
|
115
|
+
"eth-task-wave-table__duration",
|
|
116
|
+
task.durationMs == null && "eth-task-wave-table__empty"
|
|
117
|
+
)}
|
|
118
|
+
>
|
|
119
|
+
{formatDuration(task.durationMs)}
|
|
120
|
+
</span>
|
|
121
|
+
)
|
|
122
|
+
}
|
|
123
|
+
],
|
|
124
|
+
[onTaskSelect]
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
return (
|
|
128
|
+
<Surface
|
|
129
|
+
className={clsx("eth-task-wave-table", className)}
|
|
130
|
+
title={wave?.label ?? title ?? "Task wave"}
|
|
131
|
+
subtitle={wave?.id}
|
|
132
|
+
description={description}
|
|
133
|
+
data-eth-component="TaskWaveTable"
|
|
134
|
+
>
|
|
135
|
+
<DataTable<Task>
|
|
136
|
+
aria-label={
|
|
137
|
+
typeof (wave?.label ?? title) === "string"
|
|
138
|
+
? `${wave?.label ?? title} tasks`
|
|
139
|
+
: "Wave tasks"
|
|
140
|
+
}
|
|
141
|
+
className="eth-task-wave-table__table"
|
|
142
|
+
rows={rows}
|
|
143
|
+
columns={columns}
|
|
144
|
+
rowKey="id"
|
|
145
|
+
density="compact"
|
|
146
|
+
rowActions={rowActions}
|
|
147
|
+
emptyState={<EmptyState title="No wave tasks" />}
|
|
148
|
+
/>
|
|
149
|
+
</Surface>
|
|
150
|
+
);
|
|
151
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
declare module "*.css";
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import type { EthOperationalStatus, EthSeverity } from "@echothink-ui/core";
|
|
2
|
+
import { statusLabel } from "@echothink-ui/core";
|
|
3
|
+
import type { RiskLevel, TaskPriority, TaskWave } from "../types";
|
|
4
|
+
|
|
5
|
+
export function severityForStatus(status: EthOperationalStatus): EthSeverity {
|
|
6
|
+
if (status === "running" || status === "queued" || status === "in-progress") return "info";
|
|
7
|
+
if (status === "blocked" || status === "failed") return "danger";
|
|
8
|
+
if (status === "pending-approval" || status === "approval-required" || status === "warning") {
|
|
9
|
+
return "warning";
|
|
10
|
+
}
|
|
11
|
+
if (status === "succeeded" || status === "completed" || status === "synced") return "success";
|
|
12
|
+
return "neutral";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function severityForPriority(priority?: TaskPriority): EthSeverity {
|
|
16
|
+
if (priority === "critical") return "danger";
|
|
17
|
+
if (priority === "high") return "warning";
|
|
18
|
+
if (priority === "medium") return "info";
|
|
19
|
+
return "neutral";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function severityForRisk(riskLevel?: RiskLevel): EthSeverity {
|
|
23
|
+
if (riskLevel === "critical" || riskLevel === "high") return "danger";
|
|
24
|
+
if (riskLevel === "medium") return "warning";
|
|
25
|
+
if (riskLevel === "low") return "info";
|
|
26
|
+
return "neutral";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function labelForStatus(status: EthOperationalStatus) {
|
|
30
|
+
return statusLabel(status);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function formatDateTime(value?: string | Date) {
|
|
34
|
+
if (!value) return "-";
|
|
35
|
+
const date = value instanceof Date ? value : new Date(value);
|
|
36
|
+
if (Number.isNaN(date.valueOf())) return String(value);
|
|
37
|
+
return new Intl.DateTimeFormat(undefined, {
|
|
38
|
+
dateStyle: "medium",
|
|
39
|
+
timeStyle: "short"
|
|
40
|
+
}).format(date);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function relativeTime(value?: string | Date, now = new Date()) {
|
|
44
|
+
if (!value) return "";
|
|
45
|
+
const date = value instanceof Date ? value : new Date(value);
|
|
46
|
+
if (Number.isNaN(date.valueOf())) return "";
|
|
47
|
+
const deltaSeconds = Math.round((date.getTime() - now.getTime()) / 1000);
|
|
48
|
+
const units: Array<[Intl.RelativeTimeFormatUnit, number]> = [
|
|
49
|
+
["year", 31536000],
|
|
50
|
+
["month", 2592000],
|
|
51
|
+
["week", 604800],
|
|
52
|
+
["day", 86400],
|
|
53
|
+
["hour", 3600],
|
|
54
|
+
["minute", 60]
|
|
55
|
+
];
|
|
56
|
+
const formatter = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" });
|
|
57
|
+
for (const [unit, seconds] of units) {
|
|
58
|
+
if (Math.abs(deltaSeconds) >= seconds) {
|
|
59
|
+
return formatter.format(Math.round(deltaSeconds / seconds), unit);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return formatter.format(deltaSeconds, "second");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function formatDuration(durationMs?: number) {
|
|
66
|
+
if (durationMs == null) return "-";
|
|
67
|
+
if (durationMs < 1000) return `${durationMs}ms`;
|
|
68
|
+
const seconds = Math.round(durationMs / 1000);
|
|
69
|
+
if (seconds < 60) return `${seconds}s`;
|
|
70
|
+
const minutes = Math.floor(seconds / 60);
|
|
71
|
+
const remainder = seconds % 60;
|
|
72
|
+
if (minutes < 60) return remainder ? `${minutes}m ${remainder}s` : `${minutes}m`;
|
|
73
|
+
const hours = Math.floor(minutes / 60);
|
|
74
|
+
const minuteRemainder = minutes % 60;
|
|
75
|
+
return minuteRemainder ? `${hours}h ${minuteRemainder}m` : `${hours}h`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function clampProgress(value?: number, total = 100) {
|
|
79
|
+
if (value == null || Number.isNaN(value)) return 0;
|
|
80
|
+
if (total <= 0) return 0;
|
|
81
|
+
const percent = total === 100 ? value : (value / total) * 100;
|
|
82
|
+
return Math.max(0, Math.min(100, Math.round(percent)));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function waveProgress(wave?: TaskWave) {
|
|
86
|
+
if (!wave?.totalTasks) return 0;
|
|
87
|
+
const completed = (wave.statuses.completed ?? 0) + (wave.statuses.succeeded ?? 0);
|
|
88
|
+
return clampProgress(completed, wave.totalTasks);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function statusColor(status?: EthOperationalStatus) {
|
|
92
|
+
switch (status) {
|
|
93
|
+
case "completed":
|
|
94
|
+
case "succeeded":
|
|
95
|
+
case "synced":
|
|
96
|
+
case "active":
|
|
97
|
+
return "var(--eth-color-success)";
|
|
98
|
+
case "running":
|
|
99
|
+
case "in-progress":
|
|
100
|
+
case "queued":
|
|
101
|
+
return "var(--eth-color-info)";
|
|
102
|
+
case "blocked":
|
|
103
|
+
case "failed":
|
|
104
|
+
case "approval-required":
|
|
105
|
+
return "var(--eth-color-danger)";
|
|
106
|
+
case "pending-approval":
|
|
107
|
+
case "warning":
|
|
108
|
+
return "var(--eth-color-warning)";
|
|
109
|
+
default:
|
|
110
|
+
return "var(--eth-color-border-strong)";
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function fallbackId(prefix: string) {
|
|
115
|
+
return `${prefix}-${Math.random().toString(36).slice(2)}`;
|
|
116
|
+
}
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import { render, screen, within } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import {
|
|
4
|
+
TaskCard,
|
|
5
|
+
TaskProgressIndicator,
|
|
6
|
+
TaskStatusBadge,
|
|
7
|
+
TaskTable,
|
|
8
|
+
TaskTimeline,
|
|
9
|
+
TaskWaveDAG,
|
|
10
|
+
TaskWaveHeader,
|
|
11
|
+
TaskWaveTable,
|
|
12
|
+
type Task
|
|
13
|
+
} from "./index";
|
|
14
|
+
|
|
15
|
+
const tasks: Task[] = [
|
|
16
|
+
{
|
|
17
|
+
id: "t1",
|
|
18
|
+
title: "Create landing page hero",
|
|
19
|
+
assignee: "JD",
|
|
20
|
+
dueAt: "2026-05-30",
|
|
21
|
+
status: "in-progress",
|
|
22
|
+
priority: "high"
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
id: "t2",
|
|
26
|
+
title: "Approve pricing copy",
|
|
27
|
+
assignee: "Legal",
|
|
28
|
+
status: "approval-required"
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
id: "t3",
|
|
32
|
+
title: "Wire analytics",
|
|
33
|
+
assignee: "AL",
|
|
34
|
+
status: "queued"
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
id: "t4",
|
|
38
|
+
title: "QA pass",
|
|
39
|
+
assignee: "MK",
|
|
40
|
+
status: "blocked"
|
|
41
|
+
}
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
describe("@echothink-ui/task TaskCard", () => {
|
|
45
|
+
it("renders the task summary, metadata, tags, and actions in one compact surface", () => {
|
|
46
|
+
render(
|
|
47
|
+
<TaskCard
|
|
48
|
+
task={{
|
|
49
|
+
id: "t1",
|
|
50
|
+
title: "Create landing page hero",
|
|
51
|
+
assignee: "JD",
|
|
52
|
+
dueAt: "2026-05-30T20:00:00Z",
|
|
53
|
+
status: "in-progress",
|
|
54
|
+
priority: "high",
|
|
55
|
+
tags: ["q3-launch", "design"],
|
|
56
|
+
description: "Design hero with social-proof modules and animated stat strip."
|
|
57
|
+
}}
|
|
58
|
+
actions={[{ id: "view", label: "View" }]}
|
|
59
|
+
/>
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
const card = screen
|
|
63
|
+
.getByRole("heading", { name: "Create landing page hero" })
|
|
64
|
+
.closest('[data-eth-component="TaskCard"]');
|
|
65
|
+
|
|
66
|
+
expect(card?.className).toContain("eth-task-card");
|
|
67
|
+
expect(
|
|
68
|
+
screen.getByText("Design hero with social-proof modules and animated stat strip.")
|
|
69
|
+
).toBeTruthy();
|
|
70
|
+
expect(card ? within(card).getByText("Assignee") : null).toBeTruthy();
|
|
71
|
+
expect(card ? within(card).getByText("Priority") : null).toBeTruthy();
|
|
72
|
+
expect(card ? within(card).getByText("q3-launch") : null).toBeTruthy();
|
|
73
|
+
expect(screen.getByRole("button", { name: "View" })).toBeTruthy();
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("@echothink-ui/task TaskWaveTable", () => {
|
|
78
|
+
it("renders wave rows with stable table cell affordances", () => {
|
|
79
|
+
const { container } = render(
|
|
80
|
+
<TaskWaveTable
|
|
81
|
+
wave={{
|
|
82
|
+
id: "wave-q3",
|
|
83
|
+
label: "Wave Q3",
|
|
84
|
+
totalTasks: 1,
|
|
85
|
+
statuses: { "in-progress": 1 }
|
|
86
|
+
}}
|
|
87
|
+
tasks={[
|
|
88
|
+
{
|
|
89
|
+
id: "task-1",
|
|
90
|
+
title: "Create landing page hero",
|
|
91
|
+
status: "in-progress",
|
|
92
|
+
assignee: "JD",
|
|
93
|
+
order: 1,
|
|
94
|
+
dependencyCount: 2,
|
|
95
|
+
durationMs: 5400000
|
|
96
|
+
}
|
|
97
|
+
]}
|
|
98
|
+
/>
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
const waveTable = container.querySelector('[data-eth-component="TaskWaveTable"]');
|
|
102
|
+
|
|
103
|
+
expect(waveTable?.className).toContain("eth-task-wave-table");
|
|
104
|
+
expect(waveTable?.querySelector(".eth-task-wave-table__order")?.textContent).toBe("1");
|
|
105
|
+
expect(waveTable?.querySelector(".eth-task-wave-table__task-title")?.textContent).toBe(
|
|
106
|
+
"Create landing page hero"
|
|
107
|
+
);
|
|
108
|
+
expect(waveTable?.querySelector(".eth-task-wave-table__dependency-count")?.textContent).toBe(
|
|
109
|
+
"2"
|
|
110
|
+
);
|
|
111
|
+
expect(waveTable?.querySelector(".eth-task-wave-table__duration")?.textContent).toBe(
|
|
112
|
+
"1h 30m"
|
|
113
|
+
);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe("@echothink-ui/task TaskTimeline", () => {
|
|
118
|
+
it("renders task events as a readable semantic activity timeline", () => {
|
|
119
|
+
const { container } = render(
|
|
120
|
+
<TaskTimeline
|
|
121
|
+
events={[
|
|
122
|
+
{
|
|
123
|
+
id: "created",
|
|
124
|
+
timestamp: "2026-05-25T09:00:00Z",
|
|
125
|
+
actor: "JD",
|
|
126
|
+
kind: "status_change",
|
|
127
|
+
summary: "Created task"
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
id: "comment",
|
|
131
|
+
timestamp: "2026-05-26T11:30:00Z",
|
|
132
|
+
actor: "Agent",
|
|
133
|
+
kind: "comment",
|
|
134
|
+
summary: "Drafted initial copy",
|
|
135
|
+
details: "Added the launch message and review checklist."
|
|
136
|
+
}
|
|
137
|
+
]}
|
|
138
|
+
/>
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
const timeline = screen.getByRole("region", { name: "Task timeline" });
|
|
142
|
+
expect(within(timeline).getByRole("list")).toBeTruthy();
|
|
143
|
+
expect(within(timeline).getByText("Created task")).toBeTruthy();
|
|
144
|
+
expect(within(timeline).getByText("Drafted initial copy")).toBeTruthy();
|
|
145
|
+
expect(
|
|
146
|
+
within(timeline).getByText("Added the launch message and review checklist.")
|
|
147
|
+
).toBeTruthy();
|
|
148
|
+
expect(within(timeline).getByText("By Agent")).toBeTruthy();
|
|
149
|
+
|
|
150
|
+
const times = Array.from(container.querySelectorAll("time"));
|
|
151
|
+
expect(times[0]?.getAttribute("datetime")).toBe("2026-05-25T09:00:00.000Z");
|
|
152
|
+
expect(times[0]?.textContent).not.toContain("2026-05-25T09:00:00.000Z");
|
|
153
|
+
expect(times[0]?.textContent).not.toContain("ago");
|
|
154
|
+
|
|
155
|
+
expect(within(timeline).getByText("Status").closest("li")?.className).toContain(
|
|
156
|
+
"eth-task-timeline__event--status_change"
|
|
157
|
+
);
|
|
158
|
+
expect(within(timeline).getByText("Comment").closest("li")?.className).toContain(
|
|
159
|
+
"eth-task-timeline__event--comment"
|
|
160
|
+
);
|
|
161
|
+
expect(container.querySelector(".eth-task-timeline__marker")?.textContent).toBe("");
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe("@echothink-ui/task TaskProgressIndicator", () => {
|
|
166
|
+
it("renders step progress as an ordered workflow with separate labels and statuses", () => {
|
|
167
|
+
const { container } = render(
|
|
168
|
+
<TaskProgressIndicator
|
|
169
|
+
label="Deployment progress"
|
|
170
|
+
steps={[
|
|
171
|
+
{ id: "plan", label: "Plan", status: "completed" },
|
|
172
|
+
{ id: "build", label: "Build", status: "running" },
|
|
173
|
+
{ id: "ship", label: "Ship", status: "not-started" }
|
|
174
|
+
]}
|
|
175
|
+
/>
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
const list = screen.getByRole("list", { name: "Deployment progress" });
|
|
179
|
+
const steps = screen.getAllByRole("listitem");
|
|
180
|
+
|
|
181
|
+
expect(container.querySelector('[data-eth-component="TaskProgressIndicator"]')).toBeTruthy();
|
|
182
|
+
expect(steps).toHaveLength(3);
|
|
183
|
+
expect(within(steps[0]).getByText("Plan").className).toContain(
|
|
184
|
+
"eth-task-progress__step-label"
|
|
185
|
+
);
|
|
186
|
+
expect(within(steps[0]).getByText("Completed").className).toContain(
|
|
187
|
+
"eth-task-progress__step-status"
|
|
188
|
+
);
|
|
189
|
+
expect(steps[1].getAttribute("aria-current")).toBe("step");
|
|
190
|
+
expect(steps[2].getAttribute("aria-current")).toBeNull();
|
|
191
|
+
expect(list.querySelectorAll(".eth-task-progress__connector")).toHaveLength(2);
|
|
192
|
+
expect(container.querySelector(".eth-status-dot")).toBeNull();
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
describe("@echothink-ui/task TaskStatusBadge", () => {
|
|
197
|
+
it("marks task status badges with state-specific semantics and an indicator", () => {
|
|
198
|
+
render(<TaskStatusBadge status="approval-required" aria-label="Task approval required" />);
|
|
199
|
+
|
|
200
|
+
const badge = screen.getByText("Approval Required").closest(".eth-task-status-badge");
|
|
201
|
+
|
|
202
|
+
expect(badge?.className).toContain("eth-task-status-badge--approval-required");
|
|
203
|
+
expect(badge?.getAttribute("data-eth-component")).toBe("TaskStatusBadge");
|
|
204
|
+
expect(badge?.getAttribute("aria-label")).toBe("Task approval required");
|
|
205
|
+
expect(
|
|
206
|
+
badge?.querySelector(".eth-task-status-badge__indicator")?.getAttribute("aria-hidden")
|
|
207
|
+
).toBe("true");
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("allows status copy to be overridden without losing status semantics", () => {
|
|
211
|
+
render(<TaskStatusBadge status="completed" label="Done" />);
|
|
212
|
+
|
|
213
|
+
const badge = screen.getByText("Done").closest(".eth-task-status-badge");
|
|
214
|
+
|
|
215
|
+
expect(badge?.className).toContain("eth-task-status-badge--completed");
|
|
216
|
+
expect(badge?.getAttribute("data-status")).toBe("completed");
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
describe("@echothink-ui/task TaskWaveDAG", () => {
|
|
221
|
+
it("renders a compact accessible dependency graph with a viewport, statuses, and edge labels", () => {
|
|
222
|
+
render(
|
|
223
|
+
<TaskWaveDAG
|
|
224
|
+
title="Launch dependencies"
|
|
225
|
+
nodes={[
|
|
226
|
+
{ id: "research", label: "Research", status: "completed" },
|
|
227
|
+
{ id: "brief", label: "Brief", status: "in-progress" },
|
|
228
|
+
{ id: "legal", label: "Legal gate", status: "pending-approval" },
|
|
229
|
+
{ id: "publish", label: "Publish", status: "not-started" }
|
|
230
|
+
]}
|
|
231
|
+
edges={[
|
|
232
|
+
{ from: "research", to: "brief", label: "handoff" },
|
|
233
|
+
{ from: "research", to: "legal", label: "approval" },
|
|
234
|
+
{ from: "brief", to: "publish" },
|
|
235
|
+
{ from: "legal", to: "publish" }
|
|
236
|
+
]}
|
|
237
|
+
/>
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
const graph = screen.getByRole("img", { name: "Launch dependencies" });
|
|
241
|
+
|
|
242
|
+
expect(graph.className.baseVal).toContain("eth-dag");
|
|
243
|
+
expect(graph.closest(".eth-task-wave-dag__viewport")).toBeTruthy();
|
|
244
|
+
expect(graph.querySelector(".eth-dag__canvas")).toBeTruthy();
|
|
245
|
+
expect(screen.getByText("Research")).toBeTruthy();
|
|
246
|
+
expect(screen.getByText("Legal gate")).toBeTruthy();
|
|
247
|
+
expect(screen.getByText("approval")).toBeTruthy();
|
|
248
|
+
expect(screen.getAllByText("Pending Approval").length).toBeGreaterThan(0);
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
describe("@echothink-ui/task TaskTable", () => {
|
|
253
|
+
it("renders a named table with Carbon data-table classes", () => {
|
|
254
|
+
render(<TaskTable tasks={tasks} title="Tasks" />);
|
|
255
|
+
|
|
256
|
+
const table = screen.getByRole("table", { name: "Tasks table" });
|
|
257
|
+
|
|
258
|
+
expect(table.getAttribute("data-eth-component")).toBe("TaskTable");
|
|
259
|
+
expect(table.className).toContain("eth-task-table");
|
|
260
|
+
expect(table.className).toContain("eth-data-table");
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("filters rows by operational status when a status filter is supplied", () => {
|
|
264
|
+
render(<TaskTable tasks={tasks} title="Tasks" showFilters statusFilter="blocked" />);
|
|
265
|
+
|
|
266
|
+
expect(screen.getByText("QA pass")).toBeTruthy();
|
|
267
|
+
expect(screen.queryByText("Create landing page hero")).toBeNull();
|
|
268
|
+
expect(screen.getByText("1 of 4 tasks shown")).toBeTruthy();
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("renders selectable bulk actions when bulk actions are supplied", () => {
|
|
272
|
+
render(
|
|
273
|
+
<TaskTable
|
|
274
|
+
tasks={tasks}
|
|
275
|
+
title="Tasks"
|
|
276
|
+
selectedRows={["t2"]}
|
|
277
|
+
bulkActions={[{ id: "approve", label: "Approve selected", requiresConfirmation: false }]}
|
|
278
|
+
/>
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
expect(screen.getByText("1 row selected")).toBeTruthy();
|
|
282
|
+
expect(screen.getByRole("button", { name: "Approve selected" })).toBeTruthy();
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
describe("@echothink-ui/task TaskWaveHeader", () => {
|
|
287
|
+
it("renders compact wave progress, status counts, and actions", () => {
|
|
288
|
+
const { container } = render(
|
|
289
|
+
<TaskWaveHeader
|
|
290
|
+
wave={{
|
|
291
|
+
id: "wave-q3",
|
|
292
|
+
label: "Wave Q3",
|
|
293
|
+
totalTasks: 4,
|
|
294
|
+
statuses: { completed: 1, running: 1, "approval-required": 1, queued: 1 },
|
|
295
|
+
startedAt: "2026-05-25T08:00:00",
|
|
296
|
+
eta: "2026-06-01T08:00:00"
|
|
297
|
+
}}
|
|
298
|
+
actions={[{ id: "pause", label: "Pause wave" }]}
|
|
299
|
+
/>
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
const header = screen
|
|
303
|
+
.getByRole("heading", { name: "Wave Q3" })
|
|
304
|
+
.closest('[data-eth-component="TaskWaveHeader"]');
|
|
305
|
+
|
|
306
|
+
expect(header?.className).toContain("eth-task-wave-header");
|
|
307
|
+
expect(header ? within(header).getByText("Wave progress") : null).toBeTruthy();
|
|
308
|
+
expect(container.querySelector(".eth-task-wave-header__count--queued")?.textContent).toContain(
|
|
309
|
+
"Queued: 1"
|
|
310
|
+
);
|
|
311
|
+
expect(
|
|
312
|
+
container.querySelector(".eth-task-wave-header__count--approval-required")?.textContent
|
|
313
|
+
).toContain("Approval Required: 1");
|
|
314
|
+
expect(screen.getByRole("button", { name: "Pause wave" })).toBeTruthy();
|
|
315
|
+
});
|
|
316
|
+
});
|
package/src/index.tsx
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import "./styles.css";
|
|
2
|
+
|
|
3
|
+
export { BackendThinkingChain } from "./components/BackendThinkingChain";
|
|
4
|
+
export { BlockingReasonPanel } from "./components/BlockingReasonPanel";
|
|
5
|
+
export { DAGEdge } from "./components/DAGEdge";
|
|
6
|
+
export { DAGLegend } from "./components/DAGLegend";
|
|
7
|
+
export { DAGNode } from "./components/DAGNode";
|
|
8
|
+
export { DecisionRequiredPanel } from "./components/DecisionRequiredPanel";
|
|
9
|
+
export { HumanInterventionPanel } from "./components/HumanInterventionPanel";
|
|
10
|
+
export { TaskApprovalPanel } from "./components/TaskApprovalPanel";
|
|
11
|
+
export { TaskCard } from "./components/TaskCard";
|
|
12
|
+
export { TaskDependencyList } from "./components/TaskDependencyList";
|
|
13
|
+
export { TaskDetailPanel } from "./components/TaskDetailPanel";
|
|
14
|
+
export { TaskHandoffPanel } from "./components/TaskHandoffPanel";
|
|
15
|
+
export { TaskProgressIndicator } from "./components/TaskProgressIndicator";
|
|
16
|
+
export { TaskRetryPanel } from "./components/TaskRetryPanel";
|
|
17
|
+
export { TaskRunLog } from "./components/TaskRunLog";
|
|
18
|
+
export { TaskStatusBadge } from "./components/TaskStatusBadge";
|
|
19
|
+
export { TaskTable } from "./components/TaskTable";
|
|
20
|
+
export { TaskTimeline } from "./components/TaskTimeline";
|
|
21
|
+
export { TaskWaveDAG } from "./components/TaskWaveDAG";
|
|
22
|
+
export { TaskWaveHeader } from "./components/TaskWaveHeader";
|
|
23
|
+
export { TaskWaveTable } from "./components/TaskWaveTable";
|
|
24
|
+
export { MobileTaskShell } from "./components/MobileTaskShell";
|
|
25
|
+
export type { MobileTaskShellProps } from "./components/MobileTaskShell";
|
|
26
|
+
export type {
|
|
27
|
+
BackendThinkingChainProps,
|
|
28
|
+
BackendThinkingStep,
|
|
29
|
+
BlockingReasonPanelProps,
|
|
30
|
+
DAGEdgeLegendItem,
|
|
31
|
+
DAGEdgeLegendVariant,
|
|
32
|
+
DAGEdgeModel,
|
|
33
|
+
DAGEdgeProps,
|
|
34
|
+
DAGLegendProps,
|
|
35
|
+
DAGNodeModel,
|
|
36
|
+
DAGNodeProps,
|
|
37
|
+
DecisionEvidence,
|
|
38
|
+
DecisionOption,
|
|
39
|
+
DecisionRequiredPanelProps,
|
|
40
|
+
HumanInterventionPanelProps,
|
|
41
|
+
PositionedDAGNode,
|
|
42
|
+
RiskLevel,
|
|
43
|
+
Task,
|
|
44
|
+
TaskApprovalPanelProps,
|
|
45
|
+
TaskCardProps,
|
|
46
|
+
TaskDependency,
|
|
47
|
+
TaskDependencyListProps,
|
|
48
|
+
TaskDetailPanelProps,
|
|
49
|
+
TaskHandoffPanelProps,
|
|
50
|
+
TaskPriority,
|
|
51
|
+
TaskProgressIndicatorProps,
|
|
52
|
+
TaskRetryPanelProps,
|
|
53
|
+
TaskRunLogEntry,
|
|
54
|
+
TaskRunLogProps,
|
|
55
|
+
TaskStatusBadgeProps,
|
|
56
|
+
TaskStep,
|
|
57
|
+
TaskTableProps,
|
|
58
|
+
TaskTimelineEvent,
|
|
59
|
+
TaskTimelineProps,
|
|
60
|
+
TaskWave,
|
|
61
|
+
TaskWaveDAGProps,
|
|
62
|
+
TaskWaveHeaderProps,
|
|
63
|
+
TaskWaveTableProps
|
|
64
|
+
} from "./types";
|
|
65
|
+
|
|
66
|
+
export const TaskComponentNames = [
|
|
67
|
+
"TaskCard",
|
|
68
|
+
"TaskTable",
|
|
69
|
+
"TaskDetailPanel",
|
|
70
|
+
"TaskStatusBadge",
|
|
71
|
+
"TaskProgressIndicator",
|
|
72
|
+
"TaskDependencyList",
|
|
73
|
+
"TaskTimeline",
|
|
74
|
+
"TaskWaveTable",
|
|
75
|
+
"TaskWaveHeader",
|
|
76
|
+
"TaskWaveDAG",
|
|
77
|
+
"DAGNode",
|
|
78
|
+
"DAGEdge",
|
|
79
|
+
"DAGLegend",
|
|
80
|
+
"TaskApprovalPanel",
|
|
81
|
+
"DecisionRequiredPanel",
|
|
82
|
+
"BackendThinkingChain",
|
|
83
|
+
"HumanInterventionPanel",
|
|
84
|
+
"BlockingReasonPanel",
|
|
85
|
+
"TaskHandoffPanel",
|
|
86
|
+
"TaskRunLog",
|
|
87
|
+
"TaskRetryPanel",
|
|
88
|
+
"MobileTaskShell"
|
|
89
|
+
] as const;
|
|
90
|
+
export type TaskComponentName = (typeof TaskComponentNames)[number];
|