fix: stabilize agent selector defaults (#1414)

* fix: stabilize agent defaults and repo selector

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

* fix: align default repo selector styling, restore text fallback

Match the repo selector to sibling settings controls (h-7, bg-input/20,
text-xs). Fall back to a text input when the repo list is empty so a
default repo can still be entered.

* fix: make repo selector dropdown more compact

---------

Co-authored-by: open-swe[bot] <215916821+open-swe[bot]@users.noreply.github.com>
This commit is contained in:
Johannes du Plessis 2026-06-04 14:06:25 -07:00 • committed by GitHub
parent 8b18e7e95d
commit a75e9b027b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 376 additions and 284 deletions

View file

@ -67,6 +67,8 @@ from .slack_oauth import (
)
from .team_settings import (
TeamSettingsUpdate,
get_team_default_model,
get_team_default_subagent_model,
get_team_settings,
upsert_team_settings,
)
@ -279,7 +281,15 @@ async def me(session: dict[str, Any] = _SESSION_DEP) -> dict[str, Any]:
@router.get("/options")
async def options() -> dict[str, Any]:
return {"models": SUPPORTED_MODELS}
agent_model, agent_effort = await get_team_default_model("agent")
subagent_model, subagent_effort = await get_team_default_subagent_model("agent")
return {
"models": SUPPORTED_MODELS,
"default_agent_model": agent_model,
"default_agent_reasoning_effort": agent_effort,
"default_agent_subagent_model": subagent_model,
"default_agent_subagent_reasoning_effort": subagent_effort,
}
@router.get("/profile")

View file

@ -1,19 +1,17 @@
import { useEffect, useMemo, useState } from "react";
import { FolderIcon } from "@phosphor-icons/react";
import type { SessionUser } from "@/lib/api";
import type { PendingPrompt } from "@/lib/agents/pendingPrompts";
import type { AgentThread, Message } from "@/lib/agents/types";
import type { ModelSelection } from "@/lib/agents/useModelOptions";
import { AgentPromptBar } from "@/components/agents/AgentPromptBar";
import { AgentsShell } from "@/components/agents/AgentsSidebar";
import { MessageView } from "@/components/agents/ported";
import type { SessionUser } from "@/lib/api";
import type { AgentThread, Message } from "@/lib/agents/types";
import { useSendAgentMessage } from "@/lib/agents/queries";
import {
dropPendingPrompts,
getPendingPrompts,
type PendingPrompt,
} from "@/lib/agents/pendingPrompts";
import { dropPendingPrompts, getPendingPrompts } from "@/lib/agents/pendingPrompts";
import { useAgentThreadStream } from "@/lib/agents/useThreadStream";
import { useModelOptions, type ModelSelection } from "@/lib/agents/useModelOptions";
import { useModelOptions } from "@/lib/agents/useModelOptions";
interface AgentThreadViewProps {
user: SessionUser;
@ -23,7 +21,7 @@ interface AgentThreadViewProps {
export function AgentThreadView({ user, thread }: AgentThreadViewProps) {
const sendMessage = useSendAgentMessage(thread.id);
useAgentThreadStream(thread.id, thread.status === "running");
const [pendingPrompts, setPendingPrompts] = useState<PendingPrompt[]>(() =>
const [pendingPrompts, setPendingPrompts] = useState<Array<PendingPrompt>>(() =>
getPendingPrompts(thread.id),
);
@ -37,12 +35,7 @@ export function AgentThreadView({ user, thread }: AgentThreadViewProps) {
return { modelId: thread.model, effort: thread.effort };
}, [models, thread.model, thread.effort]);
const [selection, setSelection] = useState<ModelSelection | null>(null);
useEffect(() => {
if (selection !== null) return;
if (threadSelection) setSelection(threadSelection);
else if (defaultSelection) setSelection(defaultSelection);
}, [defaultSelection, selection, threadSelection]);
const activeSelection = selection ?? threadSelection ?? defaultSelection;
const userMessageTexts = useMemo(() => {
return new Set(
@ -51,7 +44,7 @@ export function AgentThreadView({ user, thread }: AgentThreadViewProps) {
.map((m) =>
m.chunks
.filter((c) => c.kind === "text")
.map((c) => (c as { kind: "text"; text: string }).text)
.map((c) => c.text)
.join(""),
),
);
@ -67,7 +60,7 @@ export function AgentThreadView({ user, thread }: AgentThreadViewProps) {
});
}, [thread.id, userMessageTexts]);
const displayMessages = useMemo<Message[]>(() => {
const displayMessages = useMemo<Array<Message>>(() => {
if (pendingPrompts.length === 0) return thread.messages;
const baseTimestamp = new Date().toISOString();
const result = thread.messages.slice();
@ -119,12 +112,12 @@ export function AgentThreadView({ user, thread }: AgentThreadViewProps) {
onSubmit={(content) =>
sendMessage.mutate({
content,
model_id: selection?.modelId ?? null,
effort: selection?.effort ?? null,
model_id: activeSelection?.modelId ?? null,
effort: activeSelection?.effort ?? null,
})
}
models={models}
selection={selection ?? threadSelection ?? defaultSelection}
selection={activeSelection}
onSelectionChange={setSelection}
/>
</div>
@ -142,12 +135,12 @@ export function AgentThreadView({ user, thread }: AgentThreadViewProps) {
onSubmit={(content) =>
sendMessage.mutate({
content,
model_id: selection?.modelId ?? null,
effort: selection?.effort ?? null,
model_id: activeSelection?.modelId ?? null,
effort: activeSelection?.effort ?? null,
})
}
models={models}
selection={selection ?? threadSelection ?? defaultSelection}
selection={activeSelection}
onSelectionChange={setSelection}
/>
</div>

View file

@ -1,5 +1,5 @@
import { Link } from "@tanstack/react-router"
import { useEffect, useState } from "react"
import { useState } from "react"
import type { ModelSelection } from "@/lib/agents/useModelOptions"
import { AgentPromptBar } from "@/components/agents/AgentPromptBar"
@ -16,17 +16,18 @@ export function AgentsHome() {
const recentRuns = (threadsQuery.data ?? []).slice(0, 5)
const { models, defaultSelection } = useModelOptions()
const [selection, setSelection] = useState<ModelSelection | null>(null)
const activeSelection = selection ?? defaultSelection
const reposQuery = useRepos()
const profileQuery = useProfile()
// undefined = untouched (fall back to the profile default); null = explicitly "no repo".
const [repoOverride, setRepoOverride] = useState<string | null | undefined>(undefined)
const [repoOverride, setRepoOverride] = useState<string | null | undefined>(
undefined
)
const repo =
repoOverride === undefined ? (profileQuery.data?.default_repo ?? null) : repoOverride
useEffect(() => {
if (selection === null && defaultSelection) setSelection(defaultSelection)
}, [defaultSelection, selection])
repoOverride === undefined
? (profileQuery.data?.default_repo ?? null)
: repoOverride
return (
<div className="flex min-w-0 flex-1 flex-col overflow-y-auto px-6 py-8">
@ -39,13 +40,13 @@ export function AgentsHome() {
createThread.mutate({
prompt,
repo,
model_id: selection?.modelId ?? null,
effort: selection?.effort ?? null,
model_id: activeSelection?.modelId ?? null,
effort: activeSelection?.effort ?? null,
})
}
disabled={createThread.isPending}
models={models}
selection={selection ?? defaultSelection}
selection={activeSelection}
onSelectionChange={setSelection}
repos={reposQuery.data?.repositories}
selectedRepo={repo}

View file

@ -0,0 +1,142 @@
import { useEffect, useMemo, useRef, useState } from "react"
import { CaretDownIcon, FolderIcon } from "@phosphor-icons/react"
import { cn } from "@/lib/utils"
type RepoOption = { full_name: string }
interface RepoSelectorProps {
repos?: Array<RepoOption>
selectedRepo?: string | null
onRepoChange: (repo: string | null) => void
placeholder?: string
emptySelectionLabel?: string
searchPlaceholder?: string
noMatchesLabel?: string
className?: string
triggerClassName?: string
dropdownClassName?: string
disabled?: boolean
}
export function RepoSelector({
repos,
selectedRepo = null,
onRepoChange,
placeholder = "Select repository",
emptySelectionLabel = "No repository",
searchPlaceholder = "Search repositories…",
noMatchesLabel = "No matches",
className,
triggerClassName,
dropdownClassName,
disabled = false,
}: RepoSelectorProps) {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState("")
const dropdownRef = useRef<HTMLDivElement>(null)
const filteredRepos = useMemo(() => {
const all = repos ?? []
const q = query.trim().toLowerCase()
if (!q) return all
return all.filter((repo) => repo.full_name.toLowerCase().includes(q))
}, [repos, query])
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
const target = e.target as Node
if (dropdownRef.current && !dropdownRef.current.contains(target)) {
setOpen(false)
}
}
document.addEventListener("mousedown", handleClickOutside)
return () => document.removeEventListener("mousedown", handleClickOutside)
}, [])
return (
<div ref={dropdownRef} className={cn("relative min-w-0 shrink", className)}>
<button
type="button"
disabled={disabled}
onClick={() => setOpen((value) => !value)}
className={cn(
"flex max-w-[260px] cursor-pointer items-center gap-1 text-muted-foreground transition-opacity hover:opacity-80 disabled:cursor-default disabled:opacity-60",
triggerClassName
)}
>
<FolderIcon className="size-3.5 shrink-0" />
<span className="flex-1 truncate text-left">
{selectedRepo || placeholder}
</span>
<CaretDownIcon className="size-3 shrink-0 opacity-70" />
</button>
{open && (
<div
className={cn(
"absolute top-full left-0 z-50 mt-1 flex max-h-72 w-72 flex-col overflow-hidden rounded border border-border bg-popover text-xs text-popover-foreground shadow-lg",
dropdownClassName
)}
>
<input
autoFocus
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={searchPlaceholder}
className="w-full border-b border-border bg-transparent px-2 py-1.5 text-foreground outline-none placeholder:text-muted-foreground"
/>
<div className="overflow-y-auto">
<button
type="button"
onClick={() => {
onRepoChange(null)
setOpen(false)
setQuery("")
}}
className={cn(
"flex w-full items-center px-2 py-1.5 text-left transition-colors hover:bg-muted",
selectedRepo ? "text-muted-foreground" : "text-foreground"
)}
>
{emptySelectionLabel}
{!selectedRepo && (
<span className="ml-auto pl-3 text-muted-foreground">✓</span>
)}
</button>
{filteredRepos.length === 0 ? (
<div className="px-2 py-1.5 text-muted-foreground">
{noMatchesLabel}
</div>
) : (
filteredRepos.map((repo) => {
const selected = repo.full_name === selectedRepo
return (
<button
key={repo.full_name}
type="button"
onClick={() => {
onRepoChange(repo.full_name)
setOpen(false)
setQuery("")
}}
className={cn(
"flex w-full items-center px-2 py-1.5 text-left transition-colors hover:bg-muted",
selected ? "text-foreground" : "text-muted-foreground"
)}
>
<span className="truncate">{repo.full_name}</span>
{selected && (
<span className="ml-auto pl-3 text-muted-foreground">
✓
</span>
)}
</button>
)
})
)}
</div>
</div>
)}
</div>
)
}

View file

@ -1,32 +1,38 @@
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { CaretDownIcon, FolderIcon } from "@phosphor-icons/react";
import type { ModelOption } from "@/lib/api";
import {
formatModelSelection,
type ModelSelection,
} from "@/lib/agents/useModelOptions";
import { cn } from "@/lib/utils";
memo,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react"
const PROMPT_TEXTAREA_MAX_HEIGHT = 200;
import type { ModelOption } from "@/lib/api"
import type { ModelSelection } from "@/lib/agents/useModelOptions"
import { RepoSelector } from "@/components/agents/RepoSelector"
import { formatModelSelection } from "@/lib/agents/useModelOptions"
import { cn } from "@/lib/utils"
const PROMPT_TEXTAREA_MAX_HEIGHT = 200
export interface CloudPromptBarProps {
placeholder?: string;
compact?: boolean;
disabled?: boolean;
busy?: boolean;
onSubmit?: (value: string) => void;
models?: ModelOption[];
selection?: ModelSelection | null;
onSelectionChange?: (next: ModelSelection) => void;
placeholder?: string
compact?: boolean
disabled?: boolean
busy?: boolean
onSubmit?: (value: string) => void
models?: Array<ModelOption>
selection?: ModelSelection | null
onSelectionChange?: (next: ModelSelection) => void
/** Repos the user can target. When provided with onRepoChange, a repo picker is shown. */
repos?: Array<{ full_name: string }>;
selectedRepo?: string | null;
onRepoChange?: (repo: string | null) => void;
repos?: Array<{ full_name: string }>
selectedRepo?: string | null
onRepoChange?: (repo: string | null) => void
}
/** Web-adapted PromptBar from open-swe-app — local state, no Electron/Zustand deps. */
export const CloudPromptBar = memo(function CloudPromptBar({
export const CloudPromptBar = memo(function CloudPromptBarComponent({
placeholder = "Ask Open SWE to build, fix bugs, explore",
compact = false,
disabled = false,
@ -39,156 +45,84 @@ export const CloudPromptBar = memo(function CloudPromptBar({
selectedRepo = null,
onRepoChange,
}: CloudPromptBarProps) {
const [value, setValue] = useState("");
const [modelDropdownOpen, setModelDropdownOpen] = useState(false);
const [repoDropdownOpen, setRepoDropdownOpen] = useState(false);
const [repoQuery, setRepoQuery] = useState("");
const inputRef = useRef<HTMLTextAreaElement>(null);
const modelDropdownRef = useRef<HTMLDivElement>(null);
const repoDropdownRef = useRef<HTMLDivElement>(null);
const [value, setValue] = useState("")
const [modelDropdownOpen, setModelDropdownOpen] = useState(false)
const inputRef = useRef<HTMLTextAreaElement>(null)
const modelDropdownRef = useRef<HTMLDivElement>(null)
const repoPickerEnabled = !!onRepoChange;
const filteredRepos = useMemo(() => {
const all = repos ?? [];
const q = repoQuery.trim().toLowerCase();
if (!q) return all;
return all.filter((r) => r.full_name.toLowerCase().includes(q));
}, [repos, repoQuery]);
const combos = useMemo<ModelSelection[]>(() => {
const list: ModelSelection[] = [];
const combos = useMemo<Array<ModelSelection>>(() => {
const list: Array<ModelSelection> = []
for (const model of models) {
for (const effort of model.efforts) {
list.push({ modelId: model.id, effort });
list.push({ modelId: model.id, effort })
}
}
return list;
}, [models]);
return list
}, [models])
const selectionLabel = formatModelSelection(models, selection);
const selectionLabel = formatModelSelection(models, selection)
const handleSubmit = useCallback(() => {
const trimmed = value.trim();
if (!trimmed || disabled) return;
onSubmit?.(trimmed);
setValue("");
}, [disabled, onSubmit, value]);
const trimmed = value.trim()
if (!trimmed || disabled) return
onSubmit?.(trimmed)
setValue("")
}, [disabled, onSubmit, value])
useLayoutEffect(() => {
const el = inputRef.current;
if (!el) return;
const el = inputRef.current
if (!el) return
el.style.height = "auto";
const clampedHeight = Math.min(el.scrollHeight, PROMPT_TEXTAREA_MAX_HEIGHT);
el.style.height = `${clampedHeight}px`;
el.style.overflowY = el.scrollHeight > PROMPT_TEXTAREA_MAX_HEIGHT ? "auto" : "hidden";
}, [value]);
el.style.height = "auto"
const clampedHeight = Math.min(el.scrollHeight, PROMPT_TEXTAREA_MAX_HEIGHT)
el.style.height = `${clampedHeight}px`
el.style.overflowY =
el.scrollHeight > PROMPT_TEXTAREA_MAX_HEIGHT ? "auto" : "hidden"
}, [value])
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
const target = e.target as Node;
if (modelDropdownRef.current && !modelDropdownRef.current.contains(target)) {
setModelDropdownOpen(false);
}
if (repoDropdownRef.current && !repoDropdownRef.current.contains(target)) {
setRepoDropdownOpen(false);
const target = e.target as Node
if (
modelDropdownRef.current &&
!modelDropdownRef.current.contains(target)
) {
setModelDropdownOpen(false)
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
document.addEventListener("mousedown", handleClickOutside)
return () => document.removeEventListener("mousedown", handleClickOutside)
}, [])
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey && value.trim()) {
e.preventDefault();
handleSubmit();
e.preventDefault()
handleSubmit()
}
};
}
const pickerDisabled = combos.length === 0 || !onSelectionChange;
const pickerDisabled = combos.length === 0 || !onSelectionChange
return (
<div className={cn("relative w-full font-sans text-[13px]", compact ? "max-w-none" : "max-w-2xl")}>
{repoPickerEnabled && (
<div
className={cn(
"relative w-full font-sans text-[13px]",
compact ? "max-w-none" : "max-w-2xl"
)}
>
{onRepoChange && (
<div className="mb-2 flex items-center gap-2 px-1 text-xs">
<div ref={repoDropdownRef} className="relative min-w-0 shrink">
<button
type="button"
onClick={() => setRepoDropdownOpen((open) => !open)}
className="flex max-w-[260px] cursor-pointer items-center gap-1 text-[color:var(--ui-text-muted)] transition-opacity hover:opacity-80"
>
<FolderIcon className="size-3.5 shrink-0" />
<span className="truncate">{selectedRepo || "Select repository"}</span>
<CaretDownIcon className="size-3 shrink-0 opacity-70" />
</button>
{repoDropdownOpen && (
<div className="absolute left-0 top-full z-50 mt-1 flex max-h-72 w-72 flex-col overflow-hidden rounded border border-[var(--ui-border)] bg-[var(--ui-surface)] shadow-lg">
<input
autoFocus
value={repoQuery}
onChange={(e) => setRepoQuery(e.target.value)}
placeholder="Search repositories…"
className="w-full border-b border-[var(--ui-border)] bg-transparent px-3 py-2 text-[color:var(--ui-text)] outline-none placeholder:text-[color:var(--ui-text-dim)]"
/>
<div className="overflow-y-auto">
<button
type="button"
onClick={() => {
onRepoChange(null);
setRepoDropdownOpen(false);
setRepoQuery("");
}}
className={cn(
"flex w-full items-center px-3 py-1.5 text-left transition-colors hover:bg-[var(--ui-panel-2)]",
selectedRepo
? "text-[color:var(--ui-text-muted)]"
: "text-[color:var(--ui-text)]",
)}
>
No repository
{!selectedRepo && (
<span className="ml-auto pl-3 text-[color:var(--ui-text-dim)]">✓</span>
)}
</button>
{filteredRepos.length === 0 ? (
<div className="px-3 py-1.5 text-[color:var(--ui-text-dim)]">No matches</div>
) : (
filteredRepos.map((repo) => {
const selected = repo.full_name === selectedRepo;
return (
<button
key={repo.full_name}
type="button"
onClick={() => {
onRepoChange(repo.full_name);
setRepoDropdownOpen(false);
setRepoQuery("");
}}
className={cn(
"flex w-full items-center px-3 py-1.5 text-left transition-colors hover:bg-[var(--ui-panel-2)]",
selected
? "text-[color:var(--ui-text)]"
: "text-[color:var(--ui-text-muted)]",
)}
>
<span className="truncate">{repo.full_name}</span>
{selected && (
<span className="ml-auto pl-3 text-[color:var(--ui-text-dim)]">✓</span>
)}
</button>
);
})
)}
</div>
</div>
)}
</div>
<RepoSelector
repos={repos}
selectedRepo={selectedRepo}
onRepoChange={onRepoChange}
/>
</div>
)}
<div
className={cn(
"relative flex min-h-[106px] flex-col rounded-2xl border border-[var(--ui-border)] bg-[var(--ui-surface)] px-4 py-3.5 shadow-sm",
compact && "min-h-[88px]",
compact && "min-h-[88px]"
)}
>
<textarea
@ -201,7 +135,7 @@ export const CloudPromptBar = memo(function CloudPromptBar({
disabled={disabled}
className={cn(
"w-full min-w-0 resize-none overflow-hidden bg-transparent leading-[1.45] text-[color:var(--ui-text)] outline-none placeholder:text-[color:var(--ui-text-dim)]",
compact ? "min-h-[36px]" : "min-h-[52px]",
compact ? "min-h-[36px]" : "min-h-[52px]"
)}
style={{ maxHeight: PROMPT_TEXTAREA_MAX_HEIGHT }}
/>
@ -222,28 +156,30 @@ export const CloudPromptBar = memo(function CloudPromptBar({
const selected =
!!selection &&
selection.modelId === combo.modelId &&
selection.effort === combo.effort;
selection.effort === combo.effort
return (
<button
key={`${combo.modelId}::${combo.effort}`}
type="button"
onClick={() => {
onSelectionChange?.(combo);
setModelDropdownOpen(false);
onSelectionChange?.(combo)
setModelDropdownOpen(false)
}}
className={cn(
"flex w-full items-center gap-2 whitespace-nowrap px-3 py-1.5 text-left transition-colors hover:bg-[var(--ui-panel-2)]",
"flex w-full items-center gap-2 px-3 py-1.5 text-left whitespace-nowrap transition-colors hover:bg-[var(--ui-panel-2)]",
selected
? "text-[color:var(--ui-text)]"
: "text-[color:var(--ui-text-muted)]",
: "text-[color:var(--ui-text-muted)]"
)}
>
{formatModelSelection(models, combo)}
{selected && (
<span className="ml-auto pl-3 text-[color:var(--ui-text-dim)]">✓</span>
<span className="ml-auto pl-3 text-[color:var(--ui-text-dim)]">
✓
</span>
)}
</button>
);
)
})}
</div>
)}
@ -251,5 +187,5 @@ export const CloudPromptBar = memo(function CloudPromptBar({
</div>
</div>
</div>
);
});
)
})

View file

@ -1,52 +1,59 @@
import { useQuery } from "@tanstack/react-query";
import { api, type ModelOption } from "@/lib/api";
import type { ModelOption } from "@/lib/api"
import { useOptions, useProfile } from "@/lib/profile"
export interface ModelSelection {
modelId: string;
effort: string;
modelId: string
effort: string
}
export interface ModelOptionsResult {
models: ModelOption[];
defaultSelection: ModelSelection | null;
isLoading: boolean;
models: Array<ModelOption>
defaultSelection: ModelSelection | null
isLoading: boolean
}
function toSupportedSelection(
models: Array<ModelOption>,
modelId?: string | null,
effort?: string | null
): ModelSelection | null {
if (!modelId || !effort) return null
const supported = models.some(
(model) => model.id === modelId && model.efforts.includes(effort)
)
return supported ? { modelId, effort } : null
}
export function useModelOptions(): ModelOptionsResult {
const optionsQuery = useQuery({
queryKey: ["dashboard", "options"],
queryFn: () => api.options(),
staleTime: 5 * 60_000,
});
const profileQuery = useQuery({
queryKey: ["dashboard", "profile"],
queryFn: () => api.profile(),
staleTime: 5 * 60_000,
});
const optionsQuery = useOptions()
const profileQuery = useProfile()
const models = optionsQuery.data?.models ?? [];
const profile = profileQuery.data;
let defaultSelection: ModelSelection | null = null;
const profileModelId = profile?.default_model;
const profileEffort = profile?.reasoning_effort;
if (
profileModelId &&
profileEffort &&
models.some((m) => m.id === profileModelId && m.efforts.includes(profileEffort))
) {
defaultSelection = { modelId: profileModelId, effort: profileEffort };
} else if (models.length > 0) {
const first = models[0]!;
defaultSelection = { modelId: first.id, effort: first.default_effort };
}
const models = optionsQuery.data?.models ?? []
const profile = profileQuery.data
const profileSelection = toSupportedSelection(
models,
profile?.default_model,
profile?.reasoning_effort
)
const teamDefaultSelection = toSupportedSelection(
models,
optionsQuery.data?.default_agent_model,
optionsQuery.data?.default_agent_reasoning_effort
)
const firstModel = models[0]
const firstSelection = firstModel
? { modelId: firstModel.id, effort: firstModel.default_effort }
: null
const defaultSelection =
optionsQuery.data && !profileQuery.isLoading
? (profileSelection ?? teamDefaultSelection ?? firstSelection)
: null
return {
models,
defaultSelection,
isLoading: optionsQuery.isLoading || profileQuery.isLoading,
};
}
}
const EFFORT_LABELS: Record<string, string> = {
@ -55,15 +62,15 @@ const EFFORT_LABELS: Record<string, string> = {
high: "High",
xhigh: "XHigh",
max: "Max",
};
}
export function formatModelSelection(
models: ModelOption[],
selection: ModelSelection | null,
models: Array<ModelOption>,
selection: ModelSelection | null
): string {
if (!selection) return "Default";
const model = models.find((m) => m.id === selection.modelId);
const modelLabel = model?.label ?? selection.modelId;
const effortLabel = EFFORT_LABELS[selection.effort] ?? selection.effort;
return `${modelLabel} ${effortLabel}`;
if (!selection) return "Default"
const model = models.find((m) => m.id === selection.modelId)
const modelLabel = model?.label ?? selection.modelId
const effortLabel = EFFORT_LABELS[selection.effort] ?? selection.effort
return `${modelLabel} ${effortLabel}`
}

View file

@ -62,6 +62,14 @@ export interface ModelOption {
default_effort: string;
}
export interface OptionsPayload {
models: Array<ModelOption>;
default_agent_model: string;
default_agent_reasoning_effort: string;
default_agent_subagent_model: string;
default_agent_subagent_reasoning_effort: string;
}
export interface Profile {
login?: string;
email?: string;
@ -167,7 +175,7 @@ export interface ReviewStyle {
export const api = {
me: () => request<SessionUser>("/me"),
options: () => request<{ models: Array<ModelOption> }>("/options"),
options: () => request<OptionsPayload>("/options"),
profile: () => request<Profile>("/profile"),
saveProfile: (body: ProfileUpdate) =>
request<Profile>("/profile", { method: "PUT", body: JSON.stringify(body) }),

View file

@ -4,14 +4,7 @@ import { useEffect, useRef, useState } from "react"
import type { ModelOption } from "@/lib/api"
import { AppShell, SettingsRow, SettingsSection } from "@/components/AppShell"
import { Button } from "@/components/ui/button"
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
} from "@/components/ui/combobox"
import { RepoSelector } from "@/components/agents/RepoSelector"
import { Input } from "@/components/ui/input"
import {
Select,
@ -53,6 +46,16 @@ function CloudAgentsPage() {
const initialized = useRef(false)
const firstModel: ModelOption | undefined = options.data?.models[0]
const defaultAgentModel =
options.data?.default_agent_model ?? firstModel?.id ?? ""
const defaultAgentEffort =
options.data?.default_agent_reasoning_effort ??
firstModel?.default_effort ??
""
const defaultSubagentModel =
options.data?.default_agent_subagent_model ?? defaultAgentModel
const defaultSubagentEffort =
options.data?.default_agent_subagent_reasoning_effort ?? defaultAgentEffort
const currentModel: ModelOption | undefined =
options.data?.models.find((m) => m.id === modelId) ?? firstModel
const currentSubagentModel: ModelOption | undefined =
@ -60,29 +63,31 @@ function CloudAgentsPage() {
useEffect(() => {
if (!profile.data || initialized.current) return
// For users with no saved profile, wait until the options API has loaded
// so the model/effort selects can initialise to the first available option.
const hasModel = !!profile.data.default_model || !!firstModel
const hasModel = !!profile.data.default_model || !!defaultAgentModel
if (!hasModel) return
initialized.current = true
setModelId(profile.data.default_model ?? firstModel?.id ?? "")
setEffort(profile.data.reasoning_effort ?? firstModel?.default_effort ?? "")
setModelId(profile.data.default_model ?? defaultAgentModel)
setEffort(profile.data.reasoning_effort ?? defaultAgentEffort)
setSubagentModelId(
profile.data.default_subagent_model ??
profile.data.default_model ??
firstModel?.id ??
""
defaultSubagentModel
)
setSubagentEffort(
profile.data.subagent_reasoning_effort ??
profile.data.reasoning_effort ??
firstModel?.default_effort ??
""
defaultSubagentEffort
)
setDefaultRepo(profile.data.default_repo ?? "")
setBaseBranch(profile.data.base_branch ?? "")
setBranchPrefix(profile.data.branch_prefix ?? "")
}, [profile.data, firstModel?.id, firstModel?.default_effort, firstModel])
}, [
profile.data,
defaultAgentModel,
defaultAgentEffort,
defaultSubagentModel,
defaultSubagentEffort,
])
useEffect(() => {
if (currentModel && !currentModel.efforts.includes(effort)) {
@ -108,8 +113,8 @@ function CloudAgentsPage() {
}
if (!session.data) return <Navigate to="/login" />
const fallbackModel = firstModel?.id ?? ""
const fallbackEffort = firstModel?.default_effort ?? ""
const fallbackModel = defaultAgentModel
const fallbackEffort = defaultAgentEffort
const persist = (patch: Parameters<typeof buildProfileUpdate>[1]) => {
setError(null)
@ -222,39 +227,26 @@ function CloudAgentsPage() {
label="Default Repository"
description="Used when no repository is specified"
control={
<div className="w-64">
{repos.data && repos.data.repositories.length > 0 ? (
<Combobox
items={repos.data.repositories.map((r) => r.full_name)}
value={defaultRepo}
onValueChange={(v) =>
setDefaultRepo(typeof v === "string" ? v : "")
}
>
<ComboboxInput
placeholder="Pick a repository…"
showClear
className="w-full"
/>
<ComboboxContent className="min-w-[var(--anchor-width)]">
<ComboboxList className="max-h-64">
<ComboboxEmpty>No matches</ComboboxEmpty>
{repos.data.repositories.map((r) => (
<ComboboxItem key={r.full_name} value={r.full_name}>
<span className="truncate">{r.full_name}</span>
</ComboboxItem>
))}
</ComboboxList>
</ComboboxContent>
</Combobox>
) : (
<Input
placeholder="owner/repo"
value={defaultRepo}
onChange={(e) => setDefaultRepo(e.target.value)}
repos.data?.repositories?.length ? (
<div className="w-56">
<RepoSelector
repos={repos.data.repositories}
selectedRepo={defaultRepo || null}
onRepoChange={(repo) => setDefaultRepo(repo ?? "")}
placeholder="Pick a repository…"
emptySelectionLabel="No default repository"
triggerClassName="h-7 w-full max-w-none rounded-md border border-input bg-input/20 px-2 py-1.5 text-xs/relaxed text-foreground transition-colors hover:opacity-100 dark:bg-input/30"
dropdownClassName="w-56"
/>
)}
</div>
</div>
) : (
<Input
className="w-56"
placeholder="owner/repo"
value={defaultRepo}
onChange={(e) => setDefaultRepo(e.target.value)}
/>
)
}
/>
<SettingsRow

View file

@ -148,8 +148,11 @@ function MySettingsPage() {
}
const firstModel = options.data?.models[0]
const fallbackModel = firstModel?.id ?? ""
const fallbackEffort = firstModel?.default_effort ?? ""
const fallbackModel = options.data?.default_agent_model ?? firstModel?.id ?? ""
const fallbackEffort =
options.data?.default_agent_reasoning_effort ??
firstModel?.default_effort ??
""
const draftChoice = toChoice(profile.data?.review_draft_prs)
const teamDefaultOn = teamSettings.data?.review_draft_prs ?? false