diff --git a/docs/frontend.md b/docs/frontend.md index f40c0c1..b59a4c2 100644 --- a/docs/frontend.md +++ b/docs/frontend.md @@ -39,7 +39,7 @@ The initiative detail page has three tabs managed via local state (not URL param ### Core Components (`src/components/`) | Component | Purpose | |-----------|---------| -| `InitiativeHeader` | Initiative name, project badges, merge config | +| `InitiativeHeader` | Initiative name, project badges, inline-editable execution mode & branch | | `InitiativeContent` | Content tab with page tree + editor | | `StatusBadge` | Colored status indicator | | `TaskRow` | Task list item with status, priority, category | diff --git a/docs/server-api.md b/docs/server-api.md index b94ee83..26e692c 100644 --- a/docs/server-api.md +++ b/docs/server-api.md @@ -87,7 +87,7 @@ Each procedure uses `require*Repository(ctx)` helpers that throw `TRPCError(INTE | listInitiatives | query | Filter by status | | getInitiative | query | With projects array | | updateInitiative | mutation | Name, status | -| updateInitiativeConfig | mutation | mergeRequiresApproval, executionMode | +| updateInitiativeConfig | mutation | mergeRequiresApproval, executionMode, branch | ### Phases | Procedure | Type | Description | diff --git a/packages/web/src/components/InitiativeHeader.tsx b/packages/web/src/components/InitiativeHeader.tsx index af22dec..a6fcc26 100644 --- a/packages/web/src/components/InitiativeHeader.tsx +++ b/packages/web/src/components/InitiativeHeader.tsx @@ -1,7 +1,8 @@ import { useState } from "react"; -import { ChevronLeft, Pencil, Check, GitBranch } from "lucide-react"; +import { ChevronLeft, Pencil, Check, X, GitBranch } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; +import { Input } from "@/components/ui/input"; import { StatusBadge } from "@/components/StatusBadge"; import { ProjectPicker } from "./ProjectPicker"; import { trpc } from "@/lib/trpc"; @@ -14,6 +15,7 @@ export interface InitiativeHeaderProps { status: string; executionMode?: string; branch?: string | null; + branchLocked?: boolean; }; projects?: Array<{ id: string; name: string; url: string }>; onBack: () => void; @@ -24,12 +26,17 @@ export function InitiativeHeader({ projects, onBack, }: InitiativeHeaderProps) { - const [editing, setEditing] = useState(false); + const [editingProjects, setEditingProjects] = useState(false); const [editIds, setEditIds] = useState([]); + const [editingBranch, setEditingBranch] = useState(false); + const [branchValue, setBranchValue] = useState(""); - const updateMutation = trpc.updateInitiativeProjects.useMutation({ + const utils = trpc.useUtils(); + + const projectMutation = trpc.updateInitiativeProjects.useMutation({ onSuccess: () => { - setEditing(false); + setEditingProjects(false); + utils.getInitiative.invalidate({ id: initiative.id }); toast.success("Projects updated"); }, onError: (err) => { @@ -37,9 +44,18 @@ export function InitiativeHeader({ }, }); - function startEditing() { + const configMutation = trpc.updateInitiativeConfig.useMutation({ + onSuccess: () => { + utils.getInitiative.invalidate({ id: initiative.id }); + }, + onError: (err) => { + toast.error(err.message); + }, + }); + + function startEditingProjects() { setEditIds(projects?.map((p) => p.id) ?? []); - setEditing(true); + setEditingProjects(true); } function saveProjects() { @@ -47,12 +63,33 @@ export function InitiativeHeader({ toast.error("At least one project is required"); return; } - updateMutation.mutate({ + projectMutation.mutate({ initiativeId: initiative.id, projectIds: editIds, }); } + function toggleExecutionMode() { + const newMode = initiative.executionMode === "yolo" ? "review_per_phase" : "yolo"; + configMutation.mutate({ + initiativeId: initiative.id, + executionMode: newMode as "yolo" | "review_per_phase", + }); + } + + function startEditingBranch() { + setBranchValue(initiative.branch ?? ""); + setEditingBranch(true); + } + + function saveBranch() { + configMutation.mutate({ + initiativeId: initiative.id, + branch: branchValue.trim() || null, + }); + setEditingBranch(false); + } + return (
@@ -65,22 +102,63 @@ export function InitiativeHeader({ {initiative.executionMode && ( - {initiative.executionMode === "yolo" ? "YOLO" : "REVIEW"} + {configMutation.isPending + ? "..." + : initiative.executionMode === "yolo" + ? "YOLO" + : "REVIEW"} )} - {initiative.branch && ( - + {!editingBranch && initiative.branch && ( + {initiative.branch} )} - {!editing && projects && projects.length > 0 && ( + {!editingBranch && !initiative.branch && !initiative.branchLocked && ( + + )} + {editingBranch && ( +
+ + setBranchValue(e.target.value)} + placeholder="cw/my-feature" + className="h-6 w-40 text-xs font-mono" + autoFocus + onKeyDown={(e) => { + if (e.key === "Enter") saveBranch(); + if (e.key === "Escape") setEditingBranch(false); + }} + /> + + +
+ )} + {!editingProjects && projects && projects.length > 0 && ( <> {projects.map((p) => ( @@ -91,40 +169,40 @@ export function InitiativeHeader({ variant="ghost" size="icon" className="h-6 w-6" - onClick={startEditing} + onClick={startEditingProjects} > )} - {!editing && (!projects || projects.length === 0) && ( + {!editingProjects && (!projects || projects.length === 0) && ( )}
- {editing && ( + {editingProjects && (
diff --git a/packages/web/src/routes/initiatives/$id.tsx b/packages/web/src/routes/initiatives/$id.tsx index 0728c8c..9d68368 100644 --- a/packages/web/src/routes/initiatives/$id.tsx +++ b/packages/web/src/routes/initiatives/$id.tsx @@ -91,6 +91,7 @@ function InitiativeDetailPage() { status: initiative.status, executionMode: (initiative as any).executionMode as string | undefined, branch: (initiative as any).branch as string | null | undefined, + branchLocked: (initiative as any).branchLocked as boolean | undefined, }; const projects = (initiative as { projects?: Array<{ id: string; name: string; url: string }> }).projects; diff --git a/src/trpc/routers/initiative.ts b/src/trpc/routers/initiative.ts index 7369248..11181d4 100644 --- a/src/trpc/routers/initiative.ts +++ b/src/trpc/routers/initiative.ts @@ -5,7 +5,7 @@ import { TRPCError } from '@trpc/server'; import { z } from 'zod'; import type { ProcedureBuilder } from '../trpc.js'; -import { requireInitiativeRepository, requireProjectRepository } from './_helpers.js'; +import { requireInitiativeRepository, requireProjectRepository, requireTaskRepository } from './_helpers.js'; export function initiativeProcedures(publicProcedure: ProcedureBuilder) { return { @@ -87,7 +87,13 @@ export function initiativeProcedures(publicProcedure: ProcedureBuilder) { projects = fullProjects.map((p) => ({ id: p.id, name: p.name, url: p.url })); } - return { ...initiative, projects }; + let branchLocked = false; + if (ctx.taskRepository) { + const tasks = await ctx.taskRepository.findByInitiativeId(input.id); + branchLocked = tasks.some((t) => t.status !== 'pending'); + } + + return { ...initiative, projects, branchLocked }; }), updateInitiative: publicProcedure @@ -107,6 +113,7 @@ export function initiativeProcedures(publicProcedure: ProcedureBuilder) { initiativeId: z.string().min(1), mergeRequiresApproval: z.boolean().optional(), executionMode: z.enum(['yolo', 'review_per_phase']).optional(), + branch: z.string().nullable().optional(), })) .mutation(async ({ ctx, input }) => { const repo = requireInitiativeRepository(ctx); @@ -120,6 +127,18 @@ export function initiativeProcedures(publicProcedure: ProcedureBuilder) { }); } + // Prevent branch changes once work has started + if (data.branch !== undefined && ctx.taskRepository) { + const tasks = await ctx.taskRepository.findByInitiativeId(initiativeId); + const hasStarted = tasks.some((t) => t.status !== 'pending'); + if (hasStarted) { + throw new TRPCError({ + code: 'PRECONDITION_FAILED', + message: 'Cannot change branch after work has started', + }); + } + } + return repo.update(initiativeId, data); }), };