@tangle-network/sandbox-ui 0.122.3 → 0.123.1

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.