mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-11 12:17:45 +02:00
[pierre][fix]: dead-center new cards in viewport and on camera fit
This commit is contained in:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user