Submatter/apps/web/app/(app)/[workspaceSlug]/plans/page.tsx

656 lines
32 KiB
TypeScript
Raw Normal View History

"use client";
import * as React from "react";
import { useParams, useRouter } from "next/navigation";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
import { ChevronDown, ChevronRight, FileText, Folder, FolderKanban, Loader2, Plus, Search, Rocket, X } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from "@/components/ui/sheet";
import { api } from "@/lib/trpc";
import { cn } from "@/lib/utils";
const STATUS_VARIANT: Record<string, string> = {
draft: "bg-muted text-muted-foreground",
ready: "bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border-emerald-500/20",
in_progress: "bg-amber-500/15 text-amber-600 dark:text-amber-400 border-amber-500/20",
blocked: "bg-destructive/10 text-destructive border-destructive/20",
done: "bg-sky-500/15 text-sky-600 dark:text-sky-400 border-sky-500/20",
cancelled: "bg-muted text-muted-foreground",
};
function StatusChip({ status }: { status: string | null }) {
const s = status ?? "unknown";
const cls = STATUS_VARIANT[s] ?? STATUS_VARIANT.draft;
return (
<span className={cn("inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-medium", cls)}>{s}</span>
);
}
function slugify(value: string): string {
return value
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.replace(/--+/g, "-");
}
type DialogState =
| { kind: "plan" }
| { kind: "epic"; planSlug: string }
| { kind: "task"; planSlug: string; epicSlug: string }
| null;
export default function PlansPage() {
const params = useParams();
const router = useRouter();
const workspaceSlug = params?.workspaceSlug as string | undefined;
const [search, setSearch] = React.useState("");
const [expandedPlans, setExpandedPlans] = React.useState<Set<string>>(new Set());
const [expandedEpics, setExpandedEpics] = React.useState<Set<string>>(new Set());
const [selectedId, setSelectedId] = React.useState<string | null>(null);
const [dialog, setDialog] = React.useState<DialogState>(null);
const [formSlug, setFormSlug] = React.useState("");
const [formTitle, setFormTitle] = React.useState("");
const [formBody, setFormBody] = React.useState("");
const [formAgentPrompt, setFormAgentPrompt] = React.useState("");
const [formError, setFormError] = React.useState<string | null>(null);
const treeQuery = api.backlog.listTree.useQuery({ workspace: workspaceSlug! }, { enabled: Boolean(workspaceSlug) });
const detailQuery = api.backlog.getBacklogItem.useQuery(
{ workspace: workspaceSlug!, backlogItemId: selectedId! },
{ enabled: Boolean(workspaceSlug && selectedId) },
);
const promptQuery = api.backlog.getWorkflowPrompt.useQuery(
{ workspace: workspaceSlug!, backlogItemId: selectedId! },
{ enabled: Boolean(workspaceSlug && selectedId) },
);
const utils = api.useUtils();
const createPlan = api.backlog.createPlan.useMutation({
onSuccess: () => {
void utils.backlog.listTree.invalidate();
closeDialog();
},
onError: (e) => setFormError(e.message),
});
const createEpic = api.backlog.createEpic.useMutation({
onSuccess: () => {
void utils.backlog.listTree.invalidate();
closeDialog();
},
onError: (e) => setFormError(e.message),
});
const createTask = api.backlog.createTask.useMutation({
onSuccess: () => {
void utils.backlog.listTree.invalidate();
closeDialog();
},
onError: (e) => setFormError(e.message),
});
const startConvoy = api.backlog.startConvoy.useMutation({
onSuccess: () => {
void utils.convoys.kanban.invalidate();
if (workspaceSlug) router.push(`/${workspaceSlug}/mission-control`);
},
onError: (e) => setFormError(e.message),
});
// Direct convoy.create path required by brief 4 — Start → Convoy via convoys.create with planSlug/epicSlug
const createConvoyDirect = api.convoys.create.useMutation({
onSuccess: () => {
void utils.convoys.kanban.invalidate();
if (workspaceSlug) router.push(`/${workspaceSlug}/mission-control`);
},
onError: (e) => setFormError(e.message),
});
function closeDialog() {
setDialog(null);
setFormSlug("");
setFormTitle("");
setFormBody("");
setFormAgentPrompt("");
setFormError(null);
}
function openPlanDialog() {
setFormError(null);
setFormSlug("");
setFormTitle("");
setFormBody("");
setFormAgentPrompt("");
setDialog({ kind: "plan" });
}
function openEpicDialog(planSlug: string) {
setFormError(null);
setFormSlug("");
setFormTitle("");
setFormBody("");
setFormAgentPrompt("");
setDialog({ kind: "epic", planSlug });
}
function openTaskDialog(planSlug: string, epicSlug: string) {
setFormError(null);
setFormSlug("");
setFormTitle("");
setFormBody("");
setFormAgentPrompt("");
setDialog({ kind: "task", planSlug, epicSlug });
}
const tree = React.useMemo(() => treeQuery.data?.plans ?? [], [treeQuery.data?.plans]);
const filtered = React.useMemo(() => {
if (!search.trim()) return tree;
const q = search.trim().toLowerCase();
return tree
.map((plan) => {
const planMatches = plan.title.toLowerCase().includes(q) || plan.slug.toLowerCase().includes(q);
const epics = plan.epics
.map((epic) => {
const epicMatches = epic.title.toLowerCase().includes(q) || epic.slug.toLowerCase().includes(q);
const tasks = epic.tasks.filter(
(t) =>
t.title.toLowerCase().includes(q) ||
t.slug.toLowerCase().includes(q) ||
(t.status ?? "").toLowerCase().includes(q),
);
if (epicMatches || tasks.length > 0 || planMatches) {
return { ...epic, tasks: epicMatches && tasks.length === 0 ? epic.tasks : tasks };
}
return null;
})
.filter(Boolean) as typeof plan.epics;
if (planMatches) return { ...plan, epics: epics.length ? epics : plan.epics };
if (epics.length) return { ...plan, epics };
return null;
})
.filter(Boolean) as typeof tree;
}, [tree, search]);
React.useEffect(() => {
if (tree.length && expandedPlans.size === 0) {
setExpandedPlans(new Set(tree.slice(0, 2).map((p) => p.slug)));
}
}, [tree, expandedPlans.size]);
if (!workspaceSlug) return null;
const isMutating = createPlan.isPending || createEpic.isPending || createTask.isPending;
return (
<div className="flex h-full min-h-0 flex-col">
<header className="flex shrink-0 flex-wrap items-center justify-between gap-3 border-b border-border px-6 py-4">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10">
<FolderKanban className="size-5 text-primary" />
</div>
<div>
<h1 className="text-lg font-semibold">Plans</h1>
<p className="text-xs text-muted-foreground">Plan → Epic → Task tree from plans/ markdown</p>
</div>
</div>
<div className="flex items-center gap-2">
<div className="relative">
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input placeholder="Search plans, epics, tasks…" value={search} onChange={(e) => setSearch(e.target.value)} className="w-64 pl-8" />
</div>
<Button type="button" size="sm" onClick={openPlanDialog}>
<Plus className="size-4" />
New Plan
</Button>
</div>
</header>
<div className="flex min-h-0 flex-1">
<div className="flex min-h-0 flex-1 flex-col border-r border-border">
{treeQuery.isLoading ? (
<div className="flex items-center gap-2 p-6 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Loading plans…
</div>
) : filtered.length === 0 ? (
<div className="flex flex-1 flex-col items-center justify-center gap-3 p-8 text-center">
<FolderKanban className="size-8 text-muted-foreground" />
<p className="text-sm font-medium">No plans yet</p>
<p className="max-w-sm text-xs text-muted-foreground">
{search.trim()
? "No results for your search."
: "Create your first plan or run pnpm --filter @tasks/database watch:markdown-backlog to import plans/ after seeding."}
</p>
{!search.trim() && (
<Button size="sm" onClick={openPlanDialog}>
<Plus className="mr-1 size-4" />
New Plan
</Button>
)}
</div>
) : (
<ScrollArea className="h-full">
<div className="space-y-1 p-3">
{filtered.map((plan) => {
const planOpen = expandedPlans.has(plan.slug);
return (
<CollapsiblePrimitive.Root
key={plan.id}
open={planOpen}
onOpenChange={(open) =>
setExpandedPlans((prev) => {
const next = new Set(prev);
if (open) next.add(plan.slug);
else next.delete(plan.slug);
return next;
})
}
>
<div className="group flex items-center gap-1 rounded-md px-1 py-1 hover:bg-muted/50">
<CollapsiblePrimitive.Trigger asChild>
<button type="button" className="flex items-center gap-1 rounded p-1 hover:bg-muted" aria-label={planOpen ? "Collapse" : "Expand"}>
{planOpen ? <ChevronDown className="size-3.5" /> : <ChevronRight className="size-3.5" />}
</button>
</CollapsiblePrimitive.Trigger>
<Folder className="size-4 shrink-0 text-primary" />
<button
type="button"
onClick={() => setSelectedId(plan.id.startsWith("placeholder-") ? null : plan.id)}
className={cn(
"min-w-0 flex-1 truncate text-left text-sm font-medium hover:underline",
selectedId === plan.id && "text-primary",
)}
>
{plan.title || plan.slug}
</button>
<StatusChip status={plan.status} />
<Badge variant="outline" className="text-[10px]">{plan.slug}</Badge>
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => openEpicDialog(plan.slug)}>
<Plus className="size-3" />
Epic
</Button>
</div>
<CollapsiblePrimitive.Content className="ml-5 border-l border-border pl-3">
<div className="space-y-1 py-1">
{plan.epics.length === 0 ? (
<p className="px-2 py-1 text-xs text-muted-foreground">No epics — add one.</p>
) : (
plan.epics.map((epic) => {
const epicKey = `${plan.slug}/${epic.slug}`;
const epicOpen = expandedEpics.has(epicKey);
return (
<CollapsiblePrimitive.Root
key={epic.id}
open={epicOpen}
onOpenChange={(open) =>
setExpandedEpics((prev) => {
const next = new Set(prev);
if (open) next.add(epicKey);
else next.delete(epicKey);
return next;
})
}
>
<div className="flex items-center gap-1 rounded-md px-1 py-1 hover:bg-muted/50">
<CollapsiblePrimitive.Trigger asChild>
<button type="button" className="flex items-center rounded p-1 hover:bg-muted">
{epicOpen ? <ChevronDown className="size-3.5" /> : <ChevronRight className="size-3.5" />}
</button>
</CollapsiblePrimitive.Trigger>
<FolderKanban className="size-3.5 shrink-0 text-muted-foreground" />
<button
type="button"
onClick={() => setSelectedId(epic.id.startsWith("placeholder-") ? null : epic.id)}
className={cn(
"min-w-0 flex-1 truncate text-left text-sm hover:underline",
selectedId === epic.id && "text-primary",
)}
>
{epic.title || epic.slug}
</button>
<StatusChip status={epic.status} />
<Badge variant="outline" className="hidden text-[10px] sm:inline-flex">{epic.slug}</Badge>
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => openTaskDialog(plan.slug, epic.slug)}>
<Plus className="size-3" />
Task
</Button>
</div>
<CollapsiblePrimitive.Content className="ml-5 border-l border-border pl-3">
<ul className="space-y-0.5 py-1">
{epic.tasks.length === 0 ? (
<li className="px-2 py-1 text-xs text-muted-foreground">No tasks.</li>
) : (
epic.tasks.map((task) => (
<li
key={task.id}
className={cn(
"flex items-center gap-1.5 rounded-md px-2 py-1.5 hover:bg-muted",
selectedId === task.id && "bg-muted ring-1 ring-primary/20",
)}
>
<FileText className="size-3.5 shrink-0 text-muted-foreground" />
<button
type="button"
onClick={() => setSelectedId(task.id)}
className="min-w-0 flex-1 truncate text-left text-sm"
>
<span className="font-medium">{task.title || task.slug}</span>
<span className="ml-1 text-xs text-muted-foreground">{task.slug}</span>
</button>
<StatusChip status={task.status} />
{task.priority && (
<Badge variant="outline" className="hidden text-[10px] sm:inline-flex">
{task.priority}
</Badge>
)}
<span className="hidden max-w-[140px] truncate text-[10px] text-muted-foreground sm:inline">{task.repoPath}</span>
<Button
type="button"
size="sm"
variant={task.status === "ready" ? "default" : "outline"}
className="h-7 shrink-0 px-2 text-xs"
disabled={startConvoy.isPending || createConvoyDirect.isPending}
onClick={() => {
setSelectedId(task.id);
// Primary: backlog.startConvoy (idempotent, creates briefFiles: [repoPath]); fallback/additional: convoys.create with planSlug/epicSlug per brief
const convoySlug = `${task.planSlug}-${task.slug}`.toLowerCase();
if (task.status === "ready") {
startConvoy.mutate({ workspace: workspaceSlug, backlogItemId: task.id });
} else {
createConvoyDirect.mutate({
workspace: workspaceSlug,
slug: convoySlug,
classification: "feature",
successMetric: task.title || task.slug,
ideaMarkdown: task.bodyMarkdown || `# ${task.title}`,
planSlug: task.planSlug,
epicSlug: task.epicSlug ?? undefined,
backlogItemId: task.id,
repoPath: task.repoPath,
briefFiles: [task.repoPath],
});
}
}}
>
<Rocket className="mr-1 size-3" />
Start
</Button>
</li>
))
)}
</ul>
</CollapsiblePrimitive.Content>
</CollapsiblePrimitive.Root>
);
})
)}
</div>
</CollapsiblePrimitive.Content>
</CollapsiblePrimitive.Root>
);
})}
</div>
</ScrollArea>
)}
</div>
<Sheet open={Boolean(selectedId)} onOpenChange={(open) => !open && setSelectedId(null)}>
<SheetContent className="flex w-[480px] max-w-[90vw] flex-col sm:max-w-[480px]">
<SheetHeader>
<SheetTitle className="pr-6">{detailQuery.data?.title ?? "Details"}</SheetTitle>
<SheetDescription className="break-all text-xs">
{detailQuery.data?.repoPath ?? ""}
{detailQuery.data ? ` · ${detailQuery.data.kind}` : ""}
</SheetDescription>
</SheetHeader>
<ScrollArea className="flex-1">
<div className="space-y-4 p-1 pr-4">
{detailQuery.isLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Loading…
</div>
) : detailQuery.data ? (
<>
<div className="flex flex-wrap gap-2">
<StatusChip status={detailQuery.data.status} />
{detailQuery.data.priority && <Badge variant="outline">{detailQuery.data.priority}</Badge>}
<Badge variant="secondary">{detailQuery.data.kind}</Badge>
</div>
<div className="rounded-md border border-border bg-muted/20 p-3">
<p className="text-xs font-medium text-muted-foreground">Repo path</p>
<p className="mt-1 break-all font-mono text-xs">{detailQuery.data.repoPath}</p>
<p className="mt-2 text-xs font-medium text-muted-foreground">Slugs</p>
<p className="mt-1 text-xs">
Plan: <code className="rounded bg-muted px-1 py-0.5">{detailQuery.data.planSlug}</code>
{detailQuery.data.epicSlug ? (
<>
{" "}
Epic: <code className="rounded bg-muted px-1 py-0.5">{detailQuery.data.epicSlug}</code>
</>
) : null}{" "}
Slug: <code className="rounded bg-muted px-1 py-0.5">{detailQuery.data.slug}</code>
</p>
</div>
<div>
<h3 className="text-sm font-semibold">Body</h3>
<pre className="mt-2 max-h-64 overflow-auto whitespace-pre-wrap break-words rounded-md border border-border bg-muted/20 p-3 text-xs leading-relaxed">
{detailQuery.data.bodyMarkdown || "(empty)"}
</pre>
</div>
<div>
<h3 className="text-sm font-semibold">Effective prompt</h3>
<p className="mt-1 text-xs text-muted-foreground">Source: {promptQuery.data?.source ?? "—"}</p>
<pre className="mt-2 max-h-48 overflow-auto whitespace-pre-wrap break-words rounded-md border border-border bg-muted/20 p-3 text-xs leading-relaxed">
{promptQuery.data?.effectivePrompt ?? promptQuery.data?.ownOverride ?? "(no prompt)"}
</pre>
{promptQuery.data?.ownOverride && <p className="mt-1 text-[10px] text-muted-foreground">Own override present</p>}
</div>
{detailQuery.data.kind === "task" && (
<div className="flex gap-2">
<Button
type="button"
size="sm"
disabled={startConvoy.isPending || createConvoyDirect.isPending}
onClick={() => {
const d = detailQuery.data!;
const convoySlug = `${d.planSlug}-${d.slug}`.toLowerCase();
// Use convoys.create with planSlug/epicSlug as required by brief, via createConvoyDirect
createConvoyDirect.mutate({
workspace: workspaceSlug,
slug: convoySlug,
classification: "feature",
successMetric: d.title || d.slug,
ideaMarkdown: d.bodyMarkdown || `# ${d.title}`,
planSlug: d.planSlug,
epicSlug: d.epicSlug ?? undefined,
backlogItemId: d.id,
repoPath: d.repoPath,
briefFiles: [d.repoPath],
});
}}
>
{(startConvoy.isPending || createConvoyDirect.isPending) ? <Loader2 className="size-4 animate-spin" /> : <Rocket className="size-4" />}
Start → Convoy
</Button>
<Button type="button" variant="outline" size="sm" onClick={() => setSelectedId(null)}>
Close
</Button>
</div>
)}
{(startConvoy.error || createConvoyDirect.error) && (
<p className="text-xs text-destructive">{(startConvoy.error ?? createConvoyDirect.error)?.message}</p>
)}
{(startConvoy.isSuccess || createConvoyDirect.isSuccess) && (
<p className="text-xs text-emerald-600">
Convoy {(startConvoy.data?.convoySlug ?? createConvoyDirect.data?.convoyId) ?? ""} created — check Mission Control.
</p>
)}
</>
) : (
<p className="text-sm text-muted-foreground">Select an item to see details.</p>
)}
</div>
</ScrollArea>
</SheetContent>
</Sheet>
</div>
<DialogPrimitive.Root open={Boolean(dialog)} onOpenChange={(open) => !open && closeDialog()}>
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-black/50" />
<DialogPrimitive.Content className="fixed left-1/2 top-1/2 z-50 w-full max-w-lg -translate-x-1/2 -translate-y-1/2 rounded-lg border border-border bg-background p-6 shadow-lg">
<div className="mb-4 flex items-center justify-between">
<DialogPrimitive.Title className="text-lg font-semibold">
{dialog?.kind === "plan" ? "New Plan" : dialog?.kind === "epic" ? "New Epic" : dialog?.kind === "task" ? "New Task" : "New"}
</DialogPrimitive.Title>
<DialogPrimitive.Close asChild>
<button type="button" className="rounded p-1 hover:bg-muted" aria-label="Close">
<X className="size-4" />
</button>
</DialogPrimitive.Close>
</div>
<div className="space-y-3">
{dialog?.kind === "epic" && (
<div>
<label className="mb-1 block text-sm font-medium">Plan</label>
<div className="rounded-md border border-input bg-muted px-3 py-2 text-sm">{dialog.planSlug}</div>
</div>
)}
{dialog?.kind === "task" && (
<div className="grid grid-cols-2 gap-3">
<div>
<label className="mb-1 block text-sm font-medium">Plan</label>
<div className="rounded-md border border-input bg-muted px-3 py-2 text-sm">{dialog.planSlug}</div>
</div>
<div>
<label className="mb-1 block text-sm font-medium">Epic</label>
<div className="rounded-md border border-input bg-muted px-3 py-2 text-sm">{dialog.epicSlug}</div>
</div>
</div>
)}
<div>
<label className="mb-1 block text-sm font-medium" htmlFor="plans-slug">
Slug <span className="font-normal text-muted-foreground">(a-z 0-9 -)</span>
</label>
<Input
id="plans-slug"
placeholder="my-slug"
value={formSlug}
onChange={(e) => {
const v = e.target.value.toLowerCase();
setFormSlug(v);
if (!formTitle.trim() || slugify(formTitle) === formSlug) {
// keep title in sync helper? no auto
}
}}
/>
<p className="mt-1 text-[11px] text-muted-foreground">Must match ^[a-z0-9-]+$</p>
</div>
<div>
<label className="mb-1 block text-sm font-medium" htmlFor="plans-title">
Title
</label>
<Input
id="plans-title"
placeholder="Human readable title"
value={formTitle}
onChange={(e) => setFormTitle(e.target.value)}
onBlur={() => {
if (!formSlug.trim() && formTitle.trim()) setFormSlug(slugify(formTitle));
}}
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium" htmlFor="plans-body">
Body markdown (optional)
</label>
<textarea
id="plans-body"
className="min-h-[90px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
placeholder="# Summary…"
value={formBody}
onChange={(e) => setFormBody(e.target.value)}
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium" htmlFor="plans-prompt">
Agent prompt (optional)
</label>
<textarea
id="plans-prompt"
className="min-h-[60px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
placeholder="Custom prompt for agents…"
value={formAgentPrompt}
onChange={(e) => setFormAgentPrompt(e.target.value)}
/>
</div>
{formError && <p className="text-xs text-destructive">{formError}</p>}
<div className="flex justify-end gap-2 pt-2">
<DialogPrimitive.Close asChild>
<Button type="button" variant="outline" onClick={closeDialog}>
Cancel
</Button>
</DialogPrimitive.Close>
<Button
type="button"
disabled={isMutating || !formSlug.trim() || !formTitle.trim()}
onClick={() => {
setFormError(null);
const slug = formSlug.trim().toLowerCase();
if (!SLUG_RE.test(slug)) {
setFormError("slug must match ^[a-z0-9-]+$");
return;
}
if (dialog?.kind === "plan") {
createPlan.mutate({
workspace: workspaceSlug,
slug,
title: formTitle.trim(),
bodyMarkdown: formBody.trim() || undefined,
agentPrompt: formAgentPrompt.trim() || null,
});
} else if (dialog?.kind === "epic") {
createEpic.mutate({
workspace: workspaceSlug,
planSlug: dialog.planSlug,
slug,
title: formTitle.trim(),
bodyMarkdown: formBody.trim() || undefined,
agentPrompt: formAgentPrompt.trim() || null,
});
} else if (dialog?.kind === "task") {
createTask.mutate({
workspace: workspaceSlug,
planSlug: dialog.planSlug,
epicSlug: dialog.epicSlug,
slug,
title: formTitle.trim(),
bodyMarkdown: formBody.trim() || undefined,
agentPrompt: formAgentPrompt.trim() || null,
});
}
}}
>
{isMutating ? <Loader2 className="size-4 animate-spin" /> : <Plus className="size-4" />}
Create
</Button>
</div>
</div>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
</div>
);
}
const SLUG_RE = /^[a-z0-9-]+$/;