Skip to content
Open
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
120 changes: 120 additions & 0 deletions docs/dashboard-deep-links.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
# Conductor Dashboard Deep-Link Specification

## Overview

The conductor web dashboard (`conductor run --web`) accepts URL query parameters
that deep-link into specific nodes of the workflow graph. This enables external
tools (e.g., the conductor-dashboard meta-dashboard) to generate clickable links
that open the UI focused on a particular agent or subworkflow.

## Query Parameters

| Parameter | Format | Description |
|----------------|---------------------------------|------------------------------------------------|
| `subworkflow` | slash-separated agent path | Navigate into a subworkflow context |
| `agent` | agent name | Select and center an agent node in the graph |

Both parameters are optional. When both are present, subworkflow navigation
happens first, then the agent is selected within that subworkflow's graph.

## URL Format

```
http://localhost:{port}[?subworkflow={path}][&agent={name}]
```

## Subworkflow Path

The `subworkflow` parameter is a `/`-separated path of **parent agent names**
that invoke each sub-workflow, starting from the root workflow.

Given this workflow nesting:

```
root
├── intake (agent)
├── planning (workflow agent → planning.yaml)
│ ├── architect (agent)
│ └── design (workflow agent → design.yaml)
│ ├── reviewer (agent)
│ └── writer (agent)
└── close_out (agent)
```

| URL | Result |
|----------------------------------------------|-------------------------------------------|
| `?subworkflow=planning` | View planning.yaml's graph |
| `?subworkflow=planning/design` | View design.yaml's graph |
| `?subworkflow=planning/design&agent=reviewer` | View design.yaml, select reviewer node |

Each path segment must match the `name` of the workflow-type agent in its
parent workflow — this is the same value shown in the breadcrumb bar.

## Agent Selection

The `agent` parameter selects and centers a node in the **currently viewed**
workflow graph:

- **Root agent** (no subworkflow context): `?agent=intake`
- **Agent inside a subworkflow**: `?subworkflow=planning&agent=architect`

**Important:** An agent that lives inside a subworkflow will NOT be found
by `?agent=reviewer` alone — you must also provide the `subworkflow` path
to navigate to the correct context first:

```
# ✗ WRONG — reviewer doesn't exist in the root workflow
?agent=reviewer

# ✓ CORRECT — navigate into planning/design, then select reviewer
?subworkflow=planning/design&agent=reviewer
```

## Behavior

1. **Parse** — On initial page load, read `subworkflow` and `agent` from
`window.location.search`.

2. **Wait** — Do nothing until the workflow graph has been populated
(agents arrive via WebSocket late-joiner replay).

3. **Navigate** — If `subworkflow` is present, split on `/` and call
`navigateIntoSubworkflow()` for each segment sequentially.
Each call is synchronous (zustand `set`/`get`), so the viewed context
updates between calls.

4. **Select** — If `agent` is present, call `selectNode(agent)` then
`fitView({ nodes: [{ id: agent }] })` to center the graph on the node
with a smooth animation.

5. **Once** — Deep-link application fires exactly once per page load.
Subsequent WebSocket events do not re-trigger navigation.

## Edge Cases

| Scenario | Behavior |
|---------------------------------------|--------------------------------------------------|
| Unknown subworkflow path segment | Navigation stops at the last valid level |
| Unknown agent name | No node selected, graph shows default view |
| Subworkflow hasn't started yet | Navigation fails silently (no context exists) |
| Page refresh | Deep-link re-applied from URL (full state replay) |
| Combined with breadcrumb navigation | User can freely navigate after deep-link applies |

## Example URLs

```
# Root workflow — default view
http://localhost:49123

# Select an agent in the root workflow
http://localhost:49123?agent=intake

# Drill into a subworkflow
http://localhost:49123?subworkflow=planning

# Drill two levels deep
http://localhost:49123?subworkflow=planning/design

# Drill into subworkflow and select an agent within it
http://localhost:49123?subworkflow=planning/design&agent=reviewer
```
22 changes: 19 additions & 3 deletions src/conductor/engine/workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -851,10 +851,11 @@ async def _check_interrupt(self, current_agent_name: str) -> InterruptResult | N

# In web mode, the interrupt was already handled at the provider level
# (partial output → _handle_web_pause). Consume the stale flag silently.
# We check for dashboard presence only (not has_connections) because in
# --web/--web-bg mode the CLI interactive handler is never appropriate,
# even if clients are transiently disconnected.
# EXCEPTION: in subworkflows (depth > 0), propagate the interrupt so it
# unwinds the child engine back to the parent, stopping the workflow.
if self._web_dashboard is not None:
if self._subworkflow_depth > 0:
raise InterruptError(agent_name=current_agent_name)
return None

