mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 10:23:14 +00:00
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:
parent
8b18e7e95d
commit
a75e9b027b
9 changed files with 376 additions and 284 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
142
ui/src/components/agents/RepoSelector.tsx
Normal file
142
ui/src/components/agents/RepoSelector.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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}`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) }),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue