- MCP create-backlog-item for natural language Plan/Epic/Task authoring - Worker non-code: optional worktree + per-harness allowlist + doc_review→published - Harness registry: harness_providers/role_harness_routes, WorkerAdapter (minimal|crewai|langgraph|remote-ag-ui), AG-UI POST /api/ag-ui, Settings → Harnesses with Test - Plans tree: backlog.listTree + createPlan/Epic/Task + Start→Convoy, web CRUD without CLI - Fleet: /fleet rollup across workspaces + SSE /api/fleet/events Decision: docs/decisions/2026-08-25-second-brain-os-pivot.md Convoy: .convoys/second-brain-os-v01 Co-authored-by: orchestrator
655 lines
32 KiB
TypeScript
655 lines
32 KiB
TypeScript
"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-]+$/;
|