Add 'resolving_conflict' to InitiativeActivityState and detect active conflict agents (name starts with conflict-) in deriveInitiativeActivity. Conflict resolution takes priority over pending_review since the agent is actively working. - Add resolving_conflict to shared types and activity derivation - Include conflict agents in listInitiatives agent filter (name + mode) - Map resolving_conflict to urgent variant with pulse in InitiativeCard - Add merge: prefix to INITIATIVE_LIST_RULES for merge event routing - Add spawnConflictResolutionAgent to INVALIDATION_MAP - Add getActiveConflictAgent to detail page agent: SSE invalidation
158 lines
5.6 KiB
TypeScript
158 lines
5.6 KiB
TypeScript
import { MoreHorizontal } from "lucide-react";
|
|
import { Card } from "@/components/ui/card";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
DropdownMenuSeparator,
|
|
} from "@/components/ui/dropdown-menu";
|
|
import { StatusDot, type StatusVariant } from "@/components/StatusDot";
|
|
import { ProgressBar } from "@/components/ProgressBar";
|
|
import { trpc } from "@/lib/trpc";
|
|
|
|
/** Initiative shape as returned by tRPC (Date serialized to string over JSON) */
|
|
export interface SerializedInitiative {
|
|
id: string;
|
|
name: string;
|
|
status: "active" | "completed" | "archived";
|
|
branch: string | null;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
projects?: Array<{ id: string; name: string }>;
|
|
activity: {
|
|
state: string;
|
|
activePhase?: { id: string; name: string };
|
|
phasesTotal: number;
|
|
phasesCompleted: number;
|
|
};
|
|
}
|
|
|
|
function activityVisual(state: string): { label: string; variant: StatusVariant; pulse: boolean } {
|
|
switch (state) {
|
|
case "executing": return { label: "Executing", variant: "active", pulse: true };
|
|
case "pending_review": return { label: "Pending Review", variant: "warning", pulse: true };
|
|
case "discussing": return { label: "Discussing", variant: "active", pulse: true };
|
|
case "detailing": return { label: "Detailing", variant: "active", pulse: true };
|
|
case "refining": return { label: "Refining", variant: "active", pulse: true };
|
|
case "resolving_conflict": return { label: "Resolving Conflict", variant: "urgent", pulse: true };
|
|
case "ready": return { label: "Ready", variant: "active", pulse: false };
|
|
case "blocked": return { label: "Blocked", variant: "error", pulse: false };
|
|
case "complete": return { label: "Complete", variant: "success", pulse: false };
|
|
case "planning": return { label: "Planning", variant: "neutral", pulse: false };
|
|
case "archived": return { label: "Archived", variant: "neutral", pulse: false };
|
|
default: return { label: "Idle", variant: "neutral", pulse: false };
|
|
}
|
|
}
|
|
|
|
interface InitiativeCardProps {
|
|
initiative: SerializedInitiative;
|
|
onClick: () => void;
|
|
}
|
|
|
|
export function InitiativeCard({ initiative, onClick }: InitiativeCardProps) {
|
|
const utils = trpc.useUtils();
|
|
const archiveMutation = trpc.updateInitiative.useMutation({
|
|
onSuccess: () => utils.listInitiatives.invalidate(),
|
|
});
|
|
const deleteMutation = trpc.deleteInitiative.useMutation({
|
|
onSuccess: () => utils.listInitiatives.invalidate(),
|
|
});
|
|
|
|
function handleArchive(e: React.MouseEvent) {
|
|
if (
|
|
!e.shiftKey &&
|
|
!window.confirm(`Archive "${initiative.name}"?`)
|
|
) {
|
|
return;
|
|
}
|
|
archiveMutation.mutate({ id: initiative.id, status: "archived" });
|
|
}
|
|
|
|
function handleDelete(e: React.MouseEvent) {
|
|
if (
|
|
!e.shiftKey &&
|
|
!window.confirm(`Delete "${initiative.name}"? This cannot be undone.`)
|
|
) {
|
|
return;
|
|
}
|
|
deleteMutation.mutate({ id: initiative.id });
|
|
}
|
|
|
|
const { activity } = initiative;
|
|
const visual = activityVisual(activity.state);
|
|
|
|
return (
|
|
<Card
|
|
interactive
|
|
className="p-4"
|
|
onClick={onClick}
|
|
>
|
|
{/* Row 1: Name + project pills + overflow menu */}
|
|
<div className="flex items-center justify-between gap-2">
|
|
<div className="flex min-w-0 items-center gap-2">
|
|
<span className="shrink-0 text-base font-bold">
|
|
{initiative.name}
|
|
</span>
|
|
{initiative.projects && initiative.projects.length > 0 &&
|
|
initiative.projects.map((p) => (
|
|
<Badge key={p.id} variant="outline" size="xs" className="shrink-0 font-normal">
|
|
{p.name}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
<div onClick={(e) => e.stopPropagation()}>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="ghost" size="icon" className="h-7 w-7">
|
|
<MoreHorizontal className="h-4 w-4" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end">
|
|
<DropdownMenuItem onClick={handleArchive}>Archive</DropdownMenuItem>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem
|
|
className="text-destructive"
|
|
onClick={handleDelete}
|
|
>
|
|
Delete
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Row 2: Activity dot + label + active phase + progress */}
|
|
<div className="mt-1.5 flex items-center gap-3">
|
|
<StatusDot
|
|
status={activity.state}
|
|
variant={visual.variant}
|
|
size="sm"
|
|
pulse={visual.pulse}
|
|
label={visual.label}
|
|
/>
|
|
<span className="text-sm font-medium">{visual.label}</span>
|
|
{activity.activePhase && (
|
|
<span className="truncate text-sm text-muted-foreground">
|
|
{activity.activePhase.name}
|
|
</span>
|
|
)}
|
|
{activity.phasesTotal > 0 && (
|
|
<>
|
|
<ProgressBar
|
|
completed={activity.phasesCompleted}
|
|
total={activity.phasesTotal}
|
|
className="ml-auto w-24"
|
|
/>
|
|
<span className="hidden text-xs text-muted-foreground md:inline">
|
|
{activity.phasesCompleted}/{activity.phasesTotal}
|
|
</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
</Card>
|
|
);
|
|
}
|