From 70c7311a46d90cc76efa95fad8bad3ff0b9b25cd Mon Sep 17 00:00:00 2001 From: fred Date: Thu, 27 Aug 2026 07:32:14 -0500 Subject: [PATCH] web: cut over to Vite SPA, retire the Next.js shell (#1444) Delete the src/app tree, next.config.ts, next-env.d.ts, and the Next-only guard/header components. Port AppShell/Sidebar/Topbar to react-router and mount them as a DashboardLayout route over all authenticated routes. Strip 'use client' directives, move globals.css up from the deleted app/ tree, rewrite tsconfig for Vite/Bundler resolution, drop the next dependency. Test fixes the port surfaced: jsdom v29 has no window.matchMedia (sidebar breakpoint) so setup.ts stubs it; the router-boundary specs render inside ThemeProvider because the chrome's ThemeToggle requires the context. --- apps/web/next-env.d.ts | 6 - apps/web/next.config.ts | 32 - apps/web/package.json | 11 +- apps/web/src/app/(auth)/layout.tsx | 14 - apps/web/src/app/(auth)/login/page.tsx | 139 --- apps/web/src/app/(auth)/register/page.tsx | 114 --- apps/web/src/app/(dashboard)/admin/page.tsx | 531 ----------- apps/web/src/app/(dashboard)/chat/page.tsx | 365 -------- apps/web/src/app/(dashboard)/layout.tsx | 11 - .../app/(dashboard)/projects/[id]/page.tsx | 338 ------- .../web/src/app/(dashboard)/projects/page.tsx | 101 --- .../web/src/app/(dashboard)/settings/page.tsx | 828 ------------------ apps/web/src/app/(dashboard)/tasks/page.tsx | 72 -- .../src/app/auth/provider/[provider]/page.tsx | 95 -- apps/web/src/app/layout.tsx | 41 - apps/web/src/app/page.tsx | 5 - apps/web/src/components/admin-role-guard.tsx | 40 - apps/web/src/components/auth-guard.tsx | 34 - apps/web/src/components/chat/chat-input.tsx | 2 - .../src/components/chat/conversation-list.tsx | 2 - .../components/chat/conversation-sidebar.tsx | 2 - .../src/components/chat/message-bubble.tsx | 2 - .../src/components/chat/streaming-message.tsx | 2 - .../freshness/freshness-notices.tsx | 2 - apps/web/src/components/guest-guard.tsx | 35 - apps/web/src/components/layout/app-header.tsx | 239 ----- apps/web/src/components/layout/app-shell.tsx | 2 - .../src/components/layout/sidebar-context.tsx | 2 - apps/web/src/components/layout/sidebar.tsx | 9 +- .../src/components/layout/theme-toggle.tsx | 2 - apps/web/src/components/layout/topbar.tsx | 8 +- .../components/projects/mission-timeline.tsx | 2 - .../src/components/projects/prd-viewer.tsx | 2 - .../src/components/projects/project-card.tsx | 2 - .../web/src/components/tasks/kanban-board.tsx | 2 - apps/web/src/components/tasks/task-card.tsx | 2 - .../components/tasks/task-detail-modal.tsx | 2 - .../src/components/tasks/task-list-view.tsx | 2 - .../components/tasks/task-status-summary.tsx | 2 - apps/web/src/components/ui/mosaic-logo.tsx | 2 - apps/web/src/{app => }/globals.css | 0 apps/web/src/main.tsx | 2 +- apps/web/src/providers/theme-provider.tsx | 2 - apps/web/src/routes.tsx | 46 +- .../spa/pages/chat-error-boundary.spec.tsx | 7 +- .../pages/resource-route-boundaries.spec.tsx | 7 +- apps/web/src/test/setup.ts | 20 + apps/web/tsconfig.json | 8 +- apps/web/vitest.config.ts | 4 - 49 files changed, 77 insertions(+), 3123 deletions(-) delete mode 100644 apps/web/next-env.d.ts delete mode 100644 apps/web/next.config.ts delete mode 100644 apps/web/src/app/(auth)/layout.tsx delete mode 100644 apps/web/src/app/(auth)/login/page.tsx delete mode 100644 apps/web/src/app/(auth)/register/page.tsx delete mode 100644 apps/web/src/app/(dashboard)/admin/page.tsx delete mode 100644 apps/web/src/app/(dashboard)/chat/page.tsx delete mode 100644 apps/web/src/app/(dashboard)/layout.tsx delete mode 100644 apps/web/src/app/(dashboard)/projects/[id]/page.tsx delete mode 100644 apps/web/src/app/(dashboard)/projects/page.tsx delete mode 100644 apps/web/src/app/(dashboard)/settings/page.tsx delete mode 100644 apps/web/src/app/(dashboard)/tasks/page.tsx delete mode 100644 apps/web/src/app/auth/provider/[provider]/page.tsx delete mode 100644 apps/web/src/app/layout.tsx delete mode 100644 apps/web/src/app/page.tsx delete mode 100644 apps/web/src/components/admin-role-guard.tsx delete mode 100644 apps/web/src/components/auth-guard.tsx delete mode 100644 apps/web/src/components/guest-guard.tsx delete mode 100644 apps/web/src/components/layout/app-header.tsx rename apps/web/src/{app => }/globals.css (100%) diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts deleted file mode 100644 index 9edff1c7..00000000 --- a/apps/web/next-env.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -/// -/// -import "./.next/types/routes.d.ts"; - -// NOTE: This file should not be edited -// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts deleted file mode 100644 index 58479dea..00000000 --- a/apps/web/next.config.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { NextConfig } from 'next'; - -const nextConfig: NextConfig = { - output: 'standalone', - transpilePackages: ['@mosaicstack/design-tokens'], - - // Enable gzip/brotli compression for all responses. - compress: true, - - // Reduce bundle size: disable source maps in production builds. - productionBrowserSourceMaps: false, - - // Image optimisation: allow the gateway origin as an external image source. - images: { - formats: ['image/avif', 'image/webp'], - remotePatterns: [ - { - protocol: 'https', - hostname: '**', - }, - ], - }, - - // Experimental: enable React compiler for automatic memoisation (Next 15+). - // Falls back gracefully if the compiler plugin is not installed. - experimental: { - // Turbopack is the default in dev for Next 15; keep it opt-in for now. - // turbo: {}, - }, -}; - -export default nextConfig; diff --git a/apps/web/package.json b/apps/web/package.json index 93ab92a1..8b6b6e57 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -3,22 +3,19 @@ "version": "0.0.2", "private": true, "scripts": { - "build": "node ../../scripts/build-web.mjs", - "build:vite": "vite build", - "dev": "next dev -p 3101", - "dev:vite": "vite", + "build": "vite build", + "dev": "vite", + "preview": "vite preview", "lint": "eslint src", "typecheck": "tsc --noEmit", "test": "vitest run --passWithNoTests", - "test:e2e": "playwright test", - "start": "next start -p 3101" + "test:e2e": "playwright test" }, "dependencies": { "@mosaicstack/design-tokens": "workspace:^", "@mosaicstack/types": "workspace:^", "better-auth": "^1.5.5", "clsx": "^2.1.0", - "next": "^16.0.0", "react": "^19.0.0", "react-dom": "^19.0.0", "react-markdown": "^10.1.0", diff --git a/apps/web/src/app/(auth)/layout.tsx b/apps/web/src/app/(auth)/layout.tsx deleted file mode 100644 index 8ff3499f..00000000 --- a/apps/web/src/app/(auth)/layout.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import type { ReactNode } from 'react'; -import { GuestGuard } from '@/components/guest-guard'; - -export default function AuthLayout({ children }: { children: ReactNode }): React.ReactElement { - return ( - -
-
- {children} -
-
-
- ); -} diff --git a/apps/web/src/app/(auth)/login/page.tsx b/apps/web/src/app/(auth)/login/page.tsx deleted file mode 100644 index a1a1c327..00000000 --- a/apps/web/src/app/(auth)/login/page.tsx +++ /dev/null @@ -1,139 +0,0 @@ -'use client'; - -import { useEffect, useState } from 'react'; -import { useRouter } from 'next/navigation'; -import Link from 'next/link'; -import { api } from '@/lib/api'; -import { authClient, signIn } from '@/lib/auth-client'; -import type { SsoProviderDiscovery } from '@/lib/sso'; -import { SsoProviderButtons } from '@/components/auth/sso-provider-buttons'; - -export default function LoginPage(): React.ReactElement { - const router = useRouter(); - const [error, setError] = useState(null); - const [loading, setLoading] = useState(false); - const [ssoProviders, setSsoProviders] = useState([]); - const [ssoLoadingProviderId, setSsoLoadingProviderId] = useState< - SsoProviderDiscovery['id'] | null - >(null); - - useEffect(() => { - api('/api/sso/providers') - .catch(() => [] as SsoProviderDiscovery[]) - .then((providers) => setSsoProviders(providers.filter((provider) => provider.configured))); - }, []); - - async function handleSubmit(e: React.FormEvent): Promise { - e.preventDefault(); - setError(null); - setLoading(true); - - const form = new FormData(e.currentTarget); - const email = form.get('email') as string; - const password = form.get('password') as string; - - const result = await signIn.email({ email, password }); - - if (result.error) { - setError(result.error.message ?? 'Sign in failed'); - setLoading(false); - return; - } - - router.push('/chat'); - } - - async function handleSsoSignIn(providerId: SsoProviderDiscovery['id']): Promise { - setError(null); - setSsoLoadingProviderId(providerId); - - try { - const result = await authClient.signIn.oauth2({ - providerId, - callbackURL: '/chat', - newUserCallbackURL: '/chat', - }); - - if (result.error) { - setError(result.error.message ?? `Sign in with ${providerId} failed`); - setSsoLoadingProviderId(null); - } - } catch (err: unknown) { - setError(err instanceof Error ? err.message : `Sign in with ${providerId} failed`); - setSsoLoadingProviderId(null); - } - } - - return ( -
-

Sign in

-

Sign in to your Mosaic account

- - {error && ( -
- {error} -
- )} - -
-
- - -
- -
- - -
- - -
- - { - void handleSsoSignIn(providerId); - }} - /> - -

- Don't have an account?{' '} - - Sign up - -

-
- ); -} diff --git a/apps/web/src/app/(auth)/register/page.tsx b/apps/web/src/app/(auth)/register/page.tsx deleted file mode 100644 index a17228c1..00000000 --- a/apps/web/src/app/(auth)/register/page.tsx +++ /dev/null @@ -1,114 +0,0 @@ -'use client'; - -import { useState } from 'react'; -import { useRouter } from 'next/navigation'; -import Link from 'next/link'; -import { signUp } from '@/lib/auth-client'; - -export default function RegisterPage(): React.ReactElement { - const router = useRouter(); - const [error, setError] = useState(null); - const [loading, setLoading] = useState(false); - - async function handleSubmit(e: React.FormEvent): Promise { - e.preventDefault(); - setError(null); - setLoading(true); - - const form = new FormData(e.currentTarget); - const name = form.get('name') as string; - const email = form.get('email') as string; - const password = form.get('password') as string; - - const result = await signUp.email({ name, email, password }); - - if (result.error) { - setError(result.error.message ?? 'Registration failed'); - setLoading(false); - return; - } - - router.push('/chat'); - } - - return ( -
-

Create account

-

Get started with Mosaic

- - {error && ( -
- {error} -
- )} - -
-
- - -
- -
- - -
- -
- - -
- - -
- -

- Already have an account?{' '} - - Sign in - -

-
- ); -} diff --git a/apps/web/src/app/(dashboard)/admin/page.tsx b/apps/web/src/app/(dashboard)/admin/page.tsx deleted file mode 100644 index 4fc235cf..00000000 --- a/apps/web/src/app/(dashboard)/admin/page.tsx +++ /dev/null @@ -1,531 +0,0 @@ -'use client'; - -import { useEffect, useState, useCallback } from 'react'; -import { AdminRoleGuard } from '@/components/admin-role-guard'; -import { api } from '@/lib/api'; -import { cn } from '@/lib/cn'; - -// ── Types ────────────────────────────────────────────────────────────────────── - -interface UserDto { - id: string; - name: string; - email: string; - role: string; - banned: boolean; - banReason: string | null; - createdAt: string; - updatedAt: string; -} - -interface UserListDto { - users: UserDto[]; - total: number; -} - -interface ServiceStatusDto { - status: 'ok' | 'error'; - latencyMs?: number; - error?: string; -} - -interface ProviderStatusDto { - id: string; - name: string; - available: boolean; - modelCount: number; -} - -interface HealthStatusDto { - status: 'ok' | 'degraded' | 'error'; - database: ServiceStatusDto; - cache: ServiceStatusDto; - agentPool: { activeSessions: number }; - providers: ProviderStatusDto[]; - checkedAt: string; -} - -// ── Admin Page ───────────────────────────────────────────────────────────────── - -export default function AdminPage(): React.ReactElement { - return ( - - - - ); -} - -function AdminContent(): React.ReactElement { - const [activeTab, setActiveTab] = useState<'users' | 'health'>('users'); - - return ( -
-
-

