open-swe/ui/src/routes/admin.tsx
Adam Moussa 589cd236c6
chore: cherry-pick clean upstream fixes + cherry-pick runbook (#117)
* fix: make plan view mobile friendly (#1636)

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
(cherry picked from commit 7ee3e05724)

* fix: return to thread after plan approval (#1637)

Co-authored-by: Ramon Nogueira <270434257+ramon-langchain@users.noreply.github.com>
Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
(cherry picked from commit f32e492ab4)

* feat: reviews block agenda, sticky headers, accurate diff scroll (#1653)

Rework the AI-sorted blocks experience on the PR reviews page into a
Google-Docs-style outline: the left sidebar is now a clean number+title
agenda with scroll-spy highlighting of the active block; each block shows
its title + description (sticky) above its diff; and diff rows are pinned to
a uniform height so scroll-to lands precisely via the virtualizer's own
geometry instead of an estimate-driven correction loop.

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
(cherry picked from commit 0b76afdc955e33805c7623d1502a75a9c7c9c1b7)

* fix: jump + ResizeObserver settle for review scroll-to (#1655)

Replace smooth-scroll plus frame-count correction loops on the PR
reviews page with an instant jump that re-asserts its target via a
ResizeObserver (the real "layout settled" signal). Block/file
navigation and finding/comment centering now land deterministically as
off-screen cards mount, files expand, and annotation cards measure,
instead of racing a smooth-scroll animation against height
reconciliation. Holds bail on user wheel/touch input and after a short
ceiling, and a new navigation cancels the previous hold.

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
Co-authored-by: Johannes du Plessis <johannes@langchain.dev>
(cherry picked from commit 7530653bba7774d66a54b8bef0d2bbc25f519942)

* fix: purge expired thread_wakeup crons (#1656)

* fix: purge expired thread_wakeup crons

One-shot wakeup crons set an end_time that stops re-firing but the cron
row is never deleted, so dead rows accumulate (86 in prod). Add a purge
that deletes thread_wakeup crons past their end_time, called
opportunistically before scheduling a new wakeup, plus a one-time
backfill script. Conservative: matches only kind=thread_wakeup with a
past end_time.

* chore: retrigger Open SWE review

---------

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
(cherry picked from commit 9e5a1924ef306269322c31342a1831e57831cfee)

* fix: add top padding to sticky review block header (#1660)

* fix: add top padding to sticky review block header

The sticky per-block header on the reviews page had padding below but
none above, so the block number badge sat glued against the top edge
when pinned. Add matching top padding for breathing room.

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>

* chore: use py-2 shorthand for review block header padding

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>

---------

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
(cherry picked from commit 23bd4a63fc5ba0fe853babf79ed33feb866cc8b2)

* fix: use global tokens for sidebar filter popover border (#1661)

The filter popover renders via base-ui Menu.Portal into document.body,
outside the .agents-ui container where the --ui-* CSS variables are
scoped. As a result border-[var(--ui-border)] resolved to an undefined
variable and border-color fell back to currentColor, producing a strong
near-black border (separators/hover/labels were similarly off).

Switch the portaled popup styling to the same global shadcn tokens the
theme/settings popover (SidebarUserMenu) already uses (border-border,
bg-border, bg-muted, text-muted-foreground). These are defined at :root
so they resolve inside portals too, and match the settings popover.

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
(cherry picked from commit 63eb9a08209f683016abf01cdcc548bc5905f158)

* fix: preserve dashboard redirect after login (#1668)

* fix: preserve dashboard redirect after login

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>

* test: cover plan login redirect in e2e

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>

---------

Co-authored-by: Ramon Nogueira <270434257+ramon-langchain@users.noreply.github.com>
Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
(cherry picked from commit bc7ce59169b5350da7286164afb83a7b037b528d)

* Disable React StrictMode (#1654)

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
(cherry picked from commit 6575c327a3ac2b107a6e79a04fa61168d779dbf0)

* docs(upstream-sync): add cherry-pick runbook

Repo-specific runbook for bringing upstream (langchain-ai/open-swe) commits
into the fork: triage-sync discovery, the git cp workflow, the triage ledger,
themed-branch layout, and conflict/regression handling.

---------

Co-authored-by: Johannes du Plessis <johannes@langchain.dev>
Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
Co-authored-by: Ramon Nogueira <ramon.nogueira@langchain.dev>
Co-authored-by: Ramon Nogueira <270434257+ramon-langchain@users.noreply.github.com>
Co-authored-by: Caroline di Vittorio <43390382+carolinedivittorio@users.noreply.github.com>
2026-07-03 11:48:40 -04:00

908 lines
29 KiB
TypeScript

import { Link, Navigate, createFileRoute } from "@tanstack/react-router"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { CaretRightIcon } from "@phosphor-icons/react"
import { useEffect, useMemo, useState } from "react"
import type {
DatadogConnectBody,
LangSmithConnectBody,
ModelOption,
PRTraceResolutionResult,
TeamSettings,
UserMapping,
} from "@/lib/api"
import { AppShell, SettingsRow, SettingsSection } from "@/components/AppShell"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { api } from "@/lib/api"
import { RequireLogin } from "@/lib/auth-redirect"
import { useSession } from "@/lib/session"
export const Route = createFileRoute("/admin")({ component: AdminPage })
function AdminPage() {
const session = useSession()
const options = useQuery({
queryKey: ["options"],
queryFn: api.options,
enabled: !!session.data?.is_admin,
})
if (session.isLoading) {
return (
<main className="p-6">
<Skeleton className="h-64 w-full" />
</main>
)
}
if (!session.data) return <RequireLogin />
if (!session.data.is_admin) return <Navigate to="/my-settings" />
return (
<AppShell
user={session.data}
title="Admin"
description="Workspace-wide defaults and user mappings."
>
<GlobalDefaultsSection models={options.data?.models ?? []} />
<TriggerReviewSection />
<SettingsSection title="Evals">
<Link
to="/admin/evals"
className="flex items-center justify-between gap-6 px-4 py-3 hover:bg-muted/40"
>
<div className="flex flex-col gap-0.5">
<span className="text-xs font-medium text-foreground">
Reviewer eval
</span>
<span className="text-xs text-muted-foreground">
Run the offline reviewer benchmark and watch its output stream
live.
</span>
</div>
<CaretRightIcon className="size-3.5 shrink-0 text-muted-foreground" />
</Link>
</SettingsSection>
<ObservabilityCredentialsSection />
<PRTraceResolutionSection />
<UserMappingsSection enabled={!!session.data.is_admin} />
</AppShell>
)
}
const PR_URL_RE = /^https:\/\/github\.com\/([^/\s]+)\/([^/\s]+)\/pull\/(\d+)/
function TriggerReviewSection() {
const [url, setUrl] = useState("")
const [error, setError] = useState<string | null>(null)
const [message, setMessage] = useState<string | null>(null)
const [trace, setTrace] = useState<PRTraceResolutionResult | null>(null)
const parsed = useMemo(() => {
const match = PR_URL_RE.exec(url.trim())
if (!match) return null
const [, owner, repo, number] = match
if (!owner || !repo || !number) return null
return { owner, repo, number: Number(number) }
}, [url])
const trigger = useMutation({
mutationFn: () => {
if (!parsed) throw new Error("invalid PR URL")
return api.reReview(parsed.owner, parsed.repo, parsed.number)
},
onSuccess: (result) => {
setError(null)
setMessage(
result.queued
? "Review queued — a run is already in progress on this PR."
: "Review started."
)
},
onError: (e: Error) => {
setMessage(null)
setError(e.message)
},
})
const resolveTrace = useMutation({
mutationFn: () => {
if (!parsed) throw new Error("invalid PR URL")
return api.resolveTrace(parsed.owner, parsed.repo, parsed.number)
},
onSuccess: (result) => {
setError(null)
setMessage(null)
setTrace(result)
},
onError: (e: Error) => {
setTrace(null)
setError(e.message)
},
})
return (
<SettingsSection
title="Trigger a review"
description="Manually start an Open SWE Review run on a pull request, or dry-run author trace resolution for it. The repository must be enabled for review."
>
<div className="flex flex-col gap-2 p-4">
<div className="flex items-center gap-2">
<Input
className="flex-1"
placeholder="https://github.com/owner/repo/pull/123"
value={url}
onChange={(e) => {
setUrl(e.target.value)
setMessage(null)
setError(null)
setTrace(null)
}}
/>
<Button
size="sm"
variant="outline"
onClick={() => resolveTrace.mutate()}
disabled={!parsed || resolveTrace.isPending}
>
{resolveTrace.isPending ? "Resolving…" : "Resolve trace"}
</Button>
<Button
size="sm"
onClick={() => trigger.mutate()}
disabled={!parsed || trigger.isPending}
>
{trigger.isPending ? "Starting…" : "Start review"}
</Button>
</div>
{url.trim() && !parsed && (
<p className="text-xs text-muted-foreground">
Enter a full PR URL like https://github.com/owner/repo/pull/123
</p>
)}
{message && parsed && (
<p className="text-xs text-muted-foreground">
{message}{" "}
<Link
to="/agents/reviews/$owner/$repo/$number"
params={{
owner: parsed.owner,
repo: parsed.repo,
number: String(parsed.number),
}}
className="underline hover:text-foreground"
>
View review
</Link>
</p>
)}
{trace &&
(trace.resolved ? (
<p className="text-xs text-muted-foreground">
Resolved thread{" "}
<code className="font-mono">{trace.thread_id}</code> · confidence{" "}
{trace.confidence?.toFixed(2)} · {trace.evidence.join(", ")} ·{" "}
{trace.run_count} run{trace.run_count === 1 ? "" : "s"}
{trace.trace_url && (
<>
{" · "}
<a
href={trace.trace_url}
target="_blank"
rel="noreferrer"
className="underline hover:text-foreground"
>
open trace
</a>
</>
)}
</p>
) : (
<p className="text-xs text-muted-foreground">
No trace resolved — {trace.detail}
{trace.project ? ` (project: ${trace.project})` : ""}
</p>
))}
{error && <p className="text-xs text-destructive">{error}</p>}
</div>
</SettingsSection>
)
}
const PAGE_SIZE = 20
function UserMappingsSection({ enabled }: { enabled: boolean }) {
const [error, setError] = useState<string | null>(null)
const [page, setPage] = useState(1)
const [newLogin, setNewLogin] = useState("")
const [newEmail, setNewEmail] = useState("")
const mappings = useQuery({
queryKey: ["adminUserMappings", page],
queryFn: () => api.adminListUserMappings(page, PAGE_SIZE),
enabled,
})
const total = mappings.data?.total ?? 0
const pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE))
useEffect(() => {
if (!mappings.isFetching && page > pageCount) {
setPage(pageCount)
}
}, [mappings.isFetching, page, pageCount])
const remove = useMutation({
mutationFn: (gh: string) => api.adminDeleteUserMapping(gh),
onSuccess: () => void mappings.refetch(),
onError: (e: Error) => setError(e.message),
})
const upsert = useMutation({
mutationFn: () =>
api.adminUpsertUserMapping({
github_login: newLogin.trim(),
work_email: newEmail.trim(),
}),
onSuccess: () => {
setNewLogin("")
setNewEmail("")
setError(null)
void mappings.refetch()
},
onError: (e: Error) => setError(e.message),
})
const canSubmit =
newLogin.trim().length > 0 &&
newEmail.trim().length > 0 &&
!upsert.isPending
const items = mappings.data?.items ?? []
return (
<SettingsSection
title="User mappings"
description="Mappings link a GitHub login to a work email. They are created automatically when users connect Slack from settings; admins can also add or remove them here."
>
<div className="flex flex-col gap-3 p-4">
{error && <span className="text-xs text-destructive">{error}</span>}
<form
className="flex flex-col gap-2 sm:flex-row sm:items-center"
onSubmit={(e) => {
e.preventDefault()
if (canSubmit) upsert.mutate()
}}
>
<Input
value={newLogin}
onChange={(e) => setNewLogin(e.target.value)}
placeholder="github-login"
aria-label="GitHub login"
className="h-8 text-xs"
/>
<Input
value={newEmail}
onChange={(e) => setNewEmail(e.target.value)}
placeholder="work@example.com"
aria-label="Work email"
type="email"
className="h-8 text-xs"
/>
<Button type="submit" size="sm" disabled={!canSubmit}>
{upsert.isPending ? "Saving…" : "Add / update"}
</Button>
</form>
<div className="flex flex-col gap-0.5">
{mappings.isLoading ? (
<Skeleton className="h-32" />
) : !items.length ? (
<p className="text-xs text-muted-foreground">No mappings yet.</p>
) : (
items.map((m: UserMapping) => (
<div
key={m.github_login}
className="flex items-center justify-between gap-2 border-b border-border py-1.5 text-xs last:border-b-0"
>
<div className="flex min-w-0 flex-col">
<span className="truncate font-medium">{m.github_login}</span>
<span className="truncate text-xs text-muted-foreground">
{m.work_email}
{m.slack_user_id ? ` · ${m.slack_user_id}` : ""}
{m.source ? ` · ${m.source}` : ""}
</span>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => remove.mutate(m.github_login)}
disabled={remove.isPending}
>
Remove
</Button>
</div>
))
)}
</div>
{total > PAGE_SIZE && (
<div className="flex items-center justify-between pt-1 text-xs text-muted-foreground">
<span>
{total} mapping{total === 1 ? "" : "s"} · page {page} of{" "}
{pageCount}
</span>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1 || mappings.isFetching}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.min(pageCount, p + 1))}
disabled={page >= pageCount || mappings.isFetching}
>
Next
</Button>
</div>
</div>
)}
</div>
</SettingsSection>
)
}
function ObservabilityCredentialsSection() {
const qc = useQueryClient()
const creds = useQuery({
queryKey: ["teamCredentials"],
queryFn: api.getTeamCredentials,
})
const [error, setError] = useState<string | null>(null)
const [ddSite, setDdSite] = useState("datadoghq.com")
const [ddApiKey, setDdApiKey] = useState("")
const [ddAppKey, setDdAppKey] = useState("")
const [lsApiKey, setLsApiKey] = useState("")
const [lsEndpoint, setLsEndpoint] = useState("")
const onError = (e: Error) => setError(e.message)
const onSuccess = (
saved: Awaited<ReturnType<typeof api.getTeamCredentials>>
) => {
qc.setQueryData(["teamCredentials"], saved)
setError(null)
}
const connectDd = useMutation({
mutationFn: (body: DatadogConnectBody) => api.connectDatadog(body),
onSuccess: (saved) => {
onSuccess(saved)
setDdApiKey("")
setDdAppKey("")
},
onError,
})
const disconnectDd = useMutation({
mutationFn: () => api.disconnectDatadog(),
onSuccess,
onError,
})
const connectLs = useMutation({
mutationFn: (body: LangSmithConnectBody) => api.connectLangSmith(body),
onSuccess: (saved) => {
onSuccess(saved)
setLsApiKey("")
},
onError,
})
const disconnectLs = useMutation({
mutationFn: () => api.disconnectLangSmith(),
onSuccess,
onError,
})
const datadog = creds.data?.datadog
const langsmith = creds.data?.langsmith
const busy = creds.isLoading
return (
<SettingsSection
title="Observability credentials"
description="Team-wide Datadog and LangSmith credentials. Stored encrypted server-side and never exposed to the sandbox. Connecting enables read-only observability tools for agent runs."
>
<div className="divide-y divide-border">
<SettingsRow
label="Datadog"
description={
datadog?.connected
? `Connected · ${datadog.site ?? ""} · key ••••${datadog.api_key_last4 ?? ""}`
: "Connect Datadog to enable read-only metrics, logs, traces, and monitor tools."
}
control={
datadog?.connected ? (
<Button
variant="outline"
size="sm"
onClick={() => disconnectDd.mutate()}
disabled={disconnectDd.isPending}
>
Disconnect
</Button>
) : (
<div className="flex flex-col items-end gap-2">
<Input
className="w-56"
placeholder="datadoghq.com"
value={ddSite}
onChange={(e) => setDdSite(e.target.value)}
disabled={busy}
/>
<Input
className="w-56"
placeholder="API key"
type="password"
value={ddApiKey}
onChange={(e) => setDdApiKey(e.target.value)}
disabled={busy}
/>
<Input
className="w-56"
placeholder="Application key"
type="password"
value={ddAppKey}
onChange={(e) => setDdAppKey(e.target.value)}
disabled={busy}
/>
<Button
size="sm"
onClick={() =>
connectDd.mutate({
site: ddSite.trim(),
api_key: ddApiKey.trim(),
app_key: ddAppKey.trim(),
})
}
disabled={
connectDd.isPending ||
!ddSite.trim() ||
!ddApiKey.trim() ||
!ddAppKey.trim()
}
>
Connect
</Button>
</div>
)
}
/>
<SettingsRow
label="LangSmith"
description={
langsmith?.connected
? `Connected · key ••••${langsmith.api_key_last4 ?? ""}${langsmith.endpoint ? ` · ${langsmith.endpoint}` : ""}`
: "Connect LangSmith to enable read-only trace and run lookup tools."
}
control={
langsmith?.connected ? (
<Button
variant="outline"
size="sm"
onClick={() => disconnectLs.mutate()}
disabled={disconnectLs.isPending}
>
Disconnect
</Button>
) : (
<div className="flex flex-col items-end gap-2">
<Input
className="w-56"
placeholder="API key"
type="password"
value={lsApiKey}
onChange={(e) => setLsApiKey(e.target.value)}
disabled={busy}
/>
<Input
className="w-56"
placeholder="Endpoint (optional)"
value={lsEndpoint}
onChange={(e) => setLsEndpoint(e.target.value)}
disabled={busy}
/>
<Button
size="sm"
onClick={() =>
connectLs.mutate({
api_key: lsApiKey.trim(),
endpoint: lsEndpoint.trim() || null,
})
}
disabled={connectLs.isPending || !lsApiKey.trim()}
>
Connect
</Button>
</div>
)
}
/>
</div>
{error && <p className="px-4 pb-3 text-xs text-destructive">{error}</p>}
</SettingsSection>
)
}
function PRTraceResolutionSection() {
const qc = useQueryClient()
const settings = useQuery({
queryKey: ["teamSettings"],
queryFn: api.getTeamSettings,
})
const [projectDraft, setProjectDraft] = useState("")
const [error, setError] = useState<string | null>(null)
useEffect(() => {
setProjectDraft(settings.data?.review_tracing_project ?? "")
}, [settings.data?.review_tracing_project])
const save = useMutation({
mutationFn: (body: TeamSettings) => api.saveTeamSettings(body),
onSuccess: (saved) => {
qc.setQueryData(["teamSettings"], saved)
setError(null)
},
onError: (e: Error) => setError(e.message),
})
const savedProject = settings.data?.review_tracing_project ?? ""
const projectDirty = projectDraft.trim() !== savedProject
const saveProject = () => {
if (!settings.data || !projectDirty) return
save.mutate({
...settings.data,
review_tracing_project: projectDraft.trim() || null,
})
}
return (
<SettingsSection
title="PR Trace Resolution"
description="Allow Open SWE Review to resolve PRs to author coding-agent traces in a configured LangSmith project. Requires connected LangSmith credentials."
>
<div className="divide-y divide-border">
<SettingsRow
label="Tracing project"
description="LangSmith project name or ID to search for author traces. Leave blank to disable trace resolution."
control={
<div className="flex items-center gap-2">
<Input
className="w-64"
placeholder="Project name or ID"
value={projectDraft}
onChange={(e) => setProjectDraft(e.target.value)}
onBlur={saveProject}
disabled={!settings.data || save.isPending}
/>
<Button
size="sm"
variant="outline"
onClick={saveProject}
disabled={!settings.data || !projectDirty || save.isPending}
>
Save
</Button>
</div>
}
/>
</div>
{error && <p className="px-4 pb-3 text-xs text-destructive">{error}</p>}
</SettingsSection>
)
}
function GlobalDefaultsSection({ models }: { models: Array<ModelOption> }) {
const qc = useQueryClient()
const settings = useQuery({
queryKey: ["teamSettings"],
queryFn: api.getTeamSettings,
})
const [error, setError] = useState<string | null>(null)
const [defaultRepoDraft, setDefaultRepoDraft] = useState("")
useEffect(() => {
setDefaultRepoDraft(settings.data?.default_repo ?? "")
}, [settings.data?.default_repo])
const save = useMutation({
mutationFn: (body: TeamSettings) => api.saveTeamSettings(body),
onSuccess: (saved) => {
qc.setQueryData(["teamSettings"], saved)
setError(null)
},
onError: (e: Error) => setError(e.message),
})
return (
<SettingsSection
title="Global defaults"
description="Workspace-wide model defaults. Per-user Cloud Agent selections override the agent defaults."
>
<div className="divide-y divide-border">
<RolePicker
label="Open SWE Agent"
description="Model used for code-writing runs triggered from Slack, Linear, GitHub, and the Open SWE Agent."
models={models}
model={settings.data?.default_agent_model ?? null}
effort={settings.data?.default_agent_reasoning_effort ?? null}
onChange={(model, effort) =>
settings.data &&
save.mutate({
...settings.data,
default_agent_model: model,
default_agent_reasoning_effort: effort,
})
}
disabled={!settings.data || save.isPending}
/>
<RolePicker
label="Open SWE Agent subagents"
description="Model used by delegated main-agent tasks."
models={models}
model={settings.data?.default_agent_subagent_model ?? null}
effort={
settings.data?.default_agent_subagent_reasoning_effort ?? null
}
onChange={(model, effort) =>
settings.data &&
save.mutate({
...settings.data,
default_agent_subagent_model: model,
default_agent_subagent_reasoning_effort: effort,
})
}
disabled={!settings.data || save.isPending}
/>
<SettingsRow
label="Default Repository"
description="Global fallback used when a run has no explicit repo and the user has no profile default. Use owner/repo."
control={
<Input
className="w-56"
placeholder="owner/repo"
value={defaultRepoDraft}
onChange={(e) => setDefaultRepoDraft(e.target.value)}
onBlur={() =>
settings.data &&
save.mutate({
...settings.data,
default_repo: defaultRepoDraft.trim() || null,
})
}
disabled={!settings.data || save.isPending}
/>
}
/>
<RolePicker
label="Open SWE Reviewer"
description="Model used for PR review runs."
models={models}
model={settings.data?.default_reviewer_model ?? null}
effort={settings.data?.default_reviewer_reasoning_effort ?? null}
onChange={(model, effort) =>
settings.data &&
save.mutate({
...settings.data,
default_reviewer_model: model,
default_reviewer_reasoning_effort: effort,
})
}
disabled={!settings.data || save.isPending}
/>
<RolePicker
label="Open SWE Reviewer subagents"
description="Model used by delegated reviewer tasks."
models={models}
model={settings.data?.default_reviewer_subagent_model ?? null}
effort={
settings.data?.default_reviewer_subagent_reasoning_effort ?? null
}
onChange={(model, effort) =>
settings.data &&
save.mutate({
...settings.data,
default_reviewer_subagent_model: model,
default_reviewer_subagent_reasoning_effort: effort,
})
}
disabled={!settings.data || save.isPending}
/>
<RolePicker
label="Open SWE Review Diff Grouping"
description="Model used for the review's 'AI sorted' view that groups changed files into a logical walkthrough. Inherits the Reviewer subagent default when unset."
models={models}
model={settings.data?.default_grouping_model ?? null}
effort={settings.data?.default_grouping_reasoning_effort ?? null}
inheritLabel="Reviewer subagent default"
onInherit={() =>
settings.data &&
save.mutate({
...settings.data,
default_grouping_model: null,
default_grouping_reasoning_effort: null,
})
}
onChange={(model, effort) =>
settings.data &&
save.mutate({
...settings.data,
default_grouping_model: model,
default_grouping_reasoning_effort: effort,
})
}
disabled={!settings.data || save.isPending}
/>
<RolePicker
label="Open SWE Review Chat"
description="Model used by the 'chat with this PR' assistant on the review page. Inherits the Agent default when unset."
models={models}
model={settings.data?.default_chat_model ?? null}
effort={settings.data?.default_chat_reasoning_effort ?? null}
inheritLabel="Agent default"
onInherit={() =>
settings.data &&
save.mutate({
...settings.data,
default_chat_model: null,
default_chat_reasoning_effort: null,
})
}
onChange={(model, effort) =>
settings.data &&
save.mutate({
...settings.data,
default_chat_model: model,
default_chat_reasoning_effort: effort,
})
}
disabled={!settings.data || save.isPending}
/>
</div>
{error && <p className="px-4 pb-3 text-xs text-destructive">{error}</p>}
</SettingsSection>
)
}
interface RolePickerProps {
label: string
description: string
models: Array<ModelOption>
model: string | null
effort: string | null
onChange: (model: string, effort: string) => void
disabled: boolean
/**
* When set, the model dropdown gains a leading "inherit" option with this
* label. Selecting it calls {@link onInherit} (clearing the override); an
* unset `model` renders as this option.
*/
inheritLabel?: string
onInherit?: () => void
}
const INHERIT_VALUE = "__inherit__"
function RolePicker({
label,
description,
models,
model,
effort,
onChange,
disabled,
inheritLabel,
onInherit,
}: RolePickerProps) {
const inheritFallback = inheritLabel ? INHERIT_VALUE : ""
const [localModel, setLocalModel] = useState<string>(model ?? inheritFallback)
const [localEffort, setLocalEffort] = useState<string>(effort ?? "")
useEffect(() => {
setLocalModel(model ?? inheritFallback)
setLocalEffort(effort ?? "")
}, [model, effort, inheritFallback])
const isInherit = localModel === INHERIT_VALUE
const selectedModel = models.find((m) => m.id === localModel)
const availableEfforts = selectedModel?.efforts ?? []
const handleModelChange = (value: string | null) => {
if (!value) return
if (value === INHERIT_VALUE) {
setLocalModel(INHERIT_VALUE)
setLocalEffort("")
onInherit?.()
return
}
const nextModel = models.find((m) => m.id === value)
if (!nextModel) return
const nextEffort = nextModel.efforts.includes(localEffort)
? localEffort
: nextModel.default_effort
setLocalModel(value)
setLocalEffort(nextEffort)
onChange(value, nextEffort)
}
const handleEffortChange = (value: string | null) => {
if (!value || !localModel || isInherit) return
setLocalEffort(value)
onChange(localModel, value)
}
return (
<SettingsRow
label={label}
description={description}
control={
<div className="flex items-center gap-2">
<Select
value={localModel}
onValueChange={handleModelChange}
disabled={disabled}
>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
{inheritLabel && (
<SelectItem value={INHERIT_VALUE}>{inheritLabel}</SelectItem>
)}
{models.map((m) => (
<SelectItem key={m.id} value={m.id}>
{m.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={localEffort}
onValueChange={handleEffortChange}
disabled={disabled || !localModel || isInherit}
>
<SelectTrigger className="w-28">
<SelectValue placeholder="effort" />
</SelectTrigger>
<SelectContent>
{availableEfforts.map((e) => (
<SelectItem key={e} value={e}>
{e}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
}
/>
)
}