From 5e895e7a931aab1826df693c3d6fddc1c47f2f9f Mon Sep 17 00:00:00 2001 From: Devarshi Shimpi Date: Fri, 11 Sep 2026 22:06:38 +0530 Subject: [PATCH 01/57] docs: add GitHub Sponsors funding config and badge --- .github/FUNDING.yml | 1 + README.md | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) create mode 100644 .github/FUNDING.yml diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 00000000..bd243ec9 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: devarshishimpi diff --git a/README.md b/README.md index 91130fa7..201c692a 100644 --- a/README.md +++ b/README.md @@ -3,11 +3,12 @@

Self-hosted AI code review for GitHub pull requests.
- Cloudflare-native, queue-backed, repository-aware, and built for teams that want to own their review engine. + Cloudflare-native (including free plan), queue-backed, repository-aware, and built for teams that want to own their review engine.

License: AGPL-3.0 + Sponsor Cloudflare Workers Built with React TypeScript From db115ee8b19520eaea19f4b256858e161a4f8eee Mon Sep 17 00:00:00 2001 From: Devarshi Shimpi Date: Sun, 13 Sep 2026 04:23:15 +0530 Subject: [PATCH 02/57] feat: add onboarding card to dashboard sidebar - Add `OnboardingCard` to UI package and bump version to 0.9.6 - Refactor `TickMeter` chart primitive to use CSS Grid - Integrate `SidebarOnboarding` into `DashboardSidebar` --- .../features/dashboard/sidebar-onboarding.tsx | 109 +++++++++++++++ .../src/components/layout/app-shell.tsx | 78 ++++++++--- packages/ui/package.json | 2 +- .../ui/src/components/chart-primitives.tsx | 8 +- .../ui/src/components/onboarding-card.tsx | 128 ++++++++++++++++++ packages/ui/src/index.ts | 1 + 6 files changed, 301 insertions(+), 25 deletions(-) create mode 100644 apps/dashboard/src/components/features/dashboard/sidebar-onboarding.tsx create mode 100644 packages/ui/src/components/onboarding-card.tsx diff --git a/apps/dashboard/src/components/features/dashboard/sidebar-onboarding.tsx b/apps/dashboard/src/components/features/dashboard/sidebar-onboarding.tsx new file mode 100644 index 00000000..28baea54 --- /dev/null +++ b/apps/dashboard/src/components/features/dashboard/sidebar-onboarding.tsx @@ -0,0 +1,109 @@ +import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Skeleton, OnboardingCard, type OnboardingStep } from '@codraoss/ui'; +import { api } from '@client/lib/api'; +import type { AuthSessionUser } from '@codraoss/schema/api'; + +const STORAGE_KEY = 'codra_onboarding_dismissed'; + +interface SidebarOnboardingProps { + user: AuthSessionUser; + onVisibleChange?: (visible: boolean) => void; +} + +export function SidebarOnboarding({ user, onVisibleChange }: SidebarOnboardingProps) { + const [mounted, setMounted] = useState(false); + const [visible, setVisible] = useState(true); + const [steps, setSteps] = useState([]); + const [loading, setLoading] = useState(true); + const navigate = useNavigate(); + + useEffect(() => { + setMounted(true); + + // Do not show if previously dismissed permanently + if (localStorage.getItem(STORAGE_KEY)) { + setVisible(false); + onVisibleChange?.(false); + return; + } + + Promise.all([ + api.getRepos().catch(() => null), + api.getModelConfigs().catch(() => null), + ]).then(([reposRes, modelsRes]) => { + const hasRepos = Array.isArray(reposRes?.repos) && reposRes.repos.length > 0; + const enabledRepos = reposRes?.repos?.filter((r) => r.enabled) ?? []; + const hasEnabledRepo = enabledRepos.length > 0; + const hasProvider = Array.isArray(modelsRes?.providers) && modelsRes.providers.length > 0; + const hasModel = Array.isArray(modelsRes?.configs) && modelsRes.configs.length > 0; + const hasAI = hasProvider && hasModel; + + const loadedSteps: OnboardingStep[] = [ + { + id: 'repos-install', + label: 'Install the GitHub App', + done: hasRepos, + onClick: () => window.open('/api/repos/install', '_blank'), + }, + { + id: 'repos-enable', + label: 'Enable a repository', + done: hasEnabledRepo, + onClick: () => navigate('/repos'), + }, + { + id: 'ai-models', + label: 'Configure AI models', + done: hasAI, + onClick: () => navigate('/settings'), + }, + ]; + + setSteps(loadedSteps); + onVisibleChange?.(true); + }).finally(() => { + setLoading(false); + }); + }, [user, onVisibleChange, navigate]); + + const handleDismiss = () => { + localStorage.setItem(STORAGE_KEY, 'true'); + setVisible(false); + onVisibleChange?.(false); + }; + + if (!mounted || !visible) return null; + + if (loading) { + return ( +

+
+
+ + +
+
+ +
+
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+
+
+ ); + } + + return ( + + ); +} diff --git a/apps/dashboard/src/components/layout/app-shell.tsx b/apps/dashboard/src/components/layout/app-shell.tsx index fd88e91b..64b12a07 100644 --- a/apps/dashboard/src/components/layout/app-shell.tsx +++ b/apps/dashboard/src/components/layout/app-shell.tsx @@ -12,6 +12,7 @@ import { navItems as defaultNavItems } from '@client/nav'; import type { NavItem } from '@client/nav'; import { SessionProvider, useSession } from '@client/hooks/use-session'; import { useCan } from '@client/hooks/use-can'; +import { SidebarOnboarding } from '@client/components/features/dashboard/sidebar-onboarding'; export function AppShell({ navItems = defaultNavItems }: { navItems?: NavItem[] } = {}) { return ( @@ -55,6 +56,7 @@ function AppShellInner({ navItems }: { navItems: NavItem[] }) { const { theme, toggleTheme } = useTheme(); const { user: sessionUser } = useSession(); const [mobileMenuOpen, setMobileMenuOpen] = useState(false); + const [isOnboardingVisible, setIsOnboardingVisible] = useState(true); useEffect(() => { const timers = new WeakMap(); @@ -74,7 +76,6 @@ function AppShellInner({ navItems }: { navItems: NavItem[] }) { return (
- {mobileMenuOpen && ( + )} +
+ + {/* Segmented bar */} +
+ {Array.from({ length: segments }).map((_, i) => ( + + ))} +
+ + {/* Step list */} +
+ {steps.map((step) => { + const content = ( +
+ {/* Check circle */} + + + + + {/* Text */} + {step.label} +
+ ); + + if (step.onClick) { + return ( + + ); + } + + return
{content}
; + })} +
+ + + ); +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index af1668fa..c850e074 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -17,6 +17,7 @@ export { CopyButton } from './components/copy-button'; export { BarSparkline } from './components/bar-sparkline'; export { GithubMark } from './components/github-mark'; export { LoadError } from './components/load-error'; +export { OnboardingCard, type OnboardingStep, type OnboardingCardProps } from './components/onboarding-card'; // Chart primitives export { GraphShell, LegendChip, SeriesMarker, ChartDefs, MeterList, TickMeter, CardDots } from './components/chart-primitives'; From 2b5a3f502c188de67e8df84c961d0187b6b21edd Mon Sep 17 00:00:00 2001 From: Anant Date: Fri, 18 Sep 2026 22:34:02 +0530 Subject: [PATCH 03/57] fix: update landing page for issue 110 --- apps/dashboard/src/pages/landing.tsx | 208 ++++++++------------------- 1 file changed, 59 insertions(+), 149 deletions(-) diff --git a/apps/dashboard/src/pages/landing.tsx b/apps/dashboard/src/pages/landing.tsx index 4040c0a8..33ebd84f 100644 --- a/apps/dashboard/src/pages/landing.tsx +++ b/apps/dashboard/src/pages/landing.tsx @@ -1,177 +1,87 @@ import { Button, GithubMark, LinkButton } from '@codraoss/ui'; -import { Sun, Moon, ExternalLink } from 'lucide-react'; +import { Sun, Moon } from 'lucide-react'; import { useTheme } from '@codraoss/ui/theme'; -import codraDark from '@/assets/codra-fullicon-dark.svg'; -import codraLight from '@/assets/codra-fullicon-light.svg'; -const FEATURES = [ - { - title: 'Understands your codebase', - desc: 'Reviews diffs with full context from the surrounding code, not just the changed lines.', - }, - { - title: 'Flags real issues', - desc: 'Security vulnerabilities, logic errors, and pattern violations - surfaced before merge.', - }, - { - title: 'Configurable per repo', - desc: 'Set review depth, model chain, and strictness from the dashboard. No config files.', - }, -]; +const TERMS_URL = 'https://codra.run/terms'; +const PRIVACY_URL = 'https://codra.run/privacy'; -/** Faint dotted-grid texture, faded out toward the bottom - the "control panel" backdrop. */ const dotGrid = { backgroundImage: - 'radial-gradient(circle, color-mix(in oklch, var(--ui-line) 75%, transparent) 1px, transparent 1px)', + 'radial-gradient(circle, var(--ui-line) 1px, transparent 1.5px)', backgroundSize: '22px 22px', - maskImage: 'radial-gradient(ellipse 90% 80% at 30% 0%, black, transparent 80%)', - WebkitMaskImage: 'radial-gradient(ellipse 90% 80% at 30% 0%, black, transparent 80%)', + maskImage: + 'radial-gradient(ellipse 85% 75% at 50% 45%, black 35%, rgba(0, 0, 0, 0.35) 100%)', + WebkitMaskImage: + 'radial-gradient(ellipse 85% 75% at 50% 45%, black 35%, rgba(0, 0, 0, 0.35) 100%)', }; function DotGrid() { return
; } +const legalLink = + 'rounded-sm text-ui-default underline-offset-2 transition-colors hover:text-ui-strong hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ui-brand'; + export function LandingPage() { const { theme, toggleTheme } = useTheme(); return ( -
- -
-
- Codra -
- - - - - + + + + +
+
+
+
- Sign in - -
-
-
- -
- -
- + C +
-
-

- AI code review
- on every PR. +

+ Sign in to Codra

- -

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

+ Use your GitHub account to open the dashboard.

- -
- } - className="!rounded-sm" - > - Get started with GitHub - - - Read the docs - -
- + . +

- -
- - -
- - What it does - - -
- -
- {FEATURES.map((item, i) => ( -
- - -
- - {String(i + 1).padStart(2, '0')} - -

{item.title}

-
- -

- {item.desc} -

-
- ))} -
-
-
); -} +} \ No newline at end of file From 345ff4cbf45dfff432c3e9f80496d6c25ebe1a46 Mon Sep 17 00:00:00 2001 From: Anant Date: Fri, 18 Sep 2026 22:50:43 +0530 Subject: [PATCH 04/57] fix: use Codra icon on login page --- apps/dashboard/src/pages/landing.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/dashboard/src/pages/landing.tsx b/apps/dashboard/src/pages/landing.tsx index 33ebd84f..ddffbf48 100644 --- a/apps/dashboard/src/pages/landing.tsx +++ b/apps/dashboard/src/pages/landing.tsx @@ -43,12 +43,12 @@ export function LandingPage() {
-
- C +
+ Codra

From 340e960f9b915a21aad8908319fe18fb319d4faf Mon Sep 17 00:00:00 2001 From: Anant Date: Fri, 18 Sep 2026 23:11:49 +0530 Subject: [PATCH 05/57] fix: update legal statement --- apps/dashboard/src/pages/landing.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/dashboard/src/pages/landing.tsx b/apps/dashboard/src/pages/landing.tsx index ddffbf48..67773fc3 100644 --- a/apps/dashboard/src/pages/landing.tsx +++ b/apps/dashboard/src/pages/landing.tsx @@ -70,7 +70,7 @@ export function LandingPage() {

- By continuing, you agree to GitHub's{' '} + By continuing, you agree to Codra's{' '} Terms of Service {' '} From 53088234e4a45b5afa97bd1db1fc9742b5e8bbb0 Mon Sep 17 00:00:00 2001 From: Devarshi Shimpi Date: Fri, 18 Sep 2026 23:39:13 +0530 Subject: [PATCH 06/57] rm: duplicate login route --- .../src/components/layout/account-menu.tsx | 2 +- apps/dashboard/src/lib/api.ts | 8 +- apps/dashboard/src/pages/login.tsx | 102 ------------------ apps/dashboard/src/routes.tsx | 6 -- apps/worker/wrangler.jsonc | 1 - 5 files changed, 5 insertions(+), 114 deletions(-) delete mode 100644 apps/dashboard/src/pages/login.tsx diff --git a/apps/dashboard/src/components/layout/account-menu.tsx b/apps/dashboard/src/components/layout/account-menu.tsx index df91406c..0fefcf9b 100644 --- a/apps/dashboard/src/components/layout/account-menu.tsx +++ b/apps/dashboard/src/components/layout/account-menu.tsx @@ -162,7 +162,7 @@ export function AccountMenu({ user }: { user: AuthSessionUser }) { onClick={async () => { setOpen(false); await api.logout(); - location.href = '/login'; + location.href = '/'; }} > (input: string, init?: RequestInit) { }); if (response.status === 401) { - if (location.pathname !== '/login') { - location.href = '/login'; + if (location.pathname !== '/') { + location.href = '/'; } throw new Error('Unauthorized'); } @@ -92,8 +92,8 @@ async function requestWithMeta(input: string, init?: RequestInit) { }); if (response.status === 401) { - if (location.pathname !== '/login') { - location.href = '/login'; + if (location.pathname !== '/') { + location.href = '/'; } throw new Error('Unauthorized'); } diff --git a/apps/dashboard/src/pages/login.tsx b/apps/dashboard/src/pages/login.tsx deleted file mode 100644 index 3cccb8fe..00000000 --- a/apps/dashboard/src/pages/login.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import { GithubMark } from '@codraoss/ui'; -import { useMemo } from 'react'; -import { useSearchParams, Link } from 'react-router-dom'; -import { Sun, Moon, ShieldCheck, ArrowLeft, AlertCircle } from 'lucide-react'; -import { useTheme } from '@codraoss/ui/theme'; -import codraDark from '@/assets/codra-fullicon-dark.svg'; -import codraLight from '@/assets/codra-fullicon-light.svg'; - -function getErrorMessage(error: string | null) { - switch (error) { - case 'not_allowed': - return 'This GitHub account is not allowed to access the Codra dashboard.'; - case 'access_denied': - return 'GitHub sign-in was cancelled before authorization completed.'; - case 'invalid_state': - return 'Your sign-in session expired. Please try signing in with GitHub again.'; - case 'invalid_callback': - return 'GitHub did not return a valid callback. Please try again.'; - case 'oauth_failed': - return 'GitHub sign-in failed while completing the OAuth flow.'; - default: - return null; - } -} - -export function LoginPage() { - const { theme, toggleTheme } = useTheme(); - const [searchParams] = useSearchParams(); - const error = useMemo(() => getErrorMessage(searchParams.get('error')), [searchParams]); - - return ( -

- - - - Home - - - -
- -
-
- -
- -

- Only authorized GitHub users can access this instance. -

-
-
-
- ); -} diff --git a/apps/dashboard/src/routes.tsx b/apps/dashboard/src/routes.tsx index 5718d53c..ee2d5e6e 100644 --- a/apps/dashboard/src/routes.tsx +++ b/apps/dashboard/src/routes.tsx @@ -8,7 +8,6 @@ import type { NavItem } from '@client/nav'; const LandingPage = React.lazy(() => import('./pages/landing').then(m => ({ default: m.LandingPage }))); const DashboardPage = React.lazy(() => import('./pages/dashboard').then(m => ({ default: m.DashboardPage }))); -const LoginPage = React.lazy(() => import('./pages/login').then(m => ({ default: m.LoginPage }))); const JobsPage = React.lazy(() => import('./pages/jobs').then(m => ({ default: m.JobsPage }))); const JobDetailPage = React.lazy(() => import('./pages/job-detail').then(m => ({ default: m.JobDetailPage }))); const JobLogsPage = React.lazy(() => import('./pages/job-logs').then(m => ({ default: m.JobLogsPage }))); @@ -31,11 +30,6 @@ export const publicRoutes: RouteObject[] = [ element: withSuspense(LandingPage, true), errorElement: , }, - { - path: '/login', - element: withSuspense(LoginPage, true), - errorElement: , - }, ]; // A boundary per child too, not just on the layout: React Router replaces the whole matched branch, so a branch-only boundary would take the sidebar and header down with a single page. diff --git a/apps/worker/wrangler.jsonc b/apps/worker/wrangler.jsonc index f0e162f5..d55c8e1a 100644 --- a/apps/worker/wrangler.jsonc +++ b/apps/worker/wrangler.jsonc @@ -76,7 +76,6 @@ "not_found_handling": "single-page-application", "run_worker_first": [ "/", - "/login", "/dashboard*", "/jobs*", "/repos*", From 6642d470676fd8a541a6eb030c5518fab87c0ddc Mon Sep 17 00:00:00 2001 From: Devarshi Shimpi Date: Fri, 18 Sep 2026 23:44:00 +0530 Subject: [PATCH 07/57] fix: failing login route dashboard test --- test/e2e/dashboard.spec.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/e2e/dashboard.spec.tsx b/test/e2e/dashboard.spec.tsx index c8cc41f6..9af5177f 100644 --- a/test/e2e/dashboard.spec.tsx +++ b/test/e2e/dashboard.spec.tsx @@ -3,7 +3,7 @@ */ import { expect, it, describe, vi, beforeEach } from 'vitest'; import { render, screen } from '@testing-library/react'; -import { LoginPage } from '@client/pages/login'; +import { LandingPage } from '@client/pages/landing'; import { DashboardPage } from '@client/pages/dashboard'; import { MemoryRouter } from 'react-router-dom'; import { api } from '@client/lib/api'; @@ -34,12 +34,12 @@ describe('Frontend UI Flows (JSDOM)', () => { render( - + ); - const signInLink = screen.getByRole('link', { name: 'Sign in with GitHub' }); + const signInLink = screen.getByRole('link', { name: /continue with github/i }); expect(signInLink.getAttribute('href')).toBe('/auth/github'); }); From a660f86cc5c9b52fa7679c96aaa05b00c3138985 Mon Sep 17 00:00:00 2001 From: Devarshi Shimpi Date: Fri, 18 Sep 2026 23:45:24 +0530 Subject: [PATCH 08/57] fix: update expected routes in dashboard test --- test/e2e/router-extensions.spec.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/e2e/router-extensions.spec.tsx b/test/e2e/router-extensions.spec.tsx index 69f2489e..0ffaa0ca 100644 --- a/test/e2e/router-extensions.spec.tsx +++ b/test/e2e/router-extensions.spec.tsx @@ -41,7 +41,7 @@ describe('Dashboard route and nav registries (JSDOM)', () => { }); it('exposes the built-in routes as composable arrays', () => { - expect(publicRoutes.map((r) => r.path)).toEqual(['/', '/login']); + expect(publicRoutes.map((r) => r.path)).toEqual(['/']); expect(shellRoutes.map((r) => r.path)).toContain('dashboard'); expect([...publicRoutes, ...shellRoutes].map((r) => r.path)).not.toContain('*'); }); From da4471930644d01042496a447669989b9675c46a Mon Sep 17 00:00:00 2001 From: Devarshi Shimpi Date: Sat, 19 Sep 2026 00:08:48 +0530 Subject: [PATCH 09/57] fix: handle errors in onboarding sidebar and update visibility logic --- .../features/dashboard/sidebar-onboarding.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/dashboard/src/components/features/dashboard/sidebar-onboarding.tsx b/apps/dashboard/src/components/features/dashboard/sidebar-onboarding.tsx index 28baea54..ca3599ec 100644 --- a/apps/dashboard/src/components/features/dashboard/sidebar-onboarding.tsx +++ b/apps/dashboard/src/components/features/dashboard/sidebar-onboarding.tsx @@ -16,6 +16,7 @@ export function SidebarOnboarding({ user, onVisibleChange }: SidebarOnboardingPr const [visible, setVisible] = useState(true); const [steps, setSteps] = useState([]); const [loading, setLoading] = useState(true); + const [error, setError] = useState(false); const navigate = useNavigate(); useEffect(() => { @@ -29,8 +30,8 @@ export function SidebarOnboarding({ user, onVisibleChange }: SidebarOnboardingPr } Promise.all([ - api.getRepos().catch(() => null), - api.getModelConfigs().catch(() => null), + api.getRepos(), + api.getModelConfigs(), ]).then(([reposRes, modelsRes]) => { const hasRepos = Array.isArray(reposRes?.repos) && reposRes.repos.length > 0; const enabledRepos = reposRes?.repos?.filter((r) => r.enabled) ?? []; @@ -62,6 +63,9 @@ export function SidebarOnboarding({ user, onVisibleChange }: SidebarOnboardingPr setSteps(loadedSteps); onVisibleChange?.(true); + }).catch(() => { + setError(true); + onVisibleChange?.(false); }).finally(() => { setLoading(false); }); @@ -73,7 +77,7 @@ export function SidebarOnboarding({ user, onVisibleChange }: SidebarOnboardingPr onVisibleChange?.(false); }; - if (!mounted || !visible) return null; + if (!mounted || !visible || error) return null; if (loading) { return ( From b2d4a647786dd6eee837bb4df9cd9c8eacff91e8 Mon Sep 17 00:00:00 2001 From: Devarshi Shimpi Date: Sat, 19 Sep 2026 00:29:05 +0530 Subject: [PATCH 10/57] fix: address code review findings in onboarding UI --- .../src/components/layout/app-shell.tsx | 25 ------------------- packages/ui/package.json | 2 +- .../ui/src/components/chart-primitives.tsx | 2 +- .../ui/src/components/onboarding-card.tsx | 9 ++++--- test/e2e/dashboard.spec.tsx | 4 +++ test/e2e/router-extensions.spec.tsx | 8 +++++- 6 files changed, 18 insertions(+), 32 deletions(-) diff --git a/apps/dashboard/src/components/layout/app-shell.tsx b/apps/dashboard/src/components/layout/app-shell.tsx index 64b12a07..79ea469c 100644 --- a/apps/dashboard/src/components/layout/app-shell.tsx +++ b/apps/dashboard/src/components/layout/app-shell.tsx @@ -152,31 +152,6 @@ function AppShellInner({ navItems }: { navItems: NavItem[] }) {
- {isOnboardingVisible && ( - - )} - {sessionUser && ( ( ))} diff --git a/packages/ui/src/components/onboarding-card.tsx b/packages/ui/src/components/onboarding-card.tsx index 2ec3ef6e..93f93992 100644 --- a/packages/ui/src/components/onboarding-card.tsx +++ b/packages/ui/src/components/onboarding-card.tsx @@ -31,10 +31,11 @@ export function OnboardingCard({ const doneCount = steps.filter((s) => s.done).length; const totalSteps = steps.length; const allDone = doneCount === totalSteps && totalSteps > 0; - + const safeSegments = Math.max(1, segments); + const filledCount = doneCount > 0 - ? Math.max(1, Math.round((doneCount / totalSteps) * segments)) + ? Math.max(1, Math.round((doneCount / totalSteps) * safeSegments)) : 0; return ( @@ -65,11 +66,11 @@ export function OnboardingCard({ {/* Segmented bar */}
- {Array.from({ length: segments }).map((_, i) => ( + {Array.from({ length: safeSegments }).map((_, i) => ( ({ subscribeUpdates: vi.fn(), getStats: vi.fn(), getJobs: vi.fn(), + getRepos: vi.fn(), + getModelConfigs: vi.fn(), } })); @@ -28,6 +30,8 @@ describe('Frontend UI Flows (JSDOM)', () => { email: 'user@example.com', updatedAt: new Date().toISOString(), }); + vi.mocked(api.getRepos).mockResolvedValue({ repos: [] }); + vi.mocked(api.getModelConfigs).mockResolvedValue({ providers: [], configs: [] }); }); it('renders the GitHub sign-in flow', async () => { diff --git a/test/e2e/router-extensions.spec.tsx b/test/e2e/router-extensions.spec.tsx index 69f2489e..2e752cd8 100644 --- a/test/e2e/router-extensions.spec.tsx +++ b/test/e2e/router-extensions.spec.tsx @@ -11,7 +11,11 @@ import { buildRouter, publicRoutes, shellRoutes } from '@client/routes'; import { navItems } from '@client/nav'; vi.mock('@client/lib/api', () => ({ - api: { getSession: vi.fn() }, + api: { + getSession: vi.fn(), + getRepos: vi.fn(), + getModelConfigs: vi.fn(), + }, })); // createBrowserRouter captures the URL when it is created, so window.history must be set before buildRouter runs. @@ -38,6 +42,8 @@ describe('Dashboard route and nav registries (JSDOM)', () => { signedInAt: new Date().toISOString(), }, }); + vi.mocked(api.getRepos).mockResolvedValue({ repos: [] }); + vi.mocked(api.getModelConfigs).mockResolvedValue({ providers: [], configs: [] }); }); it('exposes the built-in routes as composable arrays', () => { From d1e57aa2b1ad6cda0b170f0eae42ff7f39f7a6ed Mon Sep 17 00:00:00 2001 From: Devarshi Shimpi Date: Sat, 19 Sep 2026 00:34:22 +0530 Subject: [PATCH 11/57] fix: update version in package.json and remove aria-label from onboarding card --- packages/ui/package.json | 2 +- packages/ui/src/components/onboarding-card.tsx | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/ui/package.json b/packages/ui/package.json index 799c2d5c..ac17b5dd 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@codraoss/ui", - "version": "0.9.7", + "version": "0.9.9", "description": "Codra's reusable React design-system primitives and hooks.", "author": "Devarshi Shimpi", "license": "AGPL-3.0-only", diff --git a/packages/ui/src/components/onboarding-card.tsx b/packages/ui/src/components/onboarding-card.tsx index 93f93992..5f722622 100644 --- a/packages/ui/src/components/onboarding-card.tsx +++ b/packages/ui/src/components/onboarding-card.tsx @@ -67,7 +67,6 @@ export function OnboardingCard({
{Array.from({ length: safeSegments }).map((_, i) => ( From 178a0bd987e4edc345f185aafb08ffaa63e54d20 Mon Sep 17 00:00:00 2001 From: Devarshi Shimpi Date: Sat, 19 Sep 2026 04:12:51 +0530 Subject: [PATCH 12/57] fix: update dependencies --- package-lock.json | 440 ++++++++++++++++++++++++---------------------- package.json | 2 +- 2 files changed, 235 insertions(+), 207 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2d4a5833..1056c7a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -905,9 +905,9 @@ } }, "node_modules/@cloudflare/workerd-darwin-64": { - "version": "1.20260801.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260801.1.tgz", - "integrity": "sha512-wuJWbXpKvncJi1P0GKS+iYpN5tHdb7JPJJ/+6ZQe8zzovHHVMkLJPNBsgWpqeUhpM3g9qTwEKd2rglNKejuh5A==", + "version": "1.20260918.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260918.1.tgz", + "integrity": "sha512-H5Em6Wd0jjxaloYh2rp+WLBl2eWbkk7nSP1svGt6K1RSv/rNVqmKdpjZPJTBSavOmeYGa88qvtOWwS+33OHTqQ==", "cpu": [ "x64" ], @@ -922,9 +922,9 @@ } }, "node_modules/@cloudflare/workerd-darwin-arm64": { - "version": "1.20260801.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260801.1.tgz", - "integrity": "sha512-kwoZiTpnhNrF3+APx84Q/oAqvJ3sU9yefGagwm/ASaH/2W19x0vghkW/r4qCoHCK0WW7EPugZ+aXjgPMRtlq1Q==", + "version": "1.20260918.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260918.1.tgz", + "integrity": "sha512-CR9JRZEQo93fNgBVF4Df2H2/VYO4n7rxSseSwCVv3bJQEb0huADUSCj2FK4ipxar8ToOEB4wawyw0vJo5U/rQQ==", "cpu": [ "arm64" ], @@ -939,9 +939,9 @@ } }, "node_modules/@cloudflare/workerd-linux-64": { - "version": "1.20260801.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260801.1.tgz", - "integrity": "sha512-r0vAxCZH+Jih9Unm1yoyiByPNWNgawcKciOHDm5Q37ZVGOkKLsT9AtLe3yLSaul76WrKqtf+JP2n0WW32VBLJg==", + "version": "1.20260918.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260918.1.tgz", + "integrity": "sha512-UQ2nnY3qpXLzQ80frmWO+8HvtqyWaQILe8QYZwpemdjT+sqwCz4Dz+0/WVkFco0v/04kIIqikVeLTY/7gEhmkw==", "cpu": [ "x64" ], @@ -956,9 +956,9 @@ } }, "node_modules/@cloudflare/workerd-linux-arm64": { - "version": "1.20260801.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260801.1.tgz", - "integrity": "sha512-zWgpdZtSozvIgzQNmQiDSF8yEOQJUkRAWNsDXXzAAoy+fCn8YUoSibj3mpFSbZRvbUldeBEaW6SCdC2VEMkhNQ==", + "version": "1.20260918.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260918.1.tgz", + "integrity": "sha512-4rib51MaLNWweUIUxM/Xj558M5QmyZoBSf0ffv+lYah5VTrvwnez3XGxX72pElnBKHROvAPOi5msQ5Ts8JSI0A==", "cpu": [ "arm64" ], @@ -973,9 +973,9 @@ } }, "node_modules/@cloudflare/workerd-windows-64": { - "version": "1.20260801.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260801.1.tgz", - "integrity": "sha512-2oQz+Ksu4ji6e/+ZoYX+tWQEcxAii2p7l+iR8kx48W1llMalaufAsmVxTlk+3/vrM7D3/2c0iK448e0UQTcIMg==", + "version": "1.20260918.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260918.1.tgz", + "integrity": "sha512-sATrMx5ShYYgmgUGrcTmvsFSJBFuN95NEkX3xwb1qk8w1A6h5N11sea7yN2IeibwPyPmXKjWNjXOno0hZAM71Q==", "cpu": [ "x64" ], @@ -1909,9 +1909,9 @@ } }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.35.2", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.2.tgz", - "integrity": "sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz", + "integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==", "cpu": [ "arm64" ], @@ -1928,13 +1928,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.3.1" + "@img/sharp-libvips-darwin-arm64": "1.3.3" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.35.2", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.2.tgz", - "integrity": "sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz", + "integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==", "cpu": [ "x64" ], @@ -1951,13 +1951,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.3.1" + "@img/sharp-libvips-darwin-x64": "1.3.3" } }, "node_modules/@img/sharp-freebsd-wasm32": { - "version": "0.35.2", - "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.2.tgz", - "integrity": "sha512-YoAxdnd8hPUkvLHd3bWY+YA8nw3xM/RyRopYucNsWHVSan8NLVM3X2volsfoRDcXdUJPg6tXahSd7HXPK7lRnw==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz", + "integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==", "dev": true, "license": "Apache-2.0", "optional": true, @@ -1965,7 +1965,7 @@ "freebsd" ], "dependencies": { - "@img/sharp-wasm32": "0.35.2" + "@img/sharp-wasm32": "0.35.4" }, "engines": { "node": ">=20.9.0" @@ -1975,9 +1975,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.1.tgz", - "integrity": "sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz", + "integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==", "cpu": [ "arm64" ], @@ -1992,9 +1992,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.1.tgz", - "integrity": "sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz", + "integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==", "cpu": [ "x64" ], @@ -2009,9 +2009,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.1.tgz", - "integrity": "sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz", + "integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==", "cpu": [ "arm" ], @@ -2029,9 +2029,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.1.tgz", - "integrity": "sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz", + "integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==", "cpu": [ "arm64" ], @@ -2049,9 +2049,9 @@ } }, "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.1.tgz", - "integrity": "sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz", + "integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==", "cpu": [ "ppc64" ], @@ -2069,9 +2069,9 @@ } }, "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.1.tgz", - "integrity": "sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz", + "integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==", "cpu": [ "riscv64" ], @@ -2089,9 +2089,9 @@ } }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.1.tgz", - "integrity": "sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz", + "integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==", "cpu": [ "s390x" ], @@ -2109,9 +2109,9 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.1.tgz", - "integrity": "sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz", + "integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==", "cpu": [ "x64" ], @@ -2129,9 +2129,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.1.tgz", - "integrity": "sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz", + "integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==", "cpu": [ "arm64" ], @@ -2149,9 +2149,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.1.tgz", - "integrity": "sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz", + "integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==", "cpu": [ "x64" ], @@ -2169,9 +2169,9 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.35.2", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.2.tgz", - "integrity": "sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz", + "integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==", "cpu": [ "arm" ], @@ -2191,13 +2191,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.3.1" + "@img/sharp-libvips-linux-arm": "1.3.3" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.35.2", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.2.tgz", - "integrity": "sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz", + "integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==", "cpu": [ "arm64" ], @@ -2217,13 +2217,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.3.1" + "@img/sharp-libvips-linux-arm64": "1.3.3" } }, "node_modules/@img/sharp-linux-ppc64": { - "version": "0.35.2", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.2.tgz", - "integrity": "sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz", + "integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==", "cpu": [ "ppc64" ], @@ -2243,13 +2243,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.3.1" + "@img/sharp-libvips-linux-ppc64": "1.3.3" } }, "node_modules/@img/sharp-linux-riscv64": { - "version": "0.35.2", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.2.tgz", - "integrity": "sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz", + "integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==", "cpu": [ "riscv64" ], @@ -2269,13 +2269,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.3.1" + "@img/sharp-libvips-linux-riscv64": "1.3.3" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.35.2", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.2.tgz", - "integrity": "sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz", + "integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==", "cpu": [ "s390x" ], @@ -2295,13 +2295,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.3.1" + "@img/sharp-libvips-linux-s390x": "1.3.3" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.35.2", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.2.tgz", - "integrity": "sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz", + "integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==", "cpu": [ "x64" ], @@ -2321,13 +2321,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.3.1" + "@img/sharp-libvips-linux-x64": "1.3.3" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.35.2", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.2.tgz", - "integrity": "sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz", + "integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==", "cpu": [ "arm64" ], @@ -2347,13 +2347,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.3.1" + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.35.2", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.2.tgz", - "integrity": "sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz", + "integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==", "cpu": [ "x64" ], @@ -2373,18 +2373,18 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.3.1" + "@img/sharp-libvips-linuxmusl-x64": "1.3.3" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.35.2", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.2.tgz", - "integrity": "sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz", + "integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==", "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { - "@emnapi/runtime": "^1.11.1" + "@emnapi/runtime": "^1.11.3" }, "engines": { "node": ">=20.9.0" @@ -2393,10 +2393,21 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@img/sharp-wasm32/node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@img/sharp-webcontainers-wasm32": { - "version": "0.35.2", - "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.2.tgz", - "integrity": "sha512-QNV27pxs9wpApEiCfvHM1RDoP1w1+2KrUWWDPEhEwg+latvOrfuhWrHWZKwdSFwU6jh3myjw/yOCRsUIuOft3g==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz", + "integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==", "cpu": [ "wasm32" ], @@ -2404,7 +2415,7 @@ "license": "Apache-2.0", "optional": true, "dependencies": { - "@img/sharp-wasm32": "0.35.2" + "@img/sharp-wasm32": "0.35.4" }, "engines": { "node": ">=20.9.0" @@ -2414,9 +2425,9 @@ } }, "node_modules/@img/sharp-win32-arm64": { - "version": "0.35.2", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.2.tgz", - "integrity": "sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz", + "integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==", "cpu": [ "arm64" ], @@ -2434,9 +2445,9 @@ } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.35.2", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.2.tgz", - "integrity": "sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz", + "integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==", "cpu": [ "ia32" ], @@ -2454,9 +2465,9 @@ } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.35.2", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.2.tgz", - "integrity": "sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz", + "integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==", "cpu": [ "x64" ], @@ -3409,9 +3420,9 @@ } }, "node_modules/@speed-highlight/core": { - "version": "1.2.23", - "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.23.tgz", - "integrity": "sha512-iRoq6i6JDJP6Mt2A5JaPvzw0pgYHH6k92ij+yXiTrB7T2y9N789aWE3EHWj/5ztlJBokcCBja3iYLVdu5wgnkg==", + "version": "1.2.24", + "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.24.tgz", + "integrity": "sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw==", "dev": true, "license": "CC0-1.0" }, @@ -3761,6 +3772,8 @@ }, "node_modules/@types/chai": { "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", "dev": true, "license": "MIT", "dependencies": { @@ -3822,6 +3835,8 @@ }, "node_modules/@types/deep-eql": { "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", "dev": true, "license": "MIT" }, @@ -4546,16 +4561,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", - "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.10", - "@vitest/utils": "4.1.10", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -4564,13 +4579,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", - "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.10", + "@vitest/spy": "4.1.11", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -4591,9 +4606,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", - "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", "dev": true, "license": "MIT", "dependencies": { @@ -4604,13 +4619,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", - "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.10", + "@vitest/utils": "4.1.11", "pathe": "^2.0.3" }, "funding": { @@ -4618,14 +4633,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", - "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.10", - "@vitest/utils": "4.1.10", + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -4634,9 +4649,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", - "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", "dev": true, "license": "MIT", "funding": { @@ -4644,13 +4659,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", - "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.10", + "@vitest/pretty-format": "4.1.11", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -4750,6 +4765,8 @@ }, "node_modules/assertion-error": { "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", "dev": true, "license": "MIT", "engines": { @@ -4904,6 +4921,8 @@ }, "node_modules/chai": { "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", "dev": true, "license": "MIT", "engines": { @@ -6033,6 +6052,8 @@ }, "node_modules/estree-walker": { "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", "dev": true, "license": "MIT", "dependencies": { @@ -6488,9 +6509,9 @@ } }, "node_modules/hono": { - "version": "4.13.1", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.1.tgz", - "integrity": "sha512-kdJoFVv2xmayw6cY09H7AbMJMt8Jn5jdlEdXsP7AGBdF2DIptVlKlOLKXP41yPip4/a3yQPv9gVcJYI8YY04dw==", + "version": "4.13.8", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.8.tgz", + "integrity": "sha512-/Gng7NfoykZl2pjukW5Z6+8Yxm3BPRf86GTbQnt0SbySkvax4fyL4H3HhY1cCpBGmiW9XDRFzRV+CXK2W8QudQ==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -8099,16 +8120,16 @@ } }, "node_modules/miniflare": { - "version": "5.20260801.1-alpha", - "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-5.20260801.1-alpha.tgz", - "integrity": "sha512-BHPVzIDA6mbx7LefxpvkXW7DHx9FKB9GorZatbnrrFTt3CVMU8zuUpbgyCuebwDKcTTOZos43ta8GQ0eMVEpxA==", + "version": "5.20260918.0-alpha", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-5.20260918.0-alpha.tgz", + "integrity": "sha512-vyIes7yW/OTzHtz4GhcBCTohZfqk2eQNiIkngZ07VRA5Qu24aNgW/TrFwlPkMLMjWDQ8R4JJBBHR/KX+liSDtg==", "dev": true, "license": "MIT", "dependencies": { "@cspotcode/source-map-support": "0.8.1", - "sharp": "0.35.2", + "sharp": "0.35.4", "undici": "7.29.0", - "workerd": "1.20260801.1", + "workerd": "1.20260918.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" }, @@ -9115,15 +9136,15 @@ "license": "MIT" }, "node_modules/sharp": { - "version": "0.35.2", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.2.tgz", - "integrity": "sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz", + "integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==", "dev": true, "license": "Apache-2.0", "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", - "semver": "^7.8.4" + "semver": "^7.8.5" }, "engines": { "node": ">=20.9.0" @@ -9132,31 +9153,36 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.35.2", - "@img/sharp-darwin-x64": "0.35.2", - "@img/sharp-freebsd-wasm32": "0.35.2", - "@img/sharp-libvips-darwin-arm64": "1.3.1", - "@img/sharp-libvips-darwin-x64": "1.3.1", - "@img/sharp-libvips-linux-arm": "1.3.1", - "@img/sharp-libvips-linux-arm64": "1.3.1", - "@img/sharp-libvips-linux-ppc64": "1.3.1", - "@img/sharp-libvips-linux-riscv64": "1.3.1", - "@img/sharp-libvips-linux-s390x": "1.3.1", - "@img/sharp-libvips-linux-x64": "1.3.1", - "@img/sharp-libvips-linuxmusl-arm64": "1.3.1", - "@img/sharp-libvips-linuxmusl-x64": "1.3.1", - "@img/sharp-linux-arm": "0.35.2", - "@img/sharp-linux-arm64": "0.35.2", - "@img/sharp-linux-ppc64": "0.35.2", - "@img/sharp-linux-riscv64": "0.35.2", - "@img/sharp-linux-s390x": "0.35.2", - "@img/sharp-linux-x64": "0.35.2", - "@img/sharp-linuxmusl-arm64": "0.35.2", - "@img/sharp-linuxmusl-x64": "0.35.2", - "@img/sharp-webcontainers-wasm32": "0.35.2", - "@img/sharp-win32-arm64": "0.35.2", - "@img/sharp-win32-ia32": "0.35.2", - "@img/sharp-win32-x64": "0.35.2" + "@img/sharp-darwin-arm64": "0.35.4", + "@img/sharp-darwin-x64": "0.35.4", + "@img/sharp-freebsd-wasm32": "0.35.4", + "@img/sharp-libvips-darwin-arm64": "1.3.3", + "@img/sharp-libvips-darwin-x64": "1.3.3", + "@img/sharp-libvips-linux-arm": "1.3.3", + "@img/sharp-libvips-linux-arm64": "1.3.3", + "@img/sharp-libvips-linux-ppc64": "1.3.3", + "@img/sharp-libvips-linux-riscv64": "1.3.3", + "@img/sharp-libvips-linux-s390x": "1.3.3", + "@img/sharp-libvips-linux-x64": "1.3.3", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", + "@img/sharp-libvips-linuxmusl-x64": "1.3.3", + "@img/sharp-linux-arm": "0.35.4", + "@img/sharp-linux-arm64": "0.35.4", + "@img/sharp-linux-ppc64": "0.35.4", + "@img/sharp-linux-riscv64": "0.35.4", + "@img/sharp-linux-s390x": "0.35.4", + "@img/sharp-linux-x64": "0.35.4", + "@img/sharp-linuxmusl-arm64": "0.35.4", + "@img/sharp-linuxmusl-x64": "0.35.4", + "@img/sharp-webcontainers-wasm32": "0.35.4", + "@img/sharp-win32-arm64": "0.35.4", + "@img/sharp-win32-ia32": "0.35.4", + "@img/sharp-win32-x64": "0.35.4" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, "node_modules/shebang-command": { @@ -9531,7 +9557,9 @@ } }, "node_modules/tinyrainbow": { - "version": "3.1.0", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", "dev": true, "license": "MIT", "engines": { @@ -10561,19 +10589,19 @@ } }, "node_modules/vitest": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", - "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.10", - "@vitest/mocker": "4.1.10", - "@vitest/pretty-format": "4.1.10", - "@vitest/runner": "4.1.10", - "@vitest/snapshot": "4.1.10", - "@vitest/spy": "4.1.10", - "@vitest/utils": "4.1.10", + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -10601,12 +10629,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.10", - "@vitest/browser-preview": "4.1.10", - "@vitest/browser-webdriverio": "4.1.10", - "@vitest/coverage-istanbul": "4.1.10", - "@vitest/coverage-v8": "4.1.10", - "@vitest/ui": "4.1.10", + "@vitest/browser-playwright": "4.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" @@ -10740,9 +10768,9 @@ } }, "node_modules/workerd": { - "version": "1.20260801.1", - "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260801.1.tgz", - "integrity": "sha512-/g9JGTyqnHtoIscpBHqKD8swE2V4StBs2i69PmLiOhH45OP95jCFICl4F1hKlgN57rqfni5LiCitttoX5OOkVA==", + "version": "1.20260918.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260918.1.tgz", + "integrity": "sha512-NsjfQlBNQ0iEniv/STOy4zbp8s5k60PzL1Ter02Eg44arbDhHtf6UOs03E31XDRmmBFxSIkAZuCyld3RKH1wqA==", "dev": true, "hasInstallScript": true, "license": "Apache-2.0", @@ -10753,17 +10781,17 @@ "node": ">=16" }, "optionalDependencies": { - "@cloudflare/workerd-darwin-64": "1.20260801.1", - "@cloudflare/workerd-darwin-arm64": "1.20260801.1", - "@cloudflare/workerd-linux-64": "1.20260801.1", - "@cloudflare/workerd-linux-arm64": "1.20260801.1", - "@cloudflare/workerd-windows-64": "1.20260801.1" + "@cloudflare/workerd-darwin-64": "1.20260918.1", + "@cloudflare/workerd-darwin-arm64": "1.20260918.1", + "@cloudflare/workerd-linux-64": "1.20260918.1", + "@cloudflare/workerd-linux-arm64": "1.20260918.1", + "@cloudflare/workerd-windows-64": "1.20260918.1" } }, "node_modules/wrangler": { - "version": "4.120.0", - "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.120.0.tgz", - "integrity": "sha512-cBmu/MeaB/fPacC0JpATs4duTOCagBxrZo+vBzuTX06tLzwSyAHE1drlHUZ8rP0VqVz1fy3ReGYTiHdKkoHltg==", + "version": "4.135.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.135.0.tgz", + "integrity": "sha512-WrNBQSfIG6YcILJcodYr5ty8vgkzGsV8YX+kfd+uZ5/Bd8cUW0GnUIRKAVfn5kYKqh1m/BPcji9h1d7mE8euFw==", "dev": true, "license": "MIT OR Apache-2.0", "dependencies": { @@ -10771,10 +10799,10 @@ "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", - "miniflare": "5.20260801.1-alpha", + "miniflare": "5.20260918.0-alpha", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", - "workerd": "1.20260801.1" + "workerd": "1.20260918.1" }, "bin": { "cf-wrangler": "bin/cf-wrangler.js", @@ -10788,7 +10816,7 @@ "fsevents": "2.3.3" }, "peerDependencies": { - "@cloudflare/workers-types": "^5.20260801.1" + "@cloudflare/workers-types": "^5.20260918.1" }, "peerDependenciesMeta": { "@cloudflare/workers-types": { @@ -11160,7 +11188,7 @@ }, "packages/ui": { "name": "@codraoss/ui", - "version": "0.9.5", + "version": "0.9.9", "license": "AGPL-3.0-only", "dependencies": { "@base-ui/react": "^1.6.0", diff --git a/package.json b/package.json index 2b92ca70..d1408772 100644 --- a/package.json +++ b/package.json @@ -104,6 +104,6 @@ "allowScripts": { "esbuild": true, "unrs-resolver@1.12.2": true, - "workerd@1.20260801.1": true + "workerd@1.20260918.1": true } } From 1644a38da40506f78fbaf698910564b38737e0b1 Mon Sep 17 00:00:00 2001 From: Devarshi Shimpi Date: Sat, 19 Sep 2026 22:43:35 +0530 Subject: [PATCH 13/57] fix: improve lighthouse reports and frontend performance - build: optimize Vite chunking for heavy dependencies (react, recharts, motion) and inject __APP_VERSION__ - perf: add public/_headers for aggressive static asset caching and enforce no-cache on HTML routes - ui: prevent iOS auto-zoom by increasing mobile input font size to 16px (text-base) - ui: remove touch-none on sliders to fix mobile scrolling interactions - ui: various layout and hook refinements across dashboard pages and components - auth: redirect unauthorized HTML requests to / instead of /login - worker: add /account* routing rule in wrangler.jsonc - chore: bump @codraoss/api to 0.9.7 and @codraoss/ui to 0.9.10 - feat: add SVG favicon --- apps/dashboard/index.html | 22 +- apps/dashboard/src/app.css | 1 - .../features/account/details-section.tsx | 13 +- .../features/account/profile-card.tsx | 4 +- .../dashboard/updates-email-prompt.tsx | 120 +- .../features/job-detail/diff-file-panel.tsx | 181 +- .../features/job-detail/job-skeleton.tsx | 6 + .../features/models/model-chain.tsx | 2 +- .../components/features/repos/repo-row.tsx | 26 +- .../features/settings/about-section.tsx | 11 +- .../settings/default-models-section.tsx | 20 +- .../features/settings/review-section.tsx | 10 +- .../features/stats/chart-primitives.tsx | 45 +- .../src/components/layout/app-shell.tsx | 55 +- apps/dashboard/src/env.d.ts | 3 + apps/dashboard/src/hooks/use-fit-rows.ts | 4 +- apps/dashboard/src/hooks/use-job-detail.ts | 15 +- apps/dashboard/src/hooks/use-polling.ts | 38 +- apps/dashboard/src/lib/timezone.ts | 12 +- apps/dashboard/src/main.tsx | 38 +- apps/dashboard/src/pages/dashboard.tsx | 29 +- apps/dashboard/src/pages/job-detail.tsx | 19 +- apps/dashboard/src/pages/jobs.tsx | 21 +- apps/dashboard/src/pages/landing.tsx | 22 +- apps/dashboard/src/pages/repos.tsx | 56 +- apps/dashboard/src/pages/stats.tsx | 3 +- apps/dashboard/src/routes.tsx | 33 +- apps/worker/src/worker-env.d.ts | 1490 ++++++++++++++--- apps/worker/wrangler.jsonc | 3 +- package-lock.json | 4 +- packages/api/package.json | 2 +- packages/api/src/middleware/auth.ts | 2 +- packages/api/src/router.ts | 6 +- packages/ui/package.json | 2 +- packages/ui/src/components/input.tsx | 6 +- .../src/components/motion/stepped-slider.tsx | 2 +- public/_headers | 4 + public/favicon.svg | Bin 0 -> 830 bytes vite.config.ts | 13 +- 39 files changed, 1786 insertions(+), 557 deletions(-) create mode 100644 apps/dashboard/src/env.d.ts create mode 100644 public/_headers create mode 100644 public/favicon.svg diff --git a/apps/dashboard/index.html b/apps/dashboard/index.html index ecc9b210..f2221d13 100644 --- a/apps/dashboard/index.html +++ b/apps/dashboard/index.html @@ -3,14 +3,34 @@ + + + Codra | AI Code Reviews + + + -
+
+ +
+
diff --git a/apps/dashboard/src/app.css b/apps/dashboard/src/app.css index 04e24c96..e36f46be 100644 --- a/apps/dashboard/src/app.css +++ b/apps/dashboard/src/app.css @@ -1,4 +1,3 @@ -@import url('https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&family=Geist+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@300;400;500;600;700&family=JetBrains+Mono:ital,wght@0,400;0,500;0,600;1,400&display=swap'); @import "tailwindcss"; @import "@codraoss/ui/styles"; diff --git a/apps/dashboard/src/components/features/account/details-section.tsx b/apps/dashboard/src/components/features/account/details-section.tsx index 2acde290..efc58f91 100644 --- a/apps/dashboard/src/components/features/account/details-section.tsx +++ b/apps/dashboard/src/components/features/account/details-section.tsx @@ -1,5 +1,5 @@ import { SectionCard, Select, Skeleton, Text } from '@codraoss/ui'; -import { useMemo } from 'react'; + import { Mail } from 'lucide-react'; import { COMMON_TIME_ZONES, @@ -14,13 +14,16 @@ import type { AccountSettings, AuthSessionUser } from '@codraoss/schema/api'; import { DetailGroup, RevealOnClick, DetailRow } from './detail-rows'; // No "Automatic" option: defaults to UTC so timestamps read the same for everyone; the browser's own zone is folded into the list. -function zoneOptions() { +let cachedZoneOptions: { value: string; label: string }[] | null = null; +function getZoneOptions() { + if (cachedZoneOptions) return cachedZoneOptions; const zones = Array.from(new Set([DEFAULT_TIME_ZONE, ...COMMON_TIME_ZONES, browserTimeZone()])) .sort((a, b) => a.localeCompare(b)); - return zones.map((zone) => { + cachedZoneOptions = zones.map((zone) => { const offset = timeZoneOffsetLabel(zone); return { value: zone, label: offset ? `${zone} · ${offset}` : zone }; }); + return cachedZoneOptions; } function formatDate(value: string) { @@ -51,7 +54,7 @@ export function AccountDetailsSection({ onZoneChange: (zone: string) => void; }) { // Built once - a fresh array each render gave Select a new `options` identity, re-firing its highlight/measure effects and jittering the open panel. - const zoneOpts = useMemo(() => zoneOptions(), []); + const zoneOpts = getZoneOptions(); return ( ) : ( -
+
setEmail(event.target.value)} - placeholder="you@example.com" - className="min-w-0 px-3 sm:flex-1" - aria-label="Email for Codra release updates" - /> - -
- +
+ setEmail(event.target.value)} + placeholder="you@example.com" + className="min-w-0 w-full px-3" + aria-label="Email for Codra release updates" + /> + +
+
); } + +export function UpdatesEmailPrompt() { + useEffect(() => { + let cancelled = false; + api.getUpdatesEmailStatus() + .then((response) => { + if (!cancelled && response.status === 'pending') { + toast.custom(() => toast.dismiss('email-prompt')} />, { + id: 'email-prompt', + duration: Infinity, + }); + } + }) + .catch(() => {}); + + return () => { + cancelled = true; + toast.dismiss('email-prompt'); + }; + }, []); + + return null; +} diff --git a/apps/dashboard/src/components/features/job-detail/diff-file-panel.tsx b/apps/dashboard/src/components/features/job-detail/diff-file-panel.tsx index 361b4f96..49d283d4 100644 --- a/apps/dashboard/src/components/features/job-detail/diff-file-panel.tsx +++ b/apps/dashboard/src/components/features/job-detail/diff-file-panel.tsx @@ -119,26 +119,37 @@ export function FileDiff({ file, open, viewed, diffsLoading = false, onOpenChang }; return ( -
-
- +
- +
+{adds}{' '} @@ -172,87 +183,85 @@ export function FileDiff({ file, open, viewed, diffsLoading = false, onOpenChang Viewed -
+ - {open && ( -
- {rows.length === 0 ? ( -

- {diffsLoading ? 'Loading diff…' : 'Diff unavailable for this file.'} -

- ) : ( - // Each segment scrolls independently, so comment cards stay at panel width instead of - // stretching to the widest code line in a shared scroller. - segments.map((segment) => - segment.type === 'rows' ? ( -
-
- {segment.rows.map((row) => ( - - ))} -
+
+ {rows.length === 0 ? ( +

+ {diffsLoading ? 'Loading diff…' : 'Diff unavailable for this file.'} +

+ ) : ( + // Each segment scrolls independently, so comment cards stay at panel width instead of + // stretching to the widest code line in a shared scroller. + segments.map((segment) => + segment.type === 'rows' ? ( +
+
+ {segment.rows.map((row) => ( + + ))}
- ) : ( -
-
- {segment.comments.map((comment, i) => ( - - ))} -
+
+ ) : ( +
+
+ {segment.comments.map((comment, i) => ( + + ))}
- ), - ) - )} - - {hiddenLines > 0 && ( -
-

- {hiddenLines.toLocaleString()} more {hiddenLines === 1 ? 'line' : 'lines'} not shown. -

- -
- )} - {truncatable && showFull && ( -
- -
- )} +
+ ), + ) + )} - {file.fileStatus === 'failed' && file.errorMessage && ( -
0 && ( +
+

+ {hiddenLines.toLocaleString()} more {hiddenLines === 1 ? 'line' : 'lines'} not shown. +

+
- )} - {unanchored.length > 0 && ( -
-

- File-level comments -

-
- {unanchored.map((comment, i) => ( - - ))} -
+ Show full diff + +
+ )} + {truncatable && showFull && ( +
+ +
+ )} + + {file.fileStatus === 'failed' && file.errorMessage && ( +
+

Review error

+

{file.errorMessage}

+
+ )} + {unanchored.length > 0 && ( +
+

+ File-level comments +

+
+ {unanchored.map((comment, i) => ( + + ))}
- )} -
- )} -
+
+ )} +
+ ); } diff --git a/apps/dashboard/src/components/features/job-detail/job-skeleton.tsx b/apps/dashboard/src/components/features/job-detail/job-skeleton.tsx index 03538968..8dbb7656 100644 --- a/apps/dashboard/src/components/features/job-detail/job-skeleton.tsx +++ b/apps/dashboard/src/components/features/job-detail/job-skeleton.tsx @@ -53,6 +53,12 @@ export function JobDetailSkeleton({ error }: JobDetailSkeletonProps) {
+ {/* Placeholders for JobProgress and JobStatusNotice to prevent massive CLS */} +
+ + +
+ {/* Tab strip - static, so it doesn't appear from nowhere when data lands. */}
diff --git a/apps/dashboard/src/components/features/models/model-chain.tsx b/apps/dashboard/src/components/features/models/model-chain.tsx index 8e6ffbb5..61225ed0 100644 --- a/apps/dashboard/src/components/features/models/model-chain.tsx +++ b/apps/dashboard/src/components/features/models/model-chain.tsx @@ -295,7 +295,7 @@ export function ModelRouteEditor({ min={1} value={tier.max_lines} onChange={e => updateTier(index, { max_lines: Number(e.target.value) || 1 })} - className="min-w-0 flex-1 bg-transparent text-sm font-semibold outline-none" + className="min-w-0 flex-1 bg-transparent text-base sm:text-sm font-semibold outline-none" /> lines
diff --git a/apps/dashboard/src/components/features/repos/repo-row.tsx b/apps/dashboard/src/components/features/repos/repo-row.tsx index 6584afe5..ced000a4 100644 --- a/apps/dashboard/src/components/features/repos/repo-row.tsx +++ b/apps/dashboard/src/components/features/repos/repo-row.tsx @@ -1,5 +1,6 @@ import { Badge, Button, Switch } from '@codraoss/ui'; import { Settings2 } from 'lucide-react'; +import { useState, useEffect } from 'react'; import type { RepoConfigRecord } from '@codraoss/schema'; import { describeModelRoute, type ModelOption, type ModelRouteConfig } from '@client/components/features/models/model-route'; import { getRepoRoute, hasMeaningfulCustomStrategy, formatLastActivity, type GlobalModelConfig } from './repo-route'; @@ -21,6 +22,17 @@ export function RepoRow({ onToggleEnabled, onEdit, }: RepoRowProps) { + const [optimisticEnabled, setOptimisticEnabled] = useState(repo.enabled); + + useEffect(() => { + setOptimisticEnabled(repo.enabled); + }, [repo.enabled]); + + const handleToggle = (nextEnabled: boolean) => { + setOptimisticEnabled(nextEnabled); + onToggleEnabled(repo, nextEnabled); + }; + const route = getRepoRoute(repo, globalConfig); const custom = hasMeaningfulCustomStrategy(repo, globalConfig); const lastActivity = formatLastActivity(repo.lastJobCreatedAt); @@ -34,8 +46,8 @@ export function RepoRow({

{repo.owner}/{repo.repo}

- - {repo.enabled ? 'Enabled' : 'Paused'} + + {optimisticEnabled ? 'Enabled' : 'Paused'} {custom ? 'Custom strategy' : 'Global strategy'} @@ -53,17 +65,17 @@ export function RepoRow({

-
+
+
License - {pkg.license} + AGPL-3.0-only
{[ - { 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' }, + { href: 'https://github.com/devarshishimpi/codra/releases/', label: 'Releases', sub: 'Version history & notes' }, + { href: 'https://codra.run', label: 'Homepage', sub: 'codra.run' }, + { href: 'https://github.com/devarshishimpi/codra/issues', label: 'Report an issue', sub: 'GitHub issue tracker' }, ].map(({ href, label, sub }) => ( ) : ( -
- - +
+
+ + +
+
+
+ +
+
+
+ + +
+ +
+
)}
diff --git a/apps/dashboard/src/components/features/settings/review-section.tsx b/apps/dashboard/src/components/features/settings/review-section.tsx index 1a5418d8..e5fbe88b 100644 --- a/apps/dashboard/src/components/features/settings/review-section.tsx +++ b/apps/dashboard/src/components/features/settings/review-section.tsx @@ -108,9 +108,13 @@ export function ReviewSection({ ) : ( <> - - - + {[1, 2, 3].map((i) => ( +
+ + + +
+ ))} )}
diff --git a/apps/dashboard/src/components/features/stats/chart-primitives.tsx b/apps/dashboard/src/components/features/stats/chart-primitives.tsx index 1264b3ee..ff182d7d 100644 --- a/apps/dashboard/src/components/features/stats/chart-primitives.tsx +++ b/apps/dashboard/src/components/features/stats/chart-primitives.tsx @@ -47,24 +47,49 @@ export function ChartTooltip({ active, payload, label, markers }: any) { function GraphCardSkeleton({ title, icon, className = '' }: { title: string; icon?: ReactNode; className?: string }) { return ( -
+
); } +function JobHealthSkeleton({ title, icon, className = '' }: { title: string; icon?: ReactNode; className?: string }) { + return ( + +
+
+ +
+
+ {Array.from({ length: 5 }).map((_, i) => ( +
+
+ + +
+ +
+ ))} +
+
+
+ ); +} + function GraphBarCardSkeleton({ title, icon, rows = 5, className = '' }: { title: string; icon?: ReactNode; rows?: number; className?: string }) { return ( -
- {Array.from({ length: rows }).map((_, i) => ( -
- - - -
- ))} +
+
+ {Array.from({ length: rows }).map((_, i) => ( +
+ + + +
+ ))} +
); @@ -79,7 +104,7 @@ export function MetricsGridSkeleton() { } />
- } /> + } /> } rows={4} /> } rows={5} />
diff --git a/apps/dashboard/src/components/layout/app-shell.tsx b/apps/dashboard/src/components/layout/app-shell.tsx index 79ea469c..dab164c3 100644 --- a/apps/dashboard/src/components/layout/app-shell.tsx +++ b/apps/dashboard/src/components/layout/app-shell.tsx @@ -1,5 +1,5 @@ import { Outlet, Link } from 'react-router-dom'; -import { useEffect, useState } from 'react'; +import React, { useState, Suspense } from 'react'; import { SharedLayoutBg } from '@codraoss/ui/motion'; import { AlignLeft, Sun, Moon, Star, X, ArrowUpRight } from 'lucide-react'; import { cn } from '@codraoss/ui/utils'; @@ -12,7 +12,8 @@ import { navItems as defaultNavItems } from '@client/nav'; import type { NavItem } from '@client/nav'; import { SessionProvider, useSession } from '@client/hooks/use-session'; import { useCan } from '@client/hooks/use-can'; -import { SidebarOnboarding } from '@client/components/features/dashboard/sidebar-onboarding'; +import { Toaster } from 'sonner'; +const SidebarOnboarding = React.lazy(() => import('@client/components/features/dashboard/sidebar-onboarding').then(m => ({ default: m.SidebarOnboarding }))); export function AppShell({ navItems = defaultNavItems }: { navItems?: NavItem[] } = {}) { return ( @@ -58,21 +59,7 @@ function AppShellInner({ navItems }: { navItems: NavItem[] }) { const [mobileMenuOpen, setMobileMenuOpen] = useState(false); const [isOnboardingVisible, setIsOnboardingVisible] = useState(true); - useEffect(() => { - const timers = new WeakMap(); - const onScroll = (e: Event) => { - let el = e.target as Element | Document | null; - if (el === document) el = document.scrollingElement; - if (!(el instanceof Element)) return; - const node = el; - node.setAttribute('data-scrolling', 'true'); - const prev = timers.get(node); - if (prev !== undefined) window.clearTimeout(prev); - timers.set(node, window.setTimeout(() => node.removeAttribute('data-scrolling'), 700)); - }; - document.addEventListener('scroll', onScroll, true); - return () => document.removeEventListener('scroll', onScroll, true); - }, []); + return (
@@ -153,10 +140,12 @@ function AppShellInner({ navItems }: { navItems: NavItem[] }) {
{sessionUser && ( - + + + )}
@@ -223,6 +212,30 @@ function AppShellInner({ navItems }: { navItems: NavItem[] }) {

+ ); } diff --git a/apps/dashboard/src/env.d.ts b/apps/dashboard/src/env.d.ts new file mode 100644 index 00000000..dbb4c627 --- /dev/null +++ b/apps/dashboard/src/env.d.ts @@ -0,0 +1,3 @@ +/// + +declare const __APP_VERSION__: string; diff --git a/apps/dashboard/src/hooks/use-fit-rows.ts b/apps/dashboard/src/hooks/use-fit-rows.ts index 099d2187..6cfadbdb 100644 --- a/apps/dashboard/src/hooks/use-fit-rows.ts +++ b/apps/dashboard/src/hooks/use-fit-rows.ts @@ -1,4 +1,4 @@ -import { useCallback, useLayoutEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; interface FitRowsOptions { /** Height of one desktop table row, in px. Matches the `h-12` cell in JobsTable. */ @@ -102,7 +102,7 @@ export function useFitRows({ }); }, [rowHeight, mobileRowHeight, min, max, reserve]); - useLayoutEffect(() => { + useEffect(() => { measure(); const el = ref.current; diff --git a/apps/dashboard/src/hooks/use-job-detail.ts b/apps/dashboard/src/hooks/use-job-detail.ts index cd3fcb8a..22ad008a 100644 --- a/apps/dashboard/src/hooks/use-job-detail.ts +++ b/apps/dashboard/src/hooks/use-job-detail.ts @@ -58,7 +58,7 @@ export function useJobDetail(id: string) { if (!response.notModified && response.data) { latestJob.current = response.data.job; setJob(response.data.job); - writeJobCache(id, response.data.job); + // Do not cache here synchronously to prevent jank on the main thread every 3 seconds } setError(null); schedulePolling(); @@ -113,6 +113,19 @@ export function useJobDetail(id: string) { schedulePollingRef.current(); }, [job?.status, job?.nextRetryAt]); + useEffect(() => { + const saveCache = () => { + if (latestJob.current) { + writeJobCache(id, latestJob.current); + } + }; + window.addEventListener('pagehide', saveCache); + return () => { + window.removeEventListener('pagehide', saveCache); + saveCache(); + }; + }, [id]); + // Mount-only: the listener reads through the ref, so it never needs rebinding. useEffect(() => { const reschedule = () => schedulePollingRef.current(); diff --git a/apps/dashboard/src/hooks/use-polling.ts b/apps/dashboard/src/hooks/use-polling.ts index d6dfacde..864e8796 100644 --- a/apps/dashboard/src/hooks/use-polling.ts +++ b/apps/dashboard/src/hooks/use-polling.ts @@ -12,11 +12,41 @@ export function usePolling(callback: () => Promise | void, delay = 10_000, if (delay === null) return; - const id = setInterval(() => { - savedCallback.current(); - }, delay); + let id: ReturnType | null = null; - return () => clearInterval(id); + const start = () => { + if (id !== null) return; + id = setInterval(() => { + savedCallback.current(); + }, delay); + }; + + const stop = () => { + if (id !== null) { + clearInterval(id); + id = null; + } + }; + + if (document.visibilityState === 'visible') { + start(); + } + + const onVisibilityChange = () => { + if (document.visibilityState === 'visible') { + savedCallback.current(); + start(); + } else { + stop(); + } + }; + + document.addEventListener('visibilitychange', onVisibilityChange); + + return () => { + stop(); + document.removeEventListener('visibilitychange', onVisibilityChange); + }; // The caller owns `deps`, so the array is a spread the lint rule can't statically verify; the // callback itself is read through a ref, so nothing here can go stale. // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/apps/dashboard/src/lib/timezone.ts b/apps/dashboard/src/lib/timezone.ts index cfa4529c..b1f3a193 100644 --- a/apps/dashboard/src/lib/timezone.ts +++ b/apps/dashboard/src/lib/timezone.ts @@ -43,13 +43,18 @@ export function browserTimeZone(): string { } } +const offsetCache = new Map(); + export function timeZoneOffsetLabel(zone: string): string { + if (offsetCache.has(zone)) return offsetCache.get(zone)!; try { const parts = new Intl.DateTimeFormat('en-US', { timeZone: zone, timeZoneName: 'shortOffset', }).formatToParts(new Date()); - return parts.find((p) => p.type === 'timeZoneName')?.value ?? ''; + const label = parts.find((p) => p.type === 'timeZoneName')?.value ?? ''; + offsetCache.set(zone, label); + return label; } catch { return ''; } @@ -113,3 +118,8 @@ export const COMMON_TIME_ZONES: string[] = [ 'Australia/Sydney', 'Pacific/Auckland', ]; + +// Precompute labels statically +COMMON_TIME_ZONES.forEach((zone) => timeZoneOffsetLabel(zone)); +timeZoneOffsetLabel(DEFAULT_TIME_ZONE); + diff --git a/apps/dashboard/src/main.tsx b/apps/dashboard/src/main.tsx index d180197e..8ebd331d 100644 --- a/apps/dashboard/src/main.tsx +++ b/apps/dashboard/src/main.tsx @@ -1,54 +1,18 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import { RouterProvider } from 'react-router-dom'; -import { Toaster } from 'sonner'; import { buildRouter } from './routes'; import './app.css'; import { ThemeProvider } from '@codraoss/ui/theme'; -import { useIsDarkMode } from '@codraoss/ui/hooks'; -import { SmoothScroll } from '@codraoss/ui/motion'; - -function ToasterWrapper() { - const isDark = useIsDarkMode(); - return ( - - ); -} const router = buildRouter(); ReactDOM.createRoot(document.getElementById('root')!).render( - - - - + , ); diff --git a/apps/dashboard/src/pages/dashboard.tsx b/apps/dashboard/src/pages/dashboard.tsx index 418fd2ec..f3bcdc62 100644 --- a/apps/dashboard/src/pages/dashboard.tsx +++ b/apps/dashboard/src/pages/dashboard.tsx @@ -32,26 +32,37 @@ export function DashboardPage() { setDays(next); }; - const load = async (manual = false) => { - if (rows === null) return; - if (manual) setRefreshing(true); + const loadStats = async () => { try { - const [statsRes, jobsRes] = await Promise.all([ - api.getStats(days), - api.getJobs({ limit: rows }), - ]); + const statsRes = await api.getStats(days); setStats(statsRes.stats); + setError(null); + } catch (e) { + setError(e instanceof Error ? e.message : 'Failed to refresh dashboard.'); + } + }; + + const loadJobs = async () => { + if (rows === null) return; + try { + const jobsRes = await api.getJobs({ limit: rows }); setRecentJobs(jobsRes.jobs); setError(null); } catch (e) { setError(e instanceof Error ? e.message : 'Failed to refresh dashboard.'); } finally { setLoading(false); - setRefreshing(false); } }; - usePolling(load, 15_000, [days, rows]); + const load = async (manual = false) => { + if (manual) setRefreshing(true); + await Promise.all([loadStats(), loadJobs()]); + if (manual) setRefreshing(false); + }; + + usePolling(loadStats, 15_000, [days]); + usePolling(loadJobs, 15_000, [rows]); return ( diff --git a/apps/dashboard/src/pages/job-detail.tsx b/apps/dashboard/src/pages/job-detail.tsx index a05ac237..6ad5c2bb 100644 --- a/apps/dashboard/src/pages/job-detail.tsx +++ b/apps/dashboard/src/pages/job-detail.tsx @@ -1,7 +1,7 @@ import { LoadError } from '@codraoss/ui'; -import { useState } from 'react'; +import { useState, lazy, Suspense } from 'react'; import { useParams } from 'react-router-dom'; -import { LazyMotion, m, domMax } from 'motion/react'; +import { LazyMotion, domAnimation } from 'motion/react'; import { ClipboardList, FileDiff } from 'lucide-react'; import { useJobDetail } from '@client/hooks/use-job-detail'; import { JobHeader } from '@client/components/features/job-detail/job-header'; @@ -10,7 +10,8 @@ import { JobStatusNotice } from '@client/components/features/job-detail/job-stat import { JobMetaCards } from '@client/components/features/job-detail/job-meta-cards'; import { JobReviewOverview } from '@client/components/features/job-detail/job-review-overview'; import { JobFindingsList } from '@client/components/features/job-detail/job-findings-list'; -import { JobDiffs } from '@client/components/features/job-detail/job-diffs'; + +const JobDiffs = lazy(() => import('@client/components/features/job-detail/job-diffs').then((m) => ({ default: m.JobDiffs }))); import { JobDetailSkeleton } from '@client/components/features/job-detail/job-skeleton'; import { cn } from '@codraoss/ui/utils'; @@ -64,8 +65,8 @@ export function JobDetailPage() { {/* Terminal outcome (failed / superseded / stopped / partial) gets its own banner above the tabs. */} - {/* domMax, not domAnimation: the underline uses `layoutId`, which needs the layout feature. */} - + {/* Using domAnimation instead of domMax to save bundle size */} + @@ -155,12 +179,12 @@ function AppShellInner({ navItems }: { navItems: NavItem[] }) { target="_blank" rel="noopener noreferrer" className={cn( - 'dashboard-sidebar-action', - 'group relative flex h-9 w-full items-center gap-3 rounded-md pl-4 pr-3.5', - 'text-[13px] text-ui-subtle hover:text-ui-strong', - 'dark:text-ui-subtle/65 dark:hover:text-ui-default', - 'transition-colors duration-200 ease-[var(--ease-out-quart)]', - 'hover:bg-ui-fill/50', + "dashboard-sidebar-action", + "group relative flex h-9 w-full items-center gap-3 rounded-md pl-4 pr-3.5", + "text-[13px] text-ui-subtle hover:text-ui-strong", + "dark:text-ui-subtle/65 dark:hover:text-ui-default", + "transition-colors duration-200 ease-[var(--ease-out-quart)]", + "hover:bg-ui-fill/50", )} > @@ -178,16 +202,15 @@ function AppShellInner({ navItems }: { navItems: NavItem[] }) {
-
@@ -212,27 +235,27 @@ function AppShellInner({ navItems }: { navItems: NavItem[] }) {
- diff --git a/apps/dashboard/src/components/layout/page-header.tsx b/apps/dashboard/src/components/layout/page-header.tsx index 206e1f5d..63b2fa6a 100644 --- a/apps/dashboard/src/components/layout/page-header.tsx +++ b/apps/dashboard/src/components/layout/page-header.tsx @@ -1,6 +1,6 @@ -import * as React from 'react'; -import { cn } from '@codraoss/ui/utils'; -import { UpdatesEmailPrompt } from '@client/components/shared/updates-email-prompt'; +import * as React from "react"; +import { cn } from "@codraoss/ui/utils"; +import { UpdatesEmailPrompt } from "@client/components/shared/updates-email-prompt"; interface PageHeaderProps extends React.HTMLAttributes { title: string; @@ -11,21 +11,24 @@ interface PageHeaderProps extends React.HTMLAttributes { export function PageHeader({ title, - description, - actions, - className, - ...props + description, + actions, + className, + ...props }: PageHeaderProps) { return ( <>

{title} {props.versionBadge && ( diff --git a/apps/dashboard/src/components/layout/sidebar-nav-item.tsx b/apps/dashboard/src/components/layout/sidebar-nav-item.tsx index 3b42d97f..9d1183ad 100644 --- a/apps/dashboard/src/components/layout/sidebar-nav-item.tsx +++ b/apps/dashboard/src/components/layout/sidebar-nav-item.tsx @@ -1,6 +1,6 @@ -import { NavLink, useMatch, useResolvedPath } from 'react-router-dom'; -import { type ComponentType } from 'react'; -import { cn } from '@codraoss/ui/utils'; +import { NavLink, useMatch, useResolvedPath } from "react-router-dom"; +import { type ComponentType } from "react"; +import { cn } from "@codraoss/ui/utils"; /** * Resolves active state via hooks instead of NavLink's render-prop pattern, @@ -30,21 +30,21 @@ export function SidebarNavItem({ end={end} onClick={onClick} className={cn( - 'dashboard-sidebar-action', + "dashboard-sidebar-action", // `relative` anchors this row's own accent bar + skimmer overlay; the rest of the sidebar rows are positioned the same way. - 'group relative flex h-10 w-full items-center gap-3 rounded-md pl-4 pr-3.5 text-[13px] font-medium', - 'outline-none transition-[color,background-color] duration-200 ease-[var(--ease-out-quart)]', - 'focus-visible:ring-2 focus-visible:ring-ring', + "group relative flex h-10 w-full items-center gap-3 rounded-md pl-4 pr-3.5 text-[13px] font-medium", + "outline-none transition-[color,background-color] duration-200 ease-[var(--ease-out-quart)]", + "focus-visible:ring-2 focus-visible:ring-ring", isActive - ? 'bg-ui-fill/60 font-semibold text-ui-strong' - : 'text-ui-default hover:text-ui-strong dark:text-ui-subtle dark:hover:text-ui-default', + ? "bg-ui-fill/60 font-semibold text-ui-strong" + : "text-ui-default hover:text-ui-strong dark:text-ui-subtle dark:hover:text-ui-default", )} > @@ -63,16 +63,15 @@ export function SidebarNavItem({ {/* Muted a step below the label when inactive, matching icon chips elsewhere; dark mode has no mid tone between `ui-subtle` and `ui-default`, so the step comes from alpha, otherwise icon and label render at the identical grey. */} - - {label} - + {label} ); } diff --git a/apps/dashboard/src/components/shared/jobs-table.tsx b/apps/dashboard/src/components/shared/jobs-table.tsx index 00cd5902..7a3ae6da 100644 --- a/apps/dashboard/src/components/shared/jobs-table.tsx +++ b/apps/dashboard/src/components/shared/jobs-table.tsx @@ -1,23 +1,32 @@ -import { Skeleton } from '@codraoss/ui'; -import { Link } from 'react-router-dom'; -import { FolderGit2, GitCommitHorizontal, GitPullRequest } from 'lucide-react'; +import { Skeleton } from "@codraoss/ui"; +import { Link } from "react-router-dom"; +import { FolderGit2, GitCommitHorizontal, GitPullRequest } from "lucide-react"; -import { VerdictPill, MetaChip, AuthorAvatar } from '@client/components/features/job-detail/job-chips'; -import { cn } from '@codraoss/ui/utils'; -import { formatDateTime } from '@client/lib/timezone'; -import { STATUS_DOT, formatRelativeDate, jobDuration, statusLabel } from '@client/lib/job-format'; +import { + VerdictPill, + MetaChip, + AuthorAvatar, +} from "@client/components/features/job-detail/job-chips"; +import { cn } from "@codraoss/ui/utils"; +import { formatDateTime } from "@client/lib/timezone"; +import { + STATUS_DOT, + formatRelativeDate, + jobDuration, + statusLabel, +} from "@client/lib/job-format"; -import type { JobSummary } from '@codraoss/schema'; +import type { JobSummary } from "@codraoss/schema"; type Column = - | 'title' - | 'status' - | 'verdict' - | 'repo' - | 'commit' - | 'pr' - | 'updated' - | 'author'; + | "title" + | "status" + | "verdict" + | "repo" + | "commit" + | "pr" + | "updated" + | "author"; interface JobsTableProps { jobs: JobSummary[]; @@ -31,52 +40,52 @@ interface JobsTableProps { } const DEFAULT_COLUMNS: Column[] = [ - 'title', - 'status', - 'verdict', - 'repo', - 'commit', - 'pr', - 'updated', - 'author', + "title", + "status", + "verdict", + "repo", + "commit", + "pr", + "updated", + "author", ]; /* Title takes all the slack; secondary metadata drops off first on narrow viewports so a row never wraps and the title never collapses to nothing. */ const COLUMN_CLASSES: Record = { - title: 'min-w-0 pl-4', - status: 'w-[156px]', - verdict: 'hidden xl:table-cell w-[108px]', - repo: 'hidden md:table-cell w-[176px]', - commit: 'hidden 2xl:table-cell w-[96px]', - pr: 'hidden xl:table-cell w-[76px]', - updated: 'w-[84px]', - author: 'w-12 pr-4', + title: "min-w-0 pl-4", + status: "w-[156px]", + verdict: "hidden xl:table-cell w-[108px]", + repo: "hidden md:table-cell w-[176px]", + commit: "hidden 2xl:table-cell w-[96px]", + pr: "hidden xl:table-cell w-[76px]", + updated: "w-[84px]", + author: "w-12 pr-4", }; -function formatDate(value: JobSummary['createdAt']) { +function formatDate(value: JobSummary["createdAt"]) { const date = new Date(value); - if (Number.isNaN(date.getTime())) return ''; + if (Number.isNaN(date.getTime())) return ""; // Rendered in the account's display time zone (falls back to the browser's). return formatDateTime(date, { - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", }); } function StatusCell({ job }: { job: JobSummary }) { const duration = jobDuration(job); - const isRunning = job.status === 'running'; + const isRunning = job.status === "running"; return ( @@ -100,7 +109,7 @@ function JobMobileCard({ job }: { job: JobSummary }) { >

- {job.prTitle ?? 'Untitled PR'} + {job.prTitle ?? "Untitled PR"}

{formatRelativeDate(job.createdAt)} @@ -124,27 +133,41 @@ function JobMobileCard({ job }: { job: JobSummary }) { /* Fixed cell height, not vertical padding: padding-based rows grew ~6px on verdict-pill rows and broke the vertical rhythm. */ -const CELL = 'h-12 border-t border-ui-line px-2.5 align-middle'; +const CELL = "h-12 border-t border-ui-line px-2.5 align-middle"; /* Top border goes transparent, not 0-width, so it can't double up with whatever sits above the table without changing row height. */ -const ROW_DIVIDERS = 'first:[&>td]:border-transparent'; +const ROW_DIVIDERS = "first:[&>td]:border-transparent"; -export function JobsTable({ jobs, loading, columns, fill = false, skeletonRows }: JobsTableProps) { +export function JobsTable({ + jobs, + loading, + columns, + fill = false, + skeletonRows, +}: JobsTableProps) { const cols: Column[] = columns ?? DEFAULT_COLUMNS; const show = (column: Column) => cols.includes(column); return (
-
+
{loading && jobs.length === 0 ? Array.from({ length: skeletonRows ?? 6 }).map((_, i) => ( -
+
@@ -161,8 +184,10 @@ export function JobsTable({ jobs, loading, columns, fill = false, skeletonRows }
@@ -170,12 +195,12 @@ export function JobsTable({ jobs, loading, columns, fill = false, skeletonRows } {loading && jobs.length === 0 ? Array.from({ length: skeletonRows ?? 8 }).map((_, i) => ( - {show('title') && ( + {show("title") && ( )} - {show('status') && ( + {show("status") && ( )} - {show('verdict') && ( + {show("verdict") && ( )} - {show('repo') && ( + {show("repo") && ( )} - {show('commit') && ( + {show("commit") && ( )} - {show('pr') && ( + {show("pr") && ( )} - {show('updated') && ( + {show("updated") && ( )} - {show('author') && ( + {show("author") && ( - {show('title') && ( - )} - {show('status') && ( + {show("status") && ( )} - {show('verdict') && ( + {show("verdict") && ( )} - {show('repo') && ( + {show("repo") && ( )} - {show('commit') && ( + {show("commit") && ( )} - {show('pr') && ( + {show("pr") && ( )} - {show('updated') && ( + {show("updated") && (
@@ -184,34 +209,34 @@ export function JobsTable({ jobs, loading, columns, fill = false, skeletonRows } @@ -224,47 +249,60 @@ export function JobsTable({ jobs, loading, columns, fill = false, skeletonRows }
+ {show("title") && ( + {/* `after:` stretches this link across the row, making the whole row one click target. */} - {job.prTitle ?? 'Untitled PR'} + {job.prTitle ?? "Untitled PR"} {job.verdict && } - + {job.owner}/{job.repo} {job.commitSha ? ( - + {job.commitSha.slice(0, 7)} ) : ( @@ -273,7 +311,7 @@ export function JobsTable({ jobs, loading, columns, fill = false, skeletonRows } #{job.prNumber} @@ -281,12 +319,12 @@ export function JobsTable({ jobs, loading, columns, fill = false, skeletonRows } )} - {show('author') && ( + {show("author") && ( diff --git a/apps/dashboard/src/components/shared/page-header-actions.tsx b/apps/dashboard/src/components/shared/page-header-actions.tsx index 0c572d44..119aa5d4 100644 --- a/apps/dashboard/src/components/shared/page-header-actions.tsx +++ b/apps/dashboard/src/components/shared/page-header-actions.tsx @@ -1,6 +1,6 @@ -import { Button } from '@codraoss/ui'; -import { RefreshCw } from 'lucide-react'; -import { TimeRangeSelect } from '@client/components/features/stats/time-range-select'; +import { Button } from "@codraoss/ui"; +import { RefreshCw } from "lucide-react"; +import { TimeRangeSelect } from "@client/components/features/stats/time-range-select"; interface PageHeaderActionsProps { days: number; @@ -23,7 +23,9 @@ export function PageHeaderActions({ size="sm" onClick={onRefresh} disabled={refreshing} - icon={} + icon={ + + } > Refresh diff --git a/apps/dashboard/src/components/shared/route-error-boundary.tsx b/apps/dashboard/src/components/shared/route-error-boundary.tsx index bc814955..bba14c0b 100644 --- a/apps/dashboard/src/components/shared/route-error-boundary.tsx +++ b/apps/dashboard/src/components/shared/route-error-boundary.tsx @@ -1,6 +1,11 @@ -import { Button } from '@codraoss/ui'; -import { isRouteErrorResponse, Link, useRouteError } from 'react-router-dom'; -import { AlertTriangle, Compass, LayoutDashboard, RefreshCw } from 'lucide-react'; +import { Button } from "@codraoss/ui"; +import { isRouteErrorResponse, Link, useRouteError } from "react-router-dom"; +import { + AlertTriangle, + Compass, + LayoutDashboard, + RefreshCw, +} from "lucide-react"; interface Presentation { code: string; @@ -18,21 +23,26 @@ function present(error: unknown): Presentation { const notFound = error.status === 404; return { code: `ERR_ROUTE_${error.status}`, - title: notFound ? 'Resource not found' : error.statusText || 'Request failed', + title: notFound + ? "Resource not found" + : error.statusText || "Request failed", hint: notFound ? "That address isn't part of Codra. It may have been moved, or the record it pointed at was deleted." - : 'The server refused this request. Try again, or head back and pick a different route.', - detail: typeof error.data === 'string' ? error.data : undefined, + : "The server refused this request. Try again, or head back and pick a different route.", + detail: typeof error.data === "string" ? error.data : undefined, icon: notFound ? Compass : AlertTriangle, reloadable: !notFound, }; } return { - code: 'ERR_RENDER_FAILED', - title: 'Something broke on this screen', - hint: 'The page failed while rendering. Reloading usually clears it; if it keeps happening the details below are worth reporting.', - detail: error instanceof Error ? `${error.name}: ${error.message}` : String(error), + code: "ERR_RENDER_FAILED", + title: "Something broke on this screen", + hint: "The page failed while rendering. Reloading usually clears it; if it keeps happening the details below are worth reporting.", + detail: + error instanceof Error + ? `${error.name}: ${error.message}` + : String(error), icon: AlertTriangle, reloadable: true, }; @@ -50,16 +60,17 @@ function present(error: unknown): Presentation { export function RouteErrorBoundary({ inline = false }: { inline?: boolean }) { const error = useRouteError(); const { code, title, hint, detail, icon: Icon, reloadable } = present(error); - const stack = import.meta.env.DEV && error instanceof Error ? error.stack : undefined; - const Container = inline ? 'section' : 'main'; + const stack = + import.meta.env.DEV && error instanceof Error ? error.stack : undefined; + const Container = inline ? "section" : "main"; return (
@@ -67,7 +78,9 @@ export function RouteErrorBoundary({ inline = false }: { inline?: boolean }) { -

{title}

+

+ {title} +

{hint}

{detail && ( diff --git a/apps/dashboard/src/components/shared/updates-email-prompt.tsx b/apps/dashboard/src/components/shared/updates-email-prompt.tsx index e5d50455..d1e32546 100644 --- a/apps/dashboard/src/components/shared/updates-email-prompt.tsx +++ b/apps/dashboard/src/components/shared/updates-email-prompt.tsx @@ -1,18 +1,19 @@ -import { Button, Input } from '@codraoss/ui'; -import { useEffect, useState, type FormEvent } from 'react'; -import { toast } from 'sonner'; -import { Check, Mail } from 'lucide-react'; -import { api } from '@client/lib/api'; -import type { UpdatesEmailResponse } from '@codraoss/schema/api'; +import { Button, Input } from "@codraoss/ui"; +import { useEffect, useState, type FormEvent } from "react"; +import { toast } from "sonner"; +import { Check, Mail } from "lucide-react"; +import { api } from "@client/lib/api"; +import type { UpdatesEmailResponse } from "@codraoss/schema/api"; export function UpdatesEmailPrompt() { const [status, setStatus] = useState(null); - const [email, setEmail] = useState(''); + const [email, setEmail] = useState(""); const [submitting, setSubmitting] = useState(false); useEffect(() => { let cancelled = false; - api.getUpdatesEmailStatus() + api + .getUpdatesEmailStatus() .then((response) => { if (!cancelled) setStatus(response); }) @@ -25,7 +26,7 @@ export function UpdatesEmailPrompt() { }; }, []); - if (status?.status !== 'pending') return null; + if (status?.status !== "pending") return null; const subscribe = async (event: FormEvent) => { event.preventDefault(); @@ -34,12 +35,14 @@ export function UpdatesEmailPrompt() { try { const response = await api.subscribeUpdates(email); setStatus(response); - toast.success('You’re subscribed', { - description: 'We’ll only reach out for important releases and security notices.', + toast.success("You’re subscribed", { + description: + "We’ll only reach out for important releases and security notices.", }); } catch (error) { - toast.error('Subscription failed', { - description: 'We couldn’t save your email. Please check it and try again.', + toast.error("Subscription failed", { + description: + "We couldn’t save your email. Please check it and try again.", }); } finally { setSubmitting(false); @@ -54,17 +57,22 @@ export function UpdatesEmailPrompt() {
-

Get important Codra updates

+

+ Get important Codra updates +

Get release notes, security fixes, and upgrade heads-ups by email.

- Opt out anytime. No{'\u00A0'}spam. + Opt out anytime. No{"\u00A0"}spam.

-
+ current; - if (growing && available < (current + 1) * unit + margin) return current; + if (growing && available < (current + 1) * unit + margin) + return current; if (!growing && available > current * unit - margin) return current; } @@ -122,17 +131,20 @@ export function useFitRows({ }; const scroller = scrollParent(el); - const observer = typeof ResizeObserver === 'function' ? new ResizeObserver(schedule) : null; + const observer = + typeof ResizeObserver === "function" + ? new ResizeObserver(schedule) + : null; observer?.observe(scroller); // Content above shifts the table's top edge: the stat cards settling, or a banner that only // appears once its own request resolves. for (const node of elementsAbove(el, scroller)) observer?.observe(node); - window.addEventListener('resize', schedule); + window.addEventListener("resize", schedule); return () => { if (frame) cancelAnimationFrame(frame); observer?.disconnect(); - window.removeEventListener('resize', schedule); + window.removeEventListener("resize", schedule); }; }, [measure]); diff --git a/apps/dashboard/src/hooks/use-job-detail.ts b/apps/dashboard/src/hooks/use-job-detail.ts index 62b8dcf0..0f5b66f3 100644 --- a/apps/dashboard/src/hooks/use-job-detail.ts +++ b/apps/dashboard/src/hooks/use-job-detail.ts @@ -1,8 +1,8 @@ -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 '@codraoss/schema'; +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 "@codraoss/schema"; /* Job detail carries every file's full diff, so cache writes are best-effort (quota is swallowed). */ function jobCacheKey(id: string) { @@ -38,17 +38,32 @@ export function useJobDetail(id: string) { const etag = useRef(null); const latestJob = useRef(null); - const terminalStatuses: string[] = ['done', 'failed', 'superseded', 'cancelled']; - const isTerminal = (candidate: JobDetail | null) => !!candidate && terminalStatuses.includes(candidate.status); + 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; - const nextRetryAt = candidate.nextRetryAt ? new Date(candidate.nextRetryAt).getTime() : null; - const waitingForRetry = nextRetryAt !== null && Number.isFinite(nextRetryAt) && nextRetryAt > Date.now(); - const baseDelay = waitingForRetry ? Math.min(Math.max(nextRetryAt - Date.now(), 10_000), 15_000) : 3_000; - - return document.visibilityState === 'hidden' ? Math.max(baseDelay, 45_000) : baseDelay; + const nextRetryAt = candidate.nextRetryAt + ? new Date(candidate.nextRetryAt).getTime() + : null; + const waitingForRetry = + nextRetryAt !== null && + Number.isFinite(nextRetryAt) && + nextRetryAt > Date.now(); + const baseDelay = waitingForRetry + ? Math.min(Math.max(nextRetryAt - Date.now(), 10_000), 15_000) + : 3_000; + + return document.visibilityState === "hidden" + ? Math.max(baseDelay, 45_000) + : baseDelay; }; const fetchJob = async (silent = false) => { @@ -63,7 +78,12 @@ export function useJobDetail(id: string) { setError(null); schedulePolling(); } catch (loadError) { - if (!silent) setError(loadError instanceof Error ? loadError.message : 'Failed to load job.'); + if (!silent) + setError( + loadError instanceof Error + ? loadError.message + : "Failed to load job.", + ); schedulePolling(); } }; @@ -119,9 +139,9 @@ export function useJobDetail(id: string) { writeJobCache(id, latestJob.current); } }; - window.addEventListener('pagehide', saveCache); + window.addEventListener("pagehide", saveCache); return () => { - window.removeEventListener('pagehide', saveCache); + window.removeEventListener("pagehide", saveCache); saveCache(); }; }, [id]); @@ -129,8 +149,8 @@ export function useJobDetail(id: string) { // Mount-only: the listener reads through the ref, so it never needs rebinding. useEffect(() => { const reschedule = () => schedulePollingRef.current(); - document.addEventListener('visibilitychange', reschedule); - return () => document.removeEventListener('visibilitychange', reschedule); + document.addEventListener("visibilitychange", reschedule); + return () => document.removeEventListener("visibilitychange", reschedule); }, []); const handleRetry = async () => { @@ -140,7 +160,7 @@ export function useJobDetail(id: string) { const response = await api.retryJob(job.id); navigate(`/jobs/${response.job.id}`); } catch (e) { - setError(e instanceof Error ? e.message : 'Failed to retry job.'); + setError(e instanceof Error ? e.message : "Failed to retry job."); } finally { setIsRetrying(false); } @@ -149,14 +169,17 @@ export function useJobDetail(id: string) { const handleRerun = async () => { if (!job) return; setIsRerunning(true); - const t = toast.loading('Starting a fresh review…'); + const t = toast.loading("Starting a fresh review…"); try { const response = await api.rerunJob(job.id); - toast.success('Fresh review started.', { id: t }); + 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 }); + 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); @@ -166,14 +189,14 @@ export function useJobDetail(id: string) { const handleStop = async () => { if (!job) return; setIsStopping(true); - const t = toast.loading('Stopping review…'); + const t = toast.loading("Stopping review…"); try { await api.stopJob(job.id); - toast.success('Review stopped.', { id: t }); + 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 }); + 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); @@ -183,14 +206,14 @@ export function useJobDetail(id: string) { const handleDelete = async () => { if (!job) return; setIsDeleting(true); - const t = toast.loading('Deleting job…'); + const t = toast.loading("Deleting job…"); try { await api.deleteJob(job.id); - toast.success('Job deleted.', { id: t }); - navigate('/jobs'); + 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 }); + const msg = e instanceof Error ? e.message : "Failed to delete job."; + toast.error("Could not delete the job.", { id: t, description: msg }); setError(msg); } finally { setIsDeleting(false); @@ -208,6 +231,6 @@ export function useJobDetail(id: string) { handleRerun, handleStop, handleDelete, - fetchJob + fetchJob, }; } diff --git a/apps/dashboard/src/hooks/use-polling.ts b/apps/dashboard/src/hooks/use-polling.ts index 864e8796..f4ddc747 100644 --- a/apps/dashboard/src/hooks/use-polling.ts +++ b/apps/dashboard/src/hooks/use-polling.ts @@ -1,6 +1,10 @@ -import { useEffect, useRef } from 'react'; +import { useEffect, useRef } from "react"; -export function usePolling(callback: () => Promise | void, delay = 10_000, deps: any[] = []) { +export function usePolling( + callback: () => Promise | void, + delay = 10_000, + deps: any[] = [], +) { const savedCallback = useRef(callback); useEffect(() => { @@ -28,12 +32,12 @@ export function usePolling(callback: () => Promise | void, delay = 10_000, } }; - if (document.visibilityState === 'visible') { + if (document.visibilityState === "visible") { start(); } const onVisibilityChange = () => { - if (document.visibilityState === 'visible') { + if (document.visibilityState === "visible") { savedCallback.current(); start(); } else { @@ -41,11 +45,11 @@ export function usePolling(callback: () => Promise | void, delay = 10_000, } }; - document.addEventListener('visibilitychange', onVisibilityChange); + document.addEventListener("visibilitychange", onVisibilityChange); return () => { stop(); - document.removeEventListener('visibilitychange', onVisibilityChange); + document.removeEventListener("visibilitychange", onVisibilityChange); }; // The caller owns `deps`, so the array is a spread the lint rule can't statically verify; the // callback itself is read through a ref, so nothing here can go stale. diff --git a/apps/dashboard/src/hooks/use-provider-settings.ts b/apps/dashboard/src/hooks/use-provider-settings.ts index 8cf2bf20..12632cf2 100644 --- a/apps/dashboard/src/hooks/use-provider-settings.ts +++ b/apps/dashboard/src/hooks/use-provider-settings.ts @@ -1,13 +1,17 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; -import { toast } from 'sonner'; -import { api, type ProviderPayload } from '@client/lib/api'; -import type { LlmProvider, ModelConfig, ReviewSettings } from '@codraoss/schema'; -import type { ModelConfigsResponse } from '@codraoss/schema/api'; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { toast } from "sonner"; +import { api, type ProviderPayload } from "@client/lib/api"; +import type { + LlmProvider, + ModelConfig, + ReviewSettings, +} from "@codraoss/schema"; +import type { ModelConfigsResponse } from "@codraoss/schema/api"; import { normalizeModelRoute, routesEqual, type ModelRouteConfig, -} from '@client/components/features/models/model-route'; +} from "@client/components/features/models/model-route"; import { providerDraftDirty, providerHasCredential, @@ -16,17 +20,17 @@ import { type NewProviderDraft, type ProviderDraft, type SyncError, -} from '@client/components/features/settings/settings-support'; +} from "@client/components/features/settings/settings-support"; /** Alias for the global route normalizer; `test/api/settings.spec.ts` imports it from this module. */ export const normalizeGlobalConfig = normalizeModelRoute; const BLANK_NEW_PROVIDER: NewProviderDraft = { - preset: 'custom-openai', - name: 'Custom OpenAI', - apiFormat: 'openai', - baseUrl: '', - apiKey: '', + preset: "custom-openai", + name: "Custom OpenAI", + apiFormat: "openai", + baseUrl: "", + apiKey: "", enabled: true, }; @@ -49,28 +53,37 @@ export function useProviderSettings({ const [providers, setProviders] = useState([]); const [savedProviders, setSavedProviders] = useState([]); const [configs, setConfigs] = useState([]); - const [globalConfig, setGlobalConfig] = useState(null); - const [savedGlobalConfig, setSavedGlobalConfig] = useState(null); + const [globalConfig, setGlobalConfig] = useState( + null, + ); + const [savedGlobalConfig, setSavedGlobalConfig] = + useState(null); - const [newProvider, setNewProvider] = useState(BLANK_NEW_PROVIDER); + const [newProvider, setNewProvider] = + useState(BLANK_NEW_PROVIDER); const [syncErrors, setSyncErrors] = useState([]); const [catalogRefreshing, setCatalogRefreshing] = useState(false); const [catalogRefreshedOnce, setCatalogRefreshedOnce] = useState(false); const [addingProvider, setAddingProvider] = useState(false); - const [expandedProviderId, setExpandedProviderId] = useState(null); + const [expandedProviderId, setExpandedProviderId] = useState( + null, + ); const existingProviderNames = useMemo( - () => new Set(providers.map(provider => provider.name.toLowerCase())), + () => new Set(providers.map((provider) => provider.name.toLowerCase())), [providers], ); - const selectedProviderNameExists = existingProviderNames.has(newProvider.name.trim().toLowerCase()); + const selectedProviderNameExists = existingProviderNames.has( + newProvider.name.trim().toLowerCase(), + ); const providerModelCounts = useMemo( - () => configs.reduce((counts, config) => { - counts.set(config.providerId, (counts.get(config.providerId) ?? 0) + 1); - return counts; - }, new Map()), + () => + configs.reduce((counts, config) => { + counts.set(config.providerId, (counts.get(config.providerId) ?? 0) + 1); + return counts; + }, new Map()), [configs], ); @@ -81,33 +94,46 @@ export function useProviderSettings({ const applyModelConfigResponse = (modelsRes: ModelConfigsResponse) => { // A save on one provider triggers a catalog refresh that can land here mid-edit on other rows; only overwrite rows without an unsaved draft. - setProviders(current => modelsRes.providers.map(fresh => { - const draft = current.find(item => item.id === fresh.id); - const lastKnownSaved = savedProviders.find(item => item.id === fresh.id); - if (draft && providerDraftDirty(draft, lastKnownSaved)) { - return draft; - } - return providerToDraft(fresh); - })); + setProviders((current) => + modelsRes.providers.map((fresh) => { + const draft = current.find((item) => item.id === fresh.id); + const lastKnownSaved = savedProviders.find( + (item) => item.id === fresh.id, + ); + if (draft && providerDraftDirty(draft, lastKnownSaved)) { + return draft; + } + return providerToDraft(fresh); + }), + ); setSavedProviders(modelsRes.providers); setConfigs(modelsRes.configs); setSyncErrors(modelsRes.syncErrors ?? []); }; - const refreshModelCatalog = async ({ quiet = false }: { quiet?: boolean } = {}) => { + const refreshModelCatalog = async ({ + quiet = false, + }: { quiet?: boolean } = {}) => { if (catalogRefreshing) return; setCatalogRefreshing(true); setSyncErrors([]); - const tid = quiet ? null : toast.loading('Syncing providers and models...'); + const tid = quiet ? null : toast.loading("Syncing providers and models..."); try { let savedProviderCount = 0; let failedProviderCount = 0; if (!quiet) { - const dirtyProviders = providers.filter( - provider => providerDraftDirty(provider, savedProviders.find(saved => saved.id === provider.id)), + const dirtyProviders = providers.filter((provider) => + providerDraftDirty( + provider, + savedProviders.find((saved) => saved.id === provider.id), + ), ); if (dirtyProviders.length > 0) { - const results = await Promise.all(dirtyProviders.map(provider => persistProvider(provider, { quiet: true }))); + const results = await Promise.all( + dirtyProviders.map((provider) => + persistProvider(provider, { quiet: true }), + ), + ); savedProviderCount = results.filter(Boolean).length; failedProviderCount = results.length - savedProviderCount; } @@ -120,21 +146,46 @@ export function useProviderSettings({ if (!quiet) { const failedCatalogs = modelsRes.syncErrors?.length ?? 0; const parts: string[] = []; - if (savedProviderCount > 0) parts.push(`${savedProviderCount} provider${savedProviderCount === 1 ? '' : 's'} saved`); - if (failedProviderCount > 0) parts.push(`${failedProviderCount} provider${failedProviderCount === 1 ? '' : 's'} failed to save`); - if (failedCatalogs > 0) parts.push(`${failedCatalogs} provider${failedCatalogs === 1 ? '' : 's'} reported a catalog error`); + if (savedProviderCount > 0) + parts.push( + `${savedProviderCount} provider${savedProviderCount === 1 ? "" : "s"} saved`, + ); + if (failedProviderCount > 0) + parts.push( + `${failedProviderCount} provider${failedProviderCount === 1 ? "" : "s"} failed to save`, + ); + if (failedCatalogs > 0) + parts.push( + `${failedCatalogs} provider${failedCatalogs === 1 ? "" : "s"} reported a catalog error`, + ); - const description = parts.length > 0 ? parts.join(' · ') : 'Providers and model lists are up to date.'; + const description = + parts.length > 0 + ? parts.join(" · ") + : "Providers and model lists are up to date."; if (failedProviderCount > 0 || failedCatalogs > 0) { - toast.error('Sync finished with issues', { id: tid ?? undefined, description }); + toast.error("Sync finished with issues", { + id: tid ?? undefined, + description, + }); } else { - toast.success('Sync complete', { id: tid ?? undefined, description }); + toast.success("Sync complete", { id: tid ?? undefined, description }); } } } catch (e) { - const msg = e instanceof Error ? e.message : 'Catalog refresh failed'; - setSyncErrors([{ providerId: 'catalog-refresh', providerName: 'Model catalog', error: msg }]); - if (!quiet) toast.error('Could not refresh catalog', { id: tid ?? undefined, description: msg }); + const msg = e instanceof Error ? e.message : "Catalog refresh failed"; + setSyncErrors([ + { + providerId: "catalog-refresh", + providerName: "Model catalog", + error: msg, + }, + ]); + if (!quiet) + toast.error("Could not refresh catalog", { + id: tid ?? undefined, + description: msg, + }); } finally { setCatalogRefreshing(false); } @@ -154,9 +205,11 @@ export function useProviderSettings({ hydrateReviewSettings(reviewSettingsRes.settings); return true; } catch (e) { - const msg = e instanceof Error ? e.message : 'Failed to load settings'; + const msg = e instanceof Error ? e.message : "Failed to load settings"; setError(msg); - toast.error('Could not load settings', { description: 'Something went wrong fetching your configuration.' }); + toast.error("Could not load settings", { + description: "Something went wrong fetching your configuration.", + }); return false; } finally { setLoading(false); @@ -166,51 +219,69 @@ export function useProviderSettings({ // Memoized because the debounced autosave below lists it as a dependency, so it has to be stable // or the 600 ms timer restarts on every render. `setSaving`/`setError` are the page's raw // `useState` setters, which React guarantees are stable -- don't pass inline wrappers instead. - const persistGlobalConfig = useCallback(async (next: ModelRouteConfig) => { - setSaving('global'); - setError(null); - const tid = toast.loading('Saving model strategy...'); - try { - await api.updateGlobalConfig(next); - setSavedGlobalConfig(next); - toast.success('Global strategy saved', { - id: tid, - description: 'Repositories without a custom strategy will use these settings.', - }); - } catch (e) { - const msg = e instanceof Error ? e.message : 'Update failed'; - setError(msg); - toast.error('Could not save strategy', { id: tid, description: 'Your changes were not applied.' }); - } finally { - setSaving(null); - } - }, [setSaving, setError]); + const persistGlobalConfig = useCallback( + async (next: ModelRouteConfig) => { + setSaving("global"); + setError(null); + const tid = toast.loading("Saving model strategy..."); + try { + await api.updateGlobalConfig(next); + setSavedGlobalConfig(next); + toast.success("Global strategy saved", { + id: tid, + description: + "Repositories without a custom strategy will use these settings.", + }); + } catch (e) { + const msg = e instanceof Error ? e.message : "Update failed"; + setError(msg); + toast.error("Could not save strategy", { + id: tid, + description: "Your changes were not applied.", + }); + } finally { + setSaving(null); + } + }, + [setSaving, setError], + ); useEffect(() => { if (!globalConfig || !globalDirty) return; - const handle = setTimeout(() => void persistGlobalConfig(globalConfig), 600); + const handle = setTimeout( + () => void persistGlobalConfig(globalConfig), + 600, + ); return () => clearTimeout(handle); }, [globalConfig, globalDirty, persistGlobalConfig]); const updateProviderDraft = (id: string, updates: Partial) => { - setProviders(current => current.map(provider => provider.id === id ? { ...provider, ...updates } : provider)); + setProviders((current) => + current.map((provider) => + provider.id === id ? { ...provider, ...updates } : provider, + ), + ); }; const persistProvider = async ( provider: ProviderDraft, - { quiet = false, clearApiKey = false, successMessage }: { quiet?: boolean; clearApiKey?: boolean; successMessage?: string } = {}, + { + quiet = false, + clearApiKey = false, + successMessage, + }: { quiet?: boolean; clearApiKey?: boolean; successMessage?: string } = {}, ) => { if (provider.enabled && !clearApiKey && !providerHasCredential(provider)) { if (!quiet) { setExpandedProviderId(provider.id); - toast.error('Add an API key before enabling this provider.'); + toast.error("Add an API key before enabling this provider."); } return null; } setSaving(`provider:${provider.id}`); setError(null); - const tid = quiet ? null : toast.loading('Saving provider...'); + const tid = quiet ? null : toast.loading("Saving provider..."); try { const payload: ProviderPayload = { name: provider.name, @@ -223,15 +294,30 @@ export function useProviderSettings({ } else if (provider.apiKey.trim()) { payload.apiKey = provider.apiKey.trim(); } - const { provider: saved } = await api.updateProvider(provider.id, payload); - setProviders(current => current.map(item => item.id === saved.id ? providerToDraft(saved) : item)); - setSavedProviders(current => current.map(item => item.id === saved.id ? saved : item)); - if (!quiet) toast.success(successMessage ?? 'Provider saved', { id: tid ?? undefined }); + const { provider: saved } = await api.updateProvider( + provider.id, + payload, + ); + setProviders((current) => + current.map((item) => + item.id === saved.id ? providerToDraft(saved) : item, + ), + ); + setSavedProviders((current) => + current.map((item) => (item.id === saved.id ? saved : item)), + ); + if (!quiet) + toast.success(successMessage ?? "Provider saved", { + id: tid ?? undefined, + }); return saved; } catch (e) { - const msg = e instanceof Error ? e.message : 'Provider update failed'; + const msg = e instanceof Error ? e.message : "Provider update failed"; setError(msg); - toast.error('Could not save provider', { id: tid ?? undefined, description: msg }); + toast.error("Could not save provider", { + id: tid ?? undefined, + description: msg, + }); return null; } finally { setSaving(null); @@ -240,26 +326,30 @@ export function useProviderSettings({ const saveProvider = async (provider: ProviderDraft) => { const saved = await persistProvider(provider); - if (saved && saved.enabled && (saved.hasApiKey || saved.apiFormat === 'cloudflare-workers-ai')) { + if ( + saved && + saved.enabled && + (saved.hasApiKey || saved.apiFormat === "cloudflare-workers-ai") + ) { void refreshModelCatalog({ quiet: true }); } }; const clearProviderKey = async (provider: ProviderDraft) => { // Build from the last saved state, not the draft, so clearing the key doesn't persist unrelated unsaved edits; a provider without a key can't stay enabled. - const saved = savedProviders.find(item => item.id === provider.id); + const saved = savedProviders.find((item) => item.id === provider.id); const base = saved ? providerToDraft(saved) : provider; await persistProvider( - { ...base, apiKey: '', enabled: false }, - { clearApiKey: true, successMessage: 'API key removed' }, + { ...base, apiKey: "", enabled: false }, + { clearApiKey: true, successMessage: "API key removed" }, ); }; const createProvider = async () => { if (!newProvider.name.trim() || selectedProviderNameExists) return; - setSaving('provider:new'); + setSaving("provider:new"); setError(null); - const tid = toast.loading('Creating provider...'); + const tid = toast.loading("Creating provider..."); try { const { provider } = await api.createProvider({ name: newProvider.name.trim(), @@ -268,18 +358,21 @@ export function useProviderSettings({ apiKey: newProvider.apiKey.trim() || undefined, enabled: newProvider.enabled, }); - setProviders(current => [...current, providerToDraft(provider)]); - setSavedProviders(current => [...current, provider]); + setProviders((current) => [...current, providerToDraft(provider)]); + setSavedProviders((current) => [...current, provider]); setNewProvider(BLANK_NEW_PROVIDER); setAddingProvider(false); - toast.success('Provider created', { id: tid }); - if (provider.enabled && (provider.hasApiKey || provider.apiFormat === 'cloudflare-workers-ai')) { + toast.success("Provider created", { id: tid }); + if ( + provider.enabled && + (provider.hasApiKey || provider.apiFormat === "cloudflare-workers-ai") + ) { void refreshModelCatalog({ quiet: true }); } } catch (e) { - const msg = e instanceof Error ? e.message : 'Provider creation failed'; + const msg = e instanceof Error ? e.message : "Provider creation failed"; setError(msg); - toast.error('Could not create provider', { id: tid, description: msg }); + toast.error("Could not create provider", { id: tid, description: msg }); } finally { setSaving(null); } @@ -288,22 +381,27 @@ export function useProviderSettings({ const removeProvider = async (id: string) => { setSaving(`provider:${id}`); setError(null); - const tid = toast.loading('Deleting provider...'); + const tid = toast.loading("Deleting provider..."); try { await api.deleteProvider(id); - setProviders(current => current.filter(provider => provider.id !== id)); - setSavedProviders(current => current.filter(provider => provider.id !== id)); - toast.success('Provider deleted', { id: tid }); + setProviders((current) => + current.filter((provider) => provider.id !== id), + ); + setSavedProviders((current) => + current.filter((provider) => provider.id !== id), + ); + toast.success("Provider deleted", { id: tid }); } catch (e) { - const msg = e instanceof Error ? e.message : 'Provider delete failed'; + const msg = e instanceof Error ? e.message : "Provider delete failed"; setError(msg); - toast.error('Could not delete provider', { id: tid, description: msg }); + toast.error("Could not delete provider", { id: tid, description: msg }); } finally { setSaving(null); } }; - const newProviderReady = newProvider.name.trim().length > 0 && + const newProviderReady = + newProvider.name.trim().length > 0 && newProvider.baseUrl.trim().length > 0 && newProvider.apiKey.trim().length > 0 && !selectedProviderNameExists; diff --git a/apps/dashboard/src/hooks/use-review-settings.ts b/apps/dashboard/src/hooks/use-review-settings.ts index 74db8913..f8e82dc1 100644 --- a/apps/dashboard/src/hooks/use-review-settings.ts +++ b/apps/dashboard/src/hooks/use-review-settings.ts @@ -1,14 +1,14 @@ -import { useState } from 'react'; -import { toast } from 'sonner'; -import { api } from '@client/lib/api'; -import type { ReviewSettings } from '@codraoss/schema'; -import { reviewMaxFilesRange } from '@codraoss/schema/review-limits'; +import { useState } from "react"; +import { toast } from "sonner"; +import { api } from "@client/lib/api"; +import type { ReviewSettings } from "@codraoss/schema"; +import { reviewMaxFilesRange } from "@codraoss/schema/review-limits"; import { CONCURRENCY_LEVEL_LABEL, CONCURRENCY_MAX_VALUE, CONCURRENCY_VALUE_TO_LEVEL, MAX_COMMENTS_CEILING, -} from '@client/components/features/settings/settings-support'; +} from "@client/components/features/settings/settings-support"; // Does NOT fetch: SettingsPage loads all settings payloads in one Promise.all and calls `hydrate` // with the result, so splitting this out did not turn one batched load into two. `setSaving` / @@ -20,12 +20,18 @@ export function useReviewSettings({ setSaving: (value: string | null) => void; setError: (value: string | null) => void; }) { - const [reviewSettings, setReviewSettings] = useState(null); - const [savedReviewSettings, setSavedReviewSettings] = useState(null); - const [pendingConfirm, setPendingConfirm] = useState<{ field: 'concurrency' | 'comments'; value: number } | null>(null); + const [reviewSettings, setReviewSettings] = useState( + null, + ); + const [savedReviewSettings, setSavedReviewSettings] = + useState(null); + const [pendingConfirm, setPendingConfirm] = useState<{ + field: "concurrency" | "comments"; + value: number; + } | null>(null); // Held as a string so the field can be mid-edit (empty, "2" on the way to "200") without the // saved value flickering underneath the cursor. - const [maxFilesDraft, setMaxFilesDraft] = useState(''); + const [maxFilesDraft, setMaxFilesDraft] = useState(""); const hydrate = (settings: ReviewSettings) => { setReviewSettings(settings); @@ -33,20 +39,23 @@ export function useReviewSettings({ setMaxFilesDraft(String(settings.maxFiles)); }; - const persistReviewSettings = async (next: ReviewSettings, summary: string) => { + const persistReviewSettings = async ( + next: ReviewSettings, + summary: string, + ) => { setReviewSettings(next); - setSaving('review-settings'); + setSaving("review-settings"); setError(null); - const tid = toast.loading('Saving…'); + const tid = toast.loading("Saving…"); try { await api.updateReviewSettings(next); setSavedReviewSettings(next); toast.success(summary, { id: tid }); } catch (e) { - const msg = e instanceof Error ? e.message : 'Update failed'; + const msg = e instanceof Error ? e.message : "Update failed"; setReviewSettings(savedReviewSettings); setError(msg); - toast.error('Could not save settings', { id: tid, description: msg }); + toast.error("Could not save settings", { id: tid, description: msg }); } finally { setSaving(null); } @@ -54,8 +63,11 @@ export function useReviewSettings({ const handleConcurrencyChange = (value: number) => { if (!reviewSettings) return; - if (value === CONCURRENCY_MAX_VALUE && reviewSettings.concurrencyLevel !== 'max') { - setPendingConfirm({ field: 'concurrency', value }); + if ( + value === CONCURRENCY_MAX_VALUE && + reviewSettings.concurrencyLevel !== "max" + ) { + setPendingConfirm({ field: "concurrency", value }); return; } const level = CONCURRENCY_VALUE_TO_LEVEL[value]; @@ -71,29 +83,41 @@ export function useReviewSettings({ // Snap junk or out-of-range input back to something valid instead of rejecting it. const next = Number.isFinite(parsed) - ? Math.min(reviewMaxFilesRange.max, Math.max(reviewMaxFilesRange.min, parsed)) + ? Math.min( + reviewMaxFilesRange.max, + Math.max(reviewMaxFilesRange.min, parsed), + ) : reviewSettings.maxFiles; setMaxFilesDraft(String(next)); if (next === reviewSettings.maxFiles) return; - void persistReviewSettings({ ...reviewSettings, maxFiles: next }, `File limit set to ${next}`); + void persistReviewSettings( + { ...reviewSettings, maxFiles: next }, + `File limit set to ${next}`, + ); }; const handleCommentsChange = (value: number) => { if (!reviewSettings) return; - if (value === MAX_COMMENTS_CEILING && reviewSettings.maxComments !== MAX_COMMENTS_CEILING) { - setPendingConfirm({ field: 'comments', value }); + if ( + value === MAX_COMMENTS_CEILING && + reviewSettings.maxComments !== MAX_COMMENTS_CEILING + ) { + setPendingConfirm({ field: "comments", value }); return; } void persistReviewSettings( - { ...reviewSettings, maxComments: value as ReviewSettings['maxComments'] }, + { + ...reviewSettings, + maxComments: value as ReviewSettings["maxComments"], + }, `Comment limit set to ${value}`, ); }; const applyPendingConfirm = () => { if (!pendingConfirm || !reviewSettings) return; - if (pendingConfirm.field === 'concurrency') { + if (pendingConfirm.field === "concurrency") { const level = CONCURRENCY_VALUE_TO_LEVEL[pendingConfirm.value]; void persistReviewSettings( { ...reviewSettings, concurrencyLevel: level }, @@ -101,7 +125,10 @@ export function useReviewSettings({ ); } else { void persistReviewSettings( - { ...reviewSettings, maxComments: pendingConfirm.value as ReviewSettings['maxComments'] }, + { + ...reviewSettings, + maxComments: pendingConfirm.value as ReviewSettings["maxComments"], + }, `Comment limit set to ${pendingConfirm.value}`, ); } diff --git a/apps/dashboard/src/hooks/use-session.tsx b/apps/dashboard/src/hooks/use-session.tsx index 2a3bb794..ccba04b5 100644 --- a/apps/dashboard/src/hooks/use-session.tsx +++ b/apps/dashboard/src/hooks/use-session.tsx @@ -1,7 +1,7 @@ -import { createContext, useContext, useEffect, useMemo, useState } from 'react'; -import type { ReactNode } from 'react'; -import { api } from '@client/lib/api'; -import type { AuthSessionUser } from '@codraoss/schema/api'; +import { createContext, useContext, useEffect, useMemo, useState } from "react"; +import type { ReactNode } from "react"; +import { api } from "@client/lib/api"; +import type { AuthSessionUser } from "@codraoss/schema/api"; export interface SessionState { user: AuthSessionUser | null; @@ -9,25 +9,44 @@ export interface SessionState { loading: boolean; } -const SessionContext = createContext({ user: null, permissions: undefined, loading: true }); +const SessionContext = createContext({ + user: null, + permissions: undefined, + loading: true, +}); export function SessionProvider({ children }: { children: ReactNode }) { - const [state, setState] = useState({ user: null, permissions: undefined, loading: true }); + const [state, setState] = useState({ + user: null, + permissions: undefined, + loading: true, + }); useEffect(() => { let cancelled = false; - api.getSession() + api + .getSession() .then((r) => { - if (!cancelled) setState({ user: r.user, permissions: r.permissions, loading: false }); + if (!cancelled) + setState({ + user: r.user, + permissions: r.permissions, + loading: false, + }); }) .catch(() => { - if (!cancelled) setState({ user: null, permissions: undefined, loading: false }); + if (!cancelled) + setState({ user: null, permissions: undefined, loading: false }); }); - return () => { cancelled = true; }; + return () => { + cancelled = true; + }; }, []); const value = useMemo(() => state, [state]); - return {children}; + return ( + {children} + ); } export function useSession() { diff --git a/apps/dashboard/src/hooks/use-stats-range.ts b/apps/dashboard/src/hooks/use-stats-range.ts index b33f8cae..2f6fa579 100644 --- a/apps/dashboard/src/hooks/use-stats-range.ts +++ b/apps/dashboard/src/hooks/use-stats-range.ts @@ -1,4 +1,4 @@ -import { useCallback, useSyncExternalStore } from 'react'; +import { useCallback, useSyncExternalStore } from "react"; export const DEFAULT_STATS_DAYS = 14; diff --git a/apps/dashboard/src/lib/api.ts b/apps/dashboard/src/lib/api.ts index 5011c3a9..431751f9 100644 --- a/apps/dashboard/src/lib/api.ts +++ b/apps/dashboard/src/lib/api.ts @@ -11,16 +11,21 @@ import type { StatsResponse, SyncReposResponse, UpdatesEmailResponse, -} from '@codraoss/schema/api'; -import type { LlmApiFormat, LlmProvider, RepoConfig, ReviewSettings } from '@codraoss/schema'; -import { resolvedTimeZone } from '@client/lib/timezone'; +} from "@codraoss/schema/api"; +import type { + LlmApiFormat, + LlmProvider, + RepoConfig, + ReviewSettings, +} from "@codraoss/schema"; +import { resolvedTimeZone } from "@client/lib/timezone"; -const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); +const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]); function pathSegment(value: string) { const trimmed = value.trim(); if (!trimmed) { - throw new Error('Path segment cannot be empty.'); + throw new Error("Path segment cannot be empty."); } return encodeURIComponent(trimmed); } @@ -34,35 +39,39 @@ export type ProviderPayload = { clearApiKey?: boolean; enabled: boolean; }; -type RepoConfigPatch = Partial & { enabled: boolean }>; +type RepoConfigPatch = Partial< + Pick & { enabled: boolean } +>; async function request(input: string, init?: RequestInit) { - const method = init?.method?.toUpperCase() ?? 'GET'; + const method = init?.method?.toUpperCase() ?? "GET"; const headers = new Headers(init?.headers); - if (!headers.has('content-type')) { - headers.set('content-type', 'application/json'); + if (!headers.has("content-type")) { + headers.set("content-type", "application/json"); } if (!SAFE_METHODS.has(method)) { - headers.set('x-requested-with', 'XMLHttpRequest'); + headers.set("x-requested-with", "XMLHttpRequest"); } const response = await fetch(input, { - credentials: 'same-origin', + credentials: "same-origin", ...init, headers, }); if (response.status === 401) { - if (location.pathname !== '/') { - location.href = '/'; + if (location.pathname !== "/") { + location.href = "/"; } - throw new Error('Unauthorized'); + throw new Error("Unauthorized"); } if (!response.ok) { - const payload = (await response.json().catch(() => null)) as { error?: string } | null; + const payload = (await response.json().catch(() => null)) as { + error?: string; + } | null; throw new Error(payload?.error ?? `Request failed with ${response.status}`); } @@ -74,39 +83,46 @@ async function request(input: string, init?: RequestInit) { } async function requestWithMeta(input: string, init?: RequestInit) { - const method = init?.method?.toUpperCase() ?? 'GET'; + const method = init?.method?.toUpperCase() ?? "GET"; const headers = new Headers(init?.headers); - if (!headers.has('content-type')) { - headers.set('content-type', 'application/json'); + if (!headers.has("content-type")) { + headers.set("content-type", "application/json"); } if (!SAFE_METHODS.has(method)) { - headers.set('x-requested-with', 'XMLHttpRequest'); + headers.set("x-requested-with", "XMLHttpRequest"); } const response = await fetch(input, { - credentials: 'same-origin', + credentials: "same-origin", ...init, headers, }); if (response.status === 401) { - if (location.pathname !== '/') { - location.href = '/'; + if (location.pathname !== "/") { + location.href = "/"; } - throw new Error('Unauthorized'); + throw new Error("Unauthorized"); } - const etag = response.headers.get('etag'); - const lastModified = response.headers.get('last-modified'); + const etag = response.headers.get("etag"); + const lastModified = response.headers.get("last-modified"); if (response.status === 304) { - return { status: response.status, etag, lastModified, notModified: true as const }; + return { + status: response.status, + etag, + lastModified, + notModified: true as const, + }; } if (!response.ok) { - const payload = (await response.json().catch(() => null)) as { error?: string } | null; + const payload = (await response.json().catch(() => null)) as { + error?: string; + } | null; throw new Error(payload?.error ?? `Request failed with ${response.status}`); } @@ -125,34 +141,36 @@ const CACHE_TTL = 5 * 60 * 1000; export const api = { getSession() { - return request('/api/auth/session'); + return request("/api/auth/session"); }, getAccount() { - return request('/api/auth/account'); + return request("/api/auth/account"); }, updateAccountName(name: string) { - return request('/api/auth/account', { - method: 'PATCH', + return request("/api/auth/account", { + method: "PATCH", body: JSON.stringify({ name }), }); }, /** `null` = follow the viewer's browser timezone. */ updateAccountTimezone(timezone: string | null) { - return request('/api/auth/account', { - method: 'PATCH', + return request("/api/auth/account", { + method: "PATCH", body: JSON.stringify({ timezone }), }); }, logout() { - return request<{ ok: boolean }>('/auth/logout', { - method: 'POST', + return request<{ ok: boolean }>("/auth/logout", { + method: "POST", }); }, getUpdatesEmailStatus() { const now = Date.now(); - if (!updatesEmailPromise || (now - updatesEmailFetchTime > CACHE_TTL)) { + if (!updatesEmailPromise || now - updatesEmailFetchTime > CACHE_TTL) { updatesEmailFetchTime = now; - updatesEmailPromise = request('/api/auth/updates-email').catch((err) => { + updatesEmailPromise = request( + "/api/auth/updates-email", + ).catch((err) => { updatesEmailPromise = null; throw err; }); @@ -160,10 +178,13 @@ export const api = { return updatesEmailPromise; }, subscribeUpdates(email: string) { - updatesEmailPromise = request('/api/auth/updates-email', { - method: 'POST', - body: JSON.stringify({ email }), - }).catch((err) => { + updatesEmailPromise = request( + "/api/auth/updates-email", + { + method: "POST", + body: JSON.stringify({ email }), + }, + ).catch((err) => { updatesEmailPromise = null; throw err; }); @@ -172,17 +193,17 @@ export const api = { getJobs(params: Record = {}) { const searchParams = new URLSearchParams(); for (const [key, value] of Object.entries(params)) { - if (value !== undefined && value !== null && value !== '') { + if (value !== undefined && value !== null && value !== "") { searchParams.set(key, String(value)); } } const query = searchParams.toString(); - return request(`/api/jobs${query ? `?${query}` : ''}`); + return request(`/api/jobs${query ? `?${query}` : ""}`); }, getJob(id: string, options: { etag?: string | null } = {}) { const headers = new Headers(); if (options.etag) { - headers.set('if-none-match', options.etag); + headers.set("if-none-match", options.etag); } return requestWithMeta(`/api/jobs/${id}`, { headers }); }, @@ -191,100 +212,115 @@ export const api = { }, retryJob(id: string) { return request(`/api/jobs/${id}/retry`, { - method: 'POST', + method: "POST", }); }, rerunJob(id: string) { return request(`/api/jobs/${pathSegment(id)}/rerun`, { - method: 'POST', + method: "POST", }); }, stopJob(id: string) { return request(`/api/jobs/${pathSegment(id)}/stop`, { - method: 'POST', + method: "POST", }); }, deleteJob(id: string) { return request(`/api/jobs/${pathSegment(id)}`, { - method: 'DELETE', + method: "DELETE", }); }, /** Record a human verdict on one finding. 'wrong' also suppresses it repo-wide; 'right' does not. */ - setFindingLabel(jobId: string, fingerprint: string, label: 'right' | 'wrong') { - return request<{ label: 'right' | 'wrong' }>( + setFindingLabel( + jobId: string, + fingerprint: string, + label: "right" | "wrong", + ) { + return request<{ label: "right" | "wrong" }>( `/api/jobs/${pathSegment(jobId)}/findings/${pathSegment(fingerprint)}/label`, - { method: 'PUT', body: JSON.stringify({ label }) }, + { method: "PUT", body: JSON.stringify({ label }) }, ); }, clearFindingLabel(jobId: string, fingerprint: string) { return request( `/api/jobs/${pathSegment(jobId)}/findings/${pathSegment(fingerprint)}/label`, - { method: 'DELETE' }, + { method: "DELETE" }, ); }, getRepos() { - return request('/api/repos'); + return request("/api/repos"); }, getRepo(owner: string, repo: string) { - return request(`/api/repos/${pathSegment(owner)}/${pathSegment(repo)}/config`); + return request( + `/api/repos/${pathSegment(owner)}/${pathSegment(repo)}/config`, + ); }, getStats(days?: number) { // Send the display zone so day buckets group the same way timestamps render. const params = new URLSearchParams({ tz: resolvedTimeZone() }); - if (days) params.set('days', String(days)); + if (days) params.set("days", String(days)); return request(`/api/stats?${params.toString()}`); }, syncRepos() { - return request('/api/repos/sync', { - method: 'POST', + return request("/api/repos/sync", { + method: "POST", }); }, updateRepoConfig(owner: string, repo: string, config: RepoConfigPatch) { - return request<{ ok: boolean }>(`/api/repos/${pathSegment(owner)}/${pathSegment(repo)}/config`, { - method: 'PATCH', - body: JSON.stringify(config), - }); + return request<{ ok: boolean }>( + `/api/repos/${pathSegment(owner)}/${pathSegment(repo)}/config`, + { + method: "PATCH", + body: JSON.stringify(config), + }, + ); }, getModelConfigs() { - return request('/api/models'); + return request("/api/models"); }, refreshModelCatalog() { - return request('/api/models/sync', { - method: 'POST', + return request("/api/models/sync", { + method: "POST", }); }, createProvider(config: ProviderPayload) { - return request<{ provider: LlmProvider }>('/api/models/providers', { - method: 'POST', + return request<{ provider: LlmProvider }>("/api/models/providers", { + method: "POST", body: JSON.stringify(config), }); }, updateProvider(id: string, config: ProviderPayload) { - return request<{ provider: LlmProvider }>(`/api/models/providers/${pathSegment(id)}`, { - method: 'PATCH', - body: JSON.stringify(config), - }); + return request<{ provider: LlmProvider }>( + `/api/models/providers/${pathSegment(id)}`, + { + method: "PATCH", + body: JSON.stringify(config), + }, + ); }, deleteProvider(id: string) { - return request<{ ok: boolean }>(`/api/models/providers/${pathSegment(id)}`, { - method: 'DELETE', - }); + return request<{ ok: boolean }>( + `/api/models/providers/${pathSegment(id)}`, + { + method: "DELETE", + }, + ); }, getGlobalConfig() { - return request<{ config: RepoConfig['model'] }>('/api/models/global'); + return request<{ config: RepoConfig["model"] }>("/api/models/global"); }, - updateGlobalConfig(config: RepoConfig['model']) { - return request<{ ok: boolean }>('/api/models/global', { - method: 'PATCH', + updateGlobalConfig(config: RepoConfig["model"]) { + return request<{ ok: boolean }>("/api/models/global", { + method: "PATCH", body: JSON.stringify(config), }); }, getReviewSettings() { - return request<{ settings: ReviewSettings }>('/api/settings'); + return request<{ settings: ReviewSettings }>("/api/settings"); }, updateReviewSettings(settings: ReviewSettings) { - return request<{ ok: boolean; settings: ReviewSettings }>('/api/settings', { - method: 'PATCH', + return request<{ ok: boolean; settings: ReviewSettings }>("/api/settings", { + method: "PATCH", body: JSON.stringify(settings), }); }, diff --git a/apps/dashboard/src/lib/batch-groups.ts b/apps/dashboard/src/lib/batch-groups.ts index 7adbd6fb..e3429f58 100644 --- a/apps/dashboard/src/lib/batch-groups.ts +++ b/apps/dashboard/src/lib/batch-groups.ts @@ -1,4 +1,4 @@ -import type { FileReviewRecord } from '@codraoss/schema'; +import type { FileReviewRecord } from "@codraoss/schema"; // Bin membership is never persisted (pack.ts derives it rather than storing it). But every file in // a bin is written with the SAME shared response, so grouping on `rawAiOutput` reconstructs the bins @@ -6,7 +6,9 @@ import type { FileReviewRecord } from '@codraoss/schema'; // names each file it covers. export type BatchGroup = { index: number; paths: string[] }; -export function groupBatches(files: FileReviewRecord[]): Map { +export function groupBatches( + files: FileReviewRecord[], +): Map { const byResponse = new Map(); for (const file of files) { @@ -14,7 +16,11 @@ export function groupBatches(files: FileReviewRecord[]): Map if ((file.batchSize ?? 1) <= 1 || !file.rawAiOutput) continue; const existing = byResponse.get(file.rawAiOutput); if (existing) existing.paths.push(file.filePath); - else byResponse.set(file.rawAiOutput, { index: byResponse.size + 1, paths: [file.filePath] }); + else + byResponse.set(file.rawAiOutput, { + index: byResponse.size + 1, + paths: [file.filePath], + }); } // Re-keyed by path, because a row only knows its own identity. diff --git a/apps/dashboard/src/lib/job-format.ts b/apps/dashboard/src/lib/job-format.ts index a29c0f1c..2579968d 100644 --- a/apps/dashboard/src/lib/job-format.ts +++ b/apps/dashboard/src/lib/job-format.ts @@ -1,22 +1,22 @@ -import type { JobSummary } from '@codraoss/schema'; +import type { JobSummary } from "@codraoss/schema"; /** Shared job status/duration formatting - keep it here, not duplicated per-component (previous copies diverged). */ /** Dot tone per status. `pending` means "not reached yet", so it stays neutral rather than borrowing queued's amber. */ export const STATUS_DOT: Record = { - done: 'bg-success', - running: 'bg-info', - queued: 'bg-warning', - failed: 'bg-danger', - superseded: 'bg-ui-subtle', - cancelled: 'bg-ui-subtle', - stopped: 'bg-ui-subtle', - pending: 'bg-ui-subtle', - skipped: 'bg-ui-subtle', + done: "bg-success", + running: "bg-info", + queued: "bg-warning", + failed: "bg-danger", + superseded: "bg-ui-subtle", + cancelled: "bg-ui-subtle", + stopped: "bg-ui-subtle", + pending: "bg-ui-subtle", + skipped: "bg-ui-subtle", }; export function statusLabel(status: string) { - return status.charAt(0).toUpperCase() + status.slice(1).replace(/_/g, ' '); + return status.charAt(0).toUpperCase() + status.slice(1).replace(/_/g, " "); } /** Whole seconds/minutes/hours, no decimals; `formatPreciseDuration` in lib/utils.ts shows sub-second precision instead. */ @@ -30,19 +30,20 @@ export function formatRunDuration(ms: number) { } /** Wall-clock run time: start → finish, or start → now while still running. */ -export function jobDuration(job: Pick) { +export function jobDuration(job: Pick) { if (!job.startedAt) return null; const start = new Date(job.startedAt).getTime(); const end = job.finishedAt ? new Date(job.finishedAt).getTime() : Date.now(); - if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) return null; + if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) + return null; return formatRunDuration(end - start); } /** Compact "16m ago" / "15h ago" / "3d ago" - terser than Intl.RelativeTimeFormat so a table column stays narrow. */ export function formatRelativeDate(value: string | Date | null | undefined) { - if (!value) return '-'; + if (!value) return "-"; const time = new Date(value).getTime(); - if (!Number.isFinite(time)) return '-'; + if (!Number.isFinite(time)) return "-"; const seconds = Math.max(0, Math.round((Date.now() - time) / 1000)); if (seconds < 60) return `${seconds}s ago`; const minutes = Math.floor(seconds / 60); diff --git a/apps/dashboard/src/lib/timezone.ts b/apps/dashboard/src/lib/timezone.ts index b1f3a193..c66f77f8 100644 --- a/apps/dashboard/src/lib/timezone.ts +++ b/apps/dashboard/src/lib/timezone.ts @@ -1,11 +1,11 @@ -import { isSupportedTimeZone } from '@codraoss/schema/timezone'; +import { isSupportedTimeZone } from "@codraoss/schema/timezone"; // Purely presentation; storage is always TIMESTAMPTZ. Mirrored to localStorage so first paint // skips the fetch. Defaults to UTC (not browser zone) so a timestamp reads the same for everyone. -const STORAGE_KEY = 'codra-timezone'; +const STORAGE_KEY = "codra-timezone"; -export const DEFAULT_TIME_ZONE = 'UTC'; +export const DEFAULT_TIME_ZONE = "UTC"; let cached: string | null | undefined; @@ -27,7 +27,7 @@ export function setStoredTimeZone(zone: string | null) { if (cached) localStorage.setItem(STORAGE_KEY, cached); else localStorage.removeItem(STORAGE_KEY); } catch { - // storage write failed; value still held in memory + // storage write failed; value still held in memory } } @@ -37,7 +37,9 @@ export function resolvedTimeZone(): string { export function browserTimeZone(): string { try { - return Intl.DateTimeFormat().resolvedOptions().timeZone || DEFAULT_TIME_ZONE; + return ( + Intl.DateTimeFormat().resolvedOptions().timeZone || DEFAULT_TIME_ZONE + ); } catch { return DEFAULT_TIME_ZONE; } @@ -48,26 +50,32 @@ const offsetCache = new Map(); export function timeZoneOffsetLabel(zone: string): string { if (offsetCache.has(zone)) return offsetCache.get(zone)!; try { - const parts = new Intl.DateTimeFormat('en-US', { + const parts = new Intl.DateTimeFormat("en-US", { timeZone: zone, - timeZoneName: 'shortOffset', + timeZoneName: "shortOffset", }).formatToParts(new Date()); - const label = parts.find((p) => p.type === 'timeZoneName')?.value ?? ''; + const label = parts.find((p) => p.type === "timeZoneName")?.value ?? ""; offsetCache.set(zone, label); return label; } catch { - return ''; + return ""; } } export function formatDateTime( value: string | number | Date, - options: Intl.DateTimeFormatOptions = { dateStyle: 'medium', timeStyle: 'short' }, + options: Intl.DateTimeFormatOptions = { + dateStyle: "medium", + timeStyle: "short", + }, ): string { const date = value instanceof Date ? value : new Date(value); if (Number.isNaN(date.getTime())) return String(value); try { - return date.toLocaleString(undefined, { ...options, timeZone: resolvedTimeZone() }); + return date.toLocaleString(undefined, { + ...options, + timeZone: resolvedTimeZone(), + }); } catch { // Intl throws on bad zone/options combo; retry without options try { @@ -81,45 +89,44 @@ export function formatDateTime( // value is date-only, already resolved by server; parse/render as UTC or the day shifts export function formatDayLabel( day: string, - options: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric' }, + options: Intl.DateTimeFormatOptions = { month: "short", day: "numeric" }, ): string { const date = new Date(`${day}T00:00:00Z`); if (Number.isNaN(date.getTime())) return day; try { - return date.toLocaleDateString(undefined, { ...options, timeZone: 'UTC' }); + return date.toLocaleDateString(undefined, { ...options, timeZone: "UTC" }); } catch { return day; } } export const COMMON_TIME_ZONES: string[] = [ - 'UTC', - 'America/Los_Angeles', - 'America/Denver', - 'America/Chicago', - 'America/New_York', - 'America/Sao_Paulo', - 'Europe/London', - 'Europe/Berlin', - 'Europe/Paris', - 'Europe/Moscow', - 'Africa/Lagos', - 'Africa/Johannesburg', - 'Asia/Dubai', - 'Asia/Karachi', - 'Asia/Kolkata', - 'Asia/Dhaka', - 'Asia/Bangkok', - 'Asia/Singapore', - 'Asia/Shanghai', - 'Asia/Tokyo', - 'Asia/Seoul', - 'Australia/Perth', - 'Australia/Sydney', - 'Pacific/Auckland', + "UTC", + "America/Los_Angeles", + "America/Denver", + "America/Chicago", + "America/New_York", + "America/Sao_Paulo", + "Europe/London", + "Europe/Berlin", + "Europe/Paris", + "Europe/Moscow", + "Africa/Lagos", + "Africa/Johannesburg", + "Asia/Dubai", + "Asia/Karachi", + "Asia/Kolkata", + "Asia/Dhaka", + "Asia/Bangkok", + "Asia/Singapore", + "Asia/Shanghai", + "Asia/Tokyo", + "Asia/Seoul", + "Australia/Perth", + "Australia/Sydney", + "Pacific/Auckland", ]; // Precompute labels statically COMMON_TIME_ZONES.forEach((zone) => timeZoneOffsetLabel(zone)); timeZoneOffsetLabel(DEFAULT_TIME_ZONE); - diff --git a/apps/dashboard/src/main.tsx b/apps/dashboard/src/main.tsx index 8ebd331d..fe13b9ee 100644 --- a/apps/dashboard/src/main.tsx +++ b/apps/dashboard/src/main.tsx @@ -1,15 +1,15 @@ -import React from 'react'; -import ReactDOM from 'react-dom/client'; -import { RouterProvider } from 'react-router-dom'; -import { buildRouter } from './routes'; +import React from "react"; +import ReactDOM from "react-dom/client"; +import { RouterProvider } from "react-router-dom"; +import { buildRouter } from "./routes"; -import './app.css'; +import "./app.css"; -import { ThemeProvider } from '@codraoss/ui/theme'; +import { ThemeProvider } from "@codraoss/ui/theme"; const router = buildRouter(); -ReactDOM.createRoot(document.getElementById('root')!).render( +ReactDOM.createRoot(document.getElementById("root")!).render( diff --git a/apps/dashboard/src/nav.ts b/apps/dashboard/src/nav.ts index 18e7d4d9..f31db77b 100644 --- a/apps/dashboard/src/nav.ts +++ b/apps/dashboard/src/nav.ts @@ -1,6 +1,12 @@ -import { LayoutDashboard, GitBranch, BarChart2, Activity, Settings } from 'lucide-react'; -import type { LucideIcon } from 'lucide-react'; -import type { ApiAction } from '@codraoss/schema/api'; +import { + LayoutDashboard, + GitBranch, + BarChart2, + Activity, + Settings, +} from "lucide-react"; +import type { LucideIcon } from "lucide-react"; +import type { ApiAction } from "@codraoss/schema/api"; export interface NavItem { to: string; @@ -12,9 +18,9 @@ export interface NavItem { // The sidebar's contents, kept here so navigation can be extended by composing this list; /account is intentionally absent because it lives in the account menu. export const navItems: NavItem[] = [ - { to: '/dashboard', label: 'Dashboard', icon: LayoutDashboard, end: true }, - { to: '/jobs', label: 'Jobs', icon: Activity, end: false }, - { to: '/repos', label: 'Repos', icon: GitBranch, end: false }, - { to: '/stats', label: 'Stats', icon: BarChart2, end: false }, - { to: '/settings', label: 'Settings', icon: Settings, end: false }, + { to: "/dashboard", label: "Dashboard", icon: LayoutDashboard, end: true }, + { to: "/jobs", label: "Jobs", icon: Activity, end: false }, + { to: "/repos", label: "Repos", icon: GitBranch, end: false }, + { to: "/stats", label: "Stats", icon: BarChart2, end: false }, + { to: "/settings", label: "Settings", icon: Settings, end: false }, ]; diff --git a/apps/dashboard/src/pages/account.tsx b/apps/dashboard/src/pages/account.tsx index d9a4ea22..8d4287f8 100644 --- a/apps/dashboard/src/pages/account.tsx +++ b/apps/dashboard/src/pages/account.tsx @@ -1,17 +1,17 @@ -import { LoadError } from '@codraoss/ui'; -import { useEffect, useRef, useState } from 'react'; -import { toast } from 'sonner'; -import { api } from '@client/lib/api'; -import { PageHeader } from '@client/components/layout/page-header'; +import { LoadError } from "@codraoss/ui"; +import { useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; +import { api } from "@client/lib/api"; +import { PageHeader } from "@client/components/layout/page-header"; import { getStoredTimeZone, resolvedTimeZone, setStoredTimeZone, -} from '@client/lib/timezone'; -import type { AccountSettings, AuthSessionUser } from '@codraoss/schema/api'; +} from "@client/lib/timezone"; +import type { AccountSettings, AuthSessionUser } from "@codraoss/schema/api"; -import { ProfileCard } from '@client/components/features/account/profile-card'; -import { AccountDetailsSection } from '@client/components/features/account/details-section'; +import { ProfileCard } from "@client/components/features/account/profile-card"; +import { AccountDetailsSection } from "@client/components/features/account/details-section"; export function AccountPage() { const [user, setUser] = useState(null); @@ -22,7 +22,9 @@ export function AccountPage() { // A ref, not state: it only guards concurrent saves and is never rendered. const savingZone = useRef(false); // State-driven, not a render-time localStorage read - that wasn't reactive and never reflected a save. - const [zonePref, setZonePref] = useState(() => getStoredTimeZone()); + const [zonePref, setZonePref] = useState(() => + getStoredTimeZone(), + ); const load = async () => { setError(null); @@ -39,7 +41,7 @@ export function AccountPage() { setZonePref(accountRes.account.timezone ?? null); } } catch (e) { - setError(e instanceof Error ? e.message : 'Failed to load your account.'); + setError(e instanceof Error ? e.message : "Failed to load your account."); } finally { setLoading(false); } @@ -57,13 +59,13 @@ export function AccountPage() { setAccount(res.account); setStoredTimeZone(res.account.timezone ?? null); setZonePref(res.account.timezone ?? null); - toast.success('Time zone updated', { + toast.success("Time zone updated", { description: `Timestamps now show in ${resolvedTimeZone()}.`, }); } catch (e) { setZonePref(previous); setStoredTimeZone(previous); - toast.error('Could not update time zone', { + toast.error("Could not update time zone", { description: e instanceof Error ? e.message : undefined, }); } finally { @@ -77,9 +79,14 @@ export function AccountPage() { // Falls back to GitHub profile name (then login) until the user sets their own. const displayName = - account?.accountName?.trim() || user?.name?.trim() || user?.login || 'GitHub user'; + account?.accountName?.trim() || + user?.name?.trim() || + user?.login || + "GitHub user"; const initial = displayName.charAt(0).toUpperCase(); - const profileUrl = user ? `https://github.com/${user.login}` : 'https://github.com'; + const profileUrl = user + ? `https://github.com/${user.login}` + : "https://github.com"; // Skeletons replace content only; chrome and labels stay rendered so the page doesn't reflow when data lands. const pending = loading || !user; @@ -95,7 +102,10 @@ export function AccountPage() { { setLoading(true); void load(); }} + onRetry={() => { + setLoading(true); + void load(); + }} retrying={loading} /> )} diff --git a/apps/dashboard/src/pages/dashboard.tsx b/apps/dashboard/src/pages/dashboard.tsx index a5a6eecf..e17ebb97 100644 --- a/apps/dashboard/src/pages/dashboard.tsx +++ b/apps/dashboard/src/pages/dashboard.tsx @@ -1,17 +1,17 @@ -import { Button, EmptyState, LoadError } from '@codraoss/ui'; -import { useState } from 'react'; -import { api } from '@client/lib/api'; -import type { StatsPayload, JobSummary } from '@codraoss/schema'; -import { ArrowRight, GitPullRequest, Activity } from 'lucide-react'; -import { JobsTable } from '@client/components/shared/jobs-table'; -import { PageHeaderActions } from '@client/components/shared/page-header-actions'; -import { Link } from 'react-router-dom'; - -import { PageHeader } from '@client/components/layout/page-header'; -import { OverviewStats } from '@client/components/features/stats/overview-stats'; -import { useFitRows } from '@client/hooks/use-fit-rows'; -import { usePolling } from '@client/hooks/use-polling'; -import { useStatsRange } from '@client/hooks/use-stats-range'; +import { Button, EmptyState, LoadError } from "@codraoss/ui"; +import { useState } from "react"; +import { api } from "@client/lib/api"; +import type { StatsPayload, JobSummary } from "@codraoss/schema"; +import { ArrowRight, GitPullRequest, Activity } from "lucide-react"; +import { JobsTable } from "@client/components/shared/jobs-table"; +import { PageHeaderActions } from "@client/components/shared/page-header-actions"; +import { Link } from "react-router-dom"; + +import { PageHeader } from "@client/components/layout/page-header"; +import { OverviewStats } from "@client/components/features/stats/overview-stats"; +import { useFitRows } from "@client/hooks/use-fit-rows"; +import { usePolling } from "@client/hooks/use-polling"; +import { useStatsRange } from "@client/hooks/use-stats-range"; export function DashboardPage() { const [stats, setStats] = useState(null); @@ -37,7 +37,7 @@ export function DashboardPage() { const statsRes = await api.getStats(days); setStats(statsRes.stats); } catch (e: any) { - setError(e.message || 'Failed to load stats'); + setError(e.message || "Failed to load stats"); } }; @@ -47,7 +47,7 @@ export function DashboardPage() { const jobsRes = await api.getJobs({ limit: rows }); setRecentJobs(jobsRes.jobs); } catch (e: any) { - setError(e.message || 'Failed to load jobs'); + setError(e.message || "Failed to load jobs"); } }; @@ -57,7 +57,7 @@ export function DashboardPage() { await Promise.all([loadStats(), loadJobs()]); setError(null); } catch (e) { - setError(e instanceof Error ? e.message : 'Failed to refresh dashboard.'); + setError(e instanceof Error ? e.message : "Failed to refresh dashboard."); } finally { setLoading(false); if (manual) setRefreshing(false); @@ -67,10 +67,8 @@ export function DashboardPage() { usePolling(loadStats, 15_000, [days]); usePolling(loadJobs, 15_000, [rows]); - return (
-
- -

Recent reviews

+ +

+ Recent reviews +

); } - diff --git a/apps/dashboard/src/pages/job-detail.tsx b/apps/dashboard/src/pages/job-detail.tsx index 6ad5c2bb..c24a1de1 100644 --- a/apps/dashboard/src/pages/job-detail.tsx +++ b/apps/dashboard/src/pages/job-detail.tsx @@ -1,30 +1,38 @@ -import { LoadError } from '@codraoss/ui'; -import { useState, lazy, Suspense } from 'react'; -import { useParams } from 'react-router-dom'; -import { LazyMotion, domAnimation } from 'motion/react'; -import { ClipboardList, FileDiff } from 'lucide-react'; -import { useJobDetail } from '@client/hooks/use-job-detail'; -import { JobHeader } from '@client/components/features/job-detail/job-header'; -import { JobProgress } from '@client/components/features/job-detail/job-progress'; -import { JobStatusNotice } from '@client/components/features/job-detail/job-status-notice'; -import { JobMetaCards } from '@client/components/features/job-detail/job-meta-cards'; -import { JobReviewOverview } from '@client/components/features/job-detail/job-review-overview'; -import { JobFindingsList } from '@client/components/features/job-detail/job-findings-list'; +import { LoadError } from "@codraoss/ui"; +import { useState, lazy, Suspense } from "react"; +import { useParams } from "react-router-dom"; +import { LazyMotion, domAnimation } from "motion/react"; +import { ClipboardList, FileDiff } from "lucide-react"; +import { useJobDetail } from "@client/hooks/use-job-detail"; +import { JobHeader } from "@client/components/features/job-detail/job-header"; +import { JobProgress } from "@client/components/features/job-detail/job-progress"; +import { JobStatusNotice } from "@client/components/features/job-detail/job-status-notice"; +import { JobMetaCards } from "@client/components/features/job-detail/job-meta-cards"; +import { JobReviewOverview } from "@client/components/features/job-detail/job-review-overview"; +import { JobFindingsList } from "@client/components/features/job-detail/job-findings-list"; -const JobDiffs = lazy(() => import('@client/components/features/job-detail/job-diffs').then((m) => ({ default: m.JobDiffs }))); -import { JobDetailSkeleton } from '@client/components/features/job-detail/job-skeleton'; -import { cn } from '@codraoss/ui/utils'; +const JobDiffs = lazy(() => + import("@client/components/features/job-detail/job-diffs").then((m) => ({ + default: m.JobDiffs, + })), +); +import { JobDetailSkeleton } from "@client/components/features/job-detail/job-skeleton"; +import { cn } from "@codraoss/ui/utils"; -type DetailTab = 'overview' | 'files'; +type DetailTab = "overview" | "files"; -const TABS: Array<{ id: DetailTab; label: string; icon: typeof ClipboardList }> = [ - { id: 'overview', label: 'Overview', icon: ClipboardList }, - { id: 'files', label: 'Files changed', icon: FileDiff }, +const TABS: Array<{ + id: DetailTab; + label: string; + icon: typeof ClipboardList; +}> = [ + { id: "overview", label: "Overview", icon: ClipboardList }, + { id: "files", label: "Files changed", icon: FileDiff }, ]; export function JobDetailPage() { - const { id = '' } = useParams(); - const [tab, setTab] = useState('overview'); + const { id = "" } = useParams(); + const [tab, setTab] = useState("overview"); const { job, error, @@ -43,9 +51,9 @@ export function JobDetailPage() { return (
-