Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 17 additions & 8 deletions packages/tui/src/feature-plugins/system/dag-inspector-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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))
Expand Down Expand Up @@ -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}`
}

Expand Down
170 changes: 106 additions & 64 deletions packages/tui/src/feature-plugins/system/dag-inspector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -384,7 +384,7 @@ function DagInspector(props: { api: TuiPluginApi }) {
return (
<box width="100%" height="100%">
<PanelGroup axis="y" width="100%" height="100%">
<Panel border="none" flexShrink={0} padding={0} paddingLeft={1}>
<Panel border="none" flexShrink={0} padding={0} paddingLeft={1} paddingRight={1}>
<text fg={theme().text}>DAG </text>
<text fg={theme().textMuted}>{selectedWorkflowSummary()?.title ?? "workflow inspector"}</text>
<box flexGrow={1} />
Expand Down Expand Up @@ -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)}
>
Expand All @@ -450,9 +452,22 @@ function DagInspector(props: { api: TuiPluginApi }) {
</scrollbox>
</Panel>

{/* 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. */}
<Panel flexGrow={1} minHeight={0} border="none">
<Separator axis="x" start="edge-out" />
<box flexDirection="row" gap={1} paddingLeft={1} flexShrink={0}>
<box
flexDirection="row"
gap={1}
paddingLeft={1}
paddingRight={1}
flexShrink={0}
border={["left"]}
borderColor={theme().border}
>
<text fg={statusColor(selectedWorkflowSummary()?.status ?? "")} flexShrink={0}>
</text>
Expand All @@ -469,73 +484,97 @@ function DagInspector(props: { api: TuiPluginApi }) {
</text>
</box>
<Separator axis="x" start="edge" />
<scrollbox
ref={(element: ScrollBoxRenderable) => (nodeScroll = element)}
flexGrow={1}
minHeight={0}
verticalScrollbarOptions={{ visible: false }}
horizontalScrollbarOptions={{ visible: false }}
>
<For each={layers()}>
{(layer, layerIdx) => (
<>
{/* Wave header: nodes at the same topological depth, NOT a barrier */}
<box flexDirection="row" gap={1} width="100%" paddingLeft={1}>
<text fg={theme().textMuted} wrapMode="none">
wave {layerIdx() + 1} · {layer.length} {layer.length === 1 ? "node" : "nodes"}
</text>
</box>
<For each={layer}>
{(node) => {
const selected = () => selectedNode() === node.id
return (
<box
flexDirection="row"
gap={1}
width="100%"
paddingLeft={2}
backgroundColor={selected() ? theme().primary : undefined}
onMouseUp={() => setSelectedNode(node.id)}
>
<Show
when={node.status !== "running"}
fallback={<Spinner color={selected() ? theme().background : theme().textMuted} />}
{/* Border lives on the wrapper, not the rows, so the left
edge stays continuous when the node list is shorter than
the viewport. */}
<box flexGrow={1} minHeight={0} border={["left"]} borderColor={theme().border}>
<scrollbox
ref={(element: ScrollBoxRenderable) => (nodeScroll = element)}
flexGrow={1}
minHeight={0}
verticalScrollbarOptions={{ visible: false }}
horizontalScrollbarOptions={{ visible: false }}
>
<For each={layers()}>
{(layer, layerIdx) => (
<>
{/* Blank spacer between waves keeps the blocks visually
separate; computeNodeRowIndex counts it for scrolling. */}
{layerIdx() !== 0 ? <box height={1} /> : null}
{/* Wave header: nodes at the same topological depth, NOT a barrier */}
<box flexDirection="row" gap={1} width="100%" paddingLeft={1} paddingRight={1}>
<text fg={theme().accent} wrapMode="none">
wave {layerIdx() + 1}
</text>
<text fg={theme().textMuted} wrapMode="none">
· {layer.length} {layer.length === 1 ? "node" : "nodes"}
</text>
</box>
<For each={layer}>
{(node) => {
const selected = () => selectedNode() === node.id
const settled = () =>
node.status === "completed" ||
node.status === "skipped" ||
node.status === "cancelled" ||
node.status === "aborted"
return (
<box
flexDirection="row"
gap={1}
width="100%"
paddingLeft={2}
paddingRight={1}
backgroundColor={selected() ? theme().primary : undefined}
onMouseUp={() => setSelectedNode(node.id)}
>
<text fg={selected() ? theme().background : statusColor(node.status)} flexShrink={0}>
{dagNodeGlyph(node.status)}
</text>
</Show>
<box flexShrink={1} minWidth={0}>
<Show
when={node.status !== "running"}
fallback={<Spinner color={selected() ? theme().background : theme().textMuted} />}
>
<text
fg={selected() ? theme().background : statusColor(node.status)}
flexShrink={0}
>
{dagNodeGlyph(node.status)}
</text>
</Show>
<box flexShrink={1} minWidth={0}>
<text
fg={
selected() ? theme().background : settled() ? theme().textMuted : theme().text
}
wrapMode="none"
>
{node.name}
</text>
</box>
<text
fg={
selected()
? theme().background
: node.status === "running"
? theme().text
: theme().textMuted
}
fg={selected() ? theme().background : theme().textMuted}
wrapMode="none"
flexShrink={0}
>
{node.name}
{node.worker_type}
</text>
</box>
<text
fg={selected() ? theme().background : theme().textMuted}
wrapMode="none"
flexShrink={0}
>
{node.worker_type}
</text>
</box>
)
}}
</For>
</>
)}
</For>
</scrollbox>
<Separator axis="x" start="edge-in" />
<box flexDirection="column" paddingLeft={1} flexShrink={0} height={NODE_DETAIL_HEIGHT}>
)
}}
</For>
</>
)}
</For>
</scrollbox>
</box>
<Separator axis="x" start="edge" />
<box
flexDirection="column"
paddingLeft={1}
paddingRight={1}
flexShrink={0}
height={NODE_DETAIL_HEIGHT}
border={["left"]}
borderColor={theme().border}
>
<Show when={selectedNodeDetail()}>
{(node) => (
<>
Expand Down Expand Up @@ -583,13 +622,16 @@ function DagInspector(props: { api: TuiPluginApi }) {
)}
</Show>
</box>
{/* Bottom rail: closes the frame flush with the workflow
list's bottom border, mirroring the diff-viewer. */}
<Separator axis="x" start="edge-in" />
</Panel>
</PanelGroup>
</Match>
</Switch>
</box>

<Panel flexShrink={0} gap={2} paddingLeft={1} border="none">
<Panel flexShrink={0} gap={2} paddingLeft={1} paddingRight={1} border="none">
<For each={footerHints()}>
{(hint) => (
<text fg={theme().text}>
Expand Down
30 changes: 10 additions & 20 deletions packages/tui/test/feature-plugins/dag-inspector-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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?")
})
})
Expand All @@ -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", () => {
Expand Down Expand Up @@ -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()
})
})
Expand Down
Loading