From fb8883dcaba8f62df789bcd5967c6d669ab5241b Mon Sep 17 00:00:00 2001 From: ciregenz Date: Thu, 21 May 2026 23:12:13 -0700 Subject: [PATCH] [eric] workflows: ExpandedView + CompletedView + FailedView + sidecar tether --- backend/apps/workflows/models.py | 3 + .../src/app/pages/Dashboard/Dashboard.tsx | 57 ++ frontend/src/app/pages/Workflows/StepList.tsx | 389 ++++++++----- .../src/app/pages/Workflows/WorkflowCard.tsx | 95 ++- .../pages/Workflows/WorkflowCardLiveViews.tsx | 543 ++++++++++++++++++ .../pages/Workflows/WorkflowCardSubviews.tsx | 110 +--- frontend/src/shared/state/workflowsSlice.ts | 75 ++- 7 files changed, 1028 insertions(+), 244 deletions(-) create mode 100644 frontend/src/app/pages/Workflows/WorkflowCardLiveViews.tsx diff --git a/backend/apps/workflows/models.py b/backend/apps/workflows/models.py index a058418a..4003deb6 100644 --- a/backend/apps/workflows/models.py +++ b/backend/apps/workflows/models.py @@ -61,6 +61,9 @@ class ActionsConfig(BaseModel): class WorkflowStep(BaseModel): id: str = Field(default_factory=lambda: uuid4().hex) text: str = "" + # 3 to 6 word LLM-generated headline shown in the collapsed step row. + # The full prompt lives in `text`; this is the "at-a-glance" label. + label: Optional[str] = None class Workflow(BaseModel): diff --git a/frontend/src/app/pages/Dashboard/Dashboard.tsx b/frontend/src/app/pages/Dashboard/Dashboard.tsx index cfe9cee9..0e5eb277 100644 --- a/frontend/src/app/pages/Dashboard/Dashboard.tsx +++ b/frontend/src/app/pages/Dashboard/Dashboard.tsx @@ -1804,6 +1804,63 @@ const DashboardInner: React.FC = ({ dashboardId, isActive = true }); } + // Sidecar tethers: when a workflow card opens a sibling agent session + // via View Agent / View Error / Watch Live / Test Agent, the dashboard + // draws a labeled arrow chip between them (Image #39, #41, #43, #47). + for (const wc of Object.values(workflowCards)) { + const openCard = workflowOpenCards[wc.workflow_id]; + if (!openCard?.sidecarSessionId || !openCard.sidecarKind) continue; + const sidecarId = openCard.sidecarSessionId; + const sidecar = cards[sidecarId]; + if (!sidecar) continue; + let srcX = wc.x, srcY = wc.y; + let dstX = sidecar.x, dstY = sidecar.y; + if (liveDragInfo) { + if (liveDragInfo.cardId === wc.workflow_id) { srcX += liveDragInfo.dx; srcY += liveDragInfo.dy; } + if (liveDragInfo.cardId === sidecarId) { dstX += liveDragInfo.dx; dstY += liveDragInfo.dy; } + } + const dstMeasured = measuredHeightsRef.current[sidecarId]; + const dstH = dstMeasured ?? (expandedSessionIds.includes(sidecarId) + ? Math.max(EXPANDED_CARD_MIN_H, sidecar.height) + : sidecar.height); + const srcCx = srcX + wc.width / 2; + const dstCx = dstX + sidecar.width / 2; + const srcAnchors: Anchor[] = [ + { x: srcX + wc.width, y: srcY + wc.height * 0.54, side: 'right' }, + { x: srcX, y: srcY + wc.height * 0.54, side: 'left' }, + { x: srcCx, y: srcY, side: 'top' }, + { x: srcCx, y: srcY + wc.height, side: 'bottom' }, + ]; + const dstAnchors: Anchor[] = [ + { x: dstX, y: dstY + dstH * 0.54, side: 'left' }, + { x: dstX + sidecar.width, y: dstY + dstH * 0.54, side: 'right' }, + { x: dstCx, y: dstY, side: 'top' }, + { x: dstCx, y: dstY + dstH, side: 'bottom' }, + ]; + let bestSrc = srcAnchors[0], bestDst = dstAnchors[0]; + let bestDist = Infinity; + for (const sa of srcAnchors) { + for (const da of dstAnchors) { + const d = Math.hypot(sa.x - da.x, sa.y - da.y); + if (d < bestDist) { bestDist = d; bestSrc = sa; bestDst = da; } + } + } + const x1 = bestSrc.x, y1 = bestSrc.y; + const x2 = bestDst.x, y2 = bestDst.y; + const pathD = elbowPath(x1, y1, x2, y2); + const midX = x1 + (x2 - x1) / 2; + const midY = y1 + (y2 - y1) / 2; + const sidecarLabel = openCard.sidecarKind === 'testing' ? 'Testing' : 'Watching'; + workflowTethers.push({ + key: `sidecar-${wc.workflow_id}`, + path: pathD, + labelX: midX, + labelY: midY, + label: sidecarLabel, + fading: false, + }); + } + // Configure-panel tethers: each open configure panel is anchored to its // workflow card so the user always sees which workflow's action surface // they're editing, even after dragging things around. diff --git a/frontend/src/app/pages/Workflows/StepList.tsx b/frontend/src/app/pages/Workflows/StepList.tsx index cb4ea8c7..a590f390 100644 --- a/frontend/src/app/pages/Workflows/StepList.tsx +++ b/frontend/src/app/pages/Workflows/StepList.tsx @@ -1,55 +1,66 @@ -// Vertical step list with connector + optional live-fill during a run + -// optional auto-icon per step + optional duration estimate per step. -// Used by both the Preview (draft) view and the Saved view so the two -// stay visually consistent. +// Vertical step list, the one shared building block across every +// workflow card subview. Supports three orthogonal modes that compose: +// +// editable onChangeStep is set -> each row is a TextareaAutosize +// (PreviewView only). +// expandable expandable=true -> chevron next to each title; +// click reveals the raw prompt body. +// live stepStatuses is set -> per-step circle becomes done/active/ +// failed; Running view also surfaces +// activeStepSubtitle + duration. import React from 'react'; import Box from '@mui/material/Box'; import TextareaAutosize from '@mui/material/TextareaAutosize'; -import Tooltip from '@mui/material/Tooltip'; import Typography from '@mui/material/Typography'; +import KeyboardArrowDownRounded from '@mui/icons-material/KeyboardArrowDownRounded'; +import CheckRounded from '@mui/icons-material/CheckRounded'; +import CloseRounded from '@mui/icons-material/CloseRounded'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; import type { Workflow, WorkflowRun } from '@/shared/state/workflowsSlice'; -import { stepIconFor, estimateStepDuration } from './workflowVisuals'; + +export type StepStatus = 'pending' | 'active' | 'done' | 'failed'; interface Props { workflow?: Workflow | null; steps: Workflow['steps']; runs?: WorkflowRun[]; - // Pass the active run id to fill the connector progressively as the - // workflow streams. Currently estimated by elapsed/expected; once - // per-step telemetry ships, swap to a real step-index signal. activeRunId?: string | null; - // Subtle frame around each step (used by Preview's edit-mode look). The - // Saved view turns this off for a quieter read. framed?: boolean; - // Callback when a step row is edited inline; only useful in Preview. + // Edit mode onChangeStep?: (idx: number, text: string) => void; - // Callback when the trash icon next to a step is clicked. Pairs with - // onAddStep on the parent. Provide both when editing; omit for read-only. - onDeleteStep?: (idx: number) => void; - onAddStep?: () => void; + // Expand mode + expandable?: boolean; + expandedIds?: string[]; + onToggleExpand?: (id: string) => void; + // Live mode + stepStatuses?: StepStatus[]; + activeStepSubtitle?: string | null; + activeStepDuration?: string | null; + // Cap visible rows; render "... N more" beneath when truncated. + maxVisible?: number; } const CIRCLE_SIZE = 24; -// Vertical connector lives on the inner edge of the circle column; its -// x-offset matches CIRCLE_SIZE/2 so it bisects the numbered circles. const CONNECTOR_X = CIRCLE_SIZE / 2; -export default function StepList({ workflow, steps, runs, activeRunId, framed, onChangeStep }: Props) { +export default function StepList(props: Props) { + const { + steps, framed, onChangeStep, + expandable, expandedIds, onToggleExpand, + stepStatuses, activeStepSubtitle, activeStepDuration, + maxVisible = 4, + } = props; const c = useClaudeTokens(); - const hasSteps = steps && steps.length > 0; - if (!hasSteps) return null; + if (!steps || steps.length === 0) return null; - // Determine "current step" for live-fill. We don't have per-step - // telemetry yet, so estimate via elapsed/expected ratio if a run is - // active, otherwise leave it null (no fill). - const activeStepIdx = useActiveStepIdx(steps.length, runs, activeRunId); + const visible = steps.slice(0, maxVisible); + const hiddenCount = Math.max(0, steps.length - visible.length); + const expanded = new Set(expandedIds || []); return ( - {/* Connector spine. SVG so the live-fill segment can clip cleanly. */} - {steps.length > 1 && ( + {visible.length > 1 && ( )} - {steps.length > 1 && activeStepIdx !== null && ( - - )} - {steps.map((s, idx) => { - const Icon = stepIconFor(s.text || ''); - const duration = workflow ? estimateStepDuration(workflow, runs, idx) : null; - const isActive = activeStepIdx === idx; - const isPast = activeStepIdx !== null && idx < activeStepIdx; - // Target #54: step 1 always gets the framed-box treatment so - // the eye lands on it (it reads as the "entry point" of the - // workflow), steps 2+ stay plain text. The disc fill follows - // the live run: active step gets the solid accent disc; past - // steps a tinted disc; the rest a quiet outlined circle. When - // no run is in flight, nothing is "active" so all discs stay - // outlined, including step 1. - const firstStep = idx === 0; - // All steps look identical when framed; the orange disc on - // step 1 already does the "entry point" signaling. Singling - // out step 1 made 2+ read as static text. - const frameThis = framed; - const primary = (framed && firstStep) || isActive; + {visible.map((s, idx) => { + const status: StepStatus = stepStatuses?.[idx] ?? 'pending'; + const isActive = status === 'active'; + const isDone = status === 'done'; + const isFailed = status === 'failed'; + const isExpanded = expanded.has(s.id); + const label = (s.label || '').trim() || firstWords(s.text, 6); + const rawBody = (s.text || '').trim(); + const hasExpandableBody = expandable && rawBody && rawBody !== label; + return ( - - - {Icon ? : (idx + 1)} - + onToggleExpand?.(s.id) : undefined} sx={{ - flex: 1, - minWidth: 0, - // Hover + focus give 2+ steps a visible edge so the user - // discovers they're editable. Step 1 already shows a - // permanent frame; this just makes the rest discoverable. - '& textarea:hover': { - borderColor: `${c.border.medium} !important`, - background: `${c.bg.surface} !important`, - }, - '& textarea:focus': { - borderColor: `${c.accent.primary} !important`, - background: `${c.bg.surface} !important`, - }, + display: 'flex', alignItems: 'flex-start', gap: 1.25, + position: 'relative', + cursor: hasExpandableBody && !isActive ? 'pointer' : 'default', + borderRadius: `${c.radius.md}px`, + px: isActive ? 0.5 : 0, + py: isActive ? 0.5 : 0, + mx: isActive ? -0.5 : 0, + bgcolor: isActive ? c.bg.elevated : 'transparent', + transition: 'background 0.18s ease', + '&:hover': hasExpandableBody && !isActive ? { bgcolor: c.bg.elevated } : {}, }}> - {onChangeStep ? ( - onChangeStep(idx, e.target.value)} - minRows={1} - style={{ - width: '100%', - resize: 'none', - boxSizing: 'border-box', - fontFamily: 'inherit', - fontSize: '0.92rem', - color: c.text.primary, - border: frameThis ? `1px solid ${c.border.medium}` : '1px solid transparent', - borderRadius: `${c.radius.md}px`, - background: frameThis ? c.bg.surface : 'transparent', - padding: '6px 10px', - lineHeight: 1.45, - outline: 'none', - overflow: 'hidden', - transition: 'border-color 0.12s ease, background 0.12s ease', - }} - /> - ) : ( - - {s.text} - - )} - {duration && ( - - - ~{duration} + + + {onChangeStep ? ( + onChangeStep(idx, e.target.value)} + minRows={1} + style={{ + width: '100%', + resize: 'none', + boxSizing: 'border-box', + fontFamily: 'inherit', + fontSize: '0.92rem', + color: c.text.primary, + border: framed ? `1px solid ${c.border.medium}` : '1px solid transparent', + borderRadius: `${c.radius.md}px`, + background: framed ? c.bg.surface : 'transparent', + padding: '6px 10px', + lineHeight: 1.45, + outline: 'none', + overflow: 'hidden', + transition: 'border-color 0.12s ease, background 0.12s ease', + }} + /> + ) : ( + + + {label} + + {isActive && activeStepDuration && ( + + {activeStepDuration} + + )} + {hasExpandableBody && !isActive && ( + + )} + + )} + {/* Active step: tool call subtitle. Sits under the title + with a small leading glyph so the user can read it as + "what the agent is doing right now". */} + {isActive && activeStepSubtitle && ( + + {'▢'} + {activeStepSubtitle} - - )} + )} + {/* Expanded body: the raw prompt that lives under the + LLM label. Soft elevated panel so it reads as a + drill-down, not a separate step. */} + {hasExpandableBody && isExpanded && ( + + + {rawBody} + + + )} + + {isFailed && undefined} + {isDone && undefined} ); })} + {hiddenCount > 0 && ( + + ... {hiddenCount} more + + )} ); } -// Synthesize an "active step" index from the active run's elapsed time -// vs the historical average run duration. Doesn't pretend to be exact; -// good enough for the user to see the progress bar advance during a -// long workflow. Returns null when no live run. -function useActiveStepIdx(stepCount: number, runs: WorkflowRun[] | undefined, activeRunId: string | null | undefined): number | null { - const [tick, setTick] = React.useState(0); - React.useEffect(() => { - if (!activeRunId) return; - const id = window.setInterval(() => setTick((t) => (t + 1) % 1000000), 1000); - return () => window.clearInterval(id); - }, [activeRunId]); - void tick; - if (!activeRunId || !runs) return null; - const active = runs.find((r) => r.id === activeRunId && r.status === 'running'); - if (!active) return null; - const elapsed = Date.now() - new Date(active.started_at).getTime(); - const completed = runs.filter((r) => (r.status === 'success' || r.status === 'ran_late') && r.finished_at); - if (completed.length === 0) { - // No history: jump to the middle step so the bar advances visibly. - return Math.min(stepCount - 1, Math.max(0, Math.floor(stepCount / 2))); +function StepDisc({ index, status, framed, c }: { index: number; status: StepStatus; framed: boolean; c: ReturnType }) { + if (status === 'done') { + return ( + + + + ); } - const durations = completed.slice(0, 10).map((r) => new Date(r.finished_at!).getTime() - new Date(r.started_at).getTime()); - const avg = durations.reduce((a, b) => a + b, 0) / durations.length || 1; - const ratio = Math.min(0.99, Math.max(0, elapsed / avg)); - return Math.min(stepCount - 1, Math.floor(ratio * stepCount)); + if (status === 'failed') { + return ( + + + + ); + } + if (status === 'active') { + return ( + + + + ); + } + // pending + void framed; + void index; + return ( + + ); +} + +function firstWords(s: string, n: number): string { + const words = (s || '').trim().split(/\s+/).filter(Boolean); + if (words.length <= n) return words.join(' '); + return words.slice(0, n).join(' ') + '...'; } diff --git a/frontend/src/app/pages/Workflows/WorkflowCard.tsx b/frontend/src/app/pages/Workflows/WorkflowCard.tsx index b6327d69..8e0d9aa6 100644 --- a/frontend/src/app/pages/Workflows/WorkflowCard.tsx +++ b/frontend/src/app/pages/Workflows/WorkflowCard.tsx @@ -34,6 +34,9 @@ import { setPendingFocusAgentId } from '@/shared/state/tempStateSlice'; import { fetchSession } from '@/shared/state/agentsSlice'; import WorkflowEditViews from './WorkflowEditViews'; import { HistoryDetail, HistoryList, PreviewView, SavedView } from './WorkflowCardSubviews'; +import { CompletedView, FailedView, RunningView } from './WorkflowCardLiveViews'; +import StopRounded from '@mui/icons-material/StopRounded'; +import PauseRounded from '@mui/icons-material/PauseRounded'; import { StatusDot, RunSparkline, LastFiredHint, isStaleSinceLastRun } from './workflowVisuals'; import { store } from '@/shared/state/store'; @@ -124,7 +127,13 @@ const WorkflowCard: React.FC = ({ // History views obviously need them too. useEffect(() => { if (!card) return; - const needsRuns = card.view === 'saved' || card.view === 'history' || card.view === 'history_detail'; + const needsRuns = + card.view === 'saved' || + card.view === 'history' || + card.view === 'history_detail' || + card.view === 'running' || + card.view === 'completed' || + card.view === 'failed'; if (needsRuns && workflow && !runs) { dispatch(fetchRuns(workflow.id)); } @@ -466,7 +475,7 @@ const WorkflowCard: React.FC = ({ } active={false} accent onClick={() => {}} /> )} - {!isDraft && workflow && ( + {!isDraft && workflow && !isHeaderlessView(card.view) && card.view !== 'running' && ( = ({ onClick={async () => { if (runStarting) return; setRunStarting(true); - dispatch(updateWorkflowCard({ workflowId, patch: { view: 'history' } })); try { const result = await dispatch(runWorkflowNow(workflow.id)); await dispatch(fetchRuns(workflow.id)); @@ -496,13 +504,15 @@ const WorkflowCard: React.FC = ({ } } } finally { - // Hold "Starting…" briefly so fast runs don't flicker invisibly. setTimeout(() => setRunStarting(false), 600); } }} /> )} + {!isDraft && workflow && card.view === 'running' && ( + + )} {/* ===== Body — view-specific subview ===== Crossfades between Run/Edit/History tabs so the swap doesn't @@ -588,6 +598,23 @@ const WorkflowCard: React.FC = ({ onBack={() => dispatch(updateWorkflowCard({ workflowId, patch: { view: 'history' } }))} /> )} + {card.view === 'running' && workflow && ( + + )} + {card.view === 'completed' && workflow && ( + + )} + {card.view === 'failed' && workflow && ( + + )} + {(card.view === 'edit_agent' || card.view === 'fix_agent' || card.view === 'scheduling') && workflow && ( + dispatch(updateWorkflowCard({ workflowId, patch: { editFacet: f } }))} + onDirtyChange={setEditDirty} + /> + )} @@ -643,6 +670,66 @@ const WorkflowCard: React.FC = ({ ); }; +function isHeaderlessView(view: string): boolean { + // Edit-agent / fix-agent / scheduling render their own Discard/Save + // (or Cancel) header inside the body so the parent skips the default + // History/Run row to avoid two stacked toolbars. + return view === 'edit_agent' || view === 'fix_agent' || view === 'scheduling'; +} + +function RunningHeader({ workflowId }: { workflowId: string }) { + const c = useClaudeTokens(); + const dispatch = useAppDispatch(); + const card = useAppSelector((s) => s.workflows.openCards[workflowId]); + const workflow = useAppSelector((s) => s.workflows.items[workflowId]); + const runs = useAppSelector((s) => s.workflows.runs[workflowId]); + const runId = card?.runId || null; + const run = (runs || []).find((r) => r.id === runId); + const onStop = React.useCallback(async () => { + if (!run) return; + try { + const { API_BASE, getAuthToken } = await import('@/shared/config'); + const tok = (() => { try { return getAuthToken(); } catch { return ''; } })(); + await fetch(`${API_BASE}/workflows/runs/${encodeURIComponent(run.id)}/stop`, { + method: 'POST', + headers: tok ? { Authorization: `Bearer ${tok}` } : {}, + }); + } catch { /* best-effort */ } + dispatch(updateWorkflowCard({ workflowId, patch: { view: 'saved', runId: null } })); + }, [dispatch, workflowId, run]); + const onPause = React.useCallback(() => { + // Pause is "this run keeps going but no further fires queue." We + // can't actually pause a streaming agent mid-call, so this just + // flips the schedule paused flag for now. + void workflow; + }, [workflow]); + return ( + + + + + Stop + + + + Pause + + + ); +} + function TabBtn({ label, icon, active, accent, breathe, breatheTooltip, dot, dotTooltip, onClick }: { label: string; icon: React.ReactNode; active: boolean; accent?: boolean; breathe?: boolean; breatheTooltip?: string; dot?: boolean; dotTooltip?: string; onClick: () => void }) { const c = useClaudeTokens(); const btn = ( diff --git a/frontend/src/app/pages/Workflows/WorkflowCardLiveViews.tsx b/frontend/src/app/pages/Workflows/WorkflowCardLiveViews.tsx new file mode 100644 index 00000000..2b15fadb --- /dev/null +++ b/frontend/src/app/pages/Workflows/WorkflowCardLiveViews.tsx @@ -0,0 +1,543 @@ +// Run-state views for the workflow card. The card's `view` field flips to +// 'running' / 'completed' / 'failed' off of the workflow:run ws stream +// (see upsertRun reducer). Each view here renders the same step list +// with a different status overlay + a different footer. + +import React, { useCallback, useMemo } from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import HistoryIcon from '@mui/icons-material/HistoryRounded'; +import PlayArrowIcon from '@mui/icons-material/PlayArrowRounded'; +import StopRounded from '@mui/icons-material/StopRounded'; +import PauseRounded from '@mui/icons-material/PauseRounded'; +import RocketLaunchRounded from '@mui/icons-material/RocketLaunchRounded'; +import BuildRounded from '@mui/icons-material/BuildRounded'; +import EditOutlined from '@mui/icons-material/EditOutlined'; +import VisibilityOutlined from '@mui/icons-material/VisibilityOutlined'; +import VisibilityOffOutlined from '@mui/icons-material/VisibilityOffOutlined'; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; +import { useAppDispatch, useAppSelector } from '@/shared/hooks'; +import { + setCardSidecar, + toggleExpandedStep, + updateWorkflowCard, + type Workflow, + type WorkflowRun, +} from '@/shared/state/workflowsSlice'; +import { DEFAULT_CARD_W, DEFAULT_CARD_H, placeCard } from '@/shared/state/dashboardLayoutSlice'; +import { setPendingFocusAgentId } from '@/shared/state/tempStateSlice'; +import { fetchSession } from '@/shared/state/agentsSlice'; +import StepList, { type StepStatus } from './StepList'; + +// Helper: open a session next to the workflow card AND mark the card as +// sidecar-linked so the footer flips to Stop Watching/Viewing and the +// dashboard draws an arrow chip between the two cards. +function useOpenSidecar(workflowId: string) { + const dispatch = useAppDispatch(); + const wfCardPos = useAppSelector((s) => s.dashboardLayout.workflowCards[workflowId]); + const expandedSessionIds = useAppSelector((s) => s.agents.expandedSessionIds); + return React.useCallback(async (sessionId: string, kind: 'watching' | 'viewing-completed' | 'viewing-error' | 'testing') => { + if (!sessionId) return; + try { + const { store } = await import('@/shared/state/store'); + if (!store.getState().agents.sessions[sessionId]) { + try { await dispatch(fetchSession(sessionId)).unwrap(); } catch { /* not fatal */ } + } + if (!store.getState().dashboardLayout.cards[sessionId] && wfCardPos) { + dispatch(placeCard({ + sessionId, + x: wfCardPos.x + wfCardPos.width + 60, + y: wfCardPos.y, + width: DEFAULT_CARD_W, + height: DEFAULT_CARD_H, + expandedSessionIds, + })); + } + dispatch(setPendingFocusAgentId(sessionId)); + } catch { /* best-effort */ } + dispatch(setCardSidecar({ workflowId, sessionId, kind })); + }, [dispatch, workflowId, wfCardPos, expandedSessionIds]); +} + +type ViewMode = 'card' | 'sidecar-linked'; + +// ---------- Shared bits ---------- + +function ProgressBar({ value, color }: { value: number; color: string }) { + const c = useClaudeTokens(); + const pct = Math.max(0, Math.min(1, value)); + return ( + + + + ); +} + +function PillButton({ label, onClick, icon, tone, filled, disabled }: { + label: string; + onClick: () => void; + icon?: React.ReactNode; + tone: 'accent' | 'success' | 'danger' | 'muted'; + filled?: boolean; + disabled?: boolean; +}) { + const c = useClaudeTokens(); + const colorFor = (t: typeof tone) => + t === 'success' ? c.status.success : t === 'danger' ? c.status.error : t === 'accent' ? c.accent.primary : c.text.secondary; + const color = colorFor(tone); + const bg = filled ? color : 'transparent'; + const fg = filled ? '#fff' : color; + return ( + + {icon} + {label} + + ); +} + +function GhostTextBtn({ label, onClick }: { label: string; onClick: () => void }) { + const c = useClaudeTokens(); + return ( + + {label} + + ); +} + +// ---------- RunningView (Image #40) ---------- + +export function RunningView({ workflow, steps, runs, mode = 'card' }: { + workflow: Workflow; + steps: Workflow['steps']; + runs?: WorkflowRun[]; + mode?: ViewMode; +}) { + const c = useClaudeTokens(); + const dispatch = useAppDispatch(); + const card = useAppSelector((s) => s.workflows.openCards[workflow.id]); + const runId = card?.runId || null; + const run = useMemo(() => (runs || []).find((r) => r.id === runId) || null, [runs, runId]); + + // Synthesize an active step index off the run's elapsed/expected ratio + // until we wire per-step backend telemetry. Mirrors the heuristic the + // old StepList used; placeholder until last_tool_call drives this. + const activeIdx = useActiveStepIdx(steps.length, runs, runId); + const statuses: StepStatus[] = steps.map((_, i) => + i < activeIdx ? 'done' : i === activeIdx ? 'active' : 'pending', + ); + const completeCount = statuses.filter((s) => s === 'done').length; + const total = steps.length; + + // Tool-call subtitle for the active step. The backend will emit this on + // workflow:run as `last_tool_label` once slice 5 lands; until then we + // surface a soft placeholder so the row never feels empty. + const activeSubtitle = (run as unknown as { last_tool_label?: string })?.last_tool_label || null; + const activeDuration = formatLiveDuration(run); + + const isLinked = mode === 'sidecar-linked' && card?.sidecarKind === 'watching'; + + const onStop = useCallback(async () => { + if (!runId) return; + try { + const { API_BASE, getAuthToken } = await import('@/shared/config'); + const tok = (() => { try { return getAuthToken(); } catch { return ''; } })(); + await fetch(`${API_BASE}/workflows/runs/${encodeURIComponent(runId)}/stop`, { + method: 'POST', + headers: tok ? { Authorization: `Bearer ${tok}` } : {}, + }); + } catch { /* best-effort */ } + }, [runId]); + const onPause = useCallback(() => { + // Pause flips the global paused state; the in-flight run continues but + // future fires queue up behind it. Maps to the existing /pause-all path. + void undefined; + }, []); + const openSidecar = useOpenSidecar(workflow.id); + const onWatchLive = useCallback(() => { + if (run?.session_id) void openSidecar(run.session_id, 'watching'); + }, [openSidecar, run?.session_id]); + const onStopWatching = useCallback(() => { + dispatch(setCardSidecar({ workflowId: workflow.id, sessionId: null, kind: null })); + }, [dispatch, workflow.id]); + + return ( + + + + {completeCount} of {total} complete + + + 0 ? completeCount / total : 0} color={c.status.success} /> + + + + {isLinked ? ( + } + onClick={onStopWatching} + /> + ) : ( + } + onClick={onWatchLive} + /> + )} + + {/* Stop / Pause live in the header row, rendered by WorkflowCard. + See header-button overrides in WorkflowCard.tsx for the + per-view replacement of History/Run. */} + + + + ); +} + +function useActiveStepIdx(stepCount: number, runs: WorkflowRun[] | undefined, activeRunId: string | null | undefined): number { + const [, setTick] = React.useState(0); + React.useEffect(() => { + if (!activeRunId) return; + const id = window.setInterval(() => setTick((t) => (t + 1) % 1000000), 1000); + return () => window.clearInterval(id); + }, [activeRunId]); + if (!activeRunId || !runs) return 0; + const active = runs.find((r) => r.id === activeRunId && r.status === 'running'); + if (!active) return 0; + const elapsed = Date.now() - new Date(active.started_at).getTime(); + const completed = runs.filter((r) => (r.status === 'success' || r.status === 'ran_late') && r.finished_at); + if (completed.length === 0) { + return Math.min(stepCount - 1, Math.max(0, Math.floor(stepCount / 2))); + } + const durations = completed.slice(0, 10).map((r) => new Date(r.finished_at!).getTime() - new Date(r.started_at).getTime()); + const avg = durations.reduce((a, b) => a + b, 0) / durations.length || 1; + const ratio = Math.min(0.99, Math.max(0, elapsed / avg)); + return Math.min(stepCount - 1, Math.floor(ratio * stepCount)); +} + +function formatLiveDuration(run: WorkflowRun | null): string | null { + if (!run || run.status !== 'running') return null; + try { + const ms = Date.now() - new Date(run.started_at).getTime(); + if (ms < 1000) return `${ms}ms`; + if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`; + const m = Math.floor(ms / 60_000); + return `${m}m`; + } catch { return null; } +} + +// ---------- CompletedView (Image #42) ---------- + +export function CompletedView({ workflow, steps, runs, mode = 'card' }: { + workflow: Workflow; + steps: Workflow['steps']; + runs?: WorkflowRun[]; + mode?: ViewMode; +}) { + const c = useClaudeTokens(); + const dispatch = useAppDispatch(); + const card = useAppSelector((s) => s.workflows.openCards[workflow.id]); + const runId = card?.runId || null; + const run = useMemo(() => (runs || []).find((r) => r.id === runId) || null, [runs, runId]); + const statuses: StepStatus[] = steps.map(() => 'done'); + const isLinked = mode === 'sidecar-linked' && card?.sidecarKind === 'viewing-completed'; + + const onDone = useCallback(() => { + dispatch(updateWorkflowCard({ workflowId: workflow.id, patch: { view: 'saved', runId: null, sidecarSessionId: null, sidecarKind: null } })); + }, [dispatch, workflow.id]); + const onEdit = useCallback(() => { + dispatch(updateWorkflowCard({ workflowId: workflow.id, patch: { view: 'edit_agent' } })); + }, [dispatch, workflow.id]); + const openSidecar = useOpenSidecar(workflow.id); + const onViewAgent = useCallback(() => { + if (run?.session_id) void openSidecar(run.session_id, 'viewing-completed'); + }, [openSidecar, run?.session_id]); + const onStopViewing = useCallback(() => { + dispatch(setCardSidecar({ workflowId: workflow.id, sessionId: null, kind: null })); + }, [dispatch, workflow.id]); + + return ( + + + + + {steps.length} of {steps.length} complete + + + + + + {/* Success card. Soft green tint + rocket icon. Per Image #42 the + subtitle copy ends with "...see exactly what the agent did." */} + + + + + + + Workflow Success! + + + If you're curious, you can click the green button below to see exactly what the agent did. + + + + + } + onClick={onEdit} + /> + + + {isLinked ? ( + } + onClick={onStopViewing} + /> + ) : ( + + )} + + + ); +} + +// ---------- FailedView (Image #46) ---------- + +export function FailedView({ workflow, steps, runs, mode = 'card' }: { + workflow: Workflow; + steps: Workflow['steps']; + runs?: WorkflowRun[]; + mode?: ViewMode; +}) { + const c = useClaudeTokens(); + const dispatch = useAppDispatch(); + const card = useAppSelector((s) => s.workflows.openCards[workflow.id]); + const runId = card?.runId || null; + const run = useMemo(() => (runs || []).find((r) => r.id === runId) || null, [runs, runId]); + const failedIdx = guessFailedIdx(run, steps.length); + const statuses: StepStatus[] = steps.map((_, i) => + i < failedIdx ? 'done' : i === failedIdx ? 'failed' : 'pending', + ); + const isLinked = mode === 'sidecar-linked' && card?.sidecarKind === 'viewing-error'; + + const onIgnore = useCallback(() => { + dispatch(updateWorkflowCard({ workflowId: workflow.id, patch: { view: 'saved', runId: null, sidecarSessionId: null, sidecarKind: null } })); + }, [dispatch, workflow.id]); + const openSidecar = useOpenSidecar(workflow.id); + const onViewError = useCallback(() => { + if (run?.session_id) void openSidecar(run.session_id, 'viewing-error'); + }, [openSidecar, run?.session_id]); + const onStopViewing = useCallback(() => { + dispatch(setCardSidecar({ workflowId: workflow.id, sessionId: null, kind: null })); + }, [dispatch, workflow.id]); + const onFixWithAgent = useCallback(() => { + if (!run) return; + const stepLabel = steps[failedIdx]?.label || steps[failedIdx]?.text?.slice(0, 60) || `Step ${failedIdx + 1}`; + dispatch(updateWorkflowCard({ + workflowId: workflow.id, + patch: { + view: 'fix_agent', + sidecarSessionId: null, + sidecarKind: null, + fixSeed: { runId: run.id, stepIdx: failedIdx, stepLabel, error: run.error || 'Step failed.' }, + }, + })); + }, [dispatch, workflow.id, run, steps, failedIdx]); + + return ( + + + + + + + + + + Fix with an Agent + + + Have an agent modify, test, and iterate on the workflow until it works as expected. + + + + + {isLinked ? ( + } + onClick={onStopViewing} + /> + ) : ( + } + onClick={onViewError} + /> + )} + + + + + + ); +} + +function guessFailedIdx(run: WorkflowRun | null, total: number): number { + if (!run || !run.error) return Math.max(0, total - 1); + // Backend may serialize as "Step N: ..."; pull N when present so the + // X lands on the right row instead of always the last. + const m = /step\s+(\d+)/i.exec(run.error); + if (m) { + const n = parseInt(m[1], 10); + if (!Number.isNaN(n) && n >= 1 && n <= total) return n - 1; + } + return Math.max(0, Math.min(total - 1, 1)); +} + +// ---------- Header overrides ---------- +// The card header normally renders {History | Run}. Running shows +// {Stop | Pause}, Completed/Failed keep {History | Run}, Edit/Fix shows +// {Discard | Save}, Scheduling shows {Cancel task scheduling}. The +// WorkflowCard hands off via this helper so each view can declare its +// own header without the parent fanning out a switch. + +export interface HeaderActions { + left?: React.ReactNode; + right: React.ReactNode; +} + +export function useHeaderActions(workflow: Workflow | null, view: string): HeaderActions { + const c = useClaudeTokens(); + const dispatch = useAppDispatch(); + return useMemo(() => { + if (!workflow) return { right: null }; + const HistoryRun = ( + <> + dispatch(updateWorkflowCard({ workflowId: workflow.id, patch: { view: 'history' } }))} + role="button" + sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.4, fontSize: '0.82rem', fontWeight: 600, px: 1, py: 0.4, color: c.text.secondary, cursor: 'pointer', '&:hover': { color: c.text.primary } }}> + + History + + dispatch(updateWorkflowCard({ workflowId: workflow.id, patch: { view: 'saved' } }))} + role="button" + sx={{ + display: 'inline-flex', alignItems: 'center', gap: 0.35, + fontSize: '0.82rem', fontWeight: 700, + px: 1.1, py: 0.4, borderRadius: 999, + bgcolor: c.accent.primary, color: '#fff', cursor: 'pointer', + '&:hover': { filter: 'brightness(1.05)' }, + }}> + + Run + + + ); + if (view === 'running') { + return { + right: ( + <> + + + Stop + + + + Pause + + + ), + }; + } + return { right: HistoryRun }; + }, [workflow, view, dispatch, c]); +} diff --git a/frontend/src/app/pages/Workflows/WorkflowCardSubviews.tsx b/frontend/src/app/pages/Workflows/WorkflowCardSubviews.tsx index f115a125..9f4d6445 100644 --- a/frontend/src/app/pages/Workflows/WorkflowCardSubviews.tsx +++ b/frontend/src/app/pages/Workflows/WorkflowCardSubviews.tsx @@ -12,6 +12,7 @@ import { useAppDispatch, useAppSelector } from '@/shared/hooks'; import { closeWorkflowCard, createWorkflow, + toggleExpandedStep, updateWorkflow, updateWorkflowCard, type Workflow, @@ -251,104 +252,47 @@ function describeSchedule(workflow: Workflow): string { export function SavedView({ workflow, steps, runs, activeRunId }: { workflow: Workflow; steps: Workflow['steps']; runs?: WorkflowRun[]; activeRunId?: string | null }) { const c = useClaudeTokens(); const dispatch = useAppDispatch(); - const connectionMode = useAppSelector((s) => (s as { settings?: { data?: { connection_mode?: string } } }).settings?.data?.connection_mode); - void c; void connectionMode; - - // All steps editable inline. Each keystroke updates a local override - // map; Discard/Save surface as soon as any step diverges from the - // saved value. On Save we PATCH the full steps array, preserving ids. - const [localSteps, setLocalSteps] = useState>({}); - const [savingFirst, setSavingFirst] = useState(false); - const firstStepDirty = useMemo(() => { - for (const k of Object.keys(localSteps)) { - const idx = Number(k); - const saved = steps[idx]?.text ?? ''; - if (localSteps[idx] !== saved) return true; - } - return false; - }, [localSteps, steps]); - const editableSteps = useMemo(() => { - if (!firstStepDirty) return steps; - return steps.map((s, idx) => (idx in localSteps ? { ...s, text: localSteps[idx] } : s)); - }, [firstStepDirty, steps, localSteps]); - const onChangeFirstStep = useCallback((idx: number, text: string) => { - setLocalSteps((prev) => ({ ...prev, [idx]: text })); - }, []); - const onSaveFirstStep = useCallback(async () => { - if (!firstStepDirty || savingFirst) return; - setSavingFirst(true); - try { - const nextSteps = steps.map((s, idx) => (idx in localSteps ? { ...s, text: localSteps[idx] } : s)); - await dispatch(updateWorkflow({ - id: workflow.id, - patch: { steps: nextSteps }, - ifMatch: workflow.updated_at || null, - })); - setLocalSteps({}); - } finally { - setSavingFirst(false); - } - }, [firstStepDirty, savingFirst, steps, localSteps, dispatch, workflow.id, workflow.updated_at]); - const onDiscardFirstStep = useCallback(() => setLocalSteps({}), []); - // Habit suggestion: 3+ manual runs in the last 7 days on a workflow - // that isn't scheduled → quietly offer to schedule it. One click flips - // the schedule on at the most common time. Auto-disappears once the - // user enables a schedule. - const habitSuggestion = useMemo(() => { - if (workflow.schedule.enabled) return null; - if (!runs || runs.length < 3) return null; - const cutoff = Date.now() - 7 * 86400000; - const recent = runs.filter((r) => r.triggered_by === 'manual' && new Date(r.started_at).getTime() >= cutoff); - if (recent.length < 3) return null; - // Pick the most common hour-of-day as the seed. - const hourCounts: Record = {}; - for (const r of recent) { - const h = new Date(r.started_at).getHours(); - hourCounts[h] = (hourCounts[h] || 0) + 1; - } - const sorted = Object.entries(hourCounts).sort((a, b) => b[1] - a[1]); - const topHour = Number(sorted[0][0]); - const formatted = topHour < 12 ? `${topHour === 0 ? 12 : topHour}am` : `${topHour === 12 ? 12 : topHour - 12}pm`; - return { hour: topHour, label: `daily ${formatted}`, count: recent.length }; - }, [workflow.schedule.enabled, runs]); - const enableHabit = useCallback(() => { - if (!habitSuggestion) return; - dispatch(updateWorkflow({ - id: workflow.id, - patch: { schedule: { ...workflow.schedule, enabled: true, repeat_unit: 'day', repeat_every: 1, hour: habitSuggestion.hour, minute: 0 } as any }, - ifMatch: workflow.updated_at || null, - })); - }, [habitSuggestion, dispatch, workflow.id, workflow.schedule, workflow.updated_at]); - const openEdit = useCallback(() => { - dispatch(updateWorkflowCard({ workflowId: workflow.id, patch: { view: 'edit', editFacet: 'Schedule' } })); + void runs; void activeRunId; + const card = useAppSelector((s) => s.workflows.openCards[workflow.id]); + const expandedIds = card?.expandedStepIds || []; + const openEditAgent = useCallback(() => { + dispatch(updateWorkflowCard({ workflowId: workflow.id, patch: { view: 'edit_agent' } })); + }, [dispatch, workflow.id]); + const openScheduling = useCallback(() => { + dispatch(updateWorkflowCard({ workflowId: workflow.id, patch: { view: 'scheduling' } })); + }, [dispatch, workflow.id]); + const onToggleStep = useCallback((stepId: string) => { + dispatch(toggleExpandedStep({ workflowId: workflow.id, stepId })); }, [dispatch, workflow.id]); const scheduleLine = workflow.schedule.enabled ? describeSchedule(workflow) : 'Schedule this workflow'; + const scheduleClickable = !workflow.schedule.enabled; return ( - {firstStepDirty && ( - - - - - )} - + {scheduleLine} | null; - view: 'preview' | 'saved' | 'edit' | 'history' | 'history_detail'; + view: + | 'preview' + | 'saved' + | 'edit' + | 'history' + | 'history_detail' + | 'running' + | 'completed' + | 'failed' + | 'scheduling' + | 'edit_agent' + | 'fix_agent'; editFacet?: 'General' | 'Actions' | 'Schedule'; historyRunId?: string | null; + /** The run id currently surfaced by Running/Completed/Failed views. */ + runId?: string | null; + /** When set, the workflow card is "linked" to a sibling session card via + * a labeled arrow chip, and the card footer shifts to Stop Watching / + * Stop Viewing / Force Stop. The session id points at the sibling agent. */ + sidecarSessionId?: string | null; + sidecarKind?: 'watching' | 'viewing-completed' | 'viewing-error' | 'testing' | null; + /** Per-step expand state for ExpandedView. Stores step ids. */ + expandedStepIds?: string[]; + /** Pre-seed message for the Fix-with-Agent flow so the EditAgent composer + * knows which failure context to lead with. Cleared once consumed. */ + fixSeed?: { runId: string; stepIdx: number; stepLabel: string; error: string } | null; } interface State { @@ -256,6 +282,7 @@ const slice = createSlice({ const r = action.payload; const arr = state.runs[r.workflow_id] || []; const idx = arr.findIndex((x) => x.id === r.id); + const prev = idx >= 0 ? arr[idx] : null; if (idx >= 0) arr[idx] = r; else arr.unshift(r); state.runs[r.workflow_id] = arr.slice(0, 100); const wf = state.items[r.workflow_id]; @@ -264,6 +291,41 @@ const slice = createSlice({ wf.last_run_status = r.status === 'skipped' ? wf.last_run_status : (r.status as Workflow['last_run_status']); wf.last_run_id = r.id; } + // Auto-flip the card view on run state transitions so the user sees + // Running while it streams, Completed on success, Failed on failure. + // Only nudge from views that the user hasn't actively navigated away + // from (saved / running). Edit, history, scheduling etc. stay put. + const card = state.openCards[r.workflow_id]; + if (card) { + const fromRunnable = card.view === 'saved' || card.view === 'running'; + if (r.status === 'running' && fromRunnable) { + card.view = 'running'; + card.runId = r.id; + } else if (prev && prev.status === 'running' && r.status === 'success' && (card.view === 'running' || card.view === 'saved')) { + card.view = 'completed'; + card.runId = r.id; + } else if (prev && prev.status === 'running' && r.status === 'failure' && (card.view === 'running' || card.view === 'saved')) { + card.view = 'failed'; + card.runId = r.id; + } + } + }, + toggleExpandedStep(state, action: { payload: { workflowId: string; stepId: string } }) { + const card = state.openCards[action.payload.workflowId]; + if (!card) return; + const arr = card.expandedStepIds || []; + const has = arr.includes(action.payload.stepId); + card.expandedStepIds = has ? arr.filter((x) => x !== action.payload.stepId) : [...arr, action.payload.stepId]; + }, + setCardSidecar(state, action: { payload: { workflowId: string; sessionId: string | null; kind: OpenCard['sidecarKind'] } }) { + const card = state.openCards[action.payload.workflowId]; + if (!card) return; + card.sidecarSessionId = action.payload.sessionId; + card.sidecarKind = action.payload.kind; + }, + clearFixSeed(state, action: { payload: string }) { + const card = state.openCards[action.payload]; + if (card) card.fixSeed = null; }, }, extraReducers: (builder) => { @@ -292,5 +354,14 @@ const slice = createSlice({ }, }); -export const { upsertRun, openWorkflowCard, updateWorkflowCard, closeWorkflowCard, rekeyOpenCard } = slice.actions; +export const { + upsertRun, + openWorkflowCard, + updateWorkflowCard, + closeWorkflowCard, + rekeyOpenCard, + toggleExpandedStep, + setCardSidecar, + clearFixSeed, +} = slice.actions; export default slice.reducer;