@tangle-network/sandbox-ui 0.122.3 → 0.123.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.
@@ -1,9 +1,7 @@
1
- import { r as modelBrandFor, t as ModelBrandStack } from "./model-brand-hMyDTUqB.js";
2
- import { a as buildWorkflowGraph, c as triggerNodeIndex, d as providerLabel, f as shortModel, g as fmtTokens, h as fmtDuration, l as wfEdgeId, m as fmtCost, o as isStructuralKind, p as clampPreview, t as COMPACT_NODE_SIZE, u as worstSeverity } from "./model-I-NIxeM-.js";
3
- import { t as ProviderIcon } from "./provider-logo-BiuiO_c2.js";
1
+ import { D as triggerNodeIndex, F as fmtCost, I as fmtDuration, L as fmtTokens, N as shortModel, O as wfEdgeId, P as clampPreview, T as isStructuralKind, _ as problemSurface, a as NodeMark, b as COMPACT_NODE_SIZE, c as STATUS_COLOR, d as StatusFooter, f as StatusPill, g as problemBorder, h as emptySlotLabel, k as worstSeverity, l as STATUS_LABEL, m as edgeColor, n as classifyOutput, o as ProblemMarker, p as TONE_ACCENT, s as ProblemMessages, t as NodeOutputBody, v as problemTitle, w as buildWorkflowGraph, y as statusBorder } from "./node-output-D-fYilAu.js";
4
2
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
5
3
  import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
6
- import { AlertTriangle, Bell, Box, Cable, Camera, Circle, Clock, Code, GitBranch, Maximize2, Minimize2, Plus, Repeat, ScanSearch, Sparkles, UserCheck, Webhook, X, Zap } from "lucide-react";
4
+ import { Maximize2, Minimize2, Plus, X } from "lucide-react";
7
5
  import { Background, BaseEdge, Controls, EdgeLabelRenderer, Handle, MarkerType, Panel, Position, ReactFlow, getSmoothStepPath, getViewportForBounds, useEdgesState, useNodesState } from "@xyflow/react";
8
6
  import "@xyflow/react/dist/style.css";
9
7
  //#region src/workflows/flow-graph.ts
@@ -289,523 +287,6 @@ function clearOfNodeBoxes(point, boxes, direction, half) {
289
287
  return Math.abs(up) <= Math.abs(down) ? up : down;
290
288
  }
291
289
  //#endregion
