diff --git a/.dev.vars.example b/.dev.vars.example index da963bf..6806e37 100644 --- a/.dev.vars.example +++ b/.dev.vars.example @@ -16,8 +16,7 @@ GITHUB_CLIENT_SECRET="REPLACE_WITH_YOUR_CLIENT_SECRET" APP_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\nREPLACE_WITH_YOUR_GITHUB_APP_PRIVATE_KEY_CONTENT\n-----END RSA PRIVATE KEY-----" # --- Cloudflare API --- -# Required permissions: Queues Edit for DLQ actions, Workers AI Read for -# Cloudflare model catalog discovery. +# Required permission: Workers AI Read for Cloudflare model catalog discovery. CF_ACCOUNT_ID="REPLACE_WITH_YOUR_CLOUDFLARE_ACCOUNT_ID" CF_API_TOKEN="REPLACE_WITH_CLOUDFLARE_API_TOKEN" diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..05a7337 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +version: 2 +updates: + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "weekly" + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ed0bd40..56df09a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,6 +14,9 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true +permissions: + contents: read + jobs: verify: name: Verify Stability @@ -46,10 +49,10 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: 20 cache: 'npm' diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..313d1dd --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,33 @@ +name: CodeQL + +on: + push: + branches: [main] + pull_request: + branches: [main] + schedule: + - cron: '0 0 * * 1' + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + contents: read + security-events: write + + strategy: + matrix: + language: ['javascript-typescript'] + + steps: + - name: Checkout repository + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Initialize CodeQL + uses: github/codeql-action/init@411c4c9a36b3fca4d674f06b6396b2c6d23522c6 # v3.36.3 + with: + languages: ${{ matrix.language }} + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@411c4c9a36b3fca4d674f06b6396b2c6d23522c6 # v3.36.3 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..15205df --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,28 @@ +# Changelog + +## v0.9.2 + +### What's New + +- **UI Redesign:** Full visual overhaul of the dashboard, landing page, and auth flows with an updated color system, improved dark mode, and a cleaner layout overall. +- **Code Splitting:** All pages now use `React.lazy` for async loading, reducing initial bundle size and improving load times. +- **Custom LLM Providers:** Manage custom OpenAI-compatible API providers directly from the dashboard. Rate limits are optional. No more hardcoded keys in wrangler config. +- **Cloudflare Setup Script:** A new Node.js script automates the full Cloudflare deployment setup, making self-hosting significantly easier. +- **Increased Review Capacity:** Max files processed per review raised from 15 to 100. + +### Improvements + +- Review jobs are now resumable and lease-aware, stalled reviews can recover automatically. +- Added retry logic for transient model provider failures. +- Optimized job polling with ETag caching and adaptive delays. +- Improved settings UI, error reporting, and API robustness across the board. +- Added GitHub Actions CI with a disposable test environment for the full test suite. + +### Bug Fixes + +- Fixed `APP_PRIVATE_KEY` parsing for single-line strings with literal `\n` sequences. +- Fixed database migration failures on existing deployments. +- Fixed duplicate `/api/auth/updates-email` calls on page load. +- Fixed file review status bar rendering on the dashboard. + +**Full Changelog**: https://github.com/devarshishimpi/codra/commits/v0.9.2 diff --git a/README.md b/README.md index 13d2b6b..56fa3de 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ Codra listens to GitHub pull request events, runs AI-powered review jobs, posts - **Review with repository context**: Codra checks pull request diffs for correctness, security, performance, maintainability, and repo-specific patterns. - **Configure each repository**: Tune triggers, skipped paths, draft handling, mention reviews, labels, custom rules, and review budgets from the dashboard. - **Route models deliberately**: Use global defaults, per-repo model chains, fallbacks, and size-based overrides for larger pull requests. -- **Operate the system**: Inspect job history, PR findings, webhook deliveries, queue failures, DLQ replay, model usage, and dashboard stats. +- **Operate the system**: Inspect job history, PR findings, webhook deliveries, model usage, and dashboard stats. ## Features @@ -46,7 +46,6 @@ Codra listens to GitHub pull request events, runs AI-powered review jobs, posts - Mention-triggered reviews for on-demand analysis - Inline GitHub review comments plus summary reviews and check run updates - Queue-backed processing through Cloudflare Queues -- Dead letter queue inspection, replay, and purge workflows - GitHub OAuth dashboard authentication - External PostgreSQL storage through Cloudflare Hyperdrive - Dashboard-managed LLM providers for OpenAI, OpenRouter, Anthropic, Google, and Cloudflare models @@ -59,14 +58,14 @@ Codra listens to GitHub pull request events, runs AI-powered review jobs, posts 3. A review job is stored in PostgreSQL and queued on Cloudflare Queues. 4. The Worker consumes the job, fetches the PR diff, runs model review passes, and formats findings. 5. Codra posts inline comments and a summary review back to GitHub. -6. The dashboard keeps the job history, findings, logs, stats, and replay tools available for operators. +6. The dashboard keeps the job history, findings, logs, and stats available for operators. ## Stack - **Worker**: Cloudflare Workers, Hono, Wrangler - **Dashboard**: React, Vite, Tailwind CSS, Radix UI, Recharts - **Data**: PostgreSQL, Cloudflare Hyperdrive, Cloudflare KV -- **Queues**: Cloudflare Queues with DLQ workflows +- **Queues**: Cloudflare Queues and Workflows - **Models**: OpenAI, OpenRouter, Anthropic, Google, and Cloudflare providers - **GitHub**: GitHub App webhooks, checks, reviews, and OAuth - **Quality**: TypeScript, Zod, Vitest, Playwright browser tests diff --git a/db/migrations/004_review_performance_settings.sql b/db/migrations/004_review_performance_settings.sql new file mode 100644 index 0000000..794c252 --- /dev/null +++ b/db/migrations/004_review_performance_settings.sql @@ -0,0 +1,4 @@ +INSERT INTO global_settings (key, value) VALUES + ('review_concurrency_level', 'medium'), + ('review_max_comments', '10') +ON CONFLICT (key) DO NOTHING; diff --git a/db/migrations/005_drop_model_rate_limits.sql b/db/migrations/005_drop_model_rate_limits.sql new file mode 100644 index 0000000..a0daa35 --- /dev/null +++ b/db/migrations/005_drop_model_rate_limits.sql @@ -0,0 +1,3 @@ +ALTER TABLE model_configs DROP COLUMN IF EXISTS rpm; +ALTER TABLE model_configs DROP COLUMN IF EXISTS tpm; +ALTER TABLE model_configs DROP COLUMN IF EXISTS rpd; diff --git a/db/migrations/006_job_continuation_count.sql b/db/migrations/006_job_continuation_count.sql new file mode 100644 index 0000000..2e2cc8b --- /dev/null +++ b/db/migrations/006_job_continuation_count.sql @@ -0,0 +1,5 @@ +-- Tracks how many times a job has rescheduled the *same* phase without completing any +-- file review (a "no-progress continuation"). Reset to 0 whenever a chunk makes progress. +-- A hard ceiling on this counter (see MAX_JOB_CONTINUATIONS in review.ts) stops a job that +-- can never make headway from churning indefinitely on transient/budget deferrals. +ALTER TABLE jobs ADD COLUMN IF NOT EXISTS continuation_count INT NOT NULL DEFAULT 0; diff --git a/db/migrations/007_file_review_async_batch.sql b/db/migrations/007_file_review_async_batch.sql new file mode 100644 index 0000000..456cff2 --- /dev/null +++ b/db/migrations/007_file_review_async_batch.sql @@ -0,0 +1,7 @@ +-- Supports the Workers AI asynchronous Batch API path (see submitCloudflareBatch/pollCloudflareBatch). +-- A file review submitted to the async queue is persisted with file_status = 'pending' plus the +-- queue request_id, so a later review-phase invocation can poll for the result across invocations +-- (decoupling long/reasoning-model inference from any single invocation's timeout & subrequest cap). +-- Both columns are cleared once the batch completes and the review is persisted as 'done'/'failed'. +ALTER TABLE file_reviews ADD COLUMN IF NOT EXISTS async_request_id TEXT; +ALTER TABLE file_reviews ADD COLUMN IF NOT EXISTS async_model TEXT; diff --git a/db/migrations/008_job_cancelled_status.sql b/db/migrations/008_job_cancelled_status.sql new file mode 100644 index 0000000..70555b4 --- /dev/null +++ b/db/migrations/008_job_cancelled_status.sql @@ -0,0 +1,6 @@ +-- Adds a terminal 'cancelled' status for jobs that a user explicitly stops (see the /stop +-- endpoint). Distinct from 'failed' (nothing went wrong) and 'superseded' (replaced by a newer +-- job). The status column is TEXT + CHECK (not the native enum), so we just widen the CHECK. +ALTER TABLE jobs DROP CONSTRAINT IF EXISTS jobs_status_check; +ALTER TABLE jobs ADD CONSTRAINT jobs_status_check + CHECK (status IN ('queued', 'running', 'done', 'failed', 'superseded', 'cancelled')); diff --git a/package-lock.json b/package-lock.json index 95516b1..cf5aeb8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "clsx": "^2.1.1", "hono": "^4.12.12", "jsonrepair": "^3.13.3", + "lenis": "^1.3.25", "lucide-react": "^1.8.0", "motion": "^12.42.2", "picomatch": "^4.0.4", @@ -4772,6 +4773,37 @@ "node": ">=6" } }, + "node_modules/lenis": { + "version": "1.3.25", + "resolved": "https://registry.npmjs.org/lenis/-/lenis-1.3.25.tgz", + "integrity": "sha512-mOKxayErlaONK8fm4LN3XNd99Qu4plTpn9h9qf8wxzjGrJDzuD84FYzZ81HCd6ZsWp++VWVwOzL286Pf2s2u4A==", + "license": "MIT", + "workspaces": [ + "packages/*", + "playground", + "playground/*" + ], + "funding": { + "type": "github", + "url": "https://github.com/sponsors/darkroomengineering" + }, + "peerDependencies": { + "@nuxt/kit": ">=3.0.0", + "react": ">=17.0.0", + "vue": ">=3.0.0" + }, + "peerDependenciesMeta": { + "@nuxt/kit": { + "optional": true + }, + "react": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", diff --git a/package.json b/package.json index 682e74a..2fce114 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "clsx": "^2.1.1", "hono": "^4.12.12", "jsonrepair": "^3.13.3", + "lenis": "^1.3.25", "lucide-react": "^1.8.0", "motion": "^12.42.2", "picomatch": "^4.0.4", diff --git a/scripts/migrate.mjs b/scripts/migrate.mjs index bff796f..07ae8b5 100644 --- a/scripts/migrate.mjs +++ b/scripts/migrate.mjs @@ -271,15 +271,6 @@ async function ensureModelCatalog() { await query('ALTER TABLE model_configs ADD COLUMN IF NOT EXISTS provider_id UUID'); await query('ALTER TABLE model_configs ADD COLUMN IF NOT EXISTS model_name TEXT'); - await query('ALTER TABLE model_configs ALTER COLUMN rpm DROP NOT NULL'); - await query('ALTER TABLE model_configs ALTER COLUMN tpm DROP NOT NULL'); - await query('ALTER TABLE model_configs ALTER COLUMN rpd DROP NOT NULL'); - await query(` - UPDATE model_configs - SET rpm = NULL, tpm = NULL, rpd = NULL, updated_at = now() - WHERE rpm = 1 AND tpm = 1 AND rpd = 1 - `); - await query( ` UPDATE model_configs mc @@ -317,14 +308,11 @@ async function ensureModelCatalog() { await query( ` - INSERT INTO model_configs (model_id, rpm, tpm, rpd, provider, provider_id, model_name, updated_at) - SELECT $1, 10, 131072, 300, 'cloudflare', p.id, $1, now() + INSERT INTO model_configs (model_id, provider, provider_id, model_name, updated_at) + SELECT $1, 'cloudflare', p.id, $1, now() FROM llm_providers p WHERE p.name = 'Cloudflare' ON CONFLICT (model_id) DO UPDATE SET - rpm = EXCLUDED.rpm, - tpm = EXCLUDED.tpm, - rpd = EXCLUDED.rpd, provider = EXCLUDED.provider, provider_id = EXCLUDED.provider_id, model_name = EXCLUDED.model_name, @@ -335,14 +323,11 @@ async function ensureModelCatalog() { await query( ` - INSERT INTO model_configs (model_id, rpm, tpm, rpd, provider, provider_id, model_name, updated_at) - SELECT '@cf/zai-org/glm-4.7-flash', 20, 131072, 600, 'cloudflare', p.id, '@cf/zai-org/glm-4.7-flash', now() + INSERT INTO model_configs (model_id, provider, provider_id, model_name, updated_at) + SELECT '@cf/zai-org/glm-4.7-flash', 'cloudflare', p.id, '@cf/zai-org/glm-4.7-flash', now() FROM llm_providers p WHERE p.name = 'Cloudflare' ON CONFLICT (model_id) DO UPDATE SET - rpm = EXCLUDED.rpm, - tpm = EXCLUDED.tpm, - rpd = EXCLUDED.rpd, provider = EXCLUDED.provider, provider_id = EXCLUDED.provider_id, model_name = EXCLUDED.model_name, @@ -352,8 +337,8 @@ async function ensureModelCatalog() { await query( ` - INSERT INTO model_configs (model_id, rpm, tpm, rpd, provider, provider_id, model_name, updated_at) - SELECT 'gemma-4-31b-it', 15, 1000000, 1500, 'gemini', p.id, 'gemma-4-31b-it', now() + INSERT INTO model_configs (model_id, provider, provider_id, model_name, updated_at) + SELECT 'gemma-4-31b-it', 'gemini', p.id, 'gemma-4-31b-it', now() FROM llm_providers p WHERE p.name = 'Google' ON CONFLICT (model_id) DO UPDATE SET @@ -366,8 +351,8 @@ async function ensureModelCatalog() { await query( ` - INSERT INTO model_configs (model_id, rpm, tpm, rpd, provider, provider_id, model_name, updated_at) - SELECT 'gemma-4-26b-a4b-it', 30, 1000000, 1500, 'gemini', p.id, 'gemma-4-26b-a4b-it', now() + INSERT INTO model_configs (model_id, provider, provider_id, model_name, updated_at) + SELECT 'gemma-4-26b-a4b-it', 'gemini', p.id, 'gemma-4-26b-a4b-it', now() FROM llm_providers p WHERE p.name = 'Google' ON CONFLICT (model_id) DO UPDATE SET diff --git a/src/client/app.css b/src/client/app.css index b761a31..d06acd0 100644 --- a/src/client/app.css +++ b/src/client/app.css @@ -2,6 +2,10 @@ @import "tailwindcss"; +/* Make `dark:` utilities follow the app's `.dark` class (theme toggle) + instead of the OS `prefers-color-scheme` default. */ +@custom-variant dark (&:where(.dark, .dark *)); + /* ───────────────────────────────────────────────────── Easing tokens ───────────────────────────────────────────────────── */ @@ -903,8 +907,7 @@ gap: 0.625rem !important; padding: 0.75rem 0.875rem !important; border-radius: 0.625rem !important; - border-width: 1px !important; - border-style: solid !important; + border: none !important; font-family: var(--font-sans) !important; font-size: 0.8125rem !important; line-height: 1.45 !important; @@ -915,7 +918,6 @@ /* light defaults (overridden per-variant below) */ background: oklch(99.5% 0.004 115) !important; - border-color: oklch(88% 0.008 115) !important; color: oklch(15% 0.02 115) !important; /* smooth entrance */ @@ -924,7 +926,6 @@ .dark .codra-toast { background: oklch(13% 0.018 115) !important; - border-color: oklch(22% 0.022 115) !important; color: oklch(94% 0.006 115) !important; box-shadow: 0 6px 24px oklch(0% 0 0 / 0.5), @@ -983,64 +984,9 @@ background: oklch(28% 0.022 115) !important; } -/* ── SUCCESS ─────────────────────────────────────── */ -.codra-toast-success { - background: oklch(98.5% 0.045 115) !important; - border-color: oklch(82% 0.16 115) !important; - color: oklch(28% 0.10 115) !important; -} - -.dark .codra-toast-success { - background: oklch(16% 0.08 115) !important; - border-color: oklch(32% 0.14 115) !important; - color: oklch(90% 0.18 115) !important; -} - -.codra-toast-success .codra-toast-description { - color: oklch(38% 0.10 115) !important; - opacity: 0.85 !important; -} - -.dark .codra-toast-success .codra-toast-description { - color: oklch(72% 0.12 115) !important; - opacity: 0.9 !important; -} - -/* ── ERROR ───────────────────────────────────────── */ -.codra-toast-error { - background: oklch(98.5% 0.03 25) !important; - border-color: oklch(80% 0.14 25) !important; - color: oklch(32% 0.14 25) !important; -} - -.dark .codra-toast-error { - background: oklch(15% 0.07 25) !important; - border-color: oklch(35% 0.14 25) !important; - color: oklch(85% 0.08 25) !important; -} - -.codra-toast-error .codra-toast-description { - color: oklch(42% 0.12 25) !important; - opacity: 0.85 !important; -} - -.dark .codra-toast-error .codra-toast-description { - color: oklch(68% 0.10 25) !important; - opacity: 0.9 !important; -} - -/* ── LOADING ─────────────────────────────────────── */ -.codra-toast-loading { - background: oklch(98% 0.004 115) !important; - border-color: oklch(86% 0.010 115) !important; - color: oklch(20% 0.020 115) !important; -} - -.dark .codra-toast-loading { - background: oklch(14% 0.020 115) !important; - border-color: oklch(24% 0.025 115) !important; - color: oklch(88% 0.008 115) !important; -} +/* ── SUCCESS / ERROR / LOADING ───────────────────── + Use the default toast text color instead of a + status tint (icon color already conveys status). */ /* spinner inherits accent color */ .codra-toast-loader svg { @@ -1049,26 +995,18 @@ /* ── WARNING ─────────────────────────────────────── */ .codra-toast-warning { - background: oklch(98.5% 0.04 65) !important; - border-color: oklch(82% 0.13 65) !important; - color: oklch(35% 0.12 65) !important; + color: oklch(35% 0.12 65) !important; } .dark .codra-toast-warning { - background: oklch(16% 0.08 65) !important; - border-color: oklch(35% 0.14 65) !important; - color: oklch(82% 0.14 65) !important; + color: oklch(82% 0.14 65) !important; } /* ── INFO ────────────────────────────────────────── */ .codra-toast-info { - background: oklch(98.5% 0.03 250) !important; - border-color: oklch(80% 0.12 250) !important; - color: oklch(30% 0.12 250) !important; + color: oklch(30% 0.12 250) !important; } .dark .codra-toast-info { - background: oklch(15% 0.07 250) !important; - border-color: oklch(33% 0.12 250) !important; - color: oklch(80% 0.12 250) !important; + color: oklch(80% 0.12 250) !important; } diff --git a/src/client/components/features/job-detail/file-finding.tsx b/src/client/components/features/job-detail/file-finding.tsx index c972f5e..c048610 100644 --- a/src/client/components/features/job-detail/file-finding.tsx +++ b/src/client/components/features/job-detail/file-finding.tsx @@ -23,7 +23,7 @@ export function FileFinding({ file }: FileFindingProps) {
- + {file.fileStatus === 'done' && } {file.parsedComments.length > 0 && ( {file.parsedComments.length} diff --git a/src/client/components/features/job-detail/job-findings-list.tsx b/src/client/components/features/job-detail/job-findings-list.tsx index 084eb1f..a1b4b32 100644 --- a/src/client/components/features/job-detail/job-findings-list.tsx +++ b/src/client/components/features/job-detail/job-findings-list.tsx @@ -1,8 +1,8 @@ import { useState } from 'react'; import { FileText } from 'lucide-react'; -import { cn } from '@client/lib/utils'; import type { JobDetail } from '@shared/schema'; import { reviewSeverities } from '@shared/schema'; +import { Tabs, TabsList, TabsTrigger } from '@client/components/motion/tabs'; import { FileFinding } from './file-finding'; import { CommentCard } from './comment-card'; import { severityConfig } from './constants'; @@ -24,22 +24,16 @@ export function JobFindingsList({ job }: JobFindingsListProps) {
View by -
- {(['files', 'severity'] as const).map((view) => ( - - ))} -
+ setViewBy(v as 'files' | 'severity')} variant="segment"> + + + Files + + + Severity + + +
@@ -59,6 +53,30 @@ export function JobFindingsList({ job }: JobFindingsListProps) { ) : (
+ {job.files.some((f) => f.fileStatus === 'failed') && ( +
+ {/* Group header */} +
+ + + Failed Files + + + {job.files.filter((f) => f.fileStatus === 'failed').length} + +
+ {/* Failed files list */} +
+ {job.files.filter((f) => f.fileStatus === 'failed').map((file) => ( + + ))} +
+
+ )} + {reviewSeverities.map((groupName) => { const comments = job.files.flatMap((f) => f.parsedComments diff --git a/src/client/components/features/job-detail/job-header.tsx b/src/client/components/features/job-detail/job-header.tsx index 787ff00..6256049 100644 --- a/src/client/components/features/job-detail/job-header.tsx +++ b/src/client/components/features/job-detail/job-header.tsx @@ -1,16 +1,43 @@ +import { useState } from 'react'; import { Link } from 'react-router-dom'; -import { ChevronRight, ExternalLink, RotateCcw, Terminal } from 'lucide-react'; +import { + ChevronRight, + CircleStop, + ExternalLink, + Loader2, + RotateCcw, + Terminal, + Trash2, +} from 'lucide-react'; import { Button } from '@client/components/ui/button'; +import { ConfirmDialog } from '@client/components/ui/confirm-dialog'; import { UpdatesEmailPrompt } from '@client/components/features/dashboard/updates-email-prompt'; import type { JobDetail } from '@shared/schema'; interface JobHeaderProps { job: JobDetail; - isRetrying: boolean; - onRetry: () => void; + isRerunning: boolean; + isStopping: boolean; + isDeleting: boolean; + onRerun: () => void; + onStop: () => void; + onDelete: () => void; } -export function JobHeader({ job, isRetrying, onRetry }: JobHeaderProps) { +export function JobHeader({ + job, + isRerunning, + isStopping, + isDeleting, + onRerun, + onStop, + onDelete, +}: JobHeaderProps) { + const [stopOpen, setStopOpen] = useState(false); + const [deleteOpen, setDeleteOpen] = useState(false); + + const canStop = job.status === 'running' || job.status === 'queued'; + return ( <>
@@ -43,24 +70,70 @@ export function JobHeader({ job, isRetrying, onRetry }: JobHeaderProps) {

-
- + + {/* A single re-run control. It always restarts the review from the beginning (a fresh + review of every file) and works whether the job is finished, failed, or still running. */} + + + +
+ + + + + ); diff --git a/src/client/components/features/job-detail/job-meta-cards.tsx b/src/client/components/features/job-detail/job-meta-cards.tsx index 0bc5915..61e1101 100644 --- a/src/client/components/features/job-detail/job-meta-cards.tsx +++ b/src/client/components/features/job-detail/job-meta-cards.tsx @@ -3,6 +3,7 @@ import { Link } from 'react-router-dom'; import { Card, CardContent, CardHeader, CardTitle } from '@client/components/ui/card'; import { Badge, StatusBadge } from '@client/components/ui/badge'; import type { JobDetail, JobStep } from '@shared/schema'; +import { formatDuration } from '@client/lib/utils'; interface JobMetaCardsProps { job: JobDetail; @@ -13,8 +14,9 @@ function elapsedSec(step: JobStep): string | null { const start = new Date(step.startedAt).getTime(); const end = new Date(step.finishedAt).getTime(); if (!Number.isFinite(start) || !Number.isFinite(end)) return null; - const ms = end - start; - return `${(ms / 1000).toFixed(1)}s`; + // Reuse the shared formatter so long phases roll up into minutes/hours (e.g. "6m 24s") + // instead of an unwieldy "383.7s". + return formatDuration(end - start); } return null; } diff --git a/src/client/components/motion/smooth-scroll.tsx b/src/client/components/motion/smooth-scroll.tsx new file mode 100644 index 0000000..53aa6fa --- /dev/null +++ b/src/client/components/motion/smooth-scroll.tsx @@ -0,0 +1,269 @@ +// beui.dev/components/motion/scroll-animation +import type Lenis from 'lenis'; +import { ReactLenis, useLenis } from 'lenis/react'; +import { type MotionValue, useMotionValue, useReducedMotion } from 'motion/react'; +import { + createContext, + type ReactNode, + useCallback, + useContext, + useEffect, + useMemo, + useRef, +} from 'react'; + +// Lenis' own expo-out curve — the canonical smooth-scroll easing. Kept as a +// named local fn (not a lib/ease token) because tokens are bezier control +// points for the motion lib, while Lenis needs a (t) => number easing fn. +const EASE_SCROLL = (t: number) => Math.min(1, 1.001 - 2 ** (-10 * t)); + +export type ScrollTarget = number | string | HTMLElement; + +export type ScrollToOptions = { + offset?: number; + immediate?: boolean; + duration?: number; +}; + +export type SmoothScrollApi = { + /** Underlying Lenis instance, or null on the reduced-motion / native path. */ + lenis: Lenis | null; + /** Current scroll offset in px. */ + scrollY: MotionValue; + /** Scroll position as 0..1 of the scrollable height. */ + progress: MotionValue; + /** Signed scroll velocity (px/frame); drives velocity-based effects. */ + velocity: MotionValue; + /** Programmatic smooth scroll. Respects reduced motion (jumps instantly). */ + scrollTo: (target: ScrollTarget, options?: ScrollToOptions) => void; +}; + +const SmoothScrollContext = createContext(null); + +export interface SmoothScrollProps { + children: ReactNode; + /** Drive the page (window) when true, or a contained scroll area when false. */ + root?: boolean; + /** Smoothing factor; lower is smoother and heavier. */ + lerp?: number; + /** Wheel / programmatic ease duration in seconds. */ + duration?: number; + orientation?: 'vertical' | 'horizontal'; + /** Wheel scroll speed multiplier. */ + wheelMultiplier?: number; + /** Smooth touch scrolling. Off by default — native momentum is good on mobile. */ + touch?: boolean; + className?: string; +} + +type ScrollSource = Window | HTMLElement; + +function readMetrics(target: ScrollSource) { + if (target instanceof Window) { + const max = Math.max( + 0, + document.documentElement.scrollHeight - window.innerHeight, + ); + return { y: window.scrollY, max }; + } + return { + y: target.scrollTop, + max: Math.max(0, target.scrollHeight - target.clientHeight), + }; +} + +function resolveTop( + target: ScrollTarget, + source: ScrollSource, + offset = 0, +): number { + if (typeof target === 'number') return target + offset; + if (source instanceof Window) { + const el = + typeof target === 'string' ? document.querySelector(target) : target; + if (!el) return window.scrollY; + return el.getBoundingClientRect().top + window.scrollY + offset; + } + const el = + typeof target === 'string' ? source.querySelector(target) : target; + if (!(el instanceof HTMLElement)) return source.scrollTop; + return el.offsetTop + offset; +} + +/** Pushes Lenis' live scroll state into the shared motion values. */ +function LenisBridge({ + scrollY, + progress, + velocity, + lenisRef, +}: { + scrollY: MotionValue; + progress: MotionValue; + velocity: MotionValue; + lenisRef: { current: Lenis | null }; +}) { + const lenis = useLenis((instance) => { + scrollY.set(instance.scroll); + progress.set(instance.progress); + velocity.set(instance.velocity); + }); + useEffect(() => { + lenisRef.current = lenis ?? null; + return () => { + lenisRef.current = null; + }; + }, [lenis, lenisRef]); + return null; +} + +/** Native scroll listener for the reduced-motion path and the no-provider fallback. */ +function useNativeScrollSync( + enabled: boolean, + getTarget: () => ScrollSource | null, + scrollY: MotionValue, + progress: MotionValue, + velocity: MotionValue, +) { + useEffect(() => { + if (!enabled) return; + const target = getTarget(); + if (!target) return; + let lastY = readMetrics(target).y; + let lastT = performance.now(); + const onScroll = () => { + const { y, max } = readMetrics(target); + const now = performance.now(); + const dt = now - lastT || 16; + scrollY.set(y); + progress.set(max > 0 ? y / max : 0); + velocity.set(((y - lastY) / dt) * 16); + lastY = y; + lastT = now; + }; + onScroll(); + target.addEventListener('scroll', onScroll, { passive: true }); + return () => target.removeEventListener('scroll', onScroll); + }, [enabled, getTarget, scrollY, progress, velocity]); +} + +export function SmoothScroll({ + children, + root = true, + lerp = 0.1, + duration = 1.2, + orientation = 'vertical', + wheelMultiplier = 1, + touch = false, + className, +}: SmoothScrollProps) { + const reduce = useReducedMotion(); + const scrollY = useMotionValue(0); + const progress = useMotionValue(0); + const velocity = useMotionValue(0); + const lenisRef = useRef(null); + const containerRef = useRef(null); + + const nativeSource = useCallback( + (): ScrollSource | null => (root ? window : containerRef.current), + [root], + ); + + const scrollTo = useCallback( + (target: ScrollTarget, options?: ScrollToOptions) => { + const lenis = lenisRef.current; + if (lenis && !reduce) { + lenis.scrollTo(target, { + offset: options?.offset, + duration: options?.duration, + immediate: options?.immediate, + }); + return; + } + const source = nativeSource(); + const behavior = reduce || options?.immediate ? 'auto' : 'smooth'; + const top = resolveTop(target, source ?? window, options?.offset); + (source ?? window).scrollTo({ top, behavior }); + }, + [reduce, nativeSource], + ); + + // Reduced motion drives the native listener; the Lenis path leaves it + // disabled and lets LenisBridge feed the values instead. + useNativeScrollSync(!!reduce, nativeSource, scrollY, progress, velocity); + + const api = useMemo( + () => ({ lenis: lenisRef.current, scrollY, progress, velocity, scrollTo }), + [scrollY, progress, velocity, scrollTo], + ); + + if (reduce) { + return ( + +
+ {children} +
+
+ ); + } + + return ( + + + + {children} + + + ); +} + +/** + * Read the page's smooth-scroll state. Inside it returns the + * shared motion values; outside it falls back to a native window scroll + * listener so scroll-driven components still work without the provider. + */ +export function useSmoothScroll(): SmoothScrollApi { + const ctx = useContext(SmoothScrollContext); + const scrollY = useMotionValue(0); + const progress = useMotionValue(0); + const velocity = useMotionValue(0); + + const windowSource = useCallback((): ScrollSource => window, []); + useNativeScrollSync(ctx === null, windowSource, scrollY, progress, velocity); + + const scrollTo = useCallback((target: ScrollTarget, options?: ScrollToOptions) => { + window.scrollTo({ + top: resolveTop(target, window, options?.offset), + behavior: options?.immediate ? 'auto' : 'smooth', + }); + }, []); + + const fallback = useMemo( + () => ({ lenis: null, scrollY, progress, velocity, scrollTo }), + [scrollY, progress, velocity, scrollTo], + ); + + return ctx ?? fallback; +} diff --git a/src/client/components/motion/stepped-slider.tsx b/src/client/components/motion/stepped-slider.tsx new file mode 100644 index 0000000..beb46ab --- /dev/null +++ b/src/client/components/motion/stepped-slider.tsx @@ -0,0 +1,321 @@ +// Discrete-step slider with a spring-smoothed thumb and a live value readout +// that sits statically above the track (no floating/portal-positioned +// tooltip involved). Adapted from a min/max/step numeric range slider so +// labeled steps (e.g. Low/Medium/High/Max) line up exactly on evenly spaced +// stops. +import { + motion, + useMotionTemplate, + useMotionValue, + useReducedMotion, + useSpring, + useTransform, +} from 'motion/react'; +import { + type KeyboardEvent, + type PointerEvent, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; + +import { cn } from '@client/lib/utils'; +import { useIsDarkMode } from '@client/hooks/use-is-dark-mode'; + +const SPRING_GLIDE = { stiffness: 700, damping: 50, mass: 0.5 } as const; +const SPRING_BOUNCY = { type: 'spring', stiffness: 500, damping: 14, mass: 0.7 } as const; + +// Deterministic 0..1 seed derived from the slider's id, so two sliders maxed +// out at the same time get slightly different hues and animation timing +// instead of pulsing in perfect, obviously-copy-pasted unison. +function seedFromId(id: string | undefined) { + if (!id) return 0; + let hash = 0; + for (let i = 0; i < id.length; i += 1) { + hash = (hash * 31 + id.charCodeAt(i)) >>> 0; + } + return (hash % 997) / 997; +} + +export interface SteppedSliderStep { + value: number; + label: string; +} + +export interface SteppedSliderProps { + id?: string; + value?: number; + defaultValue?: number; + onValueChange?: (value: number) => void; + min?: number; + max?: number; + step?: number; + /** Tick dots + labels rendered at each defined step. */ + steps?: SteppedSliderStep[]; + /** Live value readout shown above the track, and used as the accessible aria-valuetext. Defaults to the raw value. */ + formatValue?: (value: number) => string; + disabled?: boolean; + className?: string; + 'aria-label'?: string; + 'aria-labelledby'?: string; +} + +const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v)); + +export function SteppedSlider({ + id, + value, + defaultValue = 0, + onValueChange, + min = 0, + max = 100, + step = 1, + steps = [], + formatValue, + disabled = false, + className, + 'aria-label': ariaLabel, + 'aria-labelledby': ariaLabelledBy, +}: SteppedSliderProps) { + const reduce = useReducedMotion(); + const isDark = useIsDarkMode(); + const seed = useMemo(() => seedFromId(id), [id]); + const trackRef = useRef(null); + const [internal, setInternal] = useState(defaultValue); + const [active, setActive] = useState(false); + // Live position while dragging, decoupled from the committed value so that + // onValueChange fires exactly once per gesture (on release) instead of on + // every pointer-move tick — callers may react to a commit by opening a + // confirmation dialog, which must not re-fire while the pointer is still down. + const [dragValue, setDragValue] = useState(null); + const controlled = value !== undefined; + const committedValue = clamp(controlled ? (value as number) : internal, min, max); + const current = active && dragValue !== null ? clamp(dragValue, min, max) : committedValue; + const percent = ((current - min) / (max - min)) * 100; + const isMaxed = current === max; + + // Teal accent for the "maxed out" state, hue-shifted slightly per instance + // and timed with a per-instance offset so simultaneous max sliders read as + // one cohesive design rather than a mirrored duplicate. + const hue = 185 + (seed - 0.5) * 16; + const glowColor = isDark ? `oklch(68% 0.14 ${hue})` : `oklch(52% 0.13 ${hue})`; + const fillGradient = isDark + ? `linear-gradient(90deg, oklch(28% 0.08 ${hue}), oklch(62% 0.12 ${hue}))` + : `linear-gradient(90deg, oklch(93% 0.03 ${hue}), oklch(56% 0.13 ${hue}))`; + const dotDriftDuration = 1.4 + seed * 0.5; + const glowDuration = 1.6 + seed * 0.6; + + const target = useMotionValue(percent); + useEffect(() => { + target.set(percent); + }, [percent, target]); + const smooth = useSpring(target, SPRING_GLIDE); + const pos = reduce ? target : smooth; + const left = useMotionTemplate`${pos}%`; + const thumbX = useTransform(pos, (p) => `${-p}%`); + + const snapValue = useCallback( + (next: number) => clamp(Math.round((next - min) / step) * step + min, min, max), + [min, max, step], + ); + + const commit = useCallback( + (next: number) => { + const snapped = snapValue(next); + if (!controlled) setInternal(snapped); + onValueChange?.(snapped); + }, + [controlled, onValueChange, snapValue], + ); + + const valueFromX = useCallback( + (clientX: number) => { + const rect = trackRef.current?.getBoundingClientRect(); + if (!rect) return committedValue; + const ratio = clamp((clientX - rect.left) / rect.width, 0, 1); + return min + ratio * (max - min); + }, + [committedValue, min, max], + ); + + const onPointerDown = useCallback( + (event: PointerEvent) => { + if (disabled) return; + event.currentTarget.setPointerCapture(event.pointerId); + setActive(true); + setDragValue(snapValue(valueFromX(event.clientX))); + }, + [disabled, valueFromX, snapValue], + ); + + const onPointerMove = useCallback( + (event: PointerEvent) => { + if (!active || disabled) return; + setDragValue(snapValue(valueFromX(event.clientX))); + }, + [active, disabled, valueFromX, snapValue], + ); + + const endDrag = useCallback( + (event: PointerEvent) => { + event.currentTarget.releasePointerCapture?.(event.pointerId); + setActive(false); + setDragValue((pending) => { + if (pending !== null) commit(pending); + return null; + }); + }, + [commit], + ); + + const onKeyDown = useCallback( + (event: KeyboardEvent) => { + if (disabled) return; + const map: Record = { + ArrowRight: committedValue + step, + ArrowUp: committedValue + step, + ArrowLeft: committedValue - step, + ArrowDown: committedValue - step, + Home: min, + End: max, + }; + if (event.key in map) { + event.preventDefault(); + commit(map[event.key]); + } + }, + [disabled, committedValue, step, min, max, commit], + ); + + const valueLabel = formatValue ? formatValue(current) : String(current); + + return ( +
+ {/* live value readout — sits in normal flow above the track, always visible */} +
+ {valueLabel} +
+ +
+ {/* fill — runs from the left edge to the thumb; static teal gradient with a dot texture + steadily drifting left-to-right at the top step. Every dot has the same fixed + brightness — only position moves — so it reads as one smooth, consistent motion + instead of some dots being brighter than others. The pattern repeats every 10px and + shifts by exactly one period, so the loop point is never visible. */} + + {isMaxed ? ( +
+ +
+ ) : ( +
+ )} + + + {/* ticks — slight inset so the end dots don't clip; sized/ringed up at the top step so they + read as distinct markers instead of blending into the dot texture behind them */} +
+ {steps.map((tick) => { + const tp = ((tick.value - min) / (max - min)) * 100; + return ( + + ); + })} +
+ + {/* pulsing glow halo behind the thumb at the top step — kept as its own layer so only + opacity (a plain number) is animated, since Motion's box-shadow interpolator can't + parse CSS custom properties inside the color stops */} + {isMaxed && !reduce && ( + + )} + + {/* vertical bar thumb — contained at both ends via thumbX */} + +
+ + {steps.length > 0 && ( +
+
+ {steps.map((tick, index) => { + const isFirst = index === 0; + const isLast = index === steps.length - 1; + const tp = ((tick.value - min) / (max - min)) * 100; + return ( + + {tick.label} + + ); + })} +
+
+ )} +
+ ); +} diff --git a/src/client/components/motion/tabs.tsx b/src/client/components/motion/tabs.tsx new file mode 100644 index 0000000..038883e --- /dev/null +++ b/src/client/components/motion/tabs.tsx @@ -0,0 +1,189 @@ +// beui.dev/components/motion/tabs +import { motion, MotionConfig, useReducedMotion, type Transition } from 'motion/react'; +import { createContext, useContext, useId, useState, type ReactNode } from 'react'; +import { EASE_OUT } from '@client/lib/ease'; +import { cn } from '@client/lib/utils'; + +type Variant = 'pill' | 'underline' | 'segment'; + +type Ctx = { + value: string; + setValue: (v: string) => void; + layoutId: string; + variant: Variant; +}; + +const TabsCtx = createContext(null); + +function useTabs() { + const ctx = useContext(TabsCtx); + if (!ctx) throw new Error('Tabs.* must be used inside '); + return ctx; +} + +// Weighty spring for the active-tab indicator: a touch of overshoot so it +// settles with life instead of snapping. +const transition: Transition = { + type: 'spring', + stiffness: 170, + damping: 24, + mass: 1.2, +}; + +export function Tabs({ + defaultValue, + value, + onValueChange, + variant = 'pill', + children, + className, +}: { + defaultValue?: string; + value?: string; + onValueChange?: (v: string) => void; + variant?: Variant; + children: ReactNode; + className?: string; +}) { + const [internal, setInternal] = useState(defaultValue ?? ''); + const layoutId = useId(); + const reduce = useReducedMotion(); + const controlled = value !== undefined; + const current = controlled ? value : internal; + const setValue = (v: string) => { + if (!controlled) setInternal(v); + onValueChange?.(v); + }; + return ( + + + {/* layoutRoot: the indicator's layoutId measures in page coordinates, so + inside fixed/scrolled containers it would replay scroll offsets as + movement. The pill only ever travels within the list, so scoping + projection to the Tabs wrapper is always correct. */} + + {children} + + + + ); +} + +const listClasses: Record = { + pill: 'inline-flex items-center gap-1 rounded-full bg-card p-1', + underline: 'inline-flex items-center gap-1 border-b border-border', + segment: 'inline-flex items-center gap-0 rounded-lg bg-card p-0.5', +}; + +export function TabsList({ children, className }: { children: ReactNode; className?: string }) { + const { variant } = useTabs(); + return ( +
+ {children} +
+ ); +} + +export function TabsTrigger({ + value, + children, + className, + indicatorClassName, +}: { + value: string; + children: ReactNode; + className?: string; + indicatorClassName?: string; +}) { + const { value: current, setValue, layoutId, variant } = useTabs(); + const active = current === value; + + if (variant === 'underline') { + return ( + + ); + } + + // Pill + Segment use the same trick: a max-contrast pill slides via layoutId, + // text uses `mix-blend-exclusion` so it inverts dynamically against the moving bg. + const radius = variant === 'pill' ? 'rounded-full' : 'rounded-md'; + + return ( +
+ {active ? ( + + ) : null} + +
+ ); +} + +export function TabsContent({ + value, + children, + className, +}: { + value: string; + children: ReactNode; + className?: string; +}) { + const { value: current } = useTabs(); + const reduce = useReducedMotion(); + const active = current === value; + // Inactive panels stay mounted but hidden, so their content (e.g. source + // code) is present in the server-rendered HTML for crawlers and assistive + // tech, instead of being dropped from the DOM. + if (!active) { + return ( + + ); + } + return ( + + {children} + + ); +} diff --git a/src/client/components/shared/github-mark.tsx b/src/client/components/shared/github-mark.tsx new file mode 100644 index 0000000..376cb4a --- /dev/null +++ b/src/client/components/shared/github-mark.tsx @@ -0,0 +1,15 @@ +/** GitHub brand mark — lucide-react no longer ships brand icons. */ +export function GithubMark({ size = 16, className }: { size?: number; className?: string }) { + return ( + + ); +} diff --git a/src/client/components/ui/badge.tsx b/src/client/components/ui/badge.tsx index dff9c95..5985a8d 100644 --- a/src/client/components/ui/badge.tsx +++ b/src/client/components/ui/badge.tsx @@ -47,6 +47,7 @@ function getTone(value: string): BadgeVariant { return 'danger'; case 'queued': case 'superseded': + case 'cancelled': return 'neutral'; default: return 'neutral'; diff --git a/src/client/components/ui/button.tsx b/src/client/components/ui/button.tsx index 2ae9a8d..bba97cc 100644 --- a/src/client/components/ui/button.tsx +++ b/src/client/components/ui/button.tsx @@ -12,8 +12,12 @@ const buttonVariants = cva( 'bg-primary text-primary-foreground shadow-sm hover:bg-primary/90 active:scale-[.98]', destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90', + 'destructive-outline': + 'border border-danger-border bg-danger-bg/40 text-danger shadow-sm hover:bg-destructive hover:text-destructive-foreground hover:border-destructive', + 'warning-outline': + 'border border-warning-border bg-warning-bg/40 text-warning shadow-sm hover:bg-warning-bg hover:border-warning', outline: - 'border border-zinc-200 bg-white shadow-sm hover:bg-zinc-50 hover:text-zinc-900 dark:border-input dark:bg-card/60 dark:hover:bg-secondary dark:hover:text-secondary-foreground', + 'border border-zinc-200 bg-white shadow-sm hover:bg-zinc-50 hover:text-zinc-900 dark:border-white/10 dark:bg-white/[0.06] dark:shadow-[0_1px_2px_oklch(0%_0_0/0.4),inset_0_1px_0_oklch(100%_0_0/0.06)] dark:hover:bg-white/[0.1] dark:hover:text-foreground', secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80', ghost: 'hover:bg-secondary hover:text-secondary-foreground', diff --git a/src/client/components/ui/confirm-dialog.tsx b/src/client/components/ui/confirm-dialog.tsx new file mode 100644 index 0000000..c1ad07a --- /dev/null +++ b/src/client/components/ui/confirm-dialog.tsx @@ -0,0 +1,61 @@ +import * as Dialog from '@radix-ui/react-dialog'; +import { AlertTriangle } from 'lucide-react'; +import { Button, type ButtonProps } from '@client/components/ui/button'; + +interface ConfirmDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + title: string; + description: string; + confirmLabel?: string; + cancelLabel?: string; + confirmVariant?: ButtonProps['variant']; + onConfirm: () => void; +} + +export function ConfirmDialog({ + open, + onOpenChange, + title, + description, + confirmLabel = 'Continue', + cancelLabel = 'Cancel', + confirmVariant = 'default', + onConfirm, +}: ConfirmDialogProps) { + return ( + + + + +
+ + + +
+ {title} + + {description} + +
+
+ +
+ + + + +
+
+
+
+ ); +} diff --git a/src/client/components/ui/dropdown-menu.tsx b/src/client/components/ui/dropdown-menu.tsx index b10afc0..f56b6b1 100644 --- a/src/client/components/ui/dropdown-menu.tsx +++ b/src/client/components/ui/dropdown-menu.tsx @@ -1,209 +1,299 @@ -import * as React from 'react'; -import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'; -import { Check, ChevronRight, Circle } from 'lucide-react'; +import { Slot } from '@radix-ui/react-slot'; +import { AnimatePresence, motion, useReducedMotion, type Transition } from 'motion/react'; +import { + createContext, + useContext, + useEffect, + useId, + useLayoutEffect, + useRef, + useState, + type KeyboardEvent as ReactKeyboardEvent, + type MouseEvent, + type ReactNode, + type RefCallback, +} from 'react'; +import { createPortal } from 'react-dom'; import { cn } from '@client/lib/utils'; -const DropdownMenu = DropdownMenuPrimitive.Root; - -const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; - -const DropdownMenuGroup = DropdownMenuPrimitive.Group; - -const DropdownMenuPortal = DropdownMenuPrimitive.Portal; - -const DropdownMenuSub = DropdownMenuPrimitive.Sub; - -const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; - -const menuContentClass = - 'z-50 min-w-[8rem] overflow-hidden rounded-lg border border-zinc-200 bg-white p-1 text-zinc-900 shadow-sm shadow-black/[0.02] dark:border-border dark:bg-popover dark:text-popover-foreground dark:shadow-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 duration-200'; - -const menuItemClass = - 'relative flex cursor-default select-none items-center rounded-md text-sm outline-none transition-colors hover:bg-zinc-200 hover:text-zinc-900 focus:bg-zinc-200 focus:text-zinc-900 data-[highlighted]:bg-zinc-200 data-[highlighted]:text-zinc-900 dark:hover:bg-primary/[0.12] dark:hover:text-foreground dark:focus:bg-primary/[0.12] dark:focus:text-foreground dark:data-[highlighted]:bg-primary/[0.12] dark:data-[highlighted]:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50'; - -const DropdownMenuSubTrigger = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef & { - inset?: boolean; - } ->(({ className, inset, children, ...props }, ref) => ( - - {children} - - -)); -DropdownMenuSubTrigger.displayName = - DropdownMenuPrimitive.SubTrigger.displayName; - -const DropdownMenuSubContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -DropdownMenuSubContent.displayName = - DropdownMenuPrimitive.SubContent.displayName; - -const DropdownMenuContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, sideOffset = 4, ...props }, ref) => ( - - void; + triggerRef: React.RefObject; + panelRef: React.RefObject; + contentId: string; +} + +const DropdownMenuContext = createContext(null); + +function useMenuCtx(component: string) { + const ctx = useContext(DropdownMenuContext); + if (!ctx) throw new Error(`${component} must be used within `); + return ctx; +} + +export function DropdownMenu({ children }: { children: ReactNode }) { + const [open, setOpen] = useState(false); + const triggerRef = useRef(null); + const panelRef = useRef(null); + const contentId = `${useId()}-menu`; + + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => e.key === 'Escape' && setOpen(false); + const onPointer = (e: PointerEvent) => { + const target = e.target as Node; + if (triggerRef.current?.contains(target)) return; + if (panelRef.current?.contains(target)) return; + setOpen(false); + }; + window.addEventListener('keydown', onKey); + window.addEventListener('pointerdown', onPointer); + return () => { + window.removeEventListener('keydown', onKey); + window.removeEventListener('pointerdown', onPointer); + }; + }, [open]); + + return ( + + {children} + + ); +} + +export function DropdownMenuTrigger({ + asChild, + children, +}: { + asChild?: boolean; + children: ReactNode; +}) { + const ctx = useMenuCtx('DropdownMenuTrigger'); + const Comp = asChild ? Slot : 'button'; + const setRef: RefCallback = (node) => { + ctx.triggerRef.current = node; + }; + + return ( + ctx.setOpen(!ctx.open)} + > + {children} + + ); +} + +export interface DropdownMenuContentProps { + side?: Side; + align?: Align; + sideOffset?: number; + alignOffset?: number; + className?: string; + children: ReactNode; +} + +const VIEWPORT_PADDING = 8; + +const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max); + +export function DropdownMenuContent({ + side = 'bottom', + align = 'start', + sideOffset = 4, + alignOffset = 0, + className, + children, +}: DropdownMenuContentProps) { + const ctx = useMenuCtx('DropdownMenuContent'); + const reduce = useReducedMotion() ?? false; + const [style, setStyle] = useState({ position: 'fixed', visibility: 'hidden' }); + + const setRefs: RefCallback = (node) => { + ctx.panelRef.current = node; + }; + + const getItems = () => + Array.from( + ctx.panelRef.current?.querySelectorAll('[role="menuitem"]:not([aria-disabled="true"]):not([disabled])') ?? [], + ); + + // Move focus into the menu on open and restore it to the trigger on close, so + // keyboard users land on the first item and return to where they were. + const wasOpen = useRef(false); + useEffect(() => { + if (ctx.open) { + wasOpen.current = true; + const id = requestAnimationFrame(() => getItems()[0]?.focus()); + return () => cancelAnimationFrame(id); + } + if (wasOpen.current) { + wasOpen.current = false; + // Only pull focus back to the trigger if it isn't already somewhere + // deliberate (e.g. an outside click that landed on another control). + const active = document.activeElement; + if (!active || active === document.body || ctx.panelRef.current?.contains(active)) { + ctx.triggerRef.current?.focus(); + } + } + }, [ctx.open]); + + useLayoutEffect(() => { + if (!ctx.open) return; + const trigger = ctx.triggerRef.current; + if (!trigger) return; + const update = () => { + const r = trigger.getBoundingClientRect(); + const panel = ctx.panelRef.current; + const menuW = panel?.offsetWidth ?? 0; + const menuH = panel?.offsetHeight ?? 0; + const vw = window.innerWidth; + const vh = window.innerHeight; + + let top: number; + let left: number; + + if (side === 'top' || side === 'bottom') { + top = side === 'bottom' ? r.bottom + sideOffset : r.top - sideOffset - menuH; + left = align === 'start' ? r.left + alignOffset : r.right - menuW - alignOffset; + // Flip to the other side if the preferred one overflows and the opposite fits. + if (side === 'bottom' && top + menuH > vh - VIEWPORT_PADDING && r.top - sideOffset - menuH >= VIEWPORT_PADDING) { + top = r.top - sideOffset - menuH; + } else if (side === 'top' && top < VIEWPORT_PADDING && r.bottom + sideOffset + menuH <= vh - VIEWPORT_PADDING) { + top = r.bottom + sideOffset; + } + } else { + left = side === 'right' ? r.right + sideOffset : r.left - sideOffset - menuW; + top = align === 'start' ? r.top + alignOffset : r.bottom - menuH - alignOffset; + if (side === 'right' && left + menuW > vw - VIEWPORT_PADDING && r.left - sideOffset - menuW >= VIEWPORT_PADDING) { + left = r.left - sideOffset - menuW; + } else if (side === 'left' && left < VIEWPORT_PADDING && r.right + sideOffset + menuW <= vw - VIEWPORT_PADDING) { + left = r.right + sideOffset; + } + } + + // Keep the panel within the viewport regardless of side/align. + top = clamp(top, VIEWPORT_PADDING, Math.max(VIEWPORT_PADDING, vh - menuH - VIEWPORT_PADDING)); + left = clamp(left, VIEWPORT_PADDING, Math.max(VIEWPORT_PADDING, vw - menuW - VIEWPORT_PADDING)); + + setStyle({ position: 'fixed', top, left, visibility: 'visible' }); + }; + update(); + window.addEventListener('scroll', update, true); + window.addEventListener('resize', update); + return () => { + window.removeEventListener('scroll', update, true); + window.removeEventListener('resize', update); + }; + }, [ctx.open, ctx.triggerRef, side, align, sideOffset, alignOffset]); + + const onKeyDown = (e: ReactKeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + ctx.setOpen(false); + return; + } + const items = getItems(); + if (items.length === 0) return; + const current = items.indexOf(document.activeElement as HTMLElement); + if (e.key === 'ArrowDown') { + e.preventDefault(); + items[(current + 1 + items.length) % items.length].focus(); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + items[(current - 1 + items.length) % items.length].focus(); + } else if (e.key === 'Home') { + e.preventDefault(); + items[0].focus(); + } else if (e.key === 'End') { + e.preventDefault(); + items[items.length - 1].focus(); + } + }; + + const originX = side === 'top' || side === 'bottom' ? (align === 'start' ? 'left' : 'right') : side === 'right' ? 'left' : 'right'; + const originY = side === 'bottom' ? 'top' : side === 'top' ? 'bottom' : align === 'start' ? 'top' : 'bottom'; + const slideDistance = 6; + const hiddenOffset = + side === 'bottom' + ? { y: -slideDistance } + : side === 'top' + ? { y: slideDistance } + : side === 'right' + ? { x: -slideDistance } + : { x: slideDistance }; + + const transition: Transition = reduce + ? { duration: 0.1 } + : { type: 'spring', duration: 0.35, bounce: 0.15 }; + + return createPortal( + // AnimatePresence unmounts the menu after its exit animation, so the closed + // menu leaves no invisible, focusable items in the tab order. + + {ctx.open && ( + + {children} + + )} + , + document.body, + ); +} + +export interface DropdownMenuItemProps { + asChild?: boolean; + className?: string; + onClick?: (e: MouseEvent) => void; + children: ReactNode; +} + +export function DropdownMenuItem({ asChild, className, onClick, children }: DropdownMenuItemProps) { + const ctx = useMenuCtx('DropdownMenuItem'); + const Comp = asChild ? Slot : 'button'; + + return ( + ) => { + onClick?.(e); + ctx.setOpen(false); + }} className={cn( - menuContentClass, - 'data-[state=closed]:zoom-out-98 data-[state=open]:zoom-in-98 data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1 ease-out', + 'relative flex w-full cursor-default select-none items-center rounded-md px-2 py-1.5 text-sm outline-none transition-colors', + 'hover:bg-zinc-200 hover:text-zinc-900 focus:bg-zinc-200 focus:text-zinc-900', + 'dark:hover:bg-primary/[0.12] dark:hover:text-foreground dark:focus:bg-primary/[0.12] dark:focus:text-foreground', className, )} - {...props} - /> - -)); -DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; - -const DropdownMenuItem = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef & { - inset?: boolean; - } ->(({ className, inset, ...props }, ref) => ( - -)); -DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; - -const DropdownMenuCheckboxItem = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, checked, ...props }, ref) => ( - - - - - - - {children} - -)); -DropdownMenuCheckboxItem.displayName = - DropdownMenuPrimitive.CheckboxItem.displayName; - -const DropdownMenuRadioItem = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( - - - - - - - {children} - -)); -DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; - -const DropdownMenuLabel = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef & { - inset?: boolean; - } ->(({ className, inset, ...props }, ref) => ( - -)); -DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; - -const DropdownMenuSeparator = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; - -const DropdownMenuShortcut = ({ - className, - ...props -}: React.HTMLAttributes) => { - return ( - + > + {children} + ); -}; -DropdownMenuShortcut.displayName = 'DropdownMenuShortcut'; - -export { - DropdownMenu, - DropdownMenuTrigger, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuCheckboxItem, - DropdownMenuRadioItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuShortcut, - DropdownMenuGroup, - DropdownMenuPortal, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, - DropdownMenuRadioGroup, -}; +} + +export function DropdownMenuSeparator({ className }: { className?: string }) { + return
; +} diff --git a/src/client/components/ui/select.tsx b/src/client/components/ui/select.tsx index 0f419aa..e6a9173 100644 --- a/src/client/components/ui/select.tsx +++ b/src/client/components/ui/select.tsx @@ -1,13 +1,33 @@ -import { ChevronDown } from 'lucide-react'; -import type { CSSProperties, ReactNode } from 'react'; -import { cn } from '@client/lib/utils'; +import { Check, ChevronDown } from 'lucide-react'; +import { motion, type Transition, useReducedMotion, type Variants } from 'motion/react'; import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from './dropdown-menu'; -import { Button } from './button'; + type CSSProperties, + type KeyboardEvent as ReactKeyboardEvent, + type ReactNode, + useEffect, + useId, + useLayoutEffect, + useRef, + useState, +} from 'react'; +import { createPortal } from 'react-dom'; +import { cn } from '@client/lib/utils'; +import { EASE_OUT } from '@client/lib/ease'; + +// Spring with bounce powers the unfold/separation; per-property timings in the +// content choreograph it. See the `animate`/`transition` props on the panel below. +const CHEVRON_TRANSITION: Transition = { type: 'spring', duration: 0.4, bounce: 0.3 }; + +const LIST_VARIANTS: Variants = { + hidden: {}, + show: { transition: { staggerChildren: 0.035, delayChildren: 0.05 } }, +}; +const ITEM_VARIANTS: Variants = { + hidden: { opacity: 0, y: -6, filter: 'blur(3px)' }, + show: { opacity: 1, y: 0, filter: 'blur(0px)' }, +}; + +type Placement = 'bottom' | 'top'; interface SelectOption { value: string; @@ -26,9 +46,9 @@ interface SelectProps { leadingIcon?: ReactNode; /** * 'page' — trigger sits on the gray page background (e.g. "Last 30 days"). - * Dropdown gets white bg so it lifts off the page. - * 'card' — trigger sits inside a white card (e.g. "All statuses"). - * Dropdown gets zinc-50 bg so it's distinguishable from the card. + * Dropdown gets card bg so it lifts off the page. + * 'card' — trigger sits inside a card. + * Dropdown gets muted bg so it's distinguishable from the card. * Defaults to 'page'. */ variant?: 'page' | 'card'; @@ -46,8 +66,161 @@ export function Select({ leadingIcon, variant = 'page', }: SelectProps) { + const reduce = useReducedMotion() ?? false; + const baseId = useId(); + const triggerId = `${baseId}-trigger`; + const listId = `${baseId}-list`; + const triggerRef = useRef(null); + const panelRef = useRef(null); + const innerRef = useRef(null); + const [open, setOpen] = useState(false); + const [placement, setPlacement] = useState('bottom'); + const [height, setHeight] = useState(0); + const [rect, setRect] = useState<{ left: number; width: number; top: number; bottom: number } | null>(null); + const [highlightedIndex, setHighlightedIndex] = useState(0); + const optionRefs = useRef>([]); + const selectedOption = options.find((opt) => opt.value === value); + // Move the active-descendant highlight onto the current selection (or the first option) + // each time the listbox opens, so arrow-key navigation always starts from a sane position. + useEffect(() => { + if (!open) return; + const idx = options.findIndex((opt) => opt.value === value); + setHighlightedIndex(idx >= 0 ? idx : 0); + }, [open, options, value]); + + useEffect(() => { + if (!open) return; + optionRefs.current[highlightedIndex]?.scrollIntoView({ block: 'nearest' }); + }, [open, highlightedIndex]); + + // close on outside pointer / escape + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => e.key === 'Escape' && setOpen(false); + const onPointer = (e: PointerEvent) => { + const target = e.target as Node; + if (triggerRef.current?.contains(target)) return; + if (panelRef.current?.contains(target)) return; + setOpen(false); + }; + window.addEventListener('keydown', onKey); + window.addEventListener('pointerdown', onPointer); + return () => { + window.removeEventListener('keydown', onKey); + window.removeEventListener('pointerdown', onPointer); + }; + }, [open]); + + useLayoutEffect(() => { + const node = innerRef.current; + if (!node) return; + const measure = () => setHeight(node.offsetHeight); + measure(); + const observer = new ResizeObserver(measure); + observer.observe(node); + return () => observer.disconnect(); + }, []); + + // Track the trigger's viewport position so the portaled panel can follow it, + // and flip upward when there isn't room below and there's more above. Scroll/resize + // fire far more often than the display repaints, so batch updates to at most once per + // animation frame instead of re-rendering on every raw event. + useLayoutEffect(() => { + if (!open) return; + const trigger = triggerRef.current; + if (!trigger) return; + let frame: number | null = null; + const update = () => { + frame = null; + const r = trigger.getBoundingClientRect(); + setRect({ left: r.left, width: r.width, top: r.top, bottom: r.bottom }); + const h = innerRef.current?.offsetHeight ?? 0; + const below = window.innerHeight - r.bottom; + const above = r.top; + setPlacement(below < h + 16 && above > below ? 'top' : 'bottom'); + }; + const scheduleUpdate = () => { + if (frame !== null) return; + frame = requestAnimationFrame(update); + }; + update(); + window.addEventListener('scroll', scheduleUpdate, true); + window.addEventListener('resize', scheduleUpdate); + return () => { + if (frame !== null) cancelAnimationFrame(frame); + window.removeEventListener('scroll', scheduleUpdate, true); + window.removeEventListener('resize', scheduleUpdate); + }; + }, [open]); + + const moveHighlight = (next: number) => { + setHighlightedIndex(Math.min(Math.max(next, 0), options.length - 1)); + }; + + const onTriggerKeyDown = (e: ReactKeyboardEvent) => { + if (!open) { + if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + setOpen(true); + } + return; + } + if (options.length === 0) return; + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + moveHighlight(highlightedIndex + 1); + break; + case 'ArrowUp': + e.preventDefault(); + moveHighlight(highlightedIndex - 1); + break; + case 'Home': + e.preventDefault(); + moveHighlight(0); + break; + case 'End': + e.preventDefault(); + moveHighlight(options.length - 1); + break; + case 'Enter': + case ' ': + e.preventDefault(); + onValueChange(options[highlightedIndex].value); + setOpen(false); + break; + case 'Tab': + setOpen(false); + break; + default: + break; + } + }; + + const isTop = placement === 'top'; + + // Gooey: the edge facing the panel snaps flat (panel attached) then rounds + // back once the panel pulls away — the two pinch apart. + const kf = open ? [0, 0, 12] : [12, 0, 12]; + const kfT: Transition = reduce + ? { duration: 0 } + : open + ? { duration: 0.6, times: [0, 0.4, 1], ease: EASE_OUT } + : { duration: 0.42, times: [0, 0.5, 1], ease: EASE_OUT }; + const flatT: Transition = { duration: 0 }; + + const nearGap = open ? 8 : 0; + const nearRadius = open ? 12 : 0; + const gapT: Transition = open + ? { type: 'spring', duration: 0.6, bounce: 0.5, delay: 0.12 } + : { type: 'spring', duration: 0.3, bounce: 0.1 }; + const radiusT: Transition = open + ? { duration: 0.3, ease: EASE_OUT, delay: 0.14 } + : { duration: 0.16, ease: EASE_OUT }; + const instant: Transition = { duration: 0 }; + return (
{label && ( @@ -55,50 +228,157 @@ export function Select({ {label} )} - - - - - + + + + + +
+ + {/* Portaled to so the panel always renders above cards, tables, and + other stacking contexts — it can't be clipped/hidden by an ancestor. */} + {createPortal( + round; far corners stay rounded + borderTopLeftRadius: isTop ? 12 : nearRadius, + borderTopRightRadius: isTop ? 12 : nearRadius, + borderBottomLeftRadius: isTop ? nearRadius : 12, + borderBottomRightRadius: isTop ? nearRadius : 12, + } + } + transition={ + reduce + ? { duration: 0.12 } + : { + opacity: open ? { duration: 0.18 } : { duration: 0.16, delay: 0.12 }, + height: open + ? { type: 'spring', duration: 0.42, bounce: 0.14 } + : { duration: 0.26, ease: EASE_OUT, delay: 0.14 }, + marginTop: isTop ? instant : gapT, + marginBottom: isTop ? gapT : instant, + borderTopLeftRadius: isTop ? instant : radiusT, + borderTopRightRadius: isTop ? instant : radiusT, + borderBottomLeftRadius: isTop ? radiusT : instant, + borderBottomRightRadius: isTop ? radiusT : instant, + } + } + style={{ + position: 'fixed', + left: rect?.left ?? 0, + width: rect?.width ?? 0, + top: isTop ? undefined : (rect?.bottom ?? 0), + bottom: isTop ? window.innerHeight - (rect?.top ?? 0) : undefined, + transformOrigin: isTop ? 'bottom' : 'top', + overflow: 'hidden', + pointerEvents: open ? 'auto' : 'none', + }} + // flush against the trigger, then separates into its own rounded pill; + // sits above or below depending on available space + className="z-50 border border-border bg-popover shadow-lg shadow-black/[0.03] dark:shadow-black/40" > - {options.map((option) => ( - onValueChange(option.value)} - className={cn( - 'cursor-pointer whitespace-normal break-words py-2', - value === option.value && - 'bg-primary/10 font-medium text-primary dark:bg-primary/[0.12] dark:text-primary', - )} - > - {option.label} - - ))} - - + + {options.map((option, index) => { + const selected = option.value === value; + const highlighted = index === highlightedIndex; + return ( + + + + ); + })} + + , + document.body, + )}
); } diff --git a/src/client/hooks/use-job-detail.ts b/src/client/hooks/use-job-detail.ts index ba67efb..515b268 100644 --- a/src/client/hooks/use-job-detail.ts +++ b/src/client/hooks/use-job-detail.ts @@ -1,5 +1,6 @@ import { useEffect, useState, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; +import { toast } from 'sonner'; import { api } from '@client/lib/api'; import type { JobDetail } from '@shared/schema'; @@ -8,11 +9,15 @@ export function useJobDetail(id: string) { const [job, setJob] = useState(null); const [error, setError] = useState(null); const [isRetrying, setIsRetrying] = useState(false); + const [isRerunning, setIsRerunning] = useState(false); + const [isStopping, setIsStopping] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); const pollTimeout = useRef(null); const etag = useRef(null); const latestJob = useRef(null); - const isTerminal = (candidate: JobDetail | null) => candidate?.status === 'done' || candidate?.status === 'failed' || candidate?.status === 'superseded'; + const terminalStatuses: string[] = ['done', 'failed', 'superseded', 'cancelled']; + const isTerminal = (candidate: JobDetail | null) => !!candidate && terminalStatuses.includes(candidate.status); const getPollDelay = (candidate: JobDetail | null) => { if (!candidate || isTerminal(candidate)) return null; @@ -87,11 +92,67 @@ export function useJobDetail(id: string) { } }; + const handleRerun = async () => { + if (!job) return; + setIsRerunning(true); + const t = toast.loading('Starting a fresh review…'); + try { + const response = await api.rerunJob(job.id); + toast.success('Fresh review started.', { id: t }); + navigate(`/jobs/${response.job.id}`); + } catch (e) { + const msg = e instanceof Error ? e.message : 'Failed to rerun job.'; + toast.error('Could not start a fresh review.', { id: t, description: msg }); + setError(msg); + } finally { + setIsRerunning(false); + } + }; + + const handleStop = async () => { + if (!job) return; + setIsStopping(true); + const t = toast.loading('Stopping review…'); + try { + await api.stopJob(job.id); + toast.success('Review stopped.', { id: t }); + await fetchJob(); + } catch (e) { + const msg = e instanceof Error ? e.message : 'Failed to stop job.'; + toast.error('Could not stop the review.', { id: t, description: msg }); + setError(msg); + } finally { + setIsStopping(false); + } + }; + + const handleDelete = async () => { + if (!job) return; + setIsDeleting(true); + const t = toast.loading('Deleting job…'); + try { + await api.deleteJob(job.id); + toast.success('Job deleted.', { id: t }); + navigate('/jobs'); + } catch (e) { + const msg = e instanceof Error ? e.message : 'Failed to delete job.'; + toast.error('Could not delete the job.', { id: t, description: msg }); + setError(msg); + setIsDeleting(false); + } + }; + return { job, error, isRetrying, + isRerunning, + isStopping, + isDeleting, handleRetry, + handleRerun, + handleStop, + handleDelete, fetchJob }; } diff --git a/src/client/lib/api.ts b/src/client/lib/api.ts index d218b3c..85666cb 100644 --- a/src/client/lib/api.ts +++ b/src/client/lib/api.ts @@ -10,7 +10,7 @@ import type { SyncReposResponse, UpdatesEmailResponse, } from '@shared/api'; -import type { LlmApiFormat, LlmProvider, ModelConfig, RepoConfig } from '@shared/schema'; +import type { LlmApiFormat, LlmProvider, RepoConfig, ReviewSettings } from '@shared/schema'; const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); @@ -23,7 +23,6 @@ function pathSegment(value: string) { } type QueryValue = string | number | boolean | null | undefined; -export type ModelConfigPayload = Pick; export type ProviderPayload = { name: string; apiFormat: LlmApiFormat; @@ -173,6 +172,21 @@ export const api = { method: 'POST', }); }, + rerunJob(id: string) { + return request(`/api/jobs/${pathSegment(id)}/rerun`, { + method: 'POST', + }); + }, + stopJob(id: string) { + return request(`/api/jobs/${pathSegment(id)}/stop`, { + method: 'POST', + }); + }, + deleteJob(id: string) { + return request(`/api/jobs/${pathSegment(id)}`, { + method: 'DELETE', + }); + }, getRepos() { return request('/api/repos'); }, @@ -202,17 +216,6 @@ export const api = { method: 'POST', }); }, - updateModelConfig(id: string, config: ModelConfigPayload) { - return request<{ ok: boolean; config: ModelConfig }>(`/api/models/${pathSegment(id)}`, { - method: 'PATCH', - body: JSON.stringify(config), - }); - }, - deleteModelConfig(id: string) { - return request<{ ok: boolean }>(`/api/models/${pathSegment(id)}`, { - method: 'DELETE', - }); - }, createProvider(config: ProviderPayload) { return request<{ provider: LlmProvider }>('/api/models/providers', { method: 'POST', @@ -230,11 +233,6 @@ export const api = { method: 'DELETE', }); }, - testModelConfig(id: string) { - return request<{ ok: boolean; modelUsed: string; provider: string; inputTokens: number; outputTokens: number }>(`/api/models/${pathSegment(id)}/test`, { - method: 'POST', - }); - }, getGlobalConfig() { return request<{ config: RepoConfig['model'] }>('/api/models/global'); }, @@ -244,4 +242,13 @@ export const api = { body: JSON.stringify(config), }); }, + getReviewSettings() { + return request<{ settings: ReviewSettings }>('/api/settings'); + }, + updateReviewSettings(settings: ReviewSettings) { + return request<{ ok: boolean; settings: ReviewSettings }>('/api/settings', { + method: 'PATCH', + body: JSON.stringify(settings), + }); + }, }; diff --git a/src/client/lib/ease.ts b/src/client/lib/ease.ts new file mode 100644 index 0000000..8abd8d1 --- /dev/null +++ b/src/client/lib/ease.ts @@ -0,0 +1 @@ +export const EASE_OUT = [0.16, 1, 0.3, 1] as const; diff --git a/src/client/lib/utils.ts b/src/client/lib/utils.ts index c1dd0d9..6db9aae 100644 --- a/src/client/lib/utils.ts +++ b/src/client/lib/utils.ts @@ -13,8 +13,12 @@ export function fmtNumber(n: number) { export function formatDuration(ms: number | null | undefined): string { if (ms == null) return ''; - const totalSeconds = Math.floor(ms / 1000); - if (totalSeconds < 60) return `${totalSeconds}s`; + // Sub-minute: show one decimal second so sub-second work (e.g. a 724ms file review) reads as + // "0.7s" rather than flooring to a misleading "0s". + if (ms < 60_000) { + return `${(ms / 1000).toFixed(1)}s`; + } + const totalSeconds = Math.round(ms / 1000); const minutes = Math.floor(totalSeconds / 60); const seconds = totalSeconds % 60; if (minutes < 60) { diff --git a/src/client/main.tsx b/src/client/main.tsx index 0bb5880..89016b8 100644 --- a/src/client/main.tsx +++ b/src/client/main.tsx @@ -19,6 +19,7 @@ import './app.css'; import { ThemeProvider } from './lib/theme'; import { useIsDarkMode } from './hooks/use-is-dark-mode'; +import { SmoothScroll } from './components/motion/smooth-scroll'; function ToasterWrapper() { const isDark = useIsDarkMode(); @@ -26,7 +27,6 @@ function ToasterWrapper() { - - + + + + , ); diff --git a/src/client/pages/job-detail.tsx b/src/client/pages/job-detail.tsx index 1ec8489..93c08bd 100644 --- a/src/client/pages/job-detail.tsx +++ b/src/client/pages/job-detail.tsx @@ -10,7 +10,16 @@ import { Alert } from '@client/components/ui/alert'; export function JobDetailPage() { const { id = '' } = useParams(); - const { job, error, isRetrying, handleRetry } = useJobDetail(id); + const { + job, + error, + isRerunning, + isStopping, + isDeleting, + handleRerun, + handleStop, + handleDelete, + } = useJobDetail(id); if (!job) { return ; @@ -18,10 +27,14 @@ export function JobDetailPage() { return (
- {error && ( diff --git a/src/client/pages/jobs.tsx b/src/client/pages/jobs.tsx index 00b6cc0..2334b48 100644 --- a/src/client/pages/jobs.tsx +++ b/src/client/pages/jobs.tsx @@ -102,7 +102,8 @@ export function JobsPage() { { value: 'running', label: 'Running' }, { value: 'done', label: 'Done' }, { value: 'failed', label: 'Failed' }, - { value: 'superseded', label: 'Superseded' } + { value: 'superseded', label: 'Superseded' }, + { value: 'cancelled', label: 'Cancelled' } ]} triggerClassName="bg-transparent" /> diff --git a/src/client/pages/landing.tsx b/src/client/pages/landing.tsx index 4818b57..6296927 100644 --- a/src/client/pages/landing.tsx +++ b/src/client/pages/landing.tsx @@ -1,5 +1,6 @@ import { Sun, Moon, ArrowRight, ExternalLink } from 'lucide-react'; import { useTheme } from '@client/lib/theme'; +import { GithubMark } from '@client/components/shared/github-mark'; import codraDark from '@/assets/codra-fullicon-dark.svg'; import codraLight from '@/assets/codra-fullicon-light.svg'; @@ -22,65 +23,88 @@ export function LandingPage() { const { theme, toggleTheme } = useTheme(); return ( -
+
{/* ── Header ── */} -
- Codra -
- - Sign in - - - +
+
+ Codra +
+ + + + + + Sign in + + +
{/* ── Body ── */} -
+
{/* Left — Hero */} -
+
-
-
-

- AI code review
on every PR. -

-

- Codra reviews pull requests automatically — checking for bugs, - security issues, and code patterns specific to your repository. -

-
+
{/* Footer links */} -
+ {/* Right — Features */} -
+

What it does

@@ -106,12 +130,12 @@ export function LandingPage() {
{FEATURES.map((item, i) => (
- + {i + 1}

{item.title}

-

{item.desc}

+

{item.desc}

))} diff --git a/src/client/pages/login.tsx b/src/client/pages/login.tsx index e94937c..6985c00 100644 --- a/src/client/pages/login.tsx +++ b/src/client/pages/login.tsx @@ -1,8 +1,8 @@ import { useMemo } from 'react'; -import { useSearchParams } from 'react-router-dom'; -import { Button } from '@client/components/ui/button'; -import { Sun, Moon, ShieldCheck } from 'lucide-react'; +import { useSearchParams, Link } from 'react-router-dom'; +import { Sun, Moon, ShieldCheck, ArrowLeft, AlertCircle } from 'lucide-react'; import { useTheme } from '@client/lib/theme'; +import { GithubMark } from '@client/components/shared/github-mark'; import codraDark from '@/assets/codra-fullicon-dark.svg'; import codraLight from '@/assets/codra-fullicon-light.svg'; @@ -29,62 +29,78 @@ export function LoginPage() { const error = useMemo(() => getErrorMessage(searchParams.get('error')), [searchParams]); return ( -
+
+ + {/* Top bar: back link + theme toggle */} + + + Home + -
- {/* Card */} -
- - + {/* Footer note — outside the card */} -
- +
+

Only authorized GitHub users can access this instance.

diff --git a/src/client/pages/settings.tsx b/src/client/pages/settings.tsx index ea17c9f..82c220f 100644 --- a/src/client/pages/settings.tsx +++ b/src/client/pages/settings.tsx @@ -9,24 +9,23 @@ import { Skeleton } from '@client/components/shared/skeleton'; import { Input } from '@client/components/ui/input'; import { Select } from '@client/components/ui/select'; import { Switch } from '@client/components/ui/switch'; +import { SteppedSlider } from '@client/components/motion/stepped-slider'; +import { ConfirmDialog } from '@client/components/ui/confirm-dialog'; import { - Cpu, Save, ShieldAlert, Layers, RefreshCw, - PlugZap, Plus, Trash2, - Search, ChevronDown, ChevronRight, X, - Tag, ExternalLink, - GitCommit, } from 'lucide-react'; -import type { LlmApiFormat, LlmProvider, ModelConfig, RepoConfig } from '@shared/schema'; +import { Badge } from '@client/components/ui/badge'; +import type { LlmApiFormat, LlmProvider, ModelConfig, RepoConfig, ReviewSettings } from '@shared/schema'; +import { REVIEW_CONCURRENCY_LIMITS, reviewMaxCommentsOptions, type ReviewConcurrencyLevel } from '@shared/schema'; import type { ModelConfigsResponse } from '@shared/api'; import { ModelRouteEditor, @@ -57,6 +56,24 @@ const PROVIDER_PRESETS = [ const FIXED_PROVIDER_NAMES = new Set(['OpenAI', 'OpenRouter', 'Anthropic', 'Google', 'Cloudflare']); +const CONCURRENCY_LEVEL_ORDER: ReviewConcurrencyLevel[] = ['low', 'medium', 'high', 'max']; +const CONCURRENCY_LEVEL_LABEL: Record = { + low: 'Low', + medium: 'Medium', + high: 'High', + max: 'Max', +}; +const CONCURRENCY_STEPS = CONCURRENCY_LEVEL_ORDER.map(level => ({ + value: REVIEW_CONCURRENCY_LIMITS[level], + label: CONCURRENCY_LEVEL_LABEL[level], +})); +const CONCURRENCY_VALUE_TO_LEVEL: Record = Object.fromEntries( + CONCURRENCY_LEVEL_ORDER.map(level => [REVIEW_CONCURRENCY_LIMITS[level], level]), +) as Record; +const CONCURRENCY_MAX_VALUE = REVIEW_CONCURRENCY_LIMITS.max; +const MAX_COMMENTS_STEPS = reviewMaxCommentsOptions.map(n => ({ value: n, label: String(n) })); +const MAX_COMMENTS_CEILING = reviewMaxCommentsOptions[reviewMaxCommentsOptions.length - 1]; + type ProviderDraft = LlmProvider & { apiKey: string }; type NewProviderDraft = { preset: string; @@ -66,15 +83,6 @@ type NewProviderDraft = { apiKey: string; enabled: boolean; }; -type NewModelDraft = { - modelId: string; - providerId: string; - modelName: string; - rpm: number | null; - tpm: number | null; - rpd: number | null; -}; - type SyncError = { providerId: string; providerName: string; error: string }; type GlobalConfigInput = RepoConfig['model'] | Partial | null | undefined; @@ -113,38 +121,6 @@ function routeEqual(a: ModelRouteConfig | null, b: ModelRouteConfig | null) { ); } -function configEqual(a?: ModelConfig, b?: ModelConfig) { - return Boolean( - a && b && - a.rpm === b.rpm && - a.rpd === b.rpd && - a.tpm === b.tpm && - a.providerId === b.providerId && - a.modelName === b.modelName, - ); -} - -function modelPayload(config: ModelConfig) { - return { - providerId: config.providerId, - modelName: config.modelName, - rpm: config.rpm, - rpd: config.rpd, - tpm: config.tpm, - }; -} - -function parseOptionalLimit(value: string) { - const trimmed = value.trim(); - if (!trimmed) return null; - const parsed = Number(trimmed); - return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : null; -} - -function formatOptionalLimit(value: number | null) { - return value === null ? 'Unset' : value.toLocaleString(); -} - function providerToDraft(provider: LlmProvider): ProviderDraft { return { ...provider, apiKey: '' }; } @@ -204,7 +180,7 @@ function SectionCard({
{icon && ( - + {icon} )} @@ -221,9 +197,9 @@ function SectionCard({ } /* ─── Field label ─────────────────────────────────────────────────────────── */ -function FieldLabel({ htmlFor, children }: { htmlFor: string; children: React.ReactNode }) { +function FieldLabel({ htmlFor, id, children }: { htmlFor: string; id?: string; children: React.ReactNode }) { return ( -
{/* Expanded edit panel */} - {expanded && ( + {configOpen && (
{customProvider && ( @@ -970,6 +941,16 @@ export function SettingsPage() { onChange={e => updateProviderDraft(provider.id, { apiKey: e.target.value })} className="max-w-sm" /> + {provider.hasApiKey && ( + + )}
)}
@@ -981,6 +962,30 @@ export function SettingsPage() {
)} + {/* Global model strategy */} +
+
+

Default models

+

Used by repos that don't set their own model

+
+
+ {!loading && globalConfig ? ( + + ) : ( +
+ + +
+ )} +
+
+ {/* Footer */} {!loading && (
@@ -995,343 +1000,57 @@ export function SettingsPage() { )}
- {/* ── Global model strategy ───────────────────────────────────────────── */} + {/* ── About ──────────────────────────────────────────────────────────── */} - {saving === 'global' ? : } - Save strategy - - } - > -
- {!loading && globalConfig ? ( - - ) : ( -
- - -
- )} -
-
- - {/* ── Models & Usage Limits ────────────────────────────────────────────── */} - - {dirtyConfigs.length > 0 && ( - - )} - -
- } - > - {/* Add model form */} - {addingModel && ( -
-

New model

-
-
- Codra model ID - setNewModel(current => ({ ...current, modelId: e.target.value }))} - /> -
-
- Provider model name - setNewModel(current => ({ ...current, modelName: e.target.value }))} - /> -
- setNewModel(current => ({ ...current, [field]: parseOptionalLimit(e.target.value) }))} - /> -
- ))} -
-
- -
- - )} - - {/* Filters */} -
- -
- updateModel(cfg.modelId, { providerId })} - options={providerOptions} - /> -
- Provider model name - updateModel(cfg.modelId, { modelName: e.target.value })} - /> -
-
- {(['rpm', 'rpd', 'tpm'] as const).map(field => { - const limitId = domId(`model-${field}`, cfg.modelId); - return ( -
- {field.toUpperCase()} - updateQuota(cfg.modelId, field, parseOptionalLimit(e.target.value))} - /> -
- ); - })} -
-
-
- )} - - ); - })} - - {filteredConfigs.length === 0 && ( -
- No models match the current filters. -
- )} - - )} - - - {/* ── System Information ────────────────────────────────────────────── */} -
{/* Version row */} -
- - - -
+
+
+

Version

-

Installed Codra release

- + v{pkg.version} - +
- {/* Changelog / links row */} + {/* License row */}
- - -
-

Releases

-

Browse all versions and release notes on GitHub

+

License

+ {pkg.license} +
+ +
+ + {/* Links */} +
+ {[ + { href: `${pkg.repository.url.replace(/\.git$/, '')}/releases/`, label: 'Releases', sub: 'Version history & notes' }, + { href: pkg.homepage, label: 'Homepage', sub: 'codra.run' }, + { href: pkg.bugs.url, label: 'Report an issue', sub: 'GitHub issue tracker' }, + ].map(({ href, label, sub }) => ( - View releases - + + + {label} + + + {sub} + -
- + ))}
diff --git a/src/client/pages/stats.tsx b/src/client/pages/stats.tsx index bd7e555..f0c9963 100644 --- a/src/client/pages/stats.tsx +++ b/src/client/pages/stats.tsx @@ -5,24 +5,11 @@ import { Bar, BarChart, CartesianGrid, - Line, - LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis, } from 'recharts'; -import { - Activity, - BarChart3, - Bot, - Gauge, - GitBranch, - RefreshCw, - Server, - TrendingUp, -} from 'lucide-react'; - import { TimeRangeSelect } from '@client/components/features/stats/time-range-select'; import { PageHeaderActions } from '@client/components/shared/page-header-actions'; import { PageHeader } from '@client/components/layout/page-header'; @@ -36,21 +23,16 @@ import { fmtNumber } from '@client/lib/utils'; import type { StatsPayload } from '@shared/schema'; const CHART = { - primary: '#84cc16', + primary: '#65a30d', primaryDark: '#e0fe56', comments: '#0ea5e9', - warning: '#f59e0b', - danger: '#ef4444', - quiet: '#64748b', - violet: '#8b5cf6', -}; - -const axisProps = { - fontSize: 11, - tickLine: false, - tickMargin: 10, - axisLine: false, - tick: { fontFamily: 'var(--font-sans)', fill: 'var(--muted-foreground)' } as const, + commentsDark: '#38bdf8', + warning: '#d97706', + warningDark: '#fbbf24', + danger: '#dc2626', + dangerDark: '#f87171', + quiet: '#94a3b8', + quietDark: '#64748b', }; function formatDay(value: string) { @@ -63,11 +45,6 @@ function formatCompact(value: number) { return value >= 1000 ? fmtNumber(value) : value.toLocaleString(); } -function ratio(numerator: number, denominator: number, decimals = 1) { - if (!denominator) return '0'; - return (numerator / denominator).toFixed(decimals); -} - function percent(numerator: number, denominator: number) { if (!denominator) return 0; return Math.round((numerator / denominator) * 100); @@ -111,135 +88,163 @@ function GraphSectionTitle() { function GraphShell({ title, - eyebrow, - icon: Icon, children, className = '', }: { title: string; - eyebrow: string; - icon: typeof Activity; children: ReactNode; className?: string; }) { return ( -
-
-
-

{eyebrow}

-

{title}

-
- - - +
+
+

{title}

- {children} +
{children}
); } -function GraphCardGallery({ stats, days, isDark }: { stats: StatsPayload; days: number; isDark: boolean }) { +function MetricsGrid({ stats, isDark }: { stats: StatsPayload; isDark: boolean }) { const color = isDark ? CHART.primaryDark : CHART.primary; - const tokenTrend = stats.trend.map((day) => ({ - ...day, - tokens: day.inputTokens + day.outputTokens, - tokenDensity: Math.round((day.inputTokens + day.outputTokens) / Math.max(day.jobs, 1)), - commentRate: Number(ratio(day.comments, Math.max(day.jobs, 1), 1)), - })); - const cumulativeTrend = stats.trend.reduce<{ day: string; reviews: number; comments: number }[]>((acc, day) => { - const prev = acc[acc.length - 1]; - acc.push({ - day: day.day, - reviews: (prev?.reviews ?? 0) + day.jobs, - comments: (prev?.comments ?? 0) + day.comments, - }); - return acc; - }, []); + const commentsColor = isDark ? CHART.commentsDark : CHART.comments; + const inputColor = isDark ? CHART.commentsDark : CHART.comments; + const outputColor = isDark ? CHART.warningDark : CHART.warning; + const dangerColor = isDark ? CHART.dangerDark : CHART.danger; + const quietColor = isDark ? CHART.quietDark : CHART.quiet; const repoMax = Math.max(...stats.topRepos.map((repo) => repo.jobs), 1); const modelMax = Math.max(...stats.models.map((model) => model.calls), 1); + // Theme-aware chart chrome. CSS variables don't reliably resolve inside + // Recharts SVG text, so use explicit colors keyed off the active theme. + const axisColor = isDark ? 'rgba(228,228,231,0.62)' : 'rgba(63,63,70,0.78)'; + const gridColor = isDark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)'; + const cursorColor = isDark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.05)'; + const axisProps = { + fontSize: 11, + tickLine: false, + tickMargin: 8, + axisLine: false, + tick: { fontFamily: 'var(--font-sans)', fill: axisColor }, + } as const; + // Let Recharts thin the labels by available space (respecting minTickGap) + // rather than a fixed stride keyed off the range — the trend array can have + // far fewer points than `days`, which would hide every label but the first. + const xAxisProps = { + dataKey: 'day', + tickFormatter: formatDay, + interval: 'preserveStartEnd' as const, + minTickGap: 24, + }; + + const STATUS_COLOR: Record = { + done: color, + running: commentsColor, + queued: quietColor, + failed: dangerColor, + superseded: quietColor, + cancelled: quietColor, + }; + const statusTotal = Math.max(stats.statuses.reduce((sum, s) => sum + s.count, 0), 1); + return ( -
+
-
- -
- - - - - - } /> - - - - -
-
- - -
+
+ +
- + - - - + + + + + + + - - - - } /> - + + + + } cursor={{ stroke: color, strokeDasharray: '4 4' }} /> + +
-
-
- -
+ +
- - - - - } /> - - + + + + + } cursor={{ fill: cursorColor }} /> + +
+
- -
- - - - - - } /> - - - +
+ +
+
+ {stats.statuses.map((s) => ( +
+ ))} +
+
+ {stats.statuses.map((s) => ( +
+
+ + {s.status} +
+ {s.count} +
+ ))} +
-
-
- -
- {stats.topRepos.slice(0, 7).map((repo) => ( + +
+ {stats.topRepos.slice(0, 8).map((repo) => (
-
+
{repo.owner}/{repo.repo}
-
+
@@ -249,16 +254,16 @@ function GraphCardGallery({ stats, days, isDark }: { stats: StatsPayload; days:
- -
- {stats.models.slice(0, 7).map((model) => ( + +
+ {stats.models.slice(0, 8).map((model) => (
-
+
{modelName(model.modelUsed)}
-
-
+
+
{model.calls} @@ -267,176 +272,6 @@ function GraphCardGallery({ stats, days, isDark }: { stats: StatsPayload; days:
- -
- ); -} - -function PanelHeader({ - label, - value, - detail, -}: { - label: string; - value?: string; - detail?: string; -}) { - return ( -
-
- - - -
-

{label}

- {detail &&

{detail}

} -
-
- {value && ( - - {value} - - )} -
- ); -} - -function ReviewFlowCard({ - stats, - days, - isDark, -}: { - stats: StatsPayload; - days: number; - isDark: boolean; -}) { - const color = isDark ? CHART.primaryDark : CHART.primary; - const totalReviews = stats.trend.reduce((sum, day) => sum + day.jobs, 0); - const maxDay = stats.trend.reduce( - (best, day) => (day.jobs > best.jobs ? day : best), - stats.trend[0] ?? { day: '', jobs: 0 }, - ); - - return ( -
-
-
- -
- - - - - - - - - - - - - - - - } cursor={{ stroke: color, strokeDasharray: '4 4' }} /> - - - - -
-
- - -
-
- ); -} - -function ReviewFlowSkeleton() { - return ( -
-
-
-
- - - -
- - -
-
-
-
-
- - -
-
- - - -
-
-
); } @@ -444,14 +279,10 @@ function ReviewFlowSkeleton() { function GraphCardSkeleton({ className = '' }: { className?: string }) { return (
-
-
- - -
- +
+
-
+
@@ -461,15 +292,11 @@ function GraphCardSkeleton({ className = '' }: { className?: string }) { function GraphBarCardSkeleton({ className = '' }: { className?: string }) { return (
-
-
- - -
- +
+
-
- {Array.from({ length: 5 }).map((_, i) => ( +
+ {Array.from({ length: 8 }).map((_, i) => (
@@ -483,21 +310,18 @@ function GraphBarCardSkeleton({ className = '' }: { className?: string }) { ); } -function GraphCardGallerySkeleton() { +function MetricsGridSkeleton() { return ( -
+