mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-10-07 16:19:09 +00:00
* fix: make plan view mobile friendly (#1636) Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> (cherry picked from commit7ee3e05724) * 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 commitf32e492ab4) * 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>
908 lines
29 KiB
TypeScript
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>
|
|
}
|
|
/>
|
|
)
|
|
}
|