veritas-kanban/web/src/App.tsx

286 lines
9.7 KiB
TypeScript

import { lazy, Suspense, useEffect, useState, useRef } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { resetFeatureSettingsWrites } from './lib/feature-settings-writes';
import { isNativeSettingsWindow } from './lib/desktop-settings';
import { useSettingsWindowSync } from './hooks/useSettingsWindowSync';
import { Box } from '@mantine/core';
import { Header } from './components/layout/Header';
import { Toaster } from './components/ui/toaster';
import { KeyboardProvider } from './hooks/useKeyboard';
import { CommandPalette } from './components/layout/CommandPalette';
import { KeyboardShortcutsDialog } from './components/layout/KeyboardShortcutsDialog';
import { BulkActionsProvider } from './hooks/useBulkActions';
import { useTaskSync } from './hooks/useTaskSync';
import { TaskConfigProvider } from './contexts/TaskConfigContext';
import { WebSocketStatusProvider } from './contexts/WebSocketContext';
import { ViewProvider, useView } from './contexts/ViewContext';
import { AuthProvider, useAuth } from './hooks/useAuth';
import { IdentityProvider } from './hooks/useIdentity';
import { AuthGuard } from './components/auth/AuthGuard';
import { ErrorBoundary } from './components/shared/ErrorBoundary';
import { SkipToContent } from './components/shared/SkipToContent';
import { LiveAnnouncerProvider } from './components/shared/LiveAnnouncer';
import { NAVIGATION_VIEWS, VIEW_BY_ID, type AppView, type NavigationView } from './lib/views';
import { usePendingProductMode } from './hooks/usePendingProductMode';
import { DesktopShellProvider, useDesktopShell } from './components/layout/DesktopShellContext';
import { DesktopLeftSidebar } from './components/layout/DesktopLeftSidebar';
import { DesktopBottomPanel } from './components/layout/DesktopBottomPanel';
import { RunSessionShareView } from './components/task/RunSessionSharesSection';
const NativeSettingsWindow = lazy(() =>
import('./components/settings/NativeSettingsWindow').then((mod) => ({
default: mod.NativeSettingsWindow,
}))
);
const LAZY_VIEW_COMPONENTS = Object.fromEntries(
NAVIGATION_VIEWS.map((definition) => {
if (!definition.loadComponent) {
throw new Error(`Navigation view ${definition.view} is missing a lazy component loader.`);
}
return [definition.view, lazy(definition.loadComponent)];
})
) as Record<NavigationView, ReturnType<typeof lazy>>;
const KanbanBoard = lazy(() =>
import('./components/board/KanbanBoard').then((mod) => ({
default: mod.KanbanBoard,
}))
);
const UiVocabularyGallery = lazy(() =>
import('./components/ui/UiVocabularyGallery').then((mod) => ({
default: mod.UiVocabularyGallery,
}))
);
const DesktopOnboardingDialog = lazy(() =>
import('./components/auth/DesktopOnboarding').then((mod) => ({
default: mod.DesktopOnboardingDialog,
}))
);
const MobileShell = lazy(() =>
import('./components/layout/MobileShell').then((mod) => ({
default: mod.MobileShell,
}))
);
const PwaStatusBanner = lazy(() =>
import('./components/layout/PwaStatusBanner').then((mod) => ({
default: mod.PwaStatusBanner,
}))
);
const SystemHealthBar = lazy(() =>
import('./components/layout/SystemHealthBar').then((mod) => ({
default: mod.SystemHealthBar,
}))
);
function ViewLoading({ view }: { view: AppView }) {
return (
<div className="flex items-center justify-center py-16">
<span className="text-muted-foreground">{VIEW_BY_ID[view].loadingLabel ?? 'Loading...'}</span>
</div>
);
}
function runSessionShareIdFromLocation(): string | null {
if (typeof window === 'undefined') return null;
const path = window.location.pathname.replace(/\/+$/, '');
const match = path.match(/\/runs\/shared\/([^/]+)$/);
return match?.[1] ? decodeURIComponent(match[1]) : null;
}
/** Renders the current view (board, activity feed, or backlog). */
function MainContent() {
const { view, goBack, navigateToTask, navigateToWorkflow } = useView();
const runSessionShareId = runSessionShareIdFromLocation();
if (runSessionShareId) return <RunSessionShareView shareId={runSessionShareId} />;
if (new URLSearchParams(window.location.search).get('ui-gallery') === '1') {
return (
<Suspense fallback={<ViewLoading view="board" />}>
<div className="h-full overflow-auto p-6">
<UiVocabularyGallery />
</div>
</Suspense>
);
}
if (view === 'board') {
return (
<Suspense fallback={<ViewLoading view="board" />}>
<KanbanBoard />
</Suspense>
);
}
const ViewComponent = LAZY_VIEW_COMPONENTS[view];
return (
<Suspense fallback={<ViewLoading view={view} />}>
<ViewComponent
onBack={goBack}
onTaskClick={navigateToTask}
onWorkflowClick={navigateToWorkflow}
/>
</Suspense>
);
}
function DesktopAwareAppShell({
authStatus,
refreshStatus,
showDesktopOnboarding,
setShowDesktopOnboarding,
}: {
authStatus: ReturnType<typeof useAuth>['status'];
refreshStatus: ReturnType<typeof useAuth>['refreshStatus'];
showDesktopOnboarding: boolean;
setShowDesktopOnboarding: (open: boolean) => void;
}) {
const { isDesktopClient, bottomPanel } = useDesktopShell();
const [commandCenterOpen, setCommandCenterOpen] = useState(false);
return (
<Box className="desktop-app-shell min-h-screen bg-background">
<SkipToContent />
<Header
onOpenDiagnostics={() => setShowDesktopOnboarding(true)}
onOpenCommandCenter={() => setCommandCenterOpen(true)}
/>
<Suspense fallback={<div className="h-7 border-b border-border bg-muted/30" aria-hidden />}>
<SystemHealthBar />
</Suspense>
<Suspense fallback={null}>
<PwaStatusBanner sessionExpiry={authStatus?.sessionExpiry} onRefreshAuth={refreshStatus} />
</Suspense>
<div className="desktop-workbench-container">
<div className="desktop-workbench">
<DesktopLeftSidebar />
<Box
component="main"
id="main-content"
px={isDesktopClient ? 'md' : { base: 'md', md: '3.5rem' }}
pt={isDesktopClient ? 'md' : 'lg'}
pb={
bottomPanel
? 'md'
: isDesktopClient
? 'lg'
: { base: 'calc(var(--vk-mobile-nav-height, 5rem) + 1rem)', md: 'lg' }
}
tabIndex={-1}
className="desktop-main-content"
>
<ErrorBoundary level="section">
<MainContent />
</ErrorBoundary>
</Box>
</div>
<DesktopBottomPanel />
</div>
<Toaster />
<CommandPalette nativeOpen={commandCenterOpen} onNativeOpenChange={setCommandCenterOpen} />
<KeyboardShortcutsDialog />
<Suspense fallback={null}>
{!isDesktopClient && <MobileShell showChat={!bottomPanel} />}
</Suspense>
{showDesktopOnboarding && (
<Suspense fallback={null}>
<DesktopOnboardingDialog
open={showDesktopOnboarding}
onOpenChange={setShowDesktopOnboarding}
/>
</Suspense>
)}
</Box>
);
}
// Main app content (only rendered when authenticated)
function AppContent() {
const queryClient = useQueryClient();
const settingsSessionMounted = useRef(false);
useEffect(() => {
settingsSessionMounted.current = true;
return () => {
settingsSessionMounted.current = false;
// Strict Mode may remount effects synchronously. End the writer only when
// the authenticated application really leaves, never on a Settings tab change.
queueMicrotask(() => {
if (!settingsSessionMounted.current) resetFeatureSettingsWrites(queryClient);
});
};
}, [queryClient]);
useSettingsWindowSync();
// Connect to WebSocket for real-time task updates
const { isConnected, connectionState, reconnectAttempt, reconnect } = useTaskSync();
const { status: authStatus, refreshStatus } = useAuth();
const [showDesktopOnboarding, setShowDesktopOnboarding] = useState(false);
usePendingProductMode();
useEffect(() => {
const openDiagnostics = () => setShowDesktopOnboarding(true);
window.addEventListener('veritas:open-diagnostics', openDiagnostics);
return () => {
window.removeEventListener('veritas:open-diagnostics', openDiagnostics);
};
}, []);
return (
<WebSocketStatusProvider
isConnected={isConnected}
connectionState={connectionState}
reconnectAttempt={reconnectAttempt}
reconnect={reconnect}
>
<LiveAnnouncerProvider>
<KeyboardProvider>
<BulkActionsProvider>
<TaskConfigProvider>
<ViewProvider>
<IdentityProvider>
<DesktopShellProvider>
{isNativeSettingsWindow() ? (
<Suspense fallback={<div>Loading Settings…</div>}>
<NativeSettingsWindow />
</Suspense>
) : (
<DesktopAwareAppShell
authStatus={authStatus}
refreshStatus={refreshStatus}
showDesktopOnboarding={showDesktopOnboarding}
setShowDesktopOnboarding={setShowDesktopOnboarding}
/>
)}
</DesktopShellProvider>
</IdentityProvider>
</ViewProvider>
</TaskConfigProvider>
</BulkActionsProvider>
</KeyboardProvider>
</LiveAnnouncerProvider>
</WebSocketStatusProvider>
);
}
function App() {
return (
<ErrorBoundary level="page">
<AuthProvider>
<AuthGuard>
<AppContent />
</AuthGuard>
<Toaster />
</AuthProvider>
</ErrorBoundary>
);
}
export default App;