diff --git a/panel/src/components/agents/__tests__/spawn-agent-dialog.test.tsx b/panel/src/components/agents/__tests__/spawn-agent-dialog.test.tsx index 84474691..564584b2 100644 --- a/panel/src/components/agents/__tests__/spawn-agent-dialog.test.tsx +++ b/panel/src/components/agents/__tests__/spawn-agent-dialog.test.tsx @@ -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 }) => ( + + ), +})); + 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" }, }); diff --git a/panel/src/components/agents/spawn-agent-dialog.tsx b/panel/src/components/agents/spawn-agent-dialog.tsx index a07d1cf1..2726de11 100644 --- a/panel/src/components/agents/spawn-agent-dialog.tsx +++ b/panel/src/components/agents/spawn-agent-dialog.tsx @@ -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(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({
- + - setTaskId(e.target.value)} - placeholder="UUID of task to assign" + onChange={setTaskId} + placeholder="Select task to assign (optional)..." />
- + (FlagSeverity.INFO); const [category, setCategory] = useState("quality"); - const [relatedTaskId, setRelatedTaskId] = useState(""); - const [relatedAgentId, setRelatedAgentId] = useState(""); + const [relatedTaskId, setRelatedTaskId] = useState(null); + const [relatedAgentId, setRelatedAgentId] = useState(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({
- + - setRelatedTaskId(e.target.value)} - placeholder="Task UUID" + onChange={setRelatedTaskId} + placeholder="Select task (optional)..." />
- + - setRelatedAgentId(e.target.value)} - placeholder="Agent ID" + onChange={setRelatedAgentId} + placeholder="Select agent (optional)..." />
diff --git a/panel/src/components/git/git-actions-panel.tsx b/panel/src/components/git/git-actions-panel.tsx index ee8bba64..bace5159 100644 --- a/panel/src/components/git/git-actions-panel.tsx +++ b/panel/src/components/git/git-actions-panel.tsx @@ -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." } > - +
@@ -383,9 +385,7 @@ export function GitActionsPanel({
- + ("feature"); - const [taskId, setTaskId] = useState(""); + const [taskId, setTaskId] = useState(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({
- + - setTaskId(e.target.value)} + onChange={setTaskId} + placeholder="Select task..." />
@@ -151,7 +149,7 @@ export function GitBranchPanel({
- {/* Forge (read-only — GitHub-only today) */} + {/* Forge */}
- + -
- GitHub -
-

- GitLab & Gitea support planned. -

+
{/* Git Token */} @@ -263,14 +276,17 @@ export function CreateProjectDialog() { placeholder="main" />

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

{/* Environment ladder */} setFormData({ ...formData, environments: rungs })} + onChange={(rungs) => + setFormData({ ...formData, environments: rungs }) + } /> {/* Advanced Options Toggle */} @@ -369,7 +385,9 @@ export function CreateProjectDialog() {
- +
+ +

+ Autonomous maintenance (CI-watch, video engine, + dependency-update bot, sandbox DB/Redis/Mongo) is configured + after creation, from this project's Edit Project dialog. +

)} diff --git a/panel/src/components/projects/edit-project-dialog.tsx b/panel/src/components/projects/edit-project-dialog.tsx index 23668131..59a1acb5 100644 --- a/panel/src/components/projects/edit-project-dialog.tsx +++ b/panel/src/components/projects/edit-project-dialog.tsx @@ -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 */}
- +
@@ -434,7 +440,8 @@ function EditProjectForm({ placeholder="main" />

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

diff --git a/panel/src/components/settings/__tests__/feature-flags-card.test.tsx b/panel/src/components/settings/__tests__/feature-flags-card.test.tsx index 4008a648..28184831 100644 --- a/panel/src/components/settings/__tests__/feature-flags-card.test.tsx +++ b/panel/src/components/settings/__tests__/feature-flags-card.test.tsx @@ -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()); + + 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(); + }); }); diff --git a/panel/src/components/settings/feature-flags-card.tsx b/panel/src/components/settings/feature-flags-card.tsx index 2d854643..e453d224 100644 --- a/panel/src/components/settings/feature-flags-card.tsx +++ b/panel/src/components/settings/feature-flags-card.tsx @@ -67,6 +67,8 @@ const FLAG_DESCRIPTIONS: Record = { "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 = { "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 = { 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). */} - X (Twitter) credentials + + X (Twitter) credentials + {xCredsOpen ? ( diff --git a/panel/src/components/tasks/edit-task-dialog.tsx b/panel/src/components/tasks/edit-task-dialog.tsx index 211e2541..bf5969cb 100644 --- a/panel/src/components/tasks/edit-task-dialog.tsx +++ b/panel/src/components/tasks/edit-task-dialog.tsx @@ -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.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( task.nature ?? TaskNature.TECHNICAL, ); + const [acceptanceCriteria, setAcceptanceCriteria] = useState( + task.acceptance_criteria, + ); + const [acError, setAcError] = useState(); const [taskType, setTaskType] = useState( 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({ + {/* Acceptance Criteria */} + + {/* Advanced Options */} diff --git a/panel/src/hooks/__tests__/use-git-browser.test.tsx b/panel/src/hooks/__tests__/use-git-browser.test.tsx index f48d926b..49db4230 100644 --- a/panel/src/hooks/__tests__/use-git-browser.test.tsx +++ b/panel/src/hooks/__tests__/use-git-browser.test.tsx @@ -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()); diff --git a/panel/src/hooks/use-git-browser.ts b/panel/src/hooks/use-git-browser.ts index 9b32abae..31883e42 100644 --- a/panel/src/hooks/use-git-browser.ts +++ b/panel/src/hooks/use-git-browser.ts @@ -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["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["data"]; loadingStatus: boolean; log: ReturnType["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,