[pierre][fix]: dead-center new cards in viewport and on camera fit

This commit is contained in:
SirKentut
2026-07-07 18:47:12 -07:00
parent c3d69d175d
commit f8b9e44cd9
5 changed files with 22 additions and 24 deletions
@@ -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 =
@@ -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);
@@ -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);
@@ -227,6 +227,7 @@ export function useDashboardLifecycle({
1.15,
true,
0.8,
true,
);
handleHighlightCard(browserId);
}
@@ -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.