mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
[9ef46be7] feat(prompter): implement Prompter page, nav entry, chat UI, and task creation wiring (#78) (#79)
- Add Task Assistant nav entry (Sparkles icon) to sidebar between Kanban and Projects - Create lib/api/prompter.ts with createSession, sendMessage, getDraft API functions - Create hooks/use-prompter.ts with 6-state machine (empty→chatting→draft_preview→review_modal→launching→success) - Create ChatMessages with auto-scroll, user/assistant/error rendering, inline DraftProposalCard - Create ChatComposer with Enter-to-send and Shift+Enter for newline - Create DraftProposalCard with title, description, acceptance criteria, metadata badges, Keep Chatting/Review & Confirm actions - Create ConfirmDialog (Radix Dialog, no form element) with MarkdownEditor, AcceptanceCriteriaEditor, metadata selects, warning banner, disabled Confirm & Launch until valid - Create SuccessCard with task title, team, View Task link, Start Another button - Create /prompter route page composing all components via usePrompter hook - TypeScript and ESLint pass with zero errors Co-authored-by: Frontend Developer 2 <fe-dev-2@agents.roboco.dev>
This commit is contained in:
co-authored by
Frontend Developer 2
parent
85ffec86b4
commit
5a99140a55
@@ -0,0 +1,93 @@
|
||||
"use client";
|
||||
|
||||
import { Sparkles } from "lucide-react";
|
||||
import { usePrompter } from "@/hooks/use-prompter";
|
||||
import {
|
||||
ChatMessages,
|
||||
ChatComposer,
|
||||
ConfirmDialog,
|
||||
SuccessCard,
|
||||
} from "@/components/prompter";
|
||||
|
||||
export default function PrompterPage() {
|
||||
const {
|
||||
state,
|
||||
messages,
|
||||
isSending,
|
||||
editableDraft,
|
||||
createdTaskId,
|
||||
createdTaskTitle,
|
||||
createdTaskTeam,
|
||||
send,
|
||||
openReview,
|
||||
closeReview,
|
||||
keepChatting,
|
||||
updateDraft,
|
||||
isValidForLaunch,
|
||||
launchTask,
|
||||
startAnother,
|
||||
isLaunching,
|
||||
} = usePrompter();
|
||||
|
||||
const isComposerDisabled =
|
||||
state === "launching" || state === "success";
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Page header */}
|
||||
<div className="flex items-center gap-3 border-b px-6 py-4">
|
||||
<Sparkles className="h-5 w-5 text-primary" />
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold">Task Assistant</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Describe your idea and I'll help you create a structured task
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chat area */}
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{/* Success overlay in chat area */}
|
||||
{state === "success" &&
|
||||
createdTaskId &&
|
||||
createdTaskTitle &&
|
||||
createdTaskTeam ? (
|
||||
<div className="flex flex-1 flex-col items-center justify-center px-8 py-8">
|
||||
<div className="w-full max-w-md">
|
||||
<SuccessCard
|
||||
taskId={createdTaskId}
|
||||
taskTitle={createdTaskTitle}
|
||||
team={createdTaskTeam}
|
||||
onStartAnother={startAnother}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<ChatMessages
|
||||
messages={messages}
|
||||
onOpenReview={openReview}
|
||||
onKeepChatting={keepChatting}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Composer */}
|
||||
<ChatComposer
|
||||
onSend={send}
|
||||
disabled={isComposerDisabled}
|
||||
isSending={isSending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Confirmation dialog (portal) */}
|
||||
<ConfirmDialog
|
||||
open={state === "review_modal" || state === "launching"}
|
||||
draft={editableDraft}
|
||||
onClose={closeReview}
|
||||
onUpdate={updateDraft}
|
||||
onConfirm={launchTask}
|
||||
isLaunching={isLaunching}
|
||||
isValid={isValidForLaunch()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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'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 & 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 & Confirm
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useRef } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { prompterApi, type DraftProposal } from "@/lib/api/prompter";
|
||||
import { getErrorMessage } from "@/lib/api/client";
|
||||
import { useCreateTask } from "@/hooks/use-tasks";
|
||||
import type { TaskCreate, Team, TaskType, Complexity } from "@/types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type PrompterState =
|
||||
| "empty"
|
||||
| "chatting"
|
||||
| "draft_preview"
|
||||
| "review_modal"
|
||||
| "launching"
|
||||
| "success";
|
||||
|
||||
export type MessageRole = "user" | "assistant" | "error";
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
role: MessageRole;
|
||||
content: string;
|
||||
/** Present only on an assistant message that contains a draft proposal */
|
||||
draft?: DraftProposal;
|
||||
}
|
||||
|
||||
export interface EditableDraft {
|
||||
title: string;
|
||||
description: string;
|
||||
acceptance_criteria: string[];
|
||||
team: Team | "";
|
||||
priority: number;
|
||||
task_type: TaskType | "";
|
||||
estimated_complexity: Complexity | "";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function usePrompter() {
|
||||
const createTask = useCreateTask();
|
||||
|
||||
const [state, setState] = useState<PrompterState>("empty");
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
const [createdTaskId, setCreatedTaskId] = useState<string | null>(null);
|
||||
const [createdTaskTitle, setCreatedTaskTitle] = useState<string | null>(null);
|
||||
const [createdTaskTeam, setCreatedTaskTeam] = useState<Team | null>(null);
|
||||
|
||||
/** Draft as shown in the draft-preview card */
|
||||
const [draftProposal, setDraftProposal] = useState<DraftProposal | null>(null);
|
||||
|
||||
/** Editable copy used in the confirmation dialog */
|
||||
const [editableDraft, setEditableDraft] = useState<EditableDraft>({
|
||||
title: "",
|
||||
description: "",
|
||||
acceptance_criteria: [],
|
||||
team: "",
|
||||
priority: 2,
|
||||
task_type: "",
|
||||
estimated_complexity: "",
|
||||
});
|
||||
|
||||
// Keep a ref to sessionId for callbacks to avoid stale closures
|
||||
const sessionIdRef = useRef<string | null>(null);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
const addMessage = useCallback((msg: Omit<ChatMessage, "id">) => {
|
||||
const id = `msg-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
||||
setMessages((prev) => [...prev, { ...msg, id }]);
|
||||
return id;
|
||||
}, []);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Send a chat message
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
const send = useCallback(
|
||||
async (text: string) => {
|
||||
if (!text.trim() || isSending) return;
|
||||
|
||||
setIsSending(true);
|
||||
setState("chatting");
|
||||
|
||||
// Add user message to chat
|
||||
addMessage({ role: "user", content: text.trim() });
|
||||
|
||||
try {
|
||||
let sid = sessionIdRef.current;
|
||||
|
||||
// Create session on first message
|
||||
if (!sid) {
|
||||
const { session_id } = await prompterApi.createSession();
|
||||
sid = session_id;
|
||||
sessionIdRef.current = sid;
|
||||
setSessionId(sid);
|
||||
}
|
||||
|
||||
// Send message and get reply
|
||||
const response = await prompterApi.sendMessage(sid, text.trim());
|
||||
|
||||
if (response.draft) {
|
||||
// LLM produced a draft — add assistant message with embedded draft
|
||||
addMessage({
|
||||
role: "assistant",
|
||||
content: response.reply,
|
||||
draft: response.draft,
|
||||
});
|
||||
setDraftProposal(response.draft);
|
||||
setEditableDraft({
|
||||
title: response.draft.title,
|
||||
description: response.draft.description,
|
||||
acceptance_criteria: response.draft.acceptance_criteria,
|
||||
team: response.draft.team ?? "",
|
||||
priority: response.draft.priority ?? 2,
|
||||
task_type: response.draft.task_type ?? "",
|
||||
estimated_complexity: response.draft.estimated_complexity ?? "",
|
||||
});
|
||||
setState("draft_preview");
|
||||
} else {
|
||||
// Plain text reply
|
||||
addMessage({ role: "assistant", content: response.reply });
|
||||
setState("chatting");
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = getErrorMessage(err);
|
||||
addMessage({
|
||||
role: "error",
|
||||
content: msg,
|
||||
});
|
||||
setState("chatting");
|
||||
} finally {
|
||||
setIsSending(false);
|
||||
}
|
||||
},
|
||||
[isSending, addMessage]
|
||||
);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Review & Confirm actions
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
const openReview = useCallback(() => {
|
||||
setState("review_modal");
|
||||
}, []);
|
||||
|
||||
const closeReview = useCallback(() => {
|
||||
setState("draft_preview");
|
||||
}, []);
|
||||
|
||||
const keepChatting = useCallback(() => {
|
||||
setState("chatting");
|
||||
}, []);
|
||||
|
||||
const updateDraft = useCallback((updates: Partial<EditableDraft>) => {
|
||||
setEditableDraft((prev) => ({ ...prev, ...updates }));
|
||||
}, []);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Validation
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
const isValidForLaunch = useCallback((): boolean => {
|
||||
return (
|
||||
editableDraft.title.trim().length > 0 &&
|
||||
editableDraft.description.trim().length >= 20 &&
|
||||
editableDraft.acceptance_criteria.length > 0 &&
|
||||
editableDraft.team !== ""
|
||||
);
|
||||
}, [editableDraft]);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Launch (create task)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
const launchTask = useCallback(async () => {
|
||||
if (!isValidForLaunch()) return;
|
||||
|
||||
setState("launching");
|
||||
|
||||
const payload: TaskCreate = {
|
||||
title: editableDraft.title.trim(),
|
||||
description: editableDraft.description.trim(),
|
||||
acceptance_criteria: editableDraft.acceptance_criteria,
|
||||
team: editableDraft.team as Team,
|
||||
priority: editableDraft.priority,
|
||||
...(editableDraft.task_type ? { task_type: editableDraft.task_type as TaskType } : {}),
|
||||
...(editableDraft.estimated_complexity
|
||||
? { estimated_complexity: editableDraft.estimated_complexity as Complexity }
|
||||
: {}),
|
||||
};
|
||||
|
||||
try {
|
||||
const task = await createTask.mutateAsync(payload);
|
||||
setCreatedTaskId(task.id);
|
||||
setCreatedTaskTitle(task.title);
|
||||
setCreatedTaskTeam(task.team as Team);
|
||||
toast.success("Task created successfully!");
|
||||
setState("success");
|
||||
} catch (err) {
|
||||
const msg = getErrorMessage(err);
|
||||
toast.error(`Failed to create task: ${msg}`);
|
||||
setState("review_modal");
|
||||
}
|
||||
}, [editableDraft, isValidForLaunch, createTask]);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Reset to start another conversation
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
const startAnother = useCallback(() => {
|
||||
setMessages([]);
|
||||
setSessionId(null);
|
||||
sessionIdRef.current = null;
|
||||
setDraftProposal(null);
|
||||
setEditableDraft({
|
||||
title: "",
|
||||
description: "",
|
||||
acceptance_criteria: [],
|
||||
team: "",
|
||||
priority: 2,
|
||||
task_type: "",
|
||||
estimated_complexity: "",
|
||||
});
|
||||
setCreatedTaskId(null);
|
||||
setCreatedTaskTitle(null);
|
||||
setCreatedTaskTeam(null);
|
||||
setState("empty");
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// State
|
||||
state,
|
||||
messages,
|
||||
sessionId,
|
||||
isSending,
|
||||
draftProposal,
|
||||
editableDraft,
|
||||
createdTaskId,
|
||||
createdTaskTitle,
|
||||
createdTaskTeam,
|
||||
|
||||
// Actions
|
||||
send,
|
||||
openReview,
|
||||
closeReview,
|
||||
keepChatting,
|
||||
updateDraft,
|
||||
isValidForLaunch,
|
||||
launchTask,
|
||||
startAnother,
|
||||
isLaunching: createTask.isPending,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import api from "./client";
|
||||
import type { Team, TaskType, Complexity } from "@/types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface DraftProposal {
|
||||
title: string;
|
||||
description: string;
|
||||
acceptance_criteria: string[];
|
||||
team: Team;
|
||||
priority?: number;
|
||||
task_type?: TaskType;
|
||||
estimated_complexity?: Complexity;
|
||||
}
|
||||
|
||||
export interface ChatResponse {
|
||||
reply: string;
|
||||
draft?: DraftProposal | null;
|
||||
session_id: string;
|
||||
}
|
||||
|
||||
export interface CreateSessionResponse {
|
||||
session_id: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// API functions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const prompterApi = {
|
||||
/**
|
||||
* Create a new prompter session, returning a session ID.
|
||||
*/
|
||||
createSession: async (): Promise<CreateSessionResponse> => {
|
||||
const { data } = await api.post<CreateSessionResponse>("/prompter/sessions");
|
||||
return data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Send a chat message in an existing session.
|
||||
* Returns the assistant reply and, if ready, a draft task proposal.
|
||||
*/
|
||||
sendMessage: async (
|
||||
sessionId: string,
|
||||
message: string
|
||||
): Promise<ChatResponse> => {
|
||||
const { data } = await api.post<ChatResponse>(
|
||||
`/prompter/sessions/${sessionId}/chat`,
|
||||
{ message }
|
||||
);
|
||||
return data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch the current draft for a session (if the LLM has produced one).
|
||||
*/
|
||||
getDraft: async (sessionId: string): Promise<DraftProposal | null> => {
|
||||
const { data } = await api.get<{ draft: DraftProposal | null }>(
|
||||
`/prompter/sessions/${sessionId}/draft`
|
||||
);
|
||||
return data.draft;
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user