Admin Panel

-
- -
- {(['users', 'health'] as const).map((tab) => ( - - ))} -
- - {activeTab === 'users' ? : } -
- ); -} - -// ── Users Tab ────────────────────────────────────────────────────────────────── - -function UsersTab(): React.ReactElement { - const [users, setUsers] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [showCreate, setShowCreate] = useState(false); - - const loadUsers = useCallback(async () => { - setLoading(true); - setError(null); - try { - const data = await api('/api/admin/users'); - setUsers(data.users); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to load users'); - } finally { - setLoading(false); - } - }, []); - - useEffect(() => { - void loadUsers(); - }, [loadUsers]); - - async function handleRoleToggle(user: UserDto): Promise { - const newRole = user.role === 'admin' ? 'member' : 'admin'; - try { - await api(`/api/admin/users/${user.id}/role`, { - method: 'PATCH', - body: { role: newRole }, - }); - await loadUsers(); - } catch (err) { - alert(err instanceof Error ? err.message : 'Failed to update role'); - } - } - - async function handleBanToggle(user: UserDto): Promise { - const endpoint = user.banned ? 'unban' : 'ban'; - try { - await api(`/api/admin/users/${user.id}/${endpoint}`, { method: 'POST' }); - await loadUsers(); - } catch (err) { - alert(err instanceof Error ? err.message : 'Failed to update ban status'); - } - } - - async function handleDelete(user: UserDto): Promise { - if (!confirm(`Delete user ${user.email}? This cannot be undone.`)) return; - try { - await api(`/api/admin/users/${user.id}`, { method: 'DELETE' }); - await loadUsers(); - } catch (err) { - alert(err instanceof Error ? err.message : 'Failed to delete user'); - } - } - - if (loading) { - return

