Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 68 additions & 28 deletions src/components/ai-elements/local-change-review-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,19 @@ import {
isSourceControlUntracked,
type SourceControlStatusItem,
} from "@/lib/source-control-status";
import type {
LocalChangeReviewFocus,
LocalChangeReviewScope,
import {
LOCAL_CHANGE_REVIEW_FOCUS_OPTIONS,
type LocalChangeReviewFocus,
type LocalChangeReviewScope,
} from "@/lib/local-change-review";
import {
clampModelEffort,
resolveModelEffortFromSettings,
type ModelEffort,
} from "@/lib/providers/model-effort";
import { getProviderLabel } from "@/lib/providers/model-catalog";
import { cn } from "@/lib/utils";
import { useAppStore } from "@/store/app.store";
import { ModelIcon } from "./model-icon";
import { ModelSelector, type ModelSelectorOption } from "./model-selector";

Expand All @@ -39,17 +46,6 @@ const DEFAULT_REVIEW_FOCUSES: readonly LocalChangeReviewFocus[] = [
"tests",
];

const REVIEW_FOCUS_OPTIONS: ReadonlyArray<{
value: LocalChangeReviewFocus;
label: string;
}> = [
{ value: "correctness", label: "Correctness" },
{ value: "tests", label: "Test gaps" },
{ value: "security", label: "Security" },
{ value: "performance", label: "Performance" },
{ value: "architecture", label: "Architecture" },
];

const REVIEW_SCOPE_OPTIONS: ReadonlyArray<{
value: LocalChangeReviewScope;
label: string;
Expand Down Expand Up @@ -77,6 +73,7 @@ type ReviewChangeStatus =

export interface LocalChangeReviewRequest {
reviewer: ModelSelectorOption;
effort: ModelEffort;
scope: LocalChangeReviewScope;
focuses: readonly LocalChangeReviewFocus[];
instructions?: string;
Expand Down Expand Up @@ -121,6 +118,7 @@ export function LocalChangeReviewDialog(args: LocalChangeReviewDialogProps) {
const idPrefix = useId();
const [open, setOpen] = useState(false);
const [reviewerKey, setReviewerKey] = useState<string>();
const [selectedEffort, setSelectedEffort] = useState<ModelEffort>();
const [scope, setScope] = useState<LocalChangeReviewScope>("working-tree");
const [focuses, setFocuses] = useState<readonly LocalChangeReviewFocus[]>(
DEFAULT_REVIEW_FOCUSES,
Expand All @@ -132,10 +130,25 @@ export function LocalChangeReviewDialog(args: LocalChangeReviewDialogProps) {
});
const statusRequestIdRef = useRef(0);

const settings = useAppStore((state) => state.settings);
const preferredReviewer = getPreferredReviewer(args);
const reviewer =
args.reviewerOptions.find((option) => option.key === reviewerKey) ??
preferredReviewer;
// The reviewer's own model preference is the default; an explicit pick is
// kept across provider switches and clamped to what the new model accepts.
const effort = reviewer
? clampModelEffort({
providerId: reviewer.providerId,
model: reviewer.model,
effort: selectedEffort,
fallback: resolveModelEffortFromSettings({
settings,
providerId: reviewer.providerId,
model: reviewer.model,
}),
})
: undefined;
const providerIds = useMemo(
() => [...new Set(args.reviewerOptions.map((option) => option.providerId))],
[args.reviewerOptions],
Expand Down Expand Up @@ -227,13 +240,14 @@ export function LocalChangeReviewDialog(args: LocalChangeReviewDialogProps) {
}

async function handleSubmit() {
if (!reviewer || isSubmitting) {
if (!reviewer || !effort || isSubmitting) {
return;
}
setIsSubmitting(true);
try {
const submitted = await args.onSubmit({
reviewer,
effort,
scope,
focuses,
instructions: instructions.trim() || undefined,
Expand All @@ -247,7 +261,7 @@ export function LocalChangeReviewDialog(args: LocalChangeReviewDialogProps) {
}
}

if (!reviewer) {
if (!reviewer || !effort) {
return null;
}

Expand Down Expand Up @@ -387,8 +401,8 @@ export function LocalChangeReviewDialog(args: LocalChangeReviewDialogProps) {
Review by
</h3>
<p className="text-sm leading-5 text-muted-foreground">
Choose any available provider and model. This does not change
the task&apos;s active provider.
Choose any available provider, model, and reasoning effort. This
does not change the task&apos;s active provider.
</p>
</div>
<div className="grid gap-2 sm:grid-cols-2">
Expand Down Expand Up @@ -425,7 +439,11 @@ export function LocalChangeReviewDialog(args: LocalChangeReviewDialogProps) {
<ModelSelector
value={reviewer}
options={providerModelOptions}
onSelect={({ selection }) => setReviewerKey(selection.key)}
effort={effort}
onSelect={({ selection, effort: nextEffort }) => {
setReviewerKey(selection.key);
setSelectedEffort(nextEffort);
}}
className="w-full"
triggerClassName="h-11 w-full max-w-none border-border/80 bg-background px-3"
menuClassName="sm:max-w-lg"
Expand All @@ -438,24 +456,46 @@ export function LocalChangeReviewDialog(args: LocalChangeReviewDialogProps) {
Focus
</h3>
<p className="text-sm leading-5 text-muted-foreground">
Select the signals that should receive extra scrutiny.
Each selected focus adds an explicit instruction to the review
prompt. Unselected areas are still read for context.
</p>
</div>
<div className="flex flex-wrap gap-2">
{REVIEW_FOCUS_OPTIONS.map((option) => {
<div className="grid gap-2 sm:grid-cols-2">
{LOCAL_CHANGE_REVIEW_FOCUS_OPTIONS.map((option) => {
const selected = focuses.includes(option.value);
return (
<Button
<button
key={option.value}
type="button"
variant={selected ? "secondary" : "outline"}
className="h-11 rounded-full px-4"
aria-pressed={selected}
onClick={() => toggleFocus(option.value)}
className={cn(
"flex min-h-16 items-start gap-2.5 rounded-lg border px-3 py-2.5 text-left outline-none transition-colors focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50",
selected
? "border-primary/50 bg-primary/5"
: "border-border/80 bg-background hover:bg-muted/40",
)}
>
{selected ? <Check className="size-3.5" /> : null}
{option.label}
</Button>
<span
aria-hidden="true"
className={cn(
"mt-0.5 flex size-4 shrink-0 items-center justify-center rounded border",
selected
? "border-primary bg-primary text-primary-foreground"
: "border-border",
)}
>
{selected ? <Check className="size-3" /> : null}
</span>
<span className="min-w-0 space-y-0.5">
<span className="block text-sm font-medium text-foreground">
{option.label}
</span>
<span className="block text-sm leading-5 text-muted-foreground">
{option.description}
</span>
</span>
</button>
);
})}
</div>
Expand Down
108 changes: 101 additions & 7 deletions src/components/ai-elements/model-selector.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { ChevronDown, Sparkles } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useId, useMemo, useRef, useState } from "react";
import {
Command,
CommandEmpty,
Expand All @@ -17,6 +17,12 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
clampModelEffort,
getModelEffortLabel,
listModelEffortOptions,
type ModelEffort,
} from "@/lib/providers/model-effort";
import {
getProviderLabel,
listProviderIds,
Expand Down Expand Up @@ -46,7 +52,16 @@ interface ModelSelectorProps {
triggerAriaLabel?: string;
menuClassName?: string;
openToken?: string | number;
onSelect: (args: { selection: ModelSelectorOption }) => void;
/**
* Opt-in effort axis. When provided, the trigger shows `model · effort` and
* the dialog gains an effort row; picking a model re-clamps the effort to a
* value that model accepts before it reaches `onSelect`.
*/
effort?: ModelEffort;
onSelect: (args: {
selection: ModelSelectorOption;
effort?: ModelEffort;
}) => void;
}

export function ModelSelector(args: ModelSelectorProps) {
Expand All @@ -60,9 +75,11 @@ export function ModelSelector(args: ModelSelectorProps) {
triggerAriaLabel,
menuClassName,
openToken,
effort,
onSelect,
} = args;
const [open, setOpen] = useState(false);
const effortGroupId = useId();
// Seed the handled token with the value present at mount time. `openToken` is
// a one-shot "open now" trigger owned by the parent, but the parent keeps the
// latched value across the selector's mount/unmount cycles (e.g. when an
Expand Down Expand Up @@ -95,16 +112,51 @@ export function ModelSelector(args: ModelSelectorProps) {
.filter(([, providerOptions]) => providerOptions.length > 0);
}, [options, recommendedOptionKeys]);

const effortEnabled = effort !== undefined && !value.isAuto;
const effortOptions = useMemo(
() =>
effortEnabled
? listModelEffortOptions({
providerId: value.providerId,
model: value.model,
})
: [],
[effortEnabled, value.model, value.providerId],
);
const effortLabel = effortEnabled
? getModelEffortLabel({
providerId: value.providerId,
model: value.model,
effort,
})
: undefined;

const selectOption = (option: ModelSelectorOption) => {
onSelect({
selection: option,
...(effort !== undefined
? {
effort: option.isAuto
? effort
: clampModelEffort({
providerId: option.providerId,
model: option.model,
effort,
fallback: effort,
}),
}
: {}),
});
setOpen(false);
};

const renderOption = (option: ModelSelectorOption) => (
<CommandItem
key={option.key}
value={`${option.label} ${option.model} ${getProviderLabel({ providerId: option.providerId, variant: "full" })}${option.description ? ` ${option.description}` : ""}`}
disabled={!option.available}
data-checked={option.key === value.key ? "true" : undefined}
onSelect={() => {
onSelect({ selection: option });
setOpen(false);
}}
onSelect={() => selectOption(option)}
className="gap-3 rounded-lg px-3 py-2.5"
>
{option.isAuto ? (
Expand Down Expand Up @@ -176,7 +228,10 @@ export function ModelSelector(args: ModelSelectorProps) {
className="size-3.5"
/>
)}
<span className="truncate">{value.label}</span>
<span className="truncate">
{value.label}
{effortLabel ? ` · ${effortLabel}` : ""}
</span>
</span>
<ChevronDown className="size-3.5 text-muted-foreground" />
</DialogTrigger>
Expand Down Expand Up @@ -230,6 +285,45 @@ export function ModelSelector(args: ModelSelectorProps) {
))}
</CommandList>
</Command>
{effortEnabled && effortOptions.length > 0 ? (
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 border-t border-border/70 bg-muted/20 px-3 py-2.5">
<span
id={effortGroupId}
className="text-xs font-medium text-muted-foreground"
>
Effort
</span>
<div
role="radiogroup"
aria-labelledby={effortGroupId}
className="flex flex-wrap items-center gap-1"
>
{effortOptions.map((option) => {
const selected = option.value === effort;
return (
<button
key={option.value}
type="button"
role="radio"
aria-checked={selected}
disabled={disabled}
onClick={() =>
onSelect({ selection: value, effort: option.value })
}
className={cn(
"h-8 rounded-md px-2.5 text-xs font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-60",
selected
? "bg-primary/10 text-primary ring-1 ring-primary/40"
: "text-muted-foreground hover:bg-muted/60 hover:text-foreground",
)}
>
{option.label}
</button>
);
})}
</div>
</div>
) : null}
</DialogContent>
</Dialog>
);
Expand Down
2 changes: 2 additions & 0 deletions src/components/compare/CompareRunPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -613,6 +613,7 @@ export function CompareRunPanel(props: CompareRunPanelProps) {
{displayedJudgeModel
? ` · ${toHumanModelName({ model: displayedJudgeModel })}`
: ""}
{judge.effort ? ` · ${judge.effort}` : ""}
{" · Fresh context · Read only"}
{judge.status === "completed" && judgeProvenance
? ` · Rubric v${judgeProvenance.rubricVersion} · Attempt ${judgeProvenance.attempt}`
Expand Down Expand Up @@ -727,6 +728,7 @@ export function CompareRunPanel(props: CompareRunPanelProps) {
{variant.model
? ` / ${toHumanModelName({ model: variant.model })}`
: ""}
{variant.effort ? ` · ${variant.effort}` : ""}
</p>
{candidateScore ? (
<span className="shrink-0 font-mono text-xs font-semibold text-foreground">
Expand Down
Loading
Loading