mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-06 09:47:44 +02:00
[eric] workflows: ExpandedView + CompletedView + FailedView + sidecar tether
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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'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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user