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.