mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-11 20:27:44 +02:00
[aidan] feat/run-monitor: live run monitor card on the canvas
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user