diff --git a/packages/tui/src/feature-plugins/system/dag-inspector.tsx b/packages/tui/src/feature-plugins/system/dag-inspector.tsx index 7a97a771d..62ab92f88 100644 --- a/packages/tui/src/feature-plugins/system/dag-inspector.tsx +++ b/packages/tui/src/feature-plugins/system/dag-inspector.tsx @@ -7,7 +7,6 @@ import { useTerminalDimensions } from "@opentui/solid" import { Spinner } from "../../component/spinner" import { useBindings, useCommandShortcut } from "../../keymap" import { selectedForeground, useTheme } from "../../context/theme" -import { SplitBorder } from "../../ui/border" import { computeNodeRowIndex, computeWaves, @@ -28,10 +27,11 @@ import type { DagWorkflowSummary } from "@opencode-ai/sdk/v2" const id = "internal:system-dag-inspector" const ROUTE = "dag" -// Workflow sidebar mirrors the chat sidebar: fixed width, panel background, -// and only present on wide terminals (chat uses the same > 120 threshold). -const SIDEBAR_WIDTH = 42 -const WIDE_TERMINAL_WIDTH = 120 +// The workflow list is primary navigation, not ambient metadata, so it is +// always present. It only narrows on small terminals — never disappears. +const NAV_WIDTH_MAX = 32 +const NAV_WIDTH_MIN = 18 +const NAV_WIDTH_SHARE = 0.3 // Node detail content rows: header, dependencies, error/output preview. The // detail block adds two padding rows on top; fixed so changing the selection // never moves the footer. @@ -54,10 +54,11 @@ function DagInspector(props: { api: TuiPluginApi }) { // so the full theme comes from the context, as in the diff viewer. const themeState = useTheme() const dimensions = useTerminalDimensions() - // Below this width the sidebar would squeeze the wave list into unreadable - // truncation, so it is dropped entirely and the summary block carries the - // workflow position instead. - const wide = createMemo(() => dimensions().width > WIDE_TERMINAL_WIDTH) + // Navigation stays on screen at every width; it yields columns to the wave + // list instead of vanishing, which is what a primary navigation pane owes. + const navWidth = createMemo(() => + Math.max(NAV_WIDTH_MIN, Math.min(NAV_WIDTH_MAX, Math.floor(dimensions().width * NAV_WIDTH_SHARE))), + ) const params = () => ("params" in props.api.route.current ? props.api.route.current.params : undefined) as | { sessionID?: string; returnRoute?: { name: string; params?: Record } } @@ -394,9 +395,71 @@ function DagInspector(props: { api: TuiPluginApi }) { return ( - - {/* Main column — the chat message-area vocabulary: two-column side - padding, rail-and-panel blocks, no frame lines. */} + {/* Title row — identity, subject, scale. Bold reserved for identity. */} + + + DAG + + + + {selectedWorkflowSummary()?.title ?? "workflow inspector"} + + + + {workflows().length} {workflows().length === 1 ? "workflow" : "workflows"} + + + + + {/* Navigation pane — a distinct region marked by the panel surface and + its own padding rhythm rather than a drawn frame. Always present. */} + + (workflowScroll = element)} + flexGrow={1} + minHeight={0} + verticalScrollbarOptions={{ visible: false }} + horizontalScrollbarOptions={{ visible: false }} + > + + {(wf) => { + const selected = () => selectedWorkflow() === wf.id + return ( + setSelectedWorkflow(wf.id)} + > + + • + + + + {wf.title} + + + + {formatDagProgress(wf)} + + + ) + }} + + + + + {/* Content pane — waves and nodes. */} @@ -415,48 +478,21 @@ function DagInspector(props: { api: TuiPluginApi }) { 0}> - {/* Selected workflow summary — the user-message block: status - rail + panel background, mirroring the chat transcript. */} - - - - {selectedWorkflowSummary()?.title ?? ""} - - - - {selectedWorkflowSummary()?.status ?? "unknown"} - - - {" · "} - {nodes().length} {nodes().length === 1 ? "node" : "nodes"} · {layers().length}{" "} - {layers().length === 1 ? "wave" : "waves"} - - - · {actionMessage()} - - {/* Without the sidebar the summary block is the only - place the workflow position can be read. */} - 1}> - - {" · workflow "} - {workflows().findIndex((workflow) => workflow.id === selectedWorkflow()) + 1}/ - {workflows().length} - - - - + {/* Status line — state colour plus muted scale, one row. */} + + + + {selectedWorkflowSummary()?.status ?? "unknown"} + + + {" · "} + {nodes().length} {nodes().length === 1 ? "node" : "nodes"} · {layers().length}{" "} + {layers().length === 1 ? "wave" : "waves"} + + + · {actionMessage()} + + (nodeScroll = element)} @@ -534,141 +570,73 @@ function DagInspector(props: { api: TuiPluginApi }) { )} - {/* Node detail — the block-tool vocabulary: soft inset rail, - panel background, fixed height so selection changes never - move the footer. */} + {/* Node detail — a distinct region on the panel surface, fixed + height so selection changes never move the footer. */} - - - {(node) => ( - <> - - - {node().name} - - - {node().worker_type} - {node().model_id ? ` · ${node().model_id}` : ""} - {formatDagDuration(node().started_at, node().completed_at) - ? ` · ${formatDagDuration(node().started_at, node().completed_at)}` - : ""} - {dagNodeHistoryLabel(node()) ? ` · ${dagNodeHistoryLabel(node())}` : ""} + + {(node) => ( + <> + + + {node().name} + + + {node().worker_type} + {node().model_id ? ` · ${node().model_id}` : ""} + {formatDagDuration(node().started_at, node().completed_at) + ? ` · ${formatDagDuration(node().started_at, node().completed_at)}` + : ""} + {dagNodeHistoryLabel(node()) ? ` · ${dagNodeHistoryLabel(node())}` : ""} + + + {(deadline) => ( + + ·{" "} + + {deadline()} + + + )} + + + 0}> + + depends on {node().depends_on.join(", ")} + + + + + + {formatDagError(node().error_reason!)} - - {(deadline) => ( - - ·{" "} - - {deadline()} - - - )} - - - 0}> + + - depends on {node().depends_on.join(", ")} + {formatDagOutputPreview(node().output)} - - - - - {formatDagError(node().error_reason!)} - - - - - {formatDagOutputPreview(node().output)} - - - - - )} - - + + + + )} + - - {/* Workflow sidebar — the chat sidebar vocabulary: fixed width, panel - background, bold title, dot-status rows. Fixed layout: present - whenever the terminal is wide, empty when there is nothing to list. */} - - - - - DAG - - - {workflows().length} {workflows().length === 1 ? "workflow" : "workflows"} - - - (workflowScroll = element)} - flexGrow={1} - marginTop={1} - verticalScrollbarOptions={{ visible: false }} - horizontalScrollbarOptions={{ visible: false }} - > - - {(wf) => { - const selected = () => selectedWorkflow() === wf.id - return ( - setSelectedWorkflow(wf.id)} - > - - • - - - - {wf.title} - - - - {formatDagProgress(wf)} - - - ) - }} - - - - - {/* Footer hints — the chat footer vocabulary: a full-width bottom row - that the sidebar never squeezes; key in text color, label muted. */} + {/* Footer hints — key in text colour, label muted, full width. */} {(hint) => ( diff --git a/packages/tui/test/feature-plugins/dag-inspector.test.tsx b/packages/tui/test/feature-plugins/dag-inspector.test.tsx index ceb65a5c4..6dab22c9f 100644 --- a/packages/tui/test/feature-plugins/dag-inspector.test.tsx +++ b/packages/tui/test/feature-plugins/dag-inspector.test.tsx @@ -623,33 +623,23 @@ describe("DagInspector", () => { } }) - test("the workflow sidebar only appears on wide terminals", async () => { - const wide = await renderDagInspector({ - width: 130, - workflows: [wfSummary({ id: "wf-1", title: "Evidence pipeline" })], - nodes: [dagNode({ id: "n-1", name: "collect", status: "running" })], - }) - try { - // The sidebar header is the marker: it renders "DAG" plus a workflow count. - await wide.app.waitForFrame((frame) => frame.includes("1 workflow")) - } finally { - wide.app.renderer.destroy() - } - - const narrow = await renderDagInspector({ - width: 100, - workflows: [ - wfSummary({ id: "wf-1", title: "Evidence pipeline" }), - wfSummary({ id: "wf-2", title: "Second workflow" }), - ], - nodes: [dagNode({ id: "n-1", name: "collect", status: "running" })], - }) - try { - // Sidebar dropped; the summary block carries the position instead so the - // left/right workflow cursor stays legible. - await narrow.app.waitForFrame((frame) => frame.includes("workflow 1/2") && !frame.includes("2 workflows")) - } finally { - narrow.app.renderer.destroy() + test("the workflow navigation pane survives every terminal width", async () => { + // Primary navigation must never disappear; it only narrows. A previous + // revision borrowed the chat sidebar's > 120 gate and lost the list here. + for (const width of [130, 100, 70]) { + const viewer = await renderDagInspector({ + width, + workflows: [ + wfSummary({ id: "wf-1", title: "alpha" }), + wfSummary({ id: "wf-2", title: "beta", status: "paused" }), + ], + nodes: [dagNode({ id: "n-1", name: "collect", status: "running" })], + }) + try { + await viewer.app.waitForFrame((frame) => frame.includes("alpha") && frame.includes("beta")) + } finally { + viewer.app.renderer.destroy() + } } }) })