mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-13 05:07:40 +02:00
[pierre][fix]: spawn new dashboard cards beside selected card or viewport center
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user