@tangle-network/ui 11.11.2 → 11.11.4

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 (39) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +9 -1
  3. package/dist/{chat-D-9Fm8A4.js → chat-CpzvkA6m.js} +7 -7
  4. package/dist/chat.d.ts +1 -1
  5. package/dist/chat.js +1 -1
  6. package/dist/{document-editor-pane-BcoABlhn.d.ts → document-editor-pane-DHM8DDhb.d.ts} +4 -1
  7. package/dist/{document-editor-pane-BxlPjaTP.js → document-editor-pane-LyanNypc.js} +6 -14
  8. package/dist/editor.d.ts +1 -1
  9. package/dist/editor.js +1 -1
  10. package/dist/{files-Bug33qCl.js → files-DgPc3vI2.js} +16 -7
  11. package/dist/files.d.ts +1 -1
  12. package/dist/files.js +1 -1
  13. package/dist/{index-DhYmSaLF.d.ts → index-CyGSLGVF.d.ts} +6 -17
  14. package/dist/{index-CUhfHT_V.d.ts → index-aoBxiKex.d.ts} +2 -2
  15. package/dist/index.d.ts +4 -3
  16. package/dist/index.js +6 -5
  17. package/dist/{openui-artifact-renderer-DrU1AdVk.js → openui-artifact-renderer-C3bmqfjX.js} +15 -20
  18. package/dist/openui-schema.d.ts +3 -0
  19. package/dist/openui-schema.js +2 -0
  20. package/dist/openui.d.ts +2 -1
  21. package/dist/openui.js +3 -2
  22. package/dist/{run-group-BACLbCFl.js → run-group-CxNskmHd.js} +1 -1
  23. package/dist/run.js +1 -1
  24. package/dist/schema-BFKgCGH-.d.ts +228 -0
  25. package/dist/schema-BpW9rSSG.js +9754 -0
  26. package/package.json +10 -3
  27. package/src/chat/user-message.test.tsx +9 -3
  28. package/src/chat/user-message.tsx +7 -5
  29. package/src/editor/document-editor-pane.tsx +5 -1
  30. package/src/files/file-artifact-pane.test.tsx +41 -0
  31. package/src/files/file-artifact-pane.tsx +3 -0
  32. package/src/files/file-preview.tsx +16 -3
  33. package/src/openui/index.ts +10 -0
  34. package/src/openui/openui-artifact-renderer.stories.tsx +36 -0
  35. package/src/openui/openui-artifact-renderer.tsx +19 -27
  36. package/src/openui/schema-compiled.ts +3 -0
  37. package/src/openui/schema-data.ts +95 -0
  38. package/src/openui/schema.test.tsx +74 -0
  39. package/src/openui/schema.ts +136 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tangle-network/ui",
3
- "version": "11.11.2",
3
+ "version": "11.11.4",
4
4
  "description": "Generic React UI components for Tangle products — primitives, chat, run, files, editor, markdown.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -47,6 +47,11 @@
47
47
  "import": "./dist/openui.js",
48
48
  "default": "./dist/openui.js"
49
49
  },
50
+ "./openui-schema": {
51
+ "types": "./dist/openui-schema.d.ts",
52
+ "import": "./dist/openui-schema.js",
53
+ "default": "./dist/openui-schema.js"
54
+ },
50
55
  "./files": {
51
56
  "types": "./dist/files.d.ts",
52
57
  "import": "./dist/files.js",
@@ -134,7 +139,7 @@
134
139
  "peerDependencies": {
135
140
  "@hocuspocus/provider": ">=2.15.0 <5.0.0",
136
141
  "@nanostores/react": ">=0.8.4 <3.0.0",
137
- "@tangle-network/brand": "^1.8.2",
142
+ "@tangle-network/brand": "^1.9.0",
138
143
  "@tiptap/core": ">=3.20.4 <4.0.0",
139
144
  "@tiptap/extension-collaboration": ">=3.20.4 <4.0.0",
140
145
  "@tiptap/extension-collaboration-caret": ">=3.7.0 <4.0.0",
@@ -191,6 +196,7 @@
191
196
  "@types/react": "^19.3.0",
192
197
  "@types/react-syntax-highlighter": "^15.5.13",
193
198
  "@types/turndown": "^5.0.6",
199
+ "ajv": "8.17.1",
194
200
  "nanostores": "^1.5.4",
195
201
  "react": "^19.3.0",
196
202
  "react-dom": "^19.3.0",
@@ -207,7 +213,8 @@
207
213
  "design-system"
208
214
  ],
209
215
  "scripts": {
210
- "build": "tsdown && node ../../scripts/validate-dist.mjs",
216
+ "build": "node scripts/generate-openui-validators.mjs --check && tsdown && node ../../scripts/validate-dist.mjs && node scripts/probe-openui-worker.mjs",
217
+ "gen:openui-schema": "node scripts/generate-openui-validators.mjs",
211
218
  "dev": "tsdown --watch"
212
219
  }
213
220
  }
