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:
@@ -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",
|
||||
|
||||
139
packages/web/src/components/ChangeSetBanner.tsx
Normal file
139
packages/web/src/components/ChangeSetBanner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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{" "}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
158
packages/web/src/components/ui/select.tsx
Normal file
158
packages/web/src/components/ui/select.tsx
Normal 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,
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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" && (
|
||||
|
||||
Reference in New Issue
Block a user