292
- //#region src/workflows/node-output.tsx
293
- /** Failure text, in the semantic danger token — which carries a light AND a dark
294
- * value. A palette shade (`text-red-400`) only ever reads well in one theme. */
295
- const ERROR_TEXT = { color: "var(--surface-danger-text)" };
296
- /** Max key/value pairs a shallow-object output renders before it reports
297
- * `truncated` — the card only has room for a couple rows anyway. */
298
- const MAX_JSON_ENTRIES = 6;
299
- /**
300
- * A line that is pure structure — a code fence (with or without a language tag), a
301
- * table's rule row, a thematic break — and so carries no words to keep.
302
- *
303
- * Every test is a SINGLE quantified character class: unambiguous, and linear in the
304
- * length of the line. Written as one pattern (`[\s|:-]+\|[\s|:-]*`) the two
305
- * quantifiers would compete for the same `|`, which is the shape that backtracks.
306
- * Recognize the charset in one pass instead, then ask the cheap questions — is
307
- * there a pipe? a dash? — with plain string checks.
308
- */
309
- function isStructureOnly(line) {
310
- if (/^(```|~~~)/.test(line)) return true;
311
- const isBreak = /^([-*_])\1{2,}$/.test(line);
312
- if (/^[\s|:-]+$/.test(line) && line.includes("|")) return line.includes("-");
313
- return isBreak;
314
- }
315
- /**
316
- * Flatten an agent's markdown answer into the sentence a person would read out
317
- * of it. On a two-line card preview the syntax IS the noise: a `## Heading` mid
318
- * paragraph, a `**bold**` run and a bullet's `-` all survive the clamp while the
319
- * words that carry the meaning get pushed past it. Stripping the markup (never
320
- * the words) is what turns the preview back into prose — the full text, markup
321
- * and all, is still rendered by the node's detail view.
322
- *
323
- * Structure-only lines (a fence, a table rule) are dropped; everything else keeps
324
- * its text and is joined into one flowing line.
325
- */
326
- function condenseText(text) {
327
- const isListItem = (line) => /^\s*([-*+]\s|\d+[.)]\s)/.test(line);
328
- return text.split("\n").map((line) => ({
329
- item: isListItem(line),
330
- text: line.replace(/^\s{0,3}#{1,6}\s+/, "").replace(/^\s{0,3}>\s?/, "").replace(/^\s*[-*+]\s+(\[[ xX]\]\s+)?/, "").replace(/^\s*\d+[.)]\s+/, "").trim()
331
- })).filter(({ text: line }) => line !== "" && !isStructureOnly(line)).map(({ item, text: line }, i) => i > 0 && item ? `· ${line}` : line).join(" ").replace(/(?<=^|\s)(\*\*|~~)(?!\s)(.+?)(?<!\s)\1(?=[\s.,:;!?)\]}"']|$)/g, "$2").replace(/(?<![\w*])\*(?!\s)([^*]+?)(?<!\s)\*(?![\w*])/g, "$1").replace(/`([^`]+)`/g, "$1").replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/\s+/g, " ").trim();
332
- }
333
- /** A single JSON value as a compact one-line string for the key/value render.
334
- * Scalars render verbatim; a nested object/array collapses to a shape marker so
335
- * one row never balloons into a wall of text. */
336
- function scalar(value) {
337
- if (value === null) return "null";
338
- if (typeof value === "string") return value;
339
- if (typeof value === "number" || typeof value === "boolean") return String(value);
340
- if (Array.isArray(value)) return `[${value.length} item${value.length === 1 ? "" : "s"}]`;
341
- if (typeof value === "object") return "{…}";
342
- return "";
343
- }
344
- /**
345
- * Recover the shape of an output preview string. Only text that *begins* like
346
- * JSON (`{`/`[`) is parse-attempted, so ordinary prose starting with a brace-free
347
- * sentence is never mis-parsed. A begins-like-JSON string that fails to parse
348
- * (the host clamped it mid-value) still renders as `code`, not prose, so it keeps
349
- * its monospace/data affordance.
350
- *
351
- * `condenseMarkdown` says the prose IS markdown and may be flattened for the
352
- * preview ({@link condenseText}). It is OFF by default, and the caller must only
353
- * turn it on for text an agent actually authored as markdown. Applied to anything
354
- * else it does not merely look wrong — it silently REWRITES the text: a stack
355
- * trace's `__init__.py` loses its underscores, and a shell glob `src/**` pairs
356
- * with a later `**` and both vanish. An error message is the one string a person
357
- * reads to find out what broke; it must reach them exactly as it was thrown.
358
- */
359
- function classifyOutput(preview, condenseMarkdown = false) {
360
- let trimmed = preview?.trim() ?? "";
361
- trimmed = trimmed.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])/g, "").replace(/(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, "").trimEnd();
362
- if (trimmed === "") return { kind: "empty" };
363
- if (trimmed.startsWith("{") || trimmed.startsWith("[")) try {
364
- const parsed = JSON.parse(trimmed);
365
- if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
366
- const all = Object.entries(parsed);
367
- const entries = all.slice(0, MAX_JSON_ENTRIES).map(([k, v]) => [k, scalar(v)]);
368
- if (entries.length > 0) return {
369
- kind: "json",
370
- entries,
371
- truncated: all.length > entries.length
372
- };
373
- }
374
- return {
375
- kind: "code",
376
- text: trimmed
377
- };
378
- } catch {
379
- return {
380
- kind: "code",
381
- text: trimmed
382
- };
383
- }
384
- if (!condenseMarkdown) return {
385
- kind: "text",
386
- text: trimmed
387
- };
388
- const condensed = condenseText(trimmed);
389
- return condensed === "" ? { kind: "empty" } : {
390
- kind: "text",
391
- text: condensed
392
- };
393
- }
394
- /** Line-clamp utility per row budget, so `code`/`text` shapes honor `rows` the
395
- * same way the JSON branch does (rather than always clamping to two). Covers the
396
- * full range a card can reserve; an unsupported count falls back to the two-line
397
- * clamp, which renders FEWER lines than the caller asked for — so a card whose
398
- * reservation outgrows this map shows a short body inside a tall well rather
399
- * than erroring. Extend the map when a design reserves more.
400
- * Class names are literal so the Tailwind scanner emits each utility. */
401
- const CLAMP_BY_ROWS = {
402
- 1: "truncate",
403
- 2: "line-clamp-2",
404
- 3: "line-clamp-3",
405
- 4: "line-clamp-4",
406
- 5: "line-clamp-5",
407
- 6: "line-clamp-6"
408
- };
409
- /**
410
- * Render a classified output preview's CONTENT (no surrounding frame — each node
411
- * design supplies its own container/label). `rows` caps the visible lines so the
412
- * body stays within a card's reserved output space; `tone="error"` colors it for
413
- * a failure message.
414
- *
415
- * `rows="none"` renders the body UNCLAMPED, for a container that scrolls or is
416
- * sized by its content — a drawer, a detail panel, a selection overlay. It is a
417
- * word rather than a large number because there is no number that means "do not
418
- * clamp": a count outside {@link CLAMP_BY_ROWS} falls back to two lines, so
419
- * `rows={99}` asks for everything and silently shows the least.
420
- */
421
- function NodeOutputBody({ shape, rows = 2, tone = "default" }) {
422
- const unclamped = rows === "none";
423
- const clampClass = unclamped ? "" : CLAMP_BY_ROWS[rows] ?? "line-clamp-2";
424
- const codeWrap = rows === 1 ? "" : "whitespace-pre-wrap break-all";
425
- if (shape.kind === "empty") return null;
426
- if (shape.kind === "json") {
427
- const overflow = unclamped ? shape.truncated : shape.truncated || shape.entries.length > rows;
428
- const visible = unclamped ? shape.entries : shape.entries.slice(0, overflow ? Math.max(0, rows - 1) : rows);
429
- const isError = tone === "error";
430
- const keyStyle = isError ? ERROR_TEXT : void 0;
431
- const keyClass = isError ? "" : "text-muted-foreground";
432
- const valueClass = isError ? "" : "text-foreground";
433
- return /* @__PURE__ */ jsxs("dl", {
434
- className: "space-y-0.5 font-mono text-[10.5px] leading-snug",
435
- children: [visible.map(([key, value]) => /* @__PURE__ */ jsxs("div", {
436
- className: "flex gap-1.5",
437
- children: [/* @__PURE__ */ jsx("dt", {
438
- className: `max-w-[45%] shrink-0 truncate ${keyClass}`,
439
- style: keyStyle,
440
- title: key,
441
- children: key
442
- }), /* @__PURE__ */ jsx("dd", {
443
- className: `min-w-0 flex-1 truncate ${valueClass}`,
444
- style: isError ? ERROR_TEXT : void 0,
445
- title: value,
446
- children: value
447
- })]
448
- }, key)), overflow && /* @__PURE__ */ jsx("div", {
449
- className: `text-[10px] ${keyClass}`,
450
- style: keyStyle,
451
- children: "…"
452
- })]
453
- });
454
- }
455
- if (shape.kind === "code") return /* @__PURE__ */ jsx("pre", {
456
- className: `${clampClass} ${codeWrap} font-mono text-[10.5px] leading-snug ${tone === "error" ? "" : "text-foreground"}`,
457
- style: tone === "error" ? ERROR_TEXT : void 0,
458
- title: shape.text,
459
- children: shape.text
460
- });
461
- return /* @__PURE__ */ jsx("p", {
462
- className: `${clampClass} text-[11px] leading-snug ${tone === "error" ? "" : "text-foreground"}`,
463
- style: tone === "error" ? ERROR_TEXT : void 0,
464
- title: shape.text,
465
- children: shape.text
466
- });
467
- }
468
- //#endregion
469
- //#region src/workflows/node-ui.tsx
470
- /**
471
- * Shared presentational atoms for a workflow graph node — the type glyph, the
472
- * status color/label/badge maps, and the edge coloring. One source of truth so
473
- * the node card and the edges that point at it read as one system.
474
- *
475
- * Colors resolve to the SEMANTIC surface tokens (`--surface-success-text`,
476
- * `--surface-danger-text`, …), which carry a light AND a dark value tuned for
477
- * contrast against the card. A literal palette hex (`#22c55e`) or a stock
478
- * Tailwind shade (`text-red-400`) carries ONE value, so it can only ever hold its
479
- * contrast in one of the two themes — never use one for a status color.
480
- *
481
- * Inline `style` colors here use the RAW brand tokens (`hsl(var(--primary))`,
482
- * `var(--surface-…)`) — NOT the `--color-*` @theme aliases, which are undefined
483
- * in a consumer that only imports `tokens.css` (e.g. platform-web), where a
484
- * `var(--color-*)` would silently resolve to nothing. See the edge-color test.
485
- */
486
- /** Track color behind a progress bar — a faint wash of the muted token. */
487
- const MUTED_TRACK = "color-mix(in srgb, hsl(var(--muted-foreground)) 22%, transparent)";
488
- const TONE_ACCENT = {
489
- trigger: "hsl(var(--primary))",
490
- structural: "var(--surface-warning-text)",
491
- action: "hsl(var(--muted-foreground))"
492
- };
493
- const STATUS_QUEUED = "hsl(var(--muted-foreground))";
494
- const STATUS_COLOR = {
495
- queued: STATUS_QUEUED,
496
- running: "hsl(var(--primary))",
497
- waiting: "var(--surface-warning-text)",
498
- succeeded: "var(--surface-success-text)",
499
- failed: "var(--surface-danger-text)"
500
- };
501
- /** An edge is colored by the status of the node it points AT, so the run's
502
- * "front" lights up. Neutral for a not-yet-reached (queued) target or the
503
- * static definition view (`undefined`). */
504
- function edgeColor(status) {
505
- return status ? STATUS_COLOR[status] : STATUS_QUEUED;
506
- }
507
- const STATUS_LABEL = {
508
- queued: "Queued",
509
- running: "Running",
510
- waiting: "Waiting on you",
511
- succeeded: "Done",
512
- failed: "Failed"
513
- };
514
- /** Status pill styling — a tinted well in the status color. Built from the
515
- * semantic surface trio (bg/border/text) so the pill keeps its contrast in both
516
- * themes rather than washing out in one of them. */
517
- const STATUS_PILL = {
518
- queued: {
519
- background: "color-mix(in srgb, hsl(var(--muted-foreground)) 12%, transparent)",
520
- color: "hsl(var(--muted-foreground))",
521
- borderColor: "color-mix(in srgb, hsl(var(--muted-foreground)) 28%, transparent)"
522
- },
523
- running: {
524
- background: "color-mix(in srgb, hsl(var(--primary)) 14%, transparent)",
525
- color: "hsl(var(--primary))",
526
- borderColor: "color-mix(in srgb, hsl(var(--primary)) 40%, transparent)"
527
- },
528
- waiting: {
529
- background: "var(--surface-warning-bg)",
530
- color: "var(--surface-warning-text)",
531
- borderColor: "var(--surface-warning-border)"
532
- },
533
- succeeded: {
534
- background: "var(--surface-success-bg)",
535
- color: "var(--surface-success-text)",
536
- borderColor: "var(--surface-success-border)"
537
- },
538
- failed: {
539
- background: "var(--surface-danger-bg)",
540
- color: "var(--surface-danger-text)",
541
- borderColor: "var(--surface-danger-border)"
542
- }
543
- };
544
- /** The card's border + ring once a node has live run state, so the running node
545
- * and the terminal ones read at a glance. Returned as inline style (not classes)
546
- * because the status colors are semantic tokens, not palette shades. */
547
- function statusBorder(status) {
548
- const color = STATUS_COLOR[status];
549
- switch (status) {
550
- case "running":
551
- case "waiting": return {
552
- borderColor: color,
553
- boxShadow: `0 0 0 1px color-mix(in srgb, ${color} 45%, transparent), 0 0 24px -6px ${color}`
554
- };
555
- case "queued": return { borderColor: "hsl(var(--border))" };
556
- default: return {
557
- borderColor: color,
558
- boxShadow: `0 0 0 1px color-mix(in srgb, ${color} 35%, transparent)`
559
- };
560
- }
561
- }
562
- /** Determinate progress fraction for a node's status: queued reads near-empty,
563
- * running and waiting a partial (the run reached this node but has not finished
564
- * it), terminal full. Shared so every progress treatment (bar, footer, fill) maps
565
- * status → fill identically. A `waiting` bar is deliberately NOT animated by its
566
- * callers: the run is stopped there, and a moving bar would say otherwise. */
567
- function progressFill(status) {
568
- return status === "succeeded" || status === "failed" ? "100%" : status === "running" || status === "waiting" ? "58%" : "6%";
569
- }
570
- const KIND_ICON = {
571
- schedule: Clock,
572
- provider_event: Webhook,
573
- webhook: Webhook,
574
- trigger: Zap,
575
- "agent.run": Sparkles,
576
- "integration.invoke": Cable,
577
- notify: Bell,
578
- parallel: GitBranch,
579
- foreach: Repeat,
580
- decision: UserCheck,
581
- "sandbox.spawn": Box,
582
- "sandbox.snapshot": Camera,
583
- "script.run": Code,
584
- "trace.analyze": ScanSearch
585
- };
586
- /** An authoring problem's colours — the same semantic surface trio the run
587
- * statuses use, so a broken step and a failed one are recognisably the same
588
- * family of "something is wrong here" without being the same signal. */
589
- const PROBLEM_SURFACE_BY_SEVERITY = {
590
- error: {
591
- background: "var(--surface-danger-bg)",
592
- color: "var(--surface-danger-text)",
593
- borderColor: "var(--surface-danger-border)"
594
- },
595
- warning: {
596
- background: "var(--surface-warning-bg)",
597
- color: "var(--surface-warning-text)",
598
- borderColor: "var(--surface-warning-border)"
599
- }
600
- };
601
- /**
602
- * The colours a severity is drawn in. A lookup rather than the table itself, so
603
- * a severity this library has no entry for cannot throw its way out of a render
604
- * — the same resilience `problemSentence` and `worstSeverity` already give it,
605
- * and for the same reason: the package is consumed from JavaScript, and one
606
- * malformed problem must not take the canvas down. An unrecognised severity is
607
- * drawn as an error, matching how it is named and ranked.
608
- */
609
- function problemSurface(severity) {
610
- return PROBLEM_SURFACE_BY_SEVERITY[severity] ?? PROBLEM_SURFACE_BY_SEVERITY.error;
611
- }
612
- /** A node's border once it carries an authoring problem. Applied only where the
613
- * node has no RUN status to state — a run's border is the more urgent reading
614
- * of the same edge of the same box, and a definition being edited has no run. */
615
- function problemBorder(severity) {
616
- const color = problemSurface(severity).color;
617
- return {
618
- borderColor: color,
619
- boxShadow: `0 0 0 1px color-mix(in srgb, ${color} 35%, transparent)`
620
- };
621
- }
622
- /** How each severity is named where a colour cannot be seen. */
623
- const SEVERITY_WORD = {
624
- error: "Error",
625
- warning: "Warning"
626
- };
627
- /**
628
- * One problem as a reader receives it: WHICH KIND it is, then what it says.
629
- *
630
- * The kind has to be in the words. A mark shows one aggregate severity — as a
631
- * colour and a glyph, neither of which a screen reader receives — so an anchor
632
- * carrying an error and a warning together would otherwise offer no way to tell
633
- * which of the two is the one blocking the compile.
634
- *
635
- * NEWLINES are the only thing rewritten: each line is trimmed and the lines are
636
- * run together, so a multi-line host message cannot break the one-problem-per-
637
- * line reading below. Everything else survives — including internal spacing and
638
- * the trailing punctuation a diagnostic may be ABOUT ("unexpected ;") — because
639
- * the canvas has to say what the host's own problem list says. Separators are
640
- * handled at the join instead ({@link joinSentences}).
641
- *
642
- * A problem with no message at all is named by its severity alone, rather than
643
- * by a colon with nothing after it: nothing in {@link WfProblem} requires the
644
- * text to be non-empty, and "this step has an error" is still worth saying.
645
- */
646
- function problemSentence(problem) {
647
- const message = (typeof problem.message === "string" ? problem.message : "").split("\n").map((line) => line.trim()).filter(Boolean).join(" ");
648
- const word = SEVERITY_WORD[problem.severity] ?? SEVERITY_WORD.error;
649
- return message ? `${word}: ${message}` : word;
650
- }
651
- /** Run sentences together for a listener, adding a full stop only where the text
652
- * does not already end in one — so "Why?" is followed by a space rather than
653
- * "Why?.", and a message ending in a semicolon keeps it. Empty in, empty out:
654
- * `reduce` with no seed throws on an empty list, and this is reachable from any
655
- * caller holding an empty problem set. */
656
- function joinSentences(parts) {
657
- return parts.reduce((joined, part) => joined ? `${joined}${/[.!?;,]$/.test(joined) ? "" : "."} ${part}` : part, "");
658
- }
659
- /** Every problem on one anchor, as the tooltip text a marker hangs on. One per
660
- * line, so a step with three faults lists three rather than concatenating them
661
- * into a sentence nobody can parse. */
662
- function problemTitle(problems) {
663
- return problems.map(problemSentence).join("\n");
664
- }
665
- /**
666
- * Every message on one anchor, as the text a reader who cannot hover receives.
667
- * The visible mark has room for a glyph and a count, so the messages themselves
668
- * would otherwise live only in a `title` — which a pointer opens and a keyboard,
669
- * a screen reader and a touch device do not. Rendering them visually hidden puts
670
- * the same words in the accessibility tree, where they become the mark's own
671
- * accessible name.
672
- */
673
- function ProblemMessages({ problems }) {
674
- if (problems.length === 0) return null;
675
- return /* @__PURE__ */ jsx("span", {
676
- className: "sr-only",
677
- children: problems.length === 1 ? problemSentence(problems[0]) : `${problems.length} problems. ${joinSentences(problems.map(problemSentence))}`
678
- });
679
- }
680
- /**
681
- * The mark a node wears when the draft it is drawn from has a problem on it: a
682
- * warning glyph, with a count once there is more than one. It is a READOUT, not
683
- * a control — the node's own click already opens whatever the host shows for it,
684
- * and a second target inside the card would compete with that.
685
- *
686
- * It carries every message twice over: on `title`, where a pointer finds it, and
687
- * as visually hidden text, which is what the accessibility tree reads. That is
688
- * what makes the mark worth more than the border tint — the canvas says WHICH
689
- * step is broken at a glance and WHY without a trip to the problem list, however
690
- * the reader is driving it.
691
- */
692
- function ProblemMarker({ problems, className = "" }) {
693
- const severity = worstSeverity(problems) ?? "error";
694
- return /* @__PURE__ */ jsxs("span", {
695
- "data-testid": "wf-node-problem",
696
- "data-severity": severity,
697
- className: `flex shrink-0 items-center gap-0.5 rounded-full border px-1 py-[1px] font-medium text-[10px] leading-none ${className}`,
698
- style: problemSurface(severity),
699
- children: [/* @__PURE__ */ jsxs("span", {
700
- "aria-hidden": true,
701
- className: "flex items-center gap-0.5",
702
- title: problemTitle(problems),
703
- children: [/* @__PURE__ */ jsx(AlertTriangle, { size: 9 }), problems.length > 1 && /* @__PURE__ */ jsx("span", { children: problems.length })]
704
- }), /* @__PURE__ */ jsx(ProblemMessages, { problems })]
705
- });
706
- }
707
- /** The status pill in a card's header — the one place the run state is spelled
708
- * out in words. */
709
- function StatusPill({ status }) {
710
- return /* @__PURE__ */ jsx("span", {
711
- className: "shrink-0 whitespace-nowrap rounded-full border px-2 py-[1px] font-medium text-[10px]",
712
- style: STATUS_PILL[status],
713
- children: STATUS_LABEL[status]
714
- });
715
- }
716
- /**
717
- * The run status FOOTER: a progress bar (queued = near-empty, running = pulsing
718
- * partial, terminal = full) over a caption row. Pinned to the card's BOTTOM via
719
- * `mt-auto` so it lands at the same place on every node regardless of how much
720
- * content sits above it — unlike an inline strip, whose height drifts. The
721
- * caption reads agent rounds on the left and elapsed on the right; status itself
722
- * is carried by the bar color + the header pill, so it is NOT restated here. The
723
- * caption row always renders (even when empty) so the footer band keeps a
724
- * constant height across nodes.
725
- */
726
- function StatusFooter({ status, rounds, cost, tokens, elapsed }) {
727
- const caption = [
728
- rounds !== void 0 ? `${rounds} round${rounds === 1 ? "" : "s"}` : void 0,
729
- cost,
730
- tokens
731
- ].filter(Boolean).join(" · ");
732
- return /* @__PURE__ */ jsxs("div", {
733
- className: "wf-node-body-in mt-auto border-border border-t",
734
- children: [/* @__PURE__ */ jsx("div", {
735
- className: "h-1 w-full overflow-hidden",
736
- style: { background: MUTED_TRACK },
737
- children: /* @__PURE__ */ jsx("div", {
738
- "data-testid": "wf-node-progress",
739
- className: `h-full ${status === "running" ? "animate-pulse" : ""}`,
740
- ...status === "running" ? { "data-motion": "essential" } : {},
741
- style: {
742
- width: progressFill(status),
743
- background: STATUS_COLOR[status]
744
- }
745
- })
746
- }), /* @__PURE__ */ jsxs("div", {
747
- className: "flex items-center justify-between gap-2 px-3.5 py-1 text-[10px] text-muted-foreground leading-[15px]",
748
- children: [/* @__PURE__ */ jsx("span", {
749
- className: "min-h-[15px] truncate tabular-nums",
750
- children: caption
751
- }), /* @__PURE__ */ jsx("span", {
752
- className: "min-h-[15px] shrink-0 tabular-nums",
753
- children: elapsed ?? ""
754
- })]
755
- })]
756
- });
757
- }
758
- /**
759
- * What an expanded card says where its output WOULD be, when it has none. The
760
- * card is sized for the output it may yet have (the layout is computed once,
761
- * before any run state, so it can never reflow mid-run) — leaving that space
762
- * blank reads as a broken card rather than as a step with nothing to report. A
763
- * running node is the exception: its output may still be on its way, so it waits
764
- * quietly rather than claiming there is none.
765
- */
766
- function emptySlotLabel(status) {
767
- switch (status) {
768
- case "queued": return "Not run yet";
769
- case "succeeded": return "No output";
770
- case "failed": return "No error reported";
771
- default: return;
772
- }
773
- }
774
- /**
775
- * A node's MARK, centered in a tile the caller sizes: the provider's brand logo
776
- * when the step runs against a connector — the way n8n makes a node recognizable
777
- * before a word of it is read — else the kind's glyph, tinted by tone. Both
778
- * densities render the same mark at different sizes, so a node looks like itself
779
- * whether it's an icon tile or a full card.
780
- */
781
- function NodeMark({ kind, provider, model, accent, tile }) {
782
- const Icon = kind && KIND_ICON[kind] || Circle;
783
- const brand = !provider && model ? modelBrandFor(model) : null;
784
- const mark = Math.round(tile * (provider ? .58 : .5));
785
- return /* @__PURE__ */ jsx("span", {
786
- "aria-hidden": "true",
787
- className: "flex shrink-0 items-center justify-center overflow-hidden rounded-lg border border-border",
788
- style: {
789
- width: tile,
790
- height: tile,
791
- background: `color-mix(in srgb, ${accent} 10%, hsl(var(--card)))`
792
- },
793
- children: provider ? /* @__PURE__ */ jsx(ProviderIcon, {
794
- id: provider,
795
- displayName: providerLabel(provider),
796
- size: mark,
797
- className: "rounded-[3px]"
798
- }) : brand ? /* @__PURE__ */ jsx(ModelBrandStack, {
799
- identity: brand,
800
- size: brand.combined || tile >= 38 ? "md" : "sm"
801
- }) : /* @__PURE__ */ jsx(Icon, {
802
- size: mark,
803
- strokeWidth: 1.75,
804
- style: { color: accent }
805
- })
806
- });
807
- }
808
- //#endregion
809
290
  //#region src/workflows/WorkflowGraph.tsx
