@tangle-network/ui 11.30.2 → 11.31.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 (37) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/dist/index.d.ts +8 -3
  3. package/dist/index.js +7 -2
  4. package/dist/markdown/code-block.d.ts +11 -4
  5. package/dist/markdown/code-block.js +239 -107
  6. package/dist/markdown/code-meta.d.ts +11 -0
  7. package/dist/markdown/code-meta.js +98 -0
  8. package/dist/markdown/markdown-table.d.ts +8 -0
  9. package/dist/markdown/markdown-table.js +168 -0
  10. package/dist/markdown/markdown.d.ts +17 -4
  11. package/dist/markdown/markdown.js +96 -34
  12. package/dist/markdown.d.ts +5 -3
  13. package/dist/markdown.js +4 -2
  14. package/dist/run/approval-card.d.ts +46 -0
  15. package/dist/run/approval-card.js +162 -0
  16. package/dist/run/run-phase-list.d.ts +18 -0
  17. package/dist/run/run-phase-list.js +56 -0
  18. package/dist/run/task-list.d.ts +33 -0
  19. package/dist/run/task-list.js +138 -0
  20. package/dist/run.d.ts +4 -1
  21. package/dist/run.js +4 -1
  22. package/package.json +3 -4
  23. package/src/markdown/code-block.test.tsx +35 -26
  24. package/src/markdown/code-block.tsx +197 -110
  25. package/src/markdown/code-meta.ts +68 -0
  26. package/src/markdown/index.ts +10 -2
  27. package/src/markdown/markdown-table.test.tsx +61 -0
  28. package/src/markdown/markdown-table.tsx +191 -0
  29. package/src/markdown/markdown.test.tsx +27 -0
  30. package/src/markdown/markdown.tsx +126 -48
  31. package/src/run/approval-card.test.tsx +41 -0
  32. package/src/run/approval-card.tsx +146 -0
  33. package/src/run/index.ts +17 -0
  34. package/src/run/run-phase-list.test.tsx +25 -0
  35. package/src/run/run-phase-list.tsx +56 -0
  36. package/src/run/task-list.test.tsx +35 -0
  37. 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
+ }