diff --git a/.gitignore b/.gitignore index 3f82c55..e16e1b4 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,11 @@ skills-lock.json # Claude Code worktrees and local state .claude/worktrees/ .claude/ralph-loop.local.md + +# Dashboard (Next.js) +dashboard/node_modules/ +dashboard/.next/ +dashboard/apps/*/node_modules/ +dashboard/apps/*/.next/ +dashboard/packages/*/node_modules/ +dashboard/.env.local diff --git a/Makefile b/Makefile index cf1a12a..61973bb 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: smoke check test build clean clippy fmt publish publish-dry-run install changelog release harness-audit entropy-check control-refresh control-validate conversations eval-run eval-check eval-rollback +.PHONY: smoke check test build clean clippy fmt publish publish-dry-run install changelog release harness-audit entropy-check control-refresh control-validate conversations eval-run eval-check eval-rollback dashboard-install dashboard-dev dashboard-build # === GATES === @@ -94,6 +94,17 @@ release: smoke @echo "Release v$(VERSION) ready. Push with:" @echo " git push origin master v$(VERSION)" +# === DASHBOARD === + +dashboard-install: + cd dashboard && bun install + +dashboard-dev: dashboard-install + cd dashboard && bun run dev + +dashboard-build: dashboard-install + cd dashboard && bun run build + # === CONTROL AUDIT === control-audit: smoke fmt-check diff --git a/crates/symphony-observability/src/server.rs b/crates/symphony-observability/src/server.rs index 544d966..2c07516 100644 --- a/crates/symphony-observability/src/server.rs +++ b/crates/symphony-observability/src/server.rs @@ -15,6 +15,7 @@ use axum::{Json, Router, routing::get}; use serde::Serialize; use symphony_core::OrchestratorState; use tokio::sync::Mutex; +use tower_http::cors::{AllowOrigin, Any, CorsLayer}; /// Shared state for the HTTP server. #[derive(Clone)] @@ -92,6 +93,29 @@ pub struct ErrorDetail { pub message: String, } +/// Build CORS layer from environment. +/// +/// - `SYMPHONY_CORS_ORIGINS` — comma-separated allowed origins (e.g. `http://localhost:3000,https://app.example.com`) +/// - If unset, defaults to permissive `Any` for development convenience. +fn build_cors_layer() -> CorsLayer { + match std::env::var("SYMPHONY_CORS_ORIGINS") { + Ok(origins) if !origins.is_empty() => { + let parsed: Vec<_> = origins + .split(',') + .filter_map(|o| o.trim().parse().ok()) + .collect(); + CorsLayer::new() + .allow_origin(AllowOrigin::list(parsed)) + .allow_methods(Any) + .allow_headers(Any) + } + _ => CorsLayer::new() + .allow_origin(Any) + .allow_methods(Any) + .allow_headers(Any), + } +} + /// Build the HTTP router (S13.7). pub fn build_router(state: AppState) -> Router { // API routes — protected by optional bearer token auth @@ -120,6 +144,7 @@ pub fn build_router(state: AppState) -> Router { .route("/readyz", get(readyz)) .route("/metrics", get(get_prometheus_metrics)) .merge(api_routes) + .layer(build_cors_layer()) .with_state(state) } @@ -978,6 +1003,22 @@ mod tests { assert!(text.contains("symphony_issues_completed")); } + #[tokio::test] + async fn cors_preflight_returns_headers() { + let state = make_app_state(); + let app = build_router(state); + let req = Request::builder() + .method("OPTIONS") + .uri("/api/v1/state") + .header("origin", "http://localhost:3000") + .header("access-control-request-method", "GET") + .body(Body::empty()) + .unwrap(); + let resp = app.oneshot(req).await.unwrap(); + assert!(resp.headers().contains_key("access-control-allow-origin")); + assert!(resp.headers().contains_key("access-control-allow-methods")); + } + #[tokio::test] async fn prometheus_metrics_bypasses_auth() { let state = AppState { diff --git a/dashboard/.dockerignore b/dashboard/.dockerignore new file mode 100644 index 0000000..701f086 --- /dev/null +++ b/dashboard/.dockerignore @@ -0,0 +1,5 @@ +node_modules +.next +.env.local +*.log +.git diff --git a/dashboard/.gitignore b/dashboard/.gitignore new file mode 100644 index 0000000..bcd6952 --- /dev/null +++ b/dashboard/.gitignore @@ -0,0 +1,73 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +node_modules +.pnp +.pnp.js + +# testing +coverage + +# next.js +.next/ +apps/*/.next/ +out/ +build +next-env.d.ts + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# local env files +.env.local +.neon-branch +.env.development.local +.env.test.local +.env.production.local + +# turbo +.turbo + +.env +.vercel +.env*.local + +# Playwright +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/* +apps/*/test-results/ +apps/*/playwright-report/ +apps/*/blob-report/ +apps/*/playwright/* + +tsconfig.tsbuildinfo + +packages/*/dist +packages/cli/templates +templates + +# Evalite DB +evals/db/ +apps/*/evals/db/ + +# Neon +.neon + +/.skillz +/AGENTS.md +/AGENTS.md.bak +/CLAUDE.md +/CLAUDE.md.bak +# END Skiller Generated Files +.devtools + +.cursor/hooks/state \ No newline at end of file diff --git a/dashboard/Dockerfile b/dashboard/Dockerfile new file mode 100644 index 0000000..4f3e0ec --- /dev/null +++ b/dashboard/Dockerfile @@ -0,0 +1,54 @@ +# Symphony Dashboard — multi-stage Docker build +# For Railway / self-hosted deployment + +FROM oven/bun:1.3 AS base +WORKDIR /app + +# Install dependencies +FROM base AS deps +COPY package.json bun.lock ./ +COPY apps/web/package.json ./apps/web/ +COPY packages/symphony-client/package.json ./packages/symphony-client/ +RUN bun install --frozen-lockfile + +# Build the application +FROM base AS builder +COPY --from=deps /app/node_modules ./node_modules +COPY --from=deps /app/apps/web/node_modules ./apps/web/node_modules +COPY . . + +# Build args become env vars for Next.js build +ARG DATABASE_URL +ARG AUTH_SECRET +ARG SYMPHONY_API_URL=http://localhost:8080 +ARG SYMPHONY_API_TOKEN +ARG APP_URL + +ENV DATABASE_URL=${DATABASE_URL} +ENV AUTH_SECRET=${AUTH_SECRET} +ENV SYMPHONY_API_URL=${SYMPHONY_API_URL} +ENV SYMPHONY_API_TOKEN=${SYMPHONY_API_TOKEN} +ENV APP_URL=${APP_URL} + +RUN bun run build + +# Production image +FROM base AS runner +WORKDIR /app + +ENV NODE_ENV=production + +RUN addgroup --system --gid 1001 nodejs && \ + adduser --system --uid 1001 nextjs + +COPY --from=builder /app/apps/web/.next/standalone ./ +COPY --from=builder /app/apps/web/.next/static ./apps/web/.next/static +COPY --from=builder /app/apps/web/public ./apps/web/public + +USER nextjs + +EXPOSE 3000 +ENV PORT=3000 +ENV HOSTNAME="0.0.0.0" + +CMD ["node", "apps/web/server.js"] diff --git a/dashboard/LICENSE b/dashboard/LICENSE new file mode 100644 index 0000000..560f043 --- /dev/null +++ b/dashboard/LICENSE @@ -0,0 +1,13 @@ +Copyright 2025 Francisco Moretti + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. \ No newline at end of file diff --git a/dashboard/README.md b/dashboard/README.md new file mode 100644 index 0000000..b2f947f --- /dev/null +++ b/dashboard/README.md @@ -0,0 +1,92 @@ +
+ +ChatJS + +# ChatJS + +Stop rebuilding the same AI chat infrastructure. ChatJS gives you a production-ready foundation with authentication, 120+ models, streaming, and tools so you can focus on what makes your app unique. + +[**Documentation**](https://chatjs.dev/docs) · [**Live Demo**](https://chatjs.dev) + +![DemosOnly](https://github.com/user-attachments/assets/f12e89dd-c10c-4e06-9b1a-a9fbd809d234) + +
+ +
+ +## CLI + +Create a new ChatJS app: + +```bash +npx @chat-js/cli@latest create my-app +``` + +The CLI walks you through gateway, features, and auth choices, generates `chat.config.ts`, and lists the env vars required by your selections. + +## Features + +- **120+ Models**: Claude, GPT, Gemini, Grok via one API +- **Auth**: GitHub, Google, anonymous. Ready to go. +- **Attachments**: Images, PDFs, docs. Drag and drop. +- **Resumable Streams**: Continue generation after page refresh +- **Branching**: Fork conversations, explore alternatives +- **Sharing**: Share conversations with public links +- **Web Search**: Real-time web search integration +- **Image Generation**: AI-powered image creation +- **Code Execution**: Run code snippets in sandbox +- **MCP**: Model Context Protocol support + +## Stack + +- [Next.js](https://nextjs.org) - App Router, React Server Components +- [TypeScript](https://www.typescriptlang.org) - Full type safety +- [AI SDK](https://ai-sdk.dev/) - The AI Toolkit for TypeScript +- [AI Gateway](https://vercel.com/ai-gateway) - Unified access to 120+ AI models +- [Better Auth](https://www.better-auth.com) - Authentication & authorization +- [Drizzle ORM](https://orm.drizzle.team) - Type-safe database queries +- [PostgreSQL](https://www.postgresql.org) - Primary database +- [Redis](https://redis.io) - Caching & resumable streams +- [Vercel Blob](https://vercel.com/storage/blob) - Blob storage +- [Shadcn/UI](https://ui.shadcn.com) - Beautiful, accessible components +- [Tailwind CSS](https://tailwindcss.com) - Styling +- [tRPC](https://trpc.io) - End-to-end type-safe APIs +- [Zod](https://zod.dev) - Schema validation +- [Zustand](https://docs.pmnd.rs/zustand) - State management +- [Motion](https://motion.dev) - Animations +- [t3-env](https://env.t3.gg) - Environment variables +- [Pino](https://getpino.io) - Structured Logging +- [Langfuse](https://langfuse.com) - LLM observability & analytics +- [Vercel Analytics](https://vercel.com/analytics) - Web analytics +- [Biome](https://biomejs.dev) - Code linting and formatting +- [Ultracite](https://ultracite.ai) - Biome preset for humans and AI +- [Streamdown](https://streamdown.ai/) - Markdown for AI streaming +- [AI Elements](https://ai-sdk.dev/elements/overview) - AI-native Components +- [AI SDK Tools](https://ai-sdk-tools.dev/) - Developer tools for AI SDK + +## Monorepo Layout + +- `apps/chat`: Next.js chat app +- `apps/docs`: Mintlify docs +- `packages/cli`: interactive scaffold CLI + +## Development + +- `bun dev:chat`: run chat app +- `bun dev:docs`: run docs +- `bun lint`: run workspace lint +- `bun test:types`: run chat app typecheck + +## Documentation + +Visit [chatjs.dev/docs](https://chatjs.dev/docs) to view docs. + +## License + +Apache-2.0 + +
+ + Vercel OSS Program + +
diff --git a/dashboard/apps/web/.env.example b/dashboard/apps/web/.env.example new file mode 100644 index 0000000..6f2d1a7 --- /dev/null +++ b/dashboard/apps/web/.env.example @@ -0,0 +1,12 @@ +# Database +DATABASE_URL=postgresql://localhost:5432/symphony_dashboard + +# Auth +AUTH_SECRET=change-me-to-a-random-string + +# Symphony daemon +SYMPHONY_API_URL=http://localhost:8080 +SYMPHONY_API_TOKEN= + +# App URL (for non-localhost deployments) +APP_URL=http://localhost:3000 diff --git a/dashboard/apps/web/.gitignore b/dashboard/apps/web/.gitignore new file mode 100644 index 0000000..341f5c1 --- /dev/null +++ b/dashboard/apps/web/.gitignore @@ -0,0 +1,52 @@ + +# START Skiller Generated Files +/.agents/skills +/.aider.conf.yml +/.aider.conf.yml.bak +/.amazonq/mcp.json +/.amazonq/mcp.json.bak +/.amazonq/rules/skiller_q_rules.md +/.amazonq/rules/skiller_q_rules.md.bak +/.augment/rules/skiller_augment_instructions.md +/.augment/rules/skiller_augment_instructions.md.bak +/.clinerules +/.clinerules.bak +/.codex/config.toml +/.codex/config.toml.bak +/.codex/skills +/.crush.json +/.crush.json.bak +/.gemini/skills +/.goosehints +/.goosehints.bak +/.idx/airules.md +/.idx/airules.md.bak +/.junie/guidelines.md +/.junie/guidelines.md.bak +/.kilocode/rules/skiller_kilocode_instructions.md +/.kilocode/rules/skiller_kilocode_instructions.md.bak +/.kiro/steering/skiller_kiro_instructions.md +/.kiro/steering/skiller_kiro_instructions.md.bak +/.opencode/skill +/.openhands/microagents/repo.md +/.openhands/microagents/repo.md.bak +/.roo/mcp.json +/.roo/mcp.json.bak +/.roo/skills +/.trae/rules/project_rules.md +/.trae/rules/project_rules.md.bak +/AGENTS.md +/AGENTS.md.bak +/CLAUDE.md +/CLAUDE.md.bak +/CRUSH.md +/CRUSH.md.bak +/WARP.md +/WARP.md.bak +/firebender.json +/firebender.json.bak +/opencode.json +/opencode.json.bak +# END Skiller Generated Files +.vercel +.devtools diff --git a/dashboard/apps/web/app/(dashboard)/controls/page.tsx b/dashboard/apps/web/app/(dashboard)/controls/page.tsx new file mode 100644 index 0000000..4bba18c --- /dev/null +++ b/dashboard/apps/web/app/(dashboard)/controls/page.tsx @@ -0,0 +1,128 @@ +"use client"; + +import { useTRPC } from "@/trpc/react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { ConnectionIndicator } from "@/components/dashboard/connection-indicator"; +import { toast } from "sonner"; +import { Power, RefreshCw } from "lucide-react"; + +export default function ControlsPage() { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + + const refreshMutation = useMutation( + trpc.symphony.refresh.mutationOptions({ + onSuccess: (data) => { + toast.success( + data.coalesced + ? "Refresh already queued (coalesced)" + : "Refresh triggered successfully" + ); + queryClient.invalidateQueries({ queryKey: [["symphony"]] }); + }, + onError: (error) => { + toast.error(`Refresh failed: ${error.message}`); + }, + }) + ); + + const shutdownMutation = useMutation( + trpc.symphony.shutdown.mutationOptions({ + onSuccess: () => { + toast.success("Shutdown initiated"); + }, + onError: (error) => { + toast.error(`Shutdown failed: ${error.message}`); + }, + }) + ); + + return ( +
+
+
+

