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,
|
GitBranch,
|
||||||
Database,
|
Database,
|
||||||
Cpu,
|
Cpu,
|
||||||
|
Sparkles,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
@@ -33,6 +34,7 @@ const navItems = [
|
|||||||
// Work Management
|
// Work Management
|
||||||
{ title: "Tasks", href: "/tasks", icon: ListTodo },
|
{ title: "Tasks", href: "/tasks", icon: ListTodo },
|
||||||
{ title: "Kanban", href: "/kanban", icon: Kanban },
|
{ title: "Kanban", href: "/kanban", icon: Kanban },
|
||||||
|
{ title: "Task Assistant", href: "/prompter", icon: Sparkles },
|
||||||
|
|
||||||
// Development
|
// Development
|
||||||
{ title: "Projects", href: "/projects", icon: FolderGit2 },
|
{ 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