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..968e084 100644 --- a/README.md +++ b/README.md @@ -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 @@ -66,7 +65,7 @@ Codra listens to GitHub pull request events, runs AI-powered review jobs, posts - **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/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..289d725 100644 --- a/src/client/app.css +++ b/src/client/app.css @@ -903,8 +903,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 +914,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 +922,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 +980,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 +991,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/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/ui/confirm-dialog.tsx b/src/client/components/ui/confirm-dialog.tsx new file mode 100644 index 0000000..260be9c --- /dev/null +++ b/src/client/components/ui/confirm-dialog.tsx @@ -0,0 +1,59 @@ +import * as Dialog from '@radix-ui/react-dialog'; +import { AlertTriangle } from 'lucide-react'; +import { Button } from '@client/components/ui/button'; + +interface ConfirmDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + title: string; + description: string; + confirmLabel?: string; + cancelLabel?: string; + onConfirm: () => void; +} + +export function ConfirmDialog({ + open, + onOpenChange, + title, + description, + confirmLabel = 'Continue', + cancelLabel = 'Cancel', + 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/lib/api.ts b/src/client/lib/api.ts index d218b3c..dfd7ef2 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; @@ -202,17 +201,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 +218,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 +227,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/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/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..921b863 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'; @@ -42,7 +29,6 @@ const CHART = { warning: '#f59e0b', danger: '#ef4444', quiet: '#64748b', - violet: '#8b5cf6', }; const axisProps = { @@ -63,11 +49,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,224 +92,46 @@ 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, days, isDark }: { stats: StatsPayload; days: number; 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 repoMax = Math.max(...stats.topRepos.map((repo) => repo.jobs), 1); const modelMax = Math.max(...stats.models.map((model) => model.calls), 1); - return ( -
- - -
- -
- - - - - - } /> - - - - -
-
- - -
- - - - - - - - - - - - } /> - - - -
-
-
- -
- -
- - - - - - } /> - - - - -
-
- - -
- - - - - - } /> - - - -
-
-
- -
- -
- {stats.topRepos.slice(0, 7).map((repo) => ( -
-
-
- {repo.owner}/{repo.repo} -
-
-
-
-
- {repo.jobs} -
- ))} -
- - - -
- {stats.models.slice(0, 7).map((model) => ( -
-
-
- {modelName(model.modelUsed)} -
-
-
-
-
- {model.calls} -
- ))} -
- -
- -
- ); -} + const STATUS_COLOR: Record = { + done: color, + running: CHART.comments, + queued: CHART.quiet, + failed: CHART.danger, + superseded: CHART.quiet, + }; + const statusTotal = Math.max(stats.statuses.reduce((sum, s) => sum + s.count, 0), 1); -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 ( -
-
-
- -
+
+ +
- + @@ -340,12 +143,7 @@ function ReviewFlowCard({ - + } cursor={{ stroke: color, strokeDasharray: '4 4' }} />
-
- - -
-
- ); -} + -function ReviewFlowSkeleton() { - return ( -
-
-
-
- - - -
- - -
+ +
+ {stats.topRepos.slice(0, 8).map((repo) => ( +
+
+
+ {repo.owner}/{repo.repo} +
+
+
+
+
+ {repo.jobs} +
+ ))}
-
-
-
- - -
-
- - - + + + +
+ {stats.models.slice(0, 8).map((model) => ( +
+
+
+ {modelName(model.modelUsed)} +
+
+
+
+
+ {model.calls} +
+ ))}
-
+
); @@ -444,14 +255,10 @@ function ReviewFlowSkeleton() { function GraphCardSkeleton({ className = '' }: { className?: string }) { return (
-
-
- - -
- +
+
-
+
@@ -461,15 +268,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 +286,18 @@ function GraphBarCardSkeleton({ className = '' }: { className?: string }) { ); } -function GraphCardGallerySkeleton() { +function MetricsGridSkeleton() { return ( -
+