From ff91ea1fcc7d11f3ad095ea6fe29f1b34564ea01 Mon Sep 17 00:00:00 2001 From: lex Date: Wed, 29 Jul 2026 22:13:33 +0800 Subject: [PATCH] fix(tui): rebuild dag inspector on the chat design language Replaces the diff-viewer frame vocabulary with the chat transcript's: rail and panel blocks instead of box-drawing lines, two-column side padding, and a fixed 42-wide sidebar gated on the same > 120 terminal width chat uses. - Responsive sidebar: below 120 columns the sidebar squeezed node names into 4-character truncation, so it is dropped and the summary block carries the workflow position instead. - Selection now uses primary + selectedForeground() for the node cursor and backgroundElement for the secondary workflow selection, matching the select dialog's focused/unfocused semantics. backgroundElement alone meant "selected but unfocused" and read as an 8% luminance nudge. - Cursor keys only: down/up move nodes, left/right switch workflows, return opens, escape closes. j/k/h/l/tab/q are gone and pause/resume/step/cancel are unbound by default, reachable through the command palette instead, so the footer stays a single uncrowded row. - Tightens the lint ratchet to 4690: a runCommand test helper collapses ten duplicated command-context assertions into one. --- package.json | 2 +- packages/tui/src/config/keybind.ts | 20 +- .../feature-plugins/system/dag-inspector.tsx | 475 ++++++++++-------- .../feature-plugins/dag-inspector.test.tsx | 105 +++- 4 files changed, 355 insertions(+), 247 deletions(-) diff --git a/package.json b/package.json index 23bc37274..f95bf3b23 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev", "dev:stats": "bun sst shell --stage=production -- bun run --cwd packages/stats/app dev", "dev:storybook": "bun --cwd packages/storybook storybook", - "lint": "oxlint --max-warnings=4704", + "lint": "oxlint --max-warnings=4690", "typecheck": "bun turbo typecheck", "upgrade-opentui": "bun run script/upgrade-opentui.ts", "postinstall": "bun run --cwd packages/core fix-node-pty", diff --git a/packages/tui/src/config/keybind.ts b/packages/tui/src/config/keybind.ts index 41cff0a5c..c5384fd95 100644 --- a/packages/tui/src/config/keybind.ts +++ b/packages/tui/src/config/keybind.ts @@ -75,16 +75,18 @@ export const Definitions = { diff_help: keybind("?", "Show more diff viewer shortcuts"), dag_open: keybind("none", "Open DAG inspector"), - dag_close: keybind("escape,q", "Close DAG inspector"), + dag_close: keybind("escape", "Close DAG inspector"), dag_enter: keybind("return", "Enter selected DAG node's session"), - dag_down: keybind("j,down", "Select next DAG node"), - dag_up: keybind("k,up", "Select previous DAG node"), - dag_next_workflow: keybind("l,right,tab", "Select next DAG workflow"), - dag_previous_workflow: keybind("h,left", "Select previous DAG workflow"), - dag_pause: keybind("p", "Pause selected DAG workflow"), - dag_resume: keybind("r", "Resume selected DAG workflow"), - dag_step: keybind("s", "Step selected DAG workflow (run one node)"), - dag_cancel: keybind("x", "Cancel selected DAG workflow"), + dag_down: keybind("down", "Select next DAG node"), + dag_up: keybind("up", "Select previous DAG node"), + dag_next_workflow: keybind("right", "Select next DAG workflow"), + dag_previous_workflow: keybind("left", "Select previous DAG workflow"), + // Control operations stay unbound by default and are reached through the + // command palette; the inspector advertises only cursor, enter and escape. + dag_pause: keybind("none", "Pause selected DAG workflow"), + dag_resume: keybind("none", "Resume selected DAG workflow"), + dag_step: keybind("none", "Step selected DAG workflow (run one node)"), + dag_cancel: keybind("none", "Cancel selected DAG workflow"), editor_open: keybind("e", "Open external editor"), theme_list: keybind("t", "List available themes"), diff --git a/packages/tui/src/feature-plugins/system/dag-inspector.tsx b/packages/tui/src/feature-plugins/system/dag-inspector.tsx index 9f608061c..7a97a771d 100644 --- a/packages/tui/src/feature-plugins/system/dag-inspector.tsx +++ b/packages/tui/src/feature-plugins/system/dag-inspector.tsx @@ -3,13 +3,14 @@ import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui" import type { BuiltinTuiPlugin } from "../builtins" import type { ScrollBoxRenderable } from "@opentui/core" import { createMemo, For, Show, Switch, Match, createSignal, createEffect, onCleanup } from "solid-js" +import { useTerminalDimensions } from "@opentui/solid" import { Spinner } from "../../component/spinner" import { useBindings, useCommandShortcut } from "../../keymap" -import { Panel, PanelGroup, Separator } from "./diff-viewer-ui" +import { selectedForeground, useTheme } from "../../context/theme" +import { SplitBorder } from "../../ui/border" import { computeNodeRowIndex, computeWaves, - dagControlAllowed, dagControlProgressMessage, dagControlUnavailableMessage, dagNodeGlyph, @@ -27,9 +28,13 @@ import type { DagWorkflowSummary } from "@opencode-ai/sdk/v2" const id = "internal:system-dag-inspector" const ROUTE = "dag" -const WORKFLOW_LIST_WIDTH = 32 -// Node detail rows below the wave list: header, dependencies, error/output -// preview. Fixed so changing the selection never moves the footer. +// 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 +// 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. const NODE_DETAIL_HEIGHT = 3 function scrollRowIntoView(scroll: ScrollBoxRenderable | undefined, index: number) { @@ -45,6 +50,14 @@ function scrollRowIntoView(scroll: ScrollBoxRenderable | undefined, index: numbe function DagInspector(props: { api: TuiPluginApi }) { const theme = () => props.api.theme.current + // The plugin-facing theme omits the resolver flags selectedForeground needs, + // 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) const params = () => ("params" in props.api.route.current ? props.api.route.current.params : undefined) as | { sessionID?: string; returnRoute?: { name: string; params?: Record } } @@ -111,7 +124,7 @@ function DagInspector(props: { api: TuiPluginApi }) { const res = await props.api.client.dag.nodes({ dagID }) // Discard if the user selected a different workflow while this fetch was in flight. if (selectedWorkflow() !== dagID) return - setNodes((res.data ?? []) as DagNode[]) + setNodes(res.data ?? []) } catch { if (selectedWorkflow() !== dagID) return setNodes([]) @@ -306,6 +319,7 @@ function DagInspector(props: { api: TuiPluginApi }) { name: "dag.pause", title: "Pause selected workflow", category: "Workflow", + namespace: "palette", run() { control("pause") }, @@ -314,6 +328,7 @@ function DagInspector(props: { api: TuiPluginApi }) { name: "dag.resume", title: "Resume selected workflow", category: "Workflow", + namespace: "palette", run() { control("resume") }, @@ -322,6 +337,7 @@ function DagInspector(props: { api: TuiPluginApi }) { name: "dag.step", title: "Step selected workflow (run one node)", category: "Workflow", + namespace: "palette", run() { control("step") }, @@ -330,6 +346,7 @@ function DagInspector(props: { api: TuiPluginApi }) { name: "dag.cancel", title: "Cancel selected workflow", category: "Workflow", + namespace: "palette", run() { control("cancel") }, @@ -346,28 +363,19 @@ function DagInspector(props: { api: TuiPluginApi }) { const closeShortcut = useCommandShortcut("dag.close") const enterShortcut = useCommandShortcut("dag.enter") - const pauseShortcut = useCommandShortcut("dag.pause") - const resumeShortcut = useCommandShortcut("dag.resume") - const stepShortcut = useCommandShortcut("dag.step") - const cancelShortcut = useCommandShortcut("dag.cancel") const selectedWorkflowSummary = createMemo(() => workflows().find((workflow) => workflow.id === selectedWorkflow())) const selectedNodeDetail = createMemo(() => orderedNodes().find((node) => node.id === selectedNode())) - // Footer hints mirror the diff-viewer's `key label` vocabulary. Control - // hints are contextual: only operations valid for the selected workflow's - // status appear, so pause/resume never advertise a guaranteed no-op. - const footerHints = createMemo(() => { - const status = selectedWorkflowSummary()?.status - return [ - { key: enterShortcut(), label: "open session", show: true }, - { key: pauseShortcut(), label: "pause", show: dagControlAllowed(status, "pause") }, - { key: resumeShortcut(), label: "resume", show: dagControlAllowed(status, "resume") }, - { key: stepShortcut(), label: "step", show: dagControlAllowed(status, "step") }, - { key: cancelShortcut(), label: "cancel", show: dagControlAllowed(status, "cancel") }, - { key: closeShortcut(), label: "close", show: true }, - ].filter((hint) => hint.show && hint.key !== "") - }) + // Footer advertises only the cursor-level affordances. Control operations + // (pause/resume/step/cancel) are unbound by default and reached through the + // command palette, so they never crowd this row. + const footerHints = createMemo(() => + [ + { key: enterShortcut(), label: "open session" }, + { key: closeShortcut(), label: "close" }, + ].filter((hint) => hint.key !== ""), + ) // 1s tick driving the running-node deadline countdown. Only active while the // selected node is actually counting down — idle inspectors don't re-render. @@ -380,200 +388,171 @@ function DagInspector(props: { api: TuiPluginApi }) { }) const statusColor = (status: string) => dagStatusColor(theme(), status) + // Readable foreground against the primary-filled cursor row, the same helper + // the select dialog uses for its active option. + const selectedFg = () => selectedForeground(themeState.theme, themeState.theme.primary) return ( - - - DAG - {selectedWorkflowSummary()?.title ?? "workflow inspector"} - - - {workflows().length} {workflows().length === 1 ? "workflow" : "workflows"} - - - - - - - - - Loading workflows... - - - - - + + {/* Main column — the chat message-area vocabulary: two-column side + padding, rail-and-panel blocks, no frame lines. */} + + + + + Loading workflows... + + Unable to load workflows - - - - - - No workflows for this session - - - 0}> - - - (workflowScroll = element)} - verticalScrollbarOptions={{ visible: false }} - horizontalScrollbarOptions={{ visible: false }} - > - - {(wf) => { - const selected = () => selectedWorkflow() === wf.id - return ( - setSelectedWorkflow(wf.id)} - > - - • - - - - {wf.title} - - - - {formatDagProgress(wf)} - - - ) - }} - - - - - {/* 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. */} - - + + + No workflows for this session + + + {"Run /dag-flow inside a session to start an orchestration"} + + + + 0}> + {/* Selected workflow summary — the user-message block: status + rail + panel background, mirroring the chat transcript. */} + - - • + + {selectedWorkflowSummary()?.title ?? ""} - {selectedWorkflowSummary()?.status ?? "unknown"} - - - {actionMessage()} - - - - - {nodes().length} {nodes().length === 1 ? "node" : "nodes"} · {layers().length}{" "} - {layers().length === 1 ? "wave" : "waves"} + + + {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} + + - - {/* 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 + + (nodeScroll = element)} + flexGrow={1} + minHeight={0} + marginTop={1} + 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)} + {layerIdx() !== 0 ? : null} + {/* Wave header: nodes at the same topological depth, NOT a barrier. + Bold title plus muted count, like the sidebar's MCP section. */} + + + 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.worker_type} - - - ) - }} - - - )} - - - - + {node.name} + + + + {node.worker_type} + + + ) + }} + + + )} + + + {/* Node detail — the block-tool vocabulary: soft inset rail, + panel background, fixed height so selection changes never + move the footer. */} + {(node) => ( @@ -622,25 +601,83 @@ function DagInspector(props: { api: TuiPluginApi }) { )} - {/* Bottom rail: closes the frame flush with the workflow - list's bottom border, mirroring the diff-viewer. */} - - - - - + + + + - - - {(hint) => ( + {/* 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. */} + + + - {hint.key} {hint.label} + 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. */} + + + {(hint) => ( + + {hint.key} {hint.label} + + )} + + ) } diff --git a/packages/tui/test/feature-plugins/dag-inspector.test.tsx b/packages/tui/test/feature-plugins/dag-inspector.test.tsx index c9d697e6d..ceb65a5c4 100644 --- a/packages/tui/test/feature-plugins/dag-inspector.test.tsx +++ b/packages/tui/test/feature-plugins/dag-inspector.test.tsx @@ -35,6 +35,7 @@ type RenderOpts = { nodes?: DagNode[] initialRoute?: TuiRouteCurrent summary?: (sessionID: string) => Promise<{ data: DagWorkflowSummary[] }> + width?: number } function dagNode(overrides: Partial & { id: string }): DagNode { @@ -55,7 +56,9 @@ async function renderDagInspector(opts: RenderOpts = {}) { string, NonNullable[0]["commands"]>[number] >() - const [current, setCurrent] = createSignal(opts.initialRoute ?? { name: "dag", params: { sessionID: SESSION_ID } }) + const [current, setCurrent] = createSignal( + opts.initialRoute ?? { name: "dag", params: { sessionID: SESSION_ID } }, + ) let renderInspector: TuiRouteDefinition["render"] | undefined // Updatable workflow state for change detection. @@ -156,7 +159,7 @@ async function renderDagInspector(opts: RenderOpts = {}) { ) } - const app = await testRender(() => , { width: 100, height: 30 }) + const app = await testRender(() => , { width: opts.width ?? 130, height: 30 }) await waitForCommand(app, commands, "dag.open") if (current().name === "dag") await waitForCommand(app, commands, "dag.close") // Give the initial fetchNodes a chance to resolve. @@ -206,13 +209,25 @@ async function waitForCondition(fn: () => boolean, timeout = 2000) { } } +type RegisteredCommands = Map< + string, + NonNullable[0]["commands"]>[number] +> + +/** Invoke a route command the way a keypress would. The keymap passes a real + * command context at runtime; tests only exercise the side effects, so the + * unused context is asserted away once here instead of at every call site. */ +function runCommand(commands: RegisteredCommands, name: string) { + void commands.get(name)!.run?.({} as never) +} + describe("DagInspector", () => { test("/dag dispatches dag.open locally without submitting a model command", async () => { const returnRoute = { name: "session", params: { sessionID: SESSION_ID } } const viewer = await renderDagInspector({ initialRoute: returnRoute }) try { expect(viewer.commands.get("dag.open")?.slashName).toBe("dag") - viewer.commands.get("dag.open")!.run?.({} as never) + runCommand(viewer.commands, "dag.open") await waitForCommand(viewer.app, viewer.commands, "dag.close") expect(viewer.navigations().at(-1)).toEqual({ @@ -333,13 +348,16 @@ describe("DagInspector", () => { test("closing the inspector prevents further fetches", async () => { const viewer = await renderDagInspector({ - initialRoute: { name: "dag", params: { sessionID: SESSION_ID, returnRoute: { name: "session", params: { sessionID: SESSION_ID } } } }, + initialRoute: { + name: "dag", + params: { sessionID: SESSION_ID, returnRoute: { name: "session", params: { sessionID: SESSION_ID } } }, + }, workflows: [wfSummary({ id: "wf-1" })], nodes: [dagNode({ id: "n-1", name: "build", status: "pending" })], }) try { // Close the inspector — navigates away, unmounts the component. - viewer.commands.get("dag.close")!.run?.({} as never) + runCommand(viewer.commands, "dag.close") await Bun.sleep(20) const before = viewer.nodesCalls().length @@ -359,7 +377,7 @@ describe("DagInspector", () => { nodes: [dagNode({ id: "n-1", name: "build", status: "running", child_session_id: "child_ses_1" })], }) try { - viewer.commands.get("dag.enter")!.run?.({} as never) + runCommand(viewer.commands, "dag.enter") expect(viewer.navigations()).toContainEqual( expect.objectContaining({ name: "session", params: expect.objectContaining({ sessionID: "child_ses_1" }) }), ) @@ -385,14 +403,14 @@ describe("DagInspector", () => { } }) - test("pressing p pauses a running workflow", async () => { + test("dag.pause pauses a running workflow", async () => { const viewer = await renderDagInspector({ workflows: [wfSummary({ id: "wf-1", status: "running" })], nodes: [dagNode({ id: "n-1", name: "build", status: "running" })], }) try { await viewer.app.waitForFrame((frame) => frame.includes("build")) - viewer.app.mockInput.pressKey("p") + runCommand(viewer.commands, "dag.pause") await waitForCondition(() => viewer.controlCalls().length > 0) expect(viewer.controlCalls()).toContainEqual({ dagID: "wf-1", operation: "pause" }) } finally { @@ -400,14 +418,14 @@ describe("DagInspector", () => { } }) - test("pressing p pauses a stepping workflow", async () => { + test("dag.pause pauses a stepping workflow", async () => { const viewer = await renderDagInspector({ workflows: [wfSummary({ id: "wf-1", status: "stepping" })], nodes: [dagNode({ id: "n-1", name: "build", status: "running" })], }) try { await viewer.app.waitForFrame((frame) => frame.includes("build")) - viewer.app.mockInput.pressKey("p") + runCommand(viewer.commands, "dag.pause") await waitForCondition(() => viewer.controlCalls().length > 0) expect(viewer.controlCalls()).toContainEqual({ dagID: "wf-1", operation: "pause" }) } finally { @@ -415,14 +433,14 @@ describe("DagInspector", () => { } }) - test("pressing p on a terminal workflow explains why pause is unavailable", async () => { + test("dag.pause on a terminal workflow explains why pause is unavailable", async () => { const viewer = await renderDagInspector({ workflows: [wfSummary({ id: "wf-1", status: "completed", completedNodes: 2 })], nodes: [dagNode({ id: "n-1", name: "build", status: "completed" })], }) try { await viewer.app.waitForFrame((frame) => frame.includes("build")) - viewer.app.mockInput.pressKey("p") + runCommand(viewer.commands, "dag.pause") await waitForCondition(() => viewer.toasts().length > 0) expect(viewer.controlCalls()).toEqual([]) expect(viewer.toasts().at(-1)?.message).toMatch(/completed.*cannot be paused/i) @@ -431,6 +449,22 @@ describe("DagInspector", () => { } }) + test("control operations stay reachable through the command palette", async () => { + const viewer = await renderDagInspector({ + workflows: [wfSummary({ id: "wf-1", status: "running" })], + nodes: [dagNode({ id: "n-1", name: "build", status: "running" })], + }) + try { + await viewer.app.waitForFrame((frame) => frame.includes("build")) + // Unbound by default, so the palette namespace is the only way in. + for (const name of ["dag.pause", "dag.resume", "dag.step", "dag.cancel"]) { + expect(viewer.commands.get(name)?.namespace).toBe("palette") + } + } finally { + viewer.app.renderer.destroy() + } + }) + test("the inspector leaves a blank outer row below its footer", async () => { const viewer = await renderDagInspector({ workflows: [wfSummary({ id: "wf-1" })], @@ -453,7 +487,7 @@ describe("DagInspector", () => { nodes: [dagNode({ id: "n-1", name: "build", status: "pending" })], }) try { - viewer.commands.get("dag.enter")!.run?.({} as never) + runCommand(viewer.commands, "dag.enter") expect(viewer.toasts().some((t) => /no session/i.test(t.message))).toBe(true) } finally { viewer.app.renderer.destroy() @@ -467,7 +501,7 @@ describe("DagInspector", () => { workflows: [wfSummary({ id: "wf-1" })], }) try { - viewer.commands.get("dag.close")!.run?.({} as never) + runCommand(viewer.commands, "dag.close") expect(viewer.navigations().at(-1)).toEqual( expect.objectContaining({ name: "session", params: { sessionID: SESSION_ID } }), ) @@ -489,7 +523,7 @@ describe("DagInspector", () => { await waitForCondition(() => viewer.nodesCalls().some((id) => id === "wf-1")) // Switch selection wf-1 -> wf-2. - viewer.commands.get("dag.next_workflow")!.run?.({} as never) + runCommand(viewer.commands, "dag.next_workflow") // The new selection fetches wf-2's nodes. await waitForCondition(() => viewer.nodesCalls().some((id) => id === "wf-2")) @@ -541,7 +575,7 @@ describe("DagInspector", () => { } }) - test("footer only advertises controls valid for the workflow status", async () => { + test("footer advertises only the cursor affordances, never the control operations", async () => { const viewer = await renderDagInspector({ workflows: [wfSummary({ id: "wf-1", status: "paused" })], nodes: [dagNode({ id: "n-1", name: "build", status: "pending" })], @@ -550,8 +584,13 @@ describe("DagInspector", () => { await viewer.app.waitForFrame((frame) => { const footer = frame.split("\n").find((row) => row.includes("open session")) if (!footer) return false + // A paused workflow would previously have advertised resume/cancel here. return ( - footer.includes("resume") && footer.includes("cancel") && !footer.includes("pause") && !footer.includes("step") + footer.includes("close") && + !footer.includes("resume") && + !footer.includes("cancel") && + !footer.includes("pause") && + !footer.includes("step") ) }) } finally { @@ -577,10 +616,40 @@ describe("DagInspector", () => { }) // Moving to "detailed" adds dependency and error rows to the detail // pane; the fixed-height pane must keep the footer on the same row. - viewer.commands.get("dag.down")!.run?.({} as never) + runCommand(viewer.commands, "dag.down") await viewer.app.waitForFrame((frame) => frame.includes("boom") && footerRow(frame) === before) } finally { viewer.app.renderer.destroy() } }) + + 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() + } + }) })