Controls

+

+ Manage the Symphony daemon +

+
+ +
+ +
+ + + + + Trigger Poll + + + Force an immediate tracker poll cycle. Safe to call at any time — + duplicate requests are coalesced. + + + + + + + + + + + + Shutdown + + + Initiate a graceful shutdown of the Symphony daemon. Running agents + will be allowed to complete their current turn. + + + + + + + + + + Confirm shutdown + + This will gracefully shut down the Symphony daemon. Running + agents will finish their current turn before stopping. You + will need to restart the daemon manually. + + + + Cancel + shutdownMutation.mutate()} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + Confirm Shutdown + + + + + + +
+
+ ); +} diff --git a/dashboard/apps/web/app/(dashboard)/issues/[identifier]/page.tsx b/dashboard/apps/web/app/(dashboard)/issues/[identifier]/page.tsx new file mode 100644 index 0000000..de9dc97 --- /dev/null +++ b/dashboard/apps/web/app/(dashboard)/issues/[identifier]/page.tsx @@ -0,0 +1,154 @@ +"use client"; + +import { useTRPC } from "@/trpc/react"; +import { useQuery } from "@tanstack/react-query"; +import { useParams } from "next/navigation"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { StatusBadge } from "@/components/dashboard/status-badge"; +import { ArrowLeft } from "lucide-react"; +import Link from "next/link"; + +export default function IssueDetailPage() { + const params = useParams<{ identifier: string }>(); + const trpc = useTRPC(); + const { data, isLoading, error } = useQuery( + trpc.symphony.getIssue.queryOptions( + { identifier: params.identifier }, + { refetchInterval: 5000 } + ) + ); + + if (isLoading) { + return ( +
+
+
+
+ ); + } + + if (error) { + return ( +
+ + Back to issues + + + +

+ Error loading issue: {error.message} +

+
+
+
+ ); + } + + if (!data) return null; + + const isRetrying = "status" in data && (data as { status: string }).status === "retrying"; + // Type-narrow for the running case + const running = !isRetrying ? (data as import("@symphony/client").IssueDetailRunning) : null; + const retrying = isRetrying ? (data as import("@symphony/client").IssueDetailRetrying) : null; + + return ( +
+
+ + Back + +

{params.identifier}

+ +
+ + + + Details + + +
+
+
+ Identifier +
+
{data.identifier}
+
+ {retrying ? ( + <> +
+
+ Attempt +
+
{retrying.attempt}
+
+
+
+ Due At +
+
+ {new Date(retrying.due_at_ms).toLocaleString()} +
+
+ {retrying.error && ( +
+
+ Error +
+
{retrying.error}
+
+ )} + + ) : running ? ( + <> +
+
+ State +
+
{running.state}
+
+
+
+ Session ID +
+
+ {running.session_id ?? "—"} +
+
+
+
+ Started At +
+
+ {new Date(running.started_at).toLocaleString()} +
+
+
+
+ Turn Count +
+
{running.turn_count}
+
+
+
+ Tokens +
+
+ {running.tokens.input_tokens.toLocaleString()} in /{" "} + {running.tokens.output_tokens.toLocaleString()} out /{" "} + {running.tokens.total_tokens.toLocaleString()} total +
+
+ + ) : null} +
+
+
+
+ ); +} diff --git a/dashboard/apps/web/app/(dashboard)/issues/page.tsx b/dashboard/apps/web/app/(dashboard)/issues/page.tsx new file mode 100644 index 0000000..8a3995d --- /dev/null +++ b/dashboard/apps/web/app/(dashboard)/issues/page.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { useSymphonyState } from "@/hooks/use-symphony-state"; +import { IssuesTable } from "@/components/dashboard/issues-table"; + +export default function IssuesPage() { + const { data: state, isLoading } = useSymphonyState(); + + return ( +
+
+

Issues

+

+ Active and retrying issue sessions +

+
+ + {isLoading ? ( +
+ ) : ( + + )} +
+ ); +} diff --git a/dashboard/apps/web/app/(dashboard)/layout.tsx b/dashboard/apps/web/app/(dashboard)/layout.tsx new file mode 100644 index 0000000..e649a07 --- /dev/null +++ b/dashboard/apps/web/app/(dashboard)/layout.tsx @@ -0,0 +1,20 @@ +import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"; +import { AppSidebar } from "@/components/app-sidebar"; +import { TRPCReactProvider } from "@/trpc/react"; + +export default function DashboardLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + + +
{children}
+
+
+
+ ); +} diff --git a/dashboard/apps/web/app/(dashboard)/metrics/page.tsx b/dashboard/apps/web/app/(dashboard)/metrics/page.tsx new file mode 100644 index 0000000..ac43eef --- /dev/null +++ b/dashboard/apps/web/app/(dashboard)/metrics/page.tsx @@ -0,0 +1,126 @@ +"use client"; + +import { useSymphonyMetrics } from "@/hooks/use-symphony-metrics"; +import { StatCard } from "@/components/dashboard/stat-card"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + BarChart3, + Clock, + Cpu, + Hash, + Layers, + RefreshCw, + Zap, +} from "lucide-react"; + +export default function MetricsPage() { + const { data: metrics, isLoading } = useSymphonyMetrics(); + + if (isLoading) { + return ( +
+

Metrics

+
+ {Array.from({ length: 6 }).map((_, i) => ( +
+ ))} +
+
+ ); + } + + if (!metrics) return null; + + return ( +
+
+

Metrics

+

+ Token usage, concurrency, and configuration +

+
+ +
+

Token Usage

+
+ + + +
+
+ +
+

Sessions

+
+ + + +
+
+ +
+

Configuration

+
+ + +
+
+ + + + + + Runtime + + + +

+ {(metrics.totals.seconds_running / 60).toFixed(1)} minutes +

+

+ ({metrics.totals.seconds_running.toFixed(0)}s total agent runtime) +

+
+
+ +

+ Snapshot at {new Date(metrics.timestamp).toLocaleString()} +

+
+ ); +} diff --git a/dashboard/apps/web/app/(dashboard)/overview/page.tsx b/dashboard/apps/web/app/(dashboard)/overview/page.tsx new file mode 100644 index 0000000..206d42a --- /dev/null +++ b/dashboard/apps/web/app/(dashboard)/overview/page.tsx @@ -0,0 +1,120 @@ +"use client"; + +import { useSymphonyState } from "@/hooks/use-symphony-state"; +import { useSymphonyMetrics } from "@/hooks/use-symphony-metrics"; +import { StatCard } from "@/components/dashboard/stat-card"; +import { ConnectionIndicator } from "@/components/dashboard/connection-indicator"; +import { TokenChart } from "@/components/dashboard/token-chart"; +import { + Activity, + Clock, + Cpu, + Hash, + RefreshCw, + Zap, +} from "lucide-react"; +import { useRef } from "react"; + +export default function OverviewPage() { + const { data: state, isLoading: stateLoading } = useSymphonyState(); + const { data: metrics } = useSymphonyMetrics(); + + // Accumulate token history for chart + const tokenHistory = useRef< + { time: string; input_tokens: number; output_tokens: number }[] + >([]); + + if (metrics) { + const now = new Date().toLocaleTimeString(); + const last = tokenHistory.current[tokenHistory.current.length - 1]; + if (!last || last.time !== now) { + tokenHistory.current = [ + ...tokenHistory.current.slice(-30), + { + time: now, + input_tokens: metrics.totals.input_tokens, + output_tokens: metrics.totals.output_tokens, + }, + ]; + } + } + + return ( +
+
+
+

Overview

+

+ Symphony orchestration dashboard +

+
+ +
+ + {stateLoading ? ( +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ ))} +
+ ) : ( + <> +
+ + + + +
+ + {metrics && ( +
+ + +
+ )} + + + + )} + + {state?.generated_at && ( +

+ Last updated: {new Date(state.generated_at).toLocaleString()} +

+ )} +
+ ); +} diff --git a/dashboard/apps/web/app/(dashboard)/page.tsx b/dashboard/apps/web/app/(dashboard)/page.tsx new file mode 100644 index 0000000..09401c2 --- /dev/null +++ b/dashboard/apps/web/app/(dashboard)/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation"; + +export default function DashboardRoot() { + redirect("/overview"); +} diff --git a/dashboard/apps/web/app/(dashboard)/workspaces/page.tsx b/dashboard/apps/web/app/(dashboard)/workspaces/page.tsx new file mode 100644 index 0000000..cae87ea --- /dev/null +++ b/dashboard/apps/web/app/(dashboard)/workspaces/page.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { useTRPC } from "@/trpc/react"; +import { useQuery } from "@tanstack/react-query"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { StatusBadge } from "@/components/dashboard/status-badge"; +import { FolderOpen } from "lucide-react"; + +export default function WorkspacesPage() { + const trpc = useTRPC(); + const { data: workspaces, isLoading } = useQuery( + trpc.symphony.getWorkspaces.queryOptions(undefined, { refetchInterval: 5000 }) + ); + + return ( +
+
+