Loading users...

; - } - - if (error) { - return ( -
-

{error}

- -
- ); - } - - return ( -
-
-

{users.length} user(s)

- -
- - {showCreate && ( - setShowCreate(false)} - onCreated={() => { - setShowCreate(false); - void loadUsers(); - }} - /> - )} - - {users.length === 0 ? ( -
-

No users found

-
- ) : ( -
- - - - - - - - - - - - {users.map((user) => ( - - - - - - - - ))} - -
Name / EmailRoleStatusCreatedActions
-
{user.name}
-
{user.email}
-
- - {user.role} - - - {user.banned ? ( - - Banned - - ) : ( - - Active - - )} - - {new Date(user.createdAt).toLocaleDateString()} - -
- - - -
-
-
- )} -
- ); -} - -// ── Create User Form ────────────────────────────────────────────────────────── - -interface CreateUserFormProps { - onCancel: () => void; - onCreated: () => void; -} - -function CreateUserForm({ onCancel, onCreated }: CreateUserFormProps): React.ReactElement { - const [name, setName] = useState(''); - const [email, setEmail] = useState(''); - const [password, setPassword] = useState(''); - const [role, setRole] = useState('member'); - const [submitting, setSubmitting] = useState(false); - const [error, setError] = useState(null); - - async function handleSubmit(e: React.FormEvent): Promise { - e.preventDefault(); - setSubmitting(true); - setError(null); - try { - await api('/api/admin/users', { - method: 'POST', - body: { name, email, password, role }, - }); - onCreated(); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to create user'); - } finally { - setSubmitting(false); - } - } - - return ( -
-

Create New User

-
void handleSubmit(e)} className="space-y-3"> - {error &&

{error}

} -
-
- - setName(e.target.value)} - className="w-full rounded-md border border-surface-border bg-surface-elevated px-3 py-1.5 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-blue-500" - /> -
-
- - setEmail(e.target.value)} - className="w-full rounded-md border border-surface-border bg-surface-elevated px-3 py-1.5 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-blue-500" - /> -
-
- - setPassword(e.target.value)} - className="w-full rounded-md border border-surface-border bg-surface-elevated px-3 py-1.5 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-blue-500" - /> -
-
- - -
-
-
- - -
-
-
- ); -} - -// ── Health Tab ──────────────────────────────────────────────────────────────── - -function HealthTab(): React.ReactElement { - const [health, setHealth] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - const loadHealth = useCallback(async () => { - setLoading(true); - setError(null); - try { - const data = await api('/api/admin/health'); - setHealth(data); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to load health'); - } finally { - setLoading(false); - } - }, []); - - useEffect(() => { - void loadHealth(); - }, [loadHealth]); - - if (loading) { - return

Loading health status...

; - } - - if (error) { - return ( -
-

{error}

- -
- ); - } - - if (!health) return <>; - - return ( -
-
-
- - - Last checked: {new Date(health.checkedAt).toLocaleTimeString()} - -
- -
- -
- {/* Database */} - - {health.database.latencyMs !== undefined && ( -

Latency: {health.database.latencyMs}ms

- )} - {health.database.error &&

{health.database.error}

} -
- - {/* Cache */} - - {health.cache.latencyMs !== undefined && ( -

Latency: {health.cache.latencyMs}ms

- )} - {health.cache.error &&

{health.cache.error}

} -
- - {/* Agent Pool */} - -

- Active sessions: {health.agentPool.activeSessions} -

-
- - {/* Providers */} - p.available) ? 'ok' : 'error'} - > - {health.providers.length === 0 ? ( -

No providers configured

- ) : ( -
    - {health.providers.map((p) => ( -
  • - {p.name} - - {p.available ? `${p.modelCount} models` : 'unavailable'} - -
  • - ))} -
- )} -
-
-
- ); -} - -// ── Helper Components ───────────────────────────────────────────────────────── - -function StatusBadge({ status }: { status: 'ok' | 'degraded' | 'error' }): React.ReactElement { - const map = { - ok: 'bg-green-500/20 text-green-400', - degraded: 'bg-yellow-500/20 text-yellow-400', - error: 'bg-red-500/20 text-red-400', - }; - return ( - - {status} - - ); -} - -interface HealthCardProps { - title: string; - status: 'ok' | 'error'; - children?: React.ReactNode; -} - -function HealthCard({ title, status, children }: HealthCardProps): React.ReactElement { - return ( -
-
-

{title}

