From c3d69d175d93c486eacc28cb202543492fd588f3 Mon Sep 17 00:00:00 2001 From: SirKentut <81878031+SirKentut@users.noreply.github.com> Date: Mon, 29 Jun 2026 02:20:09 -0700 Subject: [PATCH 1/5] [pierre][fix]: spawn new dashboard cards beside selected card or viewport center --- .../hooks/lifecycle/useAgentSpawn.ts | 16 ++++++-- .../lifecycle/useDashboardCardActions.ts | 29 ++++++++++---- .../hooks/lifecycle/useSpawnPlacement.ts | 40 +++++++++++++++++++ .../hooks/state/useDashboardController.ts | 3 ++ .../src/shared/state/dashboardLayoutSlice.ts | 31 ++++++++++++-- 5 files changed, 105 insertions(+), 14 deletions(-) create mode 100644 frontend/src/app/pages/Dashboard/hooks/lifecycle/useSpawnPlacement.ts diff --git a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useAgentSpawn.ts b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useAgentSpawn.ts index 74458766..4d1b05df 100644 --- a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useAgentSpawn.ts +++ b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useAgentSpawn.ts @@ -25,14 +25,18 @@ import { import { generateDashboardName } from '@/shared/state/dashboardsSlice'; import type { ContextPath } from '@/app/components/editor/DirectoryBrowser'; import type { CanvasActions } from '../interaction/useCanvasControls'; +import type { useDashboardSelection } from '../state/useDashboardSelection'; +import { useSpawnPlacement } from './useSpawnPlacement'; type SpawnOrigin = { x: number; y: number; type?: 'branch' }; +type Selection = ReturnType; interface UseAgentSpawnArgs { cards: Record; expandedSessionIds: string[]; dashboardId: string; expandNewChats: boolean; + selection: Selection; canvasActions: CanvasActions; viewportRef: RefObject; toolbarRef: RefObject; @@ -52,6 +56,7 @@ export function useAgentSpawn({ expandedSessionIds, dashboardId, expandNewChats, + selection, canvasActions, viewportRef, toolbarRef, @@ -65,6 +70,7 @@ export function useAgentSpawn({ onWelcomeNewAgent, }: UseAgentSpawnArgs) { const dispatch = useAppDispatch(); + const getSpawnPlacement = useSpawnPlacement({ selection, viewportRef, canvasStateRef, expandedSessionIds }); const handleBranchFromCard = useCallback( (sourceSessionId: string, newSessionId: string) => { @@ -138,6 +144,8 @@ export function useAgentSpawn({ // Toolbar position in canvas coords drives the spawn-from-toolbar grow animation. let origin: SpawnOrigin | null = null; + // Where the chat lands: beside the selected card, else in front of the viewport. Feeds the optimistic placement below, replacing the legacy toolbar-position anchor. + const spawnPos = getSpawnPlacement(DEFAULT_CARD_W, DEFAULT_CARD_H); const toolbarEl = toolbarRef.current; const vpEl = viewportRef.current; if (toolbarEl && vpEl) { @@ -165,8 +173,8 @@ export function useAgentSpawn({ if (origin) spawnOriginsRef.current![draftId] = origin; const cardHeight = expandNewChats ? EXPANDED_CARD_MIN_H : DEFAULT_CARD_H; - const anchorX = browserAnchor ? browserAnchor.x - DEFAULT_CARD_W - GRID_GAP * 12 : origin?.x ?? 0; - const anchorY = browserAnchor ? browserAnchor.y : origin?.y ?? 0; + const anchorX = browserAnchor ? browserAnchor.x - DEFAULT_CARD_W - GRID_GAP * 12 : spawnPos.x; + const anchorY = browserAnchor ? browserAnchor.y : spawnPos.y; dispatch(placeCard({ sessionId: draftId, x: anchorX, y: anchorY, width: DEFAULT_CARD_W, height: cardHeight, expandedSessionIds })); if (expandNewChats) { dispatch(expandSession(draftId)); @@ -212,7 +220,7 @@ export function useAgentSpawn({ if (launchAndSendFirstMessage.fulfilled.match(action)) { const realId = action.payload.session.id; dispatch(generateTitle({ sessionId: realId, prompt })); - // Re-point focus/selection at the rekeyed real card; the draft id is gone after the in-place swap. The browser tether rekeys with the card in the dashboardLayout extraReducer. + // Re-point focus/selection at the rekeyed real card; the draft id is gone after the in-place swap. The browser tether rekeys with the card in the dashboardLayout extraReducer. Placement already happened optimistically at spawn (spawnPos / browserAnchor), so there's nothing to re-place here. if (expandNewChats) setAutoFocusSessionId(realId); else setPendingSelectSessionId(realId); @@ -240,7 +248,7 @@ export function useAgentSpawn({ } }); }, - [viewportRef, canvasActions, dispatch, dashboardId, expandNewChats, expandedSessionIds, handleHighlightCard, setToolbarOpen, setAutoFocusSessionId, setPendingSelectSessionId, toolbarRef, canvasStateRef, spawnOriginsRef], + [viewportRef, canvasActions, dispatch, dashboardId, expandNewChats, expandedSessionIds, getSpawnPlacement, handleHighlightCard, setToolbarOpen, setAutoFocusSessionId, setPendingSelectSessionId, toolbarRef, canvasStateRef, spawnOriginsRef], ); return { diff --git a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardCardActions.ts b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardCardActions.ts index 7b095152..442cba75 100644 --- a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardCardActions.ts +++ b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardCardActions.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, type Dispatch, type SetStateAction } from 'react'; +import { useCallback, useEffect, type Dispatch, type RefObject, type SetStateAction } from 'react'; import { report } from '@/shared/serviceClient'; import { store } from '@/shared/state/store'; import { useAppDispatch } from '@/shared/hooks'; @@ -9,10 +9,17 @@ import { addBrowserCard, addNote, clearPendingFocusNoteId, + DEFAULT_VIEW_CARD_W, + DEFAULT_VIEW_CARD_H, + DEFAULT_BROWSER_CARD_W, + DEFAULT_BROWSER_CARD_H, + DEFAULT_NOTE_W, + DEFAULT_NOTE_H, EXPANDED_CARD_MIN_H, } from '@/shared/state/dashboardLayoutSlice'; import type { CardType, useDashboardSelection } from '../state/useDashboardSelection'; import type { CanvasActions } from '../interaction/useCanvasControls'; +import { useSpawnPlacement } from './useSpawnPlacement'; type Selection = ReturnType; @@ -23,6 +30,8 @@ interface UseDashboardCardActionsArgs { selection: Selection; canvasActions: CanvasActions; getCardRect: (id: string, type: CardType) => { x: number; y: number; width: number; height: number } | undefined; + viewportRef: RefObject; + canvasStateRef: RefObject<{ panX: number; panY: number; zoom: number }>; handleHighlightCard: (cardId: string) => void; setAutoFocusSessionId: Dispatch>; } @@ -34,13 +43,17 @@ export function useDashboardCardActions({ selection, canvasActions, getCardRect, + viewportRef, + canvasStateRef, handleHighlightCard, setAutoFocusSessionId, }: UseDashboardCardActionsArgs) { const dispatch = useAppDispatch(); + const getSpawnPlacement = useSpawnPlacement({ selection, viewportRef, canvasStateRef, expandedSessionIds }); const handleAddView = useCallback((outputId: string, opts?: { newInstance?: boolean }) => { - dispatch(addViewCard({ outputId, expandedSessionIds, newInstance: opts?.newInstance })); + const pos = getSpawnPlacement(DEFAULT_VIEW_CARD_W, DEFAULT_VIEW_CARD_H); + dispatch(addViewCard({ outputId, expandedSessionIds, x: pos.x, y: pos.y, newInstance: opts?.newInstance })); setTimeout(() => { // Focus whichever card the dispatch produced: with newInstance that's the highest-numbered instance of this output, else the primary. const viewCards = store.getState().dashboardLayout.viewCards; @@ -56,12 +69,13 @@ export function useDashboardCardActions({ handleHighlightCard(focusKey); } }, 200); - }, [dispatch, expandedSessionIds, canvasActions, handleHighlightCard]); + }, [dispatch, expandedSessionIds, getSpawnPlacement, canvasActions, handleHighlightCard]); const handleAddBrowser = useCallback(() => { report('dashboard', 'browser_added'); const prevIds = new Set(Object.keys(store.getState().dashboardLayout.browserCards)); - dispatch(addBrowserCard({ url: browserHomepage, expandedSessionIds })); + const pos = getSpawnPlacement(DEFAULT_BROWSER_CARD_W, DEFAULT_BROWSER_CARD_H); + dispatch(addBrowserCard({ url: browserHomepage, expandedSessionIds, x: pos.x, y: pos.y })); setTimeout(() => { const allBrowserCards = store.getState().dashboardLayout.browserCards; const newId = Object.keys(allBrowserCards).find((id) => !prevIds.has(id)); @@ -71,12 +85,13 @@ export function useDashboardCardActions({ handleHighlightCard(newId); } }, 200); - }, [dispatch, browserHomepage, expandedSessionIds, canvasActions, handleHighlightCard]); + }, [dispatch, browserHomepage, expandedSessionIds, getSpawnPlacement, canvasActions, handleHighlightCard]); const handleAddNote = useCallback(() => { report('dashboard', 'note_added'); const prevIds = new Set(Object.keys(store.getState().dashboardLayout.notes)); - dispatch(addNote({ expandedSessionIds })); + const pos = getSpawnPlacement(DEFAULT_NOTE_W, DEFAULT_NOTE_H); + dispatch(addNote({ expandedSessionIds, x: pos.x, y: pos.y })); setTimeout(() => { const allNotes = store.getState().dashboardLayout.notes; const newId = Object.keys(allNotes).find((id) => !prevIds.has(id)); @@ -86,7 +101,7 @@ export function useDashboardCardActions({ handleHighlightCard(newId); } }, 200); - }, [dispatch, expandedSessionIds, canvasActions, handleHighlightCard]); + }, [dispatch, expandedSessionIds, getSpawnPlacement, canvasActions, handleHighlightCard]); // Auto-clear pendingFocusNoteId after the note has had a chance to mount + autofocus. useEffect(() => { diff --git a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useSpawnPlacement.ts b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useSpawnPlacement.ts new file mode 100644 index 00000000..9779a511 --- /dev/null +++ b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useSpawnPlacement.ts @@ -0,0 +1,40 @@ +import { useCallback, type RefObject } from 'react'; +import { store } from '@/shared/state/store'; +import { computeSpawnPosition } from '@/shared/state/dashboardLayoutSlice'; +import { getCardRect } from '../../geometry/getCardRect'; +import type { useDashboardSelection } from '../state/useDashboardSelection'; + +type Selection = ReturnType; + +interface Args { + selection: Selection; + viewportRef: RefObject; + canvasStateRef: RefObject<{ panX: number; panY: number; zoom: number }>; + expandedSessionIds: string[]; +} + +// Resolves where a freshly created card (chat/app/browser/note) should land: docked beside the selected card if one is selected, otherwise centered in the current viewport ("in front of you"). Both paths collision-dodge inside computeSpawnPosition. Returns a getter that reads selection + the live store at call time, so the answer reflects whatever is selected the moment the user hits create. +export function useSpawnPlacement({ selection, viewportRef, canvasStateRef, expandedSessionIds }: Args) { + return useCallback( + (width: number, height: number): { x: number; y: number } => { + const layout = store.getState().dashboardLayout; + // Most-recently selected card wins as the dock anchor (Map preserves insertion order). + const selected = Array.from(selection.selectedIds.entries()); + if (selected.length > 0) { + const [id, type] = selected[selected.length - 1]; + const rect = getCardRect(id, type); + if (rect) return computeSpawnPosition(layout, width, height, { beside: rect }, expandedSessionIds); + } + const vp = viewportRef.current; + const cs = canvasStateRef.current; + if (vp && cs) { + const vr = vp.getBoundingClientRect(); + const cx = (vr.width / 2 - cs.panX) / cs.zoom; + const cy = (vr.height / 2 - cs.panY) / cs.zoom; + return computeSpawnPosition(layout, width, height, { viewportCenter: { x: cx, y: cy } }, expandedSessionIds); + } + return computeSpawnPosition(layout, width, height, {}, expandedSessionIds); + }, + [selection, viewportRef, canvasStateRef, expandedSessionIds], + ); +} diff --git a/frontend/src/app/pages/Dashboard/hooks/state/useDashboardController.ts b/frontend/src/app/pages/Dashboard/hooks/state/useDashboardController.ts index fff150d7..f86f74a3 100644 --- a/frontend/src/app/pages/Dashboard/hooks/state/useDashboardController.ts +++ b/frontend/src/app/pages/Dashboard/hooks/state/useDashboardController.ts @@ -247,6 +247,7 @@ export function useDashboardController(dashboardId: string, isActive: boolean) { expandedSessionIds, dashboardId, expandNewChats, + selection, canvasActions: canvas.actions, viewportRef: canvas.viewportRef, toolbarRef, @@ -274,6 +275,8 @@ export function useDashboardController(dashboardId: string, isActive: boolean) { selection, canvasActions: canvas.actions, getCardRect, + viewportRef: canvas.viewportRef, + canvasStateRef, handleHighlightCard, setAutoFocusSessionId, }); diff --git a/frontend/src/shared/state/dashboardLayoutSlice.ts b/frontend/src/shared/state/dashboardLayoutSlice.ts index 9720b790..f17f1ef6 100644 --- a/frontend/src/shared/state/dashboardLayoutSlice.ts +++ b/frontend/src/shared/state/dashboardLayoutSlice.ts @@ -469,6 +469,29 @@ export function placeInParentColumn( return placeBesideCard(state, parentCard, newW, newH, expandedSessionIds, exclude); } +// Where a user-created card (chat/app/browser/note) should land. Resolved in the UI layer where selection + viewport are known, then handed to the add reducers as an explicit x/y. `beside` (the currently selected card) docks the new card to its right, stacking under that column; `viewportCenter` (canvas-space center of what the user is looking at) drops it "in front of you". Both collision-dodge; with neither, falls back to the legacy top-left grid scan. +export interface SpawnAnchor { + beside?: { x: number; y: number; width: number; height: number }; + viewportCenter?: { x: number; y: number }; +} + +export function computeSpawnPosition( + state: DashboardLayoutState, + newW: number, + newH: number, + anchor: SpawnAnchor, + expandedSessionIds?: string[], +): { x: number; y: number } { + if (anchor.beside) { + return placeBesideCard(state, anchor.beside, newW, newH, expandedSessionIds); + } + const rects = collectOccupiedRects(state, expandedSessionIds); + if (anchor.viewportCenter) { + return findOpenSpotNear(anchor.viewportCenter.x - newW / 2, anchor.viewportCenter.y - newH / 2, rects, newW, newH); + } + return findOpenGridCell(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 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, @@ -766,11 +789,13 @@ const dashboardLayoutSlice = createSlice({ state.activeViewCardId = action.payload; }, - addBrowserCard(state, action: PayloadAction<{ url: string; expandedSessionIds?: string[] }>) { + addBrowserCard(state, action: PayloadAction<{ url: string; expandedSessionIds?: string[]; x?: number; y?: number }>) { const id = `browser-${Date.now().toString(36)}`; const tabId = generateTabId(); - const rects = collectOccupiedRects(state, action.payload.expandedSessionIds); - const pos = findOpenGridCell(rects, DEFAULT_BROWSER_CARD_W, DEFAULT_BROWSER_CARD_H); + // Caller may pre-resolve the spawn position (beside the selected card, or in front of the viewport); otherwise fall back to the top-left grid scan. + const pos = action.payload.x != null && action.payload.y != null + ? { x: action.payload.x, y: action.payload.y } + : findOpenGridCell(collectOccupiedRects(state, action.payload.expandedSessionIds), DEFAULT_BROWSER_CARD_W, DEFAULT_BROWSER_CARD_H); state.browserCards[id] = { browser_id: id, url: action.payload.url, From f8b9e44cd968f1f104583baf803180594dee53a7 Mon Sep 17 00:00:00 2001 From: SirKentut <81878031+SirKentut@users.noreply.github.com> Date: Thu, 2 Jul 2026 16:05:13 -0700 Subject: [PATCH 2/5] [pierre][fix]: dead-center new cards in viewport and on camera fit --- .../hooks/interaction/useCanvasControls.ts | 9 ++++++--- .../Dashboard/hooks/lifecycle/useAgentSpawn.ts | 11 +++++++---- .../hooks/lifecycle/useDashboardCardActions.ts | 17 ++++------------- .../hooks/lifecycle/useDashboardLifecycle.ts | 1 + .../src/shared/state/dashboardLayoutSlice.ts | 8 ++++---- 5 files changed, 22 insertions(+), 24 deletions(-) diff --git a/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts b/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts index 9a1d4cd1..1b1071f8 100644 --- a/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts +++ b/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts @@ -584,6 +584,7 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?: cardRects: Array<{ x: number; y: number; width: number; height: number }>, maxZoom?: number, minZoom?: number, + centered?: boolean, ): { panX: number; panY: number; zoom: number } | null => { const viewport = viewportRef.current; if (!viewport || cardRects.length === 0) return null; @@ -615,7 +616,8 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?: ); const targetPanX = (vRect.width - contentWidth * targetZoom) / 2 - minX * targetZoom; - const topBiased = cardRects.length === 1; + // A single card normally top-biases (header up top, no dead space below). On creation we want the opposite: the new card dead-centered "in front of you", so `centered` forces true vertical centering. + const topBiased = cardRects.length === 1 && !centered; const targetPanY = topBiased ? FIT_PADDING * 0.4 - minY * targetZoom : (vRect.height - contentHeight * targetZoom) / 2 - @@ -631,10 +633,11 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?: maxZoom?: number, animate?: boolean, minZoom?: number, + centered?: boolean, ) => { cancelAnimation(); - const target = computeFitTarget(cardRects, maxZoom, minZoom); + const target = computeFitTarget(cardRects, maxZoom, minZoom, centered); if (!target) { // Keep current camera; snapping to (0,0,1) used to desync the minimap. if (cardRects.length === 0 || !viewportRef.current) { @@ -652,7 +655,7 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?: // Settle pass: cancelAnimation() must be able to cancel it, else back-to-back fitToCards races and the first settle overwrites the second target. settleTimerRef.current = window.setTimeout(() => { settleTimerRef.current = null; - const fresh = computeFitTarget(cardRects, maxZoom, minZoom); + const fresh = computeFitTarget(cardRects, maxZoom, minZoom, centered); if (!fresh) return; const cur2 = stateRef.current; const drift = diff --git a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useAgentSpawn.ts b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useAgentSpawn.ts index 4d1b05df..d42ca6f1 100644 --- a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useAgentSpawn.ts +++ b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useAgentSpawn.ts @@ -13,6 +13,7 @@ import { import { placeCard, removeCard, + setCardPosition, setGlowingAgentCard, setGlowingBrowserCards, clearGlowingBrowserCards, @@ -144,8 +145,8 @@ export function useAgentSpawn({ // Toolbar position in canvas coords drives the spawn-from-toolbar grow animation. let origin: SpawnOrigin | null = null; - // Where the chat lands: beside the selected card, else in front of the viewport. Feeds the optimistic placement below, replacing the legacy toolbar-position anchor. - const spawnPos = getSpawnPlacement(DEFAULT_CARD_W, DEFAULT_CARD_H); + // Where the chat lands: beside the selected card, else in front of the viewport. Feeds the optimistic placement below, replacing the legacy toolbar-position anchor. Center on the height it will RENDER at (expanded chats are tall) so it lands vertically centered, not high-biased. + const spawnPos = getSpawnPlacement(DEFAULT_CARD_W, expandNewChats ? EXPANDED_CARD_MIN_H : DEFAULT_CARD_H); const toolbarEl = toolbarRef.current; const vpEl = viewportRef.current; if (toolbarEl && vpEl) { @@ -176,6 +177,8 @@ export function useAgentSpawn({ const anchorX = browserAnchor ? browserAnchor.x - DEFAULT_CARD_W - GRID_GAP * 12 : spawnPos.x; const anchorY = browserAnchor ? browserAnchor.y : spawnPos.y; dispatch(placeCard({ sessionId: draftId, x: anchorX, y: anchorY, width: DEFAULT_CARD_W, height: cardHeight, expandedSessionIds })); + // placeCard grid-snaps + collision-dodges, drifting the card off the resolved spawn point. Pin it back for the viewport-center / beside-card cases so it lands dead-center as intended; the single-browser dock keeps the dodge so it cascades off an occupied slot. + if (!browserAnchor) dispatch(setCardPosition({ sessionId: draftId, x: anchorX, y: anchorY })); if (expandNewChats) { dispatch(expandSession(draftId)); setAutoFocusSessionId(draftId); @@ -197,7 +200,7 @@ export function useAgentSpawn({ if (bc) rects.push({ x: bc.x, y: bc.y, width: bc.width, height: bc.height }); } } - canvasActions.fitToCards(rects, 1.15, true); + canvasActions.fitToCards(rects, 1.15, true, undefined, true); handleHighlightCard(draftId); } @@ -220,7 +223,7 @@ export function useAgentSpawn({ if (launchAndSendFirstMessage.fulfilled.match(action)) { const realId = action.payload.session.id; dispatch(generateTitle({ sessionId: realId, prompt })); - // Re-point focus/selection at the rekeyed real card; the draft id is gone after the in-place swap. The browser tether rekeys with the card in the dashboardLayout extraReducer. Placement already happened optimistically at spawn (spawnPos / browserAnchor), so there's nothing to re-place here. + // Re-point focus/selection at the rekeyed real card; the draft id is gone after the in-place swap. The browser tether rekeys with the card in the dashboardLayout extraReducer. Placement + centered fit already happened optimistically at spawn (spawnPos / browserAnchor), so there's nothing to re-place here. if (expandNewChats) setAutoFocusSessionId(realId); else setPendingSelectSessionId(realId); diff --git a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardCardActions.ts b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardCardActions.ts index 442cba75..89d743e4 100644 --- a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardCardActions.ts +++ b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardCardActions.ts @@ -65,7 +65,7 @@ export function useDashboardCardActions({ } const card = viewCards[focusKey]; if (card) { - canvasActions.fitToCards([{ x: card.x, y: card.y, width: card.width, height: card.height }], 1.15, true); + canvasActions.fitToCards([{ x: card.x, y: card.y, width: card.width, height: card.height }], 1.15, true, undefined, true); handleHighlightCard(focusKey); } }, 200); @@ -73,19 +73,10 @@ export function useDashboardCardActions({ const handleAddBrowser = useCallback(() => { report('dashboard', 'browser_added'); - const prevIds = new Set(Object.keys(store.getState().dashboardLayout.browserCards)); + // Camera focus + highlight are handled by the pendingFocusBrowserId effect (useDashboardLifecycle), which fires for browsers from every path (toolbar, link clicks). Doing it here too would double-fit and fight that effect's zoom. const pos = getSpawnPlacement(DEFAULT_BROWSER_CARD_W, DEFAULT_BROWSER_CARD_H); dispatch(addBrowserCard({ url: browserHomepage, expandedSessionIds, x: pos.x, y: pos.y })); - setTimeout(() => { - const allBrowserCards = store.getState().dashboardLayout.browserCards; - const newId = Object.keys(allBrowserCards).find((id) => !prevIds.has(id)); - if (newId) { - const card = allBrowserCards[newId]; - canvasActions.fitToCards([{ x: card.x, y: card.y, width: card.width, height: card.height }], 1.15, true); - handleHighlightCard(newId); - } - }, 200); - }, [dispatch, browserHomepage, expandedSessionIds, getSpawnPlacement, canvasActions, handleHighlightCard]); + }, [dispatch, browserHomepage, expandedSessionIds, getSpawnPlacement]); const handleAddNote = useCallback(() => { report('dashboard', 'note_added'); @@ -97,7 +88,7 @@ export function useDashboardCardActions({ const newId = Object.keys(allNotes).find((id) => !prevIds.has(id)); if (newId) { const note = allNotes[newId]; - canvasActions.fitToCards([{ x: note.x, y: note.y, width: note.width, height: note.height }], 1.15, true); + canvasActions.fitToCards([{ x: note.x, y: note.y, width: note.width, height: note.height }], 1.15, true, undefined, true); handleHighlightCard(newId); } }, 200); diff --git a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts index eb30d497..377e33fb 100644 --- a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts +++ b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts @@ -227,6 +227,7 @@ export function useDashboardLifecycle({ 1.15, true, 0.8, + true, ); handleHighlightCard(browserId); } diff --git a/frontend/src/shared/state/dashboardLayoutSlice.ts b/frontend/src/shared/state/dashboardLayoutSlice.ts index f17f1ef6..605c785d 100644 --- a/frontend/src/shared/state/dashboardLayoutSlice.ts +++ b/frontend/src/shared/state/dashboardLayoutSlice.ts @@ -469,7 +469,7 @@ export function placeInParentColumn( return placeBesideCard(state, parentCard, newW, newH, expandedSessionIds, exclude); } -// Where a user-created card (chat/app/browser/note) should land. Resolved in the UI layer where selection + viewport are known, then handed to the add reducers as an explicit x/y. `beside` (the currently selected card) docks the new card to its right, stacking under that column; `viewportCenter` (canvas-space center of what the user is looking at) drops it "in front of you". Both collision-dodge; with neither, falls back to the legacy top-left grid scan. +// Where a user-created card (chat/app/browser/note) should land. Resolved in the UI layer where selection + viewport are known, then handed to the add reducers as an explicit x/y. `beside` (the currently selected card) docks the new card to its right, stacking under that column (collision-aware); `viewportCenter` (canvas-space center of what the user is looking at) drops it dead-center "in front of you", overlapping whatever's there. With neither, falls back to the legacy top-left grid scan. export interface SpawnAnchor { beside?: { x: number; y: number; width: number; height: number }; viewportCenter?: { x: number; y: number }; @@ -485,11 +485,11 @@ export function computeSpawnPosition( if (anchor.beside) { return placeBesideCard(state, anchor.beside, newW, newH, expandedSessionIds); } - const rects = collectOccupiedRects(state, expandedSessionIds); if (anchor.viewportCenter) { - return findOpenSpotNear(anchor.viewportCenter.x - newW / 2, anchor.viewportCenter.y - newH / 2, rects, newW, newH); + // Land dead-center, "in front of you", even if a card is already there. Overlap is intentional (new card sits on top via its higher zOrder); dodging to free space is exactly the "spawned off to the side" behavior we're removing. + return { x: anchor.viewportCenter.x - newW / 2, y: anchor.viewportCenter.y - newH / 2 }; } - return findOpenGridCell(rects, newW, newH); + return findOpenGridCell(collectOccupiedRects(state, expandedSessionIds), 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 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. From c4789ebdab182fbffd5c3cc6ff47814253e74bdd Mon Sep 17 00:00:00 2001 From: SirKentut <81878031+SirKentut@users.noreply.github.com> Date: Mon, 6 Jul 2026 19:26:38 -0700 Subject: [PATCH 3/5] [pierre][refactor]: placeCard exact flag replaces the placeCard+setCardPosition double-dispatch The optimistic spawn placed the chat with placeCard (which grid-snaps + collision-dodges) then immediately overwrote it with setCardPosition to force the exact viewport-center/beside-card spot. Fold that into a single placeCard({ ..., exact }) so the pre-resolved position is honored directly; the single-browser dock omits exact to keep the collision-dodge cascade. --- .../app/pages/Dashboard/hooks/lifecycle/useAgentSpawn.ts | 6 ++---- frontend/src/shared/state/dashboardLayoutSlice.ts | 7 ++++--- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useAgentSpawn.ts b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useAgentSpawn.ts index d42ca6f1..08d71662 100644 --- a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useAgentSpawn.ts +++ b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useAgentSpawn.ts @@ -13,7 +13,6 @@ import { import { placeCard, removeCard, - setCardPosition, setGlowingAgentCard, setGlowingBrowserCards, clearGlowingBrowserCards, @@ -176,9 +175,8 @@ export function useAgentSpawn({ const cardHeight = expandNewChats ? EXPANDED_CARD_MIN_H : DEFAULT_CARD_H; const anchorX = browserAnchor ? browserAnchor.x - DEFAULT_CARD_W - GRID_GAP * 12 : spawnPos.x; const anchorY = browserAnchor ? browserAnchor.y : spawnPos.y; - dispatch(placeCard({ sessionId: draftId, x: anchorX, y: anchorY, width: DEFAULT_CARD_W, height: cardHeight, expandedSessionIds })); - // placeCard grid-snaps + collision-dodges, drifting the card off the resolved spawn point. Pin it back for the viewport-center / beside-card cases so it lands dead-center as intended; the single-browser dock keeps the dodge so it cascades off an occupied slot. - if (!browserAnchor) dispatch(setCardPosition({ sessionId: draftId, x: anchorX, y: anchorY })); + // exact pins the resolved spawn point for the viewport-center / beside-card cases (dead-center, overlap allowed); the single-browser dock omits it so placeCard's collision-dodge cascades the chat off an occupied slot. + dispatch(placeCard({ sessionId: draftId, x: anchorX, y: anchorY, width: DEFAULT_CARD_W, height: cardHeight, expandedSessionIds, exact: !browserAnchor })); if (expandNewChats) { dispatch(expandSession(draftId)); setAutoFocusSessionId(draftId); diff --git a/frontend/src/shared/state/dashboardLayoutSlice.ts b/frontend/src/shared/state/dashboardLayoutSlice.ts index 605c785d..85364346 100644 --- a/frontend/src/shared/state/dashboardLayoutSlice.ts +++ b/frontend/src/shared/state/dashboardLayoutSlice.ts @@ -545,11 +545,12 @@ const dashboardLayoutSlice = createSlice({ height: number; // Optional: which existing sessions are currently expanded (showing their full chat history). Without this, the collision check uses each card's STORED height, which is the collapsed value, even when the card is currently rendering at the expanded ~620px. Result: new sub-agent cards spawn into the collapsed footprint but overlap the visually expanded one. Caller (Dashboard.tsx) passes the current expanded set so the collision math matches what the user actually sees. expandedSessionIds?: string[]; + // Honor the given x/y verbatim (dead-center "in front of you", overlap allowed) instead of collision-dodging to a free grid cell. Set when the caller already resolved the spot (viewport center / beside a selected card) and dodging would defeat that; tidyLayout cleans up any overlap on demand. + exact?: boolean; }> ) { - const { sessionId, x, y, width, height, expandedSessionIds } = action.payload; - const rects = collectOccupiedRects(state, expandedSessionIds); - const pos = findOpenSpotNear(x, y, rects, width, height); + const { sessionId, x, y, width, height, expandedSessionIds, exact } = action.payload; + const pos = exact ? { x, y } : findOpenSpotNear(x, y, collectOccupiedRects(state, expandedSessionIds), width, height); state.cards[sessionId] = { session_id: sessionId, x: pos.x, From d1713f901c7cf0895ad4ef059237e05bdcfbd817 Mon Sep 17 00:00:00 2001 From: SirKentut <81878031+SirKentut@users.noreply.github.com> Date: Tue, 7 Jul 2026 18:14:17 -0700 Subject: [PATCH 4/5] [pierre] feat: browser spawned from agent will always spawn to the right of parent chat --- frontend/src/shared/state/dashboardLayoutSlice.ts | 14 ++++++++++++++ frontend/src/shared/ws/WebSocketManager.ts | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/frontend/src/shared/state/dashboardLayoutSlice.ts b/frontend/src/shared/state/dashboardLayoutSlice.ts index 85364346..5c70d0b7 100644 --- a/frontend/src/shared/state/dashboardLayoutSlice.ts +++ b/frontend/src/shared/state/dashboardLayoutSlice.ts @@ -419,6 +419,7 @@ export function placeBesideCard( exclude?: CardPlacementExclusion, gap: number = GRID_GAP * 12, exact: boolean = false, + overlap: boolean = false, ): { x: number; y: number } { const rects = collectOccupiedRects(state, expandedSessionIds, exclude); const targetX = anchor.x + anchor.width + gap; @@ -434,6 +435,8 @@ export function placeBesideCard( ? Math.max(...columnCards.map((c) => c.y + c.height)) + GRID_GAP : anchor.y; + // overlap: dock beside the anchor no matter what else is there (the new card sits on top via its higher zOrder). A chat-spawned browser must land next to its chat even onto an occupied spot; dodging to a free grid cell is the "spawned off to the side" behavior we're removing. Still stacks under this chat's own browser column (targetY) so sibling browsers don't cover each other. + if (overlap) return { x: targetX, y: targetY }; // exact keeps the precise gap (so the card mirrors however its anchor was placed, e.g. a run browser matching the hub->monitor gap); grid-snapping would knock that gap off. Fall back to the snapped search only if the exact spot is taken. if (exact && !rects.some((r) => rectsOverlap({ x: targetX, y: targetY, w: newW, h: newH }, r))) { return { x: targetX, y: targetY }; @@ -1652,6 +1655,17 @@ const dashboardLayoutSlice = createSlice({ for (const entry of Object.values(state.glowingBrowserCards)) { if (entry.sourceId === draftId) entry.sourceId = session.id; } + // First-turn browser race: a browser the first message spawns carries parent_session_id = the real id, so its browser_card_added can land BEFORE this re-key, find no parent card, and fall back to the grid. Now that the chat card exists under the real id, dock each such browser beside it (freshly spawned, so not user-moved yet) and restore the tether the racing path skipped. + const parentCard = state.cards[session.id]; + if (parentCard) { + for (const bc of Object.values(state.browserCards)) { + if (bc.spawned_by !== session.id) continue; + const pos = placeBesideCard(state, parentCard, bc.width, bc.height, undefined, { type: 'browser' as const, id: bc.browser_id }, undefined, false, true); + bc.x = pos.x; + bc.y = pos.y; + state.glowingBrowserCards[bc.browser_id] = { sourceId: session.id, fading: false, label: 'Use Browser' }; + } + } }); }, }); diff --git a/frontend/src/shared/ws/WebSocketManager.ts b/frontend/src/shared/ws/WebSocketManager.ts index 9771608f..c4507e17 100644 --- a/frontend/src/shared/ws/WebSocketManager.ts +++ b/frontend/src/shared/ws/WebSocketManager.ts @@ -791,7 +791,7 @@ class WebSocketManager { let pos: { x: number; y: number } | null = null; let glowLabel = 'Use Browser'; if (parentCard) { - pos = placeBesideCard(layoutState, parentCard, browserCard.width, browserCard.height, undefined, exclude); + pos = placeBesideCard(layoutState, parentCard, browserCard.width, browserCard.height, undefined, exclude, undefined, false, true); } else if (sess?.workflow_run_id && layoutState.workflowsMonitorCard) { pos = placeBesideCard(layoutState, layoutState.workflowsMonitorCard, browserCard.width, browserCard.height, undefined, exclude, WORKFLOW_CARD_GAP, true); } else if (sess?.workflow_edit_id && layoutState.workflowsHub) { From ae563ee9d58b51d6389932567b0fd75764957e55 Mon Sep 17 00:00:00 2001 From: SirKentut <81878031+SirKentut@users.noreply.github.com> Date: Wed, 8 Jul 2026 18:14:00 -0700 Subject: [PATCH 5/5] [pierre] fix: deterministic browser spawn --- .../src/shared/state/dashboardLayoutSlice.ts | 24 +++++++++++++++---- frontend/src/shared/ws/WebSocketManager.ts | 4 ++-- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/frontend/src/shared/state/dashboardLayoutSlice.ts b/frontend/src/shared/state/dashboardLayoutSlice.ts index 5c70d0b7..18ff1735 100644 --- a/frontend/src/shared/state/dashboardLayoutSlice.ts +++ b/frontend/src/shared/state/dashboardLayoutSlice.ts @@ -419,7 +419,6 @@ export function placeBesideCard( exclude?: CardPlacementExclusion, gap: number = GRID_GAP * 12, exact: boolean = false, - overlap: boolean = false, ): { x: number; y: number } { const rects = collectOccupiedRects(state, expandedSessionIds, exclude); const targetX = anchor.x + anchor.width + gap; @@ -435,8 +434,6 @@ export function placeBesideCard( ? Math.max(...columnCards.map((c) => c.y + c.height)) + GRID_GAP : anchor.y; - // overlap: dock beside the anchor no matter what else is there (the new card sits on top via its higher zOrder). A chat-spawned browser must land next to its chat even onto an occupied spot; dodging to a free grid cell is the "spawned off to the side" behavior we're removing. Still stacks under this chat's own browser column (targetY) so sibling browsers don't cover each other. - if (overlap) return { x: targetX, y: targetY }; // exact keeps the precise gap (so the card mirrors however its anchor was placed, e.g. a run browser matching the hub->monitor gap); grid-snapping would knock that gap off. Fall back to the snapped search only if the exact spot is taken. if (exact && !rects.some((r) => rectsOverlap({ x: targetX, y: targetY, w: newW, h: newH }, r))) { return { x: targetX, y: targetY }; @@ -444,6 +441,25 @@ export function placeBesideCard( return findOpenSpotNear(targetX, targetY, rects, newW, newH); } +// Dock a chat-spawned browser to the right of its chat card. Unlike placeBesideCard, this ALWAYS lands beside the chat (overlap is fine, the new card sits on top via zOrder) so an occupied spot can never fling the browser to a far grid cell or stack it under an unrelated card. Only this chat's OWN browsers (same spawned_by, still in the column) stack under each other so siblings don't fully cover one another; every other card is ignored. +export function placeBrowserBesideChat( + state: DashboardLayoutState, + chat: { x: number; y: number; width: number; height: number }, + parentSessionId: string, + newW: number, + newH: number, + excludeBrowserId?: string, +): { x: number; y: number } { + const targetX = chat.x + chat.width + GRID_GAP * 12; + const siblings = Object.values(state.browserCards).filter( + (c) => c.browser_id !== excludeBrowserId && c.spawned_by === parentSessionId && Math.abs(c.x - targetX) < 50, + ); + const targetY = siblings.length > 0 + ? Math.max(...siblings.map((c) => c.y + c.height)) + GRID_GAP + : chat.y; + return { x: targetX, y: targetY }; +} + // 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, @@ -1660,7 +1676,7 @@ const dashboardLayoutSlice = createSlice({ if (parentCard) { for (const bc of Object.values(state.browserCards)) { if (bc.spawned_by !== session.id) continue; - const pos = placeBesideCard(state, parentCard, bc.width, bc.height, undefined, { type: 'browser' as const, id: bc.browser_id }, undefined, false, true); + const pos = placeBrowserBesideChat(state, parentCard, session.id, bc.width, bc.height, bc.browser_id); bc.x = pos.x; bc.y = pos.y; state.glowingBrowserCards[bc.browser_id] = { sourceId: session.id, fading: false, label: 'Use Browser' }; diff --git a/frontend/src/shared/ws/WebSocketManager.ts b/frontend/src/shared/ws/WebSocketManager.ts index c4507e17..f1d3d447 100644 --- a/frontend/src/shared/ws/WebSocketManager.ts +++ b/frontend/src/shared/ws/WebSocketManager.ts @@ -27,7 +27,7 @@ import { clearTurnLabel, } from '../state/agentsSlice'; import { streamStart, streamDelta, streamEnd, clearStreamingForSession } from '../state/streamingSlice'; -import { addBrowserCardFromBackend, markBrowserCardEnding, keepBrowserCardOpen, placeBesideCard, placeBelowCard, setBrowserCardPosition, setGlowingBrowserCards, GRID_GAP, WORKFLOW_CARD_GAP, openWorkflowsApp, openWorkflowMonitor } from '../state/dashboardLayoutSlice'; +import { addBrowserCardFromBackend, markBrowserCardEnding, keepBrowserCardOpen, placeBesideCard, placeBelowCard, placeBrowserBesideChat, 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'; @@ -791,7 +791,7 @@ class WebSocketManager { let pos: { x: number; y: number } | null = null; let glowLabel = 'Use Browser'; if (parentCard) { - pos = placeBesideCard(layoutState, parentCard, browserCard.width, browserCard.height, undefined, exclude, undefined, false, true); + pos = placeBrowserBesideChat(layoutState, parentCard, parentId, browserCard.width, browserCard.height, browserCard.browser_id); } else if (sess?.workflow_run_id && layoutState.workflowsMonitorCard) { pos = placeBesideCard(layoutState, layoutState.workflowsMonitorCard, browserCard.width, browserCard.height, undefined, exclude, WORKFLOW_CARD_GAP, true); } else if (sess?.workflow_edit_id && layoutState.workflowsHub) {