diff --git a/frontend/src/app/pages/Dashboard/geometry/dashboardTethers.ts b/frontend/src/app/pages/Dashboard/geometry/dashboardTethers.ts index 04287230..f28850c1 100644 --- a/frontend/src/app/pages/Dashboard/geometry/dashboardTethers.ts +++ b/frontend/src/app/pages/Dashboard/geometry/dashboardTethers.ts @@ -123,21 +123,25 @@ export function useTethers({ }; }).filter(Boolean) as Tether[]; - function browserTether( - browserId: string, + // One tether builder for both browser and view cards: the anchor-pairing + // and elbow/vertical path are identical; only the destination card map and + // the key prefix differ, so the resolved dst card is passed in. + function cardTether( + dst: { x: number; y: number; width: number; height: number } | undefined, + dstId: string, sourceId: string, - fading: boolean, + key: string, label: string, + fading: boolean, ): Tether | null { const src = cards[sourceId]; - const dst = browserCards[browserId]; if (!src || !dst) return null; 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 === browserId) { dstX += liveDragInfo.dx; dstY += liveDragInfo.dy; } + if (liveDragInfo.cardId === dstId) { dstX += liveDragInfo.dx; dstY += liveDragInfo.dy; } } const srcMeasured = measuredHeightsRef.current![sourceId]; @@ -205,7 +209,7 @@ export function useTethers({ const labelY = isVertical ? midY + (y2 - midY) * 0.15 : y2; return { - key: `browser-${browserId}`, + key, path: pathD, labelX, labelY, @@ -214,9 +218,16 @@ export function useTethers({ }; } - const glowTethers = new Map>(); + const glowTethers = new Map>(); for (const [browserId, { sourceId, fading, label }] of Object.entries(glowingBrowserCards)) { - const t = browserTether(browserId, sourceId, fading, label || ''); + const t = cardTether( + browserCards[browserId], + browserId, + sourceId, + `browser-${browserId}`, + label || '', + fading, + ); if (t) glowTethers.set(browserId, t); } @@ -225,105 +236,19 @@ 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; - const t = browserTether(s.browser_id, s.parent_session_id, false, ''); + const t = cardTether( + browserCards[s.browser_id], + s.browser_id, + s.parent_session_id, + `browser-${s.browser_id}`, + '', + false, + ); if (t) glowTethers.set(s.browser_id, t); } const browserTethers = Array.from(glowTethers.values()).filter(Boolean) as Tether[]; - // Edit tether: same anchor-pairing as browserTether (chat <-> view card). - // Used to visually link an App Builder chat to the running app while the - // session is actively editing. - function viewTether( - outputId: string, - sourceId: string, - label: string, - ): Tether | null { - const src = cards[sourceId]; - const dst = viewCards[outputId]; - if (!src || !dst) return null; - - 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 === outputId) { dstX += liveDragInfo.dx; dstY += liveDragInfo.dy; } - } - - const srcMeasured = measuredHeightsRef.current![sourceId]; - const srcH = srcMeasured ?? (expandedSessionIds.includes(sourceId) - ? Math.max(EXPANDED_CARD_MIN_H, src.height) - : src.height); - const dstH = dst.height; - - const srcCx = srcX + src.width / 2; - const dstCx = dstX + dst.width / 2; - - const srcAnchors: Anchor[] = [ - { x: srcX + src.width, y: srcY + srcH * 0.54, side: 'right' }, - { x: srcX, y: srcY + srcH * 0.54, side: 'left' }, - { x: srcCx, y: srcY, side: 'top' }, - { x: srcCx, y: srcY + srcH, side: 'bottom' }, - ]; - const dstAnchors: Anchor[] = [ - { x: dstX, y: dstY + dstH * 0.54, side: 'left' }, - { x: dstX + dst.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 isVertical = (bestSrc.side === 'top' || bestSrc.side === 'bottom') - && (bestDst.side === 'top' || bestDst.side === 'bottom'); - - let pathD: string; - if (isVertical) { - const dx = x2 - x1; - const dy = y2 - y1; - const midY = y1 + dy / 2; - const r = (Math.abs(dx) < 1 || Math.abs(dy) < ELBOW_RADIUS * 2) - ? 0 - : Math.min(ELBOW_RADIUS, Math.abs(dx) / 2, Math.abs(dy) / 4); - const sx = dx >= 0 ? 1 : -1; - const sy = dy >= 0 ? 1 : -1; - pathD = [ - `M ${x1},${y1}`, - `V ${midY - sy * r}`, - `Q ${x1},${midY} ${x1 + sx * r},${midY}`, - `H ${x2 - sx * r}`, - `Q ${x2},${midY} ${x2},${midY + sy * r}`, - `V ${y2}`, - ].join(' '); - } else { - pathD = elbowPath(x1, y1, x2, y2); - } - - const midX = x1 + (x2 - x1) / 2; - const midY = y1 + (y2 - y1) / 2; - const labelX = isVertical ? midX : midX + (x2 - midX) * 0.15; - const labelY = isVertical ? midY + (y2 - midY) * 0.15 : y2; - - return { - key: `view-${outputId}`, - path: pathD, - labelX, - labelY, - label, - fading: false, - }; - } - // Index outputs by their owning session so the per-session lookup below // doesn't scan the whole outputs map for every view-builder chat. const outputsBySession = new Map(); @@ -341,7 +266,14 @@ export function useTethers({ if (!outIds) continue; for (const outputId of outIds) { if (!viewCards[outputId]) continue; - const t = viewTether(outputId, s.id, 'Editing'); + const t = cardTether( + viewCards[outputId], + outputId, + s.id, + `view-${outputId}`, + 'Editing', + false, + ); if (t) viewTethers.push(t); } } diff --git a/frontend/src/shared/state/dashboardLayoutSlice.ts b/frontend/src/shared/state/dashboardLayoutSlice.ts index 650d03cc..09ed3dac 100644 --- a/frontend/src/shared/state/dashboardLayoutSlice.ts +++ b/frontend/src/shared/state/dashboardLayoutSlice.ts @@ -196,6 +196,11 @@ interface Rect { h: number; } +interface CardPlacementExclusion { + type: CardType; + id: string; +} + function rectsOverlap(a: Rect, b: Rect): boolean { return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y; } @@ -203,20 +208,25 @@ function rectsOverlap(a: Rect, b: Rect): boolean { function collectOccupiedRects( state: DashboardLayoutState, expandedSessionIds?: string[], + exclude?: CardPlacementExclusion, ): Rect[] { const expanded = new Set(expandedSessionIds); const rects: Rect[] = []; for (const c of Object.values(state.cards)) { + if (exclude?.type === 'agent' && exclude.id === c.session_id) continue; const h = expanded.has(c.session_id) ? Math.max(EXPANDED_CARD_MIN_H, c.height) : c.height; rects.push({ x: c.x, y: c.y, w: c.width, h }); } for (const c of Object.values(state.viewCards)) { + if (exclude?.type === 'view' && exclude.id === c.output_id) continue; rects.push({ x: c.x, y: c.y, w: c.width, h: c.height }); } for (const c of Object.values(state.browserCards)) { + if (exclude?.type === 'browser' && exclude.id === c.browser_id) continue; rects.push({ x: c.x, y: c.y, w: c.width, h: c.height }); } for (const n of Object.values(state.notes)) { + if (exclude?.type === 'note' && exclude.id === n.note_id) continue; rects.push({ x: n.x, y: n.y, w: n.width, h: n.height }); } return rects; @@ -309,6 +319,36 @@ export function findOpenSpotNear( return findOpenGridCell(occupiedRects, newW, newH); } +export function placeInParentColumn( + state: DashboardLayoutState, + parentSessionId: string | null | undefined, + newW: number, + newH: number, + expandedSessionIds?: string[], + exclude?: CardPlacementExclusion, +): { 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 columnCards = [ + ...Object.values(state.browserCards).filter( + (c) => !(exclude?.type === 'browser' && exclude.id === c.browser_id), + ), + ...Object.values(state.viewCards).filter( + (c) => !(exclude?.type === 'view' && exclude.id === c.output_id), + ), + ].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; + + return findOpenSpotNear(targetX, targetY, rects, newW, newH); +} + // 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 @@ -533,17 +573,7 @@ const dashboardLayoutSlice = createSlice({ } else { const parentCard = parentSessionId ? state.cards[parentSessionId] : null; if (parentCard) { - const targetX = parentCard.x + parentCard.width + GRID_GAP * 12; - let targetY = parentCard.y; - const siblings = [ - ...Object.values(state.browserCards), - ...Object.values(state.viewCards), - ].filter((c) => Math.abs(c.x - targetX) < 50); - if (siblings.length > 0) { - targetY = Math.max(...siblings.map((c) => c.y + c.height)) + GRID_GAP; - } - const rects = collectOccupiedRects(state, expandedSessionIds); - const pos = findOpenSpotNear(targetX, targetY, rects, w, h); + const pos = placeInParentColumn(state, parentSessionId, w, h, expandedSessionIds); posX = pos.x; posY = pos.y; } else { diff --git a/frontend/src/shared/ws/WebSocketManager.ts b/frontend/src/shared/ws/WebSocketManager.ts index 092c4a6b..63d37f9f 100644 --- a/frontend/src/shared/ws/WebSocketManager.ts +++ b/frontend/src/shared/ws/WebSocketManager.ts @@ -23,7 +23,7 @@ import { clearTurnLabel, } from '../state/agentsSlice'; import { streamStart, streamDelta, streamEnd, clearStreamingForSession } from '../state/streamingSlice'; -import { addBrowserCardFromBackend, markBrowserCardEnding, setBrowserCardPosition, setGlowingBrowserCards, GRID_GAP } from '../state/dashboardLayoutSlice'; +import { addBrowserCardFromBackend, markBrowserCardEnding, placeInParentColumn, setBrowserCardPosition, setGlowingBrowserCards } from '../state/dashboardLayoutSlice'; import { upsertOutput } from '../state/outputsSlice'; import { displaySessionName } from '../state/sessionDisplay'; import { getAuthToken } from '../config'; @@ -756,21 +756,20 @@ class WebSocketManager { const parentId = data.parent_session_id; if (parentId) { const layoutState = store.getState().dashboardLayout; - const parentCard = layoutState.cards[parentId]; - if (parentCard) { - const targetX = parentCard.x + parentCard.width + GRID_GAP * 12; - let targetY = parentCard.y; - const columnCards = Object.values(layoutState.browserCards).filter( - (c) => Math.abs(c.x - targetX) < 50 && c.browser_id !== data.browser_card.browser_id, + 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 }, ); - if (columnCards.length > 0) { - const lowestBottom = Math.max(...columnCards.map((c) => c.y + c.height)); - targetY = lowestBottom + GRID_GAP; - } store.dispatch(setBrowserCardPosition({ browserId: data.browser_card.browser_id, - x: targetX, - y: targetY, + x: pos.x, + y: pos.y, })); store.dispatch(setGlowingBrowserCards({ browserIds: [data.browser_card.browser_id],