- -
- {children} -
- ); -} diff --git a/apps/web/src/app/(dashboard)/chat/page.tsx b/apps/web/src/app/(dashboard)/chat/page.tsx deleted file mode 100644 index 8a0760b3..00000000 --- a/apps/web/src/app/(dashboard)/chat/page.tsx +++ /dev/null @@ -1,365 +0,0 @@ -'use client'; - -import { useCallback, useEffect, useRef, useState } from 'react'; -import { api } from '@/lib/api'; -import { destroySocket, getSocket } from '@/lib/socket'; -import type { Conversation, Message } from '@/lib/types'; -import { - ConversationSidebar, - type ConversationSidebarRef, -} from '@/components/chat/conversation-sidebar'; -import { MessageBubble } from '@/components/chat/message-bubble'; -import { ChatInput } from '@/components/chat/chat-input'; -import { StreamingMessage } from '@/components/chat/streaming-message'; - -interface ModelInfo { - id: string; - provider: string; - name: string; - reasoning: boolean; - contextWindow: number; - maxTokens: number; - inputTypes: ('text' | 'image')[]; - cost: { input: number; output: number; cacheRead: number; cacheWrite: number }; -} - -interface ProviderInfo { - id: string; - name: string; - available: boolean; - models: ModelInfo[]; -} - -export default function ChatPage(): React.ReactElement { - const [activeId, setActiveId] = useState(null); - const [messages, setMessages] = useState([]); - const [streamingText, setStreamingText] = useState(''); - const [isStreaming, setIsStreaming] = useState(false); - const [isSidebarOpen, setIsSidebarOpen] = useState(true); - const [models, setModels] = useState([]); - const [selectedModelId, setSelectedModelId] = useState(''); - const messagesEndRef = useRef(null); - const sidebarRef = useRef(null); - - // Track the active conversation ID in a ref so socket event handlers always - // see the current value without needing to be re-registered. - const activeIdRef = useRef(null); - activeIdRef.current = activeId; - - // Accumulate streamed text in a ref so agent:end can read the full content - // without stale-closure issues. - const streamingTextRef = useRef(''); - - useEffect(() => { - const savedState = window.localStorage.getItem('mosaic-sidebar-open'); - if (savedState !== null) { - setIsSidebarOpen(savedState === 'true'); - } - }, []); - - useEffect(() => { - window.localStorage.setItem('mosaic-sidebar-open', String(isSidebarOpen)); - }, [isSidebarOpen]); - - useEffect(() => { - api('/api/providers') - .then((providers) => { - const availableModels = providers - .filter((provider) => provider.available) - .flatMap((provider) => provider.models); - setModels(availableModels); - setSelectedModelId((current) => current || availableModels[0]?.id || ''); - }) - .catch(() => { - setModels([]); - setSelectedModelId(''); - }); - }, []); - - // Load messages when active conversation changes - useEffect(() => { - if (!activeId) { - setMessages([]); - return; - } - // Clear streaming state when switching conversations - setIsStreaming(false); - setStreamingText(''); - streamingTextRef.current = ''; - api(`/api/conversations/${activeId}/messages`) - .then(setMessages) - .catch(() => {}); - }, [activeId]); - - // Auto-scroll to bottom - useEffect(() => { - messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); - }, [messages, streamingText]); - - // Socket.io setup — connect once for the page lifetime - useEffect(() => { - const socket = getSocket(); - - function onAgentStart(data: { conversationId: string }): void { - // Only update state if the event belongs to the currently viewed conversation - if (activeIdRef.current !== data.conversationId) return; - setIsStreaming(true); - setStreamingText(''); - streamingTextRef.current = ''; - } - - function onAgentText(data: { conversationId: string; text: string }): void { - if (activeIdRef.current !== data.conversationId) return; - streamingTextRef.current += data.text; - setStreamingText((prev) => prev + data.text); - } - - function onAgentEnd(data: { conversationId: string }): void { - if (activeIdRef.current !== data.conversationId) return; - const finalText = streamingTextRef.current; - setIsStreaming(false); - setStreamingText(''); - streamingTextRef.current = ''; - // Append the completed assistant message to the local message list. - // The Pi agent session is in-memory so the assistant response is not - // persisted to the DB — we build the local UI state instead. - if (finalText) { - setMessages((prev) => [ - ...prev, - { - id: `assistant-${Date.now()}`, - conversationId: data.conversationId, - role: 'assistant' as const, - content: finalText, - createdAt: new Date().toISOString(), - }, - ]); - sidebarRef.current?.refresh(); - } - } - - function onError(data: { error: string; conversationId?: string }): void { - setIsStreaming(false); - setStreamingText(''); - streamingTextRef.current = ''; - setMessages((prev) => [ - ...prev, - { - id: `error-${Date.now()}`, - conversationId: data.conversationId ?? '', - role: 'system' as const, - content: `Error: ${data.error}`, - createdAt: new Date().toISOString(), - }, - ]); - } - - socket.on('agent:start', onAgentStart); - socket.on('agent:text', onAgentText); - socket.on('agent:end', onAgentEnd); - socket.on('error', onError); - - // Connect if not already connected - if (!socket.connected) { - socket.connect(); - } - - return () => { - socket.off('agent:start', onAgentStart); - socket.off('agent:text', onAgentText); - socket.off('agent:end', onAgentEnd); - socket.off('error', onError); - // Fully tear down the socket when the chat page unmounts so we get a - // fresh authenticated connection next time the page is visited. - destroySocket(); - }; - }, []); - - const handleNewConversation = useCallback(async (projectId?: string | null) => { - const conv = await api('/api/conversations', { - method: 'POST', - body: { title: 'New conversation', projectId: projectId ?? null }, - }); - - sidebarRef.current?.addConversation({ - id: conv.id, - title: conv.title, - projectId: conv.projectId, - updatedAt: conv.updatedAt, - archived: conv.archived, - }); - - setActiveId(conv.id); - setMessages([]); - setIsSidebarOpen(true); - }, []); - - const handleSend = useCallback( - async (content: string, options?: { modelId?: string }) => { - let convId = activeId; - - // Auto-create conversation if none selected - if (!convId) { - const autoTitle = content.slice(0, 60); - const conv = await api('/api/conversations', { - method: 'POST', - body: { title: autoTitle }, - }); - sidebarRef.current?.addConversation({ - id: conv.id, - title: conv.title, - projectId: conv.projectId, - updatedAt: conv.updatedAt, - archived: conv.archived, - }); - setActiveId(conv.id); - convId = conv.id; - } else if (messages.length === 0) { - // Auto-title the initial placeholder conversation from the first user message. - const autoTitle = content.slice(0, 60); - api(`/api/conversations/${convId}`, { - method: 'PATCH', - body: { title: autoTitle }, - }) - .then(() => sidebarRef.current?.refresh()) - .catch(() => {}); - } - - // Optimistic user message in local UI state - setMessages((prev) => [ - ...prev, - { - id: `user-${Date.now()}`, - conversationId: convId, - role: 'user' as const, - content, - createdAt: new Date().toISOString(), - }, - ]); - - // Persist the user message to the DB so conversation history is - // available when the page is reloaded or a new session starts. - api(`/api/conversations/${convId}/messages`, { - method: 'POST', - body: { role: 'user', content }, - }).catch(() => { - // Non-fatal: the agent can still process the message even if - // REST persistence fails. - }); - - // Send to WebSocket — gateway creates/resumes the agent session and - // streams the response back via agent:start / agent:text / agent:end. - const socket = getSocket(); - if (!socket.connected) { - socket.connect(); - } - socket.emit('message', { - conversationId: convId, - content, - modelId: (options?.modelId ?? selectedModelId) || undefined, - }); - }, - [activeId, messages, selectedModelId], - ); - - return ( -
- setIsSidebarOpen(false)} - currentConversationId={activeId} - onSelectConversation={(conversationId) => { - setActiveId(conversationId); - setMessages([]); - if (conversationId && window.innerWidth < 768) { - setIsSidebarOpen(false); - } - }} - onNewConversation={(projectId) => { - void handleNewConversation(projectId); - }} - /> - -
-
- -
-

- Mosaic Chat -

-

- {activeId ? 'Active conversation selected' : 'Choose or start a conversation'} -

