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:
Johannes du Plessis 2026-06-03 14:24:08 -07:00 • committed by GitHub
parent 64e1f75ecc
commit dee78e7e84
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 189 additions and 26 deletions

View file

@ -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():

View 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"

View file

@ -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>

View file

@ -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">

View file

@ -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>

View file

@ -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",