[eric] workflows: ExpandedView + CompletedView + FailedView + sidecar tether

This commit is contained in:
ciregenz
2026-05-21 23:12:13 -07:00
parent 1436389790
commit fb8883dcab
7 changed files with 1028 additions and 244 deletions
+3
View File
@@ -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):
@@ -1804,6 +1804,63 @@ const DashboardInner: React.FC<DashboardProps> = ({ 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.
+234 -155
View File
@@ -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 (
<Box sx={{ position: 'relative', pl: 0, mt: 0.25 }}>
{/* Connector spine. SVG so the live-fill segment can clip cleanly. */}
{steps.length > 1 && (
{visible.length > 1 && (
<Box
aria-hidden
sx={{
@@ -63,148 +74,216 @@ export default function StepList({ workflow, steps, runs, activeRunId, framed, o
}}
/>
)}
{steps.length > 1 && activeStepIdx !== null && (
<Box
aria-hidden
sx={{
position: 'absolute',
left: CONNECTOR_X - 1,
top: CIRCLE_SIZE * 0.5,
// Progress = (active+1)/total, capped at total-1 so the fill
// never overshoots the bottom circle.
height: `calc((100% - ${CIRCLE_SIZE}px) * ${Math.min(steps.length - 1, activeStepIdx) / (steps.length - 1)})`,
width: 2,
bgcolor: c.accent.primary,
transition: 'height 0.4s ease-out',
boxShadow: `0 0 6px ${c.accent.primary}`,
}}
/>
)}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.85 }}>
{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 (
<Box key={s.id} sx={{ display: 'flex', alignItems: 'flex-start', gap: 1.25, position: 'relative' }}>
<Box sx={{
width: CIRCLE_SIZE, height: CIRCLE_SIZE, borderRadius: '50%',
border: `1px solid ${primary || isPast ? c.accent.primary : c.border.medium}`,
bgcolor: primary ? c.accent.primary : isPast ? c.accent.primary + '22' : c.bg.surface,
color: primary ? '#fff' : isPast ? c.accent.primary : c.text.muted,
fontSize: '0.74rem', fontWeight: 600,
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0,
position: 'relative', zIndex: 1,
lineHeight: 1,
fontVariantNumeric: 'tabular-nums',
transition: 'background 0.25s ease, color 0.25s ease',
}}>
{Icon ? <Icon sx={{ fontSize: 13 }} /> : (idx + 1)}
</Box>
<Box key={s.id} sx={{ display: 'flex', flexDirection: 'column' }}>
<Box
onClick={hasExpandableBody && !isActive ? () => 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 ? (
<TextareaAutosize
value={s.text}
onChange={(e) => 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',
}}
/>
) : (
<Box sx={{
fontSize: '0.92rem', color: c.text.primary,
border: frameThis ? `1px solid ${c.border.medium}` : 'none',
borderRadius: frameThis ? `${c.radius.md}px` : 0,
bgcolor: frameThis ? c.bg.surface : 'transparent',
px: frameThis ? 1.25 : 0, py: frameThis ? 0.75 : 0.1,
lineHeight: 1.45,
}}>
{s.text}
</Box>
)}
{duration && (
<Tooltip title="Estimated from recent successful runs (whole-run duration divided by step count).">
<Typography sx={{ fontSize: '0.7rem', color: c.text.ghost, mt: 0.25, ml: framed ? 1.25 : 0.5 }}>
~{duration}
<StepDisc
index={idx}
status={status}
framed={!!framed}
c={c}
/>
<Box sx={{ flex: 1, minWidth: 0 }}>
{onChangeStep ? (
<TextareaAutosize
value={s.text}
onChange={(e) => 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',
}}
/>
) : (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, minHeight: CIRCLE_SIZE }}>
<Typography sx={{
fontSize: '0.92rem',
fontWeight: isActive ? 600 : 500,
color: c.text.primary,
lineHeight: 1.45,
flex: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}>
{label}
</Typography>
{isActive && activeStepDuration && (
<Typography sx={{ fontSize: '0.78rem', color: c.text.muted, mr: hasExpandableBody ? 0 : 0.5, flexShrink: 0 }}>
{activeStepDuration}
</Typography>
)}
{hasExpandableBody && !isActive && (
<KeyboardArrowDownRounded sx={{
fontSize: 18,
color: c.text.muted,
transform: isExpanded ? 'rotate(180deg)' : 'none',
transition: 'transform 0.18s ease',
flexShrink: 0,
}} />
)}
</Box>
)}
{/* 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 && (
<Typography sx={{
fontSize: '0.82rem',
color: c.text.secondary,
mt: 0.4,
display: 'flex', alignItems: 'center', gap: 0.5,
}}>
<Box component="span" sx={{ display: 'inline-flex', fontSize: 13 }}>{'▢'}</Box>
{activeStepSubtitle}
</Typography>
</Tooltip>
)}
)}
{/* 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 && (
<Box sx={{
mt: 0.6,
p: 1,
borderRadius: `${c.radius.md}px`,
bgcolor: c.bg.elevated,
border: `1px solid ${c.border.subtle}`,
}}>
<Typography sx={{ fontSize: '0.82rem', color: c.text.secondary, lineHeight: 1.5, whiteSpace: 'pre-wrap' }}>
{rawBody}
</Typography>
</Box>
)}
</Box>
</Box>
{isFailed && undefined}
{isDone && undefined}
</Box>
);
})}
</Box>
{hiddenCount > 0 && (
<Typography sx={{
fontSize: '0.86rem',
color: c.text.secondary,
mt: 0.6,
ml: 0,
}}>
... {hiddenCount} more
</Typography>
)}
</Box>
);
}
// 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<typeof useClaudeTokens> }) {
if (status === 'done') {
return (
<Box sx={{
width: CIRCLE_SIZE, height: CIRCLE_SIZE, borderRadius: '50%',
bgcolor: c.text.muted + '55',
color: '#fff',
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0, position: 'relative', zIndex: 1,
}}>
<CheckRounded sx={{ fontSize: 15 }} />
</Box>
);
}
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 (
<Box sx={{
width: CIRCLE_SIZE, height: CIRCLE_SIZE, borderRadius: '50%',
bgcolor: c.status.error,
color: '#fff',
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0, position: 'relative', zIndex: 1,
boxShadow: `0 0 0 3px ${c.status.error}22`,
}}>
<CloseRounded sx={{ fontSize: 15 }} />
</Box>
);
}
if (status === 'active') {
return (
<Box sx={{
width: CIRCLE_SIZE, height: CIRCLE_SIZE, borderRadius: '50%',
border: `2px solid ${c.accent.primary}`,
bgcolor: c.bg.surface,
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0, position: 'relative', zIndex: 1,
animation: 'workflow-step-spin 1.4s linear infinite',
'@keyframes workflow-step-spin': {
'0%': { boxShadow: `0 0 0 0 ${c.accent.primary}55` },
'50%': { boxShadow: `0 0 0 4px ${c.accent.primary}00` },
'100%': { boxShadow: `0 0 0 0 ${c.accent.primary}55` },
},
}}>
<Box sx={{
width: 8, height: 8, borderRadius: '50%',
border: `1.5px solid ${c.accent.primary}`,
borderTopColor: 'transparent',
animation: 'workflow-step-dot 0.9s linear infinite',
'@keyframes workflow-step-dot': {
'0%': { transform: 'rotate(0deg)' },
'100%': { transform: 'rotate(360deg)' },
},
}} />
</Box>
);
}
// pending
void framed;
void index;
return (
<Box sx={{
width: CIRCLE_SIZE, height: CIRCLE_SIZE, borderRadius: '50%',
border: `1px solid ${c.border.medium}`,
bgcolor: c.bg.surface,
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0, position: 'relative', zIndex: 1,
}} />
);
}
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(' ') + '...';
}
@@ -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<Props> = ({
// 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<Props> = ({
<TabBtn label="Run" icon={<PlayArrowIcon sx={{ fontSize: 16 }} />} active={false} accent onClick={() => {}} />
</Box>
)}
{!isDraft && workflow && (
{!isDraft && workflow && !isHeaderlessView(card.view) && card.view !== 'running' && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.6, px: 2, pb: 1.25, pt: 0, flexShrink: 0 }}>
<Box sx={{ flex: 1 }} />
<TabBtn
@@ -485,7 +494,6 @@ const WorkflowCard: React.FC<Props> = ({
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<Props> = ({
}
}
} finally {
// Hold "Starting…" briefly so fast runs don't flicker invisibly.
setTimeout(() => setRunStarting(false), 600);
}
}}
/>
</Box>
)}
{!isDraft && workflow && card.view === 'running' && (
<RunningHeader workflowId={workflowId} />
)}
{/* ===== Body — view-specific subview =====
Crossfades between Run/Edit/History tabs so the swap doesn't
@@ -588,6 +598,23 @@ const WorkflowCard: React.FC<Props> = ({
onBack={() => dispatch(updateWorkflowCard({ workflowId, patch: { view: 'history' } }))}
/>
)}
{card.view === 'running' && workflow && (
<RunningView workflow={workflow} steps={steps} runs={runs} mode={card.sidecarKind === 'watching' ? 'sidecar-linked' : 'card'} />
)}
{card.view === 'completed' && workflow && (
<CompletedView workflow={workflow} steps={steps} runs={runs} mode={card.sidecarKind === 'viewing-completed' ? 'sidecar-linked' : 'card'} />
)}
{card.view === 'failed' && workflow && (
<FailedView workflow={workflow} steps={steps} runs={runs} mode={card.sidecarKind === 'viewing-error' ? 'sidecar-linked' : 'card'} />
)}
{(card.view === 'edit_agent' || card.view === 'fix_agent' || card.view === 'scheduling') && workflow && (
<WorkflowEditViews
workflow={workflow}
facet={card.view === 'scheduling' ? 'Schedule' : (card.editFacet || 'General')}
onChangeFacet={(f) => dispatch(updateWorkflowCard({ workflowId, patch: { editFacet: f } }))}
onDirtyChange={setEditDirty}
/>
)}
</Box>
</Box>
@@ -643,6 +670,66 @@ const WorkflowCard: React.FC<Props> = ({
);
};
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 (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.6, px: 2, pb: 1.25, pt: 0, flexShrink: 0 }}>
<Box sx={{ flex: 1 }} />
<Box
onClick={onStop}
role="button"
sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.35, fontSize: '0.82rem', fontWeight: 600, px: 1, py: 0.4, color: c.text.secondary, cursor: 'pointer', borderRadius: 999, '&:hover': { color: c.text.primary, bgcolor: c.bg.elevated } }}>
<StopRounded sx={{ fontSize: 15 }} />
Stop
</Box>
<Box
onClick={onPause}
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)' },
}}>
<PauseRounded sx={{ fontSize: 15 }} />
Pause
</Box>
</Box>
);
}
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 = (
@@ -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 (
<Box sx={{ width: '100%', height: 4, borderRadius: 999, bgcolor: c.bg.elevated, overflow: 'hidden' }}>
<Box sx={{
width: `${pct * 100}%`, height: '100%', bgcolor: color,
transition: 'width 0.4s ease',
boxShadow: `0 0 6px ${color}66`,
}} />
</Box>
);
}
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 (
<Box
onClick={disabled ? undefined : onClick}
role="button"
sx={{
display: 'inline-flex', alignItems: 'center', gap: 0.5,
fontSize: '0.86rem', fontWeight: 700,
px: 1.4, py: 0.55, borderRadius: 999,
cursor: disabled ? 'not-allowed' : 'pointer',
color: fg, bgcolor: bg,
border: filled ? `1px solid ${color}` : `1px solid ${color}55`,
opacity: disabled ? 0.5 : 1,
'&:hover': { filter: 'brightness(1.05)', bgcolor: filled ? color : color + '14' },
}}>
{icon}
{label}
</Box>
);
}
function GhostTextBtn({ label, onClick }: { label: string; onClick: () => void }) {
const c = useClaudeTokens();
return (
<Box
onClick={onClick}
role="button"
sx={{
fontSize: '0.86rem', fontWeight: 500, color: c.text.secondary,
cursor: 'pointer', px: 0.75, py: 0.5,
'&:hover': { color: c.text.primary },
}}>
{label}
</Box>
);
}
// ---------- 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 (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25, minHeight: '100%' }}>
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 1 }}>
<Typography sx={{ fontSize: '0.92rem', fontWeight: 700, color: c.status.success }}>
{completeCount} of {total} complete
</Typography>
</Box>
<ProgressBar value={total > 0 ? completeCount / total : 0} color={c.status.success} />
<StepList
workflow={workflow}
steps={steps}
stepStatuses={statuses}
activeStepSubtitle={activeSubtitle}
activeStepDuration={activeDuration}
/>
<Box sx={{ flex: 1 }} />
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end' }}>
{isLinked ? (
<PillButton
label="Stop Watching"
tone="danger"
filled={false}
icon={<VisibilityOffOutlined sx={{ fontSize: 16 }} />}
onClick={onStopWatching}
/>
) : (
<PillButton
label="Watch Live"
tone="muted"
filled={false}
icon={<VisibilityOutlined sx={{ fontSize: 16 }} />}
onClick={onWatchLive}
/>
)}
</Box>
{/* 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. */}
<Box sx={{ display: 'none' }} aria-hidden onClick={onStop} />
<Box sx={{ display: 'none' }} aria-hidden onClick={onPause} />
</Box>
);
}
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 (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25, minHeight: '100%' }}>
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.6 }}>
<Box component="span" sx={{ color: c.status.success, fontSize: 18, lineHeight: 1, mr: 0.25 }}></Box>
<Typography sx={{ fontSize: '0.92rem', fontWeight: 700, color: c.status.success }}>
{steps.length} of {steps.length} complete
</Typography>
</Box>
<ProgressBar value={1} color={c.status.success} />
<StepList
workflow={workflow}
steps={steps}
stepStatuses={statuses}
/>
<Box sx={{ flex: 1 }} />
{/* Success card. Soft green tint + rocket icon. Per Image #42 the
subtitle copy ends with "...see exactly what the agent did." */}
<Box sx={{
display: 'flex', alignItems: 'flex-start', gap: 1.25,
p: 1.5, borderRadius: `${c.radius.lg}px`,
bgcolor: c.status.successBg,
border: `1px solid ${c.status.success}30`,
}}>
<Box sx={{
width: 32, height: 32, borderRadius: `${c.radius.md}px`,
bgcolor: c.status.success + '22', color: c.status.success,
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
}}>
<RocketLaunchRounded sx={{ fontSize: 16 }} />
</Box>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography sx={{ fontSize: '0.95rem', fontWeight: 700, color: c.text.primary, lineHeight: 1.3 }}>
Workflow Success!
</Typography>
<Typography sx={{ fontSize: '0.82rem', color: c.text.secondary, mt: 0.25, lineHeight: 1.45 }}>
If you&apos;re curious, you can click the green button below to see exactly what the agent did.
</Typography>
</Box>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.25 }}>
<PillButton
label="Edit"
tone="muted"
filled={false}
icon={<EditOutlined sx={{ fontSize: 15 }} />}
onClick={onEdit}
/>
<Box sx={{ flex: 1 }} />
<GhostTextBtn label="Done" onClick={onDone} />
{isLinked ? (
<PillButton
label="Stop Viewing"
tone="success"
filled={false}
icon={<VisibilityOffOutlined sx={{ fontSize: 16 }} />}
onClick={onStopViewing}
/>
) : (
<PillButton
label="View Agent"
tone="success"
filled
onClick={onViewAgent}
/>
)}
</Box>
</Box>
);
}
// ---------- 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 (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25, minHeight: '100%' }}>
<StepList
workflow={workflow}
steps={steps}
stepStatuses={statuses}
/>
<Box sx={{ flex: 1 }} />
<Box sx={{
display: 'flex', alignItems: 'flex-start', gap: 1.25,
p: 1.5, borderRadius: `${c.radius.lg}px`,
bgcolor: c.status.errorBg,
border: `1px solid ${c.status.error}30`,
}}>
<Box sx={{
width: 32, height: 32, borderRadius: `${c.radius.md}px`,
bgcolor: c.status.error + '22', color: c.status.error,
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
}}>
<BuildRounded sx={{ fontSize: 16 }} />
</Box>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography sx={{ fontSize: '0.95rem', fontWeight: 700, color: c.text.primary, lineHeight: 1.3 }}>
Fix with an Agent
</Typography>
<Typography sx={{ fontSize: '0.82rem', color: c.text.secondary, mt: 0.25, lineHeight: 1.45 }}>
Have an agent modify, test, and iterate on the workflow until it works as expected.
</Typography>
</Box>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.25 }}>
{isLinked ? (
<PillButton
label="Stop Viewing"
tone="danger"
filled={false}
icon={<VisibilityOffOutlined sx={{ fontSize: 16 }} />}
onClick={onStopViewing}
/>
) : (
<PillButton
label="View Error"
tone="muted"
filled={false}
icon={<VisibilityOutlined sx={{ fontSize: 16 }} />}
onClick={onViewError}
/>
)}
<Box sx={{ flex: 1 }} />
<GhostTextBtn label="Ignore" onClick={onIgnore} />
<PillButton
label="Fix with Agent"
tone="danger"
filled
onClick={onFixWithAgent}
/>
</Box>
</Box>
);
}
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<HeaderActions>(() => {
if (!workflow) return { right: null };
const HistoryRun = (
<>
<Box
onClick={() => 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 } }}>
<HistoryIcon sx={{ fontSize: 15 }} />
History
</Box>
<Box
onClick={() => 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)' },
}}>
<PlayArrowIcon sx={{ fontSize: 15 }} />
Run
</Box>
</>
);
if (view === 'running') {
return {
right: (
<>
<Box role="button" sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.35, fontSize: '0.82rem', fontWeight: 600, px: 1, py: 0.4, color: c.text.secondary, cursor: 'pointer', '&:hover': { color: c.text.primary } }}>
<StopRounded sx={{ fontSize: 15 }} />
Stop
</Box>
<Box 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)' },
}}>
<PauseRounded sx={{ fontSize: 15 }} />
Pause
</Box>
</>
),
};
}
return { right: HistoryRun };
}, [workflow, view, dispatch, c]);
}
@@ -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<Record<number, string>>({});
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<number, number> = {};
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 (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25, minHeight: '100%' }}>
<StepList
workflow={workflow}
steps={editableSteps}
runs={runs}
activeRunId={activeRunId}
framed
onChangeStep={onChangeFirstStep}
steps={steps}
expandable
expandedIds={expandedIds}
onToggleExpand={onToggleStep}
/>
{firstStepDirty && (
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 1 }}>
<ActionBtn label="Discard" tone="danger" icon="trash" onClick={onDiscardFirstStep} />
<ActionBtn label={savingFirst ? 'Saving…' : 'Save'} tone="success" icon="check" disabled={savingFirst} onClick={onSaveFirstStep} />
</Box>
)}
<Box sx={{ flex: 1 }} />
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1 }}>
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.6, color: c.text.secondary, fontSize: '0.86rem', minWidth: 0 }}>
<Box
onClick={scheduleClickable ? openScheduling : undefined}
role={scheduleClickable ? 'button' : undefined}
sx={{
display: 'inline-flex', alignItems: 'center', gap: 0.6,
color: c.text.secondary, fontSize: '0.86rem', minWidth: 0,
cursor: scheduleClickable ? 'pointer' : 'default',
'&:hover': scheduleClickable ? { color: c.text.primary } : {},
}}>
<CalendarTodayRounded sx={{ fontSize: 15, color: c.text.muted, flexShrink: 0 }} />
<Box component="span" sx={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{scheduleLine}</Box>
</Box>
<Box
onClick={openEdit}
onClick={openEditAgent}
role="button"
sx={{
display: 'inline-flex', alignItems: 'center', gap: 0.45,
+73 -2
View File
@@ -48,6 +48,9 @@ export interface ActionsConfig {
export interface WorkflowStep {
id: string;
text: string;
/** LLM-generated 3-6 word label shown when the step row is collapsed. The
* full `text` is what the agent actually runs; this is just the title. */
label?: string | null;
}
export interface Workflow {
@@ -94,9 +97,32 @@ export interface OpenCard {
workflowId: string;
sourceSessionId?: string | null;
draft?: Partial<Workflow> | 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;