-
-
- - {activeId ? ( - <> -
- {messages.map((msg) => ( - - ))} - {isStreaming && } -
-
- - - ) : ( -
-
-

- Welcome to Mosaic Chat -

-

- Select a conversation or start a new one -

- -
-
- )} -
-
- ); -} diff --git a/apps/web/src/app/(dashboard)/layout.tsx b/apps/web/src/app/(dashboard)/layout.tsx deleted file mode 100644 index 9edd3627..00000000 --- a/apps/web/src/app/(dashboard)/layout.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import type { ReactNode } from 'react'; -import { AppShell } from '@/components/layout/app-shell'; -import { AuthGuard } from '@/components/auth-guard'; - -export default function DashboardLayout({ children }: { children: ReactNode }): React.ReactElement { - return ( - - {children} - - ); -} diff --git a/apps/web/src/app/(dashboard)/projects/[id]/page.tsx b/apps/web/src/app/(dashboard)/projects/[id]/page.tsx deleted file mode 100644 index 7db4bb96..00000000 --- a/apps/web/src/app/(dashboard)/projects/[id]/page.tsx +++ /dev/null @@ -1,338 +0,0 @@ -'use client'; - -import { useCallback, useEffect, useState } from 'react'; -import { useParams, useRouter } from 'next/navigation'; -import { api } from '@/lib/api'; -import { cn } from '@/lib/cn'; -import type { Mission, Project, Task, TaskStatus } from '@/lib/types'; -import { MissionTimeline } from '@/components/projects/mission-timeline'; -import { PrdViewer } from '@/components/projects/prd-viewer'; -import { TaskDetailModal } from '@/components/tasks/task-detail-modal'; -import { TaskListView } from '@/components/tasks/task-list-view'; -import { TaskStatusSummary } from '@/components/tasks/task-status-summary'; - -type Tab = 'overview' | 'tasks' | 'missions' | 'prd'; - -const statusColors: Record = { - active: 'bg-success/20 text-success', - paused: 'bg-warning/20 text-warning', - completed: 'bg-blue-600/20 text-blue-400', - archived: 'bg-gray-600/20 text-gray-400', -}; - -interface TabButtonProps { - id: Tab; - label: string; - activeTab: Tab; - onClick: (tab: Tab) => void; -} - -function TabButton({ id, label, activeTab, onClick }: TabButtonProps): React.ReactElement { - return ( - - ); -} - -export default function ProjectDetailPage(): React.ReactElement { - const params = useParams(); - const router = useRouter(); - const id = typeof params['id'] === 'string' ? params['id'] : ''; - - const [project, setProject] = useState(null); - const [missions, setMissions] = useState([]); - const [tasks, setTasks] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - const [activeTab, setActiveTab] = useState('overview'); - const [taskFilter, setTaskFilter] = useState('all'); - const [selectedTask, setSelectedTask] = useState(null); - - useEffect(() => { - if (!id) return; - - setLoading(true); - setError(null); - - Promise.all([ - api(`/api/projects/${id}`), - api('/api/missions').catch(() => [] as Mission[]), - api(`/api/tasks?projectId=${id}`).catch(() => [] as Task[]), - ]) - .then(([proj, allMissions, tks]) => { - setProject(proj); - setMissions(allMissions.filter((m) => m.projectId === id)); - setTasks(tks); - }) - .catch((err: Error) => { - setError(err.message ?? 'Failed to load project'); - }) - .finally(() => setLoading(false)); - }, [id]); - - const handleTaskClick = useCallback((task: Task) => { - setSelectedTask(task); - }, []); - - const handleCloseTaskModal = useCallback(() => { - setSelectedTask(null); - }, []); - - if (loading) { - return ( -
-

Loading project...

-
- ); - } - - if (error || !project) { - return ( -
-

{error ?? 'Project not found'}

- -
- ); - } - - const filteredTasks = taskFilter === 'all' ? tasks : tasks.filter((t) => t.status === taskFilter); - - const prdContent = getPrdContent(project); - const hasPrd = Boolean(prdContent); - - const tabs: { id: Tab; label: string }[] = [ - { id: 'overview', label: 'Overview' }, - { id: 'tasks', label: `Tasks (${tasks.length})` }, - { id: 'missions', label: `Missions (${missions.length})` }, - ...(hasPrd ? [{ id: 'prd' as Tab, label: 'PRD' }] : []), - ]; - - return ( -
- {/* Breadcrumb */} - - - {/* Project header */} -
-
-
-

{project.name}

- - {project.status} - -
- {project.description && ( -

{project.description}

- )} -

- Created {new Date(project.createdAt).toLocaleDateString()} · Updated{' '} - {new Date(project.updatedAt).toLocaleDateString()} -

-
-
- - {/* Stats bar */} -
- - t.status === 'done').length)} - valueClass="text-success" - /> - t.status === 'in-progress').length)} - valueClass="text-blue-400" - /> - t.status === 'blocked').length)} - valueClass={tasks.some((t) => t.status === 'blocked') ? 'text-error' : undefined} - /> -
- - {/* Tabs */} -
- {tabs.map((tab) => ( - - ))} -
- - {/* Tab content */} - {activeTab === 'overview' && ( - - )} - - {activeTab === 'tasks' && ( -
-
- -
- -
- )} - - {activeTab === 'missions' && } - - {activeTab === 'prd' && prdContent && ( -
- -
- )} - - {/* Task detail modal */} - {selectedTask && } -
- ); -} - -interface OverviewTabProps { - project: Project; - missions: Mission[]; - tasks: Task[]; -} - -function OverviewTab({ project, missions, tasks }: OverviewTabProps): React.ReactElement { - const recentTasks = [...tasks] - .sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()) - .slice(0, 5); - - return ( -
- {/* Recent tasks */} -
-

Recent Tasks

- {recentTasks.length === 0 ? ( -
-

No tasks yet

-
- ) : ( -
- {recentTasks.map((task) => ( - - ))} -
- )} -
- - {/* Mission summary */} -
-

Missions

- {missions.length === 0 ? ( -
-

No missions yet

-
- ) : ( - - )} -
- - {/* Metadata */} - {project.metadata && Object.keys(project.metadata).length > 0 && ( -
-

Project Metadata

-
-
-              {JSON.stringify(project.metadata, null, 2)}
-            
-
-
- )} -
- ); -} - -const taskStatusColors: Record = { - 'not-started': 'bg-gray-600/20 text-gray-300', - 'in-progress': 'bg-blue-600/20 text-blue-400', - blocked: 'bg-error/20 text-error', - done: 'bg-success/20 text-success', - cancelled: 'bg-gray-600/20 text-gray-500', -}; - -function TaskSummaryRow({ task }: { task: Task }): React.ReactElement { - return ( -
- {task.title} - - {task.status} - -
- ); -} - -function StatCard({ - label, - value, - valueClass, -}: { - label: string; - value: string; - valueClass?: string; -}): React.ReactElement { - return ( -
-

{label}

-

{value}

-
- ); -} - -function getPrdContent(project: Project): string | null { - if (!project.metadata) return null; - - const prd = project.metadata['prd']; - if (typeof prd === 'string' && prd.trim().length > 0) return prd; - - const prdContent = project.metadata['prdContent']; - if (typeof prdContent === 'string' && prdContent.trim().length > 0) return prdContent; - - return null; -} diff --git a/apps/web/src/app/(dashboard)/projects/page.tsx b/apps/web/src/app/(dashboard)/projects/page.tsx deleted file mode 100644 index bd0d662d..00000000 --- a/apps/web/src/app/(dashboard)/projects/page.tsx +++ /dev/null @@ -1,101 +0,0 @@ -'use client'; - -import { useCallback, useEffect, useState } from 'react'; -import { useRouter } from 'next/navigation'; -import { api } from '@/lib/api'; -import type { Project } from '@/lib/types'; -import { ProjectCard } from '@/components/projects/project-card'; - -export default function ProjectsPage(): React.ReactElement { - const [projects, setProjects] = useState([]); - const [loading, setLoading] = useState(true); - const router = useRouter(); - - useEffect(() => { - api('/api/projects') - .then(setProjects) - .catch(() => {}) - .finally(() => setLoading(false)); - }, []); - - const handleProjectClick = useCallback( - (project: Project) => { - router.push(`/projects/${project.id}`); - }, - [router], - ); - - return ( -
-
-

Projects

-
- - {loading ? ( -

Loading projects...

- ) : projects.length === 0 ? ( -
-

No projects yet

-

- Projects will appear here when created via the gateway API -

-
- ) : ( -
- {projects.map((project) => ( - - ))} -
- )} - - {/* Mission status section */} - -
- ); -} - -function MissionStatus(): React.ReactElement { - const [mission, setMission] = useState | null>(null); - const [loading, setLoading] = useState(true); - - useEffect(() => { - api>('/api/coord/status') - .then(setMission) - .catch(() => setMission(null)) - .finally(() => setLoading(false)); - }, []); - - return ( -
-

Active Mission

- {loading ? ( -

Loading mission status...

- ) : !mission ? ( -
-

No active mission detected

-
- ) : ( -
-
- - - - -
-
- )} -
- ); -} - -function StatCard({ label, value }: { label: string; value: string }): React.ReactElement { - return ( -
-

{label}

-

{value}

-
- ); -} diff --git a/apps/web/src/app/(dashboard)/settings/page.tsx b/apps/web/src/app/(dashboard)/settings/page.tsx deleted file mode 100644 index da134278..00000000 --- a/apps/web/src/app/(dashboard)/settings/page.tsx +++ /dev/null @@ -1,828 +0,0 @@ -'use client'; - -import { useCallback, useEffect, useState } from 'react'; -import { api } from '@/lib/api'; -import { authClient, useSession } from '@/lib/auth-client'; -import type { SsoProviderDiscovery } from '@/lib/sso'; -import { SsoProviderSection } from '@/components/settings/sso-provider-section'; - -// ─── Types ──────────────────────────────────────────────────────────────────── - -interface ModelInfo { - id: string; - provider: string; - name: string; - reasoning: boolean; - contextWindow: number; - maxTokens: number; - inputTypes: ('text' | 'image')[]; - cost: { input: number; output: number; cacheRead: number; cacheWrite: number }; -} - -interface ProviderInfo { - id: string; - name: string; - available: boolean; - models: ModelInfo[]; -} - -interface TestConnectionResult { - providerId: string; - reachable: boolean; - latencyMs?: number; - error?: string; - discoveredModels?: string[]; -} - -type TestState = 'idle' | 'testing' | 'success' | 'error'; - -interface ProviderTestStatus { - state: TestState; - result?: TestConnectionResult; -} - -interface Preference { - key: string; - value: unknown; - category: string; -} - -type Theme = 'light' | 'dark' | 'system'; -type SaveState = 'idle' | 'saving' | 'saved' | 'error'; -type Tab = 'profile' | 'appearance' | 'notifications' | 'providers'; - -// ─── Helpers ────────────────────────────────────────────────────────────────── - -function prefValue(prefs: Preference[], key: string, fallback: T): T { - const p = prefs.find((x) => x.key === key); - if (p === undefined) return fallback; - return p.value as T; -} - -// ─── Main Page ──────────────────────────────────────────────────────────────── - -export default function SettingsPage(): React.ReactElement { - const { data: session } = useSession(); - const [activeTab, setActiveTab] = useState('profile'); - - const tabs: { id: Tab; label: string }[] = [ - { id: 'profile', label: 'Profile' }, - { id: 'appearance', label: 'Appearance' }, - { id: 'notifications', label: 'Notifications' }, - { id: 'providers', label: 'Providers' }, - ]; - - return ( -
-