810
291
  /**
811
292
  * n8n-style node-graph view of a workflow, rendered from its YAML definition.
@@ -1,3 +1,7 @@
1
+ import { r as modelBrandFor, t as ModelBrandStack } from "./model-brand-hMyDTUqB.js";
2
+ import { t as ProviderIcon } from "./provider-logo-BiuiO_c2.js";
3
+ import { jsx, jsxs } from "react/jsx-runtime";
4
+ import { AlertTriangle, Bell, Box, Cable, Camera, Circle, Clock, Code, GitBranch, Repeat, ScanSearch, Sparkles, UserCheck, Webhook, Zap } from "lucide-react";
1
5
  import { parse } from "yaml";
2
6
  //#region src/workflows/cron.ts
3
7
  /**
@@ -537,7 +541,10 @@ function urlHost(url) {
537
541
  const CONTROL_FLOW_KEYS = [
538
542
  "if",
539
543
  "retry",
540
- "onError"
544
+ "onError",
545
+ "id",
546
+ "needs",
547
+ "join"
541
548
  ];
542
549
  const CONTROL_FLOW = new Set(CONTROL_FLOW_KEYS);
543
550
  /**
@@ -572,7 +579,8 @@ const MINTED_PROFILE_ID = /^ap_[A-Za-z0-9_-]{16}$/;
572
579
  * a minted catalog id, has no readable name here — the node is the generic agent
573
580
  * rather than a mangled identifier. */
