mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-10-07 16:19:09 +00:00
- Downgrade logger.warning to logger.debug in gateway_overrides for not-routed providers and missing API key (Bedrock is the default provider in this fork, so these are expected steady states) - Add Bedrock to the LLMGatewaySection route-toggle description so admins know it is not routed through the gateway - Add SanitizeOpenAIResponsesMiddleware to chat.py for parity with server.py and reviewer.py - Restore the Bedrock region comment in model.py that explains the AWS_REGION / AWS_DEFAULT_REGION precedence Refs #138
981 lines
31 KiB
TypeScript
981 lines
31 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 ?? []} />
|
|
|
|
<LLMGatewaySection />
|
|
|
|
<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>
|
|
)
|
|
}
|
|
|
|
type GatewayMode = "inherit" | "enabled" | "disabled"
|
|
|
|
function gatewayMode(value: boolean | null | undefined): GatewayMode {
|
|
if (value === true) return "enabled"
|
|
if (value === false) return "disabled"
|
|
return "inherit"
|
|
}
|
|
|
|
function gatewayModeValue(mode: GatewayMode): boolean | null {
|
|
if (mode === "enabled") return true
|
|
if (mode === "disabled") return false
|
|
return null
|
|
}
|
|
|
|
function LLMGatewaySection() {
|
|
const qc = useQueryClient()
|
|
const settings = useQuery({
|
|
queryKey: ["teamSettings"],
|
|
queryFn: api.getTeamSettings,
|
|
})
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
const save = useMutation({
|
|
mutationFn: (body: TeamSettings) => api.saveTeamSettings(body),
|
|
onSuccess: (saved) => {
|
|
qc.setQueryData(["teamSettings"], saved)
|
|
setError(null)
|
|
},
|
|
onError: (e: Error) => setError(e.message),
|
|
})
|
|
|
|
const mode = gatewayMode(settings.data?.gateway_enabled)
|
|
|
|
return (
|
|
<SettingsSection
|
|
title="LLM Gateway"
|
|
description="Route agent and reviewer LLM calls through the LangSmith LLM Gateway. It authenticates with the workspace LangSmith API key and resolves provider keys from Provider Secrets, so no provider keys are needed at runtime. Requires the gateway (private beta) enabled for your organization."
|
|
>
|
|
<div className="divide-y divide-border">
|
|
<SettingsRow
|
|
label="Route through the gateway"
|
|
description="Inherit uses the LANGSMITH_GATEWAY_ENABLED deployment default. OpenAI, Anthropic, Fireworks, and Google Gemini are routed through the gateway. Bedrock (AWS) calls the provider directly and is not routed; other providers not listed also call the provider directly."
|
|
control={
|
|
<Select
|
|
value={mode}
|
|
onValueChange={(next) =>
|
|
settings.data &&
|
|
save.mutate({
|
|
...settings.data,
|
|
gateway_enabled: gatewayModeValue(next as GatewayMode),
|
|
})
|
|
}
|
|
disabled={!settings.data || save.isPending}
|
|
>
|
|
<SelectTrigger className="w-48">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="inherit">Inherit deployment default</SelectItem>
|
|
<SelectItem value="enabled">Enabled</SelectItem>
|
|
<SelectItem value="disabled">Disabled</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
}
|
|
/>
|
|
</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>
|
|
}
|
|
/>
|
|
)
|
|
}
|