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 ( from .team_settings import (
TeamSettingsUpdate, TeamSettingsUpdate,
get_team_default_model,
get_team_default_subagent_model,
get_team_settings, get_team_settings,
upsert_team_settings, upsert_team_settings,
) )
@ -279,7 +281,15 @@ async def me(session: dict[str, Any] = _SESSION_DEP) -> dict[str, Any]:
@router.get("/options") @router.get("/options")
async def options() -> dict[str, Any]: 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") @router.get("/profile")

View file

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

View file

@ -1,5 +1,5 @@
import { Link } from "@tanstack/react-router" import { Link } from "@tanstack/react-router"
import { useEffect, useState } from "react" import { useState } from "react"
import type { ModelSelection } from "@/lib/agents/useModelOptions" import type { ModelSelection } from "@/lib/agents/useModelOptions"
import { AgentPromptBar } from "@/components/agents/AgentPromptBar" import { AgentPromptBar } from "@/components/agents/AgentPromptBar"
@ -16,17 +16,18 @@ export function AgentsHome() {
const recentRuns = (threadsQuery.data ?? []).slice(0, 5) const recentRuns = (threadsQuery.data ?? []).slice(0, 5)
const { models, defaultSelection } = useModelOptions() const { models, defaultSelection } = useModelOptions()
const [selection, setSelection] = useState<ModelSelection | null>(null) const [selection, setSelection] = useState<ModelSelection | null>(null)
const activeSelection = selection ?? defaultSelection
const reposQuery = useRepos() const reposQuery = useRepos()
const profileQuery = useProfile() const profileQuery = useProfile()
// undefined = untouched (fall back to the profile default); null = explicitly "no repo". // 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 = const repo =
repoOverride === undefined ? (profileQuery.data?.default_repo ?? null) : repoOverride repoOverride === undefined
? (profileQuery.data?.default_repo ?? null)
useEffect(() => { : repoOverride
if (selection === null && defaultSelection) setSelection(defaultSelection)
}, [defaultSelection, selection])
return ( return (
<div className="flex min-w-0 flex-1 flex-col overflow-y-auto px-6 py-8"> <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({ createThread.mutate({
prompt, prompt,
repo, repo,
model_id: selection?.modelId ?? null, model_id: activeSelection?.modelId ?? null,
effort: selection?.effort ?? null, effort: activeSelection?.effort ?? null,
}) })
} }
disabled={createThread.isPending} disabled={createThread.isPending}
models={models} models={models}
selection={selection ?? defaultSelection} selection={activeSelection}
onSelectionChange={setSelection} onSelectionChange={setSelection}
repos={reposQuery.data?.repositories} repos={reposQuery.data?.repositories}
selectedRepo={repo} 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 { import {
formatModelSelection, memo,
type ModelSelection, useCallback,
} from "@/lib/agents/useModelOptions"; useEffect,
import { cn } from "@/lib/utils"; 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 { export interface CloudPromptBarProps {
placeholder?: string; placeholder?: string
compact?: boolean; compact?: boolean
disabled?: boolean; disabled?: boolean
busy?: boolean; busy?: boolean
onSubmit?: (value: string) => void; onSubmit?: (value: string) => void
models?: ModelOption[]; models?: Array<ModelOption>
selection?: ModelSelection | null; selection?: ModelSelection | null
onSelectionChange?: (next: ModelSelection) => void; onSelectionChange?: (next: ModelSelection) => void
/** Repos the user can target. When provided with onRepoChange, a repo picker is shown. */ /** Repos the user can target. When provided with onRepoChange, a repo picker is shown. */
repos?: Array<{ full_name: string }>; repos?: Array<{ full_name: string }>
selectedRepo?: string | null; selectedRepo?: string | null
onRepoChange?: (repo: string | null) => void; onRepoChange?: (repo: string | null) => void
} }
/** Web-adapted PromptBar from open-swe-app — local state, no Electron/Zustand deps. */ /** 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", placeholder = "Ask Open SWE to build, fix bugs, explore",
compact = false, compact = false,
disabled = false, disabled = false,
@ -39,156 +45,84 @@ export const CloudPromptBar = memo(function CloudPromptBar({
selectedRepo = null, selectedRepo = null,
onRepoChange, onRepoChange,
}: CloudPromptBarProps) { }: CloudPromptBarProps) {
const [value, setValue] = useState(""); const [value, setValue] = useState("")
const [modelDropdownOpen, setModelDropdownOpen] = useState(false); const [modelDropdownOpen, setModelDropdownOpen] = useState(false)
const [repoDropdownOpen, setRepoDropdownOpen] = useState(false); const inputRef = useRef<HTMLTextAreaElement>(null)
const [repoQuery, setRepoQuery] = useState(""); const modelDropdownRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLTextAreaElement>(null);
const modelDropdownRef = useRef<HTMLDivElement>(null);
const repoDropdownRef = useRef<HTMLDivElement>(null);
const repoPickerEnabled = !!onRepoChange; const combos = useMemo<Array<ModelSelection>>(() => {
const filteredRepos = useMemo(() => { const list: Array<ModelSelection> = []
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[] = [];
for (const model of models) { for (const model of models) {
for (const effort of model.efforts) { for (const effort of model.efforts) {
list.push({ modelId: model.id, effort }); list.push({ modelId: model.id, effort })
} }
} }
return list; return list
}, [models]); }, [models])
const selectionLabel = formatModelSelection(models, selection); const selectionLabel = formatModelSelection(models, selection)
const handleSubmit = useCallback(() => { const handleSubmit = useCallback(() => {
const trimmed = value.trim(); const trimmed = value.trim()
if (!trimmed || disabled) return; if (!trimmed || disabled) return
onSubmit?.(trimmed); onSubmit?.(trimmed)
setValue(""); setValue("")
}, [disabled, onSubmit, value]); }, [disabled, onSubmit, value])
useLayoutEffect(() => { useLayoutEffect(() => {
const el = inputRef.current; const el = inputRef.current
if (!el) return; if (!el) return
el.style.height = "auto"; el.style.height = "auto"
const clampedHeight = Math.min(el.scrollHeight, PROMPT_TEXTAREA_MAX_HEIGHT); const clampedHeight = Math.min(el.scrollHeight, PROMPT_TEXTAREA_MAX_HEIGHT)
el.style.height = `${clampedHeight}px`; el.style.height = `${clampedHeight}px`
el.style.overflowY = el.scrollHeight > PROMPT_TEXTAREA_MAX_HEIGHT ? "auto" : "hidden"; el.style.overflowY =
}, [value]); el.scrollHeight > PROMPT_TEXTAREA_MAX_HEIGHT ? "auto" : "hidden"
}, [value])
useEffect(() => { useEffect(() => {
function handleClickOutside(e: MouseEvent) { function handleClickOutside(e: MouseEvent) {
const target = e.target as Node; const target = e.target as Node
if (modelDropdownRef.current && !modelDropdownRef.current.contains(target)) { if (
setModelDropdownOpen(false); modelDropdownRef.current &&
} !modelDropdownRef.current.contains(target)
if (repoDropdownRef.current && !repoDropdownRef.current.contains(target)) { ) {
setRepoDropdownOpen(false); setModelDropdownOpen(false)
} }
} }
document.addEventListener("mousedown", handleClickOutside); document.addEventListener("mousedown", handleClickOutside)
return () => document.removeEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside)
}, []); }, [])
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => { const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey && value.trim()) { if (e.key === "Enter" && !e.shiftKey && value.trim()) {
e.preventDefault(); e.preventDefault()
handleSubmit(); handleSubmit()
} }
}; }
const pickerDisabled = combos.length === 0 || !onSelectionChange; const pickerDisabled = combos.length === 0 || !onSelectionChange
return ( return (
<div className={cn("relative w-full font-sans text-[13px]", compact ? "max-w-none" : "max-w-2xl")}> <div
{repoPickerEnabled && ( 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 className="mb-2 flex items-center gap-2 px-1 text-xs">
<div ref={repoDropdownRef} className="relative min-w-0 shrink"> <RepoSelector
<button repos={repos}
type="button" selectedRepo={selectedRepo}
onClick={() => setRepoDropdownOpen((open) => !open)} onRepoChange={onRepoChange}
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>
</div> </div>
)} )}
<div <div
className={cn( 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", "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 <textarea
@ -201,7 +135,7 @@ export const CloudPromptBar = memo(function CloudPromptBar({
disabled={disabled} disabled={disabled}
className={cn( 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)]", "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 }} style={{ maxHeight: PROMPT_TEXTAREA_MAX_HEIGHT }}
/> />
@ -222,28 +156,30 @@ export const CloudPromptBar = memo(function CloudPromptBar({
const selected = const selected =
!!selection && !!selection &&
selection.modelId === combo.modelId && selection.modelId === combo.modelId &&
selection.effort === combo.effort; selection.effort === combo.effort
return ( return (
<button <button
key={`${combo.modelId}::${combo.effort}`} key={`${combo.modelId}::${combo.effort}`}
type="button" type="button"
onClick={() => { onClick={() => {
onSelectionChange?.(combo); onSelectionChange?.(combo)
setModelDropdownOpen(false); setModelDropdownOpen(false)
}} }}
className={cn( 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 selected
? "text-[color:var(--ui-text)]" ? "text-[color:var(--ui-text)]"
: "text-[color:var(--ui-text-muted)]", : "text-[color:var(--ui-text-muted)]"
)} )}
> >
{formatModelSelection(models, combo)} {formatModelSelection(models, combo)}
{selected && ( {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> </button>
); )
})} })}
</div> </div>
)} )}
@ -251,5 +187,5 @@ export const CloudPromptBar = memo(function CloudPromptBar({
</div> </div>
</div> </div>
</div> </div>
); )
}); })

View file

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

View file

@ -62,6 +62,14 @@ export interface ModelOption {
default_effort: string; 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 { export interface Profile {
login?: string; login?: string;
email?: string; email?: string;
@ -167,7 +175,7 @@ export interface ReviewStyle {
export const api = { export const api = {
me: () => request<SessionUser>("/me"), me: () => request<SessionUser>("/me"),
options: () => request<{ models: Array<ModelOption> }>("/options"), options: () => request<OptionsPayload>("/options"),
profile: () => request<Profile>("/profile"), profile: () => request<Profile>("/profile"),
saveProfile: (body: ProfileUpdate) => saveProfile: (body: ProfileUpdate) =>
request<Profile>("/profile", { method: "PUT", body: JSON.stringify(body) }), 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 type { ModelOption } from "@/lib/api"
import { AppShell, SettingsRow, SettingsSection } from "@/components/AppShell" import { AppShell, SettingsRow, SettingsSection } from "@/components/AppShell"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { import { RepoSelector } from "@/components/agents/RepoSelector"
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
} from "@/components/ui/combobox"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { import {
Select, Select,
@ -53,6 +46,16 @@ function CloudAgentsPage() {
const initialized = useRef(false) const initialized = useRef(false)
const firstModel: ModelOption | undefined = options.data?.models[0] 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 = const currentModel: ModelOption | undefined =
options.data?.models.find((m) => m.id === modelId) ?? firstModel options.data?.models.find((m) => m.id === modelId) ?? firstModel
const currentSubagentModel: ModelOption | undefined = const currentSubagentModel: ModelOption | undefined =
@ -60,29 +63,31 @@ function CloudAgentsPage() {
useEffect(() => { useEffect(() => {
if (!profile.data || initialized.current) return if (!profile.data || initialized.current) return
// For users with no saved profile, wait until the options API has loaded const hasModel = !!profile.data.default_model || !!defaultAgentModel
// so the model/effort selects can initialise to the first available option.
const hasModel = !!profile.data.default_model || !!firstModel
if (!hasModel) return if (!hasModel) return
initialized.current = true initialized.current = true
setModelId(profile.data.default_model ?? firstModel?.id ?? "") setModelId(profile.data.default_model ?? defaultAgentModel)
setEffort(profile.data.reasoning_effort ?? firstModel?.default_effort ?? "") setEffort(profile.data.reasoning_effort ?? defaultAgentEffort)
setSubagentModelId( setSubagentModelId(
profile.data.default_subagent_model ?? profile.data.default_subagent_model ??
profile.data.default_model ?? profile.data.default_model ??
firstModel?.id ?? defaultSubagentModel
""
) )
setSubagentEffort( setSubagentEffort(
profile.data.subagent_reasoning_effort ?? profile.data.subagent_reasoning_effort ??
profile.data.reasoning_effort ?? profile.data.reasoning_effort ??
firstModel?.default_effort ?? defaultSubagentEffort
""
) )
setDefaultRepo(profile.data.default_repo ?? "") setDefaultRepo(profile.data.default_repo ?? "")
setBaseBranch(profile.data.base_branch ?? "") setBaseBranch(profile.data.base_branch ?? "")
setBranchPrefix(profile.data.branch_prefix ?? "") setBranchPrefix(profile.data.branch_prefix ?? "")
}, [profile.data, firstModel?.id, firstModel?.default_effort, firstModel]) }, [
profile.data,
defaultAgentModel,
defaultAgentEffort,
defaultSubagentModel,
defaultSubagentEffort,
])
useEffect(() => { useEffect(() => {
if (currentModel && !currentModel.efforts.includes(effort)) { if (currentModel && !currentModel.efforts.includes(effort)) {
@ -108,8 +113,8 @@ function CloudAgentsPage() {
} }
if (!session.data) return <Navigate to="/login" /> if (!session.data) return <Navigate to="/login" />
const fallbackModel = firstModel?.id ?? "" const fallbackModel = defaultAgentModel
const fallbackEffort = firstModel?.default_effort ?? "" const fallbackEffort = defaultAgentEffort
const persist = (patch: Parameters<typeof buildProfileUpdate>[1]) => { const persist = (patch: Parameters<typeof buildProfileUpdate>[1]) => {
setError(null) setError(null)
@ -222,39 +227,26 @@ function CloudAgentsPage() {
label="Default Repository" label="Default Repository"
description="Used when no repository is specified" description="Used when no repository is specified"
control={ control={
<div className="w-64"> repos.data?.repositories?.length ? (
{repos.data && repos.data.repositories.length > 0 ? ( <div className="w-56">
<Combobox <RepoSelector
items={repos.data.repositories.map((r) => r.full_name)} repos={repos.data.repositories}
value={defaultRepo} selectedRepo={defaultRepo || null}
onValueChange={(v) => onRepoChange={(repo) => setDefaultRepo(repo ?? "")}
setDefaultRepo(typeof v === "string" ? v : "") 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"
<ComboboxInput dropdownClassName="w-56"
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)}
/> />
)} </div>
</div> ) : (
<Input
className="w-56"
placeholder="owner/repo"
value={defaultRepo}
onChange={(e) => setDefaultRepo(e.target.value)}
/>
)
} }
/> />
<SettingsRow <SettingsRow

View file

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