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,