@tangle-network/ui 11.30.2 → 11.31.1
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/CHANGELOG.md +17 -0
- package/dist/index.d.ts +8 -3
- package/dist/index.js +7 -2
- package/dist/markdown/code-block.d.ts +11 -4
- package/dist/markdown/code-block.js +239 -107
- package/dist/markdown/code-meta.d.ts +11 -0
- package/dist/markdown/code-meta.js +98 -0
- package/dist/markdown/markdown-table.d.ts +8 -0
- package/dist/markdown/markdown-table.js +169 -0
- package/dist/markdown/markdown.d.ts +17 -4
- package/dist/markdown/markdown.js +96 -34
- package/dist/markdown.d.ts +5 -3
- package/dist/markdown.js +4 -2
- package/dist/run/approval-card.d.ts +46 -0
- package/dist/run/approval-card.js +162 -0
- package/dist/run/run-phase-list.d.ts +18 -0
- package/dist/run/run-phase-list.js +56 -0
- package/dist/run/task-list.d.ts +33 -0
- package/dist/run/task-list.js +138 -0
- package/dist/run.d.ts +4 -1
- package/dist/run.js +4 -1
- package/package.json +3 -4
- package/src/markdown/code-block.test.tsx +35 -26
- package/src/markdown/code-block.tsx +197 -110
- package/src/markdown/code-meta.ts +68 -0
- package/src/markdown/index.ts +10 -2
- package/src/markdown/markdown-table.test.tsx +63 -0
- package/src/markdown/markdown-table.tsx +191 -0
- package/src/markdown/markdown.test.tsx +27 -0
- package/src/markdown/markdown.tsx +126 -48
- package/src/run/approval-card.test.tsx +41 -0
- package/src/run/approval-card.tsx +146 -0
- package/src/run/index.ts +17 -0
- package/src/run/run-phase-list.test.tsx +25 -0
- package/src/run/run-phase-list.tsx +56 -0
- package/src/run/task-list.test.tsx +35 -0
- package/src/run/task-list.tsx +117 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { useState, type ReactNode } from "react";
|
|
2
|
+
import { ChevronRight, CircleAlert, Loader2 } from "lucide-react";
|
|
3
|
+
import { focusRing } from "../lib/focus";
|
|
4
|
+
import { cn } from "../lib/utils";
|
|
5
|
+
|
|
6
|
+
export interface RunPhase {
|
|
7
|
+
id: string;
|
|
8
|
+
/** The step or phase the work belongs to. */
|
|
9
|
+
title: ReactNode;
|
|
10
|
+
/** One line on what happened, such as "Read 12 files · ran 3 commands". */
|
|
11
|
+
summary?: ReactNode;
|
|
12
|
+
status?: "running" | "done" | "error";
|
|
13
|
+
/** The phase's individual steps, shown when expanded. */
|
|
14
|
+
content: ReactNode;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function PhaseRow({ phase }: { phase: RunPhase }) {
|
|
18
|
+
const [open, setOpen] = useState(false);
|
|
19
|
+
const id = `run-phase-${phase.id}`;
|
|
20
|
+
return (
|
|
21
|
+
<li>
|
|
22
|
+
<button
|
|
23
|
+
type="button"
|
|
24
|
+
aria-expanded={open}
|
|
25
|
+
aria-controls={id}
|
|
26
|
+
onClick={() => setOpen(!open)}
|
|
27
|
+
className={cn(
|
|
28
|
+
"flex w-full items-start gap-2 rounded-md py-1 text-left text-sm transition-colors hover:text-foreground",
|
|
29
|
+
focusRing,
|
|
30
|
+
)}
|
|
31
|
+
>
|
|
32
|
+
<ChevronRight
|
|
33
|
+
aria-hidden
|
|
34
|
+
className={cn("mt-1 size-4 shrink-0 text-muted-foreground transition-transform", open && "rotate-90")}
|
|
35
|
+
/>
|
|
36
|
+
<span className="min-w-0 flex-1 leading-6">
|
|
37
|
+
<span className="font-medium text-foreground">{phase.title}</span>
|
|
38
|
+
{phase.summary && <span className="text-muted-foreground"> · {phase.summary}</span>}
|
|
39
|
+
</span>
|
|
40
|
+
{phase.status === "running" && <Loader2 aria-label="Running" className="mt-1 size-4 shrink-0 animate-spin text-muted-foreground" />}
|
|
41
|
+
{phase.status === "error" && <CircleAlert aria-label="Had an error" className="mt-1 size-4 shrink-0 text-[var(--surface-danger-text)]" />}
|
|
42
|
+
</button>
|
|
43
|
+
{open && <div id={id} className="mb-2 ml-6 mt-1">{phase.content}</div>}
|
|
44
|
+
</li>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A turn's tool runs grouped by step, each collapsed to one summarizing line. */
|
|
49
|
+
export function RunPhaseList({ phases, className }: { phases: readonly RunPhase[]; className?: string }) {
|
|
50
|
+
if (phases.length === 0) return null;
|
|
51
|
+
return (
|
|
52
|
+
<ul className={cn("space-y-0.5", className)} aria-label="Tool runs">
|
|
53
|
+
{phases.map((phase) => <PhaseRow key={phase.id} phase={phase} />)}
|
|
54
|
+
</ul>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { cleanup, render, screen } from "@testing-library/react";
|
|
2
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
3
|
+
import { TaskList, showsTaskPriority, taskProgress } from "./task-list";
|
|
4
|
+
|
|
5
|
+
afterEach(cleanup);
|
|
6
|
+
|
|
7
|
+
const items = [
|
|
8
|
+
{ id: "1", title: "Find the hero source", status: "completed", priority: "high" },
|
|
9
|
+
{ id: "2", title: "Prepare the smallest edit", status: "in_progress", priority: "high" },
|
|
10
|
+
{ id: "3", title: "Open a draft PR", status: "pending", priority: "high" },
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
describe("TaskList", () => {
|
|
14
|
+
it("names progress in the header and each state as a pill", () => {
|
|
15
|
+
render(<TaskList items={items} />);
|
|
16
|
+
expect(screen.getByRole("heading", { name: "Tasks" })).toBeInTheDocument();
|
|
17
|
+
expect(screen.getByText("1 of 3 done")).toBeInTheDocument();
|
|
18
|
+
expect(screen.getByText("Done")).toBeInTheDocument();
|
|
19
|
+
expect(screen.getByText("In progress")).toBeInTheDocument();
|
|
20
|
+
expect(screen.getByText("To do")).toBeInTheDocument();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("hides priority when every task shares it and shows it when they differ", () => {
|
|
24
|
+
const { rerender } = render(<TaskList items={items} />);
|
|
25
|
+
expect(screen.queryByText("High")).toBeNull();
|
|
26
|
+
rerender(<TaskList items={[...items.slice(0, 2), { ...items[2], priority: "low" }]} />);
|
|
27
|
+
expect(screen.getAllByText("High")).toHaveLength(2);
|
|
28
|
+
expect(screen.getByText("Low")).toBeInTheDocument();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("leaves cancelled tasks out of the done count", () => {
|
|
32
|
+
expect(taskProgress([...items, { id: "4", title: "Skip", status: "cancelled" }])).toEqual({ done: 1, total: 3 });
|
|
33
|
+
expect(showsTaskPriority([{ id: "a", title: "a", status: "pending" }])).toBe(false);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import { StatusPill, type StatusTone } from "../primitives/status-pill";
|
|
3
|
+
import { cn } from "../lib/utils";
|
|
4
|
+
|
|
5
|
+
/** The states an agent's working checklist reports; unknown values read as to-do. */
|
|
6
|
+
export type TaskStatus = "pending" | "in_progress" | "completed" | "cancelled" | "blocked" | "failed";
|
|
7
|
+
|
|
8
|
+
export interface TaskListItem {
|
|
9
|
+
id: string;
|
|
10
|
+
title: string;
|
|
11
|
+
status: TaskStatus | (string & {});
|
|
12
|
+
priority?: string;
|
|
13
|
+
description?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface TaskListProps {
|
|
17
|
+
items: readonly TaskListItem[];
|
|
18
|
+
/** Header title; "Tasks" by default. */
|
|
19
|
+
title?: ReactNode;
|
|
20
|
+
/** Trailing header content, such as a run state. */
|
|
21
|
+
meta?: ReactNode;
|
|
22
|
+
className?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const STATUS: Record<TaskStatus, { tone: StatusTone; label: string }> = {
|
|
26
|
+
pending: { tone: "neutral", label: "To do" },
|
|
27
|
+
in_progress: { tone: "running", label: "In progress" },
|
|
28
|
+
completed: { tone: "success", label: "Done" },
|
|
29
|
+
cancelled: { tone: "neutral", label: "Cancelled" },
|
|
30
|
+
blocked: { tone: "warning", label: "Blocked" },
|
|
31
|
+
failed: { tone: "danger", label: "Failed" },
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const STATUS_ALIASES: Record<string, TaskStatus> = {
|
|
35
|
+
todo: "pending", open: "pending", in_progress: "in_progress", "in-progress": "in_progress",
|
|
36
|
+
active: "in_progress", running: "in_progress", done: "completed", complete: "completed",
|
|
37
|
+
completed: "completed", success: "completed", cancelled: "cancelled", canceled: "cancelled",
|
|
38
|
+
skipped: "cancelled", blocked: "blocked", failed: "failed", error: "failed",
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export function taskStatus(status: string): TaskStatus {
|
|
42
|
+
const key = status.trim().toLowerCase();
|
|
43
|
+
return (STATUS as Record<string, unknown>)[key] ? key as TaskStatus : STATUS_ALIASES[key] ?? "pending";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Priority is worth a label only when the tasks disagree about it. */
|
|
47
|
+
export function showsTaskPriority(items: readonly TaskListItem[]): boolean {
|
|
48
|
+
const priorities = new Set(items.map((item) => item.priority?.trim().toLowerCase()).filter(Boolean));
|
|
49
|
+
return priorities.size > 1;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** "2 of 3 done": cancelled tasks leave the count rather than counting as done. */
|
|
53
|
+
export function taskProgress(items: readonly TaskListItem[]): { done: number; total: number } {
|
|
54
|
+
const counted = items.filter((item) => taskStatus(item.status) !== "cancelled");
|
|
55
|
+
return { done: counted.filter((item) => taskStatus(item.status) === "completed").length, total: counted.length };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function capitalized(value: string): string {
|
|
59
|
+
return value.charAt(0).toUpperCase() + value.slice(1);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* An agent's working checklist: a header with progress, each task's state as
|
|
64
|
+
* a toned pill, and priority shown only when the tasks differ in it.
|
|
65
|
+
*/
|
|
66
|
+
export function TaskList({ items, title = "Tasks", meta, className }: TaskListProps) {
|
|
67
|
+
const showPriority = showsTaskPriority(items);
|
|
68
|
+
const { done, total } = taskProgress(items);
|
|
69
|
+
return (
|
|
70
|
+
<section
|
|
71
|
+
aria-label={typeof title === "string" ? title : "Tasks"}
|
|
72
|
+
className={cn("rounded-xl border border-border bg-card p-4 shadow-sm", className)}
|
|
73
|
+
>
|
|
74
|
+
<header className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
|
75
|
+
<div className="flex min-w-0 items-baseline gap-2">
|
|
76
|
+
<h3 className="text-base font-semibold text-foreground">{title}</h3>
|
|
77
|
+
{total > 0 && <span className="text-sm text-muted-foreground">{done} of {total} done</span>}
|
|
78
|
+
</div>
|
|
79
|
+
{meta}
|
|
80
|
+
</header>
|
|
81
|
+
{total > 0 && (
|
|
82
|
+
<div className="mb-3 h-1.5 overflow-hidden rounded-full bg-muted" aria-hidden="true">
|
|
83
|
+
<div
|
|
84
|
+
className="h-full rounded-full bg-[var(--surface-success-text)] transition-[width]"
|
|
85
|
+
style={{ width: `${Math.round((done / total) * 100)}%` }}
|
|
86
|
+
/>
|
|
87
|
+
</div>
|
|
88
|
+
)}
|
|
89
|
+
<ol className="divide-y divide-border/70">
|
|
90
|
+
{items.map((item, index) => {
|
|
91
|
+
const status = STATUS[taskStatus(item.status)];
|
|
92
|
+
return (
|
|
93
|
+
<li key={`${item.id}-${index}`} className="flex items-start justify-between gap-3 py-2.5 first:pt-0 last:pb-0">
|
|
94
|
+
<div className="min-w-0 flex-1">
|
|
95
|
+
<p
|
|
96
|
+
className={cn(
|
|
97
|
+
"text-sm font-medium leading-6 text-foreground",
|
|
98
|
+
taskStatus(item.status) === "cancelled" && "text-muted-foreground line-through",
|
|
99
|
+
)}
|
|
100
|
+
>
|
|
101
|
+
{item.title}
|
|
102
|
+
</p>
|
|
103
|
+
{item.description && <p className="mt-0.5 text-sm leading-6 text-muted-foreground">{item.description}</p>}
|
|
104
|
+
</div>
|
|
105
|
+
<div className="flex shrink-0 items-center gap-2">
|
|
106
|
+
{showPriority && item.priority && (
|
|
107
|
+
<span className="text-sm text-muted-foreground">{capitalized(item.priority.trim())}</span>
|
|
108
|
+
)}
|
|
109
|
+
<StatusPill tone={status.tone} size="md">{status.label}</StatusPill>
|
|
110
|
+
</div>
|
|
111
|
+
</li>
|
|
112
|
+
);
|
|
113
|
+
})}
|
|
114
|
+
</ol>
|
|
115
|
+
</section>
|
|
116
|
+
);
|
|
117
|
+
}
|