From 1e02e39bddfb76d5716811614c7c45330acdc06a Mon Sep 17 00:00:00 2001 From: Devarshi Shimpi Date: Mon, 10 Aug 2026 07:52:46 +0530 Subject: [PATCH 1/8] feat: bucket stats trends for long ranges, share time-range state across pages --- .../features/stats/chart-primitives.tsx | 53 +++++++++++++---- .../features/stats/metrics-grid.tsx | 30 ++++++---- .../features/stats/time-range-select.tsx | 6 +- src/client/hooks/use-stats-range.ts | 36 ++++++++++++ src/client/pages/dashboard.tsx | 3 +- src/client/pages/stats.tsx | 3 +- src/server/db/file-reviews.ts | 1 + src/server/db/stats.ts | 58 ++++++++++++++----- src/shared/schema.ts | 5 ++ test/db/stats-trend.spec.ts | 27 +++++++++ test/e2e/dashboard.spec.tsx | 1 + 11 files changed, 185 insertions(+), 38 deletions(-) create mode 100644 src/client/hooks/use-stats-range.ts create mode 100644 test/db/stats-trend.spec.ts diff --git a/src/client/components/features/stats/chart-primitives.tsx b/src/client/components/features/stats/chart-primitives.tsx index 56836cfb..638b9838 100644 --- a/src/client/components/features/stats/chart-primitives.tsx +++ b/src/client/components/features/stats/chart-primitives.tsx @@ -1,4 +1,4 @@ -import { type ReactNode } from 'react'; +import { Children, type ReactNode } from 'react'; import { Activity, Boxes, Coins, FolderGit2, ShieldCheck } from 'lucide-react'; import { LayerCard } from '@client/components/ui/layer-card'; import { Skeleton } from '@client/components/shared/skeleton'; @@ -33,6 +33,12 @@ export function formatDay(value: string) { return formatDayLabel(value); } +/** Bucketed trend points cover a span; label them `Jul 1 – Jul 7` rather than just the start day. */ +export function formatDayRange(day: string, endDay?: string) { + if (!endDay || endDay === day) return formatDay(day); + return `${formatDay(day)} – ${formatDay(endDay)}`; +} + export function formatCompact(value: number) { return value >= 1000 ? fmtNumber(value) : value.toLocaleString(); } @@ -44,13 +50,13 @@ export function modelName(model: string) { export function ChartTooltip({ active, payload, label }: any) { if (!active || !payload?.length) return null; + const endDay: string | undefined = payload[0]?.payload?.endDay; + const heading = + typeof label === 'string' && label.includes('-') ? formatDayRange(label, endDay) : label; + return (
- {label && ( -

- {typeof label === 'string' && label.includes('-') ? formatDay(label) : label} -

- )} + {label &&

{heading}

}
{payload.map((item: any) => (
@@ -174,6 +180,31 @@ export function ChartDefs({ isDark }: { isDark: boolean }) { ); } +/** + * Caps a meter list at `visible` rows and scrolls the rest, so a long tail (dozens of models) + * can't stretch the card and throw off the others sharing its grid row. The cap is a pixel + * max-height derived from the fixed row/gap metrics below, which is why `TickMeter` pins its + * own height. + */ +const METER_ROW_PX = 20; +const METER_GAP_PX = 14; + +export function MeterList({ visible, children }: { visible: number; children: ReactNode }) { + // Only the overflowing case gets the cap and the scrollbar gutter, so short lists keep even padding. + const scrolls = Children.count(children) > visible; + + return ( +
+
+ {children} +
+
+ ); +} + /** Segmented tick meter (reference "cost allocation" bars). */ export function TickMeter({ label, @@ -192,7 +223,7 @@ export function TickMeter({ const filled = value > 0 ? Math.max(1, Math.round((value / Math.max(max, 1)) * SEGMENTS)) : 0; return ( -
+
{label} @@ -222,11 +253,11 @@ export function GraphCardSkeleton({ title, icon, className = '' }: { title: stri ); } -export function GraphBarCardSkeleton({ title, icon, className = '' }: { title: string; icon?: ReactNode; className?: string }) { +export function GraphBarCardSkeleton({ title, icon, rows = 5, className = '' }: { title: string; icon?: ReactNode; rows?: number; className?: string }) { return (
- {Array.from({ length: 8 }).map((_, i) => ( + {Array.from({ length: rows }).map((_, i) => (
@@ -247,8 +278,8 @@ export function MetricsGridSkeleton() {
} /> - } /> - } /> + } rows={4} /> + } rows={5} />
); diff --git a/src/client/components/features/stats/metrics-grid.tsx b/src/client/components/features/stats/metrics-grid.tsx index a865857d..cb32f5e2 100644 --- a/src/client/components/features/stats/metrics-grid.tsx +++ b/src/client/components/features/stats/metrics-grid.tsx @@ -20,6 +20,7 @@ import { ChartTooltip, GraphShell, LegendChip, + MeterList, MONO_STACK, TICK_COLORS_DARK, TICK_COLORS_LIGHT, @@ -43,6 +44,9 @@ export function MetricsGrid({ const quietColor = isDark ? CHART.quietDark : CHART.quiet; const dashColor = isDark ? 'rgba(228,228,231,0.75)' : 'rgba(63,63,70,0.65)'; const tickColors = isDark ? TICK_COLORS_DARK : TICK_COLORS_LIGHT; + // Long ranges arrive pre-combined into multi-day buckets; say so, since each point is a sum, not a day. + const bucketDays = stats.trendBucketDays ?? 1; + const bucketNote = bucketDays > 1 ? {bucketDays}-day totals : null; const repoMax = Math.max(...stats.topRepos.map((repo) => repo.jobs), 1); const modelMax = Math.max(...stats.models.map((model) => model.calls), 1); @@ -57,12 +61,13 @@ export function MetricsGrid({ axisLine: false, tick: { fontFamily: MONO_STACK, fill: axisColor }, } as const; - // minTickGap thins labels by available space, not a fixed stride, since the trend array can have far fewer points than `days`. + // `equidistantPreserveStart` drops labels on a fixed stride (every 2nd, every 3rd, ...) sized to the + // available width, so the dates stay evenly spaced instead of jumping by uneven gaps. const xAxisProps = { dataKey: 'day', tickFormatter: formatDay, - interval: 'preserveStartEnd' as const, - minTickGap: 24, + interval: 'equidistantPreserveStart' as const, + minTickGap: 12, }; const STATUS_COLOR: Record = { @@ -85,6 +90,7 @@ export function MetricsGrid({ <> + {bucketNote} } > @@ -129,6 +135,7 @@ export function MetricsGrid({ <> + {bucketNote} } > @@ -140,8 +147,9 @@ export function MetricsGrid({ } cursor={{ fill: cursorColor }} /> - - + {/* Capped so a short range (or a heavily bucketed one) doesn't render a handful of slab-wide bars. */} + +
@@ -200,8 +208,8 @@ export function MetricsGrid({ }> -
- {stats.topRepos.slice(0, 8).map((repo, i) => ( + + {stats.topRepos.map((repo, i) => ( ))} -
+
}> -
- {stats.models.slice(0, 8).map((model, i) => ( + + {stats.models.map((model, i) => ( ))} -
+
diff --git a/src/client/components/features/stats/time-range-select.tsx b/src/client/components/features/stats/time-range-select.tsx index 6cb59054..b6311ad4 100644 --- a/src/client/components/features/stats/time-range-select.tsx +++ b/src/client/components/features/stats/time-range-select.tsx @@ -1,6 +1,7 @@ import type { CSSProperties } from 'react'; import { Clock } from 'lucide-react'; import { Select } from '@client/components/ui/select'; +import { DEFAULT_STATS_DAYS } from '@client/hooks/use-stats-range'; import { cn } from '@client/lib/utils'; interface TimeRangeSelectProps { @@ -18,7 +19,10 @@ const timeRanges = [ ]; export function TimeRangeSelect({ value, onValueChange, className, triggerStyle }: TimeRangeSelectProps) { - const selectedRange = timeRanges.find((r) => r.value === value) || timeRanges[1]; + const selectedRange = + timeRanges.find((r) => r.value === value) ?? + timeRanges.find((r) => r.value === DEFAULT_STATS_DAYS) ?? + timeRanges[0]; return ( +
+ )} +
+ + + + ); +} diff --git a/src/client/components/features/account/profile-card.tsx b/src/client/components/features/account/profile-card.tsx new file mode 100644 index 00000000..28a098a7 --- /dev/null +++ b/src/client/components/features/account/profile-card.tsx @@ -0,0 +1,171 @@ +import { useState } from 'react'; +import { toast } from 'sonner'; +import { api } from '@client/lib/api'; +import { Button, LinkButton } from '@client/components/ui/button'; +import { Input } from '@client/components/ui/input'; +import { Badge } from '@client/components/ui/badge'; +import { Skeleton } from '@client/components/shared/skeleton'; +import { GithubMark } from '@client/components/shared/github-mark'; +import { ExternalLink, Pencil, Check, X } from 'lucide-react'; +import type { AccountSettings, AuthSessionUser } from '@shared/api'; + +export function ProfileCard({ + user, + pending, + displayName, + initial, + profileUrl, + onAccountChange, +}: { + user: AuthSessionUser | null; + /** Render chrome with skeletons in place of content, so the card doesn't reflow when data lands. */ + pending: boolean; + displayName: string; + initial: string; + profileUrl: string; + onAccountChange: (account: AccountSettings) => void; +}) { + const [editingName, setEditingName] = useState(false); + const [nameDraft, setNameDraft] = useState(''); + const [savingName, setSavingName] = useState(false); + + const startEditName = () => { + setNameDraft(displayName); + setEditingName(true); + }; + + const saveName = async () => { + const trimmed = nameDraft.trim(); + if (!trimmed) { + toast.error('Name cannot be empty.'); + return; + } + setSavingName(true); + try { + const res = await api.updateAccountName(trimmed); + onAccountChange(res.account); + setEditingName(false); + toast.success('Account name updated'); + } catch (e) { + toast.error('Could not update name', { + description: e instanceof Error ? e.message : undefined, + }); + } finally { + setSavingName(false); + } + }; + + return ( +
+
+ {pending ? ( + + ) : user!.avatarUrl ? ( + + ) : ( + + {initial} + + )} + +
+ {pending ? ( +
+ + +
+ ) : editingName ? ( +
+
+ 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/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({
-