mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-10-01 06:13:15 +00:00
fix: make repository optional when starting a dashboard run (#1397)
* fix: make repository optional when starting a dashboard run
The agent infers and clones the target repo from the task itself, so a
default repo is never actually required to run — but the dashboard 400'd
("no default repository configured") when a user had none set.
Treat repo as optional: _resolve_repo_config returns {} instead of
raising, repo metadata/config are only written when a repo is present,
and the "missing repository metadata" gate on follow-up messages is
dropped. UI hides the repo chip when absent.
* feat: add repo picker to the run prompt bar
Adds an optional, searchable repository selector next to the model picker
on the Agents home prompt bar (Cursor-style). It pre-fills the user's saved
default repo and can be cleared to "No repository" for a repo-less run.
Because the picker now resolves the default on the client, the create
endpoint honors the request value verbatim: _resolve_repo_config just parses
what's sent ({} when empty) instead of falling back to the saved default,
so an explicit "No repository" is respected.
* refactor: match Cursor layout for repo placement
Move the repo selector out of the prompt-box footer to a pill row above
the input (folder + caret, dropdown opens downward); the model picker
stays inside the box. In the thread view, show the thread title and repo
in a header at the top of the chat, with the follow-up input pinned to
the bottom as before.
This commit is contained in:
parent
64e1f75ecc
commit
dee78e7e84
6 changed files with 189 additions and 26 deletions
|
|
@ -16,7 +16,6 @@ from pydantic import BaseModel, Field
|
|||
|
||||
from ..utils.auth import persist_encrypted_github_token
|
||||
from ..utils.thread_ops import is_thread_active, langgraph_client, queue_message_for_thread
|
||||
from .agent_overrides import get_profile_default_repo
|
||||
from .message_adapter import state_messages_to_ui
|
||||
from .options import SUPPORTED_MODEL_IDS, model_supports_effort
|
||||
from .profiles import OAUTH_TOKENS_NAMESPACE, get_profile, get_valid_access_token
|
||||
|
|
@ -179,8 +178,8 @@ def _thread_summary(
|
|||
summary: dict[str, Any] = {
|
||||
"id": thread.get("thread_id") or thread.get("id"),
|
||||
"title": title,
|
||||
"repo": name or "unknown",
|
||||
"repoFullName": full_name or "unknown/unknown",
|
||||
"repo": name,
|
||||
"repoFullName": full_name,
|
||||
"branch": metadata.get("branch_name") or metadata.get("base_branch") or "main",
|
||||
"model": model,
|
||||
"effort": effort,
|
||||
|
|
@ -279,18 +278,15 @@ async def get_dashboard_thread(
|
|||
return _thread_summary(thread, messages=messages)
|
||||
|
||||
|
||||
async def _resolve_repo_config(login: str, repo: str | None) -> dict[str, str]:
|
||||
parsed = _parse_repo(repo)
|
||||
if parsed:
|
||||
return parsed
|
||||
profile_repo = await get_profile_default_repo(login)
|
||||
if profile_repo:
|
||||
return profile_repo
|
||||
profile = await get_profile(login)
|
||||
parsed = _parse_repo(profile.get("default_repo") if isinstance(profile, dict) else None)
|
||||
if parsed:
|
||||
return parsed
|
||||
raise HTTPException(400, "no default repository configured — set one in Cloud Agents settings")
|
||||
def _resolve_repo_config(repo: str | None) -> dict[str, str]:
|
||||
"""Resolve the run's repo from the request, or ``{}`` when none is given.
|
||||
|
||||
A repo is optional: the agent identifies and clones the target repo from the
|
||||
task itself. The dashboard pre-fills the user's default repo on the client,
|
||||
so the request value is authoritative here — an empty value means an
|
||||
intentionally repo-less run, not "fall back to the saved default".
|
||||
"""
|
||||
return _parse_repo(repo) or {}
|
||||
|
||||
|
||||
async def _start_agent_run(
|
||||
|
|
@ -308,12 +304,11 @@ async def _start_agent_run(
|
|||
chosen_model, chosen_effort = _normalize_model_choice(model_id, effort)
|
||||
metadata_model = chosen_model or profile.get("default_model") or "Default"
|
||||
metadata_effort = chosen_effort or profile.get("reasoning_effort")
|
||||
metadata = {
|
||||
has_repo = bool(repo_config.get("owner") and repo_config.get("name"))
|
||||
metadata: dict[str, Any] = {
|
||||
"source": _DASHBOARD_SOURCE,
|
||||
"github_login": login,
|
||||
"title": title or prompt[:80] or "New agent",
|
||||
"repo_owner": repo_config["owner"],
|
||||
"repo_name": repo_config["name"],
|
||||
"base_branch": profile.get("base_branch") or "main",
|
||||
"branch_prefix": profile.get("branch_prefix"),
|
||||
"model": metadata_model,
|
||||
|
|
@ -321,6 +316,9 @@ async def _start_agent_run(
|
|||
"created_at_ms": now_ms,
|
||||
"updated_at_ms": now_ms,
|
||||
}
|
||||
if has_repo:
|
||||
metadata["repo_owner"] = repo_config["owner"]
|
||||
metadata["repo_name"] = repo_config["name"]
|
||||
|
||||
client = langgraph_client()
|
||||
await client.threads.create(thread_id=thread_id, metadata=metadata, if_exists="do_nothing")
|
||||
|
|
@ -331,9 +329,10 @@ async def _start_agent_run(
|
|||
"thread_id": thread_id,
|
||||
"source": _DASHBOARD_SOURCE,
|
||||
"github_login": login,
|
||||
"repo": repo_config,
|
||||
"user_email": await _resolve_run_email(login, profile),
|
||||
}
|
||||
if has_repo:
|
||||
configurable["repo"] = repo_config
|
||||
if chosen_model and chosen_effort:
|
||||
configurable["agent_model_id"] = chosen_model
|
||||
configurable["agent_effort"] = chosen_effort
|
||||
|
|
@ -359,7 +358,7 @@ async def _start_agent_run(
|
|||
|
||||
|
||||
async def create_dashboard_thread(login: str, body: ThreadCreateBody) -> dict[str, Any]:
|
||||
repo_config = await _resolve_repo_config(login, body.repo)
|
||||
repo_config = _resolve_repo_config(body.repo)
|
||||
thread_id = str(uuid.uuid4())
|
||||
return await _start_agent_run(
|
||||
thread_id,
|
||||
|
|
@ -383,8 +382,6 @@ async def send_dashboard_message(
|
|||
metadata = thread.get("metadata") if isinstance(thread.get("metadata"), dict) else {}
|
||||
_assert_thread_owner(metadata, login, email)
|
||||
owner, name, _ = _metadata_repo(metadata)
|
||||
if not owner or not name:
|
||||
raise HTTPException(400, "thread is missing repository metadata")
|
||||
|
||||
prompt = body.content.strip()
|
||||
now_ms = _now_ms()
|
||||
|
|
@ -411,9 +408,10 @@ async def send_dashboard_message(
|
|||
"thread_id": thread_id,
|
||||
"source": thread_source,
|
||||
"github_login": login,
|
||||
"repo": {"owner": owner, "name": name},
|
||||
"user_email": await _resolve_run_email(login, profile),
|
||||
}
|
||||
if owner and name:
|
||||
configurable["repo"] = {"owner": owner, "name": name}
|
||||
source_context = metadata.get("source_context")
|
||||
if isinstance(source_context, dict):
|
||||
for key, value in source_context.items():
|
||||
|
|
|
|||
38
tests/test_dashboard_repo_optional.py
Normal file
38
tests/test_dashboard_repo_optional.py
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from agent.dashboard import thread_api
|
||||
|
||||
|
||||
def test_resolve_repo_config_parses_request_repo() -> None:
|
||||
assert thread_api._resolve_repo_config("octo/repo") == {"owner": "octo", "name": "repo"}
|
||||
|
||||
|
||||
def test_resolve_repo_config_returns_empty_when_no_repo_given() -> None:
|
||||
# None / blank / malformed all mean an intentionally repo-less run — never an error.
|
||||
assert thread_api._resolve_repo_config(None) == {}
|
||||
assert thread_api._resolve_repo_config("") == {}
|
||||
assert thread_api._resolve_repo_config("not-a-repo") == {}
|
||||
|
||||
|
||||
def test_thread_summary_blanks_repo_when_absent() -> None:
|
||||
summary = thread_api._thread_summary(
|
||||
{"thread_id": "t1", "metadata": {"source": "dashboard", "title": "no repo run"}}
|
||||
)
|
||||
assert summary["repo"] == ""
|
||||
assert summary["repoFullName"] == ""
|
||||
|
||||
|
||||
def test_thread_summary_keeps_repo_when_present() -> None:
|
||||
summary = thread_api._thread_summary(
|
||||
{
|
||||
"thread_id": "t2",
|
||||
"metadata": {
|
||||
"source": "dashboard",
|
||||
"title": "repo run",
|
||||
"repo_owner": "octo",
|
||||
"repo_name": "repo",
|
||||
},
|
||||
}
|
||||
)
|
||||
assert summary["repo"] == "repo"
|
||||
assert summary["repoFullName"] == "octo/repo"
|
||||
|
|
@ -80,8 +80,12 @@ export function AgentRunCard({ thread }: AgentRunCardProps) {
|
|||
</>
|
||||
)}
|
||||
<span>{thread.model}</span>
|
||||
<span>·</span>
|
||||
<span>{thread.repo}</span>
|
||||
{thread.repo && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span>{thread.repo}</span>
|
||||
</>
|
||||
)}
|
||||
<span>·</span>
|
||||
<span>{formatRelativeTime(thread.updatedAt)}</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { FolderIcon } from "@phosphor-icons/react";
|
||||
|
||||
import { AgentPromptBar } from "@/components/agents/AgentPromptBar";
|
||||
import { AgentsShell } from "@/components/agents/AgentsSidebar";
|
||||
|
|
@ -89,6 +90,17 @@ export function AgentThreadView({ user, thread }: AgentThreadViewProps) {
|
|||
return (
|
||||
<AgentsShell user={user} activeThreadId={thread.id}>
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<div className="flex shrink-0 items-center gap-2 border-b border-[var(--ui-border)] px-4 py-3">
|
||||
<span className="truncate text-sm font-medium text-[color:var(--ui-text)]">
|
||||
{thread.title}
|
||||
</span>
|
||||
{thread.repo && (
|
||||
<span className="flex min-w-0 items-center gap-1 text-xs text-[color:var(--ui-text-dim)]">
|
||||
<FolderIcon className="size-3.5 shrink-0" />
|
||||
<span className="truncate">{thread.repoFullName}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
{hasMessages ? (
|
||||
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { SlackConnectDialog } from "@/components/agents/SlackConnectDialog"
|
|||
import { Logo } from "@/components/agents/ported/Logo"
|
||||
import { useAgentThreads, useCreateAgentThread } from "@/lib/agents/queries"
|
||||
import { useModelOptions } from "@/lib/agents/useModelOptions"
|
||||
import { useProfile, useRepos } from "@/lib/profile"
|
||||
|
||||
export function AgentsHome() {
|
||||
const threadsQuery = useAgentThreads()
|
||||
|
|
@ -16,6 +17,13 @@ export function AgentsHome() {
|
|||
const { models, defaultSelection } = useModelOptions()
|
||||
const [selection, setSelection] = useState<ModelSelection | null>(null)
|
||||
|
||||
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 repo =
|
||||
repoOverride === undefined ? (profileQuery.data?.default_repo ?? null) : repoOverride
|
||||
|
||||
useEffect(() => {
|
||||
if (selection === null && defaultSelection) setSelection(defaultSelection)
|
||||
}, [defaultSelection, selection])
|
||||
|
|
@ -30,6 +38,7 @@ export function AgentsHome() {
|
|||
onSubmit={(prompt) =>
|
||||
createThread.mutate({
|
||||
prompt,
|
||||
repo,
|
||||
model_id: selection?.modelId ?? null,
|
||||
effort: selection?.effort ?? null,
|
||||
})
|
||||
|
|
@ -38,6 +47,9 @@ export function AgentsHome() {
|
|||
models={models}
|
||||
selection={selection ?? defaultSelection}
|
||||
onSelectionChange={setSelection}
|
||||
repos={reposQuery.data?.repositories}
|
||||
selectedRepo={repo}
|
||||
onRepoChange={setRepoOverride}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { CaretDownIcon, FolderIcon } from "@phosphor-icons/react";
|
||||
|
||||
import type { ModelOption } from "@/lib/api";
|
||||
import {
|
||||
|
|
@ -18,6 +19,10 @@ export interface CloudPromptBarProps {
|
|||
models?: 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;
|
||||
}
|
||||
|
||||
/** Web-adapted PromptBar from open-swe-app — local state, no Electron/Zustand deps. */
|
||||
|
|
@ -30,11 +35,25 @@ export const CloudPromptBar = memo(function CloudPromptBar({
|
|||
models = [],
|
||||
selection = null,
|
||||
onSelectionChange,
|
||||
repos,
|
||||
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 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[] = [];
|
||||
|
|
@ -67,9 +86,13 @@ export const CloudPromptBar = memo(function CloudPromptBar({
|
|||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (modelDropdownRef.current && !modelDropdownRef.current.contains(e.target as Node)) {
|
||||
const target = e.target as Node;
|
||||
if (modelDropdownRef.current && !modelDropdownRef.current.contains(target)) {
|
||||
setModelDropdownOpen(false);
|
||||
}
|
||||
if (repoDropdownRef.current && !repoDropdownRef.current.contains(target)) {
|
||||
setRepoDropdownOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
|
|
@ -86,6 +109,82 @@ export const CloudPromptBar = memo(function CloudPromptBar({
|
|||
|
||||
return (
|
||||
<div className={cn("relative w-full font-sans text-[13px]", compact ? "max-w-none" : "max-w-2xl")}>
|
||||
{repoPickerEnabled && (
|
||||
<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>
|
||||
</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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue