@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.
- package/CHANGELOG.md +11 -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 +168 -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 +61 -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,191 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Children,
|
|
3
|
+
createContext,
|
|
4
|
+
isValidElement,
|
|
5
|
+
useCallback,
|
|
6
|
+
useContext,
|
|
7
|
+
useEffect,
|
|
8
|
+
useMemo,
|
|
9
|
+
useRef,
|
|
10
|
+
useState,
|
|
11
|
+
type ComponentProps,
|
|
12
|
+
} from "react";
|
|
13
|
+
import type { ExtraProps } from "react-markdown";
|
|
14
|
+
import { Check, Copy } from "lucide-react";
|
|
15
|
+
import { focusRing, focusRingInset } from "../lib/focus";
|
|
16
|
+
import { cn } from "../lib/utils";
|
|
17
|
+
|
|
18
|
+
type Element = NonNullable<ExtraProps["node"]>;
|
|
19
|
+
type ElementContent = Element["children"][number];
|
|
20
|
+
|
|
21
|
+
/** A figure: optional sign or currency, digits with separators, an optional unit. */
|
|
22
|
+
const NUMERIC_CELL = /^[(]?[-+−]?\s?[$€£¥₹]?\s?\d[\d,_ ]*(?:\.\d+)?\s?(?:%|[kKmMbB]|x|ms|s|h|pp)?[)]?$/;
|
|
23
|
+
|
|
24
|
+
/** The text of a hast subtree, as a reader sees it. */
|
|
25
|
+
function textOf(node: ElementContent | Element): string {
|
|
26
|
+
if (node.type === "text") return node.value;
|
|
27
|
+
if (node.type !== "element") return "";
|
|
28
|
+
return node.children.map(textOf).join("");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function rowsOf(table: Element): { header: string[][]; body: string[][] } {
|
|
32
|
+
const header: string[][] = [];
|
|
33
|
+
const body: string[][] = [];
|
|
34
|
+
const visit = (element: Element, inHead: boolean) => {
|
|
35
|
+
for (const child of element.children) {
|
|
36
|
+
if (child.type !== "element") continue;
|
|
37
|
+
if (child.tagName === "tr") {
|
|
38
|
+
const cells = child.children
|
|
39
|
+
.filter((cell): cell is Element => cell.type === "element" && (cell.tagName === "th" || cell.tagName === "td"))
|
|
40
|
+
.map((cell) => textOf(cell).replace(/\s+/g, " ").trim());
|
|
41
|
+
(inHead ? header : body).push(cells);
|
|
42
|
+
} else {
|
|
43
|
+
visit(child, inHead || child.tagName === "thead");
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
visit(table, false);
|
|
48
|
+
return { header, body };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A column is numeric when every non-empty body cell is a figure. */
|
|
52
|
+
export function numericColumns(body: string[][]): boolean[] {
|
|
53
|
+
const width = Math.max(0, ...body.map((row) => row.length));
|
|
54
|
+
return Array.from({ length: width }, (_, column) => {
|
|
55
|
+
const cells = body.map((row) => row[column] ?? "").filter(Boolean);
|
|
56
|
+
return cells.length > 0 && cells.every((cell) => NUMERIC_CELL.test(cell));
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function csvCell(value: string): string {
|
|
61
|
+
// A spreadsheet runs a leading = + - @ as a formula unless the cell is a figure.
|
|
62
|
+
const safe = /^[=+\-@\t\r]/.test(value) && !NUMERIC_CELL.test(value) ? `'${value}` : value;
|
|
63
|
+
return /[",\n\r]/.test(safe) || safe !== safe.trim() ? `"${safe.replace(/"/g, '""')}"` : safe;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** RFC 4180 CSV of a rendered Markdown table, header first. */
|
|
67
|
+
export function tableCsv(rows: string[][]): string {
|
|
68
|
+
return rows.map((row) => row.map(csvCell).join(",")).join("\n");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const TableColumns = createContext<boolean[]>([]);
|
|
72
|
+
const CellColumn = createContext(-1);
|
|
73
|
+
|
|
74
|
+
function CopyCsvButton({ csv }: { csv: string }) {
|
|
75
|
+
const [state, setState] = useState<"idle" | "copied" | "failed">("idle");
|
|
76
|
+
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
77
|
+
useEffect(() => () => { if (timer.current) clearTimeout(timer.current); }, []);
|
|
78
|
+
const copy = useCallback(async () => {
|
|
79
|
+
if (timer.current) clearTimeout(timer.current);
|
|
80
|
+
try {
|
|
81
|
+
await navigator.clipboard.writeText(csv);
|
|
82
|
+
setState("copied");
|
|
83
|
+
} catch {
|
|
84
|
+
setState("failed");
|
|
85
|
+
}
|
|
86
|
+
timer.current = setTimeout(() => setState("idle"), 2000);
|
|
87
|
+
}, [csv]);
|
|
88
|
+
return (
|
|
89
|
+
<>
|
|
90
|
+
<button
|
|
91
|
+
type="button"
|
|
92
|
+
onClick={copy}
|
|
93
|
+
className={cn(
|
|
94
|
+
"inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-sm font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground",
|
|
95
|
+
focusRing,
|
|
96
|
+
)}
|
|
97
|
+
>
|
|
98
|
+
{state === "copied"
|
|
99
|
+
? <Check aria-hidden className="size-4 text-[var(--surface-success-text)]" />
|
|
100
|
+
: <Copy aria-hidden className="size-4" />}
|
|
101
|
+
{state === "copied" ? "Copied" : "Copy CSV"}
|
|
102
|
+
</button>
|
|
103
|
+
<span className="sr-only" role="status" aria-live="polite">
|
|
104
|
+
{state === "copied" ? "Table copied as CSV" : state === "failed" ? "Copy failed" : ""}
|
|
105
|
+
</span>
|
|
106
|
+
</>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* A Markdown table as a card: a header row, subtle row lines, figures
|
|
112
|
+
* right-aligned in tabular numerals, a copy-as-CSV control, and horizontal
|
|
113
|
+
* scrolling inside the card when the columns outgrow a phone.
|
|
114
|
+
*/
|
|
115
|
+
export function MarkdownTable({ node, className, children, ...props }: ComponentProps<"table"> & ExtraProps) {
|
|
116
|
+
const { csv, numeric, rowCount } = useMemo(() => {
|
|
117
|
+
const { header, body } = node ? rowsOf(node) : { header: [], body: [] };
|
|
118
|
+
return { csv: tableCsv([...header, ...body]), numeric: numericColumns(body), rowCount: body.length };
|
|
119
|
+
}, [node]);
|
|
120
|
+
return (
|
|
121
|
+
<div className="not-prose my-4 overflow-hidden rounded-lg border border-border bg-card">
|
|
122
|
+
<div className="flex items-center justify-between gap-2 border-b border-border px-3 py-1">
|
|
123
|
+
<span className="text-sm text-muted-foreground">{rowCount === 1 ? "1 row" : `${rowCount} rows`}</span>
|
|
124
|
+
<CopyCsvButton csv={csv} />
|
|
125
|
+
</div>
|
|
126
|
+
{/* A keyboard reaches a sideways-scrolling table only when it can take focus. */}
|
|
127
|
+
<div tabIndex={0} role="region" aria-label="Table" className={cn("overflow-x-auto", focusRingInset)}>
|
|
128
|
+
<TableColumns.Provider value={numeric}>
|
|
129
|
+
<table {...props} className={cn("w-full border-collapse text-sm text-foreground", className)}>
|
|
130
|
+
{children}
|
|
131
|
+
</table>
|
|
132
|
+
</TableColumns.Provider>
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function MarkdownTableHead({ node: _node, className, ...props }: ComponentProps<"thead"> & ExtraProps) {
|
|
139
|
+
return <thead {...props} className={cn("bg-muted/50", className)} />;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function MarkdownTableBody({ node: _node, className, ...props }: ComponentProps<"tbody"> & ExtraProps) {
|
|
143
|
+
return <tbody {...props} className={cn("[&>tr:last-child>td]:border-b-0", className)} />;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function MarkdownTableRow({ node: _node, children, ...props }: ComponentProps<"tr"> & ExtraProps) {
|
|
147
|
+
let column = 0;
|
|
148
|
+
return (
|
|
149
|
+
<tr {...props}>
|
|
150
|
+
{Children.map(children, (child) =>
|
|
151
|
+
isValidElement(child)
|
|
152
|
+
? <CellColumn.Provider value={column++}>{child}</CellColumn.Provider>
|
|
153
|
+
: child,
|
|
154
|
+
)}
|
|
155
|
+
</tr>
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function useNumericCell(): boolean {
|
|
160
|
+
const numeric = useContext(TableColumns);
|
|
161
|
+
const column = useContext(CellColumn);
|
|
162
|
+
return column >= 0 && numeric[column] === true;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function MarkdownTableHeaderCell({ node: _node, className, ...props }: ComponentProps<"th"> & ExtraProps) {
|
|
166
|
+
const numeric = useNumericCell();
|
|
167
|
+
return (
|
|
168
|
+
<th
|
|
169
|
+
{...props}
|
|
170
|
+
className={cn(
|
|
171
|
+
"whitespace-nowrap border-b border-border px-3 py-2 text-left font-semibold text-foreground",
|
|
172
|
+
numeric && "text-right",
|
|
173
|
+
className,
|
|
174
|
+
)}
|
|
175
|
+
/>
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function MarkdownTableCell({ node: _node, className, ...props }: ComponentProps<"td"> & ExtraProps) {
|
|
180
|
+
const numeric = useNumericCell();
|
|
181
|
+
return (
|
|
182
|
+
<td
|
|
183
|
+
{...props}
|
|
184
|
+
className={cn(
|
|
185
|
+
"border-b border-border/60 px-3 py-2 align-top",
|
|
186
|
+
numeric ? "whitespace-nowrap text-right tabular-nums" : "min-w-[8rem]",
|
|
187
|
+
className,
|
|
188
|
+
)}
|
|
189
|
+
/>
|
|
190
|
+
);
|
|
191
|
+
}
|
|
@@ -136,4 +136,31 @@ describe("Markdown", () => {
|
|
|
136
136
|
);
|
|
137
137
|
expect(screen.queryByRole("link")).toBeNull();
|
|
138
138
|
});
|
|
139
|
+
|
|
140
|
+
it("renders a fenced block's file name and highlights it as that file's language", () => {
|
|
141
|
+
render(<Markdown>{'```ts title="src/lib/ctr.ts"\nexport const x = 1;\n```'}</Markdown>);
|
|
142
|
+
expect(screen.getByText("src/lib/ctr.ts")).toBeInTheDocument();
|
|
143
|
+
expect(screen.getByText("TypeScript")).toBeInTheDocument();
|
|
144
|
+
expect(screen.getByRole("button", { name: "Copy to clipboard" })).toBeInTheDocument();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("renders a single-line fence without a language as a block, not inline code", () => {
|
|
148
|
+
const { container } = render(<Markdown>{"```\nnpm run build\n```\n\nRun `npm test` too."}</Markdown>);
|
|
149
|
+
expect(container.querySelectorAll("pre")).toHaveLength(1);
|
|
150
|
+
expect(container.querySelector("pre")?.textContent).toBe("npm run build");
|
|
151
|
+
expect(container.querySelector("p code")?.textContent).toBe("npm test");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("renders links the host classifies as chips that keep their href", () => {
|
|
155
|
+
render(
|
|
156
|
+
<Markdown linkChip={(href) => href.startsWith("/vault/") ? { kind: "file", label: "REVIEW.md", title: "campaigns/REVIEW.md" } : null}>
|
|
157
|
+
{"See [the review](/vault/REVIEW.md) and [docs](https://example.com)."}
|
|
158
|
+
</Markdown>,
|
|
159
|
+
);
|
|
160
|
+
const chip = screen.getByRole("link", { name: "REVIEW.md" });
|
|
161
|
+
expect(chip.getAttribute("href")).toBe("/vault/REVIEW.md");
|
|
162
|
+
expect(chip.getAttribute("data-link-chip")).toBe("file");
|
|
163
|
+
expect(chip.getAttribute("title")).toBe("campaigns/REVIEW.md");
|
|
164
|
+
expect(screen.getByRole("link", { name: "docs" }).hasAttribute("data-link-chip")).toBe(false);
|
|
165
|
+
});
|
|
139
166
|
});
|
|
@@ -9,11 +9,21 @@ import {
|
|
|
9
9
|
} from "react";
|
|
10
10
|
import ReactMarkdown, { defaultUrlTransform, type ExtraProps, type UrlTransform } from "react-markdown";
|
|
11
11
|
import remarkGfm from "remark-gfm";
|
|
12
|
-
import rehypeSanitize from "rehype-sanitize";
|
|
12
|
+
import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
|
|
13
13
|
import rehypeSlug from "rehype-slug";
|
|
14
14
|
import GithubSlugger from "github-slugger";
|
|
15
|
-
import { ImageOff } from "lucide-react";
|
|
15
|
+
import { FileImage, FileText, Folder, ImageOff, Package, type LucideIcon } from "lucide-react";
|
|
16
16
|
import { CodeBlock, CopyButton } from "./code-block";
|
|
17
|
+
import { fenceInfo, remarkCodeMeta } from "./code-meta";
|
|
18
|
+
import {
|
|
19
|
+
MarkdownTable,
|
|
20
|
+
MarkdownTableBody,
|
|
21
|
+
MarkdownTableCell,
|
|
22
|
+
MarkdownTableHead,
|
|
23
|
+
MarkdownTableHeaderCell,
|
|
24
|
+
MarkdownTableRow,
|
|
25
|
+
} from "./markdown-table";
|
|
26
|
+
import { focusRing } from "../lib/focus";
|
|
17
27
|
import { cn } from "../lib/utils";
|
|
18
28
|
|
|
19
29
|
const SANITIZED_HEADING_ID_PREFIX = "user-content-";
|
|
@@ -50,17 +60,101 @@ function transformMarkdownUrl(
|
|
|
50
60
|
return safeUrl(transformed);
|
|
51
61
|
}
|
|
52
62
|
|
|
63
|
+
/** How a link to a known object renders: a chip with that object's icon. */
|
|
64
|
+
export interface MarkdownLinkChip {
|
|
65
|
+
kind: "file" | "image" | "asset" | "folder";
|
|
66
|
+
/** Chip text; the link's own text when omitted. */
|
|
67
|
+
label?: string;
|
|
68
|
+
/** Tooltip, such as the full path. */
|
|
69
|
+
title?: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Classifies a link by its transformed href; null keeps an ordinary link. */
|
|
73
|
+
export type MarkdownLinkChipResolver = (href: string) => MarkdownLinkChip | null | undefined;
|
|
74
|
+
|
|
75
|
+
const LINK_CHIP_ICONS: Record<MarkdownLinkChip["kind"], LucideIcon> = {
|
|
76
|
+
file: FileText,
|
|
77
|
+
image: FileImage,
|
|
78
|
+
asset: Package,
|
|
79
|
+
folder: Folder,
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const LinkChipContext = createContext<MarkdownLinkChipResolver | undefined>(undefined);
|
|
83
|
+
|
|
53
84
|
// An image already wrapped in a Markdown link keeps that link: anchors cannot nest.
|
|
54
85
|
const MarkdownLinkContext = createContext(false);
|
|
55
86
|
|
|
56
|
-
function MarkdownLink({ node: _node, ...props }: ComponentProps<"a"> & ExtraProps) {
|
|
87
|
+
function MarkdownLink({ node: _node, children, className, ...props }: ComponentProps<"a"> & ExtraProps) {
|
|
88
|
+
const resolveChip = useContext(LinkChipContext);
|
|
89
|
+
const chip = typeof props.href === "string" && props.href ? resolveChip?.(props.href) : null;
|
|
90
|
+
if (!chip) {
|
|
91
|
+
return (
|
|
92
|
+
<MarkdownLinkContext.Provider value>
|
|
93
|
+
<a {...props} className={className}>{children}</a>
|
|
94
|
+
</MarkdownLinkContext.Provider>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
const Icon = LINK_CHIP_ICONS[chip.kind];
|
|
57
98
|
return (
|
|
58
99
|
<MarkdownLinkContext.Provider value>
|
|
59
|
-
<a
|
|
100
|
+
<a
|
|
101
|
+
{...props}
|
|
102
|
+
title={chip.title ?? props.title}
|
|
103
|
+
data-link-chip={chip.kind}
|
|
104
|
+
className={cn(
|
|
105
|
+
"not-prose inline-flex max-w-full items-center gap-1.5 rounded-md border border-border bg-muted/60 px-2 py-0.5 align-middle text-[0.9375em] font-medium leading-6 text-foreground no-underline transition-colors hover:border-[var(--border-strong)] hover:bg-muted",
|
|
106
|
+
focusRing,
|
|
107
|
+
className,
|
|
108
|
+
)}
|
|
109
|
+
>
|
|
110
|
+
<Icon aria-hidden className="size-4 shrink-0 text-muted-foreground" />
|
|
111
|
+
<span className="min-w-0 truncate">{chip.label ?? children}</span>
|
|
112
|
+
</a>
|
|
60
113
|
</MarkdownLinkContext.Provider>
|
|
61
114
|
);
|
|
62
115
|
}
|
|
63
116
|
|
|
117
|
+
// Fenced code arrives as <pre><code>; inline code is a bare <code>.
|
|
118
|
+
const PreContext = createContext(false);
|
|
119
|
+
|
|
120
|
+
function MarkdownPre({ children }: ComponentProps<"pre"> & ExtraProps) {
|
|
121
|
+
return <PreContext.Provider value>{children}</PreContext.Provider>;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function MarkdownCode({ node, className: codeClass, children: codeChildren, ...rest }: ComponentProps<"code"> & ExtraProps) {
|
|
125
|
+
const fenced = useContext(PreContext);
|
|
126
|
+
const code = String(codeChildren).replace(/\n$/, "");
|
|
127
|
+
if (!fenced) {
|
|
128
|
+
const { "data-meta": _meta, ...attributes } = rest as typeof rest & { "data-meta"?: unknown };
|
|
129
|
+
return (
|
|
130
|
+
<code
|
|
131
|
+
className={cn(
|
|
132
|
+
"px-1.5 py-0.5 rounded border border-border bg-muted text-foreground text-[0.85em] font-mono",
|
|
133
|
+
codeClass,
|
|
134
|
+
)}
|
|
135
|
+
{...attributes}
|
|
136
|
+
>
|
|
137
|
+
{codeChildren}
|
|
138
|
+
</code>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
const { language, filename } = fenceInfo(codeClass, node?.properties?.dataMeta);
|
|
142
|
+
return (
|
|
143
|
+
<CodeBlock code={code} language={language} filename={filename} className="my-4">
|
|
144
|
+
<CopyButton text={code} />
|
|
145
|
+
</CodeBlock>
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// The default schema plus a fence's info string on <code>, which names its file.
|
|
150
|
+
const sanitizeSchema = {
|
|
151
|
+
...defaultSchema,
|
|
152
|
+
attributes: {
|
|
153
|
+
...defaultSchema.attributes,
|
|
154
|
+
code: [...(defaultSchema.attributes?.code ?? []), "dataMeta"],
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
|
|
64
158
|
/**
|
|
65
159
|
* Renders a Markdown image that opens full size in a new tab, or a labelled
|
|
66
160
|
* placeholder when its URL is unsafe or fails to load, instead of the
|
|
@@ -131,58 +225,42 @@ export interface MarkdownProps {
|
|
|
131
225
|
className?: string;
|
|
132
226
|
/** Transform parsed link and image URLs while retaining protocol safety checks. */
|
|
133
227
|
urlTransform?: UrlTransform;
|
|
228
|
+
/** Render links to known objects (a file, an asset) as chips that keep their href. */
|
|
229
|
+
linkChip?: MarkdownLinkChipResolver;
|
|
134
230
|
}
|
|
135
231
|
|
|
136
232
|
/**
|
|
137
|
-
* Renders Markdown content with GFM support
|
|
138
|
-
*
|
|
139
|
-
* or show a labelled placeholder
|
|
233
|
+
* Renders Markdown content with GFM support and XSS sanitisation: Shiki code
|
|
234
|
+
* blocks that name their file, tables as cards with right-aligned figures and
|
|
235
|
+
* a CSV copy, images that open full size or show a labelled placeholder, and
|
|
236
|
+
* optional chips for links to known objects.
|
|
140
237
|
*/
|
|
141
|
-
export const Markdown = memo(({ children, className, urlTransform }: MarkdownProps) => {
|
|
238
|
+
export const Markdown = memo(({ children, className, urlTransform, linkChip }: MarkdownProps) => {
|
|
142
239
|
return (
|
|
143
240
|
<div
|
|
144
241
|
className={cn("tangle-prose max-w-none text-sm", className)}
|
|
145
242
|
>
|
|
146
|
-
<
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
codeClass,
|
|
168
|
-
)}
|
|
169
|
-
{...rest}
|
|
170
|
-
>
|
|
171
|
-
{codeChildren}
|
|
172
|
-
</code>
|
|
173
|
-
);
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
return (
|
|
177
|
-
<CodeBlock code={code} language={language}>
|
|
178
|
-
<CopyButton text={code} />
|
|
179
|
-
</CodeBlock>
|
|
180
|
-
);
|
|
181
|
-
},
|
|
182
|
-
}}
|
|
183
|
-
>
|
|
184
|
-
{children}
|
|
185
|
-
</ReactMarkdown>
|
|
243
|
+
<LinkChipContext.Provider value={linkChip}>
|
|
244
|
+
<ReactMarkdown
|
|
245
|
+
remarkPlugins={[remarkGfm, remarkCodeMeta]}
|
|
246
|
+
rehypePlugins={[rehypeSlug, [rehypeSanitize, sanitizeSchema]]}
|
|
247
|
+
urlTransform={(url, key, node) => transformMarkdownUrl(url, key, node, urlTransform)}
|
|
248
|
+
components={{
|
|
249
|
+
a: MarkdownLink,
|
|
250
|
+
img: MarkdownImage,
|
|
251
|
+
pre: MarkdownPre,
|
|
252
|
+
code: MarkdownCode,
|
|
253
|
+
table: MarkdownTable,
|
|
254
|
+
thead: MarkdownTableHead,
|
|
255
|
+
tbody: MarkdownTableBody,
|
|
256
|
+
tr: MarkdownTableRow,
|
|
257
|
+
th: MarkdownTableHeaderCell,
|
|
258
|
+
td: MarkdownTableCell,
|
|
259
|
+
}}
|
|
260
|
+
>
|
|
261
|
+
{children}
|
|
262
|
+
</ReactMarkdown>
|
|
263
|
+
</LinkChipContext.Provider>
|
|
186
264
|
</div>
|
|
187
265
|
);
|
|
188
266
|
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
|
|
2
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
3
|
+
import { ApprovalCard } from "./approval-card";
|
|
4
|
+
|
|
5
|
+
afterEach(cleanup);
|
|
6
|
+
|
|
7
|
+
describe("ApprovalCard", () => {
|
|
8
|
+
it("shows the plain-language action, its step, the plan, the diff and the raw input behind Details", () => {
|
|
9
|
+
render(
|
|
10
|
+
<ApprovalCard
|
|
11
|
+
title="Create a commit for src/routes/_index.tsx in tangle-network/gtm-agent"
|
|
12
|
+
context="GitHub · tangle-network/gtm-agent"
|
|
13
|
+
step={{ index: 1, total: 4 }}
|
|
14
|
+
plan={[
|
|
15
|
+
{ label: "Write the change", state: "current" },
|
|
16
|
+
{ label: "Commit it", state: "upcoming" },
|
|
17
|
+
]}
|
|
18
|
+
diff={[{ path: "src/routes/_index.tsx", change: "modified", additions: 3, deletions: 1 }]}
|
|
19
|
+
details={<pre>{'{"tree":[]}'}</pre>}
|
|
20
|
+
actions={<button type="button">Approve all 4 steps</button>}
|
|
21
|
+
/>,
|
|
22
|
+
);
|
|
23
|
+
const card = screen.getByRole("group", { name: "Approval request" });
|
|
24
|
+
expect(within(card).getByRole("heading").textContent).toContain("Create a commit for src/routes/_index.tsx");
|
|
25
|
+
expect(within(card).getByText("Step 1 of 4")).toBeInTheDocument();
|
|
26
|
+
expect(within(card).getByRole("list", { name: "Plan" }).querySelector('[aria-current="step"]')?.textContent).toBe("Write the change");
|
|
27
|
+
expect(within(card).getByText("1 file")).toBeInTheDocument();
|
|
28
|
+
expect(within(card).getAllByText("+3")).toHaveLength(2);
|
|
29
|
+
const details = within(card).getByText("Details").closest("details")!;
|
|
30
|
+
expect(details.open).toBe(false);
|
|
31
|
+
fireEvent.click(within(card).getByText("Details"));
|
|
32
|
+
expect(details.open).toBe(true);
|
|
33
|
+
expect(within(card).getByRole("button", { name: "Approve all 4 steps" })).toBeInTheDocument();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("marks unknown line counts instead of guessing zero", () => {
|
|
37
|
+
render(<ApprovalCard title="Write" diff={[{ path: "a.ts", additions: 2 }]} diffNote="Counting lines…" />);
|
|
38
|
+
expect(screen.getByText("Counting lines…")).toBeInTheDocument();
|
|
39
|
+
expect(screen.getByText("−–")).toBeInTheDocument();
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -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
|
+
});
|