mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-11 12:17:45 +02:00
[aidan] fix/workflows: connect watch tether, keep watched chat open, wire draft run/history
This commit is contained in:
@@ -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' && (
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user