@@ -3,13 +3,19 @@ import { describe, expect, it } from "vitest"
3
3
  import { UserMessage } from "./user-message"
4
4
 
5
5
  describe("UserMessage", () => {
6
- it("renders the text in a borderless muted bubble", () => {
6
+ it("renders the text in a bordered card that wraps in narrow panes", () => {
7
7
  const { container } = render(<UserMessage content="Run the tests." />)
8
8
  expect(screen.getByText("Run the tests.")).toBeInTheDocument()
9
9
  const bubble = container.querySelector(".rounded-2xl")
10
10
  expect(bubble).not.toBeNull()
11
- expect(bubble!.className).toContain("bg-muted/50")
12
- expect(bubble!.className).not.toMatch(/(^|\s)border(\s|$)/)
11
+ expect(bubble!.className).toContain("bg-card")
12
+ expect(bubble!.className).toContain("border-border")
13
+ expect(container.querySelector(".group")?.className).toContain(
14
+ "min-w-0",
15
+ )
16
+ expect(screen.getByText("Run the tests.").className).toContain(
17
+ "break-words",
18
+ )
13
19
  })
14
20
 
15
21
  it("keeps the timestamp beside the bubble as a hover-revealed label", () => {
@@ -17,8 +17,8 @@ function formatTime(date: Date): string {
17
17
  }
18
18
 
19
19
  /**
20
- * The single user message bubble — a quiet, right-aligned fill on the muted
21
- * surface (no border, no loud fill, no uppercase label). The timestamp sits
20
+ * The single user message bubble — a quiet, right-aligned card with a subtle
21
+ * semantic border (no loud fill, no uppercase label). The timestamp sits
22
22
  * beside the bubble and appears on hover, so it costs no height. Used by both
23
23
  * the run/message list (session-model `parts`) and AgentTimeline (direct
24
24
  * `content`/`timestamp`).
@@ -36,8 +36,10 @@ export const UserMessage = memo(
36
36
 
37
37
  return (
38
38
  <div className="flex justify-end">
39
- <div className="group flex max-w-[78%] flex-col items-end gap-2">
40
- <div className="relative w-full rounded-2xl bg-muted/50 px-4 py-3">
39
+ <div className="group flex min-w-0 max-w-[78%] flex-col items-end gap-2">
40
+ <div
41
+ className="relative w-full min-w-0 rounded-2xl border border-border bg-card px-4 py-3"
42
+ >
41
43
  {timestamp ? (
42
44
  <span
43
45
  data-user-message-time=""
@@ -46,7 +48,7 @@ export const UserMessage = memo(
46
48
  {formatTime(timestamp)}
47
49
  </span>
48
50
  ) : null}
49
- <div className="whitespace-pre-wrap text-[var(--font-size-base)] leading-[1.5] text-foreground">
51
+ <div className="break-words whitespace-pre-wrap text-[var(--font-size-base)] leading-[1.5] text-foreground">
50
52
  {text}
51
53
  </div>
52
54
  </div>
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { type ReactNode, useEffect, useMemo, useState } from "react";
4
+ import type { UrlTransform } from "react-markdown";
4
5
  import { PencilLine, Save, Users, Wifi, WifiOff } from "lucide-react";
5
6
  import { Markdown } from "../markdown/markdown";
6
7
  import { Tabs, TabsContent, TabsList, TabsTrigger } from "../primitives/tabs";
@@ -36,6 +37,8 @@ export interface DocumentEditorPaneProps
36
37
  tabs?: ArtifactPaneProps["tabs"];
37
38
  toolbar?: ReactNode;
38
39
  markdown?: string;
40
+ /** Transform Markdown links and images through the shared URL policy. */
41
+ urlTransform?: UrlTransform;
39
42
  mode?: DocumentEditorMode;
40
43
  defaultMode?: DocumentEditorMode;
41
44
  onModeChange?: (mode: DocumentEditorMode) => void;
@@ -188,6 +191,7 @@ export function DocumentEditorPane({
188
191
  tabs,
189
192
  toolbar,
190
193
  markdown = "",
194
+ urlTransform,
191
195
  mode,
192
196
  defaultMode = "preview",
193
197
  onModeChange,
@@ -300,7 +304,7 @@ export function DocumentEditorPane({
300
304
  previewClassName,
301
305
  )}
302
306
  >
303
- <Markdown>{draft}</Markdown>
307
+ <Markdown urlTransform={urlTransform}>{draft}</Markdown>
304
308
  </div>
305
309
  );
306
310
 
@@ -113,6 +113,47 @@ describe("FileArtifactPane previews", () => {
113
113
  expect(container.querySelector("strong")).toHaveTextContent("bold");
114
114
  });
115
115
 
116
+ it("forwards Markdown URL transforms to file and rich editor previews", async () => {
117
+ const urlTransform = vi.fn((url: string, key: string) => (
118
+ key === "href" && url === "sibling.md"
119
+ ? "/app/ws-1/vault?file=docs%2Fsibling.md"
120
+ : url
121
+ ));
122
+ const readOnly = render(
123
+ <FileArtifactPane
124
+ filename="README.md"
125
+ content={"[Sibling](sibling.md)"}
126
+ urlTransform={urlTransform}
127
+ />,
128
+ );
129
+
130
+ expect(screen.getByRole("link", { name: "Sibling" })).toHaveAttribute(
131
+ "href",
132
+ "/app/ws-1/vault?file=docs%2Fsibling.md",
133
+ );
134
+ readOnly.unmount();
135
+
136
+ render(
137
+ <FileArtifactPane
138
+ path="docs/README.md"
139
+ filename="README.md"
140
+ content={"[Sibling](sibling.md)"}
141
+ urlTransform={urlTransform}
142
+ editor={{ enabled: true, defaultMode: "preview" }}
143
+ />,
144
+ );
145
+
146
+ expect(await screen.findByRole("link", { name: "Sibling" })).toHaveAttribute(
147
+ "href",
148
+ "/app/ws-1/vault?file=docs%2Fsibling.md",
149
+ );
150
+ expect(urlTransform).toHaveBeenCalledWith(
151
+ "sibling.md",
152
+ "href",
153
+ expect.objectContaining({ tagName: "a" }),
154
+ );
155
+ });
156
+
116
157
  it("renders a binary file with no download link as one muted sentence", () => {
117
158
  const onDownload = vi.fn();
118
159
  const withDownload = render(
@@ -64,6 +64,7 @@ export function FileArtifactPane({
64
64
  size,
65
65
  onClose,
66
66
  onDownload,
67
+ urlTransform,
67
68
  path,
68
69
  tabs = [],
69
70
  activeTabId,
@@ -154,6 +155,7 @@ export function FileArtifactPane({
154
155
  headerClassName={headerClassName}
155
156
  hideTitleBlock={hideTitleBlock}
156
157
  markdown={content ?? ""}
158
+ urlTransform={urlTransform}
157
159
  mode={editor.mode}
158
160
  defaultMode={editor.defaultMode}
159
161
  onModeChange={editor.onModeChange}
@@ -193,6 +195,7 @@ export function FileArtifactPane({
193
195
  blobUrl={blobUrl}
194
196
  mimeType={mimeType}
195
197
  size={size}
198
+ urlTransform={urlTransform}
196
199
  onDownload={onDownload}
197
200
  hideHeader={true}
198
201
  />
@@ -22,6 +22,7 @@ import {
22
22
  X,
23
23
  type LucideIcon,
24
24
  } from "lucide-react";
25
+ import type { UrlTransform } from "react-markdown";
25
26
  import { focusRingInset } from "../lib/focus";
26
27
  import { cn } from "../lib/utils";
27
28
  import { Button } from "../primitives/button";
@@ -44,6 +45,8 @@ export interface FilePreviewProps {
44
45
  mimeType?: string;
45
46
  /** Size in bytes; download cards show it when known. */
46
47
  size?: number;
48
+ /** Transform Markdown links and images through the shared URL policy. */
49
+ urlTransform?: UrlTransform;
47
50
  onClose?: () => void;
48
51
  onDownload?: () => void;
49
52
  hideHeader?: boolean;
@@ -301,10 +304,16 @@ function TextPreview({ content }: { content: string }) {
301
304
  );
302
305
  }
303
306
 
304
- function MarkdownPreview({ content }: { content: string }) {
307
+ function MarkdownPreview({
308
+ content,
309
+ urlTransform,
310
+ }: {
311
+ content: string;
312
+ urlTransform?: UrlTransform;
313
+ }) {
305
314
  return (
306
315
  <div className="rounded-[var(--radius-md)] border border-border bg-background p-5">
307
- <Markdown>{content}</Markdown>
316
+ <Markdown urlTransform={urlTransform}>{content}</Markdown>
308
317
  </div>
309
318
  );
310
319
  }
@@ -358,6 +367,7 @@ function PreviewBody({
358
367
  content,
359
368
  blobUrl,
360
369
  size,
370
+ urlTransform,
361
371
  onDownload,
362
372
  }: {
363
373
  kind: FilePreviewKind;
@@ -365,6 +375,7 @@ function PreviewBody({
365
375
  content?: string;
366
376
  blobUrl?: string;
367
377
  size?: number;
378
+ urlTransform?: UrlTransform;
368
379
  onDownload?: () => void;
369
380
  }) {
370
381
  const hasText = typeof content === "string";
@@ -404,7 +415,7 @@ function PreviewBody({
404
415
  if (hasText) return <CodePreview content={content} filename={filename} kind={kind} />;
405
416
  return card(NO_INLINE_CONTENT);
406
417
  case "markdown":
407
- if (hasText) return <MarkdownPreview content={content} />;
418
+ if (hasText) return <MarkdownPreview content={content} urlTransform={urlTransform} />;
408
419
  return card(NO_INLINE_CONTENT);
409
420
  case "text":
410
421
  if (hasText) return <TextPreview content={content} />;
@@ -423,6 +434,7 @@ export function FilePreview({
423
434
  blobUrl,
424
435
  mimeType,
425
436
  size,
437
+ urlTransform,
426
438
  onClose,
427
439
  onDownload,
428
440
  hideHeader = false,
@@ -473,6 +485,7 @@ export function FilePreview({
473
485
  content={content}
474
486
  blobUrl={blobUrl}
475
487
  size={size}
488
+ urlTransform={urlTransform}
476
489
  onDownload={onDownload}
477
490
  />
478
491
  </div>
@@ -18,3 +18,13 @@ export {
18
18
  type OpenUIGridNode,
19
19
  type OpenUICardNode,
20
20
  } from "./openui-artifact-renderer";
21
+
22
+ export {
23
+ OPENUI_NODE_TYPES,
24
+ OPENUI_NODE_JSON_SCHEMA,
25
+ findUnsupportedOpenUINode,
26
+ validateOpenUIJsonNode,
27
+ validateOpenUIJsonArtifact,
28
+ type OpenUISchemaIssue,
29
+ type OpenUISchemaValidation,
30
+ } from "./schema";
@@ -0,0 +1,36 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { OpenUIArtifactRenderer } from "./openui-artifact-renderer";
3
+
4
+ const meta: Meta<typeof OpenUIArtifactRenderer> = {
5
+ title: "OpenUI/ArtifactRenderer",
6
+ component: OpenUIArtifactRenderer,
7
+ decorators: [(Story) => <div className="mx-auto max-w-2xl p-6"><Story /></div>],
8
+ };
9
+
10
+ export default meta;
11
+ type Story = StoryObj<typeof OpenUIArtifactRenderer>;
12
+
13
+ export const NestedCard: Story = {
14
+ args: {
15
+ schema: {
16
+ type: "card",
17
+ title: "Release proof",
18
+ children: [
19
+ { type: "heading", text: "Result" },
20
+ { type: "stack", children: [
21
+ { type: "stat", label: "Checks", value: "3" },
22
+ { type: "text", text: "The saved result remains visible after reload." },
23
+ ] },
24
+ ],
25
+ },
26
+ },
27
+ };
28
+
29
+ export const UnsupportedSavedNode: Story = {
30
+ args: {
31
+ schema: {
32
+ type: "card",
33
+ children: [{ type: "section", children: [{ type: "text", text: "This content must not disappear silently." }] }],
34
+ } as never,
35
+ },
36
+ };
@@ -13,6 +13,7 @@ import {
13
13
  import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../primitives/table";
14
14
  import { CodeBlock } from "../primitives/code-block";
15
15
  import { Markdown } from "../markdown/markdown";
16
+ import { findUnsupportedOpenUINode, type OPENUI_NODE_TYPES } from "./schema";
16
17
 
17
18
  export type OpenUIPrimitive = string | number | boolean | null | undefined;
18
19
 
@@ -148,21 +149,14 @@ export interface OpenUIArtifactRendererProps {
148
149
  className?: string;
149
150
  }
150
151
 
151
- const NODE_TYPES = new Set([
152
- "heading",
153
- "text",
154
- "badge",
155
- "stat",
156
- "key_value",
157
- "code",
158
- "markdown",
159
- "table",
160
- "actions",
161
- "separator",
162
- "stack",
163
- "grid",
164
- "card",
165
- ]);
152
+ type RendererNodeTypesMatchSchema =
153
+ Exclude<OpenUIComponentNode["type"], (typeof OPENUI_NODE_TYPES)[number]> extends never
154
+ ? Exclude<(typeof OPENUI_NODE_TYPES)[number], OpenUIComponentNode["type"]> extends never
155
+ ? true
156
+ : never
157
+ : never;
158
+ const rendererNodeTypesMatchSchema: RendererNodeTypesMatchSchema = true;
159
+ void rendererNodeTypesMatchSchema;
166
160
 
167
161
  const GAP_STYLES = {
168
162
  sm: "gap-2",
@@ -200,16 +194,6 @@ function asArray<T>(value: unknown): T[] {
200
194
  return Array.isArray(value) ? (value as T[]) : [];
201
195
  }
202
196
 
203
- function isOpenUIComponentNode(value: unknown): value is OpenUIComponentNode {
204
- return (
205
- typeof value === "object" &&
206
- value !== null &&
207
- "type" in value &&
208
- typeof (value as { type?: unknown }).type === "string" &&
209
- NODE_TYPES.has((value as { type: string }).type)
210
- );
211
- }
212
-
213
197
  function renderActions(actions: OpenUIAction[], onAction?: (action: OpenUIAction) => void) {
214
198
  if (actions.length === 0) return null;
215
199
 
@@ -394,7 +378,7 @@ function renderNode(node: OpenUIComponentNode, onAction?: (action: OpenUIAction)
394
378
  column.align === "right" && "text-right tabular-nums",
395
379
  )}
396
380
  >
397
- {formatValue(row[column.key])}
381
+ {formatValue(Object.prototype.hasOwnProperty.call(row, column.key) ? row[column.key] : undefined)}
398
382
  </TableCell>
399
383
  ))}
400
384
  </TableRow>
@@ -510,7 +494,15 @@ export function OpenUIArtifactRenderer({
510
494
  onAction,
511
495
  className,
512
496
  }: OpenUIArtifactRendererProps) {
513
- const nodes = (Array.isArray(schema) ? schema : [schema]).filter(isOpenUIComponentNode);
497
+ const issue = findUnsupportedOpenUINode(schema);
498
+ if (issue) {
499
+ return (
500
+ <div role="alert" className={cn("rounded-[var(--radius-xl)] border border-border bg-card p-5 text-sm text-foreground", className)}>
501
+ Cannot display this saved view. {issue.message} Path: {issue.path}.
502
+ </div>
503
+ );
504
+ }
505
+ const nodes = Array.isArray(schema) ? schema : [schema];
514
506
 
515
507
  if (nodes.length === 0) {
516
508
  return (