diff --git a/frontend/src/app/pages/Dashboard/geometry/dashboardTethers.ts b/frontend/src/app/pages/Dashboard/geometry/dashboardTethers.ts index 5215c470..142c9a77 100644 --- a/frontend/src/app/pages/Dashboard/geometry/dashboardTethers.ts +++ b/frontend/src/app/pages/Dashboard/geometry/dashboardTethers.ts @@ -91,6 +91,8 @@ interface UseTethersArgs { workflowsHub: WorkflowsHubPosition | null; workflowsMonitorCard: WorkflowsHubPosition | null; workflowsMonitorLabel: string; + /** Session id of the run the monitor is showing; its browser tethers to the monitor card, not a (suppressed) standalone agent card. */ + monitorRunSessionId: string | null; } export function useTethers({ @@ -111,8 +113,10 @@ export function useTethers({ workflowsHub, workflowsMonitorCard, workflowsMonitorLabel, + monitorRunSessionId, }: UseTethersArgs): Tether[] { return useMemo(() => { + const sessionById = new Map(sessionList.map((s) => [s.id, s])); const wfHeight = (wc: WorkflowCardPosition): number => measuredHeightsRef.current![wc.workflow_id] ?? wc.height; const agentTethers = Object.entries(glowingAgentCards).map(([copyId, { sourceId, fading, label }]) => { @@ -163,13 +167,18 @@ export function useTethers({ label: string, fading: boolean, ): Tether | null { - const src = cards[sourceId]; + // Workflow chats have no standalone agent card: a run anchors to the monitor card, an edit/compose chat to the hub window, so the browser tether lands on the workflow surface instead of nothing. + const srcSession = sessionById.get(sourceId); + const srcIsMonitor = !!workflowsMonitorCard && sourceId === monitorRunSessionId; + const srcIsHub = !srcIsMonitor && !!workflowsHub && !!srcSession?.workflow_edit_id; + const src = srcIsMonitor ? workflowsMonitorCard : srcIsHub ? workflowsHub : cards[sourceId]; if (!src || !dst) return null; + const srcDragId = srcIsMonitor ? 'workflows-monitor' : srcIsHub ? 'workflows-hub' : sourceId; let srcX = src.x, srcY = src.y; let dstX = dst.x, dstY = dst.y; if (liveDragInfo) { - if (liveDragInfo.cardId === sourceId) { srcX += liveDragInfo.dx; srcY += liveDragInfo.dy; } + if (liveDragInfo.cardId === srcDragId) { srcX += liveDragInfo.dx; srcY += liveDragInfo.dy; } if (liveDragInfo.cardId === dstId) { dstX += liveDragInfo.dx; dstY += liveDragInfo.dy; } } @@ -265,12 +274,14 @@ export function useTethers({ if (s.status !== 'running' && s.status !== 'waiting_approval') continue; if (!s.browser_id || !s.parent_session_id) continue; if (glowTethers.has(s.browser_id)) continue; + // A browser docked below the hub keeps a "Browser" pointer so the link reads at a glance; the right-docked agent/run cases stay label-free (their glow already said it on spawn). + const parent = sessionById.get(s.parent_session_id); const t = cardTether( browserCards[s.browser_id], s.browser_id, s.parent_session_id, `browser-${s.browser_id}`, - '', + parent?.workflow_edit_id ? 'Browser' : '', false, ); if (t) glowTethers.set(s.browser_id, t); @@ -407,7 +418,7 @@ export function useTethers({ }); } - // Run Monitor tether: the Workflows window to its spawned live-run card. Same border-anchor + elbow math as the sidecar "Watching" arrow. + // Run Monitor tether: the Workflows window to its spawned live-run card. const monitorTethers: Tether[] = []; if (workflowsHub && workflowsMonitorCard) { let hubX = workflowsHub.x, hubY = workflowsHub.y; @@ -417,12 +428,9 @@ export function useTethers({ if (liveDragInfo.cardId === 'workflows-hub') { hubX += liveDragInfo.dx; hubY += liveDragInfo.dy; } if (liveDragInfo.cardId === 'workflows-monitor') { monX += liveDragInfo.dx; monY += liveDragInfo.dy; } } - const hubRect = { x: hubX, y: hubY, width: workflowsHub.width, height: workflowsHub.height }; - const monRect = { x: monX, y: monY, width: workflowsMonitorCard.width, height: workflowsMonitorCard.height }; - const hubC = rectCenter(hubRect); - const monC = rectCenter(monRect); - const a = borderPoint(hubRect.x, hubRect.y, hubRect.width, hubRect.height, monC.x, monC.y); - const b = borderPoint(monRect.x, monRect.y, monRect.width, monRect.height, hubC.x, hubC.y); + // The monitor always spawns directly right of the hub, so anchor at the hub's right edge and the monitor's left edge at the same 0.54 height the browser/agent tethers use. Keeps the window->monitor line at the identical vertical spot as the monitor->browser line. + const a = { x: hubX + workflowsHub.width, y: hubY + workflowsHub.height * 0.54 }; + const b = { x: monX, y: monY + workflowsMonitorCard.height * 0.54 }; const midX = a.x + (b.x - a.x) / 2; const midY = a.y + (b.y - a.y) / 2; // The label box is left-anchored at labelX (rect starts there and grows right), so shift left by half the text width to truly center it on the line. @@ -466,5 +474,5 @@ export function useTethers({ return [...agentTethers, ...browserTethers, ...workflowTethers, ...viewTethers, ...monitorTethers]; // measuredHeightsTick re-runs the memo once ResizeObserver reports a new height after a collapse (the ref read is invisible to the dep checker). eslint-disable-next-line react-hooks/exhaustive-deps - }, [glowingAgentCards, glowingBrowserCards, cards, browserCards, workflowCards, workflowItems, workflowOpenCards, viewCards, outputs, expandedSessionIds, liveDragInfo, measuredHeightsTick, sessionList, workflowsHub, workflowsMonitorCard, workflowsMonitorLabel]); + }, [glowingAgentCards, glowingBrowserCards, cards, browserCards, workflowCards, workflowItems, workflowOpenCards, viewCards, outputs, expandedSessionIds, liveDragInfo, measuredHeightsTick, sessionList, workflowsHub, workflowsMonitorCard, workflowsMonitorLabel, monitorRunSessionId]); } diff --git a/frontend/src/shared/state/dashboardLayoutSlice.ts b/frontend/src/shared/state/dashboardLayoutSlice.ts index a7ed27f0..999e1515 100644 --- a/frontend/src/shared/state/dashboardLayoutSlice.ts +++ b/frontend/src/shared/state/dashboardLayoutSlice.ts @@ -25,6 +25,8 @@ export const DEFAULT_WORKFLOWS_HUB_W = DEFAULT_BROWSER_CARD_W; export const DEFAULT_WORKFLOWS_HUB_H = DEFAULT_BROWSER_CARD_H; export const EXPANDED_CARD_MIN_H = 620; export const GRID_GAP = 24; +// Gap between the Workflows window and the cards it spawns (run monitor, that monitor's browser). Keeps the hub -> monitor -> browser row evenly spaced. +export const WORKFLOW_CARD_GAP = 140; const GRID_ORIGIN = { x: 40, y: 100 }; const GRID_COLS_FALLBACK = 4; @@ -380,21 +382,18 @@ export function findOpenSpotNear( return findOpenGridCell(occupiedRects, newW, newH); } -export function placeInParentColumn( +// Dock a new card to the right of an anchor card, stacking under any cards already in that right-hand column. Anchor is any rect, so a browser can dock beside a normal agent card OR a workflow run/monitor card that has no session entry in state.cards. +export function placeBesideCard( state: DashboardLayoutState, - parentSessionId: string | null | undefined, + anchor: { x: number; y: number; width: number; height: number }, newW: number, newH: number, expandedSessionIds?: string[], exclude?: CardPlacementExclusion, + gap: number = GRID_GAP * 12, ): { x: number; y: number } { const rects = collectOccupiedRects(state, expandedSessionIds, exclude); - const parentCard = parentSessionId ? state.cards[parentSessionId] : null; - if (!parentCard) { - return findOpenGridCell(rects, newW, newH); - } - - const targetX = parentCard.x + parentCard.width + GRID_GAP * 12; + const targetX = anchor.x + anchor.width + gap; const columnCards = [ ...Object.values(state.browserCards).filter( (c) => !(exclude?.type === 'browser' && exclude.id === c.browser_id), @@ -405,11 +404,39 @@ export function placeInParentColumn( ].filter((c) => Math.abs(c.x - targetX) < 50); const targetY = columnCards.length > 0 ? Math.max(...columnCards.map((c) => c.y + c.height)) + GRID_GAP - : parentCard.y; + : anchor.y; return findOpenSpotNear(targetX, targetY, rects, newW, newH); } +// Dock a new card directly below an anchor card (left edges aligned). Used for a browser spawned by a Workflows-hub chat, which has no agent card to sit beside. +export function placeBelowCard( + state: DashboardLayoutState, + anchor: { x: number; y: number; width: number; height: number }, + newW: number, + newH: number, + expandedSessionIds?: string[], + exclude?: CardPlacementExclusion, +): { x: number; y: number } { + const rects = collectOccupiedRects(state, expandedSessionIds, exclude); + return findOpenSpotNear(anchor.x, anchor.y + anchor.height + GRID_GAP, rects, newW, newH); +} + +export function placeInParentColumn( + state: DashboardLayoutState, + parentSessionId: string | null | undefined, + newW: number, + newH: number, + expandedSessionIds?: string[], + exclude?: CardPlacementExclusion, +): { x: number; y: number } { + const parentCard = parentSessionId ? state.cards[parentSessionId] : null; + if (!parentCard) { + return findOpenGridCell(collectOccupiedRects(state, expandedSessionIds, exclude), newW, newH); + } + return placeBesideCard(state, parentCard, newW, newH, expandedSessionIds, exclude); +} + // Reconnect-refetch merge: ADD only the cards the snapshot carries that the client is missing (e.g. a spawned browser whose broadcast was lost in a socket gap), collision-resolving each against the live layout so a recovered card can't land on a card already on canvas, and NEVER touch a card the client already has (that's exactly what preserves its live, collision-placed position). The shared `occupied` list carries placements forward so two recovered cards in the same pass also avoid each other. function addMissingCards( live: Record, @@ -945,7 +972,7 @@ const dashboardLayoutSlice = createSlice({ // Keep the existing card position when just switching the run shown. if (!state.workflowsMonitorCard) { state.workflowsMonitorCard = { - x: hub ? hub.x + hub.width + 140 : 220, + x: hub ? hub.x + hub.width + WORKFLOW_CARD_GAP : 220, y: hub ? hub.y : 160, width: 520, height: hub ? hub.height : 560, diff --git a/frontend/src/shared/ws/WebSocketManager.ts b/frontend/src/shared/ws/WebSocketManager.ts index f52b07cf..ec571478 100644 --- a/frontend/src/shared/ws/WebSocketManager.ts +++ b/frontend/src/shared/ws/WebSocketManager.ts @@ -25,7 +25,7 @@ import { clearTurnLabel, } from '../state/agentsSlice'; import { streamStart, streamDelta, streamEnd, clearStreamingForSession } from '../state/streamingSlice'; -import { addBrowserCardFromBackend, markBrowserCardEnding, keepBrowserCardOpen, placeInParentColumn, setBrowserCardPosition, setGlowingBrowserCards, GRID_GAP, openWorkflowsApp } from '../state/dashboardLayoutSlice'; +import { addBrowserCardFromBackend, markBrowserCardEnding, keepBrowserCardOpen, placeBesideCard, placeBelowCard, setBrowserCardPosition, setGlowingBrowserCards, GRID_GAP, WORKFLOW_CARD_GAP, openWorkflowsApp, openWorkflowMonitor } from '../state/dashboardLayoutSlice'; import { upsertOutput } from '../state/outputsSlice'; import { fetchSettings } from '../state/settingsSlice'; import { displaySessionName } from '../state/sessionDisplay'; @@ -68,6 +68,9 @@ interface WSManagerOptions { const HEARTBEAT_INTERVAL_MS = 25_000; const HEARTBEAT_TIMEOUT_MS = 10_000; +// Manual runs whose monitor card we've already popped open, so the repeated "workflow:run" updates that stream during a run don't re-pin or re-stack the card. +const autoOpenedRunIds = new Set(); + interface QueuedFrame { event: string; data: Record; @@ -673,7 +676,13 @@ class WebSocketManager { case 'workflow:run': if (data.run) { - store.dispatch(upsertRun(data.run)); + const run = data.run; + store.dispatch(upsertRun(run)); + // A manual run (the Run button OR the edit agent's RunWorkflowNow) should surface its live card the moment it starts. Fire once per run so the run's later tool-call updates don't keep re-pinning the monitor; scheduled runs stay quiet so they never hijack the canvas. + if (run.status === 'running' && run.triggered_by === 'manual' && run.id && !autoOpenedRunIds.has(run.id)) { + autoOpenedRunIds.add(run.id); + store.dispatch(openWorkflowMonitor({ workflowId: run.workflow_id, runId: run.id })); + } } break; @@ -758,25 +767,33 @@ class WebSocketManager { if (parentId) { const layoutState = store.getState().dashboardLayout; const browserCard = layoutState.browserCards[data.browser_card.browser_id]; - if (layoutState.cards[parentId] && browserCard) { - const pos = placeInParentColumn( - layoutState, - parentId, - browserCard.width, - browserCard.height, - undefined, - { type: 'browser', id: browserCard.browser_id }, - ); - store.dispatch(setBrowserCardPosition({ - browserId: data.browser_card.browser_id, - x: pos.x, - y: pos.y, - })); - store.dispatch(setGlowingBrowserCards({ - browserIds: [data.browser_card.browser_id], - sessionId: parentId, - label: 'Use Browser', - })); + if (browserCard) { + const exclude = { type: 'browser' as const, id: browserCard.browser_id }; + const parentCard = layoutState.cards[parentId]; + // Workflow chats have no standalone agent card: a run lives in the monitor (dock beside it), an edit/compose chat lives in the hub window (dock below it). Without this the browser keeps the backend's default spot, which overlaps the Workflows window. + const sess = store.getState().agents.sessions[parentId]; + let pos: { x: number; y: number } | null = null; + let glowLabel = 'Use Browser'; + if (parentCard) { + pos = placeBesideCard(layoutState, parentCard, browserCard.width, browserCard.height, undefined, exclude); + } else if (sess?.workflow_run_id && layoutState.workflowsMonitorCard) { + pos = placeBesideCard(layoutState, layoutState.workflowsMonitorCard, browserCard.width, browserCard.height, undefined, exclude, WORKFLOW_CARD_GAP); + } else if (sess?.workflow_edit_id && layoutState.workflowsHub) { + pos = placeBelowCard(layoutState, layoutState.workflowsHub, browserCard.width, browserCard.height, undefined, exclude); + glowLabel = 'Browser'; + } + if (pos) { + store.dispatch(setBrowserCardPosition({ + browserId: data.browser_card.browser_id, + x: pos.x, + y: pos.y, + })); + store.dispatch(setGlowingBrowserCards({ + browserIds: [data.browser_card.browser_id], + sessionId: parentId, + label: glowLabel, + })); + } } } }