mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-08 10:47:44 +02:00
[aidan] ux/workflows-run-card: dock browsers, even spacing, aligned tether, auto-open on manual run
This commit is contained in:
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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<T extends { x: number; y: number; width: number; height: number }>(
|
||||
live: Record<string, T>,
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string>();
|
||||
|
||||
interface QueuedFrame {
|
||||
event: string;
|
||||
data: Record<string, any>;
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user