Settings

- - {/* Tab bar */} -
- {tabs.map((tab) => ( - - ))} -
- - {activeTab === 'profile' && } - {activeTab === 'appearance' && } - {activeTab === 'notifications' && } - {activeTab === 'providers' && } -
- ); -} - -// ─── Profile Tab ────────────────────────────────────────────────────────────── - -function ProfileTab({ - session, -}: { - session: { user: { id: string; name: string; email: string; image?: string | null } } | null; -}): React.ReactElement { - const [name, setName] = useState(session?.user.name ?? ''); - const [image, setImage] = useState(session?.user.image ?? ''); - const [saveState, setSaveState] = useState('idle'); - const [errorMsg, setErrorMsg] = useState(''); - - // Sync from session when it loads - useEffect(() => { - if (session?.user) { - setName(session.user.name ?? ''); - setImage(session.user.image ?? ''); - } - }, [session]); - - const handleSave = async (): Promise => { - setSaveState('saving'); - setErrorMsg(''); - try { - const result = await authClient.updateUser({ name, image: image || null }); - if (result.error) { - setErrorMsg(result.error.message ?? 'Failed to update profile'); - setSaveState('error'); - return; - } - setSaveState('saved'); - setTimeout(() => setSaveState('idle'), 2000); - } catch (err: unknown) { - const message = err instanceof Error ? err.message : 'Failed to update profile'; - setErrorMsg(message); - setSaveState('error'); - } - }; - - return ( -
-

Profile

-
- - setName(e.target.value)} - placeholder="Your name" - className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent" - /> - - - - -

Email cannot be changed here.

-
- - - setImage(e.target.value)} - placeholder="https://example.com/avatar.png" - className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent" - /> - - -
- - {saveState === 'error' && errorMsg &&

{errorMsg}

} -
-
-
- ); -} - -// ─── Appearance Tab ─────────────────────────────────────────────────────────── - -function AppearanceTab(): React.ReactElement { - const [loading, setLoading] = useState(true); - const [theme, setTheme] = useState('system'); - const [sidebarCollapsed, setSidebarCollapsed] = useState(false); - const [defaultModel, setDefaultModel] = useState(''); - const [saveState, setSaveState] = useState('idle'); - const [errorMsg, setErrorMsg] = useState(''); - - useEffect(() => { - api('/api/memory/preferences?category=appearance') - .catch(() => [] as Preference[]) - .then((p) => { - setTheme(prefValue(p, 'ui.theme', 'system')); - setSidebarCollapsed(prefValue(p, 'ui.sidebar_collapsed', false)); - setDefaultModel(prefValue(p, 'ui.default_model', '')); - }) - .finally(() => setLoading(false)); - }, []); - - const handleSave = async (): Promise => { - setSaveState('saving'); - setErrorMsg(''); - try { - await Promise.all([ - api('/api/memory/preferences', { - method: 'POST', - body: { key: 'ui.theme', value: theme, category: 'appearance', source: 'user' }, - }), - api('/api/memory/preferences', { - method: 'POST', - body: { - key: 'ui.sidebar_collapsed', - value: sidebarCollapsed, - category: 'appearance', - source: 'user', - }, - }), - ...(defaultModel - ? [ - api('/api/memory/preferences', { - method: 'POST', - body: { - key: 'ui.default_model', - value: defaultModel, - category: 'appearance', - source: 'user', - }, - }), - ] - : []), - ]); - setSaveState('saved'); - setTimeout(() => setSaveState('idle'), 2000); - } catch (err: unknown) { - const message = err instanceof Error ? err.message : 'Failed to save preferences'; - setErrorMsg(message); - setSaveState('error'); - } - }; - - if (loading) { - return ( -
-

