@tangle-network/ui 11.16.3 → 11.16.5

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.
@@ -7,7 +7,11 @@ const string = { type: "string" } as const;
7
7
  const boolean = { type: "boolean" } as const;
8
8
  const gap = { enum: ["sm", "md", "lg"] } as const;
9
9
  const primitive = { type: ["string", "number", "boolean", "null"] } as const;
10
- const children = { type: "array", minItems: 1, items: { $ref: "#/$defs/node" } } as const;
10
+ // No minItems here: stack and grid require `children` and a child can itself be
11
+ // a stack, so a nonempty minimum makes a required recursive loop that model
12
+ // tool-schema converters reject (opencode refused render_ui outright). The
13
+ // persistence gate in schema.ts still rejects an empty container.
14
+ const children = { type: "array", items: { $ref: "#/$defs/node" } } as const;
11
15
  const actions = { type: "array", minItems: 1, items: { $ref: "#/$defs/action" } } as const;
12
16
 
13
17
  function node(type: string, fields: Record<string, unknown>, required: string[] = []) {
@@ -24,6 +24,45 @@ const validPage = {
24
24
  ],
25
25
  };
26
26
 
27
+ type JsonSchema = Record<string, unknown>;
28
+
29
+ /**
30
+ * Model tool-schema converters accept a recursive `$ref` only when the loop can
31
+ * terminate: it must pass through an optional property or an array that may be
32
+ * empty. Return the first `$defs` path that recurses through required,
33
+ * nonempty values only.
34
+ */
35
+ function findRequiredRefLoop(defs: Record<string, JsonSchema>): string | null {
36
+ function visit(schema: JsonSchema, path: string, stack: string[]): string | null {
37
+ const ref = typeof schema.$ref === "string" ? schema.$ref.replace("#/$defs/", "") : null;
38
+ if (ref) {
39
+ if (stack.includes(ref)) return path;
40
+ return visit(defs[ref]!, `$defs.${ref}`, [...stack, ref]);
41
+ }
42
+ for (const key of ["oneOf", "anyOf"] as const) {
43
+ const options = schema[key];
44
+ if (Array.isArray(options)) {
45
+ for (const [index, option] of options.entries()) {
46
+ const loop = visit(option as JsonSchema, `${path}.${key}.${index}`, stack);
47
+ if (loop) return loop;
48
+ }
49
+ }
50
+ }
51
+ if (schema.type === "array" && schema.items && Number(schema.minItems ?? 0) > 0) {
52
+ return visit(schema.items as JsonSchema, `${path}.items`, stack);
53
+ }
54
+ const required = Array.isArray(schema.required) ? schema.required as string[] : [];
55
+ const properties = (schema.properties ?? {}) as Record<string, JsonSchema>;
56
+ for (const name of required) {
57
+ if (!properties[name]) continue;
58
+ const loop = visit(properties[name], `${path}.properties.${name}`, stack);
59
+ if (loop) return loop;
60
+ }
61
+ return null;
62
+ }
63
+ return visit({ $ref: "#/$defs/node" }, "$", []);
64
+ }
65
+
27
66
  describe("OpenUI JSON contract", () => {
28
67
  it("advertises every renderer node and accepts nested JSON with primitive cells", () => {
29
68
  expect(OPENUI_NODE_TYPES).toHaveLength(13);
@@ -32,6 +71,12 @@ describe("OpenUI JSON contract", () => {
32
71
  expect(validateOpenUIJsonArtifact([validPage, { type: "text", text: "Afterword" }])).toMatchObject({ ok: true });
33
72
  });
34
73
 
74
+ it("keeps every recursive node path terminable for model tool-schema converters", () => {
75
+ expect(findRequiredRefLoop(OPENUI_NODE_JSON_SCHEMA.$defs as unknown as Record<string, JsonSchema>)).toBeNull();
76
+ expect(validateOpenUIJsonNode({ type: "card", title: "Empty", children: [] }))
77
+ .toMatchObject({ ok: false, issue: { path: "$.children" } });
78
+ });
79
+
35
80
  it("rejects an unsupported top-level or nested section before it can be dropped", () => {
36
81
  expect(validateOpenUIJsonNode({ type: "section", children: [{ type: "text", text: "hidden" }] }))
37
82
  .toMatchObject({ ok: false, issue: { path: "$", message: expect.stringContaining("section") } });
@@ -40,6 +40,11 @@ export function findUnsupportedOpenUINode(value: unknown): OpenUISchemaIssue | n
40
40
  return { path, message: `Unsupported OpenUI node type: ${String(record.type)}.` };
41
41
  }
42
42
  if (record.type === "stack" || record.type === "grid" || record.type === "card") {
43
+ // The model-facing schema cannot express this minimum without a required
44
+ // recursive loop (see schema-data.ts), so the gate enforces it here.
45
+ if (Array.isArray(record.children) && record.children.length === 0) {
46
+ return { path: `${path}.children`, message: "OpenUI containers need at least one child." };
47
+ }
43
48
  if (Array.isArray(record.children)) {
44
49
  for (const [index, child] of record.children.entries()) {
45
50
  const issue = visit(child, `${path}.children[${index}]`, depth + 1);
@@ -11,6 +11,9 @@ export interface ExpandableContentProps {
11
11
  */
12
12
  fadeClassName?: string;
13
13
  className?: string;
14
+ /** Classes for the Show more / Show less toggle, e.g. an inset when the
15
+ * content sits flush against its card's edges. */
16
+ toggleClassName?: string;
14
17
  }
15
18
 
16
19
  /**
@@ -23,6 +26,7 @@ export function ExpandableContent({
23
26
  collapsedMaxPx = 180,
24
27
  fadeClassName = "from-muted",
25
28
  className,
29
+ toggleClassName,
26
30
  }: ExpandableContentProps) {
27
31
  const [expanded, setExpanded] = useState(false);
28
32
  const [overflowing, setOverflowing] = useState(false);
@@ -63,7 +67,10 @@ export function ExpandableContent({
63
67
  <button
64
68
  type="button"
65
69
  onClick={() => setExpanded((value) => !value)}
66
- className="mt-1 text-[11px] font-medium text-muted-foreground transition-colors hover:text-foreground"
70
+ className={cn(
71
+ "mt-1 text-[11px] font-medium text-muted-foreground transition-colors hover:text-foreground",
72
+ toggleClassName,
73
+ )}
67
74
  >
68
75
  {expanded ? "Show less" : "Show more"}
69
76
  </button>
@@ -23,6 +23,10 @@ export interface ExpandedToolDetailProps {
23
23
  part: ToolPart;
24
24
  }
25
25
 
26
+ // The clamped output sits flush against its card's edges; the toggle under it
27
+ // takes the card's 12px content inset so it does not touch the border.
28
+ const FLUSH_TOGGLE = "mx-3 mb-2";
29
+
26
30
  const EXT_LANG: Record<string, string> = {
27
31
  ts: "typescript", tsx: "tsx", js: "javascript", jsx: "jsx",
28
32
  rs: "rust", py: "python", go: "go", rb: "ruby",
@@ -147,7 +151,7 @@ export const ExpandedToolDetail = memo(({ part }: ExpandedToolDetailProps) => {
147
151
  Output
148
152
  </span>
149
153
  </div>
150
- <ExpandableContent fadeClassName="from-card">
154
+ <ExpandableContent fadeClassName="from-card" toggleClassName={FLUSH_TOGGLE}>
151
155
  <CodeBlock
152
156
  code={
153
157
  outputStr.length > 2000
@@ -170,7 +174,7 @@ export const ExpandedToolDetail = memo(({ part }: ExpandedToolDetailProps) => {
170
174
  Error
171
175
  </span>
172
176
  </div>
173
- <ExpandableContent fadeClassName="from-[var(--surface-danger-bg)]">
177
+ <ExpandableContent fadeClassName="from-[var(--surface-danger-bg)]" toggleClassName={FLUSH_TOGGLE}>
174
178
  <pre className="bg-[var(--surface-danger-bg)] p-3 text-xs font-mono whitespace-pre-wrap break-all text-[var(--surface-danger-text)]">
175
179
  {error}
176
180
  </pre>