Workspaces

+

+ Active workspace directories +

+
+ + {isLoading ? ( +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+ ))} +
+ ) : workspaces && workspaces.length > 0 ? ( +
+ {workspaces.map((ws) => ( + + + + {ws.name} + + + + + + ))} +
+ ) : ( + + + No active workspaces + + + )} +
+ ); +} diff --git a/dashboard/apps/web/app/api/auth/[...all]/route.ts b/dashboard/apps/web/app/api/auth/[...all]/route.ts new file mode 100644 index 0000000..83ab371 --- /dev/null +++ b/dashboard/apps/web/app/api/auth/[...all]/route.ts @@ -0,0 +1,4 @@ +import { toNextJsHandler } from "better-auth/next-js"; +import { auth } from "@/lib/auth"; + +export const { GET, POST } = toNextJsHandler(auth); diff --git a/dashboard/apps/web/app/api/trpc/[trpc]/route.ts b/dashboard/apps/web/app/api/trpc/[trpc]/route.ts new file mode 100644 index 0000000..7567aa4 --- /dev/null +++ b/dashboard/apps/web/app/api/trpc/[trpc]/route.ts @@ -0,0 +1,12 @@ +import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; +import { createTRPCContext } from "@/trpc/init"; +import { appRouter } from "@/trpc/routers/_app"; + +const handler = (req: Request) => + fetchRequestHandler({ + endpoint: "/api/trpc", + req, + router: appRouter, + createContext: createTRPCContext, + }); +export { handler as GET, handler as POST }; diff --git a/dashboard/apps/web/app/apple-icon.png b/dashboard/apps/web/app/apple-icon.png new file mode 100644 index 0000000..ee752bd Binary files /dev/null and b/dashboard/apps/web/app/apple-icon.png differ diff --git a/dashboard/apps/web/app/favicon.ico b/dashboard/apps/web/app/favicon.ico new file mode 100644 index 0000000..f6f534b Binary files /dev/null and b/dashboard/apps/web/app/favicon.ico differ diff --git a/dashboard/apps/web/app/globals.css b/dashboard/apps/web/app/globals.css new file mode 100644 index 0000000..cba6719 --- /dev/null +++ b/dashboard/apps/web/app/globals.css @@ -0,0 +1,366 @@ +@import "tailwindcss"; +@import "tw-animate-css"; + +@custom-variant dark (&:is(.dark *)); + +@theme inline { + --font-sans: var(--font-geist); + --font-mono: var(--font-geist-mono); + + --breakpoint-toast-mobile: 600px; + + --radius-lg: var(--radius); + --radius-md: calc(var(--radius) - 2px); + --radius-sm: calc(var(--radius) - 4px); + + --color-background: var(--background); + --color-foreground: var(--foreground); + + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); + + --animate-accordion-down: accordion-down 0.2s ease-out; + --animate-accordion-up: accordion-up 0.2s ease-out; + --animate-typing: typing 1.5s ease-in-out infinite; + --animate-loading-dots: loading-dots 1.4s ease-in-out infinite; + --animate-wave: wave 1s ease-in-out infinite; + --animate-blink: blink 1s ease-in-out infinite; + --animate-text-blink: text-blink 1s ease-in-out infinite; + --animate-bounce-dots: bounce-dots 1.4s ease-in-out infinite; + --animate-thin-pulse: thin-pulse 2s ease-in-out infinite; + --animate-pulse-dot: pulse-dot 1.5s ease-in-out infinite; + --animate-shimmer-text: shimmer-text 2s linear infinite; + --animate-wave-bars: wave-bars 1s ease-in-out infinite; + --animate-shimmer: shimmer 2s linear infinite; + --animate-spinner-fade: spinner-fade 0.8s ease-in-out infinite; + + @keyframes accordion-down { + from { + height: 0; + } + to { + height: var(--radix-accordion-content-height); + } + } + @keyframes accordion-up { + from { + height: var(--radix-accordion-content-height); + } + to { + height: 0; + } + } + @keyframes typing { + 0%, + 100% { + transform: translateY(0); + opacity: 0.5; + } + 50% { + transform: translateY(-2px); + opacity: 1; + } + } + @keyframes loading-dots { + 0%, + 100% { + opacity: 0; + } + 50% { + opacity: 1; + } + } + @keyframes wave { + 0%, + 100% { + transform: scaleY(1); + } + 50% { + transform: scaleY(0.6); + } + } + @keyframes blink { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0; + } + } + @keyframes text-blink { + 0%, + 100% { + color: var(--primary); + } + 50% { + color: var(--muted-foreground); + } + } + @keyframes bounce-dots { + 0%, + 100% { + transform: scale(0.8); + opacity: 0.5; + } + 50% { + transform: scale(1.2); + opacity: 1; + } + } + @keyframes thin-pulse { + 0%, + 100% { + transform: scale(0.95); + opacity: 0.8; + } + 50% { + transform: scale(1.05); + opacity: 0.4; + } + } + @keyframes pulse-dot { + 0%, + 100% { + transform: scale(1); + opacity: 0.8; + } + 50% { + transform: scale(1.5); + opacity: 1; + } + } + @keyframes shimmer-text { + 0% { + background-position: 150% center; + } + 100% { + background-position: -150% center; + } + } + @keyframes wave-bars { + 0%, + 100% { + transform: scaleY(1); + opacity: 0.5; + } + 50% { + transform: scaleY(0.6); + opacity: 1; + } + } + @keyframes shimmer { + 0% { + background-position: 200% 50%; + } + 100% { + background-position: -200% 50%; + } + } + @keyframes spinner-fade { + 0% { + opacity: 0; + } + 100% { + opacity: 1; + } + } +} + +/* + The default border color has changed to `currentcolor` in Tailwind CSS v4, + so we've added these compatibility styles to make sure everything still + looks the same as it did with Tailwind CSS v3. + + If we ever want to remove these styles, we need to add an explicit border + color utility to any element that depends on these defaults. +*/ +@layer base { + *, + ::after, + ::before, + ::backdrop, + ::file-selector-button { + border-color: var(--color-gray-200, currentcolor); + } +} + +@utility text-balance { + text-wrap: balance; +} + +@layer utilities { + :root { + --foreground-rgb: 0, 0, 0; + --background-start-rgb: 214, 219, 220; + --background-end-rgb: 255, 255, 255; + } + + @media (prefers-color-scheme: dark) { + :root { + --foreground-rgb: 255, 255, 255; + --background-start-rgb: 0, 0, 0; + --background-end-rgb: 0, 0, 0; + } + } +} + +:root { + --background: hsl(0 0% 97.0392%); + --foreground: hsl(0 0% 20%); + --card: hsl(0 0% 100%); + --card-foreground: hsl(0 0% 20%); + --popover: hsl(0 0% 100%); + --popover-foreground: hsl(0 0% 20%); + --primary: hsl(217.2193 91.2195% 59.8039%); + --primary-foreground: hsl(0 0% 100%); + --secondary: hsl(220 14.2857% 95.8824%); + --secondary-foreground: hsl(215 13.7931% 34.1176%); + --muted: hsl(210 20% 98.0392%); + --muted-foreground: hsl(220 8.9362% 46.0784%); + --accent: hsl(204 93.75% 93.7255%); + --accent-foreground: hsl(224.4444 64.2857% 32.9412%); + --destructive: hsl(0 84.2365% 60.1961%); + --destructive-foreground: hsl(0 0% 100%); + --border: hsl(220 13.0435% 90.9804%); + --input: hsl(220 13.0435% 90.9804%); + --ring: hsl(217.2193 91.2195% 59.8039%); + --chart-1: hsl(217.2193 91.2195% 59.8039%); + --chart-2: hsl(221.2121 83.1933% 53.3333%); + --chart-3: hsl(224.2781 76.3265% 48.0392%); + --chart-4: hsl(225.931 70.7317% 40.1961%); + --chart-5: hsl(224.4444 64.2857% 32.9412%); + --sidebar: hsl(210 20% 98.0392%); + --sidebar-foreground: hsl(0 0% 20%); + --sidebar-primary: hsl(217.2193 91.2195% 59.8039%); + --sidebar-primary-foreground: hsl(0 0% 100%); + --sidebar-accent: hsl(204 93.75% 93.7255%); + --sidebar-accent-foreground: hsl(224.4444 64.2857% 32.9412%); + --sidebar-border: hsl(220 13.0435% 90.9804%); + --sidebar-ring: hsl(217.2193 91.2195% 59.8039%); + --font-sans: Inter, sans-serif; + --font-serif: Source Serif 4, serif; + --font-mono: JetBrains Mono, monospace; + --radius: 0.55rem; + --shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05); + --shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05); + --shadow-sm: + 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 1px 2px -1px hsl(0 0% 0% / 0.1); + --shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 1px 2px -1px hsl(0 0% 0% / 0.1); + --shadow-md: + 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 2px 4px -1px hsl(0 0% 0% / 0.1); + --shadow-lg: + 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 4px 6px -1px hsl(0 0% 0% / 0.1); + --shadow-xl: + 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 8px 10px -1px hsl(0 0% 0% / 0.1); + --shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25); + --tracking-normal: 0em; + --spacing: 0.25rem; +} + +.dark { + --background: hsl(0 0% 9.0196%); + --foreground: hsl(0 0% 89.8039%); + --card: hsl(0 0% 14.902%); + --card-foreground: hsl(0 0% 89.8039%); + --popover: hsl(0 0% 14.902%); + --popover-foreground: hsl(0 0% 89.8039%); + --primary: hsl(217.2193 91.2195% 59.8039%); + --primary-foreground: hsl(0 0% 100%); + --secondary: hsl(0 0% 14.902%); + --secondary-foreground: hsl(0 0% 89.8039%); + --muted: hsl(0 0% 14.902%); + --muted-foreground: hsl(0 0% 63.9216%); + --accent: hsl(224.4444 64.2857% 32.9412%); + --accent-foreground: hsl(213.3333 96.9231% 87.2549%); + --destructive: hsl(0 84.2365% 60.1961%); + --destructive-foreground: hsl(0 0% 100%); + --border: hsl(0 0% 25.098%); + --input: hsl(0 0% 25.098%); + --ring: hsl(217.2193 91.2195% 59.8039%); + --chart-1: hsl(213.1169 93.9024% 67.8431%); + --chart-2: hsl(217.2193 91.2195% 59.8039%); + --chart-3: hsl(221.2121 83.1933% 53.3333%); + --chart-4: hsl(224.2781 76.3265% 48.0392%); + --chart-5: hsl(225.931 70.7317% 40.1961%); + --sidebar: hsl(0 0% 14.902%); + --sidebar-foreground: hsl(0 0% 89.8039%); + --sidebar-primary: hsl(217.2193 91.2195% 59.8039%); + --sidebar-primary-foreground: hsl(0 0% 100%); + --sidebar-accent: hsl(224.4444 64.2857% 32.9412%); + --sidebar-accent-foreground: hsl(213.3333 96.9231% 87.2549%); + --sidebar-border: hsl(0 0% 25.098%); + --sidebar-ring: hsl(217.2193 91.2195% 59.8039%); + --font-sans: Inter, sans-serif; + --font-serif: Source Serif 4, serif; + --font-mono: JetBrains Mono, monospace; + --radius: 0.55rem; + --shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05); + --shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05); + --shadow-sm: + 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 1px 2px -1px hsl(0 0% 0% / 0.1); + --shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 1px 2px -1px hsl(0 0% 0% / 0.1); + --shadow-md: + 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 2px 4px -1px hsl(0 0% 0% / 0.1); + --shadow-lg: + 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 4px 6px -1px hsl(0 0% 0% / 0.1); + --shadow-xl: + 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 8px 10px -1px hsl(0 0% 0% / 0.1); + --shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25); +} + +@layer base { + * { + @apply border-border; + } + + body { + @apply bg-background text-foreground; + } +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/dashboard/apps/web/app/icon.svg b/dashboard/apps/web/app/icon.svg new file mode 100644 index 0000000..4d3c402 --- /dev/null +++ b/dashboard/apps/web/app/icon.svg @@ -0,0 +1,3 @@ +v2 \ No newline at end of file diff --git a/dashboard/apps/web/app/layout.tsx b/dashboard/apps/web/app/layout.tsx new file mode 100644 index 0000000..6917038 --- /dev/null +++ b/dashboard/apps/web/app/layout.tsx @@ -0,0 +1,54 @@ +import type { Metadata } from "next"; +import { Geist, Geist_Mono } from "next/font/google"; +import { ThemeProvider } from "@/components/theme-provider"; +import { Toaster } from "sonner"; + +import "./globals.css"; + +export const metadata: Metadata = { + title: "Symphony Dashboard", + description: "Orchestration dashboard for Symphony coding agents", +}; + +export const viewport = { + maximumScale: 1, + interactiveWidget: "resizes-content" as const, +}; + +const geist = Geist({ + subsets: ["latin"], + display: "swap", + variable: "--font-geist", +}); + +const geistMono = Geist_Mono({ + subsets: ["latin"], + display: "swap", + variable: "--font-geist-mono", +}); + +export default async function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + + + {children} + + + + ); +} diff --git a/dashboard/apps/web/app/opengraph-image.png b/dashboard/apps/web/app/opengraph-image.png new file mode 100644 index 0000000..a5c14d6 Binary files /dev/null and b/dashboard/apps/web/app/opengraph-image.png differ diff --git a/dashboard/apps/web/biome.jsonc b/dashboard/apps/web/biome.jsonc new file mode 100644 index 0000000..e958b67 --- /dev/null +++ b/dashboard/apps/web/biome.jsonc @@ -0,0 +1,26 @@ +{ + "extends": ["ultracite/core", "ultracite/react", "ultracite/next"], + "files": { + "includes": [ + "!.next", + "!.devtools", + "!node_modules", + "!playwright-report", + "!test-results", + "!components/ui", + "!components/ai-elements", + "!components/streamdown", + "!components/stick-to-bottom", + "!lib/utils.ts", + "!hooks/use-mobile.ts" + ] + }, + "linter": { + "rules": { + "suspicious": { + /* Needs more work to fix */ + "noExplicitAny": "off" + } + } + } +} diff --git a/dashboard/apps/web/components.json b/dashboard/apps/web/components.json new file mode 100644 index 0000000..bd0ab8b --- /dev/null +++ b/dashboard/apps/web/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "app/globals.css", + "baseColor": "zinc", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + } +} diff --git a/dashboard/apps/web/components/app-sidebar.tsx b/dashboard/apps/web/components/app-sidebar.tsx new file mode 100644 index 0000000..593d4b0 --- /dev/null +++ b/dashboard/apps/web/components/app-sidebar.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { + Activity, + BarChart3, + FolderOpen, + LayoutDashboard, + ListTodo, + Settings, +} from "lucide-react"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; + +import { + Sidebar, + SidebarContent, + SidebarGroup, + SidebarGroupContent, + SidebarGroupLabel, + SidebarHeader, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarFooter, +} from "@/components/ui/sidebar"; +import { ConnectionIndicator } from "@/components/dashboard/connection-indicator"; + +const navItems = [ + { title: "Overview", href: "/overview" as const, icon: LayoutDashboard }, + { title: "Issues", href: "/issues" as const, icon: ListTodo }, + { title: "Workspaces", href: "/workspaces" as const, icon: FolderOpen }, + { title: "Metrics", href: "/metrics" as const, icon: BarChart3 }, + { title: "Controls", href: "/controls" as const, icon: Settings }, +] as const; + +export function AppSidebar() { + const pathname = usePathname(); + + return ( + + + + + Symphony + + + + + Navigation + + + {navItems.map((item) => ( + + + + + {item.title} + + + + ))} + + + + + + + + + ); +} diff --git a/dashboard/apps/web/components/dashboard/connection-indicator.tsx b/dashboard/apps/web/components/dashboard/connection-indicator.tsx new file mode 100644 index 0000000..24bcedc --- /dev/null +++ b/dashboard/apps/web/components/dashboard/connection-indicator.tsx @@ -0,0 +1,26 @@ +"use client"; + +import { useConnectionStatus } from "@/hooks/use-connection-status"; +import { cn } from "@/lib/utils"; + +export function ConnectionIndicator() { + const { isOnline, isLoading } = useConnectionStatus(); + + return ( +
+
+ + {isLoading ? "Connecting..." : isOnline ? "Connected" : "Disconnected"} + +
+ ); +} diff --git a/dashboard/apps/web/components/dashboard/issues-table.tsx b/dashboard/apps/web/components/dashboard/issues-table.tsx new file mode 100644 index 0000000..a6f6ba1 --- /dev/null +++ b/dashboard/apps/web/components/dashboard/issues-table.tsx @@ -0,0 +1,122 @@ +"use client"; + +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { StatusBadge } from "./status-badge"; +import type { RunningInfo, RetryingInfo } from "@symphony/client"; +import Link from "next/link"; + +interface IssuesTableProps { + running: RunningInfo[]; + retrying: RetryingInfo[]; +} + +export function IssuesTable({ running, retrying }: IssuesTableProps) { + return ( +
+
+

+ Running ({running.length}) +

+
+ + + + Identifier + State + Session + Turns + Tokens + + + + {running.length === 0 ? ( + + + No running issues + + + ) : ( + running.map((issue) => ( + + + + {issue.identifier} + + + + + + + {issue.session_id ?? "\u2014"} + + {issue.turn_count} + + {issue.tokens.total_tokens.toLocaleString()} + + + )) + )} + +
+
+
+ +
+

+ Retrying ({retrying.length}) +

+
+ + + + Identifier + Attempt + Due At + Error + + + + {retrying.length === 0 ? ( + + + No retrying issues + + + ) : ( + retrying.map((issue) => ( + + + + {issue.identifier} + + + {issue.attempt} + + {new Date(issue.due_at_ms).toLocaleString()} + + + {issue.error ?? "\u2014"} + + + )) + )} + +
+
+
+
+ ); +} diff --git a/dashboard/apps/web/components/dashboard/stat-card.tsx b/dashboard/apps/web/components/dashboard/stat-card.tsx new file mode 100644 index 0000000..75abff4 --- /dev/null +++ b/dashboard/apps/web/components/dashboard/stat-card.tsx @@ -0,0 +1,26 @@ +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import type { LucideIcon } from "lucide-react"; + +interface StatCardProps { + title: string; + value: string | number; + description?: string; + icon?: LucideIcon; +} + +export function StatCard({ title, value, description, icon: Icon }: StatCardProps) { + return ( + + + {title} + {Icon && } + + +
{value}
+ {description && ( +

{description}

+ )} +
+
+ ); +} diff --git a/dashboard/apps/web/components/dashboard/status-badge.tsx b/dashboard/apps/web/components/dashboard/status-badge.tsx new file mode 100644 index 0000000..e14d3f5 --- /dev/null +++ b/dashboard/apps/web/components/dashboard/status-badge.tsx @@ -0,0 +1,24 @@ +import { Badge } from "@/components/ui/badge"; + +type Status = "running" | "retrying" | "offline" | "online" | "idle"; + +const statusColors: Record = { + running: "bg-green-500/10 text-green-500 border-green-500/20", + retrying: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20", + offline: "bg-red-500/10 text-red-500 border-red-500/20", + online: "bg-green-500/10 text-green-500 border-green-500/20", + idle: "bg-gray-500/10 text-gray-500 border-gray-500/20", +}; + +interface StatusBadgeProps { + status: Status; + label?: string; +} + +export function StatusBadge({ status, label }: StatusBadgeProps) { + return ( + + {label ?? status} + + ); +} diff --git a/dashboard/apps/web/components/dashboard/token-chart.tsx b/dashboard/apps/web/components/dashboard/token-chart.tsx new file mode 100644 index 0000000..d037f1e --- /dev/null +++ b/dashboard/apps/web/components/dashboard/token-chart.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { + Area, + AreaChart, + CartesianGrid, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; + +interface TokenDataPoint { + time: string; + input_tokens: number; + output_tokens: number; +} + +interface TokenChartProps { + data: TokenDataPoint[]; +} + +export function TokenChart({ data }: TokenChartProps) { + return ( + + + Token Usage + + + + + + + + + + + + + + + ); +} diff --git a/dashboard/apps/web/components/icons.tsx b/dashboard/apps/web/components/icons.tsx new file mode 100644 index 0000000..5c05830 --- /dev/null +++ b/dashboard/apps/web/components/icons.tsx @@ -0,0 +1,40 @@ +export const GitIcon = ({ size = 16 }: { size?: number }) => ( + + Git + + + + + + + + + +); + +export const SummarizeIcon = ({ size = 16 }: { size?: number }) => ( + + Summarize + + +); diff --git a/dashboard/apps/web/components/theme-provider.tsx b/dashboard/apps/web/components/theme-provider.tsx new file mode 100644 index 0000000..1546e32 --- /dev/null +++ b/dashboard/apps/web/components/theme-provider.tsx @@ -0,0 +1,9 @@ +"use client"; + +import { ThemeProvider as NextThemesProvider } from "next-themes"; + +type NextProviderProps = Parameters[0]; + +export function ThemeProvider({ children, ...props }: NextProviderProps) { + return {children}; +} diff --git a/dashboard/apps/web/components/ui/accordion.tsx b/dashboard/apps/web/components/ui/accordion.tsx new file mode 100644 index 0000000..5a21ff5 --- /dev/null +++ b/dashboard/apps/web/components/ui/accordion.tsx @@ -0,0 +1,58 @@ +"use client"; + +import * as AccordionPrimitive from "@radix-ui/react-accordion"; +import { ChevronDown } from "lucide-react"; +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +const Accordion = AccordionPrimitive.Root; + +const AccordionItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AccordionItem.displayName = "AccordionItem"; + +const AccordionTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + svg]:rotate-180", + className + )} + ref={ref} + {...props} + > + {children} + + + +)); +AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName; + +const AccordionContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + +
{children}
+
+)); + +AccordionContent.displayName = AccordionPrimitive.Content.displayName; + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }; diff --git a/dashboard/apps/web/components/ui/alert-dialog.tsx b/dashboard/apps/web/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..561ffc8 --- /dev/null +++ b/dashboard/apps/web/components/ui/alert-dialog.tsx @@ -0,0 +1,156 @@ +"use client"; + +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"; +import type * as React from "react"; +import { buttonVariants } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +function AlertDialog({ + ...props +}: React.ComponentProps) { + return ; +} + +function AlertDialogTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogPortal({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + ); +} + +function AlertDialogHeader({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function AlertDialogFooter({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function AlertDialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogAction({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogCancel({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +}; diff --git a/dashboard/apps/web/components/ui/alert.tsx b/dashboard/apps/web/components/ui/alert.tsx new file mode 100644 index 0000000..7be1e97 --- /dev/null +++ b/dashboard/apps/web/components/ui/alert.tsx @@ -0,0 +1,59 @@ +import { cva, type VariantProps } from "class-variance-authority"; +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +const alertVariants = cva( + "relative w-full rounded-lg border p-4 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:top-4 [&>svg]:left-4 [&>svg]:text-foreground [&>svg~*]:pl-7", + { + variants: { + variant: { + default: "bg-background text-foreground", + destructive: + "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", + }, + }, + defaultVariants: { + variant: "default", + }, + } +); + +const Alert = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)); +Alert.displayName = "Alert"; + +const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +AlertTitle.displayName = "AlertTitle"; + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +AlertDescription.displayName = "AlertDescription"; + +export { Alert, AlertTitle, AlertDescription }; diff --git a/dashboard/apps/web/components/ui/avatar.tsx b/dashboard/apps/web/components/ui/avatar.tsx new file mode 100644 index 0000000..0771be3 --- /dev/null +++ b/dashboard/apps/web/components/ui/avatar.tsx @@ -0,0 +1,50 @@ +"use client"; + +import * as AvatarPrimitive from "@radix-ui/react-avatar"; +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +const Avatar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +Avatar.displayName = AvatarPrimitive.Root.displayName; + +const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AvatarImage.displayName = AvatarPrimitive.Image.displayName; + +const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName; + +export { Avatar, AvatarImage, AvatarFallback }; diff --git a/dashboard/apps/web/components/ui/badge.tsx b/dashboard/apps/web/components/ui/badge.tsx new file mode 100644 index 0000000..a856072 --- /dev/null +++ b/dashboard/apps/web/components/ui/badge.tsx @@ -0,0 +1,36 @@ +import { cva, type VariantProps } from "class-variance-authority"; +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +const badgeVariants = cva( + "inline-flex items-center rounded-full border px-2.5 py-0.5 font-semibold text-xs transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", + secondary: + "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: + "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", + outline: "text-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +); + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ); +} + +export { Badge, badgeVariants }; diff --git a/dashboard/apps/web/components/ui/breadcrumb.tsx b/dashboard/apps/web/components/ui/breadcrumb.tsx new file mode 100644 index 0000000..77094ca --- /dev/null +++ b/dashboard/apps/web/components/ui/breadcrumb.tsx @@ -0,0 +1,115 @@ +import { Slot } from "@radix-ui/react-slot"; +import { ChevronRight, MoreHorizontal } from "lucide-react"; +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +const Breadcrumb = React.forwardRef< + HTMLElement, + React.ComponentPropsWithoutRef<"nav"> & { + separator?: React.ReactNode; + } +>(({ ...props }, ref) =>