diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx index 2f72ae3d..c54a6b99 100644 --- a/packages/web/src/App.tsx +++ b/packages/web/src/App.tsx @@ -1,12 +1,14 @@ +import { lazy, Suspense } from 'react'; import { Routes, Route, Navigate } from 'react-router-dom'; import { Layout } from './components/Layout'; import { Workspace } from './pages/Workspace'; -import { Ontology } from './pages/Ontology'; -import { Agents } from './pages/Agents'; -import { Analytics } from './pages/Analytics'; import { Settings } from './pages/Settings'; -import { Admin } from './pages/Admin'; -import { Backend } from './pages/Backend'; +// Rarely-visited pages are code-split out of the main bundle (live-audit perf). +const Ontology = lazy(() => import('./pages/Ontology').then((m) => ({ default: m.Ontology }))); +const Agents = lazy(() => import('./pages/Agents').then((m) => ({ default: m.Agents }))); +const Analytics = lazy(() => import('./pages/Analytics').then((m) => ({ default: m.Analytics }))); +const Admin = lazy(() => import('./pages/Admin').then((m) => ({ default: m.Admin }))); +const Backend = lazy(() => import('./pages/Backend').then((m) => ({ default: m.Backend }))); import { RequireRole } from './components/RequireRole'; import { canAccessAdmin, canAccessBackend } from './lib/roleAccess'; import { Signin } from './pages/Signin'; @@ -102,6 +104,7 @@ function AuthenticatedApp() { + Loading…}> } /> } /> @@ -113,6 +116,7 @@ function AuthenticatedApp() { } /> } /> + diff --git a/packages/web/src/components/InteractiveGraphVisualization.tsx b/packages/web/src/components/InteractiveGraphVisualization.tsx index bce2b4eb..b56dbc22 100644 --- a/packages/web/src/components/InteractiveGraphVisualization.tsx +++ b/packages/web/src/components/InteractiveGraphVisualization.tsx @@ -262,7 +262,10 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i } } : { where: {} }, fetchPolicy: currentGraph ? 'cache-and-network' : 'cache-only', - pollInterval: currentGraph ? 2000 : 0, + // Cross-client freshness without the 2s idle hammer: poll every 15s and skip + // the tick entirely while the tab is hidden. Own edits still refetch instantly. + pollInterval: currentGraph ? 15000 : 0, + skipPollAttempt: () => typeof document !== 'undefined' && document.visibilityState !== 'visible', errorPolicy: 'all' }); @@ -277,7 +280,10 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i } } : { where: {} }, fetchPolicy: currentGraph ? 'cache-and-network' : 'cache-only', - pollInterval: currentGraph ? 2000 : 0, + // Cross-client freshness without the 2s idle hammer: poll every 15s and skip + // the tick entirely while the tab is hidden. Own edits still refetch instantly. + pollInterval: currentGraph ? 15000 : 0, + skipPollAttempt: () => typeof document !== 'undefined' && document.visibilityState !== 'visible', errorPolicy: 'all' }); @@ -4194,6 +4200,35 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i }; }, []); + // Seed the minimap from the laid-out graph on load. The tick-based publish + // (in the simulation tick) only runs while the sim is ticking; with one-shot + // physics the graph settles and stops, so without this the minimap showed + // "No nodes yet" until the user clicked a node. Publish a few times over the + // first few seconds (covers fast + slow settles), then stop β€” no idle cost. + useEffect(() => { + if (!currentGraph) return undefined; + let n = 0; + const publish = () => { + const sim = simulationRef.current; + const upd = (window as { updateMiniMapPositions?: (p: Record) => void }).updateMiniMapPositions; + if (!sim || !upd) return; + const simNodes = sim.nodes() as { id: string; x?: number; y?: number; type?: string }[]; + if (!simNodes?.length) return; + const positions: Record = {}; + const types: Record = {}; + for (const nd of simNodes) { + if (nd.x !== undefined && nd.y !== undefined) { positions[nd.id] = { x: nd.x, y: nd.y }; types[nd.id] = nd.type as string; } + } + if (Object.keys(positions).length) { + upd(positions); + (window as { updateMiniMapTypes?: (t: Record) => void }).updateMiniMapTypes?.(types); + } + }; + const id = setInterval(() => { publish(); if (++n >= 5) clearInterval(id); }, 800); + publish(); + return () => clearInterval(id); + }, [currentGraph?.id, workItemsData?.workItems?.length]); + // Center on specific node const centerOnNode = useCallback((nodeId?: string) => { const nodeToCenter = nodeId @@ -4817,9 +4852,12 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i const message = err.message || err.toString(); - // Network/connection errors + // Network/connection errors β€” keep dev hints (localhost URL) out of the + // hosted build; production users get a generic, actionable message. if (message.includes('NetworkError') || message.includes('fetch')) { - return "Cannot connect to GraphDone server. Please check if the server is running at http://localhost:4127"; + return import.meta.env.DEV + ? `Cannot connect to GraphDone server. Please check the server is running at ${import.meta.env.VITE_API_URL || 'http://localhost:4127'}` + : "Can't reach GraphDone right now. Please check your connection and try again."; } // GraphQL/Database errors @@ -4866,7 +4904,7 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i {errorMessage} - {isNetworkError && ( + {isNetworkError && import.meta.env.DEV && (
πŸ’‘ Quick fixes:
β€’ Run ./start to start the server
@@ -5038,11 +5076,17 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i return (
- - +

+ Force-directed graph of work items and their dependencies. Use the view switcher to see the same data as a list, table, or board, which are keyboard-navigable. +

+ {/* Empty State Overlay */} {showEmptyStateOverlay && ( diff --git a/packages/web/src/components/MobileBottomNav.tsx b/packages/web/src/components/MobileBottomNav.tsx index 814a0607..7c397194 100644 --- a/packages/web/src/components/MobileBottomNav.tsx +++ b/packages/web/src/components/MobileBottomNav.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { useNavigate, useLocation } from 'react-router-dom'; import { @@ -21,6 +21,16 @@ export function MobileBottomNav() { const navigate = useNavigate(); const location = useLocation(); const [moreOpen, setMoreOpen] = useState(false); + const sheetRef = useRef(null); + + // The "More" sheet is a modal: close on Esc and move focus into it on open. + useEffect(() => { + if (!moreOpen) return; + const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setMoreOpen(false); }; + document.addEventListener('keydown', onKey); + sheetRef.current?.focus(); + return () => document.removeEventListener('keydown', onKey); + }, [moreOpen]); const onWorkspace = location.pathname === '/' || location.pathname === '/workspace'; const goView = (m: ViewMode) => { @@ -83,8 +93,13 @@ export function MobileBottomNav() { >
e.stopPropagation()} >
diff --git a/packages/web/src/components/ViewSwitcher.tsx b/packages/web/src/components/ViewSwitcher.tsx index b29de1c7..205260ae 100644 --- a/packages/web/src/components/ViewSwitcher.tsx +++ b/packages/web/src/components/ViewSwitcher.tsx @@ -50,14 +50,17 @@ const viewOptions = [ const ViewSwitcher: React.FC = ({ currentView, onViewChange, className = '' }) => { return ( -
+
{viewOptions.map((option) => { const Icon = option.icon; const isActive = currentView === option.id; - + return (