Appearance

-

Loading preferences...

-
- ); - } - - return ( -
-

Appearance

-
- {/* Theme */} -
- -
- {(['system', 'light', 'dark'] as Theme[]).map((t) => ( - - ))} -
-
- - {/* Sidebar collapsed default */} -
-
-

Collapse sidebar by default

-

Start with sidebar collapsed on page load

-
- -
- - {/* Default model */} - - setDefaultModel(e.target.value)} - placeholder="e.g. ollama/llama3.2" - className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent" - /> -

- Model ID to pre-select for new conversations. -

-
- -
- - {saveState === 'error' && errorMsg &&

{errorMsg}

} -
-
-
- ); -} - -// ─── Notifications Tab ──────────────────────────────────────────────────────── - -function NotificationsTab(): React.ReactElement { - const [loading, setLoading] = useState(true); - const [emailAgentComplete, setEmailAgentComplete] = useState(false); - const [emailMentions, setEmailMentions] = useState(true); - const [emailDigest, setEmailDigest] = useState(false); - const [saveState, setSaveState] = useState('idle'); - const [errorMsg, setErrorMsg] = useState(''); - - useEffect(() => { - api('/api/memory/preferences?category=communication') - .catch(() => [] as Preference[]) - .then((p) => { - setEmailAgentComplete(prefValue(p, 'notify.email_agent_complete', false)); - setEmailMentions(prefValue(p, 'notify.email_mentions', true)); - setEmailDigest(prefValue(p, 'notify.email_digest', false)); - }) - .finally(() => setLoading(false)); - }, []); - - const handleSave = async (): Promise => { - setSaveState('saving'); - setErrorMsg(''); - try { - await Promise.all([ - api('/api/memory/preferences', { - method: 'POST', - body: { - key: 'notify.email_agent_complete', - value: emailAgentComplete, - category: 'communication', - source: 'user', - }, - }), - api('/api/memory/preferences', { - method: 'POST', - body: { - key: 'notify.email_mentions', - value: emailMentions, - category: 'communication', - source: 'user', - }, - }), - api('/api/memory/preferences', { - method: 'POST', - body: { - key: 'notify.email_digest', - value: emailDigest, - category: 'communication', - source: 'user', - }, - }), - ]); - setSaveState('saved'); - setTimeout(() => setSaveState('idle'), 2000); - } catch (err: unknown) { - const message = err instanceof Error ? err.message : 'Failed to save preferences'; - setErrorMsg(message); - setSaveState('error'); - } - }; - - if (loading) { - return ( -
-

Notifications

-

Loading preferences...

-
- ); - } - - return ( -
-

Notifications

-
-

Configure when you receive email notifications.

- - - - - -
- - {saveState === 'error' && errorMsg &&

{errorMsg}

} -
-
-
- ); -} - -// ─── Providers Tab ──────────────────────────────────────────────────────────── - -function ProvidersTab(): React.ReactElement { - const [providers, setProviders] = useState([]); - const [ssoProviders, setSsoProviders] = useState([]); - const [loading, setLoading] = useState(true); - const [ssoLoading, setSsoLoading] = useState(true); - const [testStatuses, setTestStatuses] = useState>({}); - - useEffect(() => { - api('/api/providers') - .catch(() => [] as ProviderInfo[]) - .then((p) => setProviders(p)) - .finally(() => setLoading(false)); - }, []); - - useEffect(() => { - api('/api/sso/providers') - .catch(() => [] as SsoProviderDiscovery[]) - .then((providers) => setSsoProviders(providers)) - .finally(() => setSsoLoading(false)); - }, []); - - const testConnection = useCallback(async (providerId: string): Promise => { - setTestStatuses((prev) => ({ - ...prev, - [providerId]: { state: 'testing' }, - })); - try { - const result = await api('/api/providers/test', { - method: 'POST', - body: { providerId }, - }); - setTestStatuses((prev) => ({ - ...prev, - [providerId]: { state: result.reachable ? 'success' : 'error', result }, - })); - } catch { - setTestStatuses((prev) => ({ - ...prev, - [providerId]: { - state: 'error', - result: { providerId, reachable: false, error: 'Request failed' }, - }, - })); - } - }, []); - - const defaultModel: ModelInfo | undefined = providers - .flatMap((p) => p.models) - .find((m) => providers.find((p) => p.id === m.provider)?.available); - - return ( -
-
-

SSO Providers

- -
- -
-

LLM Providers

- {loading ? ( -

Loading providers...

- ) : providers.length === 0 ? ( -
-

- No providers configured. Set{' '} - - OLLAMA_BASE_URL - {' '} - or{' '} - - MOSAIC_CUSTOM_PROVIDERS - {' '} - to add providers. -

-
- ) : ( -
- {providers.map((provider) => ( - void testConnection(provider.id)} - /> - ))} -
- )} -
-
- ); -} - -// ─── Shared UI Components ───────────────────────────────────────────────────── - -function FormField({ - label, - id, - children, -}: { - label: string; - id: string; - children: React.ReactNode; -}): React.ReactElement { - return ( -
- - {children} -
- ); -} - -function Toggle({ - checked, - onChange, -}: { - checked: boolean; - onChange: (v: boolean) => void; -}): React.ReactElement { - return ( - - ); -} - -function NotifyRow({ - label, - description, - checked, - onChange, -}: { - label: string; - description: string; - checked: boolean; - onChange: (v: boolean) => void; -}): React.ReactElement { - return ( -
-
-

{label}

-

{description}

-
- -
- ); -} - -function SaveButton({ - state, - onClick, -}: { - state: SaveState; - onClick: () => void; -}): React.ReactElement { - return ( - - ); -} - -// ─── Provider Card (from original page) ────────────────────────────────────── - -interface ProviderCardProps { - provider: ProviderInfo; - defaultModel: ModelInfo | undefined; - testStatus: ProviderTestStatus; - onTest: () => void; -} - -function ProviderCard({ - provider, - defaultModel, - testStatus, - onTest, -}: ProviderCardProps): React.ReactElement { - const [expanded, setExpanded] = useState(false); - - return ( -
- {/* Header row */} -
-
- -
-
- {provider.name} - -
-

- {provider.models.length} model{provider.models.length !== 1 ? 's' : ''} -

