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
@@ -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&apos;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>
);
}
+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>
);
}
+264
View File
@@ -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,
};
}
+65
View File
@@ -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;
},
};