Merge master (Prompter #80) into lifecycle-residual-hardening

This commit is contained in:
Renn F
2026-06-08 05:45:07 +02:00
24 changed files with 3840 additions and 0 deletions
+2
View File
@@ -21,6 +21,7 @@ import {
GitBranch,
Database,
Cpu,
Sparkles,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
@@ -33,6 +34,7 @@ const navItems = [
// Work Management
{ title: "Tasks", href: "/tasks", icon: ListTodo },
{ title: "Kanban", href: "/kanban", icon: Kanban },
{ title: "Task Assistant", href: "/prompter", icon: Sparkles },
// Development
{ title: "Projects", href: "/projects", icon: FolderGit2 },
@@ -0,0 +1,70 @@
"use client";
import { useState, useRef, KeyboardEvent } from "react";
import { Send, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
interface ChatComposerProps {
onSend: (text: string) => Promise<void> | void;
disabled?: boolean;
isSending?: boolean;
placeholder?: string;
}
export function ChatComposer({
onSend,
disabled = false,
isSending = false,
placeholder = "Describe the task you want to create… (Enter to send, Shift+Enter for newline)",
}: ChatComposerProps) {
const [value, setValue] = useState("");
const textareaRef = useRef<HTMLTextAreaElement>(null);
const isDisabled = disabled || isSending || !value.trim();
const handleSend = async () => {
const text = value.trim();
if (!text || isSending || disabled) return;
setValue("");
await onSend(text);
// Refocus after send
textareaRef.current?.focus();
};
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
// Shift+Enter falls through to default (inserts newline)
};
return (
<div className="flex items-end gap-2 border-t bg-background px-4 py-3">
<Textarea
ref={textareaRef}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={disabled || isSending}
rows={2}
className="flex-1 resize-none text-sm"
/>
<Button
size="icon"
onClick={handleSend}
disabled={isDisabled}
className="mb-0.5 shrink-0"
aria-label="Send message"
>
{isSending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Send className="h-4 w-4" />
)}
</Button>
</div>
);
}
@@ -0,0 +1,97 @@
"use client";
import { useEffect, useRef } from "react";
import { AlertTriangle } from "lucide-react";
import { cn } from "@/lib/utils";
import type { ChatMessage } from "@/hooks/use-prompter";
import { DraftProposalCard } from "./draft-proposal-card";
interface ChatMessagesProps {
messages: ChatMessage[];
onOpenReview: () => void;
onKeepChatting: () => void;
}
export function ChatMessages({
messages,
onOpenReview,
onKeepChatting,
}: ChatMessagesProps) {
const bottomRef = useRef<HTMLDivElement>(null);
// Auto-scroll to bottom whenever messages change
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
if (messages.length === 0) {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-3 text-center text-muted-foreground px-8">
<p className="text-lg font-semibold">What would you like to build?</p>
<p className="text-sm max-w-md">
Describe your task idea. I&apos;ll help you refine it into a structured task with acceptance
criteria ready to hand off to the team.
</p>
</div>
);
}
return (
<div className="flex flex-1 flex-col gap-4 overflow-y-auto px-4 py-4">
{messages.map((msg) => {
if (msg.role === "user") {
return (
<div key={msg.id} className="flex justify-end">
<div className="max-w-[70%] rounded-2xl rounded-tr-sm bg-primary px-4 py-3 text-sm text-primary-foreground">
{msg.content}
</div>
</div>
);
}
if (msg.role === "error") {
return (
<div key={msg.id} className="flex justify-start">
<div className="flex max-w-[70%] items-start gap-2 rounded-2xl rounded-tl-sm border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<span>{msg.content}</span>
</div>
</div>
);
}
// Assistant message
return (
<div key={msg.id} className="flex flex-col gap-2">
<div className="flex justify-start">
<div
className={cn(
"max-w-[70%] rounded-2xl rounded-tl-sm bg-muted px-4 py-3 text-sm",
msg.draft && "max-w-[85%]"
)}
>
<p className="whitespace-pre-wrap">{msg.content}</p>
</div>
</div>
{/* Inline draft proposal card when LLM offers a draft */}
{msg.draft && (
<div className="flex justify-start">
<div className="w-full max-w-[85%]">
<DraftProposalCard
draft={msg.draft}
onKeepChatting={onKeepChatting}
onOpenReview={onOpenReview}
/>
</div>
</div>
)}
</div>
);
})}
{/* Scroll anchor */}
<div ref={bottomRef} />
</div>
);
}
@@ -0,0 +1,212 @@
"use client";
import { AlertTriangle, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { AcceptanceCriteriaEditor } from "@/components/tasks/acceptance-criteria-editor";
import { MarkdownEditor } from "@/components/tasks/markdown-editor";
import { Team, TaskType, Complexity } from "@/types";
import type { EditableDraft } from "@/hooks/use-prompter";
interface ConfirmDialogProps {
open: boolean;
draft: EditableDraft;
onClose: () => void;
onUpdate: (updates: Partial<EditableDraft>) => void;
onConfirm: () => Promise<void> | void;
isLaunching: boolean;
isValid: boolean;
}
const WARNING_BANNER_ID = "prompter-warning-banner";
export function ConfirmDialog({
open,
draft,
onClose,
onUpdate,
onConfirm,
isLaunching,
isValid,
}: ConfirmDialogProps) {
return (
<Dialog open={open} onOpenChange={(o) => { if (!o && !isLaunching) onClose(); }}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Review &amp; Confirm Task</DialogTitle>
</DialogHeader>
{/* Warning banner */}
<div
id={WARNING_BANNER_ID}
className="flex items-start gap-2 rounded-lg border border-destructive/40 bg-destructive/10 px-4 py-3 text-sm text-destructive"
>
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<span>
This will create a real task and notify the team. It cannot be undone from this screen.
</span>
</div>
{/* Form fields — NOT wrapped in a <form> to prevent Enter-key submission bypass */}
<div className="space-y-5 py-2">
{/* Title */}
<div className="space-y-1.5">
<Label htmlFor="prompter-title">
Title <span className="text-destructive">*</span>
</Label>
<Input
id="prompter-title"
value={draft.title}
onChange={(e) => onUpdate({ title: e.target.value })}
placeholder="Task title"
disabled={isLaunching}
/>
</div>
{/* Description */}
<MarkdownEditor
label="Description"
value={draft.description}
onChange={(v) => onUpdate({ description: v })}
placeholder="Describe what needs to be done…"
required
minLength={20}
/>
{/* Acceptance Criteria */}
<AcceptanceCriteriaEditor
criteria={draft.acceptance_criteria}
onChange={(criteria) => onUpdate({ acceptance_criteria: criteria })}
/>
{/* Metadata row */}
<div className="grid grid-cols-3 gap-4">
{/* Team */}
<div className="space-y-1.5">
<Label>
Team <span className="text-destructive">*</span>
</Label>
<Select
value={draft.team}
onValueChange={(v) => onUpdate({ team: v as Team })}
disabled={isLaunching}
>
<SelectTrigger>
<SelectValue placeholder="Select team" />
</SelectTrigger>
<SelectContent>
{Object.values(Team).map((t) => (
<SelectItem key={t} value={t}>
{t.replace("_", " ")}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Priority */}
<div className="space-y-1.5">
<Label>Priority</Label>
<Select
value={String(draft.priority)}
onValueChange={(v) => onUpdate({ priority: Number(v) })}
disabled={isLaunching}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="0">Low</SelectItem>
<SelectItem value="1">Medium</SelectItem>
<SelectItem value="2">High</SelectItem>
<SelectItem value="3">Urgent</SelectItem>
</SelectContent>
</Select>
</div>
{/* Task Type */}
<div className="space-y-1.5">
<Label>Type</Label>
<Select
value={draft.task_type || ""}
onValueChange={(v) => onUpdate({ task_type: v as TaskType })}
disabled={isLaunching}
>
<SelectTrigger>
<SelectValue placeholder="Select type" />
</SelectTrigger>
<SelectContent>
{Object.values(TaskType).map((t) => (
<SelectItem key={t} value={t}>
{t.charAt(0).toUpperCase() + t.slice(1)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Complexity */}
<div className="space-y-1.5">
<Label>Estimated Complexity</Label>
<Select
value={draft.estimated_complexity || ""}
onValueChange={(v) => onUpdate({ estimated_complexity: v as Complexity })}
disabled={isLaunching}
>
<SelectTrigger className="w-48">
<SelectValue placeholder="Select complexity" />
</SelectTrigger>
<SelectContent>
{Object.values(Complexity).map((c) => (
<SelectItem key={c} value={c}>
{c.charAt(0).toUpperCase() + c.slice(1)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter className="gap-2">
<Button
variant="outline"
onClick={onClose}
disabled={isLaunching}
>
Back
</Button>
<Button
onClick={onConfirm}
disabled={!isValid || isLaunching}
aria-describedby={WARNING_BANNER_ID}
>
{isLaunching ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Creating
</>
) : (
"Confirm & Launch"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,108 @@
"use client";
import { MessageCircle, ClipboardCheck } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import type { DraftProposal } from "@/lib/api/prompter";
interface DraftProposalCardProps {
draft: DraftProposal;
onKeepChatting: () => void;
onOpenReview: () => void;
}
const PRIORITY_LABELS: Record<number, string> = {
0: "Low",
1: "Medium",
2: "High",
3: "Urgent",
};
export function DraftProposalCard({
draft,
onKeepChatting,
onOpenReview,
}: DraftProposalCardProps) {
const priorityLabel = PRIORITY_LABELS[draft.priority ?? 2] ?? "High";
return (
<Card className="border-primary/30 bg-primary/5">
<CardHeader className="pb-2">
<div className="flex items-start justify-between gap-2">
<CardTitle className="text-sm font-semibold leading-tight">
{draft.title}
</CardTitle>
<div className="flex flex-wrap gap-1 shrink-0">
{draft.team && (
<Badge variant="secondary" className="text-xs">
{draft.team}
</Badge>
)}
<Badge variant="outline" className="text-xs">
{priorityLabel}
</Badge>
{draft.task_type && (
<Badge variant="outline" className="text-xs">
{draft.task_type}
</Badge>
)}
</div>
</div>
</CardHeader>
<CardContent className="pb-3 space-y-3">
{/* Description excerpt */}
{draft.description && (
<p className="text-sm text-muted-foreground line-clamp-3">
{draft.description}
</p>
)}
{/* Acceptance criteria */}
{draft.acceptance_criteria.length > 0 && (
<div>
<p className="text-xs font-medium text-muted-foreground mb-1.5">
Acceptance criteria ({draft.acceptance_criteria.length})
</p>
<ul className="space-y-1">
{draft.acceptance_criteria.slice(0, 4).map((criterion, i) => (
<li key={i} className="flex items-start gap-2 text-xs">
<span className="mt-0.5 h-3 w-3 shrink-0 rounded-full border border-primary/50 flex items-center justify-center">
<span className="h-1.5 w-1.5 rounded-full bg-primary/50" />
</span>
<span className="text-foreground line-clamp-2">{criterion}</span>
</li>
))}
{draft.acceptance_criteria.length > 4 && (
<li className="text-xs text-muted-foreground pl-5">
+{draft.acceptance_criteria.length - 4} more
</li>
)}
</ul>
</div>
)}
</CardContent>
<CardFooter className="gap-2 pt-0">
<Button
variant="outline"
size="sm"
className="flex-1"
onClick={onKeepChatting}
>
<MessageCircle className="mr-1.5 h-3.5 w-3.5" />
Keep Chatting
</Button>
<Button
size="sm"
className="flex-1"
onClick={onOpenReview}
>
<ClipboardCheck className="mr-1.5 h-3.5 w-3.5" />
Review &amp; Confirm
</Button>
</CardFooter>
</Card>
);
}
+5
View File
@@ -0,0 +1,5 @@
export { ChatMessages } from "./chat-messages";
export { ChatComposer } from "./chat-composer";
export { DraftProposalCard } from "./draft-proposal-card";
export { ConfirmDialog } from "./confirm-dialog";
export { SuccessCard } from "./success-card";
@@ -0,0 +1,58 @@
"use client";
import Link from "next/link";
import { CheckCircle2, ExternalLink, RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import type { Team } from "@/types";
interface SuccessCardProps {
taskId: string;
taskTitle: string;
team: Team;
onStartAnother: () => void;
}
export function SuccessCard({
taskId,
taskTitle,
team,
onStartAnother,
}: SuccessCardProps) {
return (
<Card className="border-green-500/30 bg-green-500/5">
<CardHeader className="pb-2">
<div className="flex items-center gap-2">
<CheckCircle2 className="h-5 w-5 text-green-600" />
<CardTitle className="text-sm font-semibold text-green-700 dark:text-green-400">
Task Created Successfully
</CardTitle>
</div>
</CardHeader>
<CardContent className="pb-3 space-y-2">
<p className="text-sm font-medium">{taskTitle}</p>
<div className="flex items-center gap-2">
<Badge variant="secondary" className="text-xs">
{team.replace("_", " ")}
</Badge>
<span className="text-xs text-muted-foreground">ID: {taskId.slice(0, 8)}</span>
</div>
</CardContent>
<CardFooter className="gap-2 pt-0">
<Button variant="outline" size="sm" asChild className="flex-1">
<Link href={`/tasks/${taskId}`} target="_blank" rel="noopener noreferrer">
<ExternalLink className="mr-1.5 h-3.5 w-3.5" />
View Task
</Link>
</Button>
<Button size="sm" className="flex-1" onClick={onStartAnother}>
<RefreshCw className="mr-1.5 h-3.5 w-3.5" />
Start Another
</Button>
</CardFooter>
</Card>
);
}