From b5ee692843c2521a26d40fc2d0cef309da8595ff Mon Sep 17 00:00:00 2001 From: fred Date: Thu, 27 Aug 2026 13:06:49 +0000 Subject: [PATCH] =?UTF-8?q?P5:=20SPA=20cutover=20=E2=80=94=20retire=20Next?= =?UTF-8?q?.js,=20gateway=20serves=20the=20Vite=20bundle=20(#1444)=20(#145?= =?UTF-8?q?3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 9 +- .woodpecker/publish.yml | 44 - apps/gateway/package.json | 1 + apps/gateway/src/main.ts | 2 + apps/gateway/src/spa/serve-spa.ts | 75 ++ 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 - docker/gateway.Dockerfile | 9 +- docker/web.Dockerfile | 24 - package.json | 1 - pnpm-lock.yaml | 169 ++-- scripts/build-web.mjs | 146 --- scripts/build-web.test.mjs | 149 ---- scripts/clean-generated.mjs | 34 - scripts/preflight.mjs | 221 +---- scripts/preflight.test.mjs | 213 +---- scripts/publish-gate-structure.test.mjs | 1 - scripts/verify-release.test.mjs | 1 - 65 files changed, 288 insertions(+), 4011 deletions(-) create mode 100644 apps/gateway/src/spa/serve-spa.ts 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%) delete mode 100644 docker/web.Dockerfile delete mode 100644 scripts/build-web.mjs delete mode 100644 scripts/build-web.test.mjs delete mode 100644 scripts/clean-generated.mjs diff --git a/.env.example b/.env.example index 440d185e..2a5e425e 100644 --- a/.env.example +++ b/.env.example @@ -40,9 +40,12 @@ BETTER_AUTH_SECRET=change-me-to-a-random-32-char-string BETTER_AUTH_URL=http://localhost:14242 -# ─── Web App (Next.js) ─────────────────────────────────────────────────────── -# Public gateway URL — accessible from the browser, not just the server. -NEXT_PUBLIC_GATEWAY_URL=http://localhost:14242 +# ─── Web App (SPA) ─────────────────────────────────────────────────────────── +# Directory holding the built SPA bundle (vite build output). When set, the +# gateway serves the SPA same-origin; when unset (dev), run the Vite dev +# server (pnpm --filter @mosaicstack/web dev), which proxies to the gateway. +# safe-default: unset in dev — SPA serving is an opt-in production concern +#WEB_DIST_DIR=apps/web/dist # ─── OpenTelemetry ─────────────────────────────────────────────────────────── diff --git a/.woodpecker/publish.yml b/.woodpecker/publish.yml index f4e99282..0b1afdc4 100644 --- a/.woodpecker/publish.yml +++ b/.woodpecker/publish.yml @@ -510,47 +510,3 @@ steps: # ERR_PNPM_OUTDATED_LOCKFILE despite a clean restore. This edge is the # serialization invariant; add it to every new workspace consumer. - publish-next-npm - - build-web: - image: gcr.io/kaniko-project/executor:debug - when: *image_build_when - environment: - REGISTRY_USER: - from_secret: REGISTRY_USERNAME - REGISTRY_PASS: - from_secret: REGISTRY_PASSWORD - CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH} - CI_COMMIT_TAG: ${CI_COMMIT_TAG} - CI_COMMIT_SHA: ${CI_COMMIT_SHA} - commands: - - mkdir -p /kaniko/.docker - - echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json - - | - DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/web:sha-${CI_COMMIT_SHA:0:7}" - if [ "$CI_COMMIT_BRANCH" = "next" ]; then - if [ -n "$CI_COMMIT_TAG" ]; then - echo "[publish] FATAL: next web publish must be sha-only; refusing tag '$CI_COMMIT_TAG'" >&2 - exit 1 - fi - echo "[publish] next web publish is sha-only" - elif [ "$CI_COMMIT_BRANCH" = "main" ]; then - DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/web:latest" - elif [ -z "$CI_COMMIT_TAG" ]; then - echo "[publish] FATAL: web image publish may only run for main, next, or tag events" >&2 - exit 1 - fi - if [ -n "$CI_COMMIT_TAG" ]; then - DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/web:$CI_COMMIT_TAG" - fi - /kaniko/executor --context . --dockerfile docker/web.Dockerfile $DESTINATIONS - depends_on: - - build - - verify - # #1411: publish-next-npm mutates workspace manifests in place during - # its transform window and restores them at step end. Any step that - # reads the pipeline workspace (kaniko COPY of manifests, later - # installs) must run AFTER publish-next-npm, never concurrently — - # pipeline 2648 raced a COPY inside the window and failed - # ERR_PNPM_OUTDATED_LOCKFILE despite a clean restore. This edge is the - # serialization invariant; add it to every new workspace consumer. - - publish-next-npm diff --git a/apps/gateway/package.json b/apps/gateway/package.json index 8e5819f9..3965b889 100644 --- a/apps/gateway/package.json +++ b/apps/gateway/package.json @@ -28,6 +28,7 @@ "dependencies": { "@anthropic-ai/sdk": "^0.80.0", "@fastify/helmet": "^13.0.2", + "@fastify/static": "^8.3.0", "@mariozechner/pi-ai": "^0.65.0", "@mariozechner/pi-coding-agent": "^0.65.0", "@modelcontextprotocol/sdk": "^1.27.1", diff --git a/apps/gateway/src/main.ts b/apps/gateway/src/main.ts index cb066d18..34e496d2 100644 --- a/apps/gateway/src/main.ts +++ b/apps/gateway/src/main.ts @@ -12,6 +12,7 @@ import { AppModule } from './app.module.js'; import { mountAuthHandler } from './auth/auth.controller.js'; import { mountMcpHandler } from './mcp/mcp.controller.js'; import { McpService } from './mcp/mcp.service.js'; +import { mountSpaStatic } from './spa/serve-spa.js'; import { detectAndAssertTier, TierDetectionError } from '@mosaicstack/storage'; import { resolveGatewayConfigPath } from './env.js'; import { assertValidationPipeSeesDtoDecorators } from './validation-pipe-check.js'; @@ -68,6 +69,7 @@ async function bootstrap(): Promise { mountAuthHandler(app); mountMcpHandler(app, app.get(McpService)); + await mountSpaStatic(app); const port = Number(process.env['GATEWAY_PORT'] ?? 14242); await app.listen(port, '0.0.0.0'); diff --git a/apps/gateway/src/spa/serve-spa.ts b/apps/gateway/src/spa/serve-spa.ts new file mode 100644 index 00000000..bda3617f --- /dev/null +++ b/apps/gateway/src/spa/serve-spa.ts @@ -0,0 +1,75 @@ +import { existsSync } from 'node:fs'; +import path from 'node:path'; +import { Logger } from '@nestjs/common'; +import fastifyStatic from '@fastify/static'; +import type { NestFastifyApplication } from '@nestjs/platform-fastify'; + +/** Request paths that belong to the backend, never to the SPA fallback. */ +const BACKEND_PREFIXES = ['/api', '/mcp', '/socket.io'] as const; + +function isBackendPath(url: string): boolean { + return BACKEND_PREFIXES.some((prefix) => url === prefix || url.startsWith(`${prefix}/`)); +} + +/** + * Serve the built web SPA bundle (Phase P5 cutover, #1444). + * + * WEB_DIST_DIR unset: SPA serving is disabled — dev runs the Vite dev server, + * which proxies /api and /socket.io here. WEB_DIST_DIR set but not holding a + * built bundle: fail at boot, because a gateway configured to serve the UI + * silently serving 404s is an outage, not a degraded mode. + * + * Static files get exact routes (wildcard: false, so nothing shadows the API + * routes); every other GET/HEAD outside the backend prefixes falls back to + * index.html so client-side routes deep-link correctly. + */ +export async function mountSpaStatic(app: NestFastifyApplication): Promise { + const logger = new Logger('SpaStatic'); + const distDir = process.env['WEB_DIST_DIR']; + if (!distDir) { + logger.log('WEB_DIST_DIR not set; SPA serving disabled (dev mode uses the Vite dev server)'); + return; + } + + const root = path.resolve(distDir); + const indexFile = path.join(root, 'index.html'); + if (!existsSync(indexFile)) { + throw new Error(`WEB_DIST_DIR is '${distDir}' but '${indexFile}' does not exist`); + } + + // Default cache semantics: public, max-age=0 with ETag/Last-Modified, so + // every response revalidates (304 when unchanged). Always correct, including + // for index.html after a deploy; immutable caching for hashed /assets/ files + // is a P6 optimization. + await app.register( + fastifyStatic as never, + { + root, + wildcard: false, + index: false, + } as never, + ); + + // A wildcard route, not setNotFoundHandler: Nest installs its own not-found + // handler during init and Fastify allows only one. find-my-way matches + // most-specific-first, so every declared route (API, static files) wins over + // this catch-all; non-GET unmatched requests keep Fastify's stock 404. + const fastify = app.getHttpAdapter().getInstance(); + fastify.get('/*', (req, reply) => { + const url = req.raw.url ?? ''; + if (isBackendPath(url)) { + // An unknown backend path is an API 404, never the SPA page. + void reply.code(404).send({ + message: `Route ${req.raw.method ?? 'GET'}:${url} not found`, + error: 'Not Found', + statusCode: 404, + }); + return; + } + // sendFile is decorated by @fastify/static; its type augmentation targets + // a different fastify copy in the pnpm tree than the Nest adapter's. + (reply as unknown as { sendFile: (file: string) => unknown }).sendFile('index.html'); + }); + + logger.log(`Serving SPA bundle from ${root}`); +} 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 ( - - - - - -