Files
Codewalkers/apps/web/src/components/InitiativeCard.tsx
Lukas May e3246baf51 feat: Show resolving_conflict activity state on initiative cards
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
2026-03-06 13:32:37 +01:00

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>
);
}