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() {
>