[aidan] feat/run-monitor: live run monitor card on the canvas

This commit is contained in:
abccodes
2026-06-22 23:58:38 -07:00
parent fd1ab46fa9
commit fac7f46433
8 changed files with 413 additions and 11 deletions
@@ -5,6 +5,7 @@ import DashboardViewCard from '../cards/DashboardViewCard';
import BrowserCard from '../cards/BrowserCard';
import NoteCard from '../cards/NoteCard';
import WorkflowsAppCard from '@/app/pages/Workflows/app/WorkflowsAppCard';
import RunMonitor from '@/app/pages/Workflows/app/RunMonitor';
import {
EXPANDED_CARD_MIN_H,
DEFAULT_CARD_W,
@@ -17,7 +18,8 @@ import {
type WorkflowsHubPosition,
type ConfigurePanelPosition,
} from '@/shared/state/dashboardLayoutSlice';
import { useAppSelector } from '@/shared/hooks';
import { useAppSelector, useAppDispatch } from '@/shared/hooks';
import { closeWorkflowMonitor } from '@/shared/state/dashboardLayoutSlice';
import type { Output } from '@/shared/state/outputsSlice';
import type { CardType, useDashboardSelection } from '../hooks/state/useDashboardSelection';
@@ -102,6 +104,15 @@ const DashboardCardLayer: React.FC<DashboardCardLayerProps> = ({
// Ephemeral singleton, not part of the saved layout, so read it straight
// from the store rather than threading it through the selector chain.
const missedRunsCard = useAppSelector((s) => s.dashboardLayout.missedRunsCard);
const dispatch = useAppDispatch();
const monitorCard = useAppSelector((s) => s.dashboardLayout.workflowsMonitorCard);
const monitorWorkflowId = useAppSelector((s) => s.dashboardLayout.workflowsMonitorId);
const monitorWorkflow = useAppSelector((s) => (monitorWorkflowId ? s.workflows.items[monitorWorkflowId] : undefined));
// The monitor's workflow vanished (trashed/deleted) while open: tear the card
// + its tether down instead of leaving an orange line pointing at nothing.
React.useEffect(() => {
if (monitorCard && !monitorWorkflow) dispatch(closeWorkflowMonitor());
}, [monitorCard, monitorWorkflow, dispatch]);
return (
<>
<AnimatePresence>
@@ -287,6 +298,22 @@ const DashboardCardLayer: React.FC<DashboardCardLayerProps> = ({
onBringToFront={onBringToFront}
/>
)}
{monitorCard && monitorWorkflow && (
<RunMonitor
workflow={monitorWorkflow}
cardX={monitorCard.x}
cardY={monitorCard.y}
cardWidth={monitorCard.width}
cardHeight={monitorCard.height}
cardZOrder={monitorCard.zOrder ?? 0}
zoom={zoom}
panX={panX}
panY={panY}
onDragStart={onDragStart}
onDragMove={onDragMove}
onDragEnd={onDragEnd}
/>
)}
{/* Marquee selection rectangle */}
{selection.marquee && (
<div
@@ -1,5 +1,5 @@
import { useMemo, type RefObject } from 'react';
import type { CardPosition, BrowserCardPosition, WorkflowCardPosition, ConfigurePanelPosition } from '@/shared/state/dashboardLayoutSlice';
import type { CardPosition, BrowserCardPosition, WorkflowCardPosition, ConfigurePanelPosition, WorkflowsHubPosition } from '@/shared/state/dashboardLayoutSlice';
import type { Workflow, OpenCard } from '@/shared/state/workflowsSlice';
import { EXPANDED_CARD_MIN_H, GRID_GAP } from '@/shared/state/dashboardLayoutSlice';
import type { AgentSession } from '@/shared/state/agentsSlice';
@@ -88,6 +88,9 @@ interface UseTethersArgs {
measuredHeightsRef: RefObject<Record<string, number>>;
measuredHeightsTick: number;
sessionList: AgentSession[];
workflowsHub: WorkflowsHubPosition | null;
workflowsMonitorCard: WorkflowsHubPosition | null;
workflowsMonitorLabel: string;
}
export function useTethers({
@@ -104,6 +107,9 @@ export function useTethers({
measuredHeightsRef,
measuredHeightsTick,
sessionList,
workflowsHub,
workflowsMonitorCard,
workflowsMonitorLabel,
}: UseTethersArgs): Tether[] {
return useMemo(() => {
const wfHeight = (wc: WorkflowCardPosition): number =>
@@ -432,9 +438,41 @@ export function useTethers({
});
}
return [...agentTethers, ...browserTethers, ...workflowTethers, ...configureTethers];
// Run Monitor tether: the Workflows window to its spawned live-run card.
// Same border-anchor + elbow math as the sidecar "Watching" arrow.
const monitorTethers: Tether[] = [];
if (workflowsHub && workflowsMonitorCard) {
let hubX = workflowsHub.x, hubY = workflowsHub.y;
let monX = workflowsMonitorCard.x, monY = workflowsMonitorCard.y;
// Track live drag so the line follows the card in real time instead of
// snapping into place on drop (same mechanism as the agent->browser tether).
if (liveDragInfo) {
if (liveDragInfo.cardId === 'workflows-hub') { hubX += liveDragInfo.dx; hubY += liveDragInfo.dy; }
if (liveDragInfo.cardId === 'workflows-monitor') { monX += liveDragInfo.dx; monY += liveDragInfo.dy; }
}
const hubRect = { x: hubX, y: hubY, width: workflowsHub.width, height: workflowsHub.height };
const monRect = { x: monX, y: monY, width: workflowsMonitorCard.width, height: workflowsMonitorCard.height };
const hubC = rectCenter(hubRect);
const monC = rectCenter(monRect);
const a = borderPoint(hubRect.x, hubRect.y, hubRect.width, hubRect.height, monC.x, monC.y);
const b = borderPoint(monRect.x, monRect.y, monRect.width, monRect.height, hubC.x, hubC.y);
const midX = a.x + (b.x - a.x) / 2;
const midY = a.y + (b.y - a.y) / 2;
// The label box is left-anchored at labelX (rect starts there and grows
// right), so shift left by half the text width to truly center it on the line.
monitorTethers.push({
key: 'workflows-monitor',
path: elbowPath(a.x, a.y, b.x, b.y),
labelX: midX - (workflowsMonitorLabel.length * 7.5) / 2,
labelY: midY,
label: workflowsMonitorLabel,
fading: false,
});
}
return [...agentTethers, ...browserTethers, ...workflowTethers, ...configureTethers, ...monitorTethers];
// measuredHeightsTick re-runs the memo once ResizeObserver reports a new
// height after a collapse (the ref read is invisible to the dep checker).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [glowingAgentCards, glowingBrowserCards, cards, browserCards, workflowCards, workflowItems, workflowOpenCards, configurePanels, expandedSessionIds, liveDragInfo, measuredHeightsTick, sessionList]);
}, [glowingAgentCards, glowingBrowserCards, cards, browserCards, workflowCards, workflowItems, workflowOpenCards, configurePanels, expandedSessionIds, liveDragInfo, measuredHeightsTick, sessionList, workflowsHub, workflowsMonitorCard, workflowsMonitorLabel]);
}
@@ -52,6 +52,12 @@ export function useDashboardInteractions({
selection.selectCard(id, type, false);
dispatch(bringToFront({ id, type }));
// The Workflows window is an app you click around inside, not a card you
// re-center every tap. Single-click only raises + selects it; double-click
// still zoom-to-fits (handleCardDoubleClick). Without this, clicking any
// button inside it yanked the canvas into a re-zoom.
if (type === 'workflows-hub' || type === 'workflows-monitor') return;
const alreadyExpanded = type === 'agent' && expandedSessionIds.includes(id);
if (alreadyExpanded) {
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useAppSelector } from '@/shared/hooks';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { useElementSelection } from '@/app/components/editor/ElementSelectionContext';
import { useCanvasControls } from '../interaction/useCanvasControls';
@@ -42,6 +43,18 @@ export function useDashboardController(dashboardId: string, isActive: boolean) {
// ref when one of its values changes, so this is the right granularity).
const sessionList = useMemo(() => Object.values(sessions), [sessions]);
// Run Monitor card geometry + its tether label ("Watching" live, "Viewing" done).
// Only "active" while its workflow still exists; otherwise the card is gone and
// the tether must not dangle (e.g. the workflow was trashed while watching).
const workflowsMonitorIdRaw = useAppSelector((s) => s.dashboardLayout.workflowsMonitorId);
const monitorActive = !!workflowsMonitorIdRaw && !!workflowItems[workflowsMonitorIdRaw];
const workflowsMonitorId = monitorActive ? workflowsMonitorIdRaw : null;
const workflowsMonitorCard = useAppSelector((s) =>
(monitorActive ? s.dashboardLayout.workflowsMonitorCard : null));
const monitorIsLive = useAppSelector((s) =>
!!workflowsMonitorId && s.workflows.active.some((a) => a.workflow_id === workflowsMonitorId));
const workflowsMonitorLabel = monitorIsLive ? 'Watching' : 'Viewing';
const contentBounds = useMemo(
() => computeContentBounds(cards, viewCards, browserCards, workflowCards, workflowsHub),
[cards, viewCards, browserCards, workflowCards, workflowsHub],
@@ -289,6 +302,9 @@ export function useDashboardController(dashboardId: string, isActive: boolean) {
measuredHeightsRef,
measuredHeightsTick,
sessionList,
workflowsHub,
workflowsMonitorCard,
workflowsMonitorLabel,
});
return {
@@ -1,10 +1,12 @@
import React, { useEffect } from 'react';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { fetchRuns } from '@/shared/state/workflowsSlice';
import { WC, FONT_SERIF, statusChip, statusDot, statusLabel } from './uiKit';
import { openWorkflowMonitor } from '@/shared/state/dashboardLayoutSlice';
import { useWC, FONT_SERIF, statusChip, statusDot, statusLabel } from './uiKit';
import { toRunRow, whenText } from './model';
const HistoryCard: React.FC<{ workflowId: string; title: string }> = ({ workflowId, title }) => {
const WC = useWC();
const dispatch = useAppDispatch();
const runs = useAppSelector((s) => s.workflows.runs[workflowId]);
@@ -14,20 +16,20 @@ const HistoryCard: React.FC<{ workflowId: string; title: string }> = ({ workflow
const now = new Date();
return (
<div style={{ background: WC.paper, border: '1px solid rgba(33,30,27,0.08)', borderRadius: 13, padding: 16 }}>
<div style={{ background: WC.paper, border: `1px solid rgba(${WC.inkRGB},0.08)`, borderRadius: WC.radius.lg, padding: 16 }}>
<div style={{ fontFamily: FONT_SERIF, fontSize: 16, fontWeight: 500, color: WC.ink, marginBottom: 12 }}>History</div>
{rows.length === 0 && <div style={{ fontSize: 12.5, color: WC.muted2 }}>No runs yet.</div>}
<div style={{ display: 'flex', flexDirection: 'column' }}>
{rows.map((r) => (
<div key={r.id} style={{ display: 'flex', alignItems: 'center', gap: 11, padding: '9px 0', borderBottom: '1px solid rgba(33,30,27,0.05)' }}>
<div style={statusDot(r.status)} />
<div key={r.id} onClick={() => dispatch(openWorkflowMonitor({ workflowId, runId: r.id }))} title="Open this run" style={{ display: 'flex', alignItems: 'center', gap: 11, padding: '9px 0', borderBottom: `1px solid rgba(${WC.inkRGB},0.05)`, cursor: 'pointer' }}>
<div style={statusDot(r.status, WC)} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 12.5, color: WC.ink2, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{r.summary}</div>
<div style={{ fontFamily: "'JetBrains Mono',monospace", fontSize: 10.5, color: WC.muted2, marginTop: 2 }}>
{whenText(r.when, now)}{r.durationText ? ` · ${r.durationText}` : ''}
</div>
</div>
<span style={statusChip(r.status)}>{statusLabel(r.status)}</span>
<span style={statusChip(r.status, WC)}>{statusLabel(r.status)}</span>
</div>
))}
</div>
@@ -0,0 +1,256 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import AgentChat from '@/app/pages/AgentChat/AgentChat';
import { fetchRuns, controlWorkflowRun } from '@/shared/state/workflowsSlice';
import type { Workflow, WorkflowRun } from '@/shared/state/workflowsSlice';
import {
bringToFront, closeWorkflowMonitor, setWorkflowsMonitorPosition,
} from '@/shared/state/dashboardLayoutSlice';
import type { CardType } from '@/shared/state/dashboardLayoutSlice';
type StepState = 'done' | 'running' | 'failed' | 'pending';
const DRAG_THRESHOLD = 3;
function fmtClock(ms: number): string {
if (!Number.isFinite(ms) || ms < 0) ms = 0;
const s = Math.floor(ms / 1000);
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
}
function kindLabel(run: WorkflowRun | null): string {
if (!run) return 'RUN';
if (run.triggered_by === 'manual') return 'MANUAL RUN';
if (run.triggered_by === 'retry') return 'RE-RUN';
return 'SCHEDULED RUN';
}
interface Props {
workflow: Workflow;
cardX: number;
cardY: number;
cardWidth: number;
cardHeight: number;
cardZOrder: number;
zoom: number;
panX: number;
panY: number;
onDragStart: (id: string, type: CardType) => void;
onDragMove: (dx: number, dy: number, mouseX?: number, mouseY?: number) => void;
onDragEnd: (dx: number, dy: number, didDrag: boolean) => void;
}
// The live run view, a real canvas card (standard claudeTokens chrome) spawned
// beside the Workflows window. The orange connector back to the window is drawn
// by the shared TetherLayer, same mechanism as an agent spinning up a browser.
const RunMonitor: React.FC<Props> = ({ workflow, cardX, cardY, cardWidth, cardHeight, cardZOrder, zoom, panX, panY, onDragStart, onDragMove, onDragEnd }) => {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const runs = useAppSelector((s) => s.workflows.runs[workflow.id]);
const allRuns = useAppSelector((s) => s.workflows.allRuns);
const monitorRunId = useAppSelector((s) => s.dashboardLayout.workflowsMonitorRunId);
const [nowTick, setNowTick] = useState(() => Date.now());
useEffect(() => { dispatch(fetchRuns(workflow.id)); }, [workflow.id, dispatch]);
const panRef = useRef({ panX, panY });
panRef.current = { panX, panY };
const zoomRef = useRef(zoom);
zoomRef.current = zoom;
const dragState = useRef<{ sx: number; sy: number; ox: number; oy: number; spx: number; spy: number } | null>(null);
const didDrag = useRef(false);
const [localPos, setLocalPos] = useState<{ x: number; y: number } | null>(null);
const onHeaderDown = useCallback((e: React.PointerEvent) => {
if (e.button !== 0) return;
const t = e.target as HTMLElement;
if (t.closest('button, [role="button"]')) return;
e.preventDefault(); e.stopPropagation();
dispatch(bringToFront({ id: 'workflows-monitor', type: 'workflows-monitor' }));
dragState.current = { sx: e.clientX, sy: e.clientY, ox: cardX, oy: cardY, spx: panRef.current.panX, spy: panRef.current.panY };
didDrag.current = false;
onDragStart('workflows-monitor', 'workflows-monitor');
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
}, [cardX, cardY, dispatch, onDragStart]);
const onHeaderMove = useCallback((e: React.PointerEvent) => {
if (!dragState.current) return;
const rdx = e.clientX - dragState.current.sx;
const rdy = e.clientY - dragState.current.sy;
if (!didDrag.current && Math.sqrt(rdx * rdx + rdy * rdy) < DRAG_THRESHOLD) return;
didDrag.current = true;
const z = zoomRef.current;
const pdx = (panRef.current.panX - dragState.current.spx) / z;
const pdy = (panRef.current.panY - dragState.current.spy) / z;
const dx = rdx / z - pdx;
const dy = rdy / z - pdy;
setLocalPos({ x: dragState.current.ox + dx, y: dragState.current.oy + dy });
// Feed the shared drag channel so the tether tracks live, same as cards.
onDragMove(dx, dy, e.clientX, e.clientY);
}, [onDragMove]);
const onHeaderUp = useCallback((e: React.PointerEvent) => {
if (!dragState.current) return;
const z = zoomRef.current;
const pdx = (panRef.current.panX - dragState.current.spx) / z;
const pdy = (panRef.current.panY - dragState.current.spy) / z;
const dx = (e.clientX - dragState.current.sx) / z - pdx;
const dy = (e.clientY - dragState.current.sy) / z - pdy;
if (didDrag.current) {
let nx = dragState.current.ox + dx;
let ny = dragState.current.oy + dy;
if (!e.shiftKey) { nx = Math.round(nx / 24) * 24; ny = Math.round(ny / 24) * 24; }
dispatch(setWorkflowsMonitorPosition({ x: nx, y: ny }));
}
onDragEnd(dx, dy, didDrag.current);
dragState.current = null;
didDrag.current = false;
setLocalPos(null);
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
}, [dispatch, onDragEnd]);
// A pinned run id (clicked from history) wins; otherwise follow the latest run.
const run: WorkflowRun | null =
(monitorRunId
? (runs || []).find((r) => r.id === monitorRunId) || allRuns.find((r) => r.id === monitorRunId)
: (runs && runs[0]) || allRuns.find((r) => r.workflow_id === workflow.id))
|| null;
const isRunning = run?.status === 'running';
useEffect(() => {
if (!isRunning) return;
const t = setInterval(() => setNowTick(Date.now()), 1000);
return () => clearInterval(t);
}, [isRunning]);
const steps = workflow.steps.filter((s) => s.enabled !== false && s.text && s.text.trim());
const total = steps.length;
const aidx = run?.active_step_idx ?? 0;
const failed = run?.status === 'failure';
const succeeded = run?.status === 'success' || run?.status === 'ran_late';
const sessionId = run?.session_id || null;
const stepState = (i: number): StepState => {
if (succeeded) return 'done';
if (failed) return i < aidx ? 'done' : i === aidx ? 'failed' : 'pending';
if (isRunning) return i < aidx ? 'done' : i === aidx ? 'running' : 'pending';
return 'pending';
};
const pct = total > 0
? Math.round((succeeded ? total : Math.min(aidx + (isRunning ? 0.5 : 0), total)) / total * 100)
: (isRunning ? 10 : 0);
const startedMs = run?.started_at ? new Date(run.started_at).getTime() : nowTick;
const endMs = run?.finished_at ? new Date(run.finished_at).getTime() : nowTick;
const clock = fmtClock((isRunning ? nowTick : endMs) - startedMs);
const headStatus = isRunning ? 'Running' : succeeded ? 'Done' : failed ? 'Failed' : 'Idle';
const headColor = isRunning ? c.accent.primary : succeeded ? c.status.success : failed ? c.status.error : c.text.tertiary;
const headBg = isRunning ? c.bg.secondary : succeeded ? c.status.successBg : failed ? c.status.errorBg : c.bg.secondary;
const progressLabel = isRunning
? `Step ${Math.min(aidx + 1, total)} of ${total}`
: succeeded ? `All ${total} steps complete` : failed ? `Failed at step ${Math.min(aidx + 1, total)}` : `${total} steps`;
const close = () => dispatch(closeWorkflowMonitor());
const stopRun = () => { if (run?.id) dispatch(controlWorkflowRun({ runId: run.id, action: 'stop' })); };
const x = localPos?.x ?? cardX;
const y = localPos?.y ?? cardY;
return (
<div
data-select-type="workflows-monitor-card"
data-select-id="workflows-monitor"
onPointerDownCapture={() => dispatch(bringToFront({ id: 'workflows-monitor', type: 'workflows-monitor' }))}
style={{
position: 'absolute', left: x, top: y, width: cardWidth, height: cardHeight,
background: c.bg.surface, border: `1px solid ${c.border.medium}`, borderRadius: c.radius.lg,
boxShadow: c.shadow.lg, overflow: 'hidden', display: 'flex', flexDirection: 'column',
zIndex: cardZOrder, contain: 'layout style',
}}
>
{/* title bar (drag handle) */}
<div
onPointerDown={onHeaderDown}
onPointerMove={onHeaderMove}
onPointerUp={onHeaderUp}
style={{ height: 44, flex: 'none', display: 'flex', alignItems: 'center', gap: 9, padding: '0 10px 0 14px', borderBottom: `1px solid ${c.border.subtle}`, background: c.bg.elevated, cursor: localPos ? 'grabbing' : 'grab', touchAction: 'none', userSelect: 'none' }}
>
<div style={{ width: 8, height: 8, borderRadius: '50%', background: headColor, flex: 'none', ...(isRunning ? { animation: 'os-pulse 1.1s ease-in-out infinite' } : {}) }} />
<span style={{ fontSize: 13.5, fontWeight: 600, color: c.text.primary, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{workflow.title || 'Untitled workflow'}</span>
<span style={{ fontSize: 11, fontWeight: 600, color: headColor, background: headBg, padding: '2px 9px', borderRadius: 999, flex: 'none' }}>{headStatus}</span>
<div style={{ flex: 1 }} />
<span style={{ fontFamily: 'ui-monospace, monospace', fontSize: 11.5, color: c.text.tertiary, flex: 'none' }}>{clock}</span>
<button onClick={close} aria-label="Close" style={{ width: 26, height: 26, borderRadius: 7, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', color: c.text.tertiary, background: 'transparent', border: 'none', flex: 'none' }}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M6 6l12 12M18 6L6 18" /></svg>
</button>
</div>
{/* progress subhead */}
<div style={{ flex: 'none', padding: '14px 16px 13px', borderBottom: `1px solid ${c.border.subtle}` }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 9 }}>
<span style={{ fontFamily: 'ui-monospace, monospace', fontSize: 10, letterSpacing: '0.07em', color: c.text.tertiary }}>{kindLabel(run)}</span>
<span style={{ fontFamily: 'ui-monospace, monospace', fontSize: 11, color: c.text.tertiary }}>{pct}%</span>
</div>
<div style={{ height: 5, borderRadius: 999, background: c.bg.secondary, overflow: 'hidden' }}>
<div style={{ width: `${pct}%`, height: '100%', borderRadius: 999, background: failed ? c.status.error : c.accent.primary, transition: 'width .4s ease' }} />
</div>
<div style={{ fontSize: 12, color: c.text.secondary, marginTop: 8 }}>{progressLabel}</div>
</div>
{/* workflow steps (bounded; the live chat fills the rest) */}
<div style={{ flex: sessionId ? 'none' : 1, maxHeight: sessionId ? '40%' : undefined, overflowY: 'auto', minHeight: 0, padding: '12px 14px 14px', display: 'flex', flexDirection: 'column', gap: 7, borderBottom: sessionId ? `1px solid ${c.border.subtle}` : undefined }}>
{steps.map((s, i) => {
const st = stepState(i);
const iconBg = st === 'done' ? c.status.success : st === 'failed' ? c.status.error : st === 'running' ? c.accent.primary : c.bg.secondary;
return (
<div key={s.id} style={{ background: st === 'running' ? c.bg.elevated : 'transparent', border: `1px solid ${st === 'running' ? c.border.medium : c.border.subtle}`, borderRadius: c.radius.md, padding: '10px 12px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{ width: 22, height: 22, borderRadius: '50%', flex: 'none', background: iconBg, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{st === 'done' && <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="3.2"><path d="M5 12l5 5L20 6" /></svg>}
{st === 'running' && <div style={{ width: 10, height: 10, borderRadius: '50%', border: '2px solid rgba(255,255,255,0.5)', borderTopColor: '#fff', animation: 'os-spin 0.7s linear infinite' }} />}
{st === 'failed' && <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="3.2"><path d="M6 6l12 12M18 6L6 18" /></svg>}
</div>
<span style={{ flex: 1, minWidth: 0, fontSize: 13, fontWeight: 500, color: st === 'pending' ? c.text.tertiary : c.text.primary, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{s.label || s.text.slice(0, 48)}</span>
{st === 'done' && <span style={{ fontFamily: 'ui-monospace, monospace', fontSize: 10, color: c.text.tertiary, flex: 'none' }}>done</span>}
</div>
{st === 'running' && run?.last_tool_label && (
<div style={{ marginTop: 8, paddingLeft: 32, display: 'flex', alignItems: 'flex-start', gap: 8 }}>
<div style={{ width: 5, height: 5, borderRadius: '50%', background: c.accent.primary, marginTop: 6, flex: 'none' }} />
<span style={{ fontSize: 11.5, color: c.text.secondary, lineHeight: 1.45 }}>{run.last_tool_label}</span>
</div>
)}
</div>
);
})}
{total === 0 && <div style={{ fontSize: 12.5, color: c.text.tertiary }}>This workflow has no runnable steps.</div>}
</div>
{/* live transcript: read-only (prompts we send, agent responses, tool calls). Reuses AgentChat. */}
{sessionId ? (
<div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
<AgentChat sessionId={sessionId} embedded readOnly />
</div>
) : (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 12, color: c.text.tertiary }}>
{isRunning ? 'Waiting for the run to start…' : failed ? 'This run failed before any agent ran.' : 'No agent chat for this run.'}
</div>
)}
{/* footer only while live: Stop fully fails the in-flight run. Once it's
done there are no buttons; the title-bar X closes the card. */}
{isRunning && (
<div style={{ flex: 'none', borderTop: `1px solid ${c.border.subtle}`, background: c.bg.elevated, padding: '11px 14px 13px', display: 'flex', gap: 9 }}>
<button onClick={stopRun} style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 7, background: c.status.errorBg, border: `1px solid ${c.status.error}33`, borderRadius: c.radius.md, padding: 9, fontSize: 13, fontWeight: 600, color: c.status.error, cursor: 'pointer' }}>
<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor"><rect x="5" y="5" width="14" height="14" rx="2" /></svg>
<span>Stop run</span>
</button>
</div>
)}
</div>
);
};
export default RunMonitor;
@@ -29,7 +29,7 @@ export const GRID_GAP = 24;
const GRID_ORIGIN = { x: 40, y: 100 };
const GRID_COLS_FALLBACK = 4;
export type CardType = 'agent' | 'view' | 'browser' | 'note' | 'workflow' | 'workflows-hub' | 'missed_runs';
export type CardType = 'agent' | 'view' | 'browser' | 'note' | 'workflow' | 'workflows-hub' | 'workflows-monitor' | 'missed_runs';
export interface CardPosition {
session_id: string;
@@ -158,6 +158,12 @@ export interface DashboardLayoutState {
pendingFocusWorkflowsHub: boolean;
/** Transient deep-link target: the Workflows card jumps to this workflow's detail on open, then clears it. */
workflowsAppTarget: string | null;
/** Workflow id whose live run is being watched in the Run Monitor card docked beside the window. Null = closed. */
workflowsMonitorId: string | null;
/** Specific run id to show in the monitor (e.g. clicked from history); null = follow the latest run. */
workflowsMonitorRunId: string | null;
/** Geometry of the spawned Run Monitor card (a real canvas card, tethered to the window). Ephemeral, not persisted. */
workflowsMonitorCard: WorkflowsHubPosition | null;
}
const initialState: DashboardLayoutState = {
@@ -185,6 +191,9 @@ const initialState: DashboardLayoutState = {
pendingFocusMissedRuns: false,
pendingFocusWorkflowsHub: false,
workflowsAppTarget: null,
workflowsMonitorId: null,
workflowsMonitorRunId: null,
workflowsMonitorCard: null,
};
interface LayoutPayload {
@@ -497,12 +506,14 @@ const dashboardLayoutSlice = createSlice({
for (const c of Object.values(state.workflowCards)) tally(c.zOrder);
for (const n of Object.values(state.notes)) tally(n.zOrder);
if (state.workflowsHub) tally(state.workflowsHub.zOrder);
if (state.workflowsMonitorCard) tally(state.workflowsMonitorCard.zOrder);
if (state.missedRunsCard) tally(state.missedRunsCard.zOrder);
if (type === 'agent') currentZ = state.cards[id]?.zOrder ?? 0;
else if (type === 'view') currentZ = state.viewCards[id]?.zOrder ?? 0;
else if (type === 'note') currentZ = state.notes[id]?.zOrder ?? 0;
else if (type === 'workflow') currentZ = state.workflowCards[id]?.zOrder ?? 0;
else if (type === 'workflows-hub') currentZ = state.workflowsHub?.zOrder ?? 0;
else if (type === 'workflows-monitor') currentZ = state.workflowsMonitorCard?.zOrder ?? 0;
else if (type === 'missed_runs') currentZ = state.missedRunsCard?.zOrder ?? 0;
else currentZ = state.browserCards[id]?.zOrder ?? 0;
if (currentZ >= maxZ) return; // Already on top: no-op.
@@ -522,6 +533,8 @@ const dashboardLayoutSlice = createSlice({
if (card) card.zOrder = z;
} else if (type === 'workflows-hub') {
if (state.workflowsHub) state.workflowsHub.zOrder = z;
} else if (type === 'workflows-monitor') {
if (state.workflowsMonitorCard) state.workflowsMonitorCard.zOrder = z;
} else if (type === 'missed_runs') {
if (state.missedRunsCard) state.missedRunsCard.zOrder = z;
} else {
@@ -1008,12 +1021,48 @@ const dashboardLayoutSlice = createSlice({
closeWorkflowsApp(state) {
state.workflowsHub = null;
state.workflowsAppTarget = null;
state.workflowsMonitorId = null;
state.workflowsMonitorRunId = null;
state.workflowsMonitorCard = null;
},
clearWorkflowsAppTarget(state) {
state.workflowsAppTarget = null;
},
// Spawn the Run Monitor as a real canvas card to the right of the window,
// tethered back to it. Reuses the window's geometry to place + size it.
// runId pins a specific (e.g. history) run; omit it to follow the latest.
openWorkflowMonitor(state, action: PayloadAction<{ workflowId: string; runId?: string }>) {
state.workflowsMonitorId = action.payload.workflowId;
state.workflowsMonitorRunId = action.payload.runId ?? null;
const hub = state.workflowsHub;
// Keep the existing card position when just switching the run shown.
if (!state.workflowsMonitorCard) {
state.workflowsMonitorCard = {
x: hub ? hub.x + hub.width + 96 : 220,
y: hub ? hub.y : 160,
width: 520,
height: hub ? hub.height : 560,
zOrder: state.nextZOrder++,
};
} else {
state.workflowsMonitorCard.zOrder = state.nextZOrder++;
}
},
closeWorkflowMonitor(state) {
state.workflowsMonitorId = null;
state.workflowsMonitorRunId = null;
state.workflowsMonitorCard = null;
},
setWorkflowsMonitorPosition(state, action: PayloadAction<{ x: number; y: number }>) {
if (!state.workflowsMonitorCard) return;
state.workflowsMonitorCard.x = action.payload.x;
state.workflowsMonitorCard.y = action.payload.y;
},
setWorkflowsHubPosition(state, action: PayloadAction<{ x: number; y: number }>) {
if (!state.workflowsHub) return;
state.workflowsHub.x = action.payload.x;
@@ -1519,6 +1568,9 @@ export const {
openWorkflowsApp,
closeWorkflowsApp,
clearWorkflowsAppTarget,
openWorkflowMonitor,
closeWorkflowMonitor,
setWorkflowsMonitorPosition,
setWorkflowsHubPosition,
setWorkflowsHubSize,
clearPendingFocusWorkflowsHub,
+6 -1
View File
@@ -741,6 +741,11 @@ class WebSocketManager {
// or close themselves.
const watchedSidecar = Object.values(store.getState().workflows.openCards)
.some((oc) => oc.sidecarSessionId === session_id);
// The Run Monitor watches a run via its workflow id, not a sidecar:
// keep that run's session so the transcript survives completion.
const monWf = store.getState().dashboardLayout.workflowsMonitorId;
const watchedByMonitor = !!monWf
&& (store.getState().workflows.runs[monWf] || []).some((r) => r.session_id === session_id);
store.dispatch(closeSessionFromWs({
id: session_id,
name: data.name ?? 'Untitled',
@@ -751,7 +756,7 @@ class WebSocketManager {
closed_at: data.closed_at ?? new Date().toISOString(),
cost_usd: data.cost_usd ?? 0,
dashboard_id: data.dashboard_id,
keepSession: watchedSidecar,
keepSession: watchedSidecar || watchedByMonitor,
}));
// Auto-delete browsers spawned by this agent when it finishes
// normally or errors out. We intentionally skip 'stopped' , the