From 244249dade2b04a38952503e40974b90d50ddccb Mon Sep 17 00:00:00 2001 From: ciregenz Date: Sat, 23 May 2026 06:57:54 -0700 Subject: [PATCH] [eric] split: extract dashboard lifecycle, interactions, card actions, JSX layers --- .../src/app/pages/Dashboard/Dashboard.tsx | 1402 +---------------- .../app/pages/Dashboard/DashboardCanvas.tsx | 295 ++++ .../pages/Dashboard/DashboardCardLayer.tsx | 275 ++++ .../pages/Dashboard/DashboardEmptyState.tsx | 39 + .../app/pages/Dashboard/DashboardOverlays.tsx | 137 ++ .../src/app/pages/Dashboard/contentBounds.ts | 35 + .../src/app/pages/Dashboard/getCardRect.ts | 28 + .../src/app/pages/Dashboard/useAgentSpawn.ts | 231 +++ .../Dashboard/useDashboardCardActions.ts | 146 ++ .../pages/Dashboard/useDashboardController.ts | 273 ++++ .../Dashboard/useDashboardInteractions.ts | 168 ++ .../pages/Dashboard/useDashboardLifecycle.ts | 247 +++ .../pages/Dashboard/useDashboardSelectors.ts | 49 + .../pages/Dashboard/useDashboardThumbnail.ts | 75 + .../pages/Dashboard/useDashboardUiState.ts | 95 ++ .../src/app/pages/Dashboard/useLayoutSave.ts | 73 + .../app/pages/Dashboard/useSiblingRestack.ts | 111 ++ 17 files changed, 2285 insertions(+), 1394 deletions(-) create mode 100644 frontend/src/app/pages/Dashboard/DashboardCanvas.tsx create mode 100644 frontend/src/app/pages/Dashboard/DashboardCardLayer.tsx create mode 100644 frontend/src/app/pages/Dashboard/DashboardEmptyState.tsx create mode 100644 frontend/src/app/pages/Dashboard/DashboardOverlays.tsx create mode 100644 frontend/src/app/pages/Dashboard/contentBounds.ts create mode 100644 frontend/src/app/pages/Dashboard/getCardRect.ts create mode 100644 frontend/src/app/pages/Dashboard/useAgentSpawn.ts create mode 100644 frontend/src/app/pages/Dashboard/useDashboardCardActions.ts create mode 100644 frontend/src/app/pages/Dashboard/useDashboardController.ts create mode 100644 frontend/src/app/pages/Dashboard/useDashboardInteractions.ts create mode 100644 frontend/src/app/pages/Dashboard/useDashboardLifecycle.ts create mode 100644 frontend/src/app/pages/Dashboard/useDashboardSelectors.ts create mode 100644 frontend/src/app/pages/Dashboard/useDashboardThumbnail.ts create mode 100644 frontend/src/app/pages/Dashboard/useDashboardUiState.ts create mode 100644 frontend/src/app/pages/Dashboard/useLayoutSave.ts create mode 100644 frontend/src/app/pages/Dashboard/useSiblingRestack.ts diff --git a/frontend/src/app/pages/Dashboard/Dashboard.tsx b/frontend/src/app/pages/Dashboard/Dashboard.tsx index 9867d434..8584cb7a 100644 --- a/frontend/src/app/pages/Dashboard/Dashboard.tsx +++ b/frontend/src/app/pages/Dashboard/Dashboard.tsx @@ -1,1412 +1,26 @@ -import React, { useEffect, useCallback, useRef, useState, useMemo } from 'react'; -import { AnimatePresence, motion } from 'framer-motion'; -import Box from '@mui/material/Box'; -import Typography from '@mui/material/Typography'; -import DashboardHeader from './DashboardHeader'; -import { report } from '@/shared/serviceClient'; -import { useAppDispatch, useAppSelector } from '@/shared/hooks'; -import { store } from '@/shared/state/store'; -import { - fetchSessions, - fetchHistory, - collapseSession, - closeSession, - duplicateSession, - expandSession, - launchAndSendFirstMessage, - generateTitle, - resumeSession, - setExpandedSessionIds, - toggleExpandSession, -} from '@/shared/state/agentsSlice'; -import type { AgentConfig } from '@/shared/state/agentsSlice'; -import { - fetchLayout, - saveLayout, - reconcileSessions, - tidyLayout, - addViewCard, - addBrowserCard, - moveCards, - resetLayout, - setGlowingBrowserCards, - removeViewCard, - removeBrowserCard, - pasteBrowserCard, - placeCard, - setCardPosition, - removeCard, - bringToFront, - setGlowingAgentCard, - clearGlowingAgentCard, - clearPendingFocusBrowserId, - addNote, - removeNote, - clearPendingFocusNoteId, - DEFAULT_CARD_W, - DEFAULT_CARD_H, - EXPANDED_CARD_MIN_H, - GRID_GAP, -} from '@/shared/state/dashboardLayoutSlice'; -import { fetchOutputs } from '@/shared/state/outputsSlice'; -import { generateDashboardName, updateDashboardThumbnail } from '@/shared/state/dashboardsSlice'; -import { dashboardWs } from '@/shared/ws/WebSocketManager'; -import { initBrowserCommandHandler } from '@/shared/browserCommandHandler'; -import { clearPendingBrowserUrl, clearPendingFocusAgentId } from '@/shared/state/tempStateSlice'; -import AgentCard from './AgentCard'; -import DashboardViewCard from './DashboardViewCard'; -import BrowserCard from './BrowserCard'; -import NoteCard from './NoteCard'; -import CanvasControls from './CanvasControls'; -import CardSearchPalette from './CardSearchPalette'; -import DirectionHints from './DirectionHints'; -// OnboardingWalkthrough was retired in v2 , the new OnboardingRoot/Panel -// (mounted in Main.tsx) replaces it. Keeping this banner to prevent stale -// imports from sneaking back in via auto-completion. -import DashboardToolbar from './DashboardToolbar'; -import { captureDashboardThumbnail } from './captureDashboardThumbnail'; -import { useCanvasControls } from './useCanvasControls'; -import { useDashboardSelection } from './useDashboardSelection'; -import type { CardType } from './useDashboardSelection'; -import { useClaudeTokens } from '@/shared/styles/ThemeContext'; -import type { ContextPath } from '@/app/components/DirectoryBrowser'; -import { ElementSelectionProvider, useElementSelection } from '@/app/components/ElementSelectionContext'; -import { useDomElementSelector } from '@/app/components/useDomElementSelector'; +import React from 'react'; import SelectionOverlay from '@/app/components/SelectionOverlay'; -import { setClipboardCards, getClipboardCards, type ClipboardCard } from '@/shared/dashboardClipboard'; -import { API_BASE } from '@/shared/config'; -import { useTethers } from './dashboardTethers'; -import TetherLayer from './TetherLayer'; -import { useArrowNav } from './useArrowNav'; -import { useDashboardShortcuts } from './useDashboardShortcuts'; -import { useDashboardClipboard } from './useDashboardClipboard'; -import { useCardDrag } from './useCardDrag'; -import { useSubAgentLifecycle } from './useSubAgentLifecycle'; - -const SELECT_ATTR = 'data-select-type'; +import { ElementSelectionProvider } from '@/app/components/ElementSelectionContext'; +import { useDomElementSelector } from '@/app/components/useDomElementSelector'; +import { useDashboardController } from './useDashboardController'; +import DashboardCanvas from './DashboardCanvas'; const DashboardSelectionOverlay: React.FC = () => { const { overlay, dragRect, dragPreview } = useDomElementSelector(); return ; }; -function isCardTarget(target: EventTarget | null, boundary: EventTarget | null): boolean { - let el = target as HTMLElement | null; - while (el && el !== boundary) { - if (el.hasAttribute(SELECT_ATTR)) return true; - el = el.parentElement; - } - return false; -} - interface DashboardProps { dashboardId: string; isActive?: boolean; } const DashboardInner: React.FC = ({ dashboardId, isActive = true }) => { - const c = useClaudeTokens(); - const dispatch = useAppDispatch(); - const elementSelectionCtx = useElementSelection(); - const isElementSelectMode = elementSelectionCtx?.selectMode ?? false; - const dashboardName = useAppSelector((state) => - dashboardId ? state.dashboards.items[dashboardId]?.name : undefined, - ); - const sessions = useAppSelector((state) => state.agents.sessions); - const expandedSessionIds = useAppSelector((state) => state.agents.expandedSessionIds); - const cards = useAppSelector((state) => state.dashboardLayout.cards); - const viewCards = useAppSelector((state) => state.dashboardLayout.viewCards); - const browserCards = useAppSelector((state) => state.dashboardLayout.browserCards); - const notes = useAppSelector((state) => state.dashboardLayout.notes); - const pendingFocusNoteId = useAppSelector((state) => state.dashboardLayout.pendingFocusNoteId); - const layoutInitialized = useAppSelector((state) => state.dashboardLayout.initialized); - const persistedExpandedSessionIds = useAppSelector((state) => state.dashboardLayout.persistedExpandedSessionIds); - const zoomSensitivity = useAppSelector((state) => state.settings.data.zoom_sensitivity); - const newAgentShortcut = useAppSelector((state) => state.settings.data.new_agent_shortcut); - const browserHomepage = useAppSelector((state) => state.settings.data.browser_homepage); - const expandNewChats = useAppSelector((state) => state.settings.data.expand_new_chats_in_dashboard); - const autoRevealSubAgents = useAppSelector((state) => state.settings.data.auto_reveal_sub_agents); - const outputs = useAppSelector((state) => state.outputs.items); - const outputsLoaded = useAppSelector((state) => state.outputs.loaded); - const glowingAgentCards = useAppSelector((state) => state.dashboardLayout.glowingAgentCards); - const glowingBrowserCards = useAppSelector((state) => state.dashboardLayout.glowingBrowserCards); - // sessions is the top-level dict; useMemo on its identity so sessionList - // is stable when sessions hasn't actually changed (RTK only swaps the dict - // ref when one of its values changes, so this is the right granularity). - const sessionList = useMemo(() => Object.values(sessions), [sessions]); - - const contentBounds = useMemo(() => { - const allRects = [ - ...Object.values(cards).map((c) => ({ x: c.x, y: c.y, w: c.width, h: c.height })), - ...Object.values(viewCards).map((c) => ({ x: c.x, y: c.y, w: c.width, h: c.height })), - ...Object.values(browserCards).map((c) => ({ x: c.x, y: c.y, w: c.width, h: c.height })), - ]; - if (allRects.length === 0) return undefined; - let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; - for (const r of allRects) { - minX = Math.min(minX, r.x); - minY = Math.min(minY, r.y); - maxX = Math.max(maxX, r.x + r.w); - maxY = Math.max(maxY, r.y + r.h); - } - return { minX, minY, maxX, maxY }; - }, [cards, viewCards, browserCards]); - - const canvas = useCanvasControls(zoomSensitivity, contentBounds, isActive); - const selection = useDashboardSelection( - { panX: canvas.panX, panY: canvas.panY, zoom: canvas.zoom, viewportRef: canvas.viewportRef }, - cards, - viewCards, - browserCards, - notes, - ); - const toolbarRef = useRef(null); - - const [toolbarOpen, setToolbarOpen] = useState(false); - const [searchPaletteOpen, setSearchPaletteOpen] = useState(false); - const [highlightedCardId, setHighlightedCardId] = useState(null); - const highlightTimerRef = useRef | null>(null); - const [autoFocusSessionId, setAutoFocusSessionId] = useState(null); - const [pendingSelectSessionId, setPendingSelectSessionId] = useState(null); - const [focusedCardId, setFocusedCardId] = useState(null); - const [newAgentBounce, setNewAgentBounce] = useState(false); - // Cleanup any leftover walkthrough localStorage from v1 , the v2 panel - // ignores it but it would otherwise hang around forever. - useEffect(() => { - try { - localStorage.removeItem('openswarm_walkthrough_pending'); - } catch { /* ignore */ } - }, []); - - const handleHighlightCard = useCallback((cardId: string) => { - if (highlightTimerRef.current) clearTimeout(highlightTimerRef.current); - setHighlightedCardId(cardId); - highlightTimerRef.current = setTimeout(() => { - setHighlightedCardId(null); - highlightTimerRef.current = null; - }, 2000); - }, []); - - useEffect(() => { - if (autoFocusSessionId) { - const timer = setTimeout(() => setAutoFocusSessionId(null), 1500); - return () => clearTimeout(timer); - } - }, [autoFocusSessionId]); - - useEffect(() => { - if (!pendingSelectSessionId) return; - if (!cards[pendingSelectSessionId]) return; - setPendingSelectSessionId(null); - selection.selectCard(pendingSelectSessionId, 'agent', false); - }, [pendingSelectSessionId, cards, selection]); - - const spawnOriginsRef = useRef>({}); - const measuredHeightsRef = useRef>({}); - const [measuredHeightsTick, setMeasuredHeightsTick] = useState(0); - const handleMeasuredHeight = useCallback((sessionId: string, height: number) => { - if (measuredHeightsRef.current[sessionId] !== height) { - measuredHeightsRef.current[sessionId] = height; - setMeasuredHeightsTick((t) => t + 1); - } - }, []); - const revealSpawnedRef = useRef(new Set()); - useEffect(() => { - revealSpawnedRef.current.forEach((id) => { - if (!cards[id]) revealSpawnedRef.current.delete(id); - }); - }, [cards]); - const hasFittedRef = useRef(false); - const restoredExpandedRef = useRef(false); - const canvasStateRef = useRef({ panX: canvas.panX, panY: canvas.panY, zoom: canvas.zoom }); - canvasStateRef.current = { panX: canvas.panX, panY: canvas.panY, zoom: canvas.zoom }; - // Stable getter , AgentCards read pan/zoom on demand during drag math. - const getCanvasState = useCallback(() => canvasStateRef.current, []); - - const { - multiDragDelta, - liveDragInfo, - handleCardDragStart, - handleCardDragMove, - handleCardDragEnd, - } = useCardDrag({ - panX: canvas.panX, - panY: canvas.panY, - zoom: canvas.zoom, - viewportRef: canvas.viewportRef, - canvasActions: canvas.actions, - selection, - }); - - // Helper: get a card's rect from Redux state (uses collapsed height for zoom calculation) - const getCardRect = useCallback((id: string, type: CardType) => { - const layoutState = store.getState().dashboardLayout; - if (type === 'agent') { - const card = layoutState.cards[id]; - if (!card) return undefined; - return { x: card.x, y: card.y, width: card.width, height: card.height }; - } else if (type === 'view') { - const vc = layoutState.viewCards[id]; - if (!vc) return undefined; - return { x: vc.x, y: vc.y, width: vc.width, height: vc.height }; - } else if (type === 'browser') { - const bc = layoutState.browserCards[id]; - if (!bc) return undefined; - return { x: bc.x, y: bc.y, width: bc.width, height: bc.height }; - } else if (type === 'note') { - const n = layoutState.notes[id]; - if (!n) return undefined; - return { x: n.x, y: n.y, width: n.width, height: n.height }; - } - return undefined; - }, []); - - // Delay single-click collapse so double-click can override - const clickTimerRef = useRef | null>(null); - - const handleCardSelect = useCallback((id: string, type: CardType, shiftKey: boolean) => { - report('dashboard', 'card_clicked', { card_type: type, shift: shiftKey }); - if (shiftKey) { - selection.selectCard(id, type, true); - return; - } - - selection.selectCard(id, type, false); - dispatch(bringToFront({ id, type })); - - const alreadyExpanded = type === 'agent' && expandedSessionIds.includes(id); - - if (alreadyExpanded) { - // Delay single-click collapse so double-click can override. - // Double-click handler (handleCardDoubleClick) clears clickTimerRef. - clickTimerRef.current = setTimeout(() => { - clickTimerRef.current = null; - dispatch(collapseSession(id)); - }, 250); - return; - } - - // Expand (if not already) + center + zoom + bring to front - if (type === 'agent') { - dispatch(expandSession(id)); - } - setFocusedCardId(id); - setTimeout(() => { - const rect = getCardRect(id, type); - if (rect) canvas.actions.fitToCards([rect], 1.15, true, type === 'browser' ? 0.8 : undefined); - setTimeout(() => (document.activeElement as HTMLElement)?.blur?.(), 150); - }, 100); - }, [selection, getCardRect, canvas.actions, dispatch, expandedSessionIds]); - - const handleBringToFront = useCallback((id: string, type: CardType) => { - dispatch(bringToFront({ id, type })); - }, [dispatch]); - - // ---- Viewport event handlers (compose pan + marquee) ---- - const handleViewportMouseDown = useCallback((e: React.MouseEvent) => { - if (e.button === 1) { - canvas.handlers.onMouseDown(e); - return; - } - - if (e.button === 2) { - e.preventDefault(); - canvas.handlers.onMouseDown(e); - return; - } - - if (e.button !== 0) return; - if (isCardTarget(e.target, e.currentTarget)) return; - - // Canvas click , drop any lingering input focus so arrow-key nav - // works immediately without the user having to press Escape first. - const active = document.activeElement as HTMLElement | null; - const activeTag = active?.tagName; - if (activeTag === 'INPUT' || activeTag === 'TEXTAREA' || (active as any)?.isContentEditable) { - active?.blur?.(); - } - - if (isElementSelectMode) { - if (e.metaKey || e.ctrlKey) { - canvas.handlers.onMouseDown(e); - } - return; - } - - if (e.metaKey || e.ctrlKey || canvas.spaceHeld) { - selection.deselectAll(); - canvas.handlers.onMouseDown(e); - } else { - selection.handleCanvasMouseDown(e.nativeEvent); - } - }, [canvas.handlers, canvas.spaceHeld, selection, isElementSelectMode]); - - const handleViewportMouseMove = useCallback((e: React.MouseEvent) => { - canvas.handlers.onMouseMove(e); - selection.handleCanvasMouseMove(e.nativeEvent); - }, [canvas.handlers, selection]); - - const handleViewportMouseUp = useCallback((e: React.MouseEvent) => { - canvas.handlers.onMouseUp(); - selection.handleCanvasMouseUp(e.nativeEvent); - }, [canvas.handlers, selection]); - - // Double-click empty canvas → fit all cards - const handleViewportDoubleClick = useCallback((e: React.MouseEvent) => { - if (e.button !== 0) return; - if (isCardTarget(e.target, e.currentTarget)) return; - report('dashboard', 'canvas_double_clicked'); - canvas.actions.fitToView(); - }, [canvas.actions]); - - // Double-click a card → always expand + center + zoom (cancels pending collapse from single-click) - const handleCardDoubleClick = useCallback((id: string, type: CardType) => { - report('dashboard', 'card_double_clicked', { card_type: type }); - if (clickTimerRef.current) { - clearTimeout(clickTimerRef.current); - clickTimerRef.current = null; - } - if (type === 'agent') { - dispatch(expandSession(id)); - } - dispatch(bringToFront({ id, type })); - setFocusedCardId(id); - setTimeout(() => { - const rect = getCardRect(id, type); - if (rect) canvas.actions.fitToCards([rect], 1.15, true); - setTimeout(() => (document.activeElement as HTMLElement)?.blur?.(), 150); - }, 100); - }, [getCardRect, canvas.actions, dispatch]); - - // Track dashboard engagement time - useEffect(() => { - if (!dashboardId) return; - const startTime = Date.now(); - report('dashboard', 'opened', { dashboard_id: dashboardId }); - return () => { - report('dashboard', 'closed', { - dashboard_id: dashboardId, - time_spent_seconds: Math.round((Date.now() - startTime) / 1000), - }); - }; - }, [dashboardId]); - - useEffect(() => { - if (!dashboardId) return; - hasFittedRef.current = false; - restoredExpandedRef.current = false; - dispatch(resetLayout()); - // CRITICAL path: these populate the cards the user expects to see - // on first paint. Don't defer. - dispatch(fetchSessions({ dashboardId })); - dispatch(fetchLayout(dashboardId)); - const cleanupBrowserHandler = initBrowserCommandHandler(); - // DEFERRABLE: history list (for the search palette) and outputs - // (for the apps panel) aren't on the first-paint path. Same for the - // dashboard WS connection (it carries cross-session events; opens - // ~100ms later costs nothing). Pushing these into the post-paint - // window measurably improves LCP because the initial render - // pipeline isn't competing with their thunks/network setup. - const idleHandle = (typeof window !== 'undefined' && (window as any).requestIdleCallback) - ? (window as any).requestIdleCallback(() => { - dispatch(fetchHistory({ dashboardId })); - dispatch(fetchOutputs()); - dashboardWs.connect(); - }, { timeout: 2000 }) - : window.setTimeout(() => { - dispatch(fetchHistory({ dashboardId })); - dispatch(fetchOutputs()); - dashboardWs.connect(); - }, 200); - - // Pre-warm Anthropic's prompt cache for sessions on this dashboard - // ~250ms after mount (debounced; AbortController cancels on - // dashboard switch). Fires a max_tokens=1 ping per session so the - // user's first real message hits a warm cache instead of paying - // cold-start TTFT. Cheap (~$0.0001/session) and non-blocking. Skips - // for non-Anthropic sessions server-side. - const warmAbort = new AbortController(); - const warmTimer = setTimeout(async () => { - try { - const sessionsState = store.getState().agents.sessions; - const dashSessions = Object.values(sessionsState).filter( - (s) => s.dashboard_id === dashboardId && - s.status !== 'draft' && - s.mode !== 'browser-agent' && - s.mode !== 'sub-agent' && - s.mode !== 'invoked-agent', - ); - for (const s of dashSessions) { - if (warmAbort.signal.aborted) break; - // Fire-and-forget , the endpoint always 200s and the side - // effect is invisible cache population. - fetch(`${API_BASE}/agents/sessions/${s.id}/warm-cache`, { - method: 'POST', - signal: warmAbort.signal, - }).catch(() => {}); - } - } catch { - /* best-effort */ - } - }, 250); - - return () => { - clearTimeout(warmTimer); - warmAbort.abort(); - cleanupBrowserHandler(); - dashboardWs.disconnect(); - // Cancel any not-yet-fired idle work; the cleanup handler can't - // run partially if the dashboard switches before idle fired. - if (typeof window !== 'undefined') { - const cancelIdle = (window as any).cancelIdleCallback; - if (cancelIdle && typeof idleHandle === 'number') cancelIdle(idleHandle); - else if (typeof idleHandle === 'number') clearTimeout(idleHandle); - } - }; - }, [dispatch, dashboardId]); - - const pendingBrowserUrl = useAppSelector((state) => state.tempState.pendingBrowserUrl); - const pendingFocusAgentId = useAppSelector((state) => state.tempState.pendingFocusAgentId); - const pendingFocusBrowserId = useAppSelector((state) => state.dashboardLayout.pendingFocusBrowserId); - - useEffect(() => { - if (!dashboardId) return; - (window as any).__openswarm_last_dashboard_id = dashboardId; - }, [dashboardId]); - - useEffect(() => { - if (!pendingBrowserUrl || !layoutInitialized) return; - dispatch(addBrowserCard({ url: pendingBrowserUrl, expandedSessionIds })); - dispatch(clearPendingBrowserUrl()); - }, [pendingBrowserUrl, layoutInitialized, dispatch, expandedSessionIds]); - - // Capture a thumbnail screenshot of the dashboard. - // Uses Electron's native capturePage for pixel-perfect results. - // Captures current viewport as-is (no DOM mutation) to avoid visual flashes. - // Re-captures when layout is saved (piggybacking on the save debounce). - const pendingThumbnailRef = useRef(null); - const captureTimerRef = useRef | null>(null); - const captureNow = useCallback(() => { - const viewportEl = canvas.viewportRef.current; - const contentEl = canvas.contentRef.current; - if (!viewportEl || !contentEl) return; - const layoutState = store.getState().dashboardLayout; - const allCards = { - cards: layoutState.cards, - viewCards: layoutState.viewCards, - browserCards: layoutState.browserCards, - }; - const hasCards = Object.keys(allCards.cards).length > 0 - || Object.keys(allCards.viewCards).length > 0 - || Object.keys(allCards.browserCards).length > 0; - if (!hasCards) { - // Empty dashboard , queue a thumbnail clear (sent on exit alongside - // the existing capture-update path). Backend treats '' as "set to - // empty"; null in PUT body means "don't update". - pendingThumbnailRef.current = ''; - return; - } - captureDashboardThumbnail(viewportEl, contentEl, allCards) - .then((thumbnail) => { if (thumbnail) pendingThumbnailRef.current = thumbnail; }) - .catch(() => {}); - }, [canvas.viewportRef, canvas.contentRef]); - - useEffect(() => { - if (!isActive) return; // Skip thumbnail capture when dashboard is hidden - if (!dashboardId || !layoutInitialized) return; - if (captureTimerRef.current) clearTimeout(captureTimerRef.current); - captureTimerRef.current = setTimeout(captureNow, 2000); - return () => { if (captureTimerRef.current) clearTimeout(captureTimerRef.current); }; - }, [isActive, dashboardId, layoutInitialized, captureNow]); - - // On exit, save the captured thumbnail to the backend - useEffect(() => { - if (!dashboardId) return; - const exitingId = dashboardId; - return () => { - const thumbnail = pendingThumbnailRef.current; - // null = no pending change; '' = pending clear; other = pending update. - if (thumbnail !== null) { - store.dispatch(updateDashboardThumbnail({ id: exitingId, thumbnail })); - pendingThumbnailRef.current = null; - } - }; - }, [dashboardId]); - - useEffect(() => { - if (!isActive) return; // Don't auto-fit while dashboard is hidden - if (!layoutInitialized || hasFittedRef.current) return; - if (pendingFocusAgentId) return; - hasFittedRef.current = true; - const timer = setTimeout(() => canvas.actions.fitToView(), 150); - return () => clearTimeout(timer); - }, [isActive, layoutInitialized, canvas.actions, pendingFocusAgentId]); - - useEffect(() => { - if (!isActive) return; // Defer focus animation until dashboard is visible - if (!pendingFocusAgentId || !layoutInitialized) return; - const agentId = pendingFocusAgentId; - dispatch(clearPendingFocusAgentId()); - hasFittedRef.current = true; - setTimeout(() => { - const card = store.getState().dashboardLayout.cards[agentId]; - if (card) { - canvas.actions.fitToCards([{ x: card.x, y: card.y, width: card.width, height: card.height }], 1.15, true); - handleHighlightCard(agentId); - } - }, 350); - }, [isActive, pendingFocusAgentId, layoutInitialized, dispatch, canvas.actions, handleHighlightCard]); - - // Auto-focus a newly created browser card. The reducer that handles - // addBrowserCard sets pendingFocusBrowserId to the new card's id; this - // effect picks it up, pans/zooms the canvas to center on it, briefly - // highlights it, then clears the signal. Mirrors the pendingFocusAgentId - // pattern above so link clicks (intercepted in AppShell) get the same - // auto-focus behavior as the "+ Browser" toolbar button. - // - // Uses zoom=0.8 (the same value handleCardClick uses for browser cards - // at line ~344) instead of letting fitToCards auto-derive a zoom from - // padding. Browser cards are large (1280x800), so the auto-derived zoom - // would land around ~58% which feels too far back; 0.8 matches the - // "click on a browser to focus" experience the user expects. - useEffect(() => { - if (!isActive) return; - if (!pendingFocusBrowserId || !layoutInitialized) return; - const browserId = pendingFocusBrowserId; - dispatch(clearPendingFocusBrowserId()); - hasFittedRef.current = true; - setTimeout(() => { - const card = store.getState().dashboardLayout.browserCards[browserId]; - if (card) { - canvas.actions.fitToCards( - [{ x: card.x, y: card.y, width: card.width, height: card.height }], - 1.15, - true, - 0.8, - ); - handleHighlightCard(browserId); - } - }, 200); - }, [isActive, pendingFocusBrowserId, layoutInitialized, dispatch, canvas.actions, handleHighlightCard]); - - useEffect(() => { - if (!layoutInitialized || restoredExpandedRef.current) return; - restoredExpandedRef.current = true; - dispatch(setExpandedSessionIds(persistedExpandedSessionIds)); - }, [layoutInitialized, persistedExpandedSessionIds, dispatch]); - - const prevSessionIdsRef = useRef(''); - - useEffect(() => { - if (!layoutInitialized) return; - const dashboardSessionIds = Object.values(sessions) - .filter((s) => s.dashboard_id === dashboardId && s.mode !== 'browser-agent' && s.mode !== 'invoked-agent' && s.mode !== 'sub-agent') - .map((s) => s.id); - const liveIds = dashboardSessionIds.sort().join(','); - if (liveIds === prevSessionIdsRef.current) return; - prevSessionIdsRef.current = liveIds; - dispatch(reconcileSessions({ sessionIds: dashboardSessionIds, expandedSessionIds })); - }, [sessions, layoutInitialized, dispatch, dashboardId, expandedSessionIds]); - - // Prune orphan view cards whose underlying output was deleted (e.g. via - // the Views page). Without this, the layout entry persists in the - // minimap and contentBounds even though DashboardViewCard renders - // nothing. Gated on outputsLoaded so we don't wipe valid cards during - // the brief window between fetchLayout returning and outputs finishing. - useEffect(() => { - if (!layoutInitialized || !outputsLoaded) return; - for (const outputId of Object.keys(viewCards)) { - if (!outputs[outputId]) dispatch(removeViewCard(outputId)); - } - }, [layoutInitialized, outputsLoaded, viewCards, outputs, dispatch]); - - // ---- Auto-reveal / collapse / unreveal sub-agent cards ---- - useSubAgentLifecycle({ - isActive, - sessions, - cards, - layoutInitialized, - autoRevealSubAgents, - expandedSessionIds, - }); - - const skipInitialSave = useRef(true); - const saveTimerRef = useRef | null>(null); - const pendingSaveRef = useRef[0] | null>(null); - - useEffect(() => { - if (!isActive) return; // Don't persist layout while dashboard is hidden , save buffers in pendingSaveRef and flushes on resume - if (!layoutInitialized || !dashboardId) return; - if (skipInitialSave.current) { - skipInitialSave.current = false; - return; - } - const payload = { dashboardId, cards, viewCards, browserCards, notes, expandedSessionIds }; - pendingSaveRef.current = payload; - if (saveTimerRef.current) clearTimeout(saveTimerRef.current); - saveTimerRef.current = setTimeout(() => { - dispatch(saveLayout(payload)); - pendingSaveRef.current = null; - saveTimerRef.current = null; - captureNow(); - }, 500); - }, [isActive, cards, viewCards, browserCards, notes, expandedSessionIds, layoutInitialized, dashboardId, dispatch, captureNow]); - - useEffect(() => { - return () => { - if (saveTimerRef.current) { - clearTimeout(saveTimerRef.current); - saveTimerRef.current = null; - } - if (pendingSaveRef.current) { - dispatch(saveLayout(pendingSaveRef.current)); - pendingSaveRef.current = null; - } - }; - }, [dispatch]); - - useDashboardShortcuts({ - isActive, - newAgentShortcut, - selection, - setToolbarOpen, - setSearchPaletteOpen, - }); - - useDashboardClipboard({ - isActive, - dashboardId, - selection, - sessions, - cards, - viewCards, - browserCards, - outputs, - expandedSessionIds, - }); - - // ---- Arrow key card navigation (when zoomed in on a card) ---- - const { neighborDirections, shakeDirection } = useArrowNav({ - cards, - viewCards, - browserCards, - zoom: canvas.zoom, - isActive, - focusedCardId, - setFocusedCardId, - canvasActions: canvas.actions, - getCardRect, - }); - - const handleBranchFromCard = useCallback( - (sourceSessionId: string, newSessionId: string) => { - const sourceCard = cards[sourceSessionId]; - if (!sourceCard) return; - - const targetX = sourceCard.x + sourceCard.width + GRID_GAP * 12; - let targetY = sourceCard.y; - - const columnCards = Object.values(cards).filter( - (c) => Math.abs(c.x - targetX) < 50 && c.session_id !== newSessionId, - ); - if (columnCards.length > 0) { - const lowestBottom = Math.max( - ...columnCards.map((c) => c.y + Math.max(EXPANDED_CARD_MIN_H, c.height)), - ); - targetY = lowestBottom + GRID_GAP; - } - - spawnOriginsRef.current[newSessionId] = { - x: sourceCard.x, - y: sourceCard.y, - type: 'branch' as const, - }; - - dispatch(placeCard({ - sessionId: newSessionId, - x: targetX, - y: targetY, - width: DEFAULT_CARD_W, - height: DEFAULT_CARD_H, - expandedSessionIds, - })); - - if (expandedSessionIds.includes(sourceSessionId)) { - dispatch(expandSession(newSessionId)); - } - - dispatch(setGlowingAgentCard({ sessionId: newSessionId, sourceId: sourceSessionId, label: 'Branch' })); - }, - [cards, dispatch, expandedSessionIds], - ); - - const handleNewAgent = useCallback(() => { - setToolbarOpen(true); - }, []); - - const handleToolbarCancel = useCallback(() => { - setToolbarOpen(false); - }, []); - - const handleToolbarSend = useCallback( - ( - prompt: string, - mode: string, - model: string, - images?: Array<{ data: string; media_type: string }>, - contextPaths?: ContextPath[], - forcedTools?: string[], - attachedSkills?: Array<{ id: string; name: string; content: string }>, - selectedBrowserIds?: string[], - ) => { - setToolbarOpen(false); - report('dashboard', 'agent_created', { mode, model, has_images: !!images?.length, has_context: !!contextPaths?.length, has_browser: !!selectedBrowserIds?.length }); - - const draftId = `draft-${Date.now().toString(36)}`; - - const toolbarEl = toolbarRef.current; - const vpEl = canvas.viewportRef.current; - if (toolbarEl && vpEl) { - const tr = toolbarEl.getBoundingClientRect(); - const vr = vpEl.getBoundingClientRect(); - const toolbarCenterX = tr.left + tr.width / 2; - const toolbarTopY = tr.top; - const { panX, panY, zoom } = canvasStateRef.current; - spawnOriginsRef.current[draftId] = { - x: (toolbarCenterX - vr.left - panX) / zoom, - y: (toolbarTopY - vr.top - panY) / zoom, - }; - } - - const config: AgentConfig = { name: 'New chat', model, mode, dashboard_id: dashboardId }; - - dispatch( - launchAndSendFirstMessage({ - draftId, - config, - prompt, - mode, - model, - images, - contextPaths: contextPaths?.map((cp) => ({ path: cp.path, type: cp.type })), - forcedTools, - attachedSkills, - expand: expandNewChats, - }), - ).then((action) => { - if (launchAndSendFirstMessage.fulfilled.match(action)) { - const realId = action.payload.session.id; - dispatch(generateTitle({ sessionId: realId, prompt })); - if (selectedBrowserIds?.length) { - dispatch(setGlowingBrowserCards({ browserIds: selectedBrowserIds, sessionId: realId, label: 'Use Browser' })); - - if (selectedBrowserIds.length === 1) { - const bc = store.getState().dashboardLayout.browserCards[selectedBrowserIds[0]]; - if (bc) { - // Use placeCard (collision-aware) instead of - // setCardPosition (blind setter). The "left of the - // browser" anchor is the IDEAL spot , but if it's - // already taken by an existing chat (e.g. step 3's - // YouTube agent that's still on canvas when step 5 - // creates a new chat for the same browser), placeCard - // cascades to the nearest free cell instead of - // stacking on top. - dispatch(placeCard({ - sessionId: realId, - x: bc.x - DEFAULT_CARD_W - GRID_GAP * 12, - y: bc.y, - width: DEFAULT_CARD_W, - height: DEFAULT_CARD_H, - expandedSessionIds, - })); - } - } - } - spawnOriginsRef.current[realId] = spawnOriginsRef.current[draftId]; - delete spawnOriginsRef.current[draftId]; - - if (expandNewChats) { - setAutoFocusSessionId(realId); - dispatch(expandSession(realId)); - } else { - setPendingSelectSessionId(realId); - } - - setTimeout(() => { - const card = store.getState().dashboardLayout.cards[realId]; - if (card) { - canvas.actions.fitToCards([{ x: card.x, y: card.y, width: card.width, height: card.height }], 1.15, true); - handleHighlightCard(realId); - } - }, 200); - - if (dashboardId) { - const currentSessions = store.getState().agents.sessions; - const agentCount = Object.values(currentSessions).filter( - (s) => s.status !== 'draft' && s.dashboard_id === dashboardId, - ).length; - const NAME_GEN_TRIGGERS = [1, 3, 6]; - const currentDash = store.getState().dashboards.items[dashboardId]; - const canAutoName = - currentDash && - (currentDash.auto_named || currentDash.name === 'Untitled Dashboard'); - - if (NAME_GEN_TRIGGERS.includes(agentCount) && canAutoName) { - dispatch(generateDashboardName(dashboardId)); - } - } - } else { - delete spawnOriginsRef.current[draftId]; - } - }); - }, - [canvas.viewportRef, canvas.actions, dispatch, dashboardId, expandNewChats, handleHighlightCard], - ); - - const handleAddView = useCallback((outputId: string) => { - dispatch(addViewCard({ outputId, expandedSessionIds })); - setTimeout(() => { - const card = store.getState().dashboardLayout.viewCards[outputId]; - if (card) { - canvas.actions.fitToCards([{ x: card.x, y: card.y, width: card.width, height: card.height }], 1.15, true); - handleHighlightCard(outputId); - } - }, 200); - }, [dispatch, expandedSessionIds, canvas.actions, handleHighlightCard]); - - const handleAddBrowser = useCallback(() => { - report('dashboard', 'browser_added'); - const prevIds = new Set(Object.keys(store.getState().dashboardLayout.browserCards)); - dispatch(addBrowserCard({ url: browserHomepage, expandedSessionIds })); - setTimeout(() => { - const allBrowserCards = store.getState().dashboardLayout.browserCards; - const newId = Object.keys(allBrowserCards).find((id) => !prevIds.has(id)); - if (newId) { - const card = allBrowserCards[newId]; - canvas.actions.fitToCards([{ x: card.x, y: card.y, width: card.width, height: card.height }], 1.15, true); - handleHighlightCard(newId); - } - }, 200); - }, [dispatch, browserHomepage, expandedSessionIds, canvas.actions, handleHighlightCard]); - - const handleAddNote = useCallback(() => { - report('dashboard', 'note_added'); - const prevIds = new Set(Object.keys(store.getState().dashboardLayout.notes)); - dispatch(addNote({ expandedSessionIds })); - setTimeout(() => { - const allNotes = store.getState().dashboardLayout.notes; - const newId = Object.keys(allNotes).find((id) => !prevIds.has(id)); - if (newId) { - const note = allNotes[newId]; - canvas.actions.fitToCards([{ x: note.x, y: note.y, width: note.width, height: note.height }], 1.15, true); - handleHighlightCard(newId); - } - }, 200); - }, [dispatch, expandedSessionIds, canvas.actions, handleHighlightCard]); - - // Auto-clear pendingFocusNoteId after the note has had a chance to mount + autofocus. - useEffect(() => { - if (!pendingFocusNoteId) return; - const t = setTimeout(() => dispatch(clearPendingFocusNoteId()), 800); - return () => clearTimeout(t); - }, [pendingFocusNoteId, dispatch]); - - const handleHistoryResume = useCallback((sessionId: string) => { - dispatch(resumeSession({ sessionId })).then((action) => { - if (resumeSession.fulfilled.match(action)) { - dispatch(expandSession(sessionId)); - setAutoFocusSessionId(sessionId); - setTimeout(() => { - const card = store.getState().dashboardLayout.cards[sessionId]; - if (card) { - canvas.actions.fitToCards([{ x: card.x, y: card.y, width: card.width, height: card.height }], 1.15, true); - handleHighlightCard(sessionId); - } - }, 200); - } - }); - }, [dispatch, canvas.actions, handleHighlightCard, setAutoFocusSessionId]); - - // Context-aware fit: if a card is selected, zoom to it; otherwise fit all - const handleFitToView = useCallback(() => { - report('dashboard', 'fit_to_view', { has_selection: selection.selectedIds.size > 0 }); - if (selection.selectedIds.size === 1) { - const [[id, type]] = selection.selectedIds; - const rect = getCardRect(id, type); - if (rect) { - canvas.actions.fitToCards([rect], 1.15, true); - return; - } - } - canvas.actions.fitToView(); - }, [selection.selectedIds, getCardRect, canvas.actions]); - - const handleTidy = useCallback(() => { - report('dashboard', 'tidy_layout'); - const currentExpanded = store.getState().agents.expandedSessionIds; - dispatch(tidyLayout({ expandedSessionIds: currentExpanded })); - - const expandedSet = new Set(currentExpanded); - const { cards: tidied, viewCards: tidiedViews, browserCards: tidiedBrowsers } = store.getState().dashboardLayout; - const allRects = [ - ...Object.values(tidied).map((c) => ({ - x: c.x, y: c.y, width: c.width, - height: expandedSet.has(c.session_id) ? Math.max(EXPANDED_CARD_MIN_H, c.height) : c.height, - })), - ...Object.values(tidiedViews).map((c) => ({ x: c.x, y: c.y, width: c.width, height: c.height })), - ...Object.values(tidiedBrowsers).map((c) => ({ x: c.x, y: c.y, width: c.width, height: c.height })), - ]; - canvas.actions.fitToCards(allRects); - }, [dispatch, canvas.actions]); - - useEffect(() => { - if (!isActive) return; // Heavy geometry recalculation , pause when dashboard is hidden - const DRIFT_THRESHOLD = 60; - - // Group tethered sub-agent cards by source, only including those still in the spawn column - const sourceToSiblings = new Map(); - for (const [id, glow] of Object.entries(glowingAgentCards)) { - const card = cards[id]; - if (!card) continue; - const sourceCard = cards[glow.sourceId]; - if (!sourceCard) continue; - const expectedX = sourceCard.x + sourceCard.width + GRID_GAP * 12; - if (Math.abs(card.x - expectedX) > DRIFT_THRESHOLD) continue; - const list = sourceToSiblings.get(glow.sourceId) ?? []; - list.push(id); - sourceToSiblings.set(glow.sourceId, list); - } - - for (const siblings of sourceToSiblings.values()) { - if (siblings.length < 2) continue; - siblings.sort((a, b) => cards[a].y - cards[b].y); - - let cursor = cards[siblings[0]].y; - for (const id of siblings) { - const card = cards[id]; - const dy = cursor - card.y; - if (Math.abs(dy) > 1) { - dispatch(moveCards({ items: [{ id, type: 'agent' as const }], dx: 0, dy })); - } - const isExpanded = expandedSessionIds.includes(id); - const h = isExpanded - ? Math.max(EXPANDED_CARD_MIN_H, card.height) - : (measuredHeightsRef.current[id] ?? card.height); - cursor += h + GRID_GAP * 2; - } - } - // measuredHeightsTick in deps ensures we re-run once ResizeObserver reports - // the new height after a collapse (avoids stale-height no-ops) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isActive, expandedSessionIds, glowingAgentCards, cards, dispatch, measuredHeightsTick]); - - useEffect(() => { - if (!isActive) return; // Heavy geometry recalculation , pause when dashboard is hidden - const DRIFT_THRESHOLD = 60; - - const sourceToSiblings = new Map(); - for (const [browserId, glow] of Object.entries(glowingBrowserCards)) { - const bc = browserCards[browserId]; - if (!bc) continue; - const sourceCard = cards[glow.sourceId]; - if (!sourceCard) continue; - const expectedX = sourceCard.x + sourceCard.width + GRID_GAP * 12; - if (Math.abs(bc.x - expectedX) > DRIFT_THRESHOLD) continue; - const list = sourceToSiblings.get(glow.sourceId) ?? []; - list.push(browserId); - sourceToSiblings.set(glow.sourceId, list); - } - - for (const siblings of sourceToSiblings.values()) { - if (siblings.length < 2) continue; - siblings.sort((a, b) => browserCards[a].y - browserCards[b].y); - - let cursor = browserCards[siblings[0]].y; - for (const id of siblings) { - const bc = browserCards[id]; - const dy = cursor - bc.y; - if (Math.abs(dy) > 1) { - dispatch(moveCards({ items: [{ id, type: 'browser' as const }], dx: 0, dy })); - } - cursor += bc.height + GRID_GAP * 2; - } - } - }, [isActive, glowingBrowserCards, browserCards, cards, dispatch]); - - const tethers = useTethers({ - glowingAgentCards, - glowingBrowserCards, - cards, - browserCards, - expandedSessionIds, - liveDragInfo, - measuredHeightsRef, - measuredHeightsTick, - sessionList, - }); - - const dotSize = Math.max(1, 1.5 * canvas.zoom); - const dotSpacing = 24 * canvas.zoom; - + const controller = useDashboardController(dashboardId, isActive); return ( <> - - - {/* Floating header overlay */} - - - - - - - {/* Canvas viewport */} - - {/* Dot grid background */} - - - {sessionList.length === 0 && Object.keys(viewCards).length === 0 && Object.keys(browserCards).length === 0 ? ( - - - - No agents running - - - Click the "+" button below to launch your first agent - - - ) : ( -
- {/* Tether lines between branched cards */} - - - {Object.values(cards).map((card) => { - const sid = card.session_id; - - let origin = spawnOriginsRef.current[sid]; - if (origin) { - delete spawnOriginsRef.current[sid]; - } else { - const glow = glowingAgentCards[sid]; - if (glow && !revealSpawnedRef.current.has(sid)) { - revealSpawnedRef.current.add(sid); - const srcCard = cards[glow.sourceId]; - if (srcCard) { - const srcH = measuredHeightsRef.current[glow.sourceId] - ?? (expandedSessionIds.includes(glow.sourceId) - ? Math.max(EXPANDED_CARD_MIN_H, srcCard.height) - : srcCard.height); - origin = { - x: srcCard.x + srcCard.width, - y: srcCard.y + srcH / 2, - type: 'branch' as const, - }; - } - } - } - - let exitTarget: { x: number; y: number } | undefined; - const glow = glowingAgentCards[sid]; - if (glow) { - const srcCard = cards[glow.sourceId]; - if (srcCard) { - const srcH = measuredHeightsRef.current[glow.sourceId] - ?? (expandedSessionIds.includes(glow.sourceId) - ? Math.max(EXPANDED_CARD_MIN_H, srcCard.height) - : srcCard.height); - exitTarget = { - x: srcCard.x + srcCard.width, - y: srcCard.y + srcH / 2, - }; - } - } - - let snapColumn: { x: number; width: number } | undefined; - if (glow) { - const srcCard = cards[glow.sourceId]; - if (srcCard) { - snapColumn = { - x: srcCard.x + srcCard.width + GRID_GAP * 12, - width: DEFAULT_CARD_W, - }; - } - } - - const isSel = selection.isSelected(sid); - return ( - - ); - })} - - {Object.values(viewCards).map((vc) => { - const output = outputs[vc.output_id]; - if (!output) return null; - return ( - - ); - })} - {Object.values(browserCards).map((bc) => ( - - ))} - {Object.values(notes).map((n) => ( - - ))} - {/* Marquee selection rectangle */} - {selection.marquee && ( -
- )} -
- )} - - - {/* Floating bottom toolbar */} - - setNewAgentBounce(false)} - /> - - - {/* Arrow navigation hints when zoomed in on a card */} - {focusedCardId && canvas.zoom >= 0.4 && ( - - )} - - {/* Floating zoom controls + minimap */} - - canvas.actions.setState({ panX: px, panY: py, zoom: canvas.zoom })} - /> - - - - {/* Card search palette (Cmd+F) */} - setSearchPaletteOpen(false)} - onNavigate={(rect) => canvas.actions.fitToCards([rect], 1.15, true)} - cards={cards} - viewCards={viewCards} - browserCards={browserCards} - sessions={sessions} - /> - + + ); }; diff --git a/frontend/src/app/pages/Dashboard/DashboardCanvas.tsx b/frontend/src/app/pages/Dashboard/DashboardCanvas.tsx new file mode 100644 index 00000000..64c9a473 --- /dev/null +++ b/frontend/src/app/pages/Dashboard/DashboardCanvas.tsx @@ -0,0 +1,295 @@ +import React, { type RefObject } from 'react'; +import Box from '@mui/material/Box'; +import DashboardHeader from './DashboardHeader'; +import TetherLayer from './TetherLayer'; +import DashboardCardLayer from './DashboardCardLayer'; +import DashboardOverlays from './DashboardOverlays'; +import DashboardEmptyState from './DashboardEmptyState'; +import type { ClaudeTokens } from '@/shared/styles/claudeTokens'; +import type { AgentSession } from '@/shared/state/agentsSlice'; +import type { + CardPosition, + ViewCardPosition, + BrowserCardPosition, + NotePosition, +} from '@/shared/state/dashboardLayoutSlice'; +import type { Output } from '@/shared/state/outputsSlice'; +import type { CardType, useDashboardSelection } from './useDashboardSelection'; +import type { useCanvasControls } from './useCanvasControls'; +import type { Tether } from './dashboardTethers'; + +type Selection = ReturnType; +type Canvas = ReturnType; +type SpawnOrigin = { x: number; y: number; type?: 'branch' }; +type GlowingAgentCard = { sourceId: string; fading: boolean; sourceYRatio?: number; label?: string }; +type Direction = 'left' | 'right' | 'up' | 'down'; +type NeighborDirections = { left: boolean; right: boolean; up: boolean; down: boolean }; + +interface DashboardCanvasProps { + c: ClaudeTokens; + dashboardId: string; + dashboardName?: string; + canvas: Canvas; + selection: Selection; + sessions: Record; + sessionList: AgentSession[]; + cards: Record; + viewCards: Record; + browserCards: Record; + notes: Record; + outputs: Record; + glowingAgentCards: Record; + expandedSessionIds: string[]; + tethers: Tether[]; + highlightedCardId: string | null; + autoFocusSessionId: string | null; + focusedCardId: string | null; + pendingFocusNoteId: string | null; + multiDragDelta: { dx: number; dy: number } | null; + shakeDirection: Direction | null; + neighborDirections: NeighborDirections; + toolbarOpen: boolean; + searchPaletteOpen: boolean; + newAgentBounce: boolean; + toolbarRef: RefObject; + spawnOriginsRef: RefObject>; + revealSpawnedRef: RefObject>; + measuredHeightsRef: RefObject>; + getCanvasState: () => { panX: number; panY: number; zoom: number }; + onViewportMouseDown: (e: React.MouseEvent) => void; + onViewportMouseMove: (e: React.MouseEvent) => void; + onViewportMouseUp: (e: React.MouseEvent) => void; + onViewportDoubleClick: (e: React.MouseEvent) => void; + onCardSelect: (id: string, type: CardType, shiftKey: boolean) => void; + onDragStart: (id: string, type: CardType) => void; + onDragMove: (dx: number, dy: number, mouseX?: number, mouseY?: number) => void; + onDragEnd: (dx: number, dy: number, didDrag: boolean) => void; + onCardDoubleClick: (id: string, type: CardType) => void; + onBringToFront: (id: string, type: CardType) => void; + onBranch: (sourceSessionId: string, newSessionId: string) => void; + onMeasuredHeight: (sessionId: string, height: number) => void; + onHighlightCard: (cardId: string) => void; + onNewAgent: () => void; + onToolbarCancel: () => void; + onToolbarSend: (...args: any[]) => void; + onAddView: (outputId: string) => void; + onHistoryResume: (sessionId: string) => void; + onAddBrowser: () => void; + onAddNote: () => void; + onNewAgentBounceEnd: () => void; + onFitToView: () => void; + onTidy: () => void; + onSearchPaletteClose: () => void; +} + +const DashboardCanvas: React.FC = ({ + c, + dashboardId, + dashboardName, + canvas, + selection, + sessions, + sessionList, + cards, + viewCards, + browserCards, + notes, + outputs, + glowingAgentCards, + expandedSessionIds, + tethers, + highlightedCardId, + autoFocusSessionId, + focusedCardId, + pendingFocusNoteId, + multiDragDelta, + shakeDirection, + neighborDirections, + toolbarOpen, + searchPaletteOpen, + newAgentBounce, + toolbarRef, + spawnOriginsRef, + revealSpawnedRef, + measuredHeightsRef, + getCanvasState, + onViewportMouseDown, + onViewportMouseMove, + onViewportMouseUp, + onViewportDoubleClick, + onCardSelect, + onDragStart, + onDragMove, + onDragEnd, + onCardDoubleClick, + onBringToFront, + onBranch, + onMeasuredHeight, + onHighlightCard, + onNewAgent, + onToolbarCancel, + onToolbarSend, + onAddView, + onHistoryResume, + onAddBrowser, + onAddNote, + onNewAgentBounceEnd, + onFitToView, + onTidy, + onSearchPaletteClose, +}) => { + const dotSize = Math.max(1, 1.5 * canvas.zoom); + const dotSpacing = 24 * canvas.zoom; + + return ( + <> + + {/* Floating header overlay */} + + + + + + + {/* Canvas viewport */} + + {/* Dot grid background */} + + + {sessionList.length === 0 && Object.keys(viewCards).length === 0 && Object.keys(browserCards).length === 0 ? ( + + ) : ( +
+ {/* Tether lines between branched cards */} + + +
+ )} +
+ + +
+ + ); +}; + +export default DashboardCanvas; diff --git a/frontend/src/app/pages/Dashboard/DashboardCardLayer.tsx b/frontend/src/app/pages/Dashboard/DashboardCardLayer.tsx new file mode 100644 index 00000000..7b423039 --- /dev/null +++ b/frontend/src/app/pages/Dashboard/DashboardCardLayer.tsx @@ -0,0 +1,275 @@ +import React, { type RefObject } from 'react'; +import { AnimatePresence } from 'framer-motion'; +import AgentCard from './AgentCard'; +import DashboardViewCard from './DashboardViewCard'; +import BrowserCard from './BrowserCard'; +import NoteCard from './NoteCard'; +import { + EXPANDED_CARD_MIN_H, + DEFAULT_CARD_W, + GRID_GAP, + type CardPosition, + type ViewCardPosition, + type BrowserCardPosition, + type NotePosition, +} from '@/shared/state/dashboardLayoutSlice'; +import type { Output } from '@/shared/state/outputsSlice'; +import type { CardType, useDashboardSelection } from './useDashboardSelection'; + +type Selection = ReturnType; +type SpawnOrigin = { x: number; y: number; type?: 'branch' }; +type GlowingAgentCard = { sourceId: string; fading: boolean; sourceYRatio?: number; label?: string }; +type Direction = 'left' | 'right' | 'up' | 'down'; + +interface DashboardCardLayerProps { + cards: Record; + viewCards: Record; + browserCards: Record; + notes: Record; + outputs: Record; + glowingAgentCards: Record; + expandedSessionIds: string[]; + zoom: number; + panX: number; + panY: number; + cmdHeld: boolean; + selection: Selection; + highlightedCardId: string | null; + autoFocusSessionId: string | null; + focusedCardId: string | null; + pendingFocusNoteId: string | null; + multiDragDelta: { dx: number; dy: number } | null; + shakeDirection: Direction | null; + spawnOriginsRef: RefObject>; + revealSpawnedRef: RefObject>; + measuredHeightsRef: RefObject>; + getCanvasState: () => { panX: number; panY: number; zoom: number }; + onCardSelect: (id: string, type: CardType, shiftKey: boolean) => void; + onDragStart: (id: string, type: CardType) => void; + onDragMove: (dx: number, dy: number, mouseX?: number, mouseY?: number) => void; + onDragEnd: (dx: number, dy: number, didDrag: boolean) => void; + onDoubleClick: (id: string, type: CardType) => void; + onBringToFront: (id: string, type: CardType) => void; + onBranch: (sourceSessionId: string, newSessionId: string) => void; + onMeasuredHeight: (sessionId: string, height: number) => void; +} + +const DashboardCardLayer: React.FC = ({ + cards, + viewCards, + browserCards, + notes, + outputs, + glowingAgentCards, + expandedSessionIds, + zoom, + panX, + panY, + cmdHeld, + selection, + highlightedCardId, + autoFocusSessionId, + focusedCardId, + pendingFocusNoteId, + multiDragDelta, + shakeDirection, + spawnOriginsRef, + revealSpawnedRef, + measuredHeightsRef, + getCanvasState, + onCardSelect, + onDragStart, + onDragMove, + onDragEnd, + onDoubleClick, + onBringToFront, + onBranch, + onMeasuredHeight, +}) => { + return ( + <> + + {Object.values(cards).map((card) => { + const sid = card.session_id; + + let origin = spawnOriginsRef.current![sid]; + if (origin) { + delete spawnOriginsRef.current![sid]; + } else { + const glow = glowingAgentCards[sid]; + if (glow && !revealSpawnedRef.current!.has(sid)) { + revealSpawnedRef.current!.add(sid); + const srcCard = cards[glow.sourceId]; + if (srcCard) { + const srcH = measuredHeightsRef.current![glow.sourceId] + ?? (expandedSessionIds.includes(glow.sourceId) + ? Math.max(EXPANDED_CARD_MIN_H, srcCard.height) + : srcCard.height); + origin = { + x: srcCard.x + srcCard.width, + y: srcCard.y + srcH / 2, + type: 'branch' as const, + }; + } + } + } + + let exitTarget: { x: number; y: number } | undefined; + const glow = glowingAgentCards[sid]; + if (glow) { + const srcCard = cards[glow.sourceId]; + if (srcCard) { + const srcH = measuredHeightsRef.current![glow.sourceId] + ?? (expandedSessionIds.includes(glow.sourceId) + ? Math.max(EXPANDED_CARD_MIN_H, srcCard.height) + : srcCard.height); + exitTarget = { + x: srcCard.x + srcCard.width, + y: srcCard.y + srcH / 2, + }; + } + } + + let snapColumn: { x: number; width: number } | undefined; + if (glow) { + const srcCard = cards[glow.sourceId]; + if (srcCard) { + snapColumn = { + x: srcCard.x + srcCard.width + GRID_GAP * 12, + width: DEFAULT_CARD_W, + }; + } + } + + const isSel = selection.isSelected(sid); + return ( + + ); + })} + + {Object.values(viewCards).map((vc) => { + const output = outputs[vc.output_id]; + if (!output) return null; + return ( + + ); + })} + {Object.values(browserCards).map((bc) => ( + + ))} + {Object.values(notes).map((n) => ( + + ))} + {/* Marquee selection rectangle */} + {selection.marquee && ( +
+ )} + + ); +}; + +export default DashboardCardLayer; diff --git a/frontend/src/app/pages/Dashboard/DashboardEmptyState.tsx b/frontend/src/app/pages/Dashboard/DashboardEmptyState.tsx new file mode 100644 index 00000000..6183031a --- /dev/null +++ b/frontend/src/app/pages/Dashboard/DashboardEmptyState.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import type { ClaudeTokens } from '@/shared/styles/claudeTokens'; + +const DashboardEmptyState: React.FC<{ c: ClaudeTokens }> = ({ c }) => ( + + + + No agents running + + + Click the "+" button below to launch your first agent + + +); + +export default DashboardEmptyState; diff --git a/frontend/src/app/pages/Dashboard/DashboardOverlays.tsx b/frontend/src/app/pages/Dashboard/DashboardOverlays.tsx new file mode 100644 index 00000000..af96c309 --- /dev/null +++ b/frontend/src/app/pages/Dashboard/DashboardOverlays.tsx @@ -0,0 +1,137 @@ +import React, { type RefObject } from 'react'; +import Box from '@mui/material/Box'; +import DashboardToolbar from './DashboardToolbar'; +import CanvasControls from './CanvasControls'; +import CardSearchPalette from './CardSearchPalette'; +import DirectionHints from './DirectionHints'; +import type { AgentSession } from '@/shared/state/agentsSlice'; +import type { + CardPosition, + ViewCardPosition, + BrowserCardPosition, +} from '@/shared/state/dashboardLayoutSlice'; +import type { useCanvasControls } from './useCanvasControls'; + +type Canvas = ReturnType; +type Direction = 'left' | 'right' | 'up' | 'down'; +type NeighborDirections = { left: boolean; right: boolean; up: boolean; down: boolean }; + +interface DashboardOverlaysProps { + canvas: Canvas; + dashboardId: string; + sessions: Record; + cards: Record; + viewCards: Record; + browserCards: Record; + focusedCardId: string | null; + shakeDirection: Direction | null; + neighborDirections: NeighborDirections; + toolbarOpen: boolean; + searchPaletteOpen: boolean; + newAgentBounce: boolean; + toolbarRef: RefObject; + onNewAgent: () => void; + onToolbarCancel: () => void; + onToolbarSend: (...args: any[]) => void; + onAddView: (outputId: string) => void; + onHistoryResume: (sessionId: string) => void; + onAddBrowser: () => void; + onAddNote: () => void; + onNewAgentBounceEnd: () => void; + onFitToView: () => void; + onTidy: () => void; + onSearchPaletteClose: () => void; +} + +const DashboardOverlays: React.FC = ({ + canvas, + dashboardId, + sessions, + cards, + viewCards, + browserCards, + focusedCardId, + shakeDirection, + neighborDirections, + toolbarOpen, + searchPaletteOpen, + newAgentBounce, + toolbarRef, + onNewAgent, + onToolbarCancel, + onToolbarSend, + onAddView, + onHistoryResume, + onAddBrowser, + onAddNote, + onNewAgentBounceEnd, + onFitToView, + onTidy, + onSearchPaletteClose, +}) => { + return ( + <> + {/* Floating bottom toolbar */} + + + + + {/* Arrow navigation hints when zoomed in on a card */} + {focusedCardId && canvas.zoom >= 0.4 && ( + + )} + + {/* Floating zoom controls + minimap */} + + canvas.actions.setState({ panX: px, panY: py, zoom: canvas.zoom })} + /> + + + {/* Card search palette (Cmd+F) */} + canvas.actions.fitToCards([rect], 1.15, true)} + cards={cards} + viewCards={viewCards} + browserCards={browserCards} + sessions={sessions} + /> + + ); +}; + +export default DashboardOverlays; diff --git a/frontend/src/app/pages/Dashboard/contentBounds.ts b/frontend/src/app/pages/Dashboard/contentBounds.ts new file mode 100644 index 00000000..38a9675c --- /dev/null +++ b/frontend/src/app/pages/Dashboard/contentBounds.ts @@ -0,0 +1,35 @@ +import type { + CardPosition, + ViewCardPosition, + BrowserCardPosition, +} from '@/shared/state/dashboardLayoutSlice'; + +export interface ContentBounds { + minX: number; + minY: number; + maxX: number; + maxY: number; +} + +// Bounding box over agent + view + browser cards (notes intentionally +// excluded, same as before). Returns undefined for an empty canvas. +export function computeContentBounds( + cards: Record, + viewCards: Record, + browserCards: Record, +): ContentBounds | undefined { + const allRects = [ + ...Object.values(cards).map((c) => ({ x: c.x, y: c.y, w: c.width, h: c.height })), + ...Object.values(viewCards).map((c) => ({ x: c.x, y: c.y, w: c.width, h: c.height })), + ...Object.values(browserCards).map((c) => ({ x: c.x, y: c.y, w: c.width, h: c.height })), + ]; + if (allRects.length === 0) return undefined; + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (const r of allRects) { + minX = Math.min(minX, r.x); + minY = Math.min(minY, r.y); + maxX = Math.max(maxX, r.x + r.w); + maxY = Math.max(maxY, r.y + r.h); + } + return { minX, minY, maxX, maxY }; +} diff --git a/frontend/src/app/pages/Dashboard/getCardRect.ts b/frontend/src/app/pages/Dashboard/getCardRect.ts new file mode 100644 index 00000000..6b6da0f0 --- /dev/null +++ b/frontend/src/app/pages/Dashboard/getCardRect.ts @@ -0,0 +1,28 @@ +import { store } from '@/shared/state/store'; +import type { CardType } from './useDashboardSelection'; + +// Reads a card's rect straight from the live Redux store (collapsed height, +// which is what the zoom math wants). Module-level + store.getState() so the +// callback can stay stable across renders. +export function getCardRect(id: string, type: CardType): + { x: number; y: number; width: number; height: number } | undefined { + const layoutState = store.getState().dashboardLayout; + if (type === 'agent') { + const card = layoutState.cards[id]; + if (!card) return undefined; + return { x: card.x, y: card.y, width: card.width, height: card.height }; + } else if (type === 'view') { + const vc = layoutState.viewCards[id]; + if (!vc) return undefined; + return { x: vc.x, y: vc.y, width: vc.width, height: vc.height }; + } else if (type === 'browser') { + const bc = layoutState.browserCards[id]; + if (!bc) return undefined; + return { x: bc.x, y: bc.y, width: bc.width, height: bc.height }; + } else if (type === 'note') { + const n = layoutState.notes[id]; + if (!n) return undefined; + return { x: n.x, y: n.y, width: n.width, height: n.height }; + } + return undefined; +} diff --git a/frontend/src/app/pages/Dashboard/useAgentSpawn.ts b/frontend/src/app/pages/Dashboard/useAgentSpawn.ts new file mode 100644 index 00000000..47b3799d --- /dev/null +++ b/frontend/src/app/pages/Dashboard/useAgentSpawn.ts @@ -0,0 +1,231 @@ +import { useCallback, type Dispatch, type RefObject, type SetStateAction } from 'react'; +import { report } from '@/shared/serviceClient'; +import { store } from '@/shared/state/store'; +import { useAppDispatch } from '@/shared/hooks'; +import { + expandSession, + launchAndSendFirstMessage, + generateTitle, + type AgentConfig, +} from '@/shared/state/agentsSlice'; +import { + placeCard, + setGlowingAgentCard, + setGlowingBrowserCards, + DEFAULT_CARD_W, + DEFAULT_CARD_H, + EXPANDED_CARD_MIN_H, + GRID_GAP, + type CardPosition, +} from '@/shared/state/dashboardLayoutSlice'; +import { generateDashboardName } from '@/shared/state/dashboardsSlice'; +import type { ContextPath } from '@/app/components/DirectoryBrowser'; +import type { CanvasActions } from './useCanvasControls'; + +type SpawnOrigin = { x: number; y: number; type?: 'branch' }; + +interface UseAgentSpawnArgs { + cards: Record; + expandedSessionIds: string[]; + dashboardId: string; + expandNewChats: boolean; + canvasActions: CanvasActions; + viewportRef: RefObject; + toolbarRef: RefObject; + canvasStateRef: RefObject<{ panX: number; panY: number; zoom: number }>; + spawnOriginsRef: RefObject>; + handleHighlightCard: (cardId: string) => void; + setToolbarOpen: Dispatch>; + setAutoFocusSessionId: Dispatch>; + setPendingSelectSessionId: Dispatch>; +} + +export function useAgentSpawn({ + cards, + expandedSessionIds, + dashboardId, + expandNewChats, + canvasActions, + viewportRef, + toolbarRef, + canvasStateRef, + spawnOriginsRef, + handleHighlightCard, + setToolbarOpen, + setAutoFocusSessionId, + setPendingSelectSessionId, +}: UseAgentSpawnArgs) { + const dispatch = useAppDispatch(); + + const handleBranchFromCard = useCallback( + (sourceSessionId: string, newSessionId: string) => { + const sourceCard = cards[sourceSessionId]; + if (!sourceCard) return; + + const targetX = sourceCard.x + sourceCard.width + GRID_GAP * 12; + let targetY = sourceCard.y; + + const columnCards = Object.values(cards).filter( + (c) => Math.abs(c.x - targetX) < 50 && c.session_id !== newSessionId, + ); + if (columnCards.length > 0) { + const lowestBottom = Math.max( + ...columnCards.map((c) => c.y + Math.max(EXPANDED_CARD_MIN_H, c.height)), + ); + targetY = lowestBottom + GRID_GAP; + } + + spawnOriginsRef.current![newSessionId] = { + x: sourceCard.x, + y: sourceCard.y, + type: 'branch' as const, + }; + + dispatch(placeCard({ + sessionId: newSessionId, + x: targetX, + y: targetY, + width: DEFAULT_CARD_W, + height: DEFAULT_CARD_H, + expandedSessionIds, + })); + + if (expandedSessionIds.includes(sourceSessionId)) { + dispatch(expandSession(newSessionId)); + } + + dispatch(setGlowingAgentCard({ sessionId: newSessionId, sourceId: sourceSessionId, label: 'Branch' })); + }, + [cards, dispatch, expandedSessionIds], + ); + + const handleNewAgent = useCallback(() => { + setToolbarOpen(true); + }, []); + + const handleToolbarCancel = useCallback(() => { + setToolbarOpen(false); + }, []); + + const handleToolbarSend = useCallback( + ( + prompt: string, + mode: string, + model: string, + images?: Array<{ data: string; media_type: string }>, + contextPaths?: ContextPath[], + forcedTools?: string[], + attachedSkills?: Array<{ id: string; name: string; content: string }>, + selectedBrowserIds?: string[], + ) => { + setToolbarOpen(false); + report('dashboard', 'agent_created', { mode, model, has_images: !!images?.length, has_context: !!contextPaths?.length, has_browser: !!selectedBrowserIds?.length }); + + const draftId = `draft-${Date.now().toString(36)}`; + + const toolbarEl = toolbarRef.current; + const vpEl = viewportRef.current; + if (toolbarEl && vpEl) { + const tr = toolbarEl.getBoundingClientRect(); + const vr = vpEl.getBoundingClientRect(); + const toolbarCenterX = tr.left + tr.width / 2; + const toolbarTopY = tr.top; + const { panX, panY, zoom } = canvasStateRef.current!; + spawnOriginsRef.current![draftId] = { + x: (toolbarCenterX - vr.left - panX) / zoom, + y: (toolbarTopY - vr.top - panY) / zoom, + }; + } + + const config: AgentConfig = { name: 'New chat', model, mode, dashboard_id: dashboardId }; + + dispatch( + launchAndSendFirstMessage({ + draftId, + config, + prompt, + mode, + model, + images, + contextPaths: contextPaths?.map((cp) => ({ path: cp.path, type: cp.type })), + forcedTools, + attachedSkills, + expand: expandNewChats, + }), + ).then((action) => { + if (launchAndSendFirstMessage.fulfilled.match(action)) { + const realId = action.payload.session.id; + dispatch(generateTitle({ sessionId: realId, prompt })); + if (selectedBrowserIds?.length) { + dispatch(setGlowingBrowserCards({ browserIds: selectedBrowserIds, sessionId: realId, label: 'Use Browser' })); + + if (selectedBrowserIds.length === 1) { + const bc = store.getState().dashboardLayout.browserCards[selectedBrowserIds[0]]; + if (bc) { + // Use placeCard (collision-aware) instead of + // setCardPosition (blind setter). The "left of the + // browser" anchor is the IDEAL spot , but if it's + // already taken by an existing chat (e.g. step 3's + // YouTube agent that's still on canvas when step 5 + // creates a new chat for the same browser), placeCard + // cascades to the nearest free cell instead of + // stacking on top. + dispatch(placeCard({ + sessionId: realId, + x: bc.x - DEFAULT_CARD_W - GRID_GAP * 12, + y: bc.y, + width: DEFAULT_CARD_W, + height: DEFAULT_CARD_H, + expandedSessionIds, + })); + } + } + } + spawnOriginsRef.current![realId] = spawnOriginsRef.current![draftId]; + delete spawnOriginsRef.current![draftId]; + + if (expandNewChats) { + setAutoFocusSessionId(realId); + dispatch(expandSession(realId)); + } else { + setPendingSelectSessionId(realId); + } + + setTimeout(() => { + const card = store.getState().dashboardLayout.cards[realId]; + if (card) { + canvasActions.fitToCards([{ x: card.x, y: card.y, width: card.width, height: card.height }], 1.15, true); + handleHighlightCard(realId); + } + }, 200); + + if (dashboardId) { + const currentSessions = store.getState().agents.sessions; + const agentCount = Object.values(currentSessions).filter( + (s) => s.status !== 'draft' && s.dashboard_id === dashboardId, + ).length; + const NAME_GEN_TRIGGERS = [1, 3, 6]; + const currentDash = store.getState().dashboards.items[dashboardId]; + const canAutoName = + currentDash && + (currentDash.auto_named || currentDash.name === 'Untitled Dashboard'); + + if (NAME_GEN_TRIGGERS.includes(agentCount) && canAutoName) { + dispatch(generateDashboardName(dashboardId)); + } + } + } else { + delete spawnOriginsRef.current![draftId]; + } + }); + }, + [viewportRef, canvasActions, dispatch, dashboardId, expandNewChats, handleHighlightCard], + ); + + return { + handleBranchFromCard, + handleNewAgent, + handleToolbarCancel, + handleToolbarSend, + }; +} diff --git a/frontend/src/app/pages/Dashboard/useDashboardCardActions.ts b/frontend/src/app/pages/Dashboard/useDashboardCardActions.ts new file mode 100644 index 00000000..b88a98cd --- /dev/null +++ b/frontend/src/app/pages/Dashboard/useDashboardCardActions.ts @@ -0,0 +1,146 @@ +import { useCallback, useEffect, type Dispatch, type SetStateAction } from 'react'; +import { report } from '@/shared/serviceClient'; +import { store } from '@/shared/state/store'; +import { useAppDispatch } from '@/shared/hooks'; +import { expandSession, resumeSession } from '@/shared/state/agentsSlice'; +import { + tidyLayout, + addViewCard, + addBrowserCard, + addNote, + clearPendingFocusNoteId, + EXPANDED_CARD_MIN_H, +} from '@/shared/state/dashboardLayoutSlice'; +import type { CardType, useDashboardSelection } from './useDashboardSelection'; +import type { CanvasActions } from './useCanvasControls'; + +type Selection = ReturnType; + +interface UseDashboardCardActionsArgs { + expandedSessionIds: string[]; + browserHomepage: string; + pendingFocusNoteId: string | null; + selection: Selection; + canvasActions: CanvasActions; + getCardRect: (id: string, type: CardType) => { x: number; y: number; width: number; height: number } | undefined; + handleHighlightCard: (cardId: string) => void; + setAutoFocusSessionId: Dispatch>; +} + +export function useDashboardCardActions({ + expandedSessionIds, + browserHomepage, + pendingFocusNoteId, + selection, + canvasActions, + getCardRect, + handleHighlightCard, + setAutoFocusSessionId, +}: UseDashboardCardActionsArgs) { + const dispatch = useAppDispatch(); + + const handleAddView = useCallback((outputId: string) => { + dispatch(addViewCard({ outputId, expandedSessionIds })); + setTimeout(() => { + const card = store.getState().dashboardLayout.viewCards[outputId]; + if (card) { + canvasActions.fitToCards([{ x: card.x, y: card.y, width: card.width, height: card.height }], 1.15, true); + handleHighlightCard(outputId); + } + }, 200); + }, [dispatch, expandedSessionIds, canvasActions, handleHighlightCard]); + + const handleAddBrowser = useCallback(() => { + report('dashboard', 'browser_added'); + const prevIds = new Set(Object.keys(store.getState().dashboardLayout.browserCards)); + dispatch(addBrowserCard({ url: browserHomepage, expandedSessionIds })); + 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, canvasActions, handleHighlightCard]); + + const handleAddNote = useCallback(() => { + report('dashboard', 'note_added'); + const prevIds = new Set(Object.keys(store.getState().dashboardLayout.notes)); + dispatch(addNote({ expandedSessionIds })); + setTimeout(() => { + const allNotes = store.getState().dashboardLayout.notes; + 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); + handleHighlightCard(newId); + } + }, 200); + }, [dispatch, expandedSessionIds, canvasActions, handleHighlightCard]); + + // Auto-clear pendingFocusNoteId after the note has had a chance to mount + autofocus. + useEffect(() => { + if (!pendingFocusNoteId) return; + const t = setTimeout(() => dispatch(clearPendingFocusNoteId()), 800); + return () => clearTimeout(t); + }, [pendingFocusNoteId, dispatch]); + + const handleHistoryResume = useCallback((sessionId: string) => { + dispatch(resumeSession({ sessionId })).then((action) => { + if (resumeSession.fulfilled.match(action)) { + dispatch(expandSession(sessionId)); + setAutoFocusSessionId(sessionId); + setTimeout(() => { + const card = store.getState().dashboardLayout.cards[sessionId]; + if (card) { + canvasActions.fitToCards([{ x: card.x, y: card.y, width: card.width, height: card.height }], 1.15, true); + handleHighlightCard(sessionId); + } + }, 200); + } + }); + }, [dispatch, canvasActions, handleHighlightCard, setAutoFocusSessionId]); + + // Context-aware fit: if a card is selected, zoom to it; otherwise fit all + const handleFitToView = useCallback(() => { + report('dashboard', 'fit_to_view', { has_selection: selection.selectedIds.size > 0 }); + if (selection.selectedIds.size === 1) { + const [[id, type]] = selection.selectedIds; + const rect = getCardRect(id, type); + if (rect) { + canvasActions.fitToCards([rect], 1.15, true); + return; + } + } + canvasActions.fitToView(); + }, [selection.selectedIds, getCardRect, canvasActions]); + + const handleTidy = useCallback(() => { + report('dashboard', 'tidy_layout'); + const currentExpanded = store.getState().agents.expandedSessionIds; + dispatch(tidyLayout({ expandedSessionIds: currentExpanded })); + + const expandedSet = new Set(currentExpanded); + const { cards: tidied, viewCards: tidiedViews, browserCards: tidiedBrowsers } = store.getState().dashboardLayout; + const allRects = [ + ...Object.values(tidied).map((c) => ({ + x: c.x, y: c.y, width: c.width, + height: expandedSet.has(c.session_id) ? Math.max(EXPANDED_CARD_MIN_H, c.height) : c.height, + })), + ...Object.values(tidiedViews).map((c) => ({ x: c.x, y: c.y, width: c.width, height: c.height })), + ...Object.values(tidiedBrowsers).map((c) => ({ x: c.x, y: c.y, width: c.width, height: c.height })), + ]; + canvasActions.fitToCards(allRects); + }, [dispatch, canvasActions]); + + return { + handleAddView, + handleAddBrowser, + handleAddNote, + handleHistoryResume, + handleFitToView, + handleTidy, + }; +} diff --git a/frontend/src/app/pages/Dashboard/useDashboardController.ts b/frontend/src/app/pages/Dashboard/useDashboardController.ts new file mode 100644 index 00000000..483ce4b9 --- /dev/null +++ b/frontend/src/app/pages/Dashboard/useDashboardController.ts @@ -0,0 +1,273 @@ +import { useCallback, useMemo, useRef } from 'react'; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; +import { useElementSelection } from '@/app/components/ElementSelectionContext'; +import { useCanvasControls } from './useCanvasControls'; +import { useDashboardSelection } from './useDashboardSelection'; +import { useDashboardSelectors } from './useDashboardSelectors'; +import { getCardRect } from './getCardRect'; +import { computeContentBounds } from './contentBounds'; +import { useDashboardUiState } from './useDashboardUiState'; +import { useLayoutSave } from './useLayoutSave'; +import { useTethers } from './dashboardTethers'; +import { useArrowNav } from './useArrowNav'; +import { useDashboardShortcuts } from './useDashboardShortcuts'; +import { useDashboardClipboard } from './useDashboardClipboard'; +import { useCardDrag } from './useCardDrag'; +import { useSubAgentLifecycle } from './useSubAgentLifecycle'; +import { useDashboardLifecycle } from './useDashboardLifecycle'; +import { useDashboardThumbnail } from './useDashboardThumbnail'; +import { useSiblingRestack } from './useSiblingRestack'; +import { useAgentSpawn } from './useAgentSpawn'; +import { useDashboardCardActions } from './useDashboardCardActions'; +import { useDashboardInteractions } from './useDashboardInteractions'; + +// Composition root for the dashboard. Wires every dashboard hook together +// and returns exactly the prop bag DashboardCanvas renders. Kept out of +// Dashboard.tsx so the component file stays a thin shell. +export function useDashboardController(dashboardId: string, isActive: boolean) { + const c = useClaudeTokens(); + const elementSelectionCtx = useElementSelection(); + const isElementSelectMode = elementSelectionCtx?.selectMode ?? false; + const { + dashboardName, sessions, expandedSessionIds, cards, viewCards, browserCards, + notes, pendingFocusNoteId, layoutInitialized, persistedExpandedSessionIds, + zoomSensitivity, newAgentShortcut, browserHomepage, expandNewChats, + autoRevealSubAgents, outputs, outputsLoaded, glowingAgentCards, glowingBrowserCards, + } = useDashboardSelectors(dashboardId); + // sessions is the top-level dict; useMemo on its identity so sessionList + // is stable when sessions hasn't actually changed (RTK only swaps the dict + // ref when one of its values changes, so this is the right granularity). + const sessionList = useMemo(() => Object.values(sessions), [sessions]); + + const contentBounds = useMemo( + () => computeContentBounds(cards, viewCards, browserCards), + [cards, viewCards, browserCards], + ); + + const canvas = useCanvasControls(zoomSensitivity, contentBounds, isActive); + const selection = useDashboardSelection( + { panX: canvas.panX, panY: canvas.panY, zoom: canvas.zoom, viewportRef: canvas.viewportRef }, + cards, + viewCards, + browserCards, + notes, + ); + const { + toolbarRef, toolbarOpen, setToolbarOpen, searchPaletteOpen, setSearchPaletteOpen, + highlightedCardId, handleHighlightCard, autoFocusSessionId, setAutoFocusSessionId, + setPendingSelectSessionId, focusedCardId, setFocusedCardId, newAgentBounce, setNewAgentBounce, + spawnOriginsRef, measuredHeightsRef, measuredHeightsTick, handleMeasuredHeight, + revealSpawnedRef, hasFittedRef, restoredExpandedRef, + } = useDashboardUiState(selection, cards); + + const canvasStateRef = useRef({ panX: canvas.panX, panY: canvas.panY, zoom: canvas.zoom }); + canvasStateRef.current = { panX: canvas.panX, panY: canvas.panY, zoom: canvas.zoom }; + // Stable getter , AgentCards read pan/zoom on demand during drag math. + const getCanvasState = useCallback(() => canvasStateRef.current, []); + + const { + multiDragDelta, + liveDragInfo, + handleCardDragStart, + handleCardDragMove, + handleCardDragEnd, + } = useCardDrag({ + panX: canvas.panX, + panY: canvas.panY, + zoom: canvas.zoom, + viewportRef: canvas.viewportRef, + canvasActions: canvas.actions, + selection, + }); + + const { + handleCardSelect, + handleBringToFront, + handleViewportMouseDown, + handleViewportMouseMove, + handleViewportMouseUp, + handleViewportDoubleClick, + handleCardDoubleClick, + } = useDashboardInteractions({ + canvas, + selection, + expandedSessionIds, + isElementSelectMode, + getCardRect, + setFocusedCardId, + }); + + const { captureNow } = useDashboardThumbnail({ + isActive, + dashboardId, + layoutInitialized, + viewportRef: canvas.viewportRef, + contentRef: canvas.contentRef, + }); + + useDashboardLifecycle({ + isActive, + dashboardId, + layoutInitialized, + sessions, + expandedSessionIds, + persistedExpandedSessionIds, + viewCards, + outputs, + outputsLoaded, + canvasActions: canvas.actions, + handleHighlightCard, + hasFittedRef, + restoredExpandedRef, + }); + + // ---- Auto-reveal / collapse / unreveal sub-agent cards ---- + useSubAgentLifecycle({ + isActive, + sessions, + cards, + layoutInitialized, + autoRevealSubAgents, + expandedSessionIds, + }); + + useLayoutSave({ + isActive, + layoutInitialized, + dashboardId, + cards, + viewCards, + browserCards, + notes, + expandedSessionIds, + captureNow, + }); + + useDashboardShortcuts({ + isActive, + newAgentShortcut, + selection, + setToolbarOpen, + setSearchPaletteOpen, + }); + + useDashboardClipboard({ + isActive, + dashboardId, + selection, + sessions, + cards, + viewCards, + browserCards, + outputs, + expandedSessionIds, + }); + + // ---- Arrow key card navigation (when zoomed in on a card) ---- + const { neighborDirections, shakeDirection } = useArrowNav({ + cards, + viewCards, + browserCards, + zoom: canvas.zoom, + isActive, + focusedCardId, + setFocusedCardId, + canvasActions: canvas.actions, + getCardRect, + }); + + const { + handleBranchFromCard, + handleNewAgent, + handleToolbarCancel, + handleToolbarSend, + } = useAgentSpawn({ + cards, + expandedSessionIds, + dashboardId, + expandNewChats, + canvasActions: canvas.actions, + viewportRef: canvas.viewportRef, + toolbarRef, + canvasStateRef, + spawnOriginsRef, + handleHighlightCard, + setToolbarOpen, + setAutoFocusSessionId, + setPendingSelectSessionId, + }); + + const { + handleAddView, + handleAddBrowser, + handleAddNote, + handleHistoryResume, + handleFitToView, + handleTidy, + } = useDashboardCardActions({ + expandedSessionIds, + browserHomepage, + pendingFocusNoteId, + selection, + canvasActions: canvas.actions, + getCardRect, + handleHighlightCard, + setAutoFocusSessionId, + }); + + useSiblingRestack({ + isActive, + expandedSessionIds, + glowingAgentCards, + glowingBrowserCards, + cards, + browserCards, + measuredHeightsRef, + measuredHeightsTick, + }); + + const tethers = useTethers({ + glowingAgentCards, + glowingBrowserCards, + cards, + browserCards, + expandedSessionIds, + liveDragInfo, + measuredHeightsRef, + measuredHeightsTick, + sessionList, + }); + + return { + c, dashboardId, dashboardName, canvas, selection, sessions, sessionList, + cards, viewCards, browserCards, notes, outputs, glowingAgentCards, + expandedSessionIds, tethers, highlightedCardId, autoFocusSessionId, + focusedCardId, pendingFocusNoteId, multiDragDelta, shakeDirection, + neighborDirections, toolbarOpen, searchPaletteOpen, newAgentBounce, + toolbarRef, spawnOriginsRef, revealSpawnedRef, measuredHeightsRef, getCanvasState, + onViewportMouseDown: handleViewportMouseDown, + onViewportMouseMove: handleViewportMouseMove, + onViewportMouseUp: handleViewportMouseUp, + onViewportDoubleClick: handleViewportDoubleClick, + onCardSelect: handleCardSelect, + onDragStart: handleCardDragStart, + onDragMove: handleCardDragMove, + onDragEnd: handleCardDragEnd, + onCardDoubleClick: handleCardDoubleClick, + onBringToFront: handleBringToFront, + onBranch: handleBranchFromCard, + onMeasuredHeight: handleMeasuredHeight, + onHighlightCard: handleHighlightCard, + onNewAgent: handleNewAgent, + onToolbarCancel: handleToolbarCancel, + onToolbarSend: handleToolbarSend, + onAddView: handleAddView, + onHistoryResume: handleHistoryResume, + onAddBrowser: handleAddBrowser, + onAddNote: handleAddNote, + onNewAgentBounceEnd: () => setNewAgentBounce(false), + onFitToView: handleFitToView, + onTidy: handleTidy, + onSearchPaletteClose: () => setSearchPaletteOpen(false), + }; +} diff --git a/frontend/src/app/pages/Dashboard/useDashboardInteractions.ts b/frontend/src/app/pages/Dashboard/useDashboardInteractions.ts new file mode 100644 index 00000000..36a3bb74 --- /dev/null +++ b/frontend/src/app/pages/Dashboard/useDashboardInteractions.ts @@ -0,0 +1,168 @@ +import React, { useCallback, useRef, type Dispatch, type SetStateAction } from 'react'; +import { report } from '@/shared/serviceClient'; +import { useAppDispatch } from '@/shared/hooks'; +import { collapseSession, expandSession } from '@/shared/state/agentsSlice'; +import { bringToFront } from '@/shared/state/dashboardLayoutSlice'; +import type { CardType, useDashboardSelection } from './useDashboardSelection'; +import type { useCanvasControls } from './useCanvasControls'; + +type Selection = ReturnType; +type Canvas = ReturnType; + +const SELECT_ATTR = 'data-select-type'; + +function isCardTarget(target: EventTarget | null, boundary: EventTarget | null): boolean { + let el = target as HTMLElement | null; + while (el && el !== boundary) { + if (el.hasAttribute(SELECT_ATTR)) return true; + el = el.parentElement; + } + return false; +} + +interface UseDashboardInteractionsArgs { + canvas: Canvas; + selection: Selection; + expandedSessionIds: string[]; + isElementSelectMode: boolean; + getCardRect: (id: string, type: CardType) => { x: number; y: number; width: number; height: number } | undefined; + setFocusedCardId: Dispatch>; +} + +export function useDashboardInteractions({ + canvas, + selection, + expandedSessionIds, + isElementSelectMode, + getCardRect, + setFocusedCardId, +}: UseDashboardInteractionsArgs) { + const dispatch = useAppDispatch(); + + // Delay single-click collapse so double-click can override + const clickTimerRef = useRef | null>(null); + + const handleCardSelect = useCallback((id: string, type: CardType, shiftKey: boolean) => { + report('dashboard', 'card_clicked', { card_type: type, shift: shiftKey }); + if (shiftKey) { + selection.selectCard(id, type, true); + return; + } + + selection.selectCard(id, type, false); + dispatch(bringToFront({ id, type })); + + const alreadyExpanded = type === 'agent' && expandedSessionIds.includes(id); + + if (alreadyExpanded) { + // Delay single-click collapse so double-click can override. + // Double-click handler (handleCardDoubleClick) clears clickTimerRef. + clickTimerRef.current = setTimeout(() => { + clickTimerRef.current = null; + dispatch(collapseSession(id)); + }, 250); + return; + } + + // Expand (if not already) + center + zoom + bring to front + if (type === 'agent') { + dispatch(expandSession(id)); + } + setFocusedCardId(id); + setTimeout(() => { + const rect = getCardRect(id, type); + if (rect) canvas.actions.fitToCards([rect], 1.15, true, type === 'browser' ? 0.8 : undefined); + setTimeout(() => (document.activeElement as HTMLElement)?.blur?.(), 150); + }, 100); + }, [selection, getCardRect, canvas.actions, dispatch, expandedSessionIds]); + + const handleBringToFront = useCallback((id: string, type: CardType) => { + dispatch(bringToFront({ id, type })); + }, [dispatch]); + + // ---- Viewport event handlers (compose pan + marquee) ---- + const handleViewportMouseDown = useCallback((e: React.MouseEvent) => { + if (e.button === 1) { + canvas.handlers.onMouseDown(e); + return; + } + + if (e.button === 2) { + e.preventDefault(); + canvas.handlers.onMouseDown(e); + return; + } + + if (e.button !== 0) return; + if (isCardTarget(e.target, e.currentTarget)) return; + + // Canvas click , drop any lingering input focus so arrow-key nav + // works immediately without the user having to press Escape first. + const active = document.activeElement as HTMLElement | null; + const activeTag = active?.tagName; + if (activeTag === 'INPUT' || activeTag === 'TEXTAREA' || (active as any)?.isContentEditable) { + active?.blur?.(); + } + + if (isElementSelectMode) { + if (e.metaKey || e.ctrlKey) { + canvas.handlers.onMouseDown(e); + } + return; + } + + if (e.metaKey || e.ctrlKey || canvas.spaceHeld) { + selection.deselectAll(); + canvas.handlers.onMouseDown(e); + } else { + selection.handleCanvasMouseDown(e.nativeEvent); + } + }, [canvas.handlers, canvas.spaceHeld, selection, isElementSelectMode]); + + const handleViewportMouseMove = useCallback((e: React.MouseEvent) => { + canvas.handlers.onMouseMove(e); + selection.handleCanvasMouseMove(e.nativeEvent); + }, [canvas.handlers, selection]); + + const handleViewportMouseUp = useCallback((e: React.MouseEvent) => { + canvas.handlers.onMouseUp(); + selection.handleCanvasMouseUp(e.nativeEvent); + }, [canvas.handlers, selection]); + + // Double-click empty canvas → fit all cards + const handleViewportDoubleClick = useCallback((e: React.MouseEvent) => { + if (e.button !== 0) return; + if (isCardTarget(e.target, e.currentTarget)) return; + report('dashboard', 'canvas_double_clicked'); + canvas.actions.fitToView(); + }, [canvas.actions]); + + // Double-click a card → always expand + center + zoom (cancels pending collapse from single-click) + const handleCardDoubleClick = useCallback((id: string, type: CardType) => { + report('dashboard', 'card_double_clicked', { card_type: type }); + if (clickTimerRef.current) { + clearTimeout(clickTimerRef.current); + clickTimerRef.current = null; + } + if (type === 'agent') { + dispatch(expandSession(id)); + } + dispatch(bringToFront({ id, type })); + setFocusedCardId(id); + setTimeout(() => { + const rect = getCardRect(id, type); + if (rect) canvas.actions.fitToCards([rect], 1.15, true); + setTimeout(() => (document.activeElement as HTMLElement)?.blur?.(), 150); + }, 100); + }, [getCardRect, canvas.actions, dispatch]); + + return { + handleCardSelect, + handleBringToFront, + handleViewportMouseDown, + handleViewportMouseMove, + handleViewportMouseUp, + handleViewportDoubleClick, + handleCardDoubleClick, + }; +} diff --git a/frontend/src/app/pages/Dashboard/useDashboardLifecycle.ts b/frontend/src/app/pages/Dashboard/useDashboardLifecycle.ts new file mode 100644 index 00000000..fed351af --- /dev/null +++ b/frontend/src/app/pages/Dashboard/useDashboardLifecycle.ts @@ -0,0 +1,247 @@ +import { useEffect, useRef, type MutableRefObject } from 'react'; +import { report } from '@/shared/serviceClient'; +import { store } from '@/shared/state/store'; +import { useAppDispatch, useAppSelector } from '@/shared/hooks'; +import { + fetchSessions, + fetchHistory, + setExpandedSessionIds, + type AgentSession, +} from '@/shared/state/agentsSlice'; +import { + fetchLayout, + reconcileSessions, + addBrowserCard, + resetLayout, + removeViewCard, + clearPendingFocusBrowserId, + type ViewCardPosition, +} from '@/shared/state/dashboardLayoutSlice'; +import { fetchOutputs, type Output } from '@/shared/state/outputsSlice'; +import { dashboardWs } from '@/shared/ws/WebSocketManager'; +import { initBrowserCommandHandler } from '@/shared/browserCommandHandler'; +import { clearPendingBrowserUrl, clearPendingFocusAgentId } from '@/shared/state/tempStateSlice'; +import { API_BASE } from '@/shared/config'; +import type { CanvasActions } from './useCanvasControls'; + +interface UseDashboardLifecycleArgs { + isActive: boolean; + dashboardId: string; + layoutInitialized: boolean; + sessions: Record; + expandedSessionIds: string[]; + persistedExpandedSessionIds: string[]; + viewCards: Record; + outputs: Record; + outputsLoaded: boolean; + canvasActions: CanvasActions; + handleHighlightCard: (cardId: string) => void; + hasFittedRef: MutableRefObject; + restoredExpandedRef: MutableRefObject; +} + +export function useDashboardLifecycle({ + isActive, + dashboardId, + layoutInitialized, + sessions, + expandedSessionIds, + persistedExpandedSessionIds, + viewCards, + outputs, + outputsLoaded, + canvasActions, + handleHighlightCard, + hasFittedRef, + restoredExpandedRef, +}: UseDashboardLifecycleArgs) { + const dispatch = useAppDispatch(); + const pendingBrowserUrl = useAppSelector((state) => state.tempState.pendingBrowserUrl); + const pendingFocusAgentId = useAppSelector((state) => state.tempState.pendingFocusAgentId); + const pendingFocusBrowserId = useAppSelector((state) => state.dashboardLayout.pendingFocusBrowserId); + + // Track dashboard engagement time + useEffect(() => { + if (!dashboardId) return; + const startTime = Date.now(); + report('dashboard', 'opened', { dashboard_id: dashboardId }); + return () => { + report('dashboard', 'closed', { + dashboard_id: dashboardId, + time_spent_seconds: Math.round((Date.now() - startTime) / 1000), + }); + }; + }, [dashboardId]); + + useEffect(() => { + if (!dashboardId) return; + hasFittedRef.current = false; + restoredExpandedRef.current = false; + dispatch(resetLayout()); + // CRITICAL path: these populate the cards the user expects to see + // on first paint. Don't defer. + dispatch(fetchSessions({ dashboardId })); + dispatch(fetchLayout(dashboardId)); + const cleanupBrowserHandler = initBrowserCommandHandler(); + // DEFERRABLE: history list (for the search palette) and outputs + // (for the apps panel) aren't on the first-paint path. Same for the + // dashboard WS connection (it carries cross-session events; opens + // ~100ms later costs nothing). Pushing these into the post-paint + // window measurably improves LCP because the initial render + // pipeline isn't competing with their thunks/network setup. + const idleHandle = (typeof window !== 'undefined' && (window as any).requestIdleCallback) + ? (window as any).requestIdleCallback(() => { + dispatch(fetchHistory({ dashboardId })); + dispatch(fetchOutputs()); + dashboardWs.connect(); + }, { timeout: 2000 }) + : window.setTimeout(() => { + dispatch(fetchHistory({ dashboardId })); + dispatch(fetchOutputs()); + dashboardWs.connect(); + }, 200); + + // Pre-warm Anthropic's prompt cache for sessions on this dashboard + // ~250ms after mount (debounced; AbortController cancels on + // dashboard switch). Fires a max_tokens=1 ping per session so the + // user's first real message hits a warm cache instead of paying + // cold-start TTFT. Cheap (~$0.0001/session) and non-blocking. Skips + // for non-Anthropic sessions server-side. + const warmAbort = new AbortController(); + const warmTimer = setTimeout(async () => { + try { + const sessionsState = store.getState().agents.sessions; + const dashSessions = Object.values(sessionsState).filter( + (s) => s.dashboard_id === dashboardId && + s.status !== 'draft' && + s.mode !== 'browser-agent' && + s.mode !== 'sub-agent' && + s.mode !== 'invoked-agent', + ); + for (const s of dashSessions) { + if (warmAbort.signal.aborted) break; + // Fire-and-forget , the endpoint always 200s and the side + // effect is invisible cache population. + fetch(`${API_BASE}/agents/sessions/${s.id}/warm-cache`, { + method: 'POST', + signal: warmAbort.signal, + }).catch(() => {}); + } + } catch { + /* best-effort */ + } + }, 250); + + return () => { + clearTimeout(warmTimer); + warmAbort.abort(); + cleanupBrowserHandler(); + dashboardWs.disconnect(); + // Cancel any not-yet-fired idle work; the cleanup handler can't + // run partially if the dashboard switches before idle fired. + if (typeof window !== 'undefined') { + const cancelIdle = (window as any).cancelIdleCallback; + if (cancelIdle && typeof idleHandle === 'number') cancelIdle(idleHandle); + else if (typeof idleHandle === 'number') clearTimeout(idleHandle); + } + }; + }, [dispatch, dashboardId]); + + useEffect(() => { + if (!dashboardId) return; + (window as any).__openswarm_last_dashboard_id = dashboardId; + }, [dashboardId]); + + useEffect(() => { + if (!pendingBrowserUrl || !layoutInitialized) return; + dispatch(addBrowserCard({ url: pendingBrowserUrl, expandedSessionIds })); + dispatch(clearPendingBrowserUrl()); + }, [pendingBrowserUrl, layoutInitialized, dispatch, expandedSessionIds]); + + useEffect(() => { + if (!isActive) return; // Don't auto-fit while dashboard is hidden + if (!layoutInitialized || hasFittedRef.current) return; + if (pendingFocusAgentId) return; + hasFittedRef.current = true; + const timer = setTimeout(() => canvasActions.fitToView(), 150); + return () => clearTimeout(timer); + }, [isActive, layoutInitialized, canvasActions, pendingFocusAgentId]); + + useEffect(() => { + if (!isActive) return; // Defer focus animation until dashboard is visible + if (!pendingFocusAgentId || !layoutInitialized) return; + const agentId = pendingFocusAgentId; + dispatch(clearPendingFocusAgentId()); + hasFittedRef.current = true; + setTimeout(() => { + const card = store.getState().dashboardLayout.cards[agentId]; + if (card) { + canvasActions.fitToCards([{ x: card.x, y: card.y, width: card.width, height: card.height }], 1.15, true); + handleHighlightCard(agentId); + } + }, 350); + }, [isActive, pendingFocusAgentId, layoutInitialized, dispatch, canvasActions, handleHighlightCard]); + + // Auto-focus a newly created browser card. The reducer that handles + // addBrowserCard sets pendingFocusBrowserId to the new card's id; this + // effect picks it up, pans/zooms the canvas to center on it, briefly + // highlights it, then clears the signal. Mirrors the pendingFocusAgentId + // pattern above so link clicks (intercepted in AppShell) get the same + // auto-focus behavior as the "+ Browser" toolbar button. + // + // Uses zoom=0.8 (the same value handleCardClick uses for browser cards + // at line ~344) instead of letting fitToCards auto-derive a zoom from + // padding. Browser cards are large (1280x800), so the auto-derived zoom + // would land around ~58% which feels too far back; 0.8 matches the + // "click on a browser to focus" experience the user expects. + useEffect(() => { + if (!isActive) return; + if (!pendingFocusBrowserId || !layoutInitialized) return; + const browserId = pendingFocusBrowserId; + dispatch(clearPendingFocusBrowserId()); + hasFittedRef.current = true; + setTimeout(() => { + const card = store.getState().dashboardLayout.browserCards[browserId]; + if (card) { + canvasActions.fitToCards( + [{ x: card.x, y: card.y, width: card.width, height: card.height }], + 1.15, + true, + 0.8, + ); + handleHighlightCard(browserId); + } + }, 200); + }, [isActive, pendingFocusBrowserId, layoutInitialized, dispatch, canvasActions, handleHighlightCard]); + + useEffect(() => { + if (!layoutInitialized || restoredExpandedRef.current) return; + restoredExpandedRef.current = true; + dispatch(setExpandedSessionIds(persistedExpandedSessionIds)); + }, [layoutInitialized, persistedExpandedSessionIds, dispatch]); + + const prevSessionIdsRef = useRef(''); + + useEffect(() => { + if (!layoutInitialized) return; + const dashboardSessionIds = Object.values(sessions) + .filter((s) => s.dashboard_id === dashboardId && s.mode !== 'browser-agent' && s.mode !== 'invoked-agent' && s.mode !== 'sub-agent') + .map((s) => s.id); + const liveIds = dashboardSessionIds.sort().join(','); + if (liveIds === prevSessionIdsRef.current) return; + prevSessionIdsRef.current = liveIds; + dispatch(reconcileSessions({ sessionIds: dashboardSessionIds, expandedSessionIds })); + }, [sessions, layoutInitialized, dispatch, dashboardId, expandedSessionIds]); + + // Prune orphan view cards whose underlying output was deleted (e.g. via + // the Views page). Without this, the layout entry persists in the + // minimap and contentBounds even though DashboardViewCard renders + // nothing. Gated on outputsLoaded so we don't wipe valid cards during + // the brief window between fetchLayout returning and outputs finishing. + useEffect(() => { + if (!layoutInitialized || !outputsLoaded) return; + for (const outputId of Object.keys(viewCards)) { + if (!outputs[outputId]) dispatch(removeViewCard(outputId)); + } + }, [layoutInitialized, outputsLoaded, viewCards, outputs, dispatch]); +} diff --git a/frontend/src/app/pages/Dashboard/useDashboardSelectors.ts b/frontend/src/app/pages/Dashboard/useDashboardSelectors.ts new file mode 100644 index 00000000..f0644826 --- /dev/null +++ b/frontend/src/app/pages/Dashboard/useDashboardSelectors.ts @@ -0,0 +1,49 @@ +import { useAppSelector } from '@/shared/hooks'; + +// All of the dashboard's Redux reads in one place. Keeps Dashboard.tsx a +// thin composition layer instead of a 25-line selector wall. +export function useDashboardSelectors(dashboardId: string) { + const dashboardName = useAppSelector((state) => + dashboardId ? state.dashboards.items[dashboardId]?.name : undefined, + ); + const sessions = useAppSelector((state) => state.agents.sessions); + const expandedSessionIds = useAppSelector((state) => state.agents.expandedSessionIds); + const cards = useAppSelector((state) => state.dashboardLayout.cards); + const viewCards = useAppSelector((state) => state.dashboardLayout.viewCards); + const browserCards = useAppSelector((state) => state.dashboardLayout.browserCards); + const notes = useAppSelector((state) => state.dashboardLayout.notes); + const pendingFocusNoteId = useAppSelector((state) => state.dashboardLayout.pendingFocusNoteId); + const layoutInitialized = useAppSelector((state) => state.dashboardLayout.initialized); + const persistedExpandedSessionIds = useAppSelector((state) => state.dashboardLayout.persistedExpandedSessionIds); + const zoomSensitivity = useAppSelector((state) => state.settings.data.zoom_sensitivity); + const newAgentShortcut = useAppSelector((state) => state.settings.data.new_agent_shortcut); + const browserHomepage = useAppSelector((state) => state.settings.data.browser_homepage); + const expandNewChats = useAppSelector((state) => state.settings.data.expand_new_chats_in_dashboard); + const autoRevealSubAgents = useAppSelector((state) => state.settings.data.auto_reveal_sub_agents); + const outputs = useAppSelector((state) => state.outputs.items); + const outputsLoaded = useAppSelector((state) => state.outputs.loaded); + const glowingAgentCards = useAppSelector((state) => state.dashboardLayout.glowingAgentCards); + const glowingBrowserCards = useAppSelector((state) => state.dashboardLayout.glowingBrowserCards); + + return { + dashboardName, + sessions, + expandedSessionIds, + cards, + viewCards, + browserCards, + notes, + pendingFocusNoteId, + layoutInitialized, + persistedExpandedSessionIds, + zoomSensitivity, + newAgentShortcut, + browserHomepage, + expandNewChats, + autoRevealSubAgents, + outputs, + outputsLoaded, + glowingAgentCards, + glowingBrowserCards, + }; +} diff --git a/frontend/src/app/pages/Dashboard/useDashboardThumbnail.ts b/frontend/src/app/pages/Dashboard/useDashboardThumbnail.ts new file mode 100644 index 00000000..a9a925f1 --- /dev/null +++ b/frontend/src/app/pages/Dashboard/useDashboardThumbnail.ts @@ -0,0 +1,75 @@ +import { useCallback, useEffect, useRef, type RefObject } from 'react'; +import { store } from '@/shared/state/store'; +import { updateDashboardThumbnail } from '@/shared/state/dashboardsSlice'; +import { captureDashboardThumbnail } from './captureDashboardThumbnail'; + +interface UseDashboardThumbnailArgs { + isActive: boolean; + dashboardId: string; + layoutInitialized: boolean; + viewportRef: RefObject; + contentRef: RefObject; +} + +export function useDashboardThumbnail({ + isActive, + dashboardId, + layoutInitialized, + viewportRef, + contentRef, +}: UseDashboardThumbnailArgs) { + // Capture a thumbnail screenshot of the dashboard. + // Uses Electron's native capturePage for pixel-perfect results. + // Captures current viewport as-is (no DOM mutation) to avoid visual flashes. + // Re-captures when layout is saved (piggybacking on the save debounce). + const pendingThumbnailRef = useRef(null); + const captureTimerRef = useRef | null>(null); + const captureNow = useCallback(() => { + const viewportEl = viewportRef.current; + const contentEl = contentRef.current; + if (!viewportEl || !contentEl) return; + const layoutState = store.getState().dashboardLayout; + const allCards = { + cards: layoutState.cards, + viewCards: layoutState.viewCards, + browserCards: layoutState.browserCards, + }; + const hasCards = Object.keys(allCards.cards).length > 0 + || Object.keys(allCards.viewCards).length > 0 + || Object.keys(allCards.browserCards).length > 0; + if (!hasCards) { + // Empty dashboard , queue a thumbnail clear (sent on exit alongside + // the existing capture-update path). Backend treats '' as "set to + // empty"; null in PUT body means "don't update". + pendingThumbnailRef.current = ''; + return; + } + captureDashboardThumbnail(viewportEl, contentEl, allCards) + .then((thumbnail) => { if (thumbnail) pendingThumbnailRef.current = thumbnail; }) + .catch(() => {}); + }, [viewportRef, contentRef]); + + useEffect(() => { + if (!isActive) return; // Skip thumbnail capture when dashboard is hidden + if (!dashboardId || !layoutInitialized) return; + if (captureTimerRef.current) clearTimeout(captureTimerRef.current); + captureTimerRef.current = setTimeout(captureNow, 2000); + return () => { if (captureTimerRef.current) clearTimeout(captureTimerRef.current); }; + }, [isActive, dashboardId, layoutInitialized, captureNow]); + + // On exit, save the captured thumbnail to the backend + useEffect(() => { + if (!dashboardId) return; + const exitingId = dashboardId; + return () => { + const thumbnail = pendingThumbnailRef.current; + // null = no pending change; '' = pending clear; other = pending update. + if (thumbnail !== null) { + store.dispatch(updateDashboardThumbnail({ id: exitingId, thumbnail })); + pendingThumbnailRef.current = null; + } + }; + }, [dashboardId]); + + return { captureNow }; +} diff --git a/frontend/src/app/pages/Dashboard/useDashboardUiState.ts b/frontend/src/app/pages/Dashboard/useDashboardUiState.ts new file mode 100644 index 00000000..a5fc709a --- /dev/null +++ b/frontend/src/app/pages/Dashboard/useDashboardUiState.ts @@ -0,0 +1,95 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { CardPosition } from '@/shared/state/dashboardLayoutSlice'; +import type { useDashboardSelection } from './useDashboardSelection'; + +type Selection = ReturnType; +type SpawnOrigin = { x: number; y: number; type?: 'branch' }; + +// Bundles the dashboard's purely-local UI bookkeeping (highlight pulse, +// auto-focus, pending-select, measured heights, reveal tracking) so +// Dashboard.tsx stays a thin composition layer. selection + cards come in +// from the parent because the pending-select effect needs both. +export function useDashboardUiState(selection: Selection, cards: Record) { + const toolbarRef = useRef(null); + + const [toolbarOpen, setToolbarOpen] = useState(false); + const [searchPaletteOpen, setSearchPaletteOpen] = useState(false); + const [highlightedCardId, setHighlightedCardId] = useState(null); + const highlightTimerRef = useRef | null>(null); + const [autoFocusSessionId, setAutoFocusSessionId] = useState(null); + const [pendingSelectSessionId, setPendingSelectSessionId] = useState(null); + const [focusedCardId, setFocusedCardId] = useState(null); + const [newAgentBounce, setNewAgentBounce] = useState(false); + // Cleanup any leftover walkthrough localStorage from v1 , the v2 panel + // ignores it but it would otherwise hang around forever. + useEffect(() => { + try { + localStorage.removeItem('openswarm_walkthrough_pending'); + } catch { /* ignore */ } + }, []); + + const handleHighlightCard = useCallback((cardId: string) => { + if (highlightTimerRef.current) clearTimeout(highlightTimerRef.current); + setHighlightedCardId(cardId); + highlightTimerRef.current = setTimeout(() => { + setHighlightedCardId(null); + highlightTimerRef.current = null; + }, 2000); + }, []); + + useEffect(() => { + if (autoFocusSessionId) { + const timer = setTimeout(() => setAutoFocusSessionId(null), 1500); + return () => clearTimeout(timer); + } + }, [autoFocusSessionId]); + + useEffect(() => { + if (!pendingSelectSessionId) return; + if (!cards[pendingSelectSessionId]) return; + setPendingSelectSessionId(null); + selection.selectCard(pendingSelectSessionId, 'agent', false); + }, [pendingSelectSessionId, cards, selection]); + + const spawnOriginsRef = useRef>({}); + const measuredHeightsRef = useRef>({}); + const [measuredHeightsTick, setMeasuredHeightsTick] = useState(0); + const handleMeasuredHeight = useCallback((sessionId: string, height: number) => { + if (measuredHeightsRef.current[sessionId] !== height) { + measuredHeightsRef.current[sessionId] = height; + setMeasuredHeightsTick((t) => t + 1); + } + }, []); + const revealSpawnedRef = useRef(new Set()); + useEffect(() => { + revealSpawnedRef.current.forEach((id) => { + if (!cards[id]) revealSpawnedRef.current.delete(id); + }); + }, [cards]); + const hasFittedRef = useRef(false); + const restoredExpandedRef = useRef(false); + + return { + toolbarRef, + toolbarOpen, + setToolbarOpen, + searchPaletteOpen, + setSearchPaletteOpen, + highlightedCardId, + handleHighlightCard, + autoFocusSessionId, + setAutoFocusSessionId, + setPendingSelectSessionId, + focusedCardId, + setFocusedCardId, + newAgentBounce, + setNewAgentBounce, + spawnOriginsRef, + measuredHeightsRef, + measuredHeightsTick, + handleMeasuredHeight, + revealSpawnedRef, + hasFittedRef, + restoredExpandedRef, + }; +} diff --git a/frontend/src/app/pages/Dashboard/useLayoutSave.ts b/frontend/src/app/pages/Dashboard/useLayoutSave.ts new file mode 100644 index 00000000..9dd0be40 --- /dev/null +++ b/frontend/src/app/pages/Dashboard/useLayoutSave.ts @@ -0,0 +1,73 @@ +import { useEffect, useRef } from 'react'; +import { useAppDispatch } from '@/shared/hooks'; +import { + saveLayout, + type CardPosition, + type ViewCardPosition, + type BrowserCardPosition, + type NotePosition, +} from '@/shared/state/dashboardLayoutSlice'; + +interface UseLayoutSaveArgs { + isActive: boolean; + layoutInitialized: boolean; + dashboardId: string; + cards: Record; + viewCards: Record; + browserCards: Record; + notes: Record; + expandedSessionIds: string[]; + captureNow: () => void; +} + +// Debounced layout persistence. The buffered pendingSaveRef + the unmount +// flush live together here, and this hook tears down exactly when +// DashboardInner does, so the launchAndSendFirstMessage-vs-unmount race +// keeps the same cadence it had inline. +export function useLayoutSave({ + isActive, + layoutInitialized, + dashboardId, + cards, + viewCards, + browserCards, + notes, + expandedSessionIds, + captureNow, +}: UseLayoutSaveArgs) { + const dispatch = useAppDispatch(); + const skipInitialSave = useRef(true); + const saveTimerRef = useRef | null>(null); + const pendingSaveRef = useRef[0] | null>(null); + + useEffect(() => { + if (!isActive) return; // Don't persist layout while dashboard is hidden , save buffers in pendingSaveRef and flushes on resume + if (!layoutInitialized || !dashboardId) return; + if (skipInitialSave.current) { + skipInitialSave.current = false; + return; + } + const payload = { dashboardId, cards, viewCards, browserCards, notes, expandedSessionIds }; + pendingSaveRef.current = payload; + if (saveTimerRef.current) clearTimeout(saveTimerRef.current); + saveTimerRef.current = setTimeout(() => { + dispatch(saveLayout(payload)); + pendingSaveRef.current = null; + saveTimerRef.current = null; + captureNow(); + }, 500); + }, [isActive, cards, viewCards, browserCards, notes, expandedSessionIds, layoutInitialized, dashboardId, dispatch, captureNow]); + + useEffect(() => { + return () => { + if (saveTimerRef.current) { + clearTimeout(saveTimerRef.current); + saveTimerRef.current = null; + } + if (pendingSaveRef.current) { + dispatch(saveLayout(pendingSaveRef.current)); + pendingSaveRef.current = null; + } + }; + }, [dispatch]); +} diff --git a/frontend/src/app/pages/Dashboard/useSiblingRestack.ts b/frontend/src/app/pages/Dashboard/useSiblingRestack.ts new file mode 100644 index 00000000..6e9e321e --- /dev/null +++ b/frontend/src/app/pages/Dashboard/useSiblingRestack.ts @@ -0,0 +1,111 @@ +import { useEffect, type RefObject } from 'react'; +import { useAppDispatch } from '@/shared/hooks'; +import { + moveCards, + EXPANDED_CARD_MIN_H, + GRID_GAP, + type CardPosition, + type BrowserCardPosition, +} from '@/shared/state/dashboardLayoutSlice'; + +interface GlowingCard { + sourceId: string; +} + +interface UseSiblingRestackArgs { + isActive: boolean; + expandedSessionIds: string[]; + glowingAgentCards: Record; + glowingBrowserCards: Record; + cards: Record; + browserCards: Record; + measuredHeightsRef: RefObject>; + measuredHeightsTick: number; +} + +export function useSiblingRestack({ + isActive, + expandedSessionIds, + glowingAgentCards, + glowingBrowserCards, + cards, + browserCards, + measuredHeightsRef, + measuredHeightsTick, +}: UseSiblingRestackArgs) { + const dispatch = useAppDispatch(); + + useEffect(() => { + if (!isActive) return; // Heavy geometry recalculation , pause when dashboard is hidden + const DRIFT_THRESHOLD = 60; + + // Group tethered sub-agent cards by source, only including those still in the spawn column + const sourceToSiblings = new Map(); + for (const [id, glow] of Object.entries(glowingAgentCards)) { + const card = cards[id]; + if (!card) continue; + const sourceCard = cards[glow.sourceId]; + if (!sourceCard) continue; + const expectedX = sourceCard.x + sourceCard.width + GRID_GAP * 12; + if (Math.abs(card.x - expectedX) > DRIFT_THRESHOLD) continue; + const list = sourceToSiblings.get(glow.sourceId) ?? []; + list.push(id); + sourceToSiblings.set(glow.sourceId, list); + } + + for (const siblings of sourceToSiblings.values()) { + if (siblings.length < 2) continue; + siblings.sort((a, b) => cards[a].y - cards[b].y); + + let cursor = cards[siblings[0]].y; + for (const id of siblings) { + const card = cards[id]; + const dy = cursor - card.y; + if (Math.abs(dy) > 1) { + dispatch(moveCards({ items: [{ id, type: 'agent' as const }], dx: 0, dy })); + } + const isExpanded = expandedSessionIds.includes(id); + const h = isExpanded + ? Math.max(EXPANDED_CARD_MIN_H, card.height) + : (measuredHeightsRef.current![id] ?? card.height); + cursor += h + GRID_GAP * 2; + } + } + // measuredHeightsTick in deps ensures we re-run once ResizeObserver reports + // the new height after a collapse (avoids stale-height no-ops) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isActive, expandedSessionIds, glowingAgentCards, cards, dispatch, measuredHeightsTick]); + + useEffect(() => { + if (!isActive) return; // Heavy geometry recalculation , pause when dashboard is hidden + const DRIFT_THRESHOLD = 60; + + const sourceToSiblings = new Map(); + for (const [browserId, glow] of Object.entries(glowingBrowserCards)) { + const bc = browserCards[browserId]; + if (!bc) continue; + const sourceCard = cards[glow.sourceId]; + if (!sourceCard) continue; + const expectedX = sourceCard.x + sourceCard.width + GRID_GAP * 12; + if (Math.abs(bc.x - expectedX) > DRIFT_THRESHOLD) continue; + const list = sourceToSiblings.get(glow.sourceId) ?? []; + list.push(browserId); + sourceToSiblings.set(glow.sourceId, list); + } + + for (const siblings of sourceToSiblings.values()) { + if (siblings.length < 2) continue; + siblings.sort((a, b) => browserCards[a].y - browserCards[b].y); + + let cursor = browserCards[siblings[0]].y; + for (const id of siblings) { + const bc = browserCards[id]; + const dy = cursor - bc.y; + if (Math.abs(dy) > 1) { + dispatch(moveCards({ items: [{ id, type: 'browser' as const }], dx: 0, dy })); + } + cursor += bc.height + GRID_GAP * 2; + } + } + }, [isActive, glowingBrowserCards, browserCards, cards, dispatch]); +}