[aidan] fix/workflows: connect watch tether, keep watched chat open, wire draft run/history

This commit is contained in:
abccodes
2026-06-17 23:21:02 -07:00
parent 72dd5208a6
commit 09fa080378
6 changed files with 131 additions and 34 deletions
@@ -304,6 +304,7 @@ const DashboardCardLayer: React.FC<DashboardCardLayerProps> = ({
onDragEnd={onDragEnd}
onDoubleClick={onDoubleClick}
onBringToFront={onBringToFront}
onMeasuredHeight={onMeasuredHeight}
/>
))}
{Object.values(configurePanels).map((p) => (
@@ -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<string, GlowingAgentCard>;
glowingBrowserCards: Record<string, GlowingBrowserCard>;
@@ -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' },
@@ -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<Props> = ({
@@ -102,6 +104,7 @@ const WorkflowCard: React.FC<Props> = ({
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<Props> = ({
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<HTMLDivElement>(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<Props> = ({
(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<Workflow | null> => {
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<Workflow>));
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<Props> = ({
'&: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). */}
<Box ref={cardBoxRef} aria-hidden sx={{ position: 'absolute', inset: 0, pointerEvents: 'none' }} />
{/* ===== 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<Props> = ({
fallbackSourceSessionId={card?.draft?.source_session_id}
/>
<Box sx={{ flex: 1 }} />
<TabBtn label="History" icon={<HistoryIcon sx={{ fontSize: 16 }} />} active={false} onClick={() => {}} />
<TabBtn label="Run" icon={<PlayArrowIcon sx={{ fontSize: 16 }} />} active={false} accent onClick={() => {}} />
<TabBtn
label="History"
icon={<HistoryIcon sx={{ fontSize: 16 }} />}
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));
}}
/>
<TabBtn
label={runStarting ? 'Starting…' : 'Run'}
icon={<PlayArrowIcon sx={{ fontSize: 16 }} />}
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);
}
}}
/>
</Box>
)}
{!isDraft && workflow && !isHeaderlessView(card.view) && card.view !== 'running' && (
+9 -2
View File
@@ -1001,11 +1001,18 @@ const agentsSlice = createSlice({
}
},
closeSessionFromWs(state, action: PayloadAction<HistorySession>) {
const entry = action.payload;
closeSessionFromWs(state, action: PayloadAction<HistorySession & { keepSession?: boolean }>) {
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 {
@@ -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 } }) {
@@ -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