feat(dashboard): one-click auto-layout respecting column bands

This commit is contained in:
gsxdsm
2026-06-04 21:00:52 -07:00
parent 5a499ea212
commit d382d51c75
11 changed files with 527 additions and 3 deletions

View File

@@ -14,7 +14,7 @@ import {
type Edge as FlowEdge,
} from "@xyflow/react";
import { useTranslation } from "react-i18next";
import { X, Plus, Trash2, Save, MessageSquare, Terminal, Shield, GitMerge, Loader2, HelpCircle, PauseCircle, Split, Merge, Repeat, ClipboardCheck, ListChecks, Code2 } from "lucide-react";
import { X, Plus, Trash2, Save, MessageSquare, Terminal, Shield, GitMerge, Loader2, HelpCircle, PauseCircle, Split, Merge, Repeat, ClipboardCheck, ListChecks, Code2, LayoutGrid } from "lucide-react";
import type { WorkflowDefinition, WorkflowIrColumn, TraitViolation } from "@fusion/core";
import { getErrorMessage } from "@fusion/core";
import {
@@ -61,6 +61,7 @@ import {
FOREACH_CHILD_X,
FOREACH_CHILD_Y,
} from "./workflow-flow-mapping";
import { autoLayout, applyAutoLayout } from "./workflow-auto-layout";
import { fetchTraits, fetchStepParsers, type TraitCatalogEntry } from "../api";
import { WorkflowColumnPanel } from "./WorkflowColumnPanel";
import { WorkflowFieldsPanel } from "./WorkflowFieldsPanel";
@@ -542,6 +543,13 @@ function InnerEditor({
[setNodes, t],
);
// Auto-layout: one-click left-to-right tidy (U5, R8). Recomputes positions
// only; bands and foreach template children are left in place. Marks the
// editor dirty automatically via the layout serialization in isDirty.
const handleAutoLayout = useCallback(() => {
setNodes((ns) => applyAutoLayout(ns, autoLayout(ns, edges, columns)));
}, [setNodes, edges, columns]);
const updateSelectedData = useCallback(
(
patch:
@@ -1175,6 +1183,13 @@ function InnerEditor({
))}
</div>
<div className="wf-editor-actions">
<button
className="wf-editor-action"
onClick={handleAutoLayout}
data-testid="wf-auto-layout"
>
<LayoutGrid size={13} /> {t("workflowNodes.autoLayout", "Auto-layout")}
</button>
<button className="wf-editor-delete" onClick={handleDeleteWorkflow}>
<Trash2 size={13} /> {t("common.delete", "Delete")}
</button>

View File

@@ -329,6 +329,47 @@ describe("WorkflowNodeEditor — U3 deletion", () => {
});
});
describe("WorkflowNodeEditor — U5 auto-layout", () => {
beforeEach(() => {
vi.mocked(fetchTraits).mockResolvedValue(TRAIT_CATALOG);
vi.mocked(fetchStepParsers).mockResolvedValue(["step-headings", "json-steps"]);
vi.mocked(fetchModels).mockResolvedValue({ models: [] });
});
afterEach(() => cleanup());
it("shows the Auto-layout button for an editable workflow", async () => {
vi.mocked(fetchWorkflows).mockResolvedValue([v2Def()]);
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
await screen.findByTestId("wf-node-start");
expect(screen.getByTestId("wf-auto-layout")).toBeInTheDocument();
});
it("does not show the Auto-layout button for a built-in workflow", async () => {
vi.mocked(fetchWorkflows).mockResolvedValue([builtinDef()]);
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
await screen.findByTestId("wf-readonly-banner");
expect(screen.queryByTestId("wf-auto-layout")).not.toBeInTheDocument();
});
it("repositions nodes on click (a node's transform changes)", async () => {
vi.mocked(fetchWorkflows).mockResolvedValue([v2Def()]);
const { container } = render(
<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />,
);
await screen.findByTestId("wf-node-start");
// React Flow positions step nodes via a translate transform on their wrapper.
const wrapperFor = (id: string) =>
container.querySelector<HTMLElement>(`.react-flow__node[data-id="${id}"]`);
const before = wrapperFor("step")?.style.transform ?? "";
fireEvent.click(screen.getByTestId("wf-auto-layout"));
await waitFor(() => {
const after = wrapperFor("step")?.style.transform ?? "";
expect(after).not.toBe("");
expect(after).not.toBe(before);
});
});
});
// ── U8: step-inversion authoring (foreach/step-review/parse-steps/code) ──────
/** A custom v2 workflow with a foreach (one step-execute child + a step-review)
@@ -415,7 +456,9 @@ describe("WorkflowNodeEditor — U8 step-inversion authoring", () => {
// Adding a foreach renders a group node with an empty inspector hint absent
// (it has a child) and an inspector for the foreach.
fireEvent.click(screen.getByText("For-each step").closest("button")!);
await waitFor(() => expect(screen.getByTestId("wf-node-foreach")).toBeInTheDocument());
// 3s timeout: React Flow group-node mount can exceed the 1s default under
// cold-transform shard load (observed intermittently in CI-like runs).
await waitFor(() => expect(screen.getByTestId("wf-node-foreach")).toBeInTheDocument(), { timeout: 3000 });
// The foreach inspector shows the Mode select (KTD-3).
expect(screen.getByText("Mode")).toBeInTheDocument();
// No empty-state hint because the palette seeded a step-execute child.
@@ -854,7 +897,11 @@ describe("WorkflowNodeEditor — U4 create dialog / delete / inline rename / dir
it("cancels an inline rename on Escape (value reverts)", async () => {
vi.mocked(fetchWorkflows).mockResolvedValue([v2Def()]);
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
fireEvent.click(await screen.findByTestId("wf-workflow-name"));
// Wait for the editor to fully stabilize (column panel rendered) before
// interacting — clicking mid-load races the initial render cycle.
await screen.findByText("Save");
await waitFor(() => expect(screen.getAllByLabelText(/Column name/i).length).toBeGreaterThan(0));
fireEvent.click(screen.getByTestId("wf-workflow-name"));
const input = (await screen.findByTestId("wf-workflow-name-input")) as HTMLInputElement;
fireEvent.change(input, { target: { value: "Throwaway" } });
fireEvent.keyDown(input, { key: "Escape" });

View File

@@ -0,0 +1,240 @@
import { describe, it, expect } from "vitest";
import type { Node as FlowNode, Edge as FlowEdge } from "@xyflow/react";
import type { WorkflowIrColumn } from "@fusion/core";
import type { WorkflowFlowNodeData } from "../nodes/WorkflowNodeTypes";
import { autoLayout, applyAutoLayout } from "../workflow-auto-layout";
import {
strictColumnForY,
bandTop,
columnBandNodeId,
COLUMN_BAND_HEIGHT,
} from "../workflow-flow-mapping";
type N = FlowNode<WorkflowFlowNodeData>;
function node(
id: string,
kind: WorkflowFlowNodeData["kind"],
x: number,
y: number,
extra: Partial<N> & { column?: string } = {},
): N {
const { column, ...rest } = extra;
return {
id,
type: kind,
position: { x, y },
data: { kind, label: id, ...(column ? { column } : {}) },
...rest,
} as N;
}
function edge(source: string, target: string, kind?: "rework"): FlowEdge {
return {
id: `e-${source}-${target}`,
source,
target,
data: { condition: "success", kind },
};
}
const COLUMNS_3: WorkflowIrColumn[] = [
{ id: "triage", name: "Triage", traits: [] },
{ id: "in-progress", name: "In progress", traits: [] },
{ id: "done", name: "Done", traits: [] },
];
/** Mid-band y for a column index (a stable starting placement). */
function midBand(index: number): number {
return bandTop(index) + COLUMN_BAND_HEIGHT / 2;
}
describe("autoLayout — v2 (column-preserving)", () => {
it("linear chain: strictly increasing x and every node keeps its column", () => {
const nodes: N[] = [
node("start", "start", 999, midBand(0), { column: "triage" }),
node("a", "prompt", 50, midBand(1), { column: "in-progress" }),
node("b", "prompt", 10, midBand(1), { column: "in-progress" }),
node("end", "end", 0, midBand(2), { column: "done" }),
];
const edges = [edge("start", "a"), edge("a", "b"), edge("b", "end")];
const pos = autoLayout(nodes, edges, COLUMNS_3);
const xs = ["start", "a", "b", "end"].map((id) => pos.get(id)!.x);
for (let i = 1; i < xs.length; i++) {
expect(xs[i]).toBeGreaterThan(xs[i - 1]);
}
// Invariant: column unchanged for every node.
for (const n of nodes) {
const original = n.data.column!;
const newY = pos.get(n.id)!.y;
expect(strictColumnForY(newY, COLUMNS_3)).toBe(original);
}
});
it("branching graph: two branch targets get distinct positions", () => {
const nodes: N[] = [
node("start", "start", 0, midBand(0), { column: "triage" }),
node("ok", "prompt", 0, midBand(1), { column: "in-progress" }),
node("fail", "prompt", 0, midBand(1), { column: "in-progress" }),
];
const edges = [edge("start", "ok"), edge("start", "fail")];
const pos = autoLayout(nodes, edges, COLUMNS_3);
const a = pos.get("ok")!;
const b = pos.get("fail")!;
expect(a.x === b.x && a.y === b.y).toBe(false);
// Same layer + same band → stacked vertically.
expect(a.x).toBe(b.x);
expect(a.y).not.toBe(b.y);
expect(strictColumnForY(a.y, COLUMNS_3)).toBe("in-progress");
expect(strictColumnForY(b.y, COLUMNS_3)).toBe("in-progress");
});
it("dense band: more same-layer/same-band nodes than fit 220px stay in-band, staggered x, no collisions", () => {
const count = 12;
const nodes: N[] = [node("start", "start", 0, midBand(0), { column: "triage" })];
const edges: FlowEdge[] = [];
for (let i = 0; i < count; i++) {
nodes.push(node(`n${i}`, "prompt", 0, midBand(1), { column: "in-progress" }));
edges.push(edge("start", `n${i}`));
}
const pos = autoLayout(nodes, edges, COLUMNS_3);
const seen = new Set<string>();
for (let i = 0; i < count; i++) {
const p = pos.get(`n${i}`)!;
// All stay in their band.
expect(strictColumnForY(p.y, COLUMNS_3)).toBe("in-progress");
// No two nodes share a position.
const key = `${p.x},${p.y}`;
expect(seen.has(key)).toBe(false);
seen.add(key);
}
// Overflow forced horizontal staggering (more than one distinct x).
const distinctX = new Set([...seen].map((k) => k.split(",")[0]));
expect(distinctX.size).toBeGreaterThan(1);
});
it("derives column from y when data.column is absent", () => {
const nodes: N[] = [
node("start", "start", 0, midBand(0)),
node("a", "prompt", 0, midBand(2)),
];
const pos = autoLayout(nodes, [edge("start", "a")], COLUMNS_3);
expect(strictColumnForY(pos.get("start")!.y, COLUMNS_3)).toBe("triage");
expect(strictColumnForY(pos.get("a")!.y, COLUMNS_3)).toBe("done");
});
});
describe("autoLayout — v1 (free placement)", () => {
it("produces layered positions, no NaN, deterministic across two calls", () => {
const nodes: N[] = [
node("start", "start", 0, 0),
node("a", "prompt", 0, 0),
node("b", "prompt", 0, 0),
node("end", "end", 0, 0),
];
const edges = [edge("start", "a"), edge("start", "b"), edge("a", "end"), edge("b", "end")];
const p1 = autoLayout(nodes, edges, []);
const p2 = autoLayout(nodes, edges, []);
for (const n of nodes) {
const p = p1.get(n.id)!;
expect(Number.isFinite(p.x)).toBe(true);
expect(Number.isFinite(p.y)).toBe(true);
}
// start before branches before end.
expect(p1.get("start")!.x).toBeLessThan(p1.get("a")!.x);
expect(p1.get("a")!.x).toBeLessThan(p1.get("end")!.x);
// Deterministic.
for (const n of nodes) {
expect(p1.get(n.id)).toEqual(p2.get(n.id));
}
});
});
describe("autoLayout — foreach / unreachable / cycles", () => {
it("repositions a foreach group but leaves its parentId children untouched", () => {
const childPos = { x: 30, y: 56 };
const nodes: N[] = [
node("start", "start", 0, midBand(0), { column: "triage" }),
node("grp", "foreach", 999, midBand(1), { column: "in-progress" }),
{
id: "grp::c1",
type: "prompt",
position: { ...childPos },
parentId: "grp",
extent: "parent",
data: { kind: "prompt", label: "c1" },
} as N,
];
const edges = [edge("start", "grp")];
const pos = autoLayout(nodes, edges, COLUMNS_3);
// Group moved.
expect(pos.has("grp")).toBe(true);
expect(pos.get("grp")!.x).not.toBe(999);
// Child not in the position map → untouched.
expect(pos.has("grp::c1")).toBe(false);
const applied = applyAutoLayout(nodes, pos);
const child = applied.find((n) => n.id === "grp::c1")!;
expect(child.position).toEqual(childPos);
});
it("gives an unreachable node a finite position in a trailing layer", () => {
const nodes: N[] = [
node("start", "start", 0, midBand(0), { column: "triage" }),
node("a", "prompt", 0, midBand(1), { column: "in-progress" }),
node("orphan", "prompt", 0, midBand(2), { column: "done" }),
];
const edges = [edge("start", "a")];
const pos = autoLayout(nodes, edges, COLUMNS_3);
const o = pos.get("orphan")!;
expect(Number.isFinite(o.x)).toBe(true);
expect(Number.isFinite(o.y)).toBe(true);
// Trailing layer is past the reachable nodes.
expect(o.x).toBeGreaterThan(pos.get("a")!.x);
expect(strictColumnForY(o.y, COLUMNS_3)).toBe("done");
});
it("terminates and stays sane with a rework cycle edge present", () => {
const nodes: N[] = [
node("start", "start", 0, midBand(0), { column: "triage" }),
node("a", "prompt", 0, midBand(1), { column: "in-progress" }),
node("b", "prompt", 0, midBand(1), { column: "in-progress" }),
node("end", "end", 0, midBand(2), { column: "done" }),
];
const edges = [
edge("start", "a"),
edge("a", "b"),
edge("b", "end"),
edge("b", "a", "rework"), // rework loop — ignored for layering
];
const pos = autoLayout(nodes, edges, COLUMNS_3);
// Layering ignores rework: a strictly before b.
expect(pos.get("a")!.x).toBeLessThan(pos.get("b")!.x);
for (const n of nodes) {
expect(strictColumnForY(pos.get(n.id)!.y, COLUMNS_3)).toBe(n.data.column);
}
});
it("ignores column band group nodes", () => {
const nodes: N[] = [
{
id: columnBandNodeId("triage"),
type: "group",
position: { x: -40, y: bandTop(0) },
data: { kind: "start", label: "Triage" },
} as N,
node("start", "start", 0, midBand(0), { column: "triage" }),
];
const pos = autoLayout(nodes, [], COLUMNS_3);
expect(pos.has(columnBandNodeId("triage"))).toBe(false);
expect(pos.has("start")).toBe(true);
});
});

View File

@@ -0,0 +1,215 @@
import type { Node as FlowNode, Edge as FlowEdge } from "@xyflow/react";
import type { WorkflowIrColumn } from "@fusion/core";
import type { WorkflowFlowNodeData } from "./nodes/WorkflowNodeTypes";
import {
WF_CARD_MAX_WIDTH,
WF_CARD_HEIGHT,
COLUMN_BAND_HEIGHT,
bandTop,
columnForY,
isColumnBandNode,
} from "./workflow-flow-mapping";
// ── One-click auto-layout (U5, R8) ───────────────────────────────────────────
//
// Pure left-to-right "tidy" that NEVER re-columns or unplaces a node. Layering
// derives x from graph topology (longest-path from the start node, ignoring
// rework edges and tolerating cycles); y is constrained per-node to the band of
// the column the node already belongs to (v2) or assigned by within-layer index
// (v1). When same-layer/same-band nodes exceed a band's vertical capacity,
// overflow staggers horizontally (extra x offset) rather than escaping the band
// — preserving the test-enforced invariant strictColumnForY(newY) === original
// column for every node. Foreach group nodes move as units; their parentId
// template children are positioned parent-relative and are left untouched.
/** Horizontal gap between layer columns (added to the card max-width to derive
* the per-layer x spacing). Exported so U5's tests and any future tuning share
* the single source of truth rather than duplicating the number. */
export const WF_AUTO_LAYOUT_GAP_X = 80;
/** Vertical gap between stacked same-layer/same-band cards. */
export const WF_AUTO_LAYOUT_GAP_Y = 24;
/** Padding inside a band before the first card / after the last card row. */
export const WF_AUTO_LAYOUT_BAND_PADDING = 16;
/** Per-layer horizontal spacing: a full card-width plus the gap. */
export const WF_AUTO_LAYOUT_SPACING = WF_CARD_MAX_WIDTH + WF_AUTO_LAYOUT_GAP_X;
/** Left/top origin for the laid-out graph. */
const ORIGIN_X = 40;
const ORIGIN_Y = 40;
/** Row height for a stacked card (card + vertical gap). */
const ROW_HEIGHT = WF_CARD_HEIGHT + WF_AUTO_LAYOUT_GAP_Y;
type LayoutNode = FlowNode<WorkflowFlowNodeData>;
/** A node is layoutable by auto-layout when it is a top-level step node: not a
* column band group and not a foreach template child (parentId set). Foreach
* GROUP nodes ARE layoutable (they move as a unit). */
function isLayoutable(node: LayoutNode): boolean {
if (isColumnBandNode(node.id)) return false;
if (node.parentId) return false;
return true;
}
/**
* Assign each layoutable node a layer index via longest-path layering from the
* start node. Rework edges (data.kind === "rework") are ignored for layering.
* Cycle-safe: a per-node depth cap plus a visited guard bounds the relaxation so
* non-rework cycles (should not occur, but be defensive) cannot loop forever.
* Nodes unreachable from start land in a trailing layer (max + 1).
*/
function layerNodes(nodeIds: string[], edges: FlowEdge[]): Map<string, number> {
const idSet = new Set(nodeIds);
// Adjacency over non-rework edges whose endpoints are both layoutable.
const adj = new Map<string, string[]>();
const indegree = new Map<string, number>();
for (const id of nodeIds) indegree.set(id, 0);
for (const e of edges) {
if ((e.data?.kind as string | undefined) === "rework") continue;
if (!idSet.has(e.source) || !idSet.has(e.target)) continue;
if (e.source === e.target) continue;
(adj.get(e.source) ?? adj.set(e.source, []).get(e.source)!).push(e.target);
indegree.set(e.target, (indegree.get(e.target) ?? 0) + 1);
}
// Roots: explicit start node(s), plus any node with no incoming layering edge
// (so isolated graphs without a "start" still get laid out).
const startIds = nodeIds.filter((id) => id === "start");
const roots = startIds.length
? startIds
: nodeIds.filter((id) => (indegree.get(id) ?? 0) === 0);
const layer = new Map<string, number>();
// BFS longest-path relaxation. The cap bounds work in the presence of any
// accidental non-rework cycle: a node can be relaxed at most nodeIds.length
// times before its layer would exceed the maximum possible acyclic depth.
const cap = nodeIds.length + 1;
const queue: string[] = [];
for (const r of roots) {
layer.set(r, 0);
queue.push(r);
}
let guard = nodeIds.length * nodeIds.length + nodeIds.length + 1;
while (queue.length && guard-- > 0) {
const cur = queue.shift()!;
const curLayer = layer.get(cur) ?? 0;
for (const next of adj.get(cur) ?? []) {
const candidate = curLayer + 1;
const existing = layer.get(next);
if ((existing === undefined || candidate > existing) && candidate <= cap) {
layer.set(next, candidate);
queue.push(next);
}
}
}
// Unreachable nodes → a trailing layer after the deepest reached layer.
let maxLayer = 0;
for (const v of layer.values()) if (v > maxLayer) maxLayer = v;
const trailing = layer.size ? maxLayer + 1 : 0;
for (const id of nodeIds) {
if (!layer.has(id)) layer.set(id, trailing);
}
return layer;
}
/** Result: nodeId → new absolute position. Only positions change. */
export type AutoLayoutPositions = Map<string, { x: number; y: number }>;
/**
* Compute new positions for the layoutable nodes. Returns a Map keyed by node
* id; nodes not in the map (band groups, foreach children) keep their current
* positions. The caller applies the map via setNodes (positions only).
*
* @param nodes current flow nodes (bands + steps + foreach children)
* @param edges current flow edges
* @param columns the authored v2 columns (empty array ⇒ v1 free placement)
*/
export function autoLayout(
nodes: LayoutNode[],
edges: FlowEdge[],
columns: WorkflowIrColumn[],
): AutoLayoutPositions {
const layoutables = nodes.filter(isLayoutable);
const ids = layoutables.map((n) => n.id);
const layer = layerNodes(ids, edges);
// Stable sort within a layer: current y, then id. Deterministic across calls.
const byId = new Map(layoutables.map((n) => [n.id, n]));
const positions: AutoLayoutPositions = new Map();
// Group node ids by layer.
const layers = new Map<number, string[]>();
for (const id of ids) {
const l = layer.get(id) ?? 0;
(layers.get(l) ?? layers.set(l, []).get(l)!).push(id);
}
const v2 = columns.length > 0;
for (const [layerIndex, layerIds] of layers) {
const sorted = [...layerIds].sort((a, b) => {
const na = byId.get(a)!;
const nb = byId.get(b)!;
if (na.position.y !== nb.position.y) return na.position.y - nb.position.y;
return a < b ? -1 : a > b ? 1 : 0;
});
const layerX = ORIGIN_X + layerIndex * WF_AUTO_LAYOUT_SPACING;
if (!v2) {
// v1: free placement — within-layer index × row height.
sorted.forEach((id, withinIdx) => {
positions.set(id, { x: layerX, y: ORIGIN_Y + withinIdx * ROW_HEIGHT });
});
continue;
}
// v2: each node KEEPS its column. Track the next free row per column so
// same-layer/same-band nodes stack downward; overflow staggers x.
// Per (column, stagger-bucket) we track how many rows are filled.
const rowsPerColumn = new Map<string, number>();
for (const id of sorted) {
const node = byId.get(id)!;
const colId = node.data.column ?? columnForY(node.position.y, columns);
const colIndex = colId ? columns.findIndex((c) => c.id === colId) : -1;
const safeColIndex = colIndex >= 0 ? colIndex : 0;
const top = bandTop(safeColIndex);
const firstY = top + WF_AUTO_LAYOUT_BAND_PADDING;
// Last y at which a card still fits fully inside the band.
const maxY = top + COLUMN_BAND_HEIGHT - WF_CARD_HEIGHT - WF_AUTO_LAYOUT_BAND_PADDING;
const capacity = Math.max(1, Math.floor((maxY - firstY) / ROW_HEIGHT) + 1);
const used = rowsPerColumn.get(colId ?? `__idx${safeColIndex}`) ?? 0;
const rowInBucket = used % capacity;
const staggerBucket = Math.floor(used / capacity);
const y = firstY + rowInBucket * ROW_HEIGHT;
// Stagger x by half-spacing per overflow bucket so wrapped rows don't
// collide with the un-staggered column while staying in the same band.
const x = layerX + staggerBucket * (WF_AUTO_LAYOUT_SPACING / 2);
positions.set(id, { x, y });
rowsPerColumn.set(colId ?? `__idx${safeColIndex}`, used + 1);
}
}
return positions;
}
/** Apply auto-layout positions to a node list, returning a new array. Only
* positions of mapped nodes change; everything else is preserved by reference
* shape. Convenience for setNodes in the editor. */
export function applyAutoLayout(
nodes: LayoutNode[],
positions: AutoLayoutPositions,
): LayoutNode[] {
return nodes.map((n) => {
const pos = positions.get(n.id);
return pos ? { ...n, position: pos } : n;
});
}

View File

@@ -6757,6 +6757,7 @@
},
"workflowNodes": {
"advisory": "Advisory",
"autoLayout": "Auto-layout",
"codeNote": "Runs sandboxed TypeScript. Syntax is validated at save.",
"codeSource": "Source (TypeScript)",
"codeTimeout": "Timeout (ms)",

View File

@@ -6757,6 +6757,7 @@
},
"workflowNodes": {
"advisory": "",
"autoLayout": "",
"codeNote": "Ejecuta TypeScript en un entorno aislado. La sintaxis se valida al guardar.",
"codeSource": "Origen (TypeScript)",
"codeTimeout": "Tiempo de espera (ms)",

View File

@@ -6757,6 +6757,7 @@
},
"workflowNodes": {
"advisory": "",
"autoLayout": "",
"codeNote": "Exécute du TypeScript en bac à sable. La syntaxe est validée à l’enregistrement.",
"codeSource": "Source (TypeScript)",
"codeTimeout": "Délai d’expiration (ms)",

View File

@@ -6757,6 +6757,7 @@
},
"workflowNodes": {
"advisory": "",
"autoLayout": "",
"codeNote": "샌드박스에서 TypeScript를 실행합니다. 구문은 저장 시 검증됩니다.",
"codeSource": "소스(TypeScript)",
"codeTimeout": "제한 시간(ms)",

View File

@@ -6757,6 +6757,7 @@
},
"workflowNodes": {
"advisory": "",
"autoLayout": "",
"codeNote": "在沙箱中运行 TypeScript。语法在保存时校验。",
"codeSource": "源代码(TypeScript)",
"codeTimeout": "超时(毫秒)",

View File

@@ -6757,6 +6757,7 @@
},
"workflowNodes": {
"advisory": "",
"autoLayout": "",
"codeNote": "在沙箱中執行 TypeScript。語法會在儲存時驗證。",
"codeSource": "原始碼(TypeScript)",
"codeTimeout": "逾時(毫秒)",

View File

@@ -6759,6 +6759,7 @@ export default interface Resources {
},
"workflowNodes": {
"advisory": "Advisory",
"autoLayout": "Auto-layout",
"codeNote": "Runs sandboxed TypeScript. Syntax is validated at save.",
"codeSource": "Source (TypeScript)",
"codeTimeout": "Timeout (ms)",