From dee78e7e846f481f8581d11228438c2b09940237 Mon Sep 17 00:00:00 2001 From: Johannes du Plessis Date: Wed, 3 Jun 2026 14:24:08 -0700 Subject: [PATCH] fix: make repository optional when starting a dashboard run (#1397) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- agent/dashboard/thread_api.py | 44 ++++---- tests/test_dashboard_repo_optional.py | 38 +++++++ ui/src/components/agents/AgentRunCard.tsx | 8 +- ui/src/components/agents/AgentThreadView.tsx | 12 +++ ui/src/components/agents/AgentsHome.tsx | 12 +++ .../agents/ported/CloudPromptBar.tsx | 101 +++++++++++++++++- 6 files changed, 189 insertions(+), 26 deletions(-) create mode 100644 tests/test_dashboard_repo_optional.py diff --git a/agent/dashboard/thread_api.py b/agent/dashboard/thread_api.py index 98c2c868..64e1957c 100644 --- a/agent/dashboard/thread_api.py +++ b/agent/dashboard/thread_api.py @@ -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(): diff --git a/tests/test_dashboard_repo_optional.py b/tests/test_dashboard_repo_optional.py new file mode 100644 index 00000000..711140e1 --- /dev/null +++ b/tests/test_dashboard_repo_optional.py @@ -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" diff --git a/ui/src/components/agents/AgentRunCard.tsx b/ui/src/components/agents/AgentRunCard.tsx index 1b29f991..c4387c90 100644 --- a/ui/src/components/agents/AgentRunCard.tsx +++ b/ui/src/components/agents/AgentRunCard.tsx @@ -80,8 +80,12 @@ export function AgentRunCard({ thread }: AgentRunCardProps) { )} {thread.model} - · - {thread.repo} + {thread.repo && ( + <> + · + {thread.repo} + + )} · {formatRelativeTime(thread.updatedAt)} diff --git a/ui/src/components/agents/AgentThreadView.tsx b/ui/src/components/agents/AgentThreadView.tsx index def167e4..0b1e67f3 100644 --- a/ui/src/components/agents/AgentThreadView.tsx +++ b/ui/src/components/agents/AgentThreadView.tsx @@ -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 (
+
+ + {thread.title} + + {thread.repo && ( + + + {thread.repoFullName} + + )} +
{hasMessages ? (
diff --git a/ui/src/components/agents/AgentsHome.tsx b/ui/src/components/agents/AgentsHome.tsx index ce2f69c0..5b6b5368 100644 --- a/ui/src/components/agents/AgentsHome.tsx +++ b/ui/src/components/agents/AgentsHome.tsx @@ -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(null) + const reposQuery = useRepos() + const profileQuery = useProfile() + // undefined = untouched (fall back to the profile default); null = explicitly "no repo". + const [repoOverride, setRepoOverride] = useState(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} />
diff --git a/ui/src/components/agents/ported/CloudPromptBar.tsx b/ui/src/components/agents/ported/CloudPromptBar.tsx index 598f4bd4..4bb973ea 100644 --- a/ui/src/components/agents/ported/CloudPromptBar.tsx +++ b/ui/src/components/agents/ported/CloudPromptBar.tsx @@ -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(null); const modelDropdownRef = useRef(null); + const repoDropdownRef = 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[] = []; @@ -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 (
+ {repoPickerEnabled && ( +
+
+ + {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 ( + + ); + }) + )} +
+
+ )} +
+
+ )}