574
581
  function agentTitle(profile) {
575
- const named = str(profile);
582
+ const inline = asRecord(profile).name;
583
+ const named = str(profile) ?? str(inline);
576
584
  if (!named || MINTED_PROFILE_ID.test(named)) return "AI Agent";
577
585
  return humanizeIdentifier(named);
578
586
  }
@@ -1247,4 +1255,521 @@ function buildWorkflowGraph(yaml, options) {
1247
1255
  };
1248
1256
  }
1249
1257
  //#endregion
1250
- export { buildWorkflowGraph as a, triggerNodeIndex as c, providerLabel as d, shortModel as f, fmtTokens as g, fmtDuration as h, branchNodeId as i, wfEdgeId as l, fmtCost as m, TRIGGER_NODE_ID as n, isStructuralKind as o, clampPreview as p, actionNodeId as r, triggerNodeId as s, COMPACT_NODE_SIZE as t, worstSeverity as u };
1258
+ //#region src/workflows/node-ui.tsx
1259
+ /**
1260
+ * Shared presentational atoms for a workflow graph node — the type glyph, the
1261
+ * status color/label/badge maps, and the edge coloring. One source of truth so
1262
+ * the node card and the edges that point at it read as one system.
1263
+ *
1264
+ * Colors resolve to the SEMANTIC surface tokens (`--surface-success-text`,
1265
+ * `--surface-danger-text`, …), which carry a light AND a dark value tuned for
1266
+ * contrast against the card. A literal palette hex (`#22c55e`) or a stock
1267
+ * Tailwind shade (`text-red-400`) carries ONE value, so it can only ever hold its
1268
+ * contrast in one of the two themes — never use one for a status color.
1269
+ *
1270
+ * Inline `style` colors here use the RAW brand tokens (`hsl(var(--primary))`,
1271
+ * `var(--surface-…)`) — NOT the `--color-*` @theme aliases, which are undefined
1272
+ * in a consumer that only imports `tokens.css` (e.g. platform-web), where a
1273
+ * `var(--color-*)` would silently resolve to nothing. See the edge-color test.
1274
+ */
1275
+ /** Track color behind a progress bar — a faint wash of the muted token. */
1276
+ const MUTED_TRACK = "color-mix(in srgb, hsl(var(--muted-foreground)) 22%, transparent)";
1277
+ const TONE_ACCENT = {
1278
+ trigger: "hsl(var(--primary))",
1279
+ structural: "var(--surface-warning-text)",
1280
+ action: "hsl(var(--muted-foreground))"
1281
+ };
1282
+ const STATUS_QUEUED = "hsl(var(--muted-foreground))";
1283
+ const STATUS_COLOR = {
1284
+ queued: STATUS_QUEUED,
1285
+ running: "hsl(var(--primary))",
1286
+ waiting: "var(--surface-warning-text)",
1287
+ succeeded: "var(--surface-success-text)",
1288
+ failed: "var(--surface-danger-text)"
1289
+ };
1290
+ /** An edge is colored by the status of the node it points AT, so the run's
1291
+ * "front" lights up. Neutral for a not-yet-reached (queued) target or the
1292
+ * static definition view (`undefined`). */
1293
+ function edgeColor(status) {
1294
+ return status ? STATUS_COLOR[status] : STATUS_QUEUED;
1295
+ }
1296
+ const STATUS_LABEL = {
1297
+ queued: "Queued",
1298
+ running: "Running",
1299
+ waiting: "Waiting on you",
1300
+ succeeded: "Done",
1301
+ failed: "Failed"
1302
+ };
1303
+ /** Status pill styling — a tinted well in the status color. Built from the
1304
+ * semantic surface trio (bg/border/text) so the pill keeps its contrast in both
1305
+ * themes rather than washing out in one of them. */
1306
+ const STATUS_PILL = {
1307
+ queued: {
1308
+ background: "color-mix(in srgb, hsl(var(--muted-foreground)) 12%, transparent)",
1309
+ color: "hsl(var(--muted-foreground))",
1310
+ borderColor: "color-mix(in srgb, hsl(var(--muted-foreground)) 28%, transparent)"
1311
+ },
1312
+ running: {
1313
+ background: "color-mix(in srgb, hsl(var(--primary)) 14%, transparent)",
1314
+ color: "hsl(var(--primary))",
1315
+ borderColor: "color-mix(in srgb, hsl(var(--primary)) 40%, transparent)"
1316
+ },
1317
+ waiting: {
1318
+ background: "var(--surface-warning-bg)",
1319
+ color: "var(--surface-warning-text)",
1320
+ borderColor: "var(--surface-warning-border)"
1321
+ },
1322
+ succeeded: {
1323
+ background: "var(--surface-success-bg)",
1324
+ color: "var(--surface-success-text)",
1325
+ borderColor: "var(--surface-success-border)"
1326
+ },
1327
+ failed: {
1328
+ background: "var(--surface-danger-bg)",
1329
+ color: "var(--surface-danger-text)",
1330
+ borderColor: "var(--surface-danger-border)"
1331
+ }
1332
+ };
1333
+ /** The card's border + ring once a node has live run state, so the running node
1334
+ * and the terminal ones read at a glance. Returned as inline style (not classes)
1335
+ * because the status colors are semantic tokens, not palette shades. */
1336
+ function statusBorder(status) {
1337
+ const color = STATUS_COLOR[status];
1338
+ switch (status) {
1339
+ case "running":
1340
+ case "waiting": return {
1341
+ borderColor: color,
1342
+ boxShadow: `0 0 0 1px color-mix(in srgb, ${color} 45%, transparent), 0 0 24px -6px ${color}`
1343
+ };
1344
+ case "queued": return { borderColor: "hsl(var(--border))" };
1345
+ default: return {
1346
+ borderColor: color,
1347
+ boxShadow: `0 0 0 1px color-mix(in srgb, ${color} 35%, transparent)`
1348
+ };
1349
+ }
1350
+ }
1351
+ /** Determinate progress fraction for a node's status: queued reads near-empty,
1352
+ * running and waiting a partial (the run reached this node but has not finished
1353
+ * it), terminal full. Shared so every progress treatment (bar, footer, fill) maps
1354
+ * status → fill identically. A `waiting` bar is deliberately NOT animated by its
1355
+ * callers: the run is stopped there, and a moving bar would say otherwise. */
1356
+ function progressFill(status) {
1357
+ return status === "succeeded" || status === "failed" ? "100%" : status === "running" || status === "waiting" ? "58%" : "6%";
1358
+ }
1359
+ const KIND_ICON = {
1360
+ schedule: Clock,
1361
+ provider_event: Webhook,
1362
+ webhook: Webhook,
1363
+ trigger: Zap,
1364
+ "agent.run": Sparkles,
1365
+ "integration.invoke": Cable,
1366
+ notify: Bell,
1367
+ parallel: GitBranch,
1368
+ foreach: Repeat,
1369
+ decision: UserCheck,
1370
+ "sandbox.spawn": Box,
1371
+ "sandbox.snapshot": Camera,
1372
+ "script.run": Code,
1373
+ "trace.analyze": ScanSearch
1374
+ };
1375
+ /** An authoring problem's colours — the same semantic surface trio the run
1376
+ * statuses use, so a broken step and a failed one are recognisably the same
1377
+ * family of "something is wrong here" without being the same signal. */
1378
+ const PROBLEM_SURFACE_BY_SEVERITY = {
1379
+ error: {
1380
+ background: "var(--surface-danger-bg)",
1381
+ color: "var(--surface-danger-text)",
1382
+ borderColor: "var(--surface-danger-border)"
1383
+ },
1384
+ warning: {
1385
+ background: "var(--surface-warning-bg)",
1386
+ color: "var(--surface-warning-text)",
1387
+ borderColor: "var(--surface-warning-border)"
1388
+ }
1389
+ };
1390
+ /**
1391
+ * The colours a severity is drawn in. A lookup rather than the table itself, so
1392
+ * a severity this library has no entry for cannot throw its way out of a render
1393
+ * — the same resilience `problemSentence` and `worstSeverity` already give it,
1394
+ * and for the same reason: the package is consumed from JavaScript, and one
1395
+ * malformed problem must not take the canvas down. An unrecognised severity is
1396
+ * drawn as an error, matching how it is named and ranked.
1397
+ */
1398
+ function problemSurface(severity) {
1399
+ return PROBLEM_SURFACE_BY_SEVERITY[severity] ?? PROBLEM_SURFACE_BY_SEVERITY.error;
1400
+ }
1401
+ /** A node's border once it carries an authoring problem. Applied only where the
1402
+ * node has no RUN status to state — a run's border is the more urgent reading
1403
+ * of the same edge of the same box, and a definition being edited has no run. */
1404
+ function problemBorder(severity) {
1405
+ const color = problemSurface(severity).color;
1406
+ return {
1407
+ borderColor: color,
1408
+ boxShadow: `0 0 0 1px color-mix(in srgb, ${color} 35%, transparent)`
1409
+ };
1410
+ }
1411
+ /** How each severity is named where a colour cannot be seen. */
1412
+ const SEVERITY_WORD = {
1413
+ error: "Error",
1414
+ warning: "Warning"
1415
+ };
1416
+ /**
1417
+ * One problem as a reader receives it: WHICH KIND it is, then what it says.
1418
+ *
1419
+ * The kind has to be in the words. A mark shows one aggregate severity — as a
1420
+ * colour and a glyph, neither of which a screen reader receives — so an anchor
1421
+ * carrying an error and a warning together would otherwise offer no way to tell
1422
+ * which of the two is the one blocking the compile.
1423
+ *
1424
+ * NEWLINES are the only thing rewritten: each line is trimmed and the lines are
1425
+ * run together, so a multi-line host message cannot break the one-problem-per-
1426
+ * line reading below. Everything else survives — including internal spacing and
1427
+ * the trailing punctuation a diagnostic may be ABOUT ("unexpected ;") — because
1428
+ * the canvas has to say what the host's own problem list says. Separators are
1429
+ * handled at the join instead ({@link joinSentences}).
1430
+ *
1431
+ * A problem with no message at all is named by its severity alone, rather than
1432
+ * by a colon with nothing after it: nothing in {@link WfProblem} requires the
1433
+ * text to be non-empty, and "this step has an error" is still worth saying.
1434
+ */
1435
+ function problemSentence(problem) {
1436
+ const message = (typeof problem.message === "string" ? problem.message : "").split("\n").map((line) => line.trim()).filter(Boolean).join(" ");
1437
+ const word = SEVERITY_WORD[problem.severity] ?? SEVERITY_WORD.error;
1438
+ return message ? `${word}: ${message}` : word;
1439
+ }
1440
+ /** Run sentences together for a listener, adding a full stop only where the text
1441
+ * does not already end in one — so "Why?" is followed by a space rather than
1442
+ * "Why?.", and a message ending in a semicolon keeps it. Empty in, empty out:
1443
+ * `reduce` with no seed throws on an empty list, and this is reachable from any
1444
+ * caller holding an empty problem set. */
1445
+ function joinSentences(parts) {
1446
+ return parts.reduce((joined, part) => joined ? `${joined}${/[.!?;,]$/.test(joined) ? "" : "."} ${part}` : part, "");
1447
+ }
1448
+ /** Every problem on one anchor, as the tooltip text a marker hangs on. One per
1449
+ * line, so a step with three faults lists three rather than concatenating them
1450
+ * into a sentence nobody can parse. */
1451
+ function problemTitle(problems) {
1452
+ return problems.map(problemSentence).join("\n");
1453
+ }
1454
+ /**
1455
+ * Every message on one anchor, as the text a reader who cannot hover receives.
1456
+ * The visible mark has room for a glyph and a count, so the messages themselves
1457
+ * would otherwise live only in a `title` — which a pointer opens and a keyboard,
1458
+ * a screen reader and a touch device do not. Rendering them visually hidden puts
1459
+ * the same words in the accessibility tree, where they become the mark's own
1460
+ * accessible name.
1461
+ */
1462
+ function ProblemMessages({ problems }) {
1463
+ if (problems.length === 0) return null;
1464
+ return /* @__PURE__ */ jsx("span", {
1465
+ className: "sr-only",
1466
+ children: problems.length === 1 ? problemSentence(problems[0]) : `${problems.length} problems. ${joinSentences(problems.map(problemSentence))}`
1467
+ });
1468
+ }
1469
+ /**
1470
+ * The mark a node wears when the draft it is drawn from has a problem on it: a
1471
+ * warning glyph, with a count once there is more than one. It is a READOUT, not
1472
+ * a control — the node's own click already opens whatever the host shows for it,
1473
+ * and a second target inside the card would compete with that.
1474
+ *
1475
+ * It carries every message twice over: on `title`, where a pointer finds it, and
1476
+ * as visually hidden text, which is what the accessibility tree reads. That is
1477
+ * what makes the mark worth more than the border tint — the canvas says WHICH
1478
+ * step is broken at a glance and WHY without a trip to the problem list, however
1479
+ * the reader is driving it.
1480
+ */
1481
+ function ProblemMarker({ problems, className = "" }) {
1482
+ const severity = worstSeverity(problems) ?? "error";
1483
+ return /* @__PURE__ */ jsxs("span", {
1484
+ "data-testid": "wf-node-problem",
1485
+ "data-severity": severity,
1486
+ className: `flex shrink-0 items-center gap-0.5 rounded-full border px-1 py-[1px] font-medium text-[10px] leading-none ${className}`,
1487
+ style: problemSurface(severity),
1488
+ children: [/* @__PURE__ */ jsxs("span", {
1489
+ "aria-hidden": true,
1490
+ className: "flex items-center gap-0.5",
1491
+ title: problemTitle(problems),
1492
+ children: [/* @__PURE__ */ jsx(AlertTriangle, { size: 9 }), problems.length > 1 && /* @__PURE__ */ jsx("span", { children: problems.length })]
1493
+ }), /* @__PURE__ */ jsx(ProblemMessages, { problems })]
1494
+ });
1495
+ }
1496
+ /** The status pill in a card's header — the one place the run state is spelled
1497
+ * out in words. */
1498
+ function StatusPill({ status }) {
1499
+ return /* @__PURE__ */ jsx("span", {
1500
+ className: "shrink-0 whitespace-nowrap rounded-full border px-2 py-[1px] font-medium text-[10px]",
1501
+ style: STATUS_PILL[status],
1502
+ children: STATUS_LABEL[status]
1503
+ });
1504
+ }
1505
+ /**
1506
+ * The run status FOOTER: a progress bar (queued = near-empty, running = pulsing
1507
+ * partial, terminal = full) over a caption row. Pinned to the card's BOTTOM via
1508
+ * `mt-auto` so it lands at the same place on every node regardless of how much
1509
+ * content sits above it — unlike an inline strip, whose height drifts. The
1510
+ * caption reads agent rounds on the left and elapsed on the right; status itself
1511
+ * is carried by the bar color + the header pill, so it is NOT restated here. The
1512
+ * caption row always renders (even when empty) so the footer band keeps a
1513
+ * constant height across nodes.
1514
+ */
1515
+ function StatusFooter({ status, rounds, cost, tokens, elapsed }) {
1516
+ const caption = [
1517
+ rounds !== void 0 ? `${rounds} round${rounds === 1 ? "" : "s"}` : void 0,
1518
+ cost,
1519
+ tokens
1520
+ ].filter(Boolean).join(" · ");
1521
+ return /* @__PURE__ */ jsxs("div", {
1522
+ className: "wf-node-body-in mt-auto border-border border-t",
1523
+ children: [/* @__PURE__ */ jsx("div", {
1524
+ className: "h-1 w-full overflow-hidden",
1525
+ style: { background: MUTED_TRACK },
1526
+ children: /* @__PURE__ */ jsx("div", {
1527
+ "data-testid": "wf-node-progress",
1528
+ className: `h-full ${status === "running" ? "animate-pulse" : ""}`,
1529
+ ...status === "running" ? { "data-motion": "essential" } : {},
1530
+ style: {
1531
+ width: progressFill(status),
1532
+ background: STATUS_COLOR[status]
1533
+ }
1534
+ })
1535
+ }), /* @__PURE__ */ jsxs("div", {
1536
+ className: "flex items-center justify-between gap-2 px-3.5 py-1 text-[10px] text-muted-foreground leading-[15px]",
1537
+ children: [/* @__PURE__ */ jsx("span", {
1538
+ className: "min-h-[15px] truncate tabular-nums",
1539
+ children: caption
1540
+ }), /* @__PURE__ */ jsx("span", {
1541
+ className: "min-h-[15px] shrink-0 tabular-nums",
1542
+ children: elapsed ?? ""
1543
+ })]
1544
+ })]
1545
+ });
1546
+ }
1547
+ /**
1548
+ * What an expanded card says where its output WOULD be, when it has none. The
1549
+ * card is sized for the output it may yet have (the layout is computed once,
1550
+ * before any run state, so it can never reflow mid-run) — leaving that space
1551
+ * blank reads as a broken card rather than as a step with nothing to report. A
1552
+ * running node is the exception: its output may still be on its way, so it waits
1553
+ * quietly rather than claiming there is none.
1554
+ */
1555
+ function emptySlotLabel(status) {
1556
+ switch (status) {
1557
+ case "queued": return "Not run yet";
1558
+ case "succeeded": return "No output";
1559
+ case "failed": return "No error reported";
1560
+ default: return;
1561
+ }
1562
+ }
1563
+ /**
1564
+ * A node's MARK, centered in a tile the caller sizes: the provider's brand logo
1565
+ * when the step runs against a connector — the way n8n makes a node recognizable
1566
+ * before a word of it is read — else the kind's glyph, tinted by tone. Both
1567
+ * densities render the same mark at different sizes, so a node looks like itself
1568
+ * whether it's an icon tile or a full card.
1569
+ */
1570
+ function NodeMark({ kind, provider, model, accent, tile }) {
1571
+ const Icon = kind && KIND_ICON[kind] || Circle;
1572
+ const brand = !provider && model ? modelBrandFor(model) : null;
1573
+ const mark = Math.round(tile * (provider ? .58 : .5));
1574
+ return /* @__PURE__ */ jsx("span", {
1575
+ "aria-hidden": "true",
1576
+ className: "flex shrink-0 items-center justify-center overflow-hidden rounded-lg border border-border",
1577
+ style: {
1578
+ width: tile,
1579
+ height: tile,
1580
+ background: `color-mix(in srgb, ${accent} 10%, hsl(var(--card)))`
1581
+ },
1582
+ children: provider ? /* @__PURE__ */ jsx(ProviderIcon, {
1583
+ id: provider,
1584
+ displayName: providerLabel(provider),
1585
+ size: mark,
1586
+ className: "rounded-[3px]"
1587
+ }) : brand ? /* @__PURE__ */ jsx(ModelBrandStack, {
1588
+ identity: brand,
1589
+ size: brand.combined || tile >= 38 ? "md" : "sm"
1590
+ }) : /* @__PURE__ */ jsx(Icon, {
1591
+ size: mark,
1592
+ strokeWidth: 1.75,
1593
+ style: { color: accent }
1594
+ })
1595
+ });
1596
+ }
1597
+ //#endregion
1598
+ //#region src/workflows/node-output.tsx
1599
+ /** Failure text, in the semantic danger token — which carries a light AND a dark
1600
+ * value. A palette shade (`text-red-400`) only ever reads well in one theme. */
1601
+ const ERROR_TEXT = { color: "var(--surface-danger-text)" };
1602
+ /** Max key/value pairs a shallow-object output renders before it reports
1603
+ * `truncated` — the card only has room for a couple rows anyway. */
1604
+ const MAX_JSON_ENTRIES = 6;
1605
+ /**
1606
+ * A line that is pure structure — a code fence (with or without a language tag), a
1607
+ * table's rule row, a thematic break — and so carries no words to keep.
1608
+ *
1609
+ * Every test is a SINGLE quantified character class: unambiguous, and linear in the
1610
+ * length of the line. Written as one pattern (`[\s|:-]+\|[\s|:-]*`) the two
1611
+ * quantifiers would compete for the same `|`, which is the shape that backtracks.
1612
+ * Recognize the charset in one pass instead, then ask the cheap questions — is
1613
+ * there a pipe? a dash? — with plain string checks.
1614
+ */
1615
+ function isStructureOnly(line) {
1616
+ if (/^(```|~~~)/.test(line)) return true;
1617
+ const isBreak = /^([-*_])\1{2,}$/.test(line);
1618
+ if (/^[\s|:-]+$/.test(line) && line.includes("|")) return line.includes("-");
1619
+ return isBreak;
1620
+ }
1621
+ /**
1622
+ * Flatten an agent's markdown answer into the sentence a person would read out
1623
+ * of it. On a two-line card preview the syntax IS the noise: a `## Heading` mid
1624
+ * paragraph, a `**bold**` run and a bullet's `-` all survive the clamp while the
1625
+ * words that carry the meaning get pushed past it. Stripping the markup (never
1626
+ * the words) is what turns the preview back into prose — the full text, markup
1627
+ * and all, is still rendered by the node's detail view.
1628
+ *
1629
+ * Structure-only lines (a fence, a table rule) are dropped; everything else keeps
1630
+ * its text and is joined into one flowing line.
1631
+ */
1632
+ function condenseText(text) {
1633
+ const isListItem = (line) => /^\s*([-*+]\s|\d+[.)]\s)/.test(line);
1634
+ return text.split("\n").map((line) => ({
1635
+ item: isListItem(line),
1636
+ text: line.replace(/^\s{0,3}#{1,6}\s+/, "").replace(/^\s{0,3}>\s?/, "").replace(/^\s*[-*+]\s+(\[[ xX]\]\s+)?/, "").replace(/^\s*\d+[.)]\s+/, "").trim()
1637
+ })).filter(({ text: line }) => line !== "" && !isStructureOnly(line)).map(({ item, text: line }, i) => i > 0 && item ? `· ${line}` : line).join(" ").replace(/(?<=^|\s)(\*\*|~~)(?!\s)(.+?)(?<!\s)\1(?=[\s.,:;!?)\]}"']|$)/g, "$2").replace(/(?<![\w*])\*(?!\s)([^*]+?)(?<!\s)\*(?![\w*])/g, "$1").replace(/`([^`]+)`/g, "$1").replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/\s+/g, " ").trim();
1638
+ }
1639
+ /** A single JSON value as a compact one-line string for the key/value render.
1640
+ * Scalars render verbatim; a nested object/array collapses to a shape marker so
1641
+ * one row never balloons into a wall of text. */
1642
+ function scalar(value) {
1643
+ if (value === null) return "null";
1644
+ if (typeof value === "string") return value;
1645
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
1646
+ if (Array.isArray(value)) return `[${value.length} item${value.length === 1 ? "" : "s"}]`;
1647
+ if (typeof value === "object") return "{…}";
1648
+ return "";
1649
+ }
1650
+ /**
1651
+ * Recover the shape of an output preview string. Only text that *begins* like
1652
+ * JSON (`{`/`[`) is parse-attempted, so ordinary prose starting with a brace-free
1653
+ * sentence is never mis-parsed. A begins-like-JSON string that fails to parse
1654
+ * (the host clamped it mid-value) still renders as `code`, not prose, so it keeps
1655
+ * its monospace/data affordance.
1656
+ *
1657
+ * `condenseMarkdown` says the prose IS markdown and may be flattened for the
1658
+ * preview ({@link condenseText}). It is OFF by default, and the caller must only
1659
+ * turn it on for text an agent actually authored as markdown. Applied to anything
1660
+ * else it does not merely look wrong — it silently REWRITES the text: a stack
1661
+ * trace's `__init__.py` loses its underscores, and a shell glob `src/**` pairs
1662
+ * with a later `**` and both vanish. An error message is the one string a person
1663
+ * reads to find out what broke; it must reach them exactly as it was thrown.
1664
+ */
1665
+ function classifyOutput(preview, condenseMarkdown = false) {
1666
+ let trimmed = preview?.trim() ?? "";
1667
+ trimmed = trimmed.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])/g, "").replace(/(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, "").trimEnd();
1668
+ if (trimmed === "") return { kind: "empty" };
1669
+ if (trimmed.startsWith("{") || trimmed.startsWith("[")) try {
1670
+ const parsed = JSON.parse(trimmed);
1671
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
1672
+ const all = Object.entries(parsed);
1673
+ const entries = all.slice(0, MAX_JSON_ENTRIES).map(([k, v]) => [k, scalar(v)]);
1674
+ if (entries.length > 0) return {
1675
+ kind: "json",
1676
+ entries,
1677
+ truncated: all.length > entries.length
1678
+ };
1679
+ }
1680
+ return {
1681
+ kind: "code",
1682
+ text: trimmed
1683
+ };
1684
+ } catch {
1685
+ return {
1686
+ kind: "code",
1687
+ text: trimmed
1688
+ };
1689
+ }
1690
+ if (!condenseMarkdown) return {
1691
+ kind: "text",
1692
+ text: trimmed
1693
+ };
1694
+ const condensed = condenseText(trimmed);
1695
+ return condensed === "" ? { kind: "empty" } : {
1696
+ kind: "text",
1697
+ text: condensed
1698
+ };
1699
+ }
1700
+ /** Line-clamp utility per row budget, so `code`/`text` shapes honor `rows` the
1701
+ * same way the JSON branch does (rather than always clamping to two). Covers the
1702
+ * full range a card can reserve; an unsupported count falls back to the two-line
1703
+ * clamp, which renders FEWER lines than the caller asked for — so a card whose
1704
+ * reservation outgrows this map shows a short body inside a tall well rather
1705
+ * than erroring. Extend the map when a design reserves more.
1706
+ * Class names are literal so the Tailwind scanner emits each utility. */
1707
+ const CLAMP_BY_ROWS = {
1708
+ 1: "truncate",
1709
+ 2: "line-clamp-2",
1710
+ 3: "line-clamp-3",
1711
+ 4: "line-clamp-4",
1712
+ 5: "line-clamp-5",
1713
+ 6: "line-clamp-6"
1714
+ };
1715
+ /**
1716
+ * Render a classified output preview's CONTENT (no surrounding frame — each node
1717
+ * design supplies its own container/label). `rows` caps the visible lines so the
1718
+ * body stays within a card's reserved output space; `tone="error"` colors it for
1719
+ * a failure message.
1720
+ *
1721
+ * `rows="none"` renders the body UNCLAMPED, for a container that scrolls or is
1722
+ * sized by its content — a drawer, a detail panel, a selection overlay. It is a
1723
+ * word rather than a large number because there is no number that means "do not
1724
+ * clamp": a count outside {@link CLAMP_BY_ROWS} falls back to two lines, so
1725
+ * `rows={99}` asks for everything and silently shows the least.
1726
+ */
1727
+ function NodeOutputBody({ shape, rows = 2, tone = "default" }) {
1728
+ const unclamped = rows === "none";
1729
+ const clampClass = unclamped ? "" : CLAMP_BY_ROWS[rows] ?? "line-clamp-2";
1730
+ const codeWrap = rows === 1 ? "" : "whitespace-pre-wrap break-all";
1731
+ if (shape.kind === "empty") return null;
1732
+ if (shape.kind === "json") {
1733
+ const overflow = unclamped ? shape.truncated : shape.truncated || shape.entries.length > rows;
1734
+ const visible = unclamped ? shape.entries : shape.entries.slice(0, overflow ? Math.max(0, rows - 1) : rows);
1735
+ const isError = tone === "error";
1736
+ const keyStyle = isError ? ERROR_TEXT : void 0;
1737
+ const keyClass = isError ? "" : "text-muted-foreground";
1738
+ const valueClass = isError ? "" : "text-foreground";
1739
+ return /* @__PURE__ */ jsxs("dl", {
1740
+ className: "space-y-0.5 font-mono text-[10.5px] leading-snug",
1741
+ children: [visible.map(([key, value]) => /* @__PURE__ */ jsxs("div", {
1742
+ className: "flex gap-1.5",
1743
+ children: [/* @__PURE__ */ jsx("dt", {
1744
+ className: `max-w-[45%] shrink-0 truncate ${keyClass}`,
1745
+ style: keyStyle,
1746
+ title: key,
1747
+ children: key
1748
+ }), /* @__PURE__ */ jsx("dd", {
1749
+ className: `min-w-0 flex-1 truncate ${valueClass}`,
1750
+ style: isError ? ERROR_TEXT : void 0,
1751
+ title: value,
1752
+ children: value
1753
+ })]
1754
+ }, key)), overflow && /* @__PURE__ */ jsx("div", {
1755
+ className: `text-[10px] ${keyClass}`,
1756
+ style: keyStyle,
1757
+ children: "…"
1758
+ })]
1759
+ });
1760
+ }
1761
+ if (shape.kind === "code") return /* @__PURE__ */ jsx("pre", {
1762
+ className: `${clampClass} ${codeWrap} font-mono text-[10.5px] leading-snug ${tone === "error" ? "" : "text-foreground"}`,
1763
+ style: tone === "error" ? ERROR_TEXT : void 0,
1764
+ title: shape.text,
1765
+ children: shape.text
1766
+ });
1767
+ return /* @__PURE__ */ jsx("p", {
1768
+ className: `${clampClass} text-[11px] leading-snug ${tone === "error" ? "" : "text-foreground"}`,
1769
+ style: tone === "error" ? ERROR_TEXT : void 0,
1770
+ title: shape.text,
1771
+ children: shape.text
1772
+ });
1773
+ }
1774
+ //#endregion
1775
+ export { actionPathLabel as A, branchNodeId as C, triggerNodeIndex as D, triggerNodeId as E, fmtCost as F, fmtDuration as I, fmtTokens as L, parseActionPath as M, shortModel as N, wfEdgeId as O, clampPreview as P, actionNodeId as S, isStructuralKind as T, problemSurface as _, NodeMark as a, COMPACT_NODE_SIZE as b, STATUS_COLOR as c, StatusFooter as d, StatusPill as f, problemBorder as g, emptySlotLabel as h, KIND_ICON as i, humanizeIdentifier as j, worstSeverity as k, STATUS_LABEL as l, edgeColor as m, classifyOutput as n, ProblemMarker as o, TONE_ACCENT as p, condenseText as r, ProblemMessages as s, NodeOutputBody as t, STATUS_PILL as u, problemTitle as v, buildWorkflowGraph as w, TRIGGER_NODE_ID as x, statusBorder as y };
@@ -1,4 +1,6 @@
1
1
  import { a as modelBrandFor, n as ModelBrandStack, t as ModelBrandIdentity } from "./model-brand-B9A4H9pJ.js";
2
+ import { ReactElement } from "react";
3
+ import { LucideIcon } from "lucide-react";
2
4
  import "@xyflow/react";
3
5
  import "@xyflow/react/dist/style.css";
4
6
  //#region src/workflows/model.d.ts
@@ -488,4 +490,167 @@ interface WorkflowGraphProps {
488
490
  //#region src/workflows/WorkflowGraphLazy.d.ts
489
491
  declare function WorkflowGraph(props: WorkflowGraphProps): import("react").JSX.Element;
490
492
  //#endregion
491
- export { type BuildWorkflowGraphOptions, type ModelBrandIdentity, ModelBrandStack, type WfEdge, type WfEdgeKind, type WfEdgeSpec, type WfGraph, type WfNode, type WfNodeData, type WfNodeState, type WfNodeStatus, type WfNodeTone, type WfProblem, type WfProblemSeverity, WorkflowGraph as WorkflowGraphLazy, type WorkflowGraphProps, modelBrandFor };
493
+ //#region src/workflows/node-ui.d.ts
494
+ export declare const STATUS_COLOR: Record<WfNodeStatus, string>;
495
+ export declare const STATUS_LABEL: Record<WfNodeStatus, string>;
496
+ /** Status pill styling — a tinted well in the status color. Built from the
497
+ * semantic surface trio (bg/border/text) so the pill keeps its contrast in both
498
+ * themes rather than washing out in one of them. */
499
+ export declare const STATUS_PILL: Record<WfNodeStatus, {
500
+ background: string;
501
+ color: string;
502
+ borderColor: string;
503
+ }>;
504
+ export declare const KIND_ICON: Record<string, LucideIcon>;
505
+ /** The status pill in a card's header — the one place the run state is spelled
506
+ * out in words. */
507
+ declare function StatusPill({ status }: {
508
+ status: WfNodeStatus;
509
+ }): import("react").JSX.Element;
510
+ /**
511
+ * A node's MARK, centered in a tile the caller sizes: the provider's brand logo
512
+ * when the step runs against a connector — the way n8n makes a node recognizable
513
+ * before a word of it is read — else the kind's glyph, tinted by tone. Both
514
+ * densities render the same mark at different sizes, so a node looks like itself
515
+ * whether it's an icon tile or a full card.
516
+ */
517
+ export declare function NodeMark({ kind, provider, model, accent, tile }: {
518
+ kind?: string;
519
+ provider?: string;
520
+ /** The model an agent runs, e.g. "anthropic/claude-sonnet-4-5". Its lab/host
521
+ * brand mark identifies the node far faster than a generic sparkle. */
522
+ model?: string;
523
+ accent: string;
524
+ /** Edge length of the square the mark is centered in. */
525
+ tile: number;
526
+ }): import("react").JSX.Element;
527
+ //#endregion
528
+ //#region src/workflows/naming.d.ts
529
+ /**
530
+ * Human-facing naming for workflow nodes. A definition addresses things by
531
+ * machine identifier — an action kind (`agent.run`), a hub action path
532
+ * (`github.pulls.reviews.create`), a profile slug (`pr-reviewer`) — and showing
533
+ * those verbatim on a node makes the graph read like a config file. These turn
534
+ * an identifier into the label a person would say out loud.
535
+ *
536
+ * Pure and dependency-free so the naming rules are unit-testable without the
537
+ * YAML parser or a render.
538
+ */
539
+ /** How the first word of a humanized identifier is cased. An initialism is
540
+ * ALWAYS upper-cased whichever is chosen — that is the whole point of the table
541
+ * above, and `.toLowerCase()`ing the result afterwards silently undoes it
542
+ * ("List API keys" → "list api keys"). */
543
+ type LeadCase = "sentence" | "lower" | "title";
544
+ /**
545
+ * Humanize a machine identifier: `pr-reviewer` → "PR reviewer", `sandbox.spawn`
546
+ * → "Sandbox spawn", `postMessage` → "Post message". Known initialisms are
547
+ * upper-cased wherever they appear; `lead` picks the case of the rest.
548
+ *
549
+ * - `sentence` (default): "Create issue comment" — a label or a title.
550
+ * - `lower`: "create issue comment" — a fragment that sits INSIDE a phrase
551
+ * ("On pull request"), where a capital would read as a new sentence. An
552
+ * initialism still wins: `ci_run_completed` → "CI run completed", never
553
+ * "cI run completed".
554
+ * - `title`: "Google Sheets" — a brand/proper name.
555
+ *
556
+ * An identifier that carries no word characters is returned verbatim rather than
557
+ * blanked.
558
+ */
559
+ export declare function humanizeIdentifier(id: string, lead?: LeadCase): string;
560
+ /** A model slug without its provider prefix (`anthropic/claude-sonnet-5` →
561
+ * `claude-sonnet-5`) — the vendor is noise on a card whose width is the scarce
562
+ * resource, and the full slug stays in the node's config for the detail view. */
563
+ export declare function shortModel(model: string): string;
564
+ /** The parts of a hub action path (`<provider>.<resource…>.<operation>`). */
565
+ interface ActionPathParts {
566
+ /** The connector the action runs against (`github`). */
567
+ provider: string;
568
+ /** The trailing verb (`create`). */
569
+ operation: string;
570
+ /** Everything between provider and operation (`pulls.reviews`), when present —
571
+ * a two-segment path (`slack.postMessage`) has no resource. */
572
+ resource?: string;
573
+ }
574
+ /** Split a hub action path into provider / resource / operation. Null when the
575
+ * value isn't a dotted path (a `${…}` expression, or a bare word), so callers
576
+ * fall back to showing it verbatim instead of inventing structure. */
577
+ export declare function parseActionPath(path: string): ActionPathParts | null;
578
+ /**
579
+ * The operation label for an action path, in n8n's `operation: resource` shape:
580
+ * `github.pulls.reviews.create` → "create: pulls.reviews", `slack.postMessage`
581
+ * → "post message". The provider names the node itself (its title + logo), so
582
+ * it is deliberately absent here. A path we can't split is shown verbatim.
583
+ */
584
+ export declare function actionPathLabel(path: string): string;
585
+ //#endregion
586
+ //#region src/workflows/node-output.d.ts
587
+ /** The recovered shape of an output preview. */
588
+ type OutputShape = {
589
+ kind: "empty";
590
+ } |
591
+ /** A shallow JSON object, flattened to top-level key/value pairs for a
592
+ * key-value render. `truncated` when more pairs exist than we kept. */
593
+ {
594
+ kind: "json";
595
+ entries: [string, string][];
596
+ truncated: boolean;
597
+ } |
598
+ /** Structured-looking text we won't key/value render — a JSON array, a nested
599
+ * object, or a fragment the host clamped mid-token so it won't parse. Shown in
600
+ * monospace so it still reads as data, not prose. */
601
+ {
602
+ kind: "code";
603
+ text: string;
604
+ } |
605
+ /** Free-form prose (agent output, a status line, an error message). */
606
+ {
607
+ kind: "text";
608
+ text: string;
609
+ };
610
+ /**
611
+ * Flatten an agent's markdown answer into the sentence a person would read out
612
+ * of it. On a two-line card preview the syntax IS the noise: a `## Heading` mid
613
+ * paragraph, a `**bold**` run and a bullet's `-` all survive the clamp while the
614
+ * words that carry the meaning get pushed past it. Stripping the markup (never
615
+ * the words) is what turns the preview back into prose — the full text, markup
616
+ * and all, is still rendered by the node's detail view.
617
+ *
618
+ * Structure-only lines (a fence, a table rule) are dropped; everything else keeps
619
+ * its text and is joined into one flowing line.
620
+ */
621
+ export declare function condenseText(text: string): string;
622
+ /**
623
+ * Recover the shape of an output preview string. Only text that *begins* like
624
+ * JSON (`{`/`[`) is parse-attempted, so ordinary prose starting with a brace-free
625
+ * sentence is never mis-parsed. A begins-like-JSON string that fails to parse
626
+ * (the host clamped it mid-value) still renders as `code`, not prose, so it keeps
627
+ * its monospace/data affordance.
628
+ *
629
+ * `condenseMarkdown` says the prose IS markdown and may be flattened for the
630
+ * preview ({@link condenseText}). It is OFF by default, and the caller must only
631
+ * turn it on for text an agent actually authored as markdown. Applied to anything
632
+ * else it does not merely look wrong — it silently REWRITES the text: a stack
633
+ * trace's `__init__.py` loses its underscores, and a shell glob `src/**` pairs
634
+ * with a later `**` and both vanish. An error message is the one string a person
635
+ * reads to find out what broke; it must reach them exactly as it was thrown.
636
+ */
637
+ export declare function classifyOutput(preview: string | undefined, condenseMarkdown?: boolean): OutputShape;
638
+ /**
639
+ * Render a classified output preview's CONTENT (no surrounding frame — each node
640
+ * design supplies its own container/label). `rows` caps the visible lines so the
641
+ * body stays within a card's reserved output space; `tone="error"` colors it for
642
+ * a failure message.
643
+ *
644
+ * `rows="none"` renders the body UNCLAMPED, for a container that scrolls or is
645
+ * sized by its content — a drawer, a detail panel, a selection overlay. It is a
646
+ * word rather than a large number because there is no number that means "do not
647
+ * clamp": a count outside {@link CLAMP_BY_ROWS} falls back to two lines, so
648
+ * `rows={99}` asks for everything and silently shows the least.
649
+ */
650
+ export declare function NodeOutputBody({ shape, rows, tone }: {
651
+ shape: OutputShape;
652
+ rows?: number | "none";
653
+ tone?: "default" | "error";
654
+ }): ReactElement | null;
655
+ //#endregion
656
+ export { type ActionPathParts, type BuildWorkflowGraphOptions, type LeadCase, type ModelBrandIdentity, ModelBrandStack, StatusPill as NodeStatusPill, type OutputShape, type WfEdge, type WfEdgeKind, type WfEdgeSpec, type WfGraph, type WfNode, type WfNodeData, type WfNodeState, type WfNodeStatus, type WfNodeTone, type WfProblem, type WfProblemSeverity, WorkflowGraph as WorkflowGraphLazy, type WorkflowGraphProps, modelBrandFor };
package/dist/workflows.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { r as modelBrandFor, t as ModelBrandStack } from "./model-brand-hMyDTUqB.js";
2
- import { a as buildWorkflowGraph, c as triggerNodeIndex, i as branchNodeId, n as TRIGGER_NODE_ID, r as actionNodeId, s as triggerNodeId } from "./model-I-NIxeM-.js";
2
+ import { A as actionPathLabel, C as branchNodeId, D as triggerNodeIndex, E as triggerNodeId, M as parseActionPath, N as shortModel, S as actionNodeId, a as NodeMark, c as STATUS_COLOR, f as StatusPill, i as KIND_ICON, j as humanizeIdentifier, l as STATUS_LABEL, n as classifyOutput, r as condenseText, t as NodeOutputBody, u as STATUS_PILL, w as buildWorkflowGraph, x as TRIGGER_NODE_ID } from "./node-output-D-fYilAu.js";
3
3
  import { jsx, jsxs } from "react/jsx-runtime";
4
4
  import { Component, Suspense, lazy, useMemo, useState } from "react";
5
5
  //#region src/workflows/WorkflowGraphLazy.tsx
@@ -30,7 +30,7 @@ function retryImport(factory, retries = 2, delayMs = 350) {
30
30
  });