-
-
- -
- - -
-
- - {/* Test result banner */} - {testStatus.state !== 'idle' && testStatus.state !== 'testing' && testStatus.result && ( - - )} - - {/* Model list */} - {expanded && ( -
- - - - - - - - - - - - {provider.models.map((model) => ( - - ))} - -
ModelCapabilitiesContextCost (in/out)Default
-
- )} -
- ); -} - -interface ModelRowProps { - model: ModelInfo; - isDefault: boolean; -} - -function ModelRow({ model, isDefault }: ModelRowProps): React.ReactElement { - return ( - - - {model.name} - - -
- - {model.reasoning && } - {model.inputTypes.includes('image') && } -
- - - {formatContext(model.contextWindow)} - - - {model.cost.input === 0 && model.cost.output === 0 - ? 'free' - : `$${model.cost.input} / $${model.cost.output}`} - - - {isDefault && ( - - default - - )} - - - ); -} - -function ProviderAvatar({ id }: { id: string }): React.ReactElement { - const letter = id.charAt(0).toUpperCase(); - return ( -
- {letter} -
- ); -} - -function ProviderStatusBadge({ available }: { available: boolean }): React.ReactElement { - return ( - - {available ? 'Active' : 'Inactive'} - - ); -} - -interface TestConnectionButtonProps { - status: ProviderTestStatus; - onTest: () => void; -} - -function TestConnectionButton({ status, onTest }: TestConnectionButtonProps): React.ReactElement { - const isTesting = status.state === 'testing'; - return ( - - ); -} - -function TestResultBanner({ result }: { result: TestConnectionResult }): React.ReactElement { - return ( -
- {result.reachable ? ( - <> - Connected - {result.latencyMs !== undefined && ( - ({result.latencyMs}ms) - )} - {result.discoveredModels && result.discoveredModels.length > 0 && ( - - — {result.discoveredModels.length} model - {result.discoveredModels.length !== 1 ? 's' : ''} discovered - - )} - - ) : ( - <>Connection failed{result.error ? `: ${result.error}` : ''} - )} -
- ); -} - -function CapabilityBadge({ - label, - color = 'default', -}: { - label: string; - color?: 'default' | 'purple' | 'blue'; -}): React.ReactElement { - const colorClass = - color === 'purple' - ? 'bg-purple-500/20 text-purple-400' - : color === 'blue' - ? 'bg-blue-500/20 text-blue-400' - : 'bg-surface-elevated text-text-muted'; - return {label}; -} - -function formatContext(tokens: number): string { - if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`; - if (tokens >= 1_000) return `${Math.round(tokens / 1_000)}k`; - return String(tokens); -} diff --git a/apps/web/src/app/(dashboard)/tasks/page.tsx b/apps/web/src/app/(dashboard)/tasks/page.tsx deleted file mode 100644 index 36ee9d4b..00000000 --- a/apps/web/src/app/(dashboard)/tasks/page.tsx +++ /dev/null @@ -1,72 +0,0 @@ -'use client'; - -import { useCallback, useEffect, useState } from 'react'; -import { api } from '@/lib/api'; -import { cn } from '@/lib/cn'; -import type { Task } from '@/lib/types'; -import { KanbanBoard } from '@/components/tasks/kanban-board'; -import { TaskListView } from '@/components/tasks/task-list-view'; - -type ViewMode = 'list' | 'kanban'; - -export default function TasksPage(): React.ReactElement { - const [tasks, setTasks] = useState([]); - const [view, setView] = useState('kanban'); - const [loading, setLoading] = useState(true); - - useEffect(() => { - api('/api/tasks') - .then(setTasks) - .catch(() => {}) - .finally(() => setLoading(false)); - }, []); - - const handleTaskClick = useCallback((task: Task) => { - // Task detail view will be added in future iteration - console.log('Task clicked:', task.id); - }, []); - - return ( -
-
-

Tasks

-
-
- - -
-
-
- - {loading ? ( -

Loading tasks...

- ) : view === 'kanban' ? ( - - ) : ( - - )} -
- ); -} diff --git a/apps/web/src/app/auth/provider/[provider]/page.tsx b/apps/web/src/app/auth/provider/[provider]/page.tsx deleted file mode 100644 index d2771843..00000000 --- a/apps/web/src/app/auth/provider/[provider]/page.tsx +++ /dev/null @@ -1,95 +0,0 @@ -'use client'; - -import Link from 'next/link'; -import { useEffect, useState } from 'react'; -import { useParams, useSearchParams } from 'next/navigation'; -import { api } from '@/lib/api'; -import { resolveAuthCallbackURL } from '@/lib/auth-redirect'; -import { signIn } from '@/lib/auth-client'; -import type { SsoProviderDiscovery } from '@/lib/sso'; - -export default function AuthProviderRedirectPage(): React.ReactElement { - const params = useParams<{ provider: string }>(); - const searchParams = useSearchParams(); - const providerId = typeof params.provider === 'string' ? params.provider : ''; - const requestedCallbackURL = searchParams.get('callbackURL'); - const [providerName, setProviderName] = useState(null); - const [error, setError] = useState(null); - - useEffect(() => { - let cancelled = false; - - async function redirectToProvider(): Promise { - try { - const callbackURL = resolveAuthCallbackURL(requestedCallbackURL, window.location.origin); - const providers = await api('/api/sso/providers'); - if (cancelled) return; - - const provider = providers.find((candidate) => candidate.id === providerId); - if (!provider) { - setError('Unknown SSO provider.'); - return; - } - - setProviderName(provider.name); - if (!provider.configured) { - setError(`${provider.name} is not enabled in this deployment.`); - return; - } - if (provider.loginMode !== 'oidc') { - setError(`${provider.name} is not available for OIDC sign in.`); - return; - } - - const result = await signIn.oauth2({ - providerId: provider.id, - callbackURL, - }); - - if (!cancelled && result?.error) { - setError(result.error.message ?? `${provider.name} sign in failed.`); - } - } catch (caught: unknown) { - if (!cancelled) { - setError(caught instanceof Error ? caught.message : 'Unable to start single sign-on.'); - } - } - } - - void redirectToProvider(); - - return () => { - cancelled = true; - }; - }, [providerId, requestedCallbackURL]); - - return ( -
-

Single sign-on

-

- {providerName - ? `Redirecting you to ${providerName}...` - : 'Preparing your sign-in request...'} -

- - {error ? ( -
-

{error}

- - Return to login - -
- ) : ( -
- If the redirect does not start automatically, return to the login page and try again. -
- )} -
- ); -} diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx deleted file mode 100644 index 6b176e9a..00000000 --- a/apps/web/src/app/layout.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import type { Metadata } from 'next'; -import type { ReactNode } from 'react'; -import { ThemeProvider } from '@/providers/theme-provider'; -import './globals.css'; - -export const metadata: Metadata = { - title: 'Mosaic', - description: 'Mosaic Stack Dashboard', -}; - -function themeScript(): string { - return ` - (function () { - try { - var theme = window.localStorage.getItem('mosaic-theme') || 'dark'; - document.documentElement.setAttribute('data-theme', theme === 'light' ? 'light' : 'dark'); - } catch (error) { - document.documentElement.setAttribute('data-theme', 'dark'); - } - })(); - `; -} - -export default function RootLayout({ children }: { children: ReactNode }): React.ReactElement { - return ( - - - - - -