[pierre][fix]: spawn new dashboard cards beside selected card or viewport center

This commit is contained in:
SirKentut
2026-07-07 18:47:12 -07:00
parent 09262801d9
commit c3d69d175d
5 changed files with 105 additions and 14 deletions
@@ -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<typeof useDashboardSelection>;
interface UseAgentSpawnArgs {
cards: Record<string, CardPosition>;
expandedSessionIds: string[];
dashboardId: string;
expandNewChats: boolean;
selection: Selection;
canvasActions: CanvasActions;
viewportRef: RefObject<HTMLDivElement | null>;
toolbarRef: RefObject<HTMLDivElement | null>;
@@ -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 {
@@ -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<typeof useDashboardSelection>;
@@ -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<HTMLDivElement | null>;
canvasStateRef: RefObject<{ panX: number; panY: number; zoom: number }>;
handleHighlightCard: (cardId: string) => void;
setAutoFocusSessionId: Dispatch<SetStateAction<string | null>>;
}
@@ -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(() => {
@@ -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<typeof useDashboardSelection>;
interface Args {
selection: Selection;
viewportRef: RefObject<HTMLDivElement | null>;
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],
);
}
@@ -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,
});
@@ -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<T extends { x: number; y: number; width: number; height: number }>(
live: Record<string, T>,
@@ -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,