From a75e9b027b54834d947ea71879489ed18b9ddbc0 Mon Sep 17 00:00:00 2001 From: Johannes du Plessis Date: Thu, 4 Jun 2026 14:06:25 -0700 Subject: [PATCH] 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> --- agent/dashboard/routes.py | 12 +- ui/src/components/agents/AgentThreadView.tsx | 39 ++- ui/src/components/agents/AgentsHome.tsx | 21 +- ui/src/components/agents/RepoSelector.tsx | 142 +++++++++++ .../agents/ported/CloudPromptBar.tsx | 240 +++++++----------- ui/src/lib/agents/useModelOptions.ts | 93 +++---- ui/src/lib/api.ts | 10 +- ui/src/routes/cloud-agents.tsx | 96 ++++--- ui/src/routes/my-settings.tsx | 7 +- 9 files changed, 376 insertions(+), 284 deletions(-) create mode 100644 ui/src/components/agents/RepoSelector.tsx diff --git a/agent/dashboard/routes.py b/agent/dashboard/routes.py index 3681db32..cb001c3a 100644 --- a/agent/dashboard/routes.py +++ b/agent/dashboard/routes.py @@ -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") diff --git a/ui/src/components/agents/AgentThreadView.tsx b/ui/src/components/agents/AgentThreadView.tsx index 0b1e67f3..7479f732 100644 --- a/ui/src/components/agents/AgentThreadView.tsx +++ b/ui/src/components/agents/AgentThreadView.tsx @@ -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(() => + const [pendingPrompts, setPendingPrompts] = useState>(() => 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(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(() => { + const displayMessages = useMemo>(() => { 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} /> @@ -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} /> diff --git a/ui/src/components/agents/AgentsHome.tsx b/ui/src/components/agents/AgentsHome.tsx index 5b6b5368..f93af9a4 100644 --- a/ui/src/components/agents/AgentsHome.tsx +++ b/ui/src/components/agents/AgentsHome.tsx @@ -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(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(undefined) + const [repoOverride, setRepoOverride] = useState( + 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 (
@@ -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} diff --git a/ui/src/components/agents/RepoSelector.tsx b/ui/src/components/agents/RepoSelector.tsx new file mode 100644 index 00000000..b646b72a --- /dev/null +++ b/ui/src/components/agents/RepoSelector.tsx @@ -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 + 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(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 ( +
+ + {open && ( +
+ 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" + /> +
+ + {filteredRepos.length === 0 ? ( +
+ {noMatchesLabel} +
+ ) : ( + filteredRepos.map((repo) => { + const selected = repo.full_name === selectedRepo + return ( + + ) + }) + )} +
+
+ )} +
+ ) +} diff --git a/ui/src/components/agents/ported/CloudPromptBar.tsx b/ui/src/components/agents/ported/CloudPromptBar.tsx index 4bb973ea..0f549d0f 100644 --- a/ui/src/components/agents/ported/CloudPromptBar.tsx +++ b/ui/src/components/agents/ported/CloudPromptBar.tsx @@ -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 + 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(null); - const modelDropdownRef = useRef(null); - const repoDropdownRef = useRef(null); + const [value, setValue] = useState("") + const [modelDropdownOpen, setModelDropdownOpen] = useState(false) + const inputRef = useRef(null) + const modelDropdownRef = useRef(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(() => { - const list: ModelSelection[] = []; + const combos = useMemo>(() => { + const list: Array = [] 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) => { 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 ( -
- {repoPickerEnabled && ( +
+ {onRepoChange && (
-
- - {repoDropdownOpen && ( -
- 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)]" - /> -
- - {filteredRepos.length === 0 ? ( -
No matches
- ) : ( - filteredRepos.map((repo) => { - const selected = repo.full_name === selectedRepo; - return ( - - ); - }) - )} -
-
- )} -
+
)}