fix(panel): forms catch up with the backend (#614)

New Project claimed GitLab/Gitea were 'planned' while both providers are
fully shipped, showed a hardcoded GitHub badge, and never sent git_provider
at all — a non-GitHub project could not be created without an immediate
edit. It now carries the same forge Select the edit dialog has; the edit
dialog's own 'GitLab support is planned' tooltip is corrected too.

Also: the git actions panel's hardcoded 'main' (wrong PR-eligibility and
target label for master-default and env-ladder projects) is replaced by the
project's resolved head branch; acceptance criteria become editable in the
edit-task dialog; three feature flags get their missing descriptions; and
three forms swap raw-UUID text inputs for the existing Task/Agent selectors.

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-20 20:38:36 +02:00
committed by GitHub
co-authored by Renn F
parent fbec679878
commit 8cb233c1e8
13 changed files with 243 additions and 74 deletions
@@ -29,6 +29,17 @@ vi.mock("@/store/rate-limit-store", () => ({
useRateLimitStore: { getState: vi.fn(() => ({ hitRateLimit: vi.fn() })) },
}));
// TaskSelector is a data-fetching combobox (useTasks); stub it with a button
// that reports a fixed task id, mirroring create-task-dialog.test.tsx's
// approach to the same component.
vi.mock("@/components/tasks/task-selector", () => ({
TaskSelector: ({ onChange }: { onChange: (v: string | null) => void }) => (
<button type="button" onClick={() => onChange("task-123")}>
Set Task
</button>
),
}));
import { SpawnAgentDialog } from "../spawn-agent-dialog";
function openDialog() {
@@ -59,9 +70,7 @@ describe("SpawnAgentDialog", () => {
mutateAsync.mockResolvedValue({ already_running: false });
openDialog();
fireEvent.change(screen.getByLabelText(/Task ID/i), {
target: { value: "task-123" },
});
fireEvent.click(screen.getByRole("button", { name: "Set Task" }));
fireEvent.change(screen.getByLabelText(/Initial Prompt/i), {
target: { value: "go fix it" },
});
@@ -16,6 +16,7 @@ import {
} from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { HelpTip } from "@/components/ui/help-tip";
import { TaskSelector } from "@/components/tasks/task-selector";
import { Play } from "lucide-react";
import { toast } from "sonner";
@@ -31,7 +32,7 @@ export function SpawnAgentDialog({
trigger,
}: SpawnAgentDialogProps) {
const [open, setOpen] = useState(false);
const [taskId, setTaskId] = useState("");
const [taskId, setTaskId] = useState<string | null>(null);
const [initialPrompt, setInitialPrompt] = useState("");
const spawnAgent = useSpawnAgent();
// Synchronous re-entrancy guard: `spawnAgent.isPending` only flips on a
@@ -66,7 +67,7 @@ export function SpawnAgentDialog({
};
const resetForm = () => {
setTaskId("");
setTaskId(null);
setInitialPrompt("");
};
@@ -102,18 +103,19 @@ export function SpawnAgentDialog({
<div className="space-y-4">
<div className="space-y-2">
<HelpTip label="Pre-claims this task on spawn instead of pulling from the pool">
<Label htmlFor="taskId" className="w-fit">Task ID (optional)</Label>
<Label className="w-fit">Task (optional)</Label>
</HelpTip>
<Input
id="taskId"
<TaskSelector
value={taskId}
onChange={(e) => setTaskId(e.target.value)}
placeholder="UUID of task to assign"
onChange={setTaskId}
placeholder="Select task to assign (optional)..."
/>
</div>
<div className="space-y-2">
<HelpTip label="Extra instructions passed to the agent's first turn">
<Label htmlFor="initialPrompt" className="w-fit">Initial Prompt (optional)</Label>
<Label htmlFor="initialPrompt" className="w-fit">
Initial Prompt (optional)
</Label>
</HelpTip>
<Input
id="initialPrompt"
@@ -8,6 +8,8 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { HelpTip } from "@/components/ui/help-tip";
import { TaskSelector } from "@/components/tasks/task-selector";
import { AgentSelector } from "@/components/agents/agent-selector";
import {
Dialog,
DialogContent,
@@ -53,8 +55,8 @@ export function CreateFlagDialog({
const [description, setDescription] = useState("");
const [severity, setSeverity] = useState<FlagSeverity>(FlagSeverity.INFO);
const [category, setCategory] = useState("quality");
const [relatedTaskId, setRelatedTaskId] = useState("");
const [relatedAgentId, setRelatedAgentId] = useState("");
const [relatedTaskId, setRelatedTaskId] = useState<string | null>(null);
const [relatedAgentId, setRelatedAgentId] = useState<string | null>(null);
const createFlag = useCreateAuditorFlag();
@@ -72,8 +74,8 @@ export function CreateFlagDialog({
description: description.trim(),
severity,
category,
related_task_id: relatedTaskId.trim() || undefined,
related_agent_id: relatedAgentId.trim() || undefined,
related_task_id: relatedTaskId || undefined,
related_agent_id: relatedAgentId || undefined,
});
toast.success("Flag created successfully");
onOpenChange(false);
@@ -88,8 +90,8 @@ export function CreateFlagDialog({
setDescription("");
setSeverity(FlagSeverity.INFO);
setCategory("quality");
setRelatedTaskId("");
setRelatedAgentId("");
setRelatedTaskId(null);
setRelatedAgentId(null);
};
return (
@@ -172,24 +174,22 @@ export function CreateFlagDialog({
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<HelpTip label="Optional. Links this flag to a task — shown as a quick-link in the flags list">
<Label htmlFor="task">Related Task ID (optional)</Label>
<Label>Related Task (optional)</Label>
</HelpTip>
<Input
id="task"
<TaskSelector
value={relatedTaskId}
onChange={(e) => setRelatedTaskId(e.target.value)}
placeholder="Task UUID"
onChange={setRelatedTaskId}
placeholder="Select task (optional)..."
/>
</div>
<div className="space-y-2">
<HelpTip label="Optional. Associates this flag with a specific agent for audit tracking">
<Label htmlFor="agent">Related Agent ID (optional)</Label>
<Label>Related Agent (optional)</Label>
</HelpTip>
<Input
id="agent"
<AgentSelector
value={relatedAgentId}
onChange={(e) => setRelatedAgentId(e.target.value)}
placeholder="Agent ID"
onChange={setRelatedAgentId}
placeholder="Select agent (optional)..."
/>
</div>
</div>
+11 -11
View File
@@ -45,6 +45,9 @@ interface GitActionsPanelProps {
projectSlug: string;
taskId: string;
agentId: string;
// Real project default/head branch (env-ladder-aware) — PRs target this,
// not a hardcoded "main". See useGitBrowser.
defaultBranch: string;
onCommit: (message: string) => void;
onPush: (force?: boolean) => void;
onCreatePR: (title: string, body: string) => void;
@@ -68,6 +71,7 @@ export function GitActionsPanel({
projectSlug,
taskId,
agentId: _agentId,
defaultBranch,
onCommit,
onPush,
onCreatePR,
@@ -98,7 +102,8 @@ export function GitActionsPanel({
const hasStagedChanges = (status?.staged_files.length ?? 0) > 0;
const hasUnpushedCommits = (status?.ahead ?? 0) > 0;
const canPush = hasUnpushedCommits;
const canCreatePR = hasUnpushedCommits || status?.current_branch !== "main";
const canCreatePR =
hasUnpushedCommits || status?.current_branch !== defaultBranch;
const handleCommitDialogOpenChange = (newOpen: boolean) => {
if (!newOpen) setCommitMessage("");
@@ -251,10 +256,7 @@ export function GitActionsPanel({
: "Nothing to push — no local commits sit ahead of the remote branch yet."
}
>
<span
className="block w-full"
tabIndex={!canPush ? 0 : undefined}
>
<span className="block w-full" tabIndex={!canPush ? 0 : undefined}>
<Button
className="w-full justify-start"
variant={canPush ? "default" : "outline"}
@@ -284,8 +286,8 @@ export function GitActionsPanel({
<HelpTip
label={
canCreatePR
? "Opens GitHub's PR creation flow for this branch against main."
: "Already on main with nothing ahead of it — there's no branch content to open a PR for."
? `Opens the forge's PR creation flow for this branch against ${defaultBranch}.`
: `Already on ${defaultBranch} with nothing ahead of it — there's no branch content to open a PR for.`
}
>
<span
@@ -313,7 +315,7 @@ export function GitActionsPanel({
<div className="flex items-center gap-2 text-sm text-muted-foreground w-fit">
<Badge variant="outline">{status?.current_branch}</Badge>
<span></span>
<Badge variant="outline">main</Badge>
<Badge variant="outline">{defaultBranch}</Badge>
</div>
</HelpTip>
<div className="space-y-2">
@@ -383,9 +385,7 @@ export function GitActionsPanel({
<div className="space-y-4 py-4">
<div className="space-y-2">
<HelpTip label="The GitHub PR number to merge, e.g. the 42 in .../pull/42.">
<label className="text-sm font-medium w-fit">
PR Number
</label>
<label className="text-sm font-medium w-fit">PR Number</label>
</HelpTip>
<Input
type="number"
+10 -12
View File
@@ -4,7 +4,6 @@ import { useState } from "react";
import { GitBranchListResponse, BranchType } from "@/types/git";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
@@ -24,6 +23,7 @@ import {
} from "@/components/ui/dialog";
import { GitBranch, Check, Cloud, Plus, RefreshCw } from "lucide-react";
import { HelpTip } from "@/components/ui/help-tip";
import { TaskSelector } from "@/components/tasks/task-selector";
interface GitBranchPanelProps {
branches: GitBranchListResponse | undefined;
@@ -44,13 +44,13 @@ export function GitBranchPanel({
}: GitBranchPanelProps) {
const [showCreateDialog, setShowCreateDialog] = useState(false);
const [newBranchType, setNewBranchType] = useState<BranchType>("feature");
const [taskId, setTaskId] = useState("");
const [taskId, setTaskId] = useState<string | null>(null);
const handleCreateBranch = () => {
if (taskId.trim()) {
onCreateBranch(newBranchType, taskId.trim());
if (taskId) {
onCreateBranch(newBranchType, taskId);
setShowCreateDialog(false);
setTaskId("");
setTaskId(null);
}
};
@@ -131,14 +131,12 @@ export function GitBranchPanel({
</div>
<div className="space-y-2">
<HelpTip label="Identifies the task this branch is for — gets embedded in the branch name (shortened to 8 chars).">
<label className="text-sm font-medium w-fit">
Task ID
</label>
<label className="text-sm font-medium w-fit">Task</label>
</HelpTip>
<Input
placeholder="Enter task ID..."
<TaskSelector
value={taskId}
onChange={(e) => setTaskId(e.target.value)}
onChange={setTaskId}
placeholder="Select task..."
/>
</div>
</div>
@@ -151,7 +149,7 @@ export function GitBranchPanel({
</Button>
<Button
onClick={handleCreateBranch}
disabled={!taskId.trim() || isCreating}
disabled={!taskId || isCreating}
>
{isCreating && (
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
+2
View File
@@ -26,6 +26,7 @@ function GitBrowserContent() {
taskId,
projects,
loadingProjects,
defaultBranch,
status,
loadingStatus,
log,
@@ -134,6 +135,7 @@ function GitBrowserContent() {
projectSlug={projectSlug}
taskId={taskId}
agentId="pm"
defaultBranch={defaultBranch}
onCommit={handleCommit}
onPush={handlePush}
onCreatePR={handleCreatePR}
@@ -27,7 +27,6 @@ import { Team, type ProjectCreate } from "@/types";
import { EnvironmentLadderEditor } from "@/components/projects/environment-ladder-editor";
import { validateLadder } from "@/components/projects/ladder-validation";
import { HelpTip } from "@/components/ui/help-tip";
import { Badge } from "@/components/ui/badge";
const cells: { value: Team; label: string }[] = [
{ value: Team.BACKEND, label: "Backend" },
@@ -54,6 +53,9 @@ export function CreateProjectDialog() {
default_branch: "main",
environments: null,
});
// "auto" is a UI-only sentinel — never sent as-is; null on the wire lets
// the backend auto-detect from the Git URL host at creation time.
const [gitProvider, setGitProvider] = useState("auto");
const [showAdvanced, setShowAdvanced] = useState(false);
const createProject = useCreateProject();
@@ -90,6 +92,7 @@ export function CreateProjectDialog() {
name: formData.name,
slug: formData.slug,
git_url: formData.git_url,
git_provider: gitProvider === "auto" ? null : gitProvider,
assigned_cell: formData.assigned_cell,
git_token: formData.git_token || undefined,
default_branch: formData.default_branch || "main",
@@ -112,6 +115,7 @@ export function CreateProjectDialog() {
default_branch: "main",
environments: null,
});
setGitProvider("auto");
setShowAdvanced(false);
} catch (error) {
toast.error(
@@ -189,17 +193,26 @@ export function CreateProjectDialog() {
</p>
</div>
{/* Forge (read-only — GitHub-only today) */}
{/* Forge */}
<div className="grid gap-2">
<HelpTip label="Auto-detected from the Git URL's host at creation; RoboCo's PR/CI/review surface is GitHub-only today. GitLab & Gitea support planned.">
<HelpTip label="Which forge API serves PR/CI/review operations. Auto-detect resolves from the Git URL's host at creation (github.com -> GitHub, gitlab.com -> GitLab); a self-hosted Gitea/GitLab instance or GitHub Enterprise can't be told apart by host alone and must be set explicitly.">
<Label>Forge</Label>
</HelpTip>
<div>
<Badge variant="secondary">GitHub</Badge>
</div>
<p className="text-xs text-muted-foreground">
GitLab & Gitea support planned.
</p>
<Select value={gitProvider} onValueChange={setGitProvider}>
<SelectTrigger>
<SelectValue placeholder="Auto-detect" />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">Auto-detect</SelectItem>
<SelectItem value="github">
GitHub / GitHub Enterprise
</SelectItem>
<SelectItem value="gitea">Gitea (self-hosted)</SelectItem>
<SelectItem value="gitlab">
GitLab (gitlab.com / self-hosted)
</SelectItem>
</SelectContent>
</Select>
</div>
{/* Git Token */}
@@ -263,14 +276,17 @@ export function CreateProjectDialog() {
placeholder="main"
/>
<p className="text-xs text-muted-foreground">
Where PRs land and releases are cut when no environment ladder is set below.
Where PRs land and releases are cut when no environment ladder
is set below.
</p>
</div>
{/* Environment ladder */}
<EnvironmentLadderEditor
rungs={formData.environments ?? null}
onChange={(rungs) => setFormData({ ...formData, environments: rungs })}
onChange={(rungs) =>
setFormData({ ...formData, environments: rungs })
}
/>
{/* Advanced Options Toggle */}
@@ -369,7 +385,9 @@ export function CreateProjectDialog() {
<div className="grid gap-2">
<HelpTip label="When set, replaces the Lint + Typecheck pair as the dev's complete pre-submit gate command.">
<Label htmlFor="quality_command">Quality Gate Command</Label>
<Label htmlFor="quality_command">
Quality Gate Command
</Label>
</HelpTip>
<Input
id="quality_command"
@@ -387,6 +405,12 @@ export function CreateProjectDialog() {
run in the dev&apos;s workspace at hand-off to QA.
</p>
</div>
<p className="text-xs text-muted-foreground">
Autonomous maintenance (CI-watch, video engine,
dependency-update bot, sandbox DB/Redis/Mongo) is configured
after creation, from this project&apos;s Edit Project dialog.
</p>
</>
)}
</div>
@@ -127,10 +127,14 @@ function EditProjectForm({
// Initialize form state from project
const [name, setName] = useState(project.name);
const [gitUrl, setGitUrl] = useState(project.git_url);
const [gitProvider, setGitProvider] = useState(project.git_provider ?? "auto");
const [gitProvider, setGitProvider] = useState(
project.git_provider ?? "auto",
);
const [assignedCell, setAssignedCell] = useState(project.assigned_cell);
const [defaultBranch, setDefaultBranch] = useState(project.default_branch);
const [environments, setEnvironments] = useState(project.environments ?? null);
const [environments, setEnvironments] = useState(
project.environments ?? null,
);
const [isActive, setIsActive] = useState(project.is_active);
const [testCommand, setTestCommand] = useState(project.test_command || "");
const [lintCommand, setLintCommand] = useState(project.lint_command || "");
@@ -312,7 +316,7 @@ function EditProjectForm({
{/* Forge provider */}
<div className="grid gap-2">
<HelpTip label="Which forge API serves PR/CI/review operations. Auto-detect covers github.com; a self-hosted Gitea instance (or GitHub Enterprise) must be set explicitly — the host comes from the Git URL. GitLab support is planned.">
<HelpTip label="Which forge API serves PR/CI/review operations. Auto-detect resolves from the Git URL's host — but only at creation time, so changing the Git URL's host here needs an explicit provider re-pick (github.com -> GitHub, gitlab.com -> GitLab); a self-hosted Gitea/GitLab instance or GitHub Enterprise can't be told apart by host alone and must always be set explicitly.">
<Label>Forge</Label>
</HelpTip>
<Select value={gitProvider} onValueChange={setGitProvider}>
@@ -323,7 +327,9 @@ function EditProjectForm({
<SelectItem value="auto">Auto-detect (github.com)</SelectItem>
<SelectItem value="github">GitHub / GitHub Enterprise</SelectItem>
<SelectItem value="gitea">Gitea (self-hosted)</SelectItem>
<SelectItem value="gitlab">GitLab (gitlab.com / self-hosted)</SelectItem>
<SelectItem value="gitlab">
GitLab (gitlab.com / self-hosted)
</SelectItem>
</SelectContent>
</Select>
</div>
@@ -434,7 +440,8 @@ function EditProjectForm({
placeholder="main"
/>
<p className="text-xs text-muted-foreground">
Where PRs land and releases are cut when no environment ladder is set below.
Where PRs land and releases are cut when no environment ladder is
set below.
</p>
</div>
@@ -154,4 +154,26 @@ describe("FeatureFlagsCard — M42 off-transition confirm + pending-keys Set", (
const unmapped = screen.getByText("Alpha");
expect(unmapped.getAttribute("data-state")).toBeNull();
});
// FLAG_DESCRIPTIONS previously lagged FLAG_TOOLTIPS for three vault/docs
// flags — the always-visible paragraph silently rendered empty for them.
it("renders an always-visible description for every vault/docs-sync flag", async () => {
getFeatureFlags.mockResolvedValueOnce({
flags: [
{ key: "docs_sync_enabled", label: "Docs Sync", enabled: false },
{
key: "obsidian_vault_enabled",
label: "Obsidian Vault",
enabled: false,
},
{ key: "vault_intake_enabled", label: "Vault Intake", enabled: false },
],
note: "Changes take effect on the next backend restart.",
});
render(withQueryClient(<FeatureFlagsCard />));
expect(await screen.findByText(/docs-update task/i)).toBeInTheDocument();
expect(screen.getByText(/wikilinked Obsidian vault/i)).toBeInTheDocument();
expect(screen.getByText(/board-review drafts/i)).toBeInTheDocument();
});
});
@@ -67,6 +67,8 @@ const FLAG_DESCRIPTIONS: Record<string, string> = {
"Periodically probe opted-in projects for dependency updates and open an update task when a lockfile would change (per-project opt-in; never auto-merges).",
env_sync_enabled:
"Cascade each project's declared environment ladder prod→dev via GitHub's merges API so dev never falls behind prod; a conflicted rung opens a sync PR for you to merge (per-project opt-in; never pushes prod).",
docs_sync_enabled:
"When a release publishes and the public docs site (roboco-website) has drifted from what shipped, open ONE docs-update task that rides the normal delivery flow (+ PR-review gate) — release-triggered, not polling; never auto-merges. Needs the docs-site repo registered as a project with a git token.",
release_manager_enabled:
"Run the deterministic release-readiness sweep and propose a release for you to approve or reject — it never publishes without your approval, and the executor is fail-closed on a red gate.",
org_memory_enabled:
@@ -91,6 +93,10 @@ const FLAG_DESCRIPTIONS: Record<string, string> = {
"Also open a video-authoring task when a release publishes. Off by default even with video_engine_enabled on.",
video_on_spotlight:
"Also open a video-authoring task when you approve a feature-spotlight draft that requests one. Off by default even with video_engine_enabled on.",
obsidian_vault_enabled:
"Project tasks, journals, and A2A digests into a human-readable, wikilinked Obsidian vault on disk (RoboCo/Tasks, Journals, A2A, Agents) — a rebuildable, DB-derived projection, never the system of record. Needs ROBOCO_VAULT_PATH set.",
vault_intake_enabled:
"Watch the vault's inbox folder for #roboco-tagged notes and turn them into board-review drafts — the same Product-Owner-reviewed path a chat-confirmed task takes, never straight into delivery. Needs the Obsidian vault projection on.",
vault_report_enabled:
"Materialize a weekly org-report note (velocity, cycle time, rework, cost) in the vault's Reports/ folder and notify you — deterministic numbers, no LLM. Needs the Obsidian vault projection on.",
vault_kb_enabled:
@@ -148,11 +154,11 @@ const FLAG_TOOLTIPS: Record<string, string> = {
video_engine_enabled:
"Authors and renders motion-graphics videos for social posts.",
video_on_release: "Drafts a video whenever a release publishes.",
video_on_spotlight:
"Drafts a video whenever a feature spotlight is drafted.",
video_on_spotlight: "Drafts a video whenever a feature spotlight is drafted.",
roadmap_engine_enabled:
"Weekly has the Board draft a themed roadmap for CEO approval.",
fable_mode_enabled: "Adopts the Fable/Ponytail behavioral doctrine fleet-wide.",
fable_mode_enabled:
"Adopts the Fable/Ponytail behavioral doctrine fleet-wide.",
obsidian_vault_enabled:
"Projects tasks/journals/A2A into a human-readable Obsidian vault.",
vault_intake_enabled:
@@ -297,7 +303,9 @@ export function FeatureFlagsCard() {
trigger itself would clobber its open/closed
data-state (same trap as Switch/TabsTrigger). */}
<HelpTip label="Only takes effect once x_engine_enabled above is on.">
<span className="text-sm">X (Twitter) credentials</span>
<span className="text-sm">
X (Twitter) credentials
</span>
</HelpTip>
{xCredsOpen ? (
<ChevronDown className="h-4 w-4" />
@@ -28,6 +28,7 @@ import {
import { ChevronDown, ChevronRight, GitBranch } from "lucide-react";
import { toast } from "sonner";
import { MarkdownEditor } from "./markdown-editor";
import { AcceptanceCriteriaEditor } from "./acceptance-criteria-editor";
import { AgentSelector } from "@/components/agents/agent-selector";
import { ProjectSelector } from "@/components/projects/project-selector";
import { HelpTip } from "@/components/ui/help-tip";
@@ -67,7 +68,8 @@ const TASK_TYPE_OPTIONS = [
// (branch, commits, PR) — this only classifies the kind of artifact.
const TASK_TYPE_DESCRIPTIONS: Record<TaskType, string> = {
[TaskType.CODE]: "Source code changes. Follows the full git workflow.",
[TaskType.DOCUMENTATION]: "Documentation updates. Follows the full git workflow.",
[TaskType.DOCUMENTATION]:
"Documentation updates. Follows the full git workflow.",
[TaskType.RESEARCH]:
"Research findings, committed as notes. Follows the full git workflow.",
[TaskType.PLANNING]:
@@ -102,6 +104,10 @@ function EditTaskDialogInner({
const [nature, setNature] = useState<TaskNature>(
task.nature ?? TaskNature.TECHNICAL,
);
const [acceptanceCriteria, setAcceptanceCriteria] = useState<string[]>(
task.acceptance_criteria,
);
const [acError, setAcError] = useState<string | undefined>();
const [taskType, setTaskType] = useState<TaskType>(
task.task_type ?? TaskType.CODE,
);
@@ -131,6 +137,19 @@ function EditTaskDialogInner({
return;
}
if (acceptanceCriteria.length === 0) {
setAcError("At least one acceptance criterion is required");
return;
}
setAcError(undefined);
const trimmedCriteria = acceptanceCriteria
.map((c) => c.trim())
.filter(Boolean);
const criteriaChanged =
JSON.stringify(trimmedCriteria) !==
JSON.stringify(task.acceptance_criteria);
try {
await updateTask.mutateAsync({
taskId: task.id,
@@ -145,6 +164,7 @@ function EditTaskDialogInner({
project_id: projectId,
assigned_to: assignedTo,
target_date: targetDate ? new Date(targetDate).toISOString() : null,
...(criteriaChanged && { acceptance_criteria: trimmedCriteria }),
},
});
toast.success("Task updated successfully");
@@ -267,6 +287,13 @@ function EditTaskDialogInner({
</div>
</div>
{/* Acceptance Criteria */}
<AcceptanceCriteriaEditor
criteria={acceptanceCriteria}
onChange={setAcceptanceCriteria}
error={acError}
/>
{/* Advanced Options */}
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
<CollapsibleTrigger asChild>
@@ -4,6 +4,7 @@ import { useGitBrowser } from "../use-git-browser";
const {
mockUseProjects,
mockUseProject,
mockUseGitStatus,
mockUseGitLog,
mockUseGitBranches,
@@ -16,6 +17,7 @@ const {
mockToastError,
} = vi.hoisted(() => ({
mockUseProjects: vi.fn(),
mockUseProject: vi.fn(),
mockUseGitStatus: vi.fn(),
mockUseGitLog: vi.fn(),
mockUseGitBranches: vi.fn(),
@@ -30,6 +32,7 @@ const {
vi.mock("@/hooks/use-projects", () => ({
useProjects: () => mockUseProjects(),
useProject: (...args: unknown[]) => mockUseProject(...args),
}));
vi.mock("@/hooks/use-git", () => ({
@@ -95,6 +98,7 @@ describe("useGitBrowser", () => {
registeredCallbacks.length = 0;
mockUseProjects.mockReturnValue(buildQueryResult([]));
mockUseProject.mockReturnValue(buildQueryResult(null));
mockUseGitStatus.mockReturnValue(buildQueryResult(null));
mockUseGitLog.mockReturnValue(buildQueryResult(null));
mockUseGitBranches.mockReturnValue(buildQueryResult(null));
@@ -121,6 +125,52 @@ describe("useGitBrowser", () => {
expect(result.current.taskId).toBe("t1");
});
// Bug: GitActionsPanel used to hardcode "main" for the PR-target branch,
// wrong for the fleet default ("master") and any project on a real
// environment ladder. defaultBranch resolves the selected project's real
// head rung (or default_branch), not a literal.
it("resolves defaultBranch from the selected project's environment ladder head rung", () => {
mockUseProjects.mockReturnValue(
buildQueryResult([{ id: "proj-1", slug: "roboco", name: "RoboCo" }]),
);
mockUseProject.mockReturnValue(
buildQueryResult({
id: "proj-1",
default_branch: "master",
environments: [
{ name: "head", branch: "slave" },
{ name: "prod", branch: "master" },
],
}),
);
const { result } = renderHook(() => useGitBrowser());
expect(mockUseProject).toHaveBeenCalledWith("proj-1");
expect(result.current.defaultBranch).toBe("slave");
});
it("falls back to default_branch when no environment ladder is set", () => {
mockUseProjects.mockReturnValue(
buildQueryResult([{ id: "proj-1", slug: "roboco", name: "RoboCo" }]),
);
mockUseProject.mockReturnValue(
buildQueryResult({
id: "proj-1",
default_branch: "master",
environments: null,
}),
);
const { result } = renderHook(() => useGitBrowser());
expect(result.current.defaultBranch).toBe("master");
});
it('falls back to "main" before any project has loaded', () => {
const { result } = renderHook(() => useGitBrowser());
expect(mockUseProject).toHaveBeenCalledWith("");
expect(result.current.defaultBranch).toBe("main");
});
it("passes project slug and enabled flag to git query hooks", () => {
renderHook(() => useGitBrowser());
+22 -2
View File
@@ -3,7 +3,7 @@
import { useCallback, useEffect, useRef } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { toast } from "sonner";
import { useProjects } from "@/hooks/use-projects";
import { useProjects, useProject } from "@/hooks/use-projects";
import {
useGitStatus,
useGitLog,
@@ -20,6 +20,10 @@ export interface UseGitBrowserResult {
taskId: string;
projects: ReturnType<typeof useProjects>["data"];
loadingProjects: boolean;
// Real head/default branch of the selected project (env-ladder rung 0,
// falling back to default_branch, then "main" before any project loads) —
// never a hardcoded "main".
defaultBranch: string;
status: ReturnType<typeof useGitStatus>["data"];
loadingStatus: boolean;
log: ReturnType<typeof useGitLog>["data"];
@@ -80,6 +84,19 @@ export function useGitBrowser(): UseGitBrowserResult {
refetch: refetchProjects,
} = useProjects();
// The project list is a lightweight ProjectSummary (no default_branch /
// environments) — resolve its id, then fetch the full Project for the
// ladder/branch fields.
const currentProjectId =
projects?.find((p) => p.slug === projectSlug)?.id ?? "";
const { data: currentProject } = useProject(currentProjectId);
// Ladder rung 0 (head) when an environment ladder is declared, else the
// plain default_branch shim — mirrors roboco/models/env_branches.head_branch.
const defaultBranch =
currentProject?.environments?.[0]?.branch ??
currentProject?.default_branch ??
"main";
const {
data: status,
isLoading: loadingStatus,
@@ -168,7 +185,9 @@ export function useGitBrowser(): UseGitBrowserResult {
} = useGitOperations();
// Resume point for a capped stale-branch sweep, per project.
const cleanupCursorRef = useRef<{ slug: string; cursor: string } | null>(null);
const cleanupCursorRef = useRef<{ slug: string; cursor: string } | null>(
null,
);
const handleCheckout = useCallback(
async (branch: string) => {
@@ -362,6 +381,7 @@ export function useGitBrowser(): UseGitBrowserResult {
taskId,
projects,
loadingProjects,
defaultBranch,
status,
loadingStatus,
log,