@tangle-network/ui 11.30.1 → 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 (41) hide show
  1. package/CHANGELOG.md +17 -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/primitives/toolbar.d.ts +10 -10
  15. package/dist/primitives/toolbar.js +2 -2
  16. package/dist/run/approval-card.d.ts +46 -0
  17. package/dist/run/approval-card.js +162 -0
  18. package/dist/run/run-phase-list.d.ts +18 -0
  19. package/dist/run/run-phase-list.js +56 -0
  20. package/dist/run/task-list.d.ts +33 -0
  21. package/dist/run/task-list.js +138 -0
  22. package/dist/run.d.ts +4 -1
  23. package/dist/run.js +4 -1
  24. package/package.json +3 -4
  25. package/src/markdown/code-block.test.tsx +35 -26
  26. package/src/markdown/code-block.tsx +197 -110
  27. package/src/markdown/code-meta.ts +68 -0
  28. package/src/markdown/index.ts +10 -2
  29. package/src/markdown/markdown-table.test.tsx +61 -0
  30. package/src/markdown/markdown-table.tsx +191 -0
  31. package/src/markdown/markdown.test.tsx +27 -0
  32. package/src/markdown/markdown.tsx +126 -48
  33. package/src/primitives/toolbar.test.tsx +9 -10
  34. package/src/primitives/toolbar.tsx +12 -12
  35. package/src/run/approval-card.test.tsx +41 -0
  36. package/src/run/approval-card.tsx +146 -0
  37. package/src/run/index.ts +17 -0
  38. package/src/run/run-phase-list.test.tsx +25 -0
  39. package/src/run/run-phase-list.tsx +56 -0
  40. package/src/run/task-list.test.tsx +35 -0
  41. package/src/run/task-list.tsx +117 -0
@@ -0,0 +1,146 @@
1
+ import { type HTMLAttributes, type ReactNode } from "react";
2
+ import { Check, ChevronRight, Circle, FileMinus, FilePen, FilePlus } from "lucide-react";
3
+ import { cn } from "../lib/utils";
4
+
5
+ export interface ApprovalDiffFile {
6
+ path: string;
7
+ change?: "added" | "modified" | "deleted";
8
+ /** Unknown counts render as a dash rather than a guessed zero. */
9
+ additions?: number;
10
+ deletions?: number;
11
+ }
12
+
13
+ export interface ApprovalPlanStep {
14
+ label: ReactNode;
15
+ state: "done" | "current" | "upcoming";
16
+ }
17
+
18
+ export interface ApprovalCardProps extends Omit<HTMLAttributes<HTMLElement>, "title"> {
19
+ /** What the action does, in plain words. */
20
+ title: ReactNode;
21
+ /** Where it acts, such as "GitHub · owner/repo". */
22
+ context?: ReactNode;
23
+ /** This action's place in a declared plan. */
24
+ step?: { index: number; total: number };
25
+ /** The plan's steps, when the action belongs to one. */
26
+ plan?: readonly ApprovalPlanStep[];
27
+ /** Files the action writes, with line counts when known. */
28
+ diff?: readonly ApprovalDiffFile[];
29
+ /** Shown while line counts are being read. */
30
+ diffNote?: ReactNode;
31
+ /** The raw request, behind a Details toggle. */
32
+ details?: ReactNode;
33
+ status?: ReactNode;
34
+ actions?: ReactNode;
35
+ }
36
+
37
+ const CHANGE_ICON = { added: FilePlus, modified: FilePen, deleted: FileMinus } as const;
38
+
39
+ function count(value: number | undefined, sign: "+" | "−"): string {
40
+ return value === undefined ? `${sign}–` : `${sign}${value}`;
41
+ }
42
+
43
+ /** Files and +/− line counts for a write, with totals when every count is known. */
44
+ export function ApprovalDiffSummary({ files, note }: { files: readonly ApprovalDiffFile[]; note?: ReactNode }) {
45
+ const known = files.every((file) => file.additions !== undefined && file.deletions !== undefined);
46
+ const additions = files.reduce((sum, file) => sum + (file.additions ?? 0), 0);
47
+ const deletions = files.reduce((sum, file) => sum + (file.deletions ?? 0), 0);
48
+ return (
49
+ <div className="rounded-lg border border-border">
50
+ <div className="flex flex-wrap items-center justify-between gap-2 border-b border-border px-3 py-2 text-sm">
51
+ <span className="font-medium text-foreground">{files.length === 1 ? "1 file" : `${files.length} files`}</span>
52
+ {known
53
+ ? (
54
+ <span className="tabular-nums">
55
+ <span className="text-[var(--surface-success-text)]">+{additions}</span>{" "}
56
+ <span className="text-[var(--surface-danger-text)]">−{deletions}</span>
57
+ </span>
58
+ )
59
+ : note && <span className="text-muted-foreground">{note}</span>}
60
+ </div>
61
+ <ul className="divide-y divide-border/70">
62
+ {files.map((file) => {
63
+ const Icon = CHANGE_ICON[file.change ?? "modified"];
64
+ return (
65
+ <li key={file.path} className="flex items-center justify-between gap-3 px-3 py-2 text-sm">
66
+ <span className="flex min-w-0 items-center gap-2">
67
+ <Icon aria-hidden className="size-4 shrink-0 text-muted-foreground" />
68
+ <span className="min-w-0 truncate font-mono text-foreground" title={file.path}>{file.path}</span>
69
+ </span>
70
+ <span className="shrink-0 tabular-nums">
71
+ <span className="text-[var(--surface-success-text)]">{count(file.additions, "+")}</span>{" "}
72
+ <span className="text-[var(--surface-danger-text)]">{count(file.deletions, "−")}</span>
73
+ </span>
74
+ </li>
75
+ );
76
+ })}
77
+ </ul>
78
+ </div>
79
+ );
80
+ }
81
+
82
+ function PlanMarker({ state }: { state: ApprovalPlanStep["state"] }) {
83
+ if (state === "done") return <Check aria-hidden className="size-4 shrink-0 text-[var(--surface-success-text)]" />;
84
+ if (state === "current") return <ChevronRight aria-hidden className="size-4 shrink-0 text-foreground" />;
85
+ return <Circle aria-hidden className="size-3.5 shrink-0 text-muted-foreground" />;
86
+ }
87
+
88
+ /**
89
+ * A request for a person to approve one action: what it does in plain words,
90
+ * where, its step in a plan, the files it writes, and the raw request behind
91
+ * a Details toggle. The caller owns the decision and supplies the actions.
92
+ */
93
+ export function ApprovalCard({
94
+ title, context, step, plan, diff, diffNote, details, status, actions, className, ...props
95
+ }: ApprovalCardProps) {
96
+ return (
97
+ <section
98
+ role="group"
99
+ aria-label="Approval request"
100
+ {...props}
101
+ className={cn("space-y-3 rounded-xl border border-border bg-card p-4 shadow-sm", className)}
102
+ >
103
+ <div className="space-y-1">
104
+ {(step || context) && (
105
+ <p className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
106
+ {step && (
107
+ <span className="rounded-md bg-muted px-2 py-0.5 font-medium text-foreground">
108
+ Step {step.index} of {step.total}
109
+ </span>
110
+ )}
111
+ {context && <span className="min-w-0 truncate">{context}</span>}
112
+ </p>
113
+ )}
114
+ <h3 className="text-base font-semibold leading-6 text-foreground">{title}</h3>
115
+ </div>
116
+ {plan && plan.length > 0 && (
117
+ <ol className="space-y-1.5" aria-label="Plan">
118
+ {plan.map((item, index) => (
119
+ <li
120
+ key={index}
121
+ aria-current={item.state === "current" ? "step" : undefined}
122
+ className={cn(
123
+ "flex items-start gap-2 text-sm leading-6",
124
+ item.state === "current" ? "font-medium text-foreground" : "text-muted-foreground",
125
+ )}
126
+ >
127
+ <span className="mt-1"><PlanMarker state={item.state} /></span>
128
+ <span className="min-w-0">{item.label}</span>
129
+ </li>
130
+ ))}
131
+ </ol>
132
+ )}
133
+ {diff && diff.length > 0 && <ApprovalDiffSummary files={diff} note={diffNote} />}
134
+ {details !== undefined && (
135
+ <details className="group text-sm">
136
+ <summary className="cursor-pointer select-none font-medium text-muted-foreground hover:text-foreground">
137
+ Details
138
+ </summary>
139
+ <div className="mt-2">{details}</div>
140
+ </details>
141
+ )}
142
+ {status}
143
+ {actions && <div className="flex flex-wrap gap-2">{actions}</div>}
144
+ </section>
145
+ );
146
+ }
package/src/run/index.ts CHANGED
@@ -20,3 +20,20 @@ export { LiveDuration } from "./run-item-primitives";
20
20
  // because ToolCallData references it.
