feat: Make initiative branch and execution mode editable from header

- Execution mode badge toggles between YOLO/REVIEW on click
- Branch badge opens inline editor (input + save/cancel)
- Branch editing locked once any task has left pending status
- Server-side guard rejects branch changes after work has started
- getInitiative returns branchLocked flag
- updateInitiativeConfig now accepts optional branch field
This commit is contained in:
Lukas May
2026-02-10 15:52:40 +01:00
parent 3ff1f485f1
commit c2d665c24f
5 changed files with 124 additions and 26 deletions

View File

@@ -39,7 +39,7 @@ The initiative detail page has three tabs managed via local state (not URL param
### Core Components (`src/components/`) ### Core Components (`src/components/`)
| Component | Purpose | | 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 | | `InitiativeContent` | Content tab with page tree + editor |
| `StatusBadge` | Colored status indicator | | `StatusBadge` | Colored status indicator |
| `TaskRow` | Task list item with status, priority, category | | `TaskRow` | Task list item with status, priority, category |

View File

@@ -87,7 +87,7 @@ Each procedure uses `require*Repository(ctx)` helpers that throw `TRPCError(INTE
| listInitiatives | query | Filter by status | | listInitiatives | query | Filter by status |
| getInitiative | query | With projects array | | getInitiative | query | With projects array |
| updateInitiative | mutation | Name, status | | updateInitiative | mutation | Name, status |
| updateInitiativeConfig | mutation | mergeRequiresApproval, executionMode | | updateInitiativeConfig | mutation | mergeRequiresApproval, executionMode, branch |
### Phases ### Phases
| Procedure | Type | Description | | Procedure | Type | Description |

View File

@@ -1,7 +1,8 @@
import { useState } from "react"; 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 { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { StatusBadge } from "@/components/StatusBadge"; import { StatusBadge } from "@/components/StatusBadge";
import { ProjectPicker } from "./ProjectPicker"; import { ProjectPicker } from "./ProjectPicker";
import { trpc } from "@/lib/trpc"; import { trpc } from "@/lib/trpc";
@@ -14,6 +15,7 @@ export interface InitiativeHeaderProps {
status: string; status: string;
executionMode?: string; executionMode?: string;
branch?: string | null; branch?: string | null;
branchLocked?: boolean;
}; };
projects?: Array<{ id: string; name: string; url: string }>; projects?: Array<{ id: string; name: string; url: string }>;
onBack: () => void; onBack: () => void;
@@ -24,12 +26,17 @@ export function InitiativeHeader({
projects, projects,
onBack, onBack,
}: InitiativeHeaderProps) { }: InitiativeHeaderProps) {
const [editing, setEditing] = useState(false); const [editingProjects, setEditingProjects] = useState(false);
const [editIds, setEditIds] = useState<string[]>([]); const [editIds, setEditIds] = useState<string[]>([]);
const [editingBranch, setEditingBranch] = useState(false);
const [branchValue, setBranchValue] = useState("");
const updateMutation = trpc.updateInitiativeProjects.useMutation({ const utils = trpc.useUtils();
const projectMutation = trpc.updateInitiativeProjects.useMutation({
onSuccess: () => { onSuccess: () => {
setEditing(false); setEditingProjects(false);
utils.getInitiative.invalidate({ id: initiative.id });
toast.success("Projects updated"); toast.success("Projects updated");
}, },
onError: (err) => { 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) ?? []); setEditIds(projects?.map((p) => p.id) ?? []);
setEditing(true); setEditingProjects(true);
} }
function saveProjects() { function saveProjects() {
@@ -47,12 +63,33 @@ export function InitiativeHeader({
toast.error("At least one project is required"); toast.error("At least one project is required");
return; return;
} }
updateMutation.mutate({ projectMutation.mutate({
initiativeId: initiative.id, initiativeId: initiative.id,
projectIds: editIds, 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 ( return (
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -65,22 +102,63 @@ export function InitiativeHeader({
{initiative.executionMode && ( {initiative.executionMode && (
<Badge <Badge
variant="outline" variant="outline"
className={ className={`cursor-pointer select-none transition-colors ${
initiative.executionMode === "yolo" initiative.executionMode === "yolo"
? "border-orange-300 text-orange-700 text-[10px]" ? "border-orange-300 text-orange-700 text-[10px] hover:bg-orange-50"
: "border-blue-300 text-blue-700 text-[10px]" : "border-blue-300 text-blue-700 text-[10px] hover:bg-blue-50"
} }`}
onClick={toggleExecutionMode}
> >
{initiative.executionMode === "yolo" ? "YOLO" : "REVIEW"} {configMutation.isPending
? "..."
: initiative.executionMode === "yolo"
? "YOLO"
: "REVIEW"}
</Badge> </Badge>
)} )}
{initiative.branch && ( {!editingBranch && initiative.branch && (
<Badge variant="outline" className="gap-1 text-[10px] font-mono"> <Badge
variant="outline"
className={`gap-1 text-[10px] font-mono transition-colors ${
initiative.branchLocked ? "" : "cursor-pointer hover:bg-muted"
}`}
onClick={initiative.branchLocked ? undefined : startEditingBranch}
>
<GitBranch className="h-3 w-3" /> <GitBranch className="h-3 w-3" />
{initiative.branch} {initiative.branch}
</Badge> </Badge>
)} )}
{!editing && projects && projects.length > 0 && ( {!editingBranch && !initiative.branch && !initiative.branchLocked && (
<button
className="text-[10px] text-muted-foreground hover:text-foreground transition-colors"
onClick={startEditingBranch}
>
+ branch
</button>
)}
{editingBranch && (
<div className="flex items-center gap-1">
<GitBranch className="h-3 w-3 text-muted-foreground" />
<Input
value={branchValue}
onChange={(e) => 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);
}}
/>
<Button variant="ghost" size="icon" className="h-5 w-5" onClick={saveBranch}>
<Check className="h-3 w-3" />
</Button>
<Button variant="ghost" size="icon" className="h-5 w-5" onClick={() => setEditingBranch(false)}>
<X className="h-3 w-3" />
</Button>
</div>
)}
{!editingProjects && projects && projects.length > 0 && (
<> <>
{projects.map((p) => ( {projects.map((p) => (
<Badge key={p.id} variant="outline" className="text-xs font-normal"> <Badge key={p.id} variant="outline" className="text-xs font-normal">
@@ -91,40 +169,40 @@ export function InitiativeHeader({
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-6 w-6" className="h-6 w-6"
onClick={startEditing} onClick={startEditingProjects}
> >
<Pencil className="h-3 w-3" /> <Pencil className="h-3 w-3" />
</Button> </Button>
</> </>
)} )}
{!editing && (!projects || projects.length === 0) && ( {!editingProjects && (!projects || projects.length === 0) && (
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="text-xs text-muted-foreground" className="text-xs text-muted-foreground"
onClick={startEditing} onClick={startEditingProjects}
> >
+ Add projects + Add projects
</Button> </Button>
)} )}
</div> </div>
</div> </div>
{editing && ( {editingProjects && (
<div className="ml-11 max-w-sm space-y-2"> <div className="ml-11 max-w-sm space-y-2">
<ProjectPicker value={editIds} onChange={setEditIds} /> <ProjectPicker value={editIds} onChange={setEditIds} />
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
size="sm" size="sm"
onClick={saveProjects} onClick={saveProjects}
disabled={editIds.length === 0 || updateMutation.isPending} disabled={editIds.length === 0 || projectMutation.isPending}
> >
<Check className="mr-1 h-3 w-3" /> <Check className="mr-1 h-3 w-3" />
{updateMutation.isPending ? "Saving..." : "Save"} {projectMutation.isPending ? "Saving..." : "Save"}
</Button> </Button>
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
onClick={() => setEditing(false)} onClick={() => setEditingProjects(false)}
> >
Cancel Cancel
</Button> </Button>

View File

@@ -91,6 +91,7 @@ function InitiativeDetailPage() {
status: initiative.status, status: initiative.status,
executionMode: (initiative as any).executionMode as string | undefined, executionMode: (initiative as any).executionMode as string | undefined,
branch: (initiative as any).branch as string | null | 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; const projects = (initiative as { projects?: Array<{ id: string; name: string; url: string }> }).projects;

View File

@@ -5,7 +5,7 @@
import { TRPCError } from '@trpc/server'; import { TRPCError } from '@trpc/server';
import { z } from 'zod'; import { z } from 'zod';
import type { ProcedureBuilder } from '../trpc.js'; import type { ProcedureBuilder } from '../trpc.js';
import { requireInitiativeRepository, requireProjectRepository } from './_helpers.js'; import { requireInitiativeRepository, requireProjectRepository, requireTaskRepository } from './_helpers.js';
export function initiativeProcedures(publicProcedure: ProcedureBuilder) { export function initiativeProcedures(publicProcedure: ProcedureBuilder) {
return { return {
@@ -87,7 +87,13 @@ export function initiativeProcedures(publicProcedure: ProcedureBuilder) {
projects = fullProjects.map((p) => ({ id: p.id, name: p.name, url: p.url })); 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 updateInitiative: publicProcedure
@@ -107,6 +113,7 @@ export function initiativeProcedures(publicProcedure: ProcedureBuilder) {
initiativeId: z.string().min(1), initiativeId: z.string().min(1),
mergeRequiresApproval: z.boolean().optional(), mergeRequiresApproval: z.boolean().optional(),
executionMode: z.enum(['yolo', 'review_per_phase']).optional(), executionMode: z.enum(['yolo', 'review_per_phase']).optional(),
branch: z.string().nullable().optional(),
})) }))
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const repo = requireInitiativeRepository(ctx); 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); return repo.update(initiativeId, data);
}), }),
}; };