diff --git a/frontend/src/app/pages/Dashboard/canvas/DashboardCardLayer.tsx b/frontend/src/app/pages/Dashboard/canvas/DashboardCardLayer.tsx index 2ab00dfa..454fc8ba 100644 --- a/frontend/src/app/pages/Dashboard/canvas/DashboardCardLayer.tsx +++ b/frontend/src/app/pages/Dashboard/canvas/DashboardCardLayer.tsx @@ -304,6 +304,7 @@ const DashboardCardLayer: React.FC = ({ onDragEnd={onDragEnd} onDoubleClick={onDoubleClick} onBringToFront={onBringToFront} + onMeasuredHeight={onMeasuredHeight} /> ))} {Object.values(configurePanels).map((p) => ( diff --git a/frontend/src/app/pages/Dashboard/geometry/dashboardTethers.ts b/frontend/src/app/pages/Dashboard/geometry/dashboardTethers.ts index 9fc02e24..ec5a1e22 100644 --- a/frontend/src/app/pages/Dashboard/geometry/dashboardTethers.ts +++ b/frontend/src/app/pages/Dashboard/geometry/dashboardTethers.ts @@ -56,6 +56,19 @@ export function elbowPath(x1: number, y1: number, x2: number, y2: number): strin type Anchor = { x: number; y: number; side: 'left' | 'right' | 'top' | 'bottom' }; +// Where the ray from a rect's center toward (tx,ty) crosses the rect border. +// Pins a tether endpoint to the card edge facing the other card, so it can +// never float in empty space the way nearest-corner anchoring could. +function borderPoint(x: number, y: number, w: number, h: number, tx: number, ty: number): { x: number; y: number } { + const cx = x + w / 2; + const cy = y + h / 2; + const dx = tx - cx; + const dy = ty - cy; + if (dx === 0 && dy === 0) return { x: cx, y: cy }; + const scale = 1 / Math.max(Math.abs(dx) / (w / 2), Math.abs(dy) / (h / 2)); + return { x: cx + dx * scale, y: cy + dy * scale }; +} + interface UseTethersArgs { glowingAgentCards: Record; glowingBrowserCards: Record; @@ -88,6 +101,8 @@ export function useTethers({ sessionList, }: UseTethersArgs): Tether[] { return useMemo(() => { + const wfHeight = (wc: WorkflowCardPosition): number => + measuredHeightsRef.current![wc.workflow_id] ?? wc.height; const agentTethers = Object.entries(glowingAgentCards).map(([copyId, { sourceId, fading, label }]) => { const src = cards[sourceId]; const dst = cards[copyId]; @@ -262,6 +277,7 @@ export function useTethers({ ? Math.max(EXPANDED_CARD_MIN_H, src.height) : src.height); + const wcH = wfHeight(wc); const srcCx = srcX + src.width / 2; const dstCx = dstX + wc.width / 2; const srcAnchors: Anchor[] = [ @@ -271,10 +287,10 @@ export function useTethers({ { x: srcCx, y: srcY + srcH, side: 'bottom' }, ]; const dstAnchors: Anchor[] = [ - { x: dstX, y: dstY + wc.height * 0.54, side: 'left' }, - { x: dstX + wc.width, y: dstY + wc.height * 0.54, side: 'right' }, + { x: dstX, y: dstY + wcH * 0.54, side: 'left' }, + { x: dstX + wc.width, y: dstY + wcH * 0.54, side: 'right' }, { x: dstCx, y: dstY, side: 'top' }, - { x: dstCx, y: dstY + wc.height, side: 'bottom' }, + { x: dstCx, y: dstY + wcH, side: 'bottom' }, ]; let bestSrc = srcAnchors[0], bestDst = dstAnchors[0]; let bestDist = Infinity; @@ -340,30 +356,13 @@ export function useTethers({ 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 wcH = wfHeight(wc); + const srcCx = srcX + wc.width / 2, srcCy = srcY + wcH / 2; + const dstCx = dstX + sidecar.width / 2, dstCy = dstY + dstH / 2; + const a = borderPoint(srcX, srcY, wc.width, wcH, dstCx, dstCy); + const b = borderPoint(dstX, dstY, sidecar.width, dstH, srcCx, srcCy); + const x1 = a.x, y1 = a.y; + const x2 = b.x, y2 = b.y; const pathD = elbowPath(x1, y1, x2, y2); const midX = x1 + (x2 - x1) / 2; const midY = y1 + (y2 - y1) / 2; @@ -388,13 +387,14 @@ export function useTethers({ if (liveDragInfo) { if (liveDragInfo.cardId === p.workflow_id) { srcX += liveDragInfo.dx; srcY += liveDragInfo.dy; } } + const wcH = wfHeight(wc); const srcCx = srcX + wc.width / 2; const dstCx = dstX + p.width / 2; const srcAnchors: Anchor[] = [ - { x: srcX + wc.width, y: srcY + wc.height * 0.5, side: 'right' }, - { x: srcX, y: srcY + wc.height * 0.5, side: 'left' }, + { x: srcX + wc.width, y: srcY + wcH * 0.5, side: 'right' }, + { x: srcX, y: srcY + wcH * 0.5, side: 'left' }, { x: srcCx, y: srcY, side: 'top' }, - { x: srcCx, y: srcY + wc.height, side: 'bottom' }, + { x: srcCx, y: srcY + wcH, side: 'bottom' }, ]; const dstAnchors: Anchor[] = [ { x: dstX, y: dstY + p.height * 0.5, side: 'left' }, diff --git a/frontend/src/app/pages/Workflows/WorkflowCard.tsx b/frontend/src/app/pages/Workflows/WorkflowCard.tsx index c89fd0d7..1bba3997 100644 --- a/frontend/src/app/pages/Workflows/WorkflowCard.tsx +++ b/frontend/src/app/pages/Workflows/WorkflowCard.tsx @@ -14,6 +14,7 @@ import { useClaudeTokens } from '@/shared/styles/ThemeContext'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; import { closeWorkflowCard, + createWorkflow, deleteWorkflow, fetchRuns, openWorkflowCard as openWorkflowCardAction, @@ -94,6 +95,7 @@ interface Props { onDragEnd?: (dx: number, dy: number, didDrag: boolean) => void; onDoubleClick?: (id: string, type: 'agent' | 'view' | 'browser' | 'note' | 'workflow') => void; onBringToFront?: (id: string, type: 'agent' | 'view' | 'browser' | 'note' | 'workflow') => void; + onMeasuredHeight?: (id: string, height: number) => void; } const WorkflowCard: React.FC = ({ @@ -102,6 +104,7 @@ const WorkflowCard: React.FC = ({ zoom = 1, panX = 0, panY = 0, isSelected = false, isHighlighted = false, multiDragDelta, onCardSelect, onDragStart, onDragMove, onDragEnd, onDoubleClick, onBringToFront, + onMeasuredHeight, }) => { const c = useClaudeTokens(); const dispatch = useAppDispatch(); @@ -110,6 +113,19 @@ const WorkflowCard: React.FC = ({ const workflow = useAppSelector((s) => s.workflows.items[workflowId]); const runs = useAppSelector((s) => s.workflows.runs[workflowId]); const expandedSessionIds = useAppSelector((s) => s.agents.expandedSessionIds); + const defaultModel = useAppSelector((s) => s.settings.data.default_model); + const defaultMode = useAppSelector((s) => s.settings.data.default_mode); + + const cardBoxRef = useRef(null); + useEffect(() => { + const el = cardBoxRef.current; + if (!el || !onMeasuredHeight) return; + const ro = new ResizeObserver((entries) => { + for (const entry of entries) onMeasuredHeight(workflowId, entry.contentRect.height); + }); + ro.observe(el); + return () => ro.disconnect(); + }, [workflowId, onMeasuredHeight]); // Transient "Starting…" label state on the Run button. See onClick handler // for the full rationale (avoid no-feedback flicker on fast manual runs). @@ -356,6 +372,34 @@ const WorkflowCard: React.FC = ({ (e.target as HTMLElement).releasePointerCapture(e.pointerId); }, [computeResize, dispatch, workflowId]); + // History / Run on an unsaved draft have nothing to hit yet, so persist the + // draft first (mirrors PreviewView's save), rekey the card to the real id, + // and hand back the saved workflow so the caller can act on it. + const persistingRef = useRef(false); + const persistDraft = useCallback(async (): Promise => { + const d = card?.draft; + if (!d || persistingRef.current) return null; + persistingRef.current = true; + try { + const result = await dispatch(createWorkflow({ + title: (d.title as string) || 'New workflow', + description: (d.description as string) || '', + steps: (d.steps || []).map((s) => ({ id: s.id, text: s.text })), + source_session_id: (d.source_session_id as string | undefined) || card?.sourceSessionId || null, + use_synced_prompt: true, + model: defaultModel || (d.model as string), + mode: defaultMode || (d.mode as string), + } as Partial)); + const wf = (result as unknown as { payload: Workflow }).payload; + if (!wf?.id) return null; + dispatch(rekeyOpenCard({ oldId: workflowId, newId: wf.id })); + dispatch(rekeyWorkflowCard({ oldId: workflowId, newId: wf.id })); + return wf; + } finally { + persistingRef.current = false; + } + }, [card?.draft, card?.sourceSessionId, defaultModel, defaultMode, dispatch, workflowId]); + const discardDraft = useCallback(() => { const sourceId = card?.sourceSessionId || (card?.draft?.source_session_id as string | undefined) || null; dispatch(closeWorkflowCard(workflowId)); @@ -471,6 +515,10 @@ const WorkflowCard: React.FC = ({ '&:hover .resize-handle': { opacity: 1 }, }} > + {/* Plain sentinel that fills the card so a ResizeObserver can read the + real auto-height. Measuring the motion.div root directly is flaky + (ref forwarding through MUI component + framer-motion). */} + {/* ===== Title bar / drag handle ===== Matches target image #54 spec: drag-grip on the far left, then a single bold title (no pill prefix), then a quiet close X. The @@ -570,8 +618,36 @@ const WorkflowCard: React.FC = ({ fallbackSourceSessionId={card?.draft?.source_session_id} /> - } active={false} onClick={() => {}} /> - } active={false} accent onClick={() => {}} /> + } + active={false} + onClick={async () => { + const wf = await persistDraft(); + if (!wf) return; + dispatch(openWorkflowCardAction({ workflowId: wf.id, sourceSessionId: card?.sourceSessionId || null, view: 'history', draft: null })); + dispatch(fetchRuns(wf.id)); + }} + /> + } + active={false} + accent + onClick={async () => { + if (runStarting) return; + setRunStarting(true); + try { + const wf = await persistDraft(); + if (!wf) return; + dispatch(openWorkflowCardAction({ workflowId: wf.id, sourceSessionId: card?.sourceSessionId || null, view: 'saved', draft: null })); + await dispatch(runWorkflowNow(wf.id)); + await dispatch(fetchRuns(wf.id)); + } finally { + setTimeout(() => setRunStarting(false), 600); + } + }} + /> )} {!isDraft && workflow && !isHeaderlessView(card.view) && card.view !== 'running' && ( diff --git a/frontend/src/shared/state/agentsSlice.ts b/frontend/src/shared/state/agentsSlice.ts index 6046909a..95ac55e4 100644 --- a/frontend/src/shared/state/agentsSlice.ts +++ b/frontend/src/shared/state/agentsSlice.ts @@ -1001,11 +1001,18 @@ const agentsSlice = createSlice({ } }, - closeSessionFromWs(state, action: PayloadAction) { - const entry = action.payload; + closeSessionFromWs(state, action: PayloadAction) { + const { keepSession, ...entry } = action.payload; state.history[entry.id] = entry; const session = state.sessions[entry.id]; + // keepSession: the user is watching this run live, so a workflow finishing + // shouldn't yank the chat out from under them. Keep it as a normal + // completed chat (continue / exit) instead of deleting the card. + if (keepSession && session) { + session.status = (entry.status as AgentSession['status']) || 'completed'; + return; + } if (session?.mode === 'browser-agent' && session.parent_session_id) { session.status = (entry.status as AgentSession['status']) || 'completed'; } else { diff --git a/frontend/src/shared/state/workflowsSlice.ts b/frontend/src/shared/state/workflowsSlice.ts index a775a498..3b02f1a0 100644 --- a/frontend/src/shared/state/workflowsSlice.ts +++ b/frontend/src/shared/state/workflowsSlice.ts @@ -346,6 +346,13 @@ const slice = createSlice({ card.view = 'failed'; card.runId = r.id; } + // A run that finishes while the user is watching it live becomes a + // "viewing" link so the sibling chat stays open with Stop Viewing, + // not a stale "watching" arrow pointing at a finished run. + if (card.sidecarSessionId && card.sidecarKind === 'watching' && prev && prev.status === 'running') { + if (r.status === 'failure') card.sidecarKind = 'viewing-error'; + else if (r.status === 'success' || r.status === 'ran_late') card.sidecarKind = 'viewing-completed'; + } } }, toggleExpandedStep(state, action: { payload: { workflowId: string; stepId: string } }) { diff --git a/frontend/src/shared/ws/WebSocketManager.ts b/frontend/src/shared/ws/WebSocketManager.ts index c690f633..78b517f6 100644 --- a/frontend/src/shared/ws/WebSocketManager.ts +++ b/frontend/src/shared/ws/WebSocketManager.ts @@ -723,6 +723,11 @@ class WebSocketManager { case 'agent:closed': if (session_id) { const closedStatus = data.status ?? 'stopped'; + // Don't evict a chat the user is actively watching from a workflow + // card; let it settle into a normal completed chat they can continue + // or close themselves. + const watchedSidecar = Object.values(store.getState().workflows.openCards) + .some((oc) => oc.sidecarSessionId === session_id); store.dispatch(closeSessionFromWs({ id: session_id, name: data.name ?? 'Untitled', @@ -733,6 +738,7 @@ class WebSocketManager { closed_at: data.closed_at ?? new Date().toISOString(), cost_usd: data.cost_usd ?? 0, dashboard_id: data.dashboard_id, + keepSession: watchedSidecar, })); // Auto-delete browsers spawned by this agent when it finishes // normally or errors out. We intentionally skip 'stopped' , the