21
21
  export { type ToolCallType, type ToolCallStatus } from "./tool-call-step";
22
22
  export { ToolCallFeed, parseToolEvent, type ToolCallFeedProps, type ToolCallData, type FeedSegment } from "./tool-call-feed";
23
+ export {
24
+ TaskList,
25
+ taskProgress,
26
+ taskStatus,
27
+ showsTaskPriority,
28
+ type TaskListItem,
29
+ type TaskListProps,
30
+ type TaskStatus,
31
+ } from "./task-list";
32
+ export {
33
+ ApprovalCard,
34
+ ApprovalDiffSummary,
35
+ type ApprovalCardProps,
36
+ type ApprovalDiffFile,
37
+ type ApprovalPlanStep,
38
+ } from "./approval-card";
39
+ export { RunPhaseList, type RunPhase } from "./run-phase-list";
@@ -0,0 +1,25 @@
1
+ import { cleanup, fireEvent, render, screen } from "@testing-library/react";
2
+ import { afterEach, describe, expect, it } from "vitest";
3
+ import { RunPhaseList } from "./run-phase-list";
4
+
5
+ afterEach(cleanup);
6
+
7
+ describe("RunPhaseList", () => {
8
+ it("collapses each phase to one summarizing line and expands its steps", () => {
9
+ render(
10
+ <RunPhaseList
11
+ phases={[
12
+ { id: "a", title: "Find the hero source", summary: "Read 12 files · searched 3 times", content: <p>step detail</p> },
13
+ { id: "b", title: "Open a draft PR", summary: "4 GitHub calls", status: "error", content: <p>pr detail</p> },
14
+ ]}
15
+ />,
16
+ );
17
+ const first = screen.getByRole("button", { name: /Find the hero source/ });
18
+ expect(first.textContent).toContain("Read 12 files · searched 3 times");
19
+ expect(first.getAttribute("aria-expanded")).toBe("false");
20
+ expect(screen.queryByText("step detail")).toBeNull();
21
+ fireEvent.click(first);
22
+ expect(screen.getByText("step detail")).toBeInTheDocument();
23
+ expect(screen.getByLabelText("Had an error")).toBeInTheDocument();
24
+ });
25
+ });
@@ -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
+ }