31
31
  }
32
32
  function lazyWorkflowGraph() {
33
- return lazy(() => retryImport(() => import("./WorkflowGraph-E2Gqe8sl.js").then((m) => ({ default: m.WorkflowGraph }))));
33
+ return lazy(() => retryImport(() => import("./WorkflowGraph-B4mkYGe3.js").then((m) => ({ default: m.WorkflowGraph }))));
34
34
  }
35
35
  var GraphErrorBoundary = class extends Component {
36
36
  state = { hasError: false };
@@ -81,4 +81,4 @@ function WorkflowGraph(props) {
81
81
  }, `${props.yaml}::${attempt}`);
82
82
  }
83
83
  //#endregion
84
- export { ModelBrandStack, TRIGGER_NODE_ID, WorkflowGraph as WorkflowGraphLazy, actionNodeId, branchNodeId, buildWorkflowGraph, modelBrandFor, triggerNodeId, triggerNodeIndex };
84
+ export { KIND_ICON, ModelBrandStack, NodeMark, NodeOutputBody, StatusPill as NodeStatusPill, STATUS_COLOR, STATUS_LABEL, STATUS_PILL, TRIGGER_NODE_ID, WorkflowGraph as WorkflowGraphLazy, actionNodeId, actionPathLabel, branchNodeId, buildWorkflowGraph, classifyOutput, condenseText, humanizeIdentifier, modelBrandFor, parseActionPath, shortModel, triggerNodeId, triggerNodeIndex };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tangle-network/sandbox-ui",
3
- "version": "0.122.3",
3
+ "version": "0.123.0",
4
4
  "description": "Unified UI component library for Tangle Sandbox — primitives, chat, dashboard, terminal, editor, and workspace components",
5
5
  "repository": {
6
6
  "type": "git",