From fac7f46433f57725462b107a5eec55344364248f Mon Sep 17 00:00:00 2001 From: abccodes Date: Mon, 22 Jun 2026 23:58:38 -0700 Subject: [PATCH] [aidan] feat/run-monitor: live run monitor card on the canvas --- .../Dashboard/canvas/DashboardCardLayer.tsx | 29 +- .../Dashboard/geometry/dashboardTethers.ts | 44 ++- .../interaction/useDashboardInteractions.ts | 6 + .../hooks/state/useDashboardController.ts | 16 ++ .../app/pages/Workflows/app/HistoryCard.tsx | 12 +- .../app/pages/Workflows/app/RunMonitor.tsx | 256 ++++++++++++++++++ .../src/shared/state/dashboardLayoutSlice.ts | 54 +++- frontend/src/shared/ws/WebSocketManager.ts | 7 +- 8 files changed, 413 insertions(+), 11 deletions(-) create mode 100644 frontend/src/app/pages/Workflows/app/RunMonitor.tsx diff --git a/frontend/src/app/pages/Dashboard/canvas/DashboardCardLayer.tsx b/frontend/src/app/pages/Dashboard/canvas/DashboardCardLayer.tsx index 167076bf..9fca7751 100644 --- a/frontend/src/app/pages/Dashboard/canvas/DashboardCardLayer.tsx +++ b/frontend/src/app/pages/Dashboard/canvas/DashboardCardLayer.tsx @@ -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 = ({ // 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 ( <> @@ -287,6 +298,22 @@ const DashboardCardLayer: React.FC = ({ onBringToFront={onBringToFront} /> )} + {monitorCard && monitorWorkflow && ( + + )} {/* Marquee selection rectangle */} {selection.marquee && (
>; 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]); } diff --git a/frontend/src/app/pages/Dashboard/hooks/interaction/useDashboardInteractions.ts b/frontend/src/app/pages/Dashboard/hooks/interaction/useDashboardInteractions.ts index ddaf5223..544f9ccd 100644 --- a/frontend/src/app/pages/Dashboard/hooks/interaction/useDashboardInteractions.ts +++ b/frontend/src/app/pages/Dashboard/hooks/interaction/useDashboardInteractions.ts @@ -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) { diff --git a/frontend/src/app/pages/Dashboard/hooks/state/useDashboardController.ts b/frontend/src/app/pages/Dashboard/hooks/state/useDashboardController.ts index e37e4233..89f0757d 100644 --- a/frontend/src/app/pages/Dashboard/hooks/state/useDashboardController.ts +++ b/frontend/src/app/pages/Dashboard/hooks/state/useDashboardController.ts @@ -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 { diff --git a/frontend/src/app/pages/Workflows/app/HistoryCard.tsx b/frontend/src/app/pages/Workflows/app/HistoryCard.tsx index 18aa5a1e..aafaafc3 100644 --- a/frontend/src/app/pages/Workflows/app/HistoryCard.tsx +++ b/frontend/src/app/pages/Workflows/app/HistoryCard.tsx @@ -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 ( -
+
History
{rows.length === 0 &&
No runs yet.
}
{rows.map((r) => ( -
-
+
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' }}> +
{r.summary}
{whenText(r.when, now)}{r.durationText ? ` · ${r.durationText}` : ''}
- {statusLabel(r.status)} + {statusLabel(r.status)}
))}
diff --git a/frontend/src/app/pages/Workflows/app/RunMonitor.tsx b/frontend/src/app/pages/Workflows/app/RunMonitor.tsx new file mode 100644 index 00000000..651f92f6 --- /dev/null +++ b/frontend/src/app/pages/Workflows/app/RunMonitor.tsx @@ -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 = ({ 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 ( +
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) */} +
+
+ {workflow.title || 'Untitled workflow'} + {headStatus} +
+ {clock} + +
+ + {/* progress subhead */} +
+
+ {kindLabel(run)} + {pct}% +
+
+
+
+
{progressLabel}
+
+ + {/* workflow steps (bounded; the live chat fills the rest) */} +
+ {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 ( +
+
+
+ {st === 'done' && } + {st === 'running' &&
} + {st === 'failed' && } +
+ {s.label || s.text.slice(0, 48)} + {st === 'done' && done} +
+ {st === 'running' && run?.last_tool_label && ( +
+
+ {run.last_tool_label} +
+ )} +
+ ); + })} + {total === 0 &&
This workflow has no runnable steps.
} +
+ + {/* live transcript: read-only (prompts we send, agent responses, tool calls). Reuses AgentChat. */} + {sessionId ? ( +
+ +
+ ) : ( +
+ {isRunning ? 'Waiting for the run to start…' : failed ? 'This run failed before any agent ran.' : 'No agent chat for this run.'} +
+ )} + + {/* 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 && ( +
+ +
+ )} +
+ ); +}; + +export default RunMonitor; diff --git a/frontend/src/shared/state/dashboardLayoutSlice.ts b/frontend/src/shared/state/dashboardLayoutSlice.ts index 8858260e..ac815afa 100644 --- a/frontend/src/shared/state/dashboardLayoutSlice.ts +++ b/frontend/src/shared/state/dashboardLayoutSlice.ts @@ -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, diff --git a/frontend/src/shared/ws/WebSocketManager.ts b/frontend/src/shared/ws/WebSocketManager.ts index 141fa433..3df3c8f4 100644 --- a/frontend/src/shared/ws/WebSocketManager.ts +++ b/frontend/src/shared/ws/WebSocketManager.ts @@ -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