Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions packages/web/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -102,6 +104,7 @@ function AuthenticatedApp() {
<GraphProvider>
<ViewModeProvider>
<Layout>
<Suspense fallback={<div className="h-full w-full flex items-center justify-center text-gray-400 text-sm">Loading…</div>}>
<Routes>
<Route path="/" element={<Workspace />} />
<Route path="/workspace" element={<Workspace />} />
Expand All @@ -113,6 +116,7 @@ function AuthenticatedApp() {
<Route path="/backend" element={<RequireRole can={canAccessBackend}><Backend /></RequireRole>} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Suspense>
</Layout>
</ViewModeProvider>
</GraphProvider>
Expand Down
62 changes: 53 additions & 9 deletions packages/web/src/components/InteractiveGraphVisualization.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
});

Expand 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'
});

Expand Down Expand Up @@ -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<string, { x: number; y: number }>) => 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<string, { x: number; y: number }> = {};
const types: Record<string, string> = {};
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<string, string>) => 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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -4866,7 +4904,7 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i
{errorMessage}
</div>

{isNetworkError && (
{isNetworkError && import.meta.env.DEV && (
<div className="text-gray-400 text-sm space-y-2">
<div>💡 <strong>Quick fixes:</strong></div>
<div>• Run <code className="bg-gray-800 px-2 py-1 rounded">./start</code> to start the server</div>
Expand Down Expand Up @@ -5038,11 +5076,17 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i

return (
<div ref={containerRef} className="graph-container relative w-full h-full overflow-hidden select-none" data-quality={qualityTier} data-dense={isDenseGraph ? 'true' : undefined} data-simplify={isSimplified ? 'true' : undefined} data-dots={isDotMode ? 'true' : undefined}>
<svg
ref={svgRef}
className="w-full h-full"
<svg
ref={svgRef}
className="w-full h-full"
role="img"
aria-label="Interactive work-item dependency graph"
aria-describedby="graph-a11y-desc"
/>

<p id="graph-a11y-desc" className="sr-only">
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.
</p>


{/* Empty State Overlay */}
{showEmptyStateOverlay && (
Expand Down
19 changes: 17 additions & 2 deletions packages/web/src/components/MobileBottomNav.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -21,6 +21,16 @@ export function MobileBottomNav() {
const navigate = useNavigate();
const location = useLocation();
const [moreOpen, setMoreOpen] = useState(false);
const sheetRef = useRef<HTMLDivElement>(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) => {
Expand Down Expand Up @@ -83,8 +93,13 @@ export function MobileBottomNav() {
>
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" />
<div
ref={sheetRef}
data-testid="mobile-more-sheet"
className="relative bg-gray-900 border-t border-gray-700 rounded-t-2xl p-4 pb-[calc(1rem+env(safe-area-inset-bottom))] shadow-2xl max-h-[80vh] overflow-y-auto"
role="dialog"
aria-modal="true"
aria-label="More navigation"
tabIndex={-1}
className="relative bg-gray-900 border-t border-gray-700 rounded-t-2xl p-4 pb-[calc(1rem+env(safe-area-inset-bottom))] shadow-2xl max-h-[80vh] overflow-y-auto outline-none"
onClick={(e) => e.stopPropagation()}
>
<div className="mx-auto mb-3 h-1 w-10 rounded-full bg-gray-600" />
Expand Down
7 changes: 5 additions & 2 deletions packages/web/src/components/ViewSwitcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,14 +50,17 @@ const viewOptions = [

const ViewSwitcher: React.FC<ViewSwitcherProps> = ({ currentView, onViewChange, className = '' }) => {
return (
<div className={`flex bg-gray-700 rounded-lg p-1 ${className}`}>
<div className={`flex bg-gray-700 rounded-lg p-1 ${className}`} role="tablist" aria-label="View mode">
{viewOptions.map((option) => {
const Icon = option.icon;
const isActive = currentView === option.id;

return (
<button
key={option.id}
role="tab"
aria-selected={isActive}
aria-label={option.name}
onClick={() => onViewChange(option.id)}
className={`
group relative flex items-center px-3 py-2 text-sm font-medium rounded-md transition-all duration-200
Expand Down
19 changes: 16 additions & 3 deletions packages/web/src/contexts/AuthContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,13 +56,26 @@ export function AuthProvider({ children }: AuthProviderProps) {
}, [meData]);

useEffect(() => {
if (meError) {
// Token is invalid, clear it
if (!meError) return;
// Only log the user out on a GENUINE auth failure. A transient network/5xx
// blip must not wipe the token (that was silently logging guests out).
const isAuthFailure =
meError.graphQLErrors?.some((e: { extensions?: { code?: string } }) => e.extensions?.code === 'UNAUTHENTICATED') ||
(meError.networkError as { statusCode?: number } | null)?.statusCode === 401 ||
(meError.networkError as { statusCode?: number } | null)?.statusCode === 403;
if (isAuthFailure) {
clearSession();
setCurrentUser(null);
setCurrentTeam(null);
setIsInitializing(false);
} else if (!currentUser) {
// Transient error — keep the session and hydrate from the cached user so a
// momentary backend hiccup leaves the app usable instead of bouncing to login.
const raw = getUserRaw();
if (raw) {
try { const u = JSON.parse(raw); setCurrentUser(u); setCurrentTeam(u.team ?? null); } catch { /* corrupt cache — ignore */ }
}
}
setIsInitializing(false);
}, [meError]);

// Load saved session (localStorage if "keep me logged in", else sessionStorage)
Expand Down
10 changes: 8 additions & 2 deletions packages/web/src/hooks/useAdaptiveQuality.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ export function useAdaptiveQuality(): {
}, [governor]);

// FPS sampling: count frames per second, feed smoothed samples to the governor.
// Paused while the tab is hidden so we don't run a perpetual rAF loop in
// backgrounded tabs (no rendering to measure there anyway).
useEffect(() => {
let raf = 0;
let frames = 0;
Expand All @@ -67,8 +69,12 @@ export function useAdaptiveQuality(): {
}
raf = requestAnimationFrame(loop);
};
raf = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf);
const start = () => { if (!raf) { windowStart = performance.now(); frames = 0; raf = requestAnimationFrame(loop); } };
const stop = () => { if (raf) { cancelAnimationFrame(raf); raf = 0; } };
const onVisibility = () => (document.visibilityState === 'visible' ? start() : stop());
document.addEventListener('visibilitychange', onVisibility);
if (document.visibilityState === 'visible') start();
return () => { document.removeEventListener('visibilitychange', onVisibility); stop(); };
}, [governor]);

const setOverride = useCallback(
Expand Down
12 changes: 8 additions & 4 deletions packages/web/src/pages/Signin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@
}
`;

export function Signin({ initialMagicLink = false }: { initialMagicLink?: boolean } = {}) {
export function Signin({ initialMagicLink = true }: { initialMagicLink?: boolean } = {}) {
const navigate = useNavigate();
const { login: setAuthUser } = useAuth();
const [searchParams] = useSearchParams();
Expand Down Expand Up @@ -242,7 +242,7 @@
hasEmail: hasEnteredEmail(formData.magicLinkEmail),
});
if (target === 'email') magicLinkEmailRef.current?.focus();
// eslint-disable-next-line react-hooks/exhaustive-deps

Check failure on line 245 in packages/web/src/pages/Signin.tsx

View workflow job for this annotation

GitHub Actions / Lint & Type Check

Definition for rule 'react-hooks/exhaustive-deps' was not found
}, [useMagicLink, magicLinkSent]);

// Check if guest access is enabled
Expand Down Expand Up @@ -584,17 +584,19 @@
)}

{/* Sign In Method Toggle */}
<div className="flex items-center justify-center gap-2">
<div className="flex items-center justify-center gap-2" role="tablist" aria-label="Sign-in method">
<button
type="button"
role="tab"
aria-selected={!useMagicLink}
onClick={() => {
setUseMagicLink(false);
setMagicLinkSent(false);
setErrors({});
}}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-all ${
!useMagicLink
? 'bg-teal-600 text-white'
? 'bg-teal-700 text-white'
: 'bg-gray-700/50 text-gray-400 hover:bg-gray-600/50 hover:text-gray-300'
}`}
>
Expand All @@ -603,14 +605,16 @@
</button>
<button
type="button"
role="tab"
aria-selected={useMagicLink}
onClick={() => {
setUseMagicLink(true);
setMagicLinkSent(false);
setErrors({});
}}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-all ${
useMagicLink
? 'bg-gradient-to-r from-teal-600 to-cyan-600 text-white shadow-lg shadow-teal-500/30'
? 'bg-gradient-to-r from-teal-700 to-cyan-700 text-white shadow-lg shadow-teal-500/30'
: 'bg-gray-700/50 text-gray-400 hover:bg-gray-600/50 hover:text-gray-300'
}`}
>
Expand Down
8 changes: 8 additions & 0 deletions packages/web/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,14 @@ export default defineConfig({
sourcemap: false,
rollupOptions: {
output: {
// Split heavy vendors into separate, independently-cacheable chunks so the
// entry isn't one monolithic ~1.5MB file (live-audit perf finding).
manualChunks: {
react: ['react', 'react-dom', 'react-router-dom'],
apollo: ['@apollo/client', 'graphql'],
d3: ['d3'],
icons: ['lucide-react'],
},
// Force new filenames to break cache
entryFileNames: `assets/[name]-${Date.now()}.js`,
chunkFileNames: `assets/[name]-${Date.now()}.js`,
Expand Down
17 changes: 17 additions & 0 deletions playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,23 @@ export default defineConfig({
},
},

/* LIVE Cloudflare user journey: drives the real production SPA as a guest on
* desktop + phone, recording .webm video + a labelled screenshot per step.
* Run via the unified harness:
* TEST_URL=https://graphdone-cloud.pages.dev node tests/run-unified.mjs --profile live --out ...
* Heavy + network-bound; report-only, never part of the smoke gate. */
{
name: 'live-journey',
testMatch: /live-cloud-journey\.spec\.ts/,
use: {
...devices['Desktop Chrome'],
video: 'on',
screenshot: 'on',
trace: 'retain-on-failure',
ignoreHTTPSErrors: true,
},
},

/* Performance budgets (ADAPT-8). Lives in tests/perf, run via
* `npm run test:perf`. Reads window.__graphPerf / API latency and asserts
* budgets. Kept separate from the smoke gate so it can have its own
Expand Down
16 changes: 15 additions & 1 deletion scripts/narrate-report.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -81,12 +81,26 @@ const perf = PERF_ORDER.filter((k) => latestByMetric.has(k)).map((k) => {
return { metric: k, label: PERF_LABELS[k] || k, value: m.value, unit: m.unit, better: m.better };
});

// Give the live-site journey clips a clean spoken name + a real description
// (the raw attachment name is just the test title, which would otherwise be
// read out twice). Falls back to the generic name for any other video clip.
const liveClipMeta = (title) => {
const t = String(title || '').toLowerCase();
if (/phone|mobile/.test(t)) return { name: 'The mobile journey', note: 'On a phone, a guest lands on the list view, switches to the live graph, opens the navigation, and reaches settings — with no horizontal overflow.' };
if (/desktop/.test(t)) return { name: 'The desktop journey', note: 'On the desktop, a guest signs in, the live graph renders with real nodes and edges, and they open a node card, re-fit the view, and tour the pages.' };
return null;
};

const tour = [];
if (mediaRun) {
for (const s of mediaRun.report.sequences || []) {
for (const c of s.cases || []) {
for (const a of c.attachments || []) {
if (a.type === 'video') tour.push({ name: a.name || c.ref || `clip-${tour.length + 1}`, note: c.title || '', kind: 'video', runId: mediaRun.runId, href: a.href });
if (a.type !== 'video') continue;
const meta = liveClipMeta(c.title || a.name);
tour.push(meta
? { name: meta.name, note: meta.note, kind: 'video', runId: mediaRun.runId, href: a.href }
: { name: a.name || c.ref || `clip-${tour.length + 1}`, note: c.title || '', kind: 'video', runId: mediaRun.runId, href: a.href });
}
}
}
Expand Down
Loading
Loading