# Build output preview from last stored output
Expand Down Expand Up @@ -958,6 +959,15 @@ async def _handle_web_pause(self, agent_name: str, partial_output: AgentOutput)
disconnect_task = asyncio.create_task(disconnect_event.wait())
tasks = {resume_task, kill_task, disconnect_task}

# In subworkflows, also watch the interrupt_event so that a second
# Stop click while paused will stop the workflow without requiring
# the user to first Resume then wait for the next between-agent check.
stop_task = None
if self._subworkflow_depth > 0 and self._interrupt_event is not None:
self._interrupt_event.clear()
stop_task = asyncio.create_task(self._interrupt_event.wait())
tasks.add(stop_task)

# If any event was set between clear() and task creation, the task
# will already be done — no need to wait, but we still fall through
# to the normal done/pending handling below.
Expand All @@ -981,6 +991,12 @@ async def _handle_web_pause(self, agent_name: str, partial_output: AgentOutput)
if kill_task in done:
raise InterruptError(agent_name=agent_name)

# Stop-while-paused in a subworkflow: treat as interrupt
if stop_task is not None and stop_task in done:
if self._interrupt_event is not None:
self._interrupt_event.clear()
raise InterruptError(agent_name=agent_name)

if disconnect_task in done:
logger.info(
"All dashboard clients disconnected while '%s' was paused — auto-resuming",
Expand Down
2 changes: 2 additions & 0 deletions src/conductor/web/frontend/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useState, useEffect } from 'react';
import { Header } from '@/components/layout/Header';
import { BreadcrumbBar } from '@/components/layout/BreadcrumbBar';
import { StatusBar } from '@/components/layout/StatusBar';
import { ReplayBar } from '@/components/layout/ReplayBar';
import { ResizableLayout } from '@/components/layout/ResizableLayout';
Expand Down Expand Up @@ -55,6 +56,7 @@ export default function App() {
<div className="h-full flex flex-col bg-[var(--bg)]">
{isReplayMode ? <ReplayMode /> : <LiveMode />}
<Header />
<BreadcrumbBar />
<ResizableLayout />
{replayMode ? <ReplayBar /> : <StatusBar />}
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
import { useEffect, useState } from 'react';
import { X } from 'lucide-react';
import { useWorkflowStore } from '@/stores/workflow-store';
import { useViewedNodes } from '@/hooks/use-viewed-context';
import { AgentDetail } from './AgentDetail';
import { ScriptDetail } from './ScriptDetail';
import { GateDetail } from './GateDetail';
import { GroupDetail } from './GroupDetail';
import { SubworkflowDetail } from './SubworkflowDetail';
import { cn } from '@/lib/utils';

export function DetailPanel() {
const selectedNode = useWorkflowStore((s) => s.selectedNode);
const nodes = useWorkflowStore((s) => s.nodes);
const viewedNodes = useViewedNodes();
const selectNode = useWorkflowStore((s) => s.selectNode);

// Slide-in animation state
Expand All @@ -20,7 +22,7 @@ export function DetailPanel() {
return () => setMounted(false);
}, [selectedNode]);

const node = selectedNode ? nodes[selectedNode] : null;
const node = selectedNode ? viewedNodes[selectedNode] : null;

if (!selectedNode || !node) {
return (
Expand All @@ -44,6 +46,8 @@ export function DetailPanel() {
case 'parallel_group':
case 'for_each_group':
return GroupDetail;
case 'workflow':
return SubworkflowDetail;
default:
return AgentDetail;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { ActivityStream } from './ActivityStream';
import type { NodeData, ForEachItemData } from '@/stores/workflow-store';
import { NODE_STATUS_HEX } from '@/lib/constants';
import { formatElapsed, formatCost, formatTokens } from '@/lib/utils';
import { useWorkflowStore } from '@/stores/workflow-store';
import { useViewedGroupProgress } from '@/hooks/use-viewed-context';
import type { NodeStatus } from '@/lib/constants';

interface GroupDetailProps {
Expand All @@ -16,8 +16,8 @@ interface GroupDetailProps {
export function GroupDetail({ node }: GroupDetailProps) {
const status = node.status as NodeStatus;
const statusColor = NODE_STATUS_HEX[status] || NODE_STATUS_HEX.pending;
const groupProgress = useWorkflowStore((s) => s.groupProgress);
const progress = groupProgress[node.name];
const viewedProgress = useViewedGroupProgress();
const progress = viewedProgress[node.name];
const isForEach = node.type === 'for_each_group';

const [showItems, setShowItems] = useState(true);
Expand Down
113 changes: 113 additions & 0 deletions src/conductor/web/frontend/src/components/detail/SubworkflowDetail.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { Layers, ChevronRight, Coins, Hash } from 'lucide-react';
import { MetadataGrid } from './MetadataGrid';
import { useWorkflowStore } from '@/stores/workflow-store';
import { useViewedSubworkflowContexts } from '@/hooks/use-viewed-context';
import type { NodeData, SubworkflowContext } from '@/stores/workflow-store';
import { NODE_STATUS_HEX } from '@/lib/constants';
import { formatElapsed, formatCost, formatTokens } from '@/lib/utils';
import type { NodeStatus } from '@/lib/constants';

interface SubworkflowDetailProps {
node: NodeData;
}

export function SubworkflowDetail({ node }: SubworkflowDetailProps) {
const status = node.status as NodeStatus;
const statusColor = NODE_STATUS_HEX[status] || NODE_STATUS_HEX.pending;
const navigateIntoSubworkflow = useWorkflowStore((s) => s.navigateIntoSubworkflow);
const allSubContexts = useViewedSubworkflowContexts();
const subContexts = allSubContexts.filter((c) => c.parentAgent === node.name);

const items: Array<{ label: string; value: string | number | null | undefined }> = [];
if (node.elapsed != null) items.push({ label: 'Elapsed', value: formatElapsed(node.elapsed) });
if (node.cost_usd != null) items.push({ label: 'Cost', value: formatCost(node.cost_usd) });
if (node.tokens != null) items.push({ label: 'Tokens', value: formatTokens(node.tokens) });
if (node.iteration != null && node.iteration > 1) items.push({ label: 'Iteration', value: node.iteration });

return (
<div className="space-y-4">
<div className="flex items-center gap-2">
<span
className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold uppercase tracking-wider"
style={{ backgroundColor: `${statusColor}20`, color: statusColor }}
>
{status}
</span>
<span className="text-xs text-[var(--text-muted)]">Subworkflow Agent</span>
</div>

<MetadataGrid items={items} />

{/* List subworkflow runs */}
{subContexts.length > 0 && (
<div className="space-y-2">
<div className="text-[10px] uppercase tracking-wider text-[var(--text-muted)] font-semibold">
Subworkflow Runs ({subContexts.length})
</div>
<div className="space-y-1">
{subContexts.map((ctx, idx) => (
<SubworkflowRunRow
key={`${ctx.parentAgent}-${ctx.iteration}-${idx}`}
ctx={ctx}
onClick={() => navigateIntoSubworkflow(node.name, ctx.iteration)}
/>
))}
</div>
</div>
)}

{/* Error info */}
{status === 'failed' && (node.error_type || node.error_message) && (
<div className="text-xs text-red-400">
{node.error_type && <span className="font-semibold">{node.error_type}</span>}
{node.error_message && <span className="ml-1">— {node.error_message}</span>}
</div>
)}

{subContexts.length === 0 && status === 'pending' && (
<div className="text-xs text-[var(--text-muted)] italic">
Subworkflow has not started yet.
</div>
)}
</div>
);
}

function SubworkflowRunRow({ ctx, onClick }: { ctx: SubworkflowContext; onClick: () => void }) {
const statusColor = NODE_STATUS_HEX[ctx.status] || NODE_STATUS_HEX.pending;

return (
<button
onClick={onClick}
className="flex items-center gap-2 w-full px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--surface)] hover:bg-[var(--node-bg)] transition-colors text-left"
>
<Layers className="w-3.5 h-3.5 flex-shrink-0" style={{ color: statusColor }} />
<div className="flex flex-col min-w-0 flex-1">
<span className="text-xs font-medium text-[var(--text)] truncate">
{ctx.workflowName || ctx.workflowFile || 'Subworkflow'}
</span>
<div className="flex items-center gap-2 text-[10px] text-[var(--text-muted)]">
{ctx.agentsTotal > 0 && (
<span className="flex items-center gap-0.5">
<Hash className="w-2.5 h-2.5" />
{ctx.agentsCompleted}/{ctx.agentsTotal} agents
</span>
)}
{ctx.totalCost > 0 && (
<span className="flex items-center gap-0.5">
<Coins className="w-2.5 h-2.5" />
{formatCost(ctx.totalCost)}
</span>
)}
</div>
</div>
<span
className="text-[10px] font-bold uppercase tracking-wider flex-shrink-0 px-1.5 py-0.5 rounded"
style={{ backgroundColor: `${statusColor}20`, color: statusColor }}
>
{ctx.status}
</span>
<ChevronRight className="w-3.5 h-3.5 flex-shrink-0 text-[var(--text-muted)]" />
</button>
);
}
Loading
Loading