diff --git a/README.md b/README.md index c43baa22..c85fecf6 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ Codra listens to GitHub pull request events, runs AI-powered review jobs, posts - Queue-backed processing through Cloudflare Queues - GitHub OAuth dashboard authentication - External PostgreSQL storage through Cloudflare Hyperdrive -- Dashboard-managed LLM providers for OpenAI, OpenRouter, Anthropic, Google, and Cloudflare models +- Dashboard-managed LLM providers for OpenAI, OpenRouter, Anthropic, Google, NVIDIA, and Cloudflare models - Repository settings for labels, skipped globs, custom rules, and model routing ## How It Works @@ -66,7 +66,7 @@ Codra listens to GitHub pull request events, runs AI-powered review jobs, posts - **Dashboard**: React, Vite, Tailwind CSS, Radix UI, Recharts - **Data**: PostgreSQL, Cloudflare Hyperdrive, Cloudflare KV - **Queues**: Cloudflare Queues and Workflows -- **Models**: OpenAI, OpenRouter, Anthropic, Google, and Cloudflare providers +- **Models**: OpenAI, OpenRouter, Anthropic, Google, NVIDIA, and Cloudflare providers - **GitHub**: GitHub App webhooks, checks, reviews, and OAuth - **Quality**: TypeScript, Zod, Vitest, Playwright browser tests @@ -77,6 +77,7 @@ The full setup and operations guides live at [codra.run/docs](https://codra.run/ - [Installation guide](https://codra.run/docs/installation) - [Configuration guide](https://codra.run/docs/configuration) - [Deploy with Neon](https://codra.run/docs/neon) +- [Deploy with Railway](https://codra.run/docs/railway) - [Contributing](CONTRIBUTING.md) - [Security policy](SECURITY.md) diff --git a/db/migrations/003_grounding.sql b/db/migrations/003_grounding.sql index eefba816..766097c8 100644 --- a/db/migrations/003_grounding.sql +++ b/db/migrations/003_grounding.sql @@ -185,3 +185,12 @@ ALTER TABLE file_reviews ADD COLUMN IF NOT EXISTS batch_size INTEGER; CREATE INDEX IF NOT EXISTS file_reviews_batch_size_idx ON file_reviews (batch_size) WHERE batch_size > 1; + +-- Top level, not inside the $backfill$ block above: that block returns early on databases that +-- predate consolidation, and this seed has to reach every fresh database. +INSERT INTO llm_providers (name, api_format, base_url, enabled) +VALUES ('NVIDIA', 'openai', 'https://integrate.api.nvidia.com/v1', FALSE) +ON CONFLICT (name) DO UPDATE SET + api_format = EXCLUDED.api_format, + base_url = EXCLUDED.base_url, + updated_at = now(); diff --git a/eslint.config.js b/eslint.config.js index c18b53fb..bb56b9df 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -50,11 +50,11 @@ export default tseslint.config( 'import-x/no-cycle': 'error', // 400 lines, counting neither blanks nor comments, so adding an explanation never pushes a file - // over. Two files carry an explicit override below, each for a stated reason -- a third should - // be a split, not a third override. + // over. One file carries an explicit override below, for a stated reason -- a second should + // be a split, not a second override. 'max-lines': ['error', { max: 400, skipBlankLines: true, skipComments: true }], - // An error, not a warning: the four places whose dependency array is deliberately narrower + // An error, not a warning: the three places whose dependency array is deliberately narrower // than their closure now carry a line-level disable stating why. A new violation should fail. 'react-hooks/exhaustive-deps': 'error', @@ -107,19 +107,13 @@ export default tseslint.config( }, }, { - // The two files still over the limit, each for a stated reason. Both are known work, not - // permanent carve-outs -- delete the entry rather than raising `max` when either is split. - // - // settings.tsx (552): SettingsPage is one component whose JSX reads nearly all of its 19 pieces - // of state, so its remaining sections do not align with extractable units -- lifting the - // providers section out means threading ~23 props. The support module, provider row, About - // section, review section and review-settings hook are already out (1096 -> 612 lines); what - // is left needs the provider load/CRUD split into a hook first. + // The one file still over the limit, for a stated reason. Known work, not a permanent + // carve-out -- delete the entry rather than raising `max` when it is split. // // test/api/auth.spec.ts (422): the review-settings suites here read-modify-write the same // singleton `global_settings` row set and race across files once `fileParallelism` is on. See // the DO-NOT-SPLIT header on the file itself. - files: ['src/client/pages/settings.tsx', 'test/api/auth.spec.ts'], + files: ['test/api/auth.spec.ts'], rules: { 'max-lines': 'off', }, diff --git a/scripts/migrate.mjs b/scripts/migrate.mjs index cc903577..10da3eb2 100644 --- a/scripts/migrate.mjs +++ b/scripts/migrate.mjs @@ -107,6 +107,7 @@ async function ensureModelCatalog() { ('Anthropic', 'anthropic', 'https://api.anthropic.com/v1', FALSE), ('OpenRouter', 'openai', 'https://openrouter.ai/api/v1', FALSE), ('xAI', 'openai', 'https://api.x.ai/v1', FALSE), + ('NVIDIA', 'openai', 'https://integrate.api.nvidia.com/v1', FALSE), ('Vertex AI', 'vertex', NULL, FALSE) ON CONFLICT (name) DO UPDATE SET api_format = EXCLUDED.api_format, diff --git a/src/client/components/features/account/details-section.tsx b/src/client/components/features/account/details-section.tsx new file mode 100644 index 00000000..fec9b946 --- /dev/null +++ b/src/client/components/features/account/details-section.tsx @@ -0,0 +1,127 @@ +import { useMemo } from 'react'; +import { Mail } from 'lucide-react'; +import { Text } from '@client/components/ui/text'; +import { Select } from '@client/components/ui/select'; +import { Skeleton } from '@client/components/shared/skeleton'; +import { SectionCard } from '@client/components/shared/section-card'; +import { + COMMON_TIME_ZONES, + DEFAULT_TIME_ZONE, + browserTimeZone, + formatDateTime, + resolvedTimeZone, + timeZoneOffsetLabel, +} from '@client/lib/timezone'; +import type { AccountSettings, AuthSessionUser } from '@shared/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() { + const zones = Array.from(new Set([DEFAULT_TIME_ZONE, ...COMMON_TIME_ZONES, browserTimeZone()])) + .sort((a, b) => a.localeCompare(b)); + return zones.map((zone) => { + const offset = timeZoneOffsetLabel(zone); + return { value: zone, label: offset ? `${zone} · ${offset}` : zone }; + }); +} + +function formatDate(value: string) { + return formatDateTime(value, { + year: 'numeric', + month: 'long', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + timeZoneName: 'short', + }); +} + +export function AccountDetailsSection({ + user, + account, + pending, + displayName, + zonePref, + onZoneChange, +}: { + user: AuthSessionUser | null; + account: AccountSettings | null; + /** Render chrome with skeletons in place of content, so the card doesn't reflow when data lands. */ + pending: boolean; + displayName: string; + zonePref: string | null; + 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(), []); + + return ( + +
+ + + {displayName} + + + {user?.email ? ( + + + {user.email} + + ) : ( + Not provided + )} + + + + + + @{user?.login} + + + {user?.githubUserId} + + + + + {(pending || account) && ( + + {account?.id} + + )} + + {user ? formatDate(user.signedInAt) : null} + + + {/* Timestamps are stored absolute (UTC); this only controls how they're rendered. */} +
+ + + Date & time zone + + + Stored in UTC, shown in {resolvedTimeZone()} + + + {pending ? ( + + ) : ( +
+ setNameDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') void saveName(); + if (e.key === 'Escape') setEditingName(false); + }} + autoFocus + maxLength={120} + aria-label="Account name" + className="h-9 w-full max-w-xs" + /> +
+ + +
+
+

+ Enter to save · Esc to cancel - this name is used across Codra. +

+
+ ) : ( + <> +
+

+ {displayName} +

+ +
+
+ + @{user!.login} + + + + GitHub + +
+ + )} +
+ + {pending ? ( + + ) : ( + } + className="shrink-0 self-start sm:self-auto" + > + View on GitHub + + + )} + + + ); +} diff --git a/src/client/components/features/job-detail/comment-card.tsx b/src/client/components/features/job-detail/comment-card.tsx index 42c9c17b..fa8faccd 100644 --- a/src/client/components/features/job-detail/comment-card.tsx +++ b/src/client/components/features/job-detail/comment-card.tsx @@ -8,6 +8,7 @@ import { CopyButton } from '@client/components/shared/copy-button'; import { preventToggleOnTextSelection } from '@client/lib/selection'; import type { ParsedReviewComment } from '@shared/schema'; import { severityConfig } from './constants'; +import { ContextSnippet } from './context-snippet'; import { safeRehypePlugins } from '@client/lib/markdown-plugins'; /** Plain-English reason a finding never reached the pull request. */ @@ -173,12 +174,7 @@ export function CommentCard({ comment, filePath, jobId }: CommentCardProps) {
-
-            {comment.contextSnippet}
-          
+ )} diff --git a/src/client/components/features/job-detail/context-snippet.tsx b/src/client/components/features/job-detail/context-snippet.tsx new file mode 100644 index 00000000..d3c5c051 --- /dev/null +++ b/src/client/components/features/job-detail/context-snippet.tsx @@ -0,0 +1,65 @@ +import { useMemo } from 'react'; +import { highlightLine, langForPath } from '@client/lib/highlight'; +import { cn } from '@client/lib/utils'; +import { ROW_TONES } from './diff-file-panel-utils'; + +/** + * The stored diff context of a finding, rendered like the diff viewer: gutter, +/- marker and + * syntax-highlighted code. Lines arrive from renderDiffSnippet as `%4d `. + */ + +const SNIPPET_LINE = /^(\s*\d*) ([+\- ])(.*)$/; + +type SnippetRow = { gutter: string; kind: 'add' | 'del' | 'ctx'; text: string }; + +function parseSnippet(snippet: string): SnippetRow[] { + return snippet.split('\n').map((line) => { + const match = SNIPPET_LINE.exec(line); + // Anything that doesn't fit the server's shape renders verbatim as a context line. + if (!match) return { gutter: '', kind: 'ctx' as const, text: line }; + const [, gutter, prefix, text] = match; + return { + gutter: gutter.trim(), + kind: prefix === '+' ? ('add' as const) : prefix === '-' ? ('del' as const) : ('ctx' as const), + text, + }; + }); +} + +export function ContextSnippet({ snippet, filePath }: { snippet: string; filePath: string }) { + const lang = useMemo(() => langForPath(filePath), [filePath]); + const rows = useMemo(() => parseSnippet(snippet), [snippet]); + + return ( +
+
+ {rows.map((row, i) => { + const tone = ROW_TONES[row.kind]; + return ( +
+ + {row.gutter} + + + {row.kind === 'add' ? '+' : row.kind === 'del' ? '-' : ' '} + + + {highlightLine(row.text, lang)} + +
+ ); + })} +
+
+ ); +} diff --git a/src/client/components/features/job-detail/diff-file-panel-utils.ts b/src/client/components/features/job-detail/diff-file-panel-utils.ts new file mode 100644 index 00000000..ee924c62 --- /dev/null +++ b/src/client/components/features/job-detail/diff-file-panel-utils.ts @@ -0,0 +1,47 @@ +import type { CSSProperties } from 'react'; +import type { DiffRow } from '@client/lib/prompt-diff'; +import type { ParsedReviewComment } from '@shared/schema'; + +export const LARGE_DIFF_ROWS = 300; + +// Longer diffs render only the first PREVIEW_ROWS lines behind a "Show full diff" control, so a huge +// PR never dumps tens of thousands of rows into the DOM. Files with comments are never truncated. +export const PREVIEW_ROWS = 150; + +// Estimated row height, used to size a panel before it first paints. +export const DIFF_ROW_PX = 20; + +// Offscreen panels skip layout/paint; this placeholder height keeps the scrollbar and page height +// stable instead of the page "growing" as panels come into view. +export function panelCvStyle(open: boolean, lineEstimate: number): CSSProperties { + const body = open ? Math.min(lineEstimate, PREVIEW_ROWS) * DIFF_ROW_PX + 140 : 0; + return { + contentVisibility: 'auto', + containIntrinsicSize: `auto ${46 + body}px`, + }; +} + +export function fileAnchorId(id: string) { + return `diff-file-${id}`; +} + +export const ROW_TONES: Record = { + add: { row: 'diff-add', gutter: 'diff-add-fg', marker: 'diff-add-fg' }, + del: { row: 'diff-del', gutter: 'diff-del-fg', marker: 'diff-del-fg' }, + ctx: { row: '', gutter: 'text-ui-subtle/60', marker: 'text-transparent' }, + hunk: { row: 'ui-well', gutter: '', marker: '' }, +}; + +export const NO_ROWS: DiffRow[] = []; + +/** Row identity: the embedded line numbers, plus the text so hunk headers (which have none) differ. */ +export function rowKey(row: DiffRow) { + return `${row.kind}:${row.oldNo ?? ''}:${row.newNo ?? ''}:${row.text}`; +} + +// The (path, line, title) triple `fingerprint` hashes, plus the position within the rendered list. +// The triple alone is not unique: `parsedComments` is the ungated set, so a model that reports the +// same finding twice on one line keeps both rows, and duplicate React keys drop siblings. +export function commentKey(comment: ParsedReviewComment, index: number) { + return `${comment.path}:${comment.line ?? ''}:${comment.title}#${index}`; +} diff --git a/src/client/components/features/job-detail/diff-file-panel.tsx b/src/client/components/features/job-detail/diff-file-panel.tsx index 9ff39061..6363187b 100644 --- a/src/client/components/features/job-detail/diff-file-panel.tsx +++ b/src/client/components/features/job-detail/diff-file-panel.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState, type CSSProperties } from 'react'; +import { useMemo, useState } from 'react'; import { Check, ChevronDown } from 'lucide-react'; import { Badge, StatusBadge } from '@client/components/ui/badge'; import { parsePromptDiff, diffStats, type DiffRow } from '@client/lib/prompt-diff'; @@ -6,38 +6,16 @@ import { highlightLine, langForPath } from '@client/lib/highlight'; import { cn } from '@client/lib/utils'; import type { FileReviewRecord, ParsedReviewComment } from '@shared/schema'; import { CommentCard } from './comment-card'; +import { + LARGE_DIFF_ROWS, + NO_ROWS, + PREVIEW_ROWS, + ROW_TONES, + commentKey, + rowKey, +} from './diff-file-panel-utils'; -export const LARGE_DIFF_ROWS = 300; - -// Longer diffs render only the first PREVIEW_ROWS lines behind a "Show full diff" control, so a huge -// PR never dumps tens of thousands of rows into the DOM. Files with comments are never truncated. -export const PREVIEW_ROWS = 150; - -// Estimated row height, used to size a panel before it first paints. -export const DIFF_ROW_PX = 20; - -// Offscreen panels skip layout/paint; this placeholder height keeps the scrollbar and page height -// stable instead of the page "growing" as panels come into view. -export function panelCvStyle(open: boolean, lineEstimate: number): CSSProperties { - const body = open ? Math.min(lineEstimate, PREVIEW_ROWS) * DIFF_ROW_PX + 140 : 0; - return { - contentVisibility: 'auto', - containIntrinsicSize: `auto ${46 + body}px`, - }; -} - -export function fileAnchorId(id: string) { - return `diff-file-${id}`; -} - -export const ROW_TONES: Record = { - add: { row: 'diff-add', gutter: 'diff-add-fg', marker: 'diff-add-fg' }, - del: { row: 'diff-del', gutter: 'diff-del-fg', marker: 'diff-del-fg' }, - ctx: { row: '', gutter: 'text-ui-subtle/60', marker: 'text-transparent' }, - hunk: { row: 'ui-well', gutter: '', marker: '' }, -}; - -export function DiffLine({ row, lang }: { row: DiffRow; lang: ReturnType }) { +function DiffLine({ row, lang }: { row: DiffRow; lang: ReturnType }) { const tone = ROW_TONES[row.kind]; if (row.kind === 'hunk') { @@ -79,8 +57,6 @@ export interface FileDiffProps { onToggleViewed: (viewed: boolean) => void; } -export const NO_ROWS: DiffRow[] = []; - export function FileDiff({ file, open, viewed, diffsLoading = false, onOpenChange, onToggleViewed }: FileDiffProps) { const lang = useMemo(() => langForPath(file.filePath), [file.filePath]); // Header stats come from a cheap line scan; the full row parse only happens once the panel opens. @@ -114,21 +90,23 @@ export function FileDiff({ file, open, viewed, diffsLoading = false, onOpenChang } } + // Each segment is keyed off its first row / anchor line, so keys survive a re-slice when the + // panel expands from preview to full. const segs: Array< - | { type: 'rows'; rows: DiffRow[] } - | { type: 'comments'; comments: ParsedReviewComment[] } + | { type: 'rows'; key: string; rows: DiffRow[] } + | { type: 'comments'; key: string; comments: ParsedReviewComment[] } > = []; let run: DiffRow[] = []; for (const row of visibleRows) { run.push(row); const comments = row.newNo !== null ? byLine.get(row.newNo) : undefined; if (comments) { - segs.push({ type: 'rows', rows: run }); - segs.push({ type: 'comments', comments }); + segs.push({ type: 'rows', key: `rows:${rowKey(run[0])}`, rows: run }); + segs.push({ type: 'comments', key: `comments:${row.newNo}`, comments }); run = []; } } - if (run.length > 0) segs.push({ type: 'rows', rows: run }); + if (run.length > 0) segs.push({ type: 'rows', key: `rows:${rowKey(run[0])}`, rows: run }); return { segments: segs, unanchored: rest }; }, [visibleRows, file.parsedComments]); @@ -204,20 +182,20 @@ export function FileDiff({ file, open, viewed, diffsLoading = false, onOpenChang ) : ( // 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, i) => + segments.map((segment) => segment.type === 'rows' ? ( -
+
- {segment.rows.map((row, j) => ( - + {segment.rows.map((row) => ( + ))}
) : ( -
+
- {segment.comments.map((comment, j) => ( - + {segment.comments.map((comment, i) => ( + ))}
@@ -266,8 +244,8 @@ export function FileDiff({ file, open, viewed, diffsLoading = false, onOpenChang File-level comments

- {unanchored.map((comment, j) => ( - + {unanchored.map((comment, i) => ( + ))}
diff --git a/src/client/components/features/job-detail/file-finding.tsx b/src/client/components/features/job-detail/file-finding.tsx index 4f6e1ebe..298c43c8 100644 --- a/src/client/components/features/job-detail/file-finding.tsx +++ b/src/client/components/features/job-detail/file-finding.tsx @@ -4,7 +4,8 @@ import { ChevronRight } from 'lucide-react'; import type { FileReviewRecord, ParsedReviewComment } from '@shared/schema'; import { CommentCard } from './comment-card'; import { preventToggleOnTextSelection } from '@client/lib/selection'; -import { MonoPath, StatusDot, VerdictPill, statusLabel } from './job-chips'; +import { MonoPath, StatusDot, VerdictPill } from './job-chips'; +import { statusLabel } from './job-chip-utils'; import { safeRehypePlugins } from '@client/lib/markdown-plugins'; interface FileFindingProps { diff --git a/src/client/components/features/job-detail/job-chip-utils.ts b/src/client/components/features/job-detail/job-chip-utils.ts new file mode 100644 index 00000000..514db1d2 --- /dev/null +++ b/src/client/components/features/job-detail/job-chip-utils.ts @@ -0,0 +1,31 @@ +/** + * The non-component half of the row vocabulary in `job-chips.tsx` - formatters and shared class + * strings, kept out of the component module so Fast Refresh can preserve chip state. + */ +import { formatDateTime } from '@client/lib/timezone'; + +// Re-exported so the sibling job-detail components get the whole row vocabulary from one place. +export { formatRelativeDate, statusLabel } from '@client/lib/job-format'; + +/** Full stamp for `title` tooltips, in the account's display time zone (falling back to UTC). */ +export function formatAbsoluteDate(value: string | Date | null | undefined) { + if (!value) return undefined; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return undefined; + // Component options, not dateStyle/timeStyle: Intl throws if a style shorthand is combined + // with a component option like `timeZoneName`. + return formatDateTime(date, { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + timeZoneName: 'short', + }); +} + +/** One label → value row. Fixed height and hairline dividers echo the table's 48px rhythm. */ +export const DETAIL_ROW = + 'flex h-11 items-center justify-between gap-4 border-t border-ui-line first:border-transparent'; + +export const DETAIL_LABEL = 'shrink-0 text-xs leading-none text-ui-default dark:text-ui-subtle'; diff --git a/src/client/components/features/job-detail/job-chips.tsx b/src/client/components/features/job-detail/job-chips.tsx index 90f75063..8c854dc7 100644 --- a/src/client/components/features/job-detail/job-chips.tsx +++ b/src/client/components/features/job-detail/job-chips.tsx @@ -5,32 +5,11 @@ import { useState, type ReactNode } from 'react'; import { CheckCircle2, MessageSquare, type LucideIcon } from 'lucide-react'; import { cn } from '@client/lib/utils'; -import { formatDateTime } from '@client/lib/timezone'; import { STATUS_DOT, jobDuration, statusLabel } from '@client/lib/job-format'; -// Re-exported so the sibling job-detail components keep importing the row vocabulary from one place. -export { formatRelativeDate, formatRunDuration, jobDuration, statusLabel } from '@client/lib/job-format'; - import type { JobDetail, JobSummary } from '@shared/schema'; -/** Full stamp for `title` tooltips, in the account's display time zone (falling back to UTC). */ -export function formatAbsoluteDate(value: string | Date | null | undefined) { - if (!value) return undefined; - const date = new Date(value); - if (Number.isNaN(date.getTime())) return undefined; - // Component options, not dateStyle/timeStyle: Intl throws if a style shorthand is combined - // with a component option like `timeZoneName`. - return formatDateTime(date, { - year: 'numeric', - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - timeZoneName: 'short', - }); -} - /** Status dot alone, for rows that render their own label. */ export function StatusDot({ status, className }: { status: string; className?: string }) { return ( @@ -183,12 +162,6 @@ export function AuthorChip({ login }: { login: string | null }) { ); } -/** One label → value row. Fixed height and hairline dividers echo the table's 48px rhythm. */ -export const DETAIL_ROW = - 'flex h-11 items-center justify-between gap-4 border-t border-ui-line first:border-transparent'; - -export const DETAIL_LABEL = 'shrink-0 text-xs leading-none text-ui-default dark:text-ui-subtle'; - export function EmptyValue() { return -; } diff --git a/src/client/components/features/job-detail/job-diffs.tsx b/src/client/components/features/job-detail/job-diffs.tsx index b5a66e6b..4d7403bd 100644 --- a/src/client/components/features/job-detail/job-diffs.tsx +++ b/src/client/components/features/job-detail/job-diffs.tsx @@ -14,7 +14,8 @@ import { diffStats } from '@client/lib/prompt-diff'; import { readDiffsCache, writeDiffsCache } from '@client/lib/diffs-cache'; import type { FileReviewRecord, JobDetail } from '@shared/schema'; -import { panelCvStyle, fileAnchorId, FileDiff } from './diff-file-panel'; +import { FileDiff } from './diff-file-panel'; +import { panelCvStyle, fileAnchorId } from './diff-file-panel-utils'; import { FileTree } from './diff-file-tree'; /** A file present in the PR diff with no review row yet (job still running or file skipped) - shown as pending, GitHub-style. */ function syntheticFileReview(jobId: string, filePath: string, diffInput: string): FileReviewRecord { diff --git a/src/client/components/features/job-detail/job-findings-list.tsx b/src/client/components/features/job-detail/job-findings-list.tsx index c139c065..6e50bc4a 100644 --- a/src/client/components/features/job-detail/job-findings-list.tsx +++ b/src/client/components/features/job-detail/job-findings-list.tsx @@ -6,6 +6,7 @@ import { Tabs, TabsList, TabsTrigger } from '@client/components/motion/tabs'; import { FileFinding } from './file-finding'; import { CommentCard } from './comment-card'; import { severityConfig } from './constants'; +import { commentKey } from './diff-file-panel-utils'; interface JobFindingsListProps { job: JobDetail; @@ -123,9 +124,9 @@ export function JobFindingsList({ job }: JobFindingsListProps) { {reviewSeverities.map((groupName) => { const comments = job.files.flatMap((f) => - f.parsedComments - .filter((c) => c.severity === groupName) - .map((c) => ({ ...c, filePath: f.filePath })), + f.parsedComments.flatMap((c) => + c.severity === groupName ? [{ ...c, filePath: f.filePath }] : [], + ), ); if (comments.length === 0) return null; @@ -147,9 +148,9 @@ export function JobFindingsList({ job }: JobFindingsListProps) { {groupName}
- {comments.map((comment, index) => ( + {comments.map((comment, i) => ( ; + label: string; + /** In-flight: swaps the icon for a spinner. Also disables unless `disabled` says otherwise. */ + busy: boolean; + disabled?: boolean; + variant?: ButtonProps['variant']; + className?: string; + onClick: () => void; +} + +// Every header action is the same icon-only button whose only state is "in flight", so the busy flag +// lives here rather than branching the header itself. +function JobActionButton({ + icon: Icon, + label, + busy, + disabled, + variant = 'secondary', + className = 'rounded-[7px]', + onClick, +}: JobActionButtonProps) { + return ( + + ); +} + interface JobHeaderProps { job: JobDetail; isRerunning: boolean; @@ -148,45 +182,30 @@ export function JobHeader({ - + /> {/* Always restarts the review from the beginning (every file), regardless of the job's current status. */} - + /> - + />
diff --git a/src/client/components/features/job-detail/job-meta-cards.tsx b/src/client/components/features/job-detail/job-meta-cards.tsx index 028aea1c..794a6d8a 100644 --- a/src/client/components/features/job-detail/job-meta-cards.tsx +++ b/src/client/components/features/job-detail/job-meta-cards.tsx @@ -4,16 +4,13 @@ import { Link } from 'react-router-dom'; import { cn, formatPreciseDuration } from '@client/lib/utils'; import type { JobDetail, JobStep } from '@shared/schema'; import { - DETAIL_LABEL, - DETAIL_ROW, EmptyValue, JobStatusLine, MetaChip, StatusDot, VerdictPill, - formatAbsoluteDate, - formatRelativeDate, } from './job-chips'; +import { DETAIL_LABEL, DETAIL_ROW, formatAbsoluteDate, formatRelativeDate } from './job-chip-utils'; interface JobMetaCardsProps { job: JobDetail; @@ -192,7 +189,7 @@ export function JobMetaCards({ job }: JobMetaCardsProps) { {steps.length === 0 ? (

No steps recorded yet.

) : ( - steps.map((step, idx) => ) + steps.map((step) => ) )}
diff --git a/src/client/components/features/job-detail/job-skeleton.tsx b/src/client/components/features/job-detail/job-skeleton.tsx index d8bc59c3..6721443a 100644 --- a/src/client/components/features/job-detail/job-skeleton.tsx +++ b/src/client/components/features/job-detail/job-skeleton.tsx @@ -2,7 +2,7 @@ import { Link } from 'react-router-dom'; import { ChevronRight, ClipboardList, FileDiff, Info, ListChecks } from 'lucide-react'; import { Skeleton } from '@client/components/shared/skeleton'; import { LoadError } from '@client/components/shared/load-error'; -import { DETAIL_LABEL, DETAIL_ROW } from './job-chips'; +import { DETAIL_LABEL, DETAIL_ROW } from './job-chip-utils'; interface JobDetailSkeletonProps { error: string | null; diff --git a/src/client/components/features/models/model-chain.tsx b/src/client/components/features/models/model-chain.tsx index 3e066bcd..33432f96 100644 --- a/src/client/components/features/models/model-chain.tsx +++ b/src/client/components/features/models/model-chain.tsx @@ -1,10 +1,9 @@ -import { useState, useMemo, useEffect } from 'react'; +import { useId, useMemo, useState } from 'react'; import { cn } from '@client/lib/utils'; import { Select } from '@client/components/ui/select'; import { Button } from '@client/components/ui/button'; import { Trash2, ListPlus } from 'lucide-react'; - import type { ModelDensity, ModelOption, @@ -12,25 +11,7 @@ import type { ModelRouteTier, ProviderOption, } from './model-route'; -import { - EMPTY_MODEL_ROUTE, - describeModelRoute, - normalizeModelRoute, - routesEqual, -} from './model-route'; -// Re-exported so repos.tsx and settings.tsx keep importing these names from here, where they've always lived. -export { - EMPTY_MODEL_ROUTE, - describeModelRoute, - normalizeModelRoute, - routesEqual, - type ModelDensity, - type ModelOption, - type ModelRouteConfig, - type ModelRouteTier, - type ProviderOption, -}; interface ModelSelectorProps { value: string | null; onValueChange: (value: string) => void; @@ -41,7 +22,7 @@ interface ModelSelectorProps { className?: string; } -export function ModelSelector({ +function ModelSelector({ value, onValueChange, models, @@ -50,18 +31,14 @@ export function ModelSelector({ density = 'comfortable', className, }: ModelSelectorProps) { + // The shown provider follows the selected model, so it stays derived rather than synced. The picked + // provider only decides the filter while nothing is selected yet. + const [pickedProvider, setPickedProvider] = useState(null); const currentModel = models.find(m => m.value === value); - const [provider, setProvider] = useState(currentModel?.providerId ?? providers[0]?.value ?? ''); - - useEffect(() => { - const model = models.find(m => m.value === value); - if (model && model.providerId !== provider) { - setProvider(model.providerId); - } - }, [models, provider, value]); + const provider = currentModel?.providerId ?? pickedProvider ?? providers[0]?.value ?? ''; const filteredModels = useMemo( - () => models.filter(m => m.providerId === provider).map(m => ({ value: m.value, label: m.label })), + () => models.flatMap(m => (m.providerId === provider ? [{ value: m.value, label: m.label }] : [])), [models, provider], ); @@ -87,7 +64,7 @@ export function ModelSelector({ label={hideLabels ? undefined : 'Provider'} value={provider} onValueChange={(nextProvider) => { - setProvider(nextProvider); + setPickedProvider(nextProvider); const first = models.find(m => m.providerId === nextProvider); if (first) onValueChange(first.value); }} @@ -115,7 +92,7 @@ interface ModelChainProps { density?: ModelDensity; } -export function ModelChain({ +function ModelChain({ primary, fallbacks, onChange, @@ -219,6 +196,7 @@ export function ModelRouteEditor({ density = 'comfortable', className, }: ModelRouteEditorProps) { + const fieldId = useId(); const tiers = value.size_overrides ?? []; const updateTier = (index: number, updates: Partial) => { @@ -305,11 +283,15 @@ export function ModelRouteEditor({
-