feat: Task decomposition for Tailwind/Radix/shadcn foundation setup

Decomposed "Foundation Setup - Install Dependencies & Configure Tailwind"
phase into 6 executable tasks:

1. Install Tailwind CSS, PostCSS & Autoprefixer
2. Map MUI theme to Tailwind design tokens
3. Setup CSS variables for dynamic theming
4. Install Radix UI primitives
5. Initialize shadcn/ui and setup component directory
6. Move MUI to devDependencies and verify setup

Tasks follow logical dependency chain with final human verification
checkpoint before proceeding with component migration.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Lukas May
2026-02-10 09:48:51 +01:00
parent da4152264c
commit 342b490fe7
83 changed files with 3200 additions and 913 deletions

View File

@@ -13,6 +13,7 @@
"@codewalk-district/shared": "*",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-select": "^2.2.6",
"@tanstack/react-query": "^5.75.0",
"@tanstack/react-router": "^1.158.0",
"@tiptap/extension-link": "^3.19.0",

View File

@@ -0,0 +1,139 @@
import { useState, useCallback } from "react";
import { ChevronDown, ChevronRight, Undo2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { trpc } from "@/lib/trpc";
import type { ChangeSet } from "@codewalk-district/shared";
interface ChangeSetBannerProps {
changeSet: ChangeSet;
onDismiss: () => void;
}
const MODE_LABELS: Record<string, string> = {
breakdown: "phases",
decompose: "tasks",
refine: "pages",
};
export function ChangeSetBanner({ changeSet, onDismiss }: ChangeSetBannerProps) {
const [expanded, setExpanded] = useState(false);
const [conflicts, setConflicts] = useState<string[] | null>(null);
const detailQuery = trpc.getChangeSet.useQuery(
{ id: changeSet.id },
{ enabled: expanded },
);
const revertMutation = trpc.revertChangeSet.useMutation({
onSuccess: (result) => {
if (!result.success && "conflicts" in result) {
setConflicts(result.conflicts);
} else {
setConflicts(null);
}
},
});
const handleRevert = useCallback(
(force?: boolean) => {
revertMutation.mutate({ id: changeSet.id, force });
},
[changeSet.id, revertMutation],
);
const entries = detailQuery.data?.entries ?? [];
const entityLabel = MODE_LABELS[changeSet.mode] ?? "entities";
const isReverted = changeSet.status === "reverted";
return (
<div className="rounded-lg border border-border bg-card p-3 space-y-2">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<button
className="flex items-center gap-1 text-sm font-medium hover:text-foreground/80"
onClick={() => setExpanded(!expanded)}
>
{expanded ? (
<ChevronDown className="h-3.5 w-3.5 shrink-0" />
) : (
<ChevronRight className="h-3.5 w-3.5 shrink-0" />
)}
{changeSet.summary ??
`Agent ${isReverted ? "reverted" : "applied"} ${entityLabel}`}
</button>
{isReverted && (
<span className="text-xs text-muted-foreground italic">
(reverted)
</span>
)}
</div>
<div className="flex gap-1.5 shrink-0">
{!isReverted && (
<Button
variant="outline"
size="sm"
onClick={() => handleRevert()}
disabled={revertMutation.isPending}
className="gap-1"
>
<Undo2 className="h-3 w-3" />
{revertMutation.isPending ? "Reverting..." : "Revert"}
</Button>
)}
<Button variant="ghost" size="sm" onClick={onDismiss}>
Dismiss
</Button>
</div>
</div>
{conflicts && (
<div className="rounded border border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-amber-950 p-2 space-y-2">
<p className="text-xs font-medium text-amber-800 dark:text-amber-200">
Conflicts detected:
</p>
<ul className="text-xs text-amber-700 dark:text-amber-300 list-disc pl-4 space-y-0.5">
{conflicts.map((c, i) => (
<li key={i}>{c}</li>
))}
</ul>
<Button
variant="outline"
size="sm"
onClick={() => {
setConflicts(null);
handleRevert(true);
}}
disabled={revertMutation.isPending}
>
Force Revert
</Button>
</div>
)}
{expanded && (
<div className="pl-5 space-y-1 text-xs text-muted-foreground">
{detailQuery.isLoading && <p>Loading entries...</p>}
{entries.map((entry) => (
<div key={entry.id} className="flex items-center gap-2">
<span className="font-mono">
{entry.action === "create" ? "+" : entry.action === "delete" ? "-" : "~"}
</span>
<span>
{entry.entityType}
{entry.newState && (() => {
try {
const parsed = JSON.parse(entry.newState);
return parsed.name || parsed.title ? `: ${parsed.name || parsed.title}` : "";
} catch { return ""; }
})()}
</span>
</div>
))}
{entries.length === 0 && !detailQuery.isLoading && (
<p>No entries</p>
)}
</div>
)}
</div>
);
}

View File

@@ -10,6 +10,13 @@ import {
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { toast } from "sonner";
import { trpc } from "@/lib/trpc";
import { ProjectPicker } from "./ProjectPicker";
@@ -25,6 +32,8 @@ export function CreateInitiativeDialog({
}: CreateInitiativeDialogProps) {
const [name, setName] = useState("");
const [projectIds, setProjectIds] = useState<string[]>([]);
const [executionMode, setExecutionMode] = useState<"yolo" | "review_per_phase">("review_per_phase");
const [mergeTarget, setMergeTarget] = useState("");
const [error, setError] = useState<string | null>(null);
const utils = trpc.useUtils();
@@ -63,6 +72,8 @@ export function CreateInitiativeDialog({
if (open) {
setName("");
setProjectIds([]);
setExecutionMode("review_per_phase");
setMergeTarget("");
setError(null);
}
}, [open]);
@@ -73,6 +84,8 @@ export function CreateInitiativeDialog({
createMutation.mutate({
name: name.trim(),
projectIds: projectIds.length > 0 ? projectIds : undefined,
executionMode,
mergeTarget: mergeTarget.trim() || null,
});
}
@@ -98,6 +111,32 @@ export function CreateInitiativeDialog({
autoFocus
/>
</div>
<div className="space-y-2">
<Label>Execution Mode</Label>
<Select value={executionMode} onValueChange={(v) => setExecutionMode(v as "yolo" | "review_per_phase")}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="review_per_phase">Review per Phase</SelectItem>
<SelectItem value="yolo">YOLO (auto-merge)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="merge-target">
Merge Target Branch{" "}
<span className="text-muted-foreground font-normal">
(optional)
</span>
</Label>
<Input
id="merge-target"
placeholder="e.g. feat/auth"
value={mergeTarget}
onChange={(e) => setMergeTarget(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>
Projects{" "}

View File

@@ -22,6 +22,7 @@ interface ExecutionTabProps {
phasesLoading: boolean;
phasesLoaded: boolean;
dependencyEdges: DependencyEdge[];
mergeTarget?: string | null;
}
export function ExecutionTab({
@@ -30,6 +31,7 @@ export function ExecutionTab({
phasesLoading,
phasesLoaded,
dependencyEdges,
mergeTarget,
}: ExecutionTabProps) {
// Topological sort
const sortedPhases = useMemo(
@@ -257,6 +259,7 @@ export function ExecutionTab({
tasksLoading={allTasksQuery.isLoading}
onDelete={() => deletePhase.mutate({ id: activePhase.id })}
decomposeAgent={decomposeAgentByPhase.get(activePhase.id) ?? null}
mergeTarget={mergeTarget}
/>
) : (
<PhaseDetailEmpty />

View File

@@ -1,5 +1,5 @@
import { useState } from "react";
import { ChevronLeft, Pencil, Check } from "lucide-react";
import { ChevronLeft, Pencil, Check, GitBranch } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { StatusBadge } from "@/components/StatusBadge";
@@ -12,6 +12,8 @@ export interface InitiativeHeaderProps {
id: string;
name: string;
status: string;
executionMode?: string;
mergeTarget?: string | null;
};
projects?: Array<{ id: string; name: string; url: string }>;
onBack: () => void;
@@ -60,6 +62,24 @@ export function InitiativeHeader({
</Button>
<h1 className="text-xl font-semibold">{initiative.name}</h1>
<StatusBadge status={initiative.status} />
{initiative.executionMode && (
<Badge
variant="outline"
className={
initiative.executionMode === "yolo"
? "border-orange-300 text-orange-700 text-[10px]"
: "border-blue-300 text-blue-700 text-[10px]"
}
>
{initiative.executionMode === "yolo" ? "YOLO" : "REVIEW"}
</Badge>
)}
{initiative.mergeTarget && (
<Badge variant="outline" className="gap-1 text-[10px] font-mono">
<GitBranch className="h-3 w-3" />
{initiative.mergeTarget}
</Badge>
)}
{!editing && projects && projects.length > 0 && (
<>
{projects.map((p) => (

View File

@@ -12,6 +12,8 @@ const statusStyles: Record<string, string> = {
approved: "bg-amber-100 text-amber-800 hover:bg-amber-100/80 border-amber-200",
in_progress: "bg-blue-100 text-blue-800 hover:bg-blue-100/80 border-blue-200",
blocked: "bg-red-100 text-red-800 hover:bg-red-100/80 border-red-200",
pending_review: "bg-amber-100 text-amber-800 hover:bg-amber-100/80 border-amber-200",
pending_approval: "bg-amber-100 text-amber-800 hover:bg-amber-100/80 border-amber-200",
};
const defaultStyle = "bg-gray-100 text-gray-800 hover:bg-gray-100/80 border-gray-200";

View File

@@ -1,234 +0,0 @@
import { useState, useCallback, useMemo } from "react";
import { Check, ChevronDown, ChevronRight, AlertTriangle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { trpc } from "@/lib/trpc";
import type { Proposal } from "@codewalk-district/shared";
interface ContentProposalReviewProps {
proposals: Proposal[];
agentCreatedAt: Date;
agentId: string;
onDismiss: () => void;
}
export function ContentProposalReview({
proposals,
agentCreatedAt,
agentId,
onDismiss,
}: ContentProposalReviewProps) {
const [accepted, setAccepted] = useState<Set<string>>(new Set());
const [acceptError, setAcceptError] = useState<string | null>(null);
const utils = trpc.useUtils();
const acceptMutation = trpc.acceptProposal.useMutation({
onMutate: async ({ id }) => {
await utils.listProposals.cancel({ agentId });
const previousProposals = utils.listProposals.getData({ agentId });
utils.listProposals.setData({ agentId }, (old = []) =>
old.map(p => p.id === id ? { ...p, status: 'accepted' as const } : p)
);
return { previousProposals };
},
onSuccess: () => {
setAcceptError(null);
},
onError: (err, _variables, context) => {
if (context?.previousProposals) {
utils.listProposals.setData({ agentId }, context.previousProposals);
}
setAcceptError(err.message);
},
});
const acceptAllMutation = trpc.acceptAllProposals.useMutation({
onSuccess: (result) => {
if (result.failed > 0) {
setAcceptError(`${result.failed} proposal(s) failed: ${result.errors.join('; ')}`);
} else {
setAcceptError(null);
onDismiss();
}
},
});
const dismissAllMutation = trpc.dismissAllProposals.useMutation({
onMutate: async () => {
await utils.listProposals.cancel({ agentId });
const previousProposals = utils.listProposals.getData({ agentId });
utils.listProposals.setData({ agentId }, []);
return { previousProposals };
},
onError: (_err, _variables, context) => {
if (context?.previousProposals) {
utils.listProposals.setData({ agentId }, context.previousProposals);
}
},
});
const handleAccept = useCallback(
async (proposal: Proposal) => {
await acceptMutation.mutateAsync({ id: proposal.id });
setAccepted((prev) => new Set(prev).add(proposal.id));
},
[acceptMutation],
);
const handleAcceptAll = useCallback(async () => {
await acceptAllMutation.mutateAsync({ agentId });
}, [acceptAllMutation, agentId]);
const handleDismissAll = useCallback(() => {
dismissAllMutation.mutate({ agentId });
}, [dismissAllMutation, agentId]);
// Batch-fetch page updatedAt timestamps for staleness check (eliminates N+1)
const pageTargetIds = useMemo(() => {
const ids = new Set<string>();
for (const p of proposals) {
if (p.targetType === 'page' && p.targetId) ids.add(p.targetId);
}
return [...ids];
}, [proposals]);
const pageUpdatedAtMap = trpc.getPageUpdatedAtMap.useQuery(
{ ids: pageTargetIds },
{ enabled: pageTargetIds.length > 0 },
);
const allAccepted = proposals.every((p) => accepted.has(p.id) || p.status === 'accepted');
return (
<div className="rounded-lg border border-border bg-card p-4 space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold">
Agent Proposals ({proposals.length})
</h3>
<div className="flex gap-2">
{!allAccepted && (
<Button
variant="outline"
size="sm"
onClick={handleAcceptAll}
disabled={acceptAllMutation.isPending}
>
Accept All
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={handleDismissAll}
disabled={dismissAllMutation.isPending}
>
{dismissAllMutation.isPending ? "Dismissing..." : "Dismiss"}
</Button>
</div>
</div>
{acceptError && (
<div className="flex items-center gap-1.5 text-xs text-destructive bg-destructive/10 rounded px-2 py-1.5">
<AlertTriangle className="h-3 w-3 shrink-0" />
<span>{acceptError}</span>
</div>
)}
<div className="space-y-2">
{proposals.map((proposal) => (
<ProposalCard
key={proposal.id}
proposal={proposal}
isAccepted={accepted.has(proposal.id) || proposal.status === 'accepted'}
agentCreatedAt={agentCreatedAt}
pageUpdatedAt={proposal.targetType === 'page' && proposal.targetId
? pageUpdatedAtMap.data?.[proposal.targetId] ?? null
: null}
onAccept={() => handleAccept(proposal)}
isAccepting={acceptMutation.isPending}
/>
))}
</div>
</div>
);
}
interface ProposalCardProps {
proposal: Proposal;
isAccepted: boolean;
agentCreatedAt: Date;
pageUpdatedAt: string | null;
onAccept: () => void;
isAccepting: boolean;
}
function ProposalCard({
proposal,
isAccepted,
agentCreatedAt,
pageUpdatedAt,
onAccept,
isAccepting,
}: ProposalCardProps) {
const [expanded, setExpanded] = useState(false);
const isStale =
proposal.targetType === 'page' &&
pageUpdatedAt && new Date(pageUpdatedAt) > agentCreatedAt;
return (
<div className="rounded border border-border p-3 space-y-2">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<button
className="flex items-center gap-1 text-sm font-medium hover:text-foreground/80"
onClick={() => setExpanded(!expanded)}
>
{expanded ? (
<ChevronDown className="h-3.5 w-3.5 shrink-0" />
) : (
<ChevronRight className="h-3.5 w-3.5 shrink-0" />
)}
{proposal.title}
</button>
{proposal.summary && (
<p className="text-xs text-muted-foreground mt-0.5 pl-5">
{proposal.summary}
</p>
)}
</div>
{isAccepted ? (
<div className="flex items-center gap-1 text-xs text-green-600 shrink-0">
<Check className="h-3.5 w-3.5" />
Accepted
</div>
) : (
<Button
variant="outline"
size="sm"
onClick={onAccept}
disabled={isAccepting}
className="shrink-0"
>
Accept
</Button>
)}
</div>
{isStale && !isAccepted && (
<div className="flex items-center gap-1.5 text-xs text-yellow-600 pl-5">
<AlertTriangle className="h-3 w-3" />
Content was modified since agent started
</div>
)}
{expanded && proposal.content && (
<div className="pl-5 pt-1">
<div className="prose prose-sm max-w-none rounded bg-muted/50 p-3 text-xs overflow-auto max-h-64">
<pre className="whitespace-pre-wrap text-xs">{proposal.content}</pre>
</div>
</div>
)}
</div>
);
}

View File

@@ -2,7 +2,7 @@ import { useCallback, useEffect } from "react";
import { Loader2, AlertCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { QuestionForm } from "@/components/QuestionForm";
import { ContentProposalReview } from "./ContentProposalReview";
import { ChangeSetBanner } from "@/components/ChangeSetBanner";
import { RefineSpawnDialog } from "../RefineSpawnDialog";
import { useRefineAgent } from "@/hooks";
@@ -12,7 +12,7 @@ interface RefineAgentPanelProps {
export function RefineAgentPanel({ initiativeId }: RefineAgentPanelProps) {
// All agent logic is now encapsulated in the hook
const { state, agent, questions, proposals, spawn, resume, stop, dismiss, refresh } = useRefineAgent(initiativeId);
const { state, agent, questions, changeSet, spawn, resume, stop, dismiss, refresh } = useRefineAgent(initiativeId);
// spawn.mutate and resume.mutate are stable (ref-backed in useRefineAgent),
// so these callbacks won't change on every render.
@@ -95,26 +95,24 @@ export function RefineAgentPanel({ initiativeId }: RefineAgentPanelProps) {
);
}
// Completed with proposals
if (state === "completed" && proposals && proposals.length > 0) {
// Completed with change set
if (state === "completed" && changeSet) {
return (
<div className="mb-3">
<ContentProposalReview
proposals={proposals}
agentCreatedAt={new Date(agent!.createdAt)}
agentId={agent!.id}
<ChangeSetBanner
changeSet={changeSet}
onDismiss={handleDismiss}
/>
</div>
);
}
// Completed without proposals (or generic result)
// Completed without changes
if (state === "completed") {
return (
<div className="mb-3 flex items-center gap-2 rounded-lg border border-border bg-card px-3 py-2">
<span className="text-sm text-muted-foreground">
Agent completed no changes proposed.
Agent completed no changes made.
</span>
<Button variant="ghost" size="sm" onClick={handleDismiss}>
Dismiss

View File

@@ -3,7 +3,7 @@ import { Loader2, Plus, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import { trpc } from "@/lib/trpc";
import { useSpawnMutation } from "@/hooks/useSpawnMutation";
import { ContentProposalReview } from "@/components/editor/ContentProposalReview";
import { ChangeSetBanner } from "@/components/ChangeSetBanner";
interface BreakdownSectionProps {
initiativeId: string;
@@ -38,14 +38,14 @@ export function BreakdownSection({
const isBreakdownRunning = breakdownAgent?.status === "running";
// Query proposals when we have a completed breakdown agent
const proposalsQuery = trpc.listProposals.useQuery(
// Query change sets when we have a completed breakdown agent
const changeSetsQuery = trpc.listChangeSets.useQuery(
{ agentId: breakdownAgent?.id ?? "" },
{ enabled: !!breakdownAgent && breakdownAgent.status === "idle" },
);
const pendingProposals = useMemo(
() => (proposalsQuery.data ?? []).filter((p) => p.status === "pending"),
[proposalsQuery.data],
const latestChangeSet = useMemo(
() => (changeSetsQuery.data ?? []).find((cs) => cs.status === "applied") ?? null,
[changeSetsQuery.data],
);
const dismissMutation = trpc.dismissAgent.useMutation();
@@ -68,19 +68,17 @@ export function BreakdownSection({
return null;
}
// If phases exist and no pending proposals to review, hide section
if (phases.length > 0 && pendingProposals.length === 0) {
// If phases exist and no change set to show, hide section
if (phases.length > 0 && !latestChangeSet) {
return null;
}
// Show proposal review when breakdown agent completed with pending proposals
if (breakdownAgent?.status === "idle" && pendingProposals.length > 0) {
// Show change set banner when breakdown agent completed
if (breakdownAgent?.status === "idle" && latestChangeSet) {
return (
<div className="py-4">
<ContentProposalReview
proposals={pendingProposals}
agentCreatedAt={new Date(breakdownAgent.createdAt)}
agentId={breakdownAgent.id}
<ChangeSetBanner
changeSet={latestChangeSet}
onDismiss={handleDismiss}
/>
</div>

View File

@@ -1,11 +1,11 @@
import { useEffect, useState, useRef, useMemo, useCallback } from "react";
import { Loader2, MoreHorizontal, Plus, Sparkles, Trash2, X } from "lucide-react";
import { GitBranch, Loader2, MoreHorizontal, Plus, Sparkles, Trash2, X } from "lucide-react";
import { toast } from "sonner";
import { trpc } from "@/lib/trpc";
import { StatusBadge } from "@/components/StatusBadge";
import { TaskRow, type SerializedTask } from "@/components/TaskRow";
import { PhaseContentEditor } from "@/components/editor/PhaseContentEditor";
import { ContentProposalReview } from "@/components/editor/ContentProposalReview";
import { ChangeSetBanner } from "@/components/ChangeSetBanner";
import { Skeleton } from "@/components/Skeleton";
import { Button } from "@/components/ui/button";
import {
@@ -36,6 +36,7 @@ interface PhaseDetailPanelProps {
tasks: SerializedTask[];
tasksLoading: boolean;
onDelete?: () => void;
mergeTarget?: string | null;
decomposeAgent: {
id: string;
status: string;
@@ -52,6 +53,7 @@ export function PhaseDetailPanel({
tasks,
tasksLoading,
onDelete,
mergeTarget,
decomposeAgent,
}: PhaseDetailPanelProps) {
const { setSelectedTaskId, handleTaskCounts, handleRegisterTasks } =
@@ -135,14 +137,14 @@ export function PhaseDetailPanel({
handleRegisterTasks(phase.id, entries);
}, [tasks, phase.id, displayIndex, phase.name, handleTaskCounts, handleRegisterTasks]);
// --- Proposals for decompose agent ---
const proposalsQuery = trpc.listProposals.useQuery(
// --- Change sets for decompose agent ---
const changeSetsQuery = trpc.listChangeSets.useQuery(
{ agentId: decomposeAgent?.id ?? "" },
{ enabled: !!decomposeAgent && decomposeAgent.status === "idle" },
);
const pendingProposals = useMemo(
() => (proposalsQuery.data ?? []).filter((p) => p.status === "pending"),
[proposalsQuery.data],
const latestChangeSet = useMemo(
() => (changeSetsQuery.data ?? []).find((cs) => cs.status === "applied") ?? null,
[changeSetsQuery.data],
);
// --- Decompose spawn ---
@@ -152,13 +154,20 @@ export function PhaseDetailPanel({
decomposeMutation.mutate({ phaseId: phase.id });
}, [phase.id, decomposeMutation]);
// --- Dismiss handler for proposal review ---
// --- Dismiss handler for decompose agent ---
const dismissMutation = trpc.dismissAgent.useMutation();
const handleDismissDecompose = useCallback(() => {
if (!decomposeAgent) return;
dismissMutation.mutate({ id: decomposeAgent.id });
}, [decomposeAgent, dismissMutation]);
// Compute phase branch name if initiative has a merge target
const phaseBranch = mergeTarget
? `${mergeTarget}-phase-${phase.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")}`
: null;
const isPendingReview = phase.status === "pending_review";
const sortedTasks = sortByPriorityAndQueueTime(tasks);
const hasTasks = tasks.length > 0;
const isDecomposeRunning =
@@ -166,8 +175,8 @@ export function PhaseDetailPanel({
decomposeAgent?.status === "waiting_for_input";
const showBreakdownButton =
!decomposeAgent && !hasTasks;
const showProposals =
decomposeAgent?.status === "idle" && pendingProposals.length > 0;
const showChangeSet =
decomposeAgent?.status === "idle" && !!latestChangeSet;
return (
<div className="space-y-6">
@@ -198,6 +207,12 @@ export function PhaseDetailPanel({
</h3>
)}
<StatusBadge status={phase.status} />
{phaseBranch && ["in_progress", "completed", "pending_review"].includes(phase.status) && (
<span className="inline-flex items-center gap-1 rounded-md border border-border px-2 py-0.5 text-[10px] font-mono text-muted-foreground">
<GitBranch className="h-3 w-3" />
{phaseBranch}
</span>
)}
{/* Breakdown button in header */}
{showBreakdownButton && (
@@ -243,6 +258,16 @@ export function PhaseDetailPanel({
</DropdownMenu>
</div>
{/* Pending review banner */}
{isPendingReview && (
<div className="rounded-md border border-amber-200 bg-amber-50 px-4 py-3 dark:border-amber-800 dark:bg-amber-950">
<p className="text-sm font-medium text-amber-800 dark:text-amber-200">
This phase is pending review. Switch to the{" "}
<span className="font-semibold">Review</span> tab to view the diff and approve.
</p>
</div>
)}
{/* Tiptap Editor */}
<PhaseContentEditor phaseId={phase.id} initiativeId={initiativeId} />
@@ -317,12 +342,10 @@ export function PhaseDetailPanel({
)}
</div>
{/* Decompose proposals */}
{showProposals && (
<ContentProposalReview
proposals={pendingProposals as any}
agentCreatedAt={new Date(decomposeAgent!.createdAt)}
agentId={decomposeAgent!.id}
{/* Decompose change set */}
{showChangeSet && (
<ChangeSetBanner
changeSet={latestChangeSet!}
onDismiss={handleDismissDecompose}
/>
)}

View File

@@ -1,6 +1,7 @@
import { useState, useCallback, useRef } from "react";
import { useState, useCallback, useMemo, useRef } from "react";
import { toast } from "sonner";
import { DUMMY_REVIEW } from "./dummy-data";
import { trpc } from "@/lib/trpc";
import { parseUnifiedDiff } from "./parse-diff";
import { DiffViewer } from "./DiffViewer";
import { ReviewSidebar } from "./ReviewSidebar";
import type { ReviewComment, ReviewStatus, DiffLine } from "./types";
@@ -9,11 +10,44 @@ interface ReviewTabProps {
initiativeId: string;
}
export function ReviewTab({ initiativeId: _initiativeId }: ReviewTabProps) {
const [comments, setComments] = useState<ReviewComment[]>(DUMMY_REVIEW.comments);
const [status, setStatus] = useState<ReviewStatus>(DUMMY_REVIEW.status);
export function ReviewTab({ initiativeId }: ReviewTabProps) {
const [comments, setComments] = useState<ReviewComment[]>([]);
const [status, setStatus] = useState<ReviewStatus>("pending");
const fileRefs = useRef<Map<string, HTMLDivElement>>(new Map());
// Fetch phases for this initiative
const phasesQuery = trpc.listPhases.useQuery({ initiativeId });
const pendingReviewPhases = useMemo(
() => (phasesQuery.data ?? []).filter((p) => p.status === "pending_review"),
[phasesQuery.data],
);
// Select first pending review phase
const [selectedPhaseId, setSelectedPhaseId] = useState<string | null>(null);
const activePhaseId = selectedPhaseId ?? pendingReviewPhases[0]?.id ?? null;
// Fetch diff for active phase
const diffQuery = trpc.getPhaseReviewDiff.useQuery(
{ phaseId: activePhaseId! },
{ enabled: !!activePhaseId },
);
const approveMutation = trpc.approvePhaseReview.useMutation({
onSuccess: () => {
setStatus("approved");
toast.success("Phase approved and merged");
phasesQuery.refetch();
},
onError: (err) => {
toast.error(err.message);
},
});
const files = useMemo(() => {
if (!diffQuery.data?.rawDiff) return [];
return parseUnifiedDiff(diffQuery.data.rawDiff);
}, [diffQuery.data?.rawDiff]);
const handleAddComment = useCallback(
(filePath: string, lineNumber: number, lineType: DiffLine["type"], body: string) => {
const newComment: ReviewComment = {
@@ -45,9 +79,9 @@ export function ReviewTab({ initiativeId: _initiativeId }: ReviewTabProps) {
}, []);
const handleApprove = useCallback(() => {
setStatus("approved");
toast.success("Review approved");
}, []);
if (!activePhaseId) return;
approveMutation.mutate({ phaseId: activePhaseId });
}, [activePhaseId, approveMutation]);
const handleRequestChanges = useCallback(() => {
setStatus("changes_requested");
@@ -63,42 +97,85 @@ export function ReviewTab({ initiativeId: _initiativeId }: ReviewTabProps) {
}
}, []);
return (
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[1fr_300px]">
{/* Left: Diff */}
<div className="min-w-0">
<div className="flex items-center justify-between border-b border-border pb-3 mb-4">
<h2 className="text-lg font-semibold">Review</h2>
<span className="text-xs text-muted-foreground">
{comments.filter((c) => !c.resolved).length} unresolved thread
{comments.filter((c) => !c.resolved).length !== 1 ? "s" : ""}
</span>
</div>
<DiffViewer
files={DUMMY_REVIEW.files}
comments={comments}
onAddComment={handleAddComment}
onResolveComment={handleResolveComment}
onUnresolveComment={handleUnresolveComment}
/>
if (pendingReviewPhases.length === 0) {
return (
<div className="flex h-64 items-center justify-center text-muted-foreground">
<p>No phases pending review</p>
</div>
);
}
{/* Right: Sidebar */}
<div className="w-full lg:w-[300px]">
<ReviewSidebar
title={DUMMY_REVIEW.title}
description={DUMMY_REVIEW.description}
author={DUMMY_REVIEW.author}
status={status}
sourceBranch={DUMMY_REVIEW.sourceBranch}
targetBranch={DUMMY_REVIEW.targetBranch}
files={DUMMY_REVIEW.files}
comments={comments}
onApprove={handleApprove}
onRequestChanges={handleRequestChanges}
onFileClick={handleFileClick}
/>
</div>
const activePhaseName = diffQuery.data?.phaseName ?? pendingReviewPhases.find(p => p.id === activePhaseId)?.name ?? "Phase";
return (
<div className="space-y-4">
{/* Phase selector if multiple pending */}
{pendingReviewPhases.length > 1 && (
<div className="flex gap-2">
{pendingReviewPhases.map((phase) => (
<button
key={phase.id}
onClick={() => setSelectedPhaseId(phase.id)}
className={`rounded-md border px-3 py-1.5 text-sm transition-colors ${
phase.id === activePhaseId
? "border-primary bg-primary/10 font-medium"
: "border-border hover:bg-muted"
}`}
>
{phase.name}
</button>
))}
</div>
)}
{diffQuery.isLoading ? (
<div className="flex h-64 items-center justify-center text-muted-foreground">
Loading diff...
</div>
) : (
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[1fr_300px]">
{/* Left: Diff */}
<div className="min-w-0">
<div className="flex items-center justify-between border-b border-border pb-3 mb-4">
<h2 className="text-lg font-semibold">Review: {activePhaseName}</h2>
<span className="text-xs text-muted-foreground">
{comments.filter((c) => !c.resolved).length} unresolved thread
{comments.filter((c) => !c.resolved).length !== 1 ? "s" : ""}
</span>
</div>
{files.length === 0 ? (
<div className="flex h-32 items-center justify-center text-muted-foreground">
No changes in this phase
</div>
) : (
<DiffViewer
files={files}
comments={comments}
onAddComment={handleAddComment}
onResolveComment={handleResolveComment}
onUnresolveComment={handleUnresolveComment}
/>
)}
</div>
{/* Right: Sidebar */}
<div className="w-full lg:w-[300px]">
<ReviewSidebar
title={`Phase: ${activePhaseName}`}
description={`Review changes from phase "${activePhaseName}" before merging into the initiative branch.`}
author="system"
status={status}
sourceBranch={diffQuery.data?.sourceBranch ?? ""}
targetBranch={diffQuery.data?.targetBranch ?? ""}
files={files}
comments={comments}
onApprove={handleApprove}
onRequestChanges={handleRequestChanges}
onFileClick={handleFileClick}
/>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,158 @@
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import { cn } from "@/lib/utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}

View File

@@ -1,6 +1,6 @@
import { useCallback, useMemo, useRef } from 'react';
import { trpc } from '@/lib/trpc';
import type { PendingQuestions, Proposal } from '@codewalk-district/shared';
import type { PendingQuestions, ChangeSet } from '@codewalk-district/shared';
export type RefineAgentState = 'none' | 'running' | 'waiting' | 'completed' | 'crashed';
@@ -18,8 +18,8 @@ export interface UseRefineAgentResult {
state: RefineAgentState;
/** Questions from the agent (when state is 'waiting') */
questions: PendingQuestions | null;
/** Proposal rows from the DB (when state is 'completed') */
proposals: Proposal[] | null;
/** Latest applied change set (when state is 'completed') */
changeSet: ChangeSet | null;
/** Raw result message (when state is 'completed') */
result: string | null;
/** Mutation for spawning a new refine agent */
@@ -82,8 +82,8 @@ export function useRefineAgent(initiativeId: string): UseRefineAgentResult {
{ enabled: state === 'waiting' && !!agent },
);
// Fetch proposals from DB when completed
const proposalsQuery = trpc.listProposals.useQuery(
// Fetch change sets from DB when completed
const changeSetsQuery = trpc.listChangeSets.useQuery(
{ agentId: agent?.id ?? '' },
{ enabled: state === 'completed' && !!agent },
);
@@ -94,12 +94,11 @@ export function useRefineAgent(initiativeId: string): UseRefineAgentResult {
{ enabled: state === 'completed' && !!agent },
);
// Filter to only pending proposals
const proposals = useMemo(() => {
if (!proposalsQuery.data || proposalsQuery.data.length === 0) return null;
const pending = proposalsQuery.data.filter((p) => p.status === 'pending');
return pending.length > 0 ? pending : null;
}, [proposalsQuery.data]);
// Get latest applied change set
const changeSet = useMemo(() => {
if (!changeSetsQuery.data || changeSetsQuery.data.length === 0) return null;
return changeSetsQuery.data.find((cs) => cs.status === 'applied') ?? null;
}, [changeSetsQuery.data]);
const result = useMemo(() => {
if (!resultQuery.data?.success || !resultQuery.data.message) return null;
@@ -182,9 +181,7 @@ export function useRefineAgent(initiativeId: string): UseRefineAgentResult {
return { previousAgents };
},
onSuccess: () => {
void utils.listProposals.invalidate();
},
onSuccess: () => {},
onError: (err, variables, context) => {
// Revert optimistic update
if (context?.previousAgents) {
@@ -256,18 +253,18 @@ export function useRefineAgent(initiativeId: string): UseRefineAgentResult {
const refresh = useCallback(() => {
void utils.getActiveRefineAgent.invalidate({ initiativeId });
void utils.listProposals.invalidate();
void utils.listChangeSets.invalidate();
}, [utils, initiativeId]);
const isLoading = agentQuery.isLoading ||
(state === 'waiting' && questionsQuery.isLoading) ||
(state === 'completed' && (resultQuery.isLoading || proposalsQuery.isLoading));
(state === 'completed' && (resultQuery.isLoading || changeSetsQuery.isLoading));
return {
agent,
state,
questions: questionsQuery.data ?? null,
proposals,
changeSet,
result,
spawn,
resume,

View File

@@ -35,7 +35,7 @@ const INVALIDATION_MAP: Partial<Record<MutationName, QueryName[]>> = {
// --- Agents ---
stopAgent: ["listAgents", "listWaitingAgents", "listMessages"],
deleteAgent: ["listAgents"],
dismissAgent: ["listAgents", "listProposals"],
dismissAgent: ["listAgents"],
resumeAgent: ["listAgents", "listWaitingAgents", "listMessages"],
respondToMessage: ["listWaitingAgents", "listMessages"],
@@ -66,10 +66,8 @@ const INVALIDATION_MAP: Partial<Record<MutationName, QueryName[]>> = {
queueTask: ["listTasks", "listInitiativeTasks", "listPhaseTasks"],
approveTask: ["listTasks", "listInitiativeTasks", "listPhaseTasks", "listPendingApprovals"],
// --- Proposals ---
acceptProposal: ["listProposals", "listPages", "getPage", "listAgents", "listPhases", "listTasks"],
acceptAllProposals: ["listProposals", "listPages", "getPage", "listAgents", "listPhases", "listTasks"],
dismissAllProposals: ["listProposals", "listAgents"],
// --- Change Sets ---
revertChangeSet: ["listPhases", "listPhaseTasks", "listInitiativeTasks", "listPages", "getPage", "listChangeSets", "getRootPage"],
// --- Pages ---
updatePage: ["listPages", "getPage", "getRootPage"],

View File

@@ -89,6 +89,8 @@ function InitiativeDetailPage() {
id: initiative.id,
name: initiative.name,
status: initiative.status,
executionMode: (initiative as any).executionMode as string | undefined,
mergeTarget: (initiative as any).mergeTarget as string | null | undefined,
};
const projects = (initiative as { projects?: Array<{ id: string; name: string; url: string }> }).projects;
@@ -135,6 +137,7 @@ function InitiativeDetailPage() {
phasesLoading={phasesQuery.isLoading}
phasesLoaded={phasesQuery.isSuccess}
dependencyEdges={depsQuery.data ?? []}
mergeTarget={serializedInitiative.mergeTarget}
/>
)}
{activeTab === "execution" && (