From 5c883b1e917f0af079bbb35b227d5c5f08e696e7 Mon Sep 17 00:00:00 2001 From: lex Date: Wed, 29 Jul 2026 17:16:08 +0800 Subject: [PATCH] fix(tui): dag inspector frame alignment, padding, and wave grouping --- .../system/dag-inspector-utils.ts | 25 ++- .../feature-plugins/system/dag-inspector.tsx | 170 +++++++++++------- .../dag-inspector-utils.test.ts | 30 ++-- 3 files changed, 133 insertions(+), 92 deletions(-) diff --git a/packages/tui/src/feature-plugins/system/dag-inspector-utils.ts b/packages/tui/src/feature-plugins/system/dag-inspector-utils.ts index d6d49fcf7..fb1e37102 100644 --- a/packages/tui/src/feature-plugins/system/dag-inspector-utils.ts +++ b/packages/tui/src/feature-plugins/system/dag-inspector-utils.ts @@ -44,12 +44,14 @@ export function computeWaves(nodes: readonly DagNode[]): DagNode[][] { /** * Visual row index of a node inside the rendered wave list, counting one row - * per wave header and one row per node. Used to scroll the selected node into - * view — valid only while every node renders as a single row. + * per wave header, one row per node, and one blank spacer row between waves. + * Used to scroll the selected node into view — valid only while every node + * renders as a single row. */ export function computeNodeRowIndex(layers: readonly (readonly DagNode[])[], nodeID: string): number | undefined { let row = 0 - for (const layer of layers) { + for (const [index, layer] of layers.entries()) { + if (index > 0) row++ // spacer between waves row++ // wave header for (const node of layer) { if (node.id === nodeID) return row @@ -60,15 +62,16 @@ export function computeNodeRowIndex(layers: readonly (readonly DagNode[])[], nod } export function formatDagError(error: string) { - return error - .replace(/^Cause\(\[Die\((.*)\)\]\)$/, "$1") - .replace(/^ProviderModelNotFoundError:\s*/, "") + return error.replace(/^Cause\(\[Die\((.*)\)\]\)$/, "$1").replace(/^ProviderModelNotFoundError:\s*/, "") } /** Compact "3m 12s" duration between two epoch-millis timestamps. The SDK * serializes numbers with Infinity/NaN sentinels — non-finite inputs yield * no duration. */ -export function formatDagDuration(startedAt: number | string | undefined, completedAt: number | string | undefined): string | undefined { +export function formatDagDuration( + startedAt: number | string | undefined, + completedAt: number | string | undefined, +): string | undefined { if (typeof startedAt !== "number" || !Number.isFinite(startedAt)) return undefined const end = typeof completedAt === "number" && Number.isFinite(completedAt) ? completedAt : Date.now() const totalSeconds = Math.max(0, Math.round((end - startedAt) / 1000)) @@ -170,7 +173,13 @@ export function dagControlAllowed(status: string | undefined, operation: DagCont export function dagControlUnavailableMessage(status: string | undefined, operation: DagControlOperation) { if (dagControlAllowed(status, operation)) return undefined const action = - operation === "pause" ? "paused" : operation === "resume" ? "resumed" : operation === "step" ? "stepped" : "cancelled" + operation === "pause" + ? "paused" + : operation === "resume" + ? "resumed" + : operation === "step" + ? "stepped" + : "cancelled" return `Workflow is ${status ?? "unavailable"} and cannot be ${action}` } diff --git a/packages/tui/src/feature-plugins/system/dag-inspector.tsx b/packages/tui/src/feature-plugins/system/dag-inspector.tsx index 91ae320a2..9f608061c 100644 --- a/packages/tui/src/feature-plugins/system/dag-inspector.tsx +++ b/packages/tui/src/feature-plugins/system/dag-inspector.tsx @@ -384,7 +384,7 @@ function DagInspector(props: { api: TuiPluginApi }) { return ( - + DAG {selectedWorkflowSummary()?.title ?? "workflow inspector"} @@ -429,6 +429,8 @@ function DagInspector(props: { api: TuiPluginApi }) { flexDirection="row" gap={1} width="100%" + paddingLeft={1} + paddingRight={1} backgroundColor={selected() ? theme().primary : undefined} onMouseUp={() => setSelectedWorkflow(wf.id)} > @@ -450,9 +452,22 @@ function DagInspector(props: { api: TuiPluginApi }) { + {/* The right pane draws its own left border on every content + block; the horizontal separators' ┬/├/┴ edge glyphs land in + the same column, forming one continuous frame around both + panes — the same construction as the diff-viewer's patch + pane next to its file tree. */} - + @@ -469,73 +484,97 @@ function DagInspector(props: { api: TuiPluginApi }) { - (nodeScroll = element)} - flexGrow={1} - minHeight={0} - verticalScrollbarOptions={{ visible: false }} - horizontalScrollbarOptions={{ visible: false }} - > - - {(layer, layerIdx) => ( - <> - {/* Wave header: nodes at the same topological depth, NOT a barrier */} - - - wave {layerIdx() + 1} · {layer.length} {layer.length === 1 ? "node" : "nodes"} - - - - {(node) => { - const selected = () => selectedNode() === node.id - return ( - setSelectedNode(node.id)} - > - } + {/* Border lives on the wrapper, not the rows, so the left + edge stays continuous when the node list is shorter than + the viewport. */} + + (nodeScroll = element)} + flexGrow={1} + minHeight={0} + verticalScrollbarOptions={{ visible: false }} + horizontalScrollbarOptions={{ visible: false }} + > + + {(layer, layerIdx) => ( + <> + {/* Blank spacer between waves keeps the blocks visually + separate; computeNodeRowIndex counts it for scrolling. */} + {layerIdx() !== 0 ? : null} + {/* Wave header: nodes at the same topological depth, NOT a barrier */} + + + wave {layerIdx() + 1} + + + · {layer.length} {layer.length === 1 ? "node" : "nodes"} + + + + {(node) => { + const selected = () => selectedNode() === node.id + const settled = () => + node.status === "completed" || + node.status === "skipped" || + node.status === "cancelled" || + node.status === "aborted" + return ( + setSelectedNode(node.id)} > - - {dagNodeGlyph(node.status)} - - - + } + > + + {dagNodeGlyph(node.status)} + + + + + {node.name} + + - {node.name} + {node.worker_type} - - {node.worker_type} - - - ) - }} - - - )} - - - - + ) + }} + + + )} + + + + + {(node) => ( <> @@ -583,13 +622,16 @@ function DagInspector(props: { api: TuiPluginApi }) { )} + {/* Bottom rail: closes the frame flush with the workflow + list's bottom border, mirroring the diff-viewer. */} + - + {(hint) => ( diff --git a/packages/tui/test/feature-plugins/dag-inspector-utils.test.ts b/packages/tui/test/feature-plugins/dag-inspector-utils.test.ts index 98883e4b6..782befae9 100644 --- a/packages/tui/test/feature-plugins/dag-inspector-utils.test.ts +++ b/packages/tui/test/feature-plugins/dag-inspector-utils.test.ts @@ -67,9 +67,7 @@ describe("computeWaves", () => { describe("formatDagError", () => { test("removes Effect and provider error wrappers without hiding the useful message", () => { expect( - formatDagError( - "Cause([Die(ProviderModelNotFoundError: Model not found: local/local/glm. Did you mean: glm?)])", - ), + formatDagError("Cause([Die(ProviderModelNotFoundError: Model not found: local/local/glm. Did you mean: glm?)])"), ).toBe("Model not found: local/local/glm. Did you mean: glm?") }) }) @@ -79,18 +77,10 @@ describe("DAG control state", () => { expect(dagControlUnavailableMessage("running", "pause")).toBeUndefined() expect(dagControlUnavailableMessage("stepping", "pause")).toBeUndefined() expect(dagControlUnavailableMessage("paused", "resume")).toBeUndefined() - expect(dagControlUnavailableMessage("completed", "pause")).toBe( - "Workflow is completed and cannot be paused", - ) - expect(dagControlUnavailableMessage("cancelled", "cancel")).toBe( - "Workflow is cancelled and cannot be cancelled", - ) - expect(dagControlUnavailableMessage("pending", "cancel")).toBe( - "Workflow is pending and cannot be cancelled", - ) - expect(dagControlUnavailableMessage("archived", "cancel")).toBe( - "Workflow is archived and cannot be cancelled", - ) + expect(dagControlUnavailableMessage("completed", "pause")).toBe("Workflow is completed and cannot be paused") + expect(dagControlUnavailableMessage("cancelled", "cancel")).toBe("Workflow is cancelled and cannot be cancelled") + expect(dagControlUnavailableMessage("pending", "cancel")).toBe("Workflow is pending and cannot be cancelled") + expect(dagControlUnavailableMessage("archived", "cancel")).toBe("Workflow is archived and cannot be cancelled") }) test("formats progress without component-level branching", () => { @@ -118,13 +108,13 @@ describe("DAG control state", () => { }) describe("computeNodeRowIndex", () => { - test("counts one row per wave header plus one row per node", () => { + test("counts wave headers, nodes, and inter-wave spacer rows", () => { const layers = computeWaves([node("a"), node("b", ["a"]), node("c", ["a"]), node("d", ["b", "c"])]) - // rows: 0 wave1 header, 1 a, 2 wave2 header, 3 b, 4 c, 5 wave3 header, 6 d + // rows: 0 wave1 header, 1 a, 2 spacer, 3 wave2 header, 4 b, 5 c, 6 spacer, 7 wave3 header, 8 d expect(computeNodeRowIndex(layers, "a")).toBe(1) - expect(computeNodeRowIndex(layers, "b")).toBe(3) - expect(computeNodeRowIndex(layers, "c")).toBe(4) - expect(computeNodeRowIndex(layers, "d")).toBe(6) + expect(computeNodeRowIndex(layers, "b")).toBe(4) + expect(computeNodeRowIndex(layers, "c")).toBe(5) + expect(computeNodeRowIndex(layers, "d")).toBe(8) expect(computeNodeRowIndex(layers, "missing")).toBeUndefined() }) })