diff --git a/panel/src/app/(dashboard)/prompter/page.tsx b/panel/src/app/(dashboard)/prompter/page.tsx
new file mode 100644
index 00000000..a9641a45
--- /dev/null
+++ b/panel/src/app/(dashboard)/prompter/page.tsx
@@ -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 (
+
+ {/* Page header */}
+
+
+
+
Task Assistant
+
+ Describe your idea and I'll help you create a structured task
+
+
+
+
+ {/* Chat area */}
+
+ {/* Success overlay in chat area */}
+ {state === "success" &&
+ createdTaskId &&
+ createdTaskTitle &&
+ createdTaskTeam ? (
+
+ ) : (
+
+ )}
+
+ {/* Composer */}
+
+
+
+ {/* Confirmation dialog (portal) */}
+
+
+ );
+}
diff --git a/panel/src/components/layout/sidebar.tsx b/panel/src/components/layout/sidebar.tsx
index 96f8d6d6..bc5ff1c9 100644
--- a/panel/src/components/layout/sidebar.tsx
+++ b/panel/src/components/layout/sidebar.tsx
@@ -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 },
diff --git a/panel/src/components/prompter/chat-composer.tsx b/panel/src/components/prompter/chat-composer.tsx
new file mode 100644
index 00000000..6eb36556
--- /dev/null
+++ b/panel/src/components/prompter/chat-composer.tsx
@@ -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;
+ 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(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) => {
+ if (e.key === "Enter" && !e.shiftKey) {
+ e.preventDefault();
+ handleSend();
+ }
+ // Shift+Enter falls through to default (inserts newline)
+ };
+
+ return (
+
+
+ );
+}
diff --git a/panel/src/components/prompter/chat-messages.tsx b/panel/src/components/prompter/chat-messages.tsx
new file mode 100644
index 00000000..3622bfb1
--- /dev/null
+++ b/panel/src/components/prompter/chat-messages.tsx
@@ -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(null);
+
+ // Auto-scroll to bottom whenever messages change
+ useEffect(() => {
+ bottomRef.current?.scrollIntoView({ behavior: "smooth" });
+ }, [messages]);
+
+ if (messages.length === 0) {
+ return (
+
+
What would you like to build?
+
+ Describe your task idea. I'll help you refine it into a structured task with acceptance
+ criteria ready to hand off to the team.
+
+
+ );
+ }
+
+ return (
+
+ {messages.map((msg) => {
+ if (msg.role === "user") {
+ return (
+
+ );
+ }
+
+ if (msg.role === "error") {
+ return (
+
+ );
+ }
+
+ // Assistant message
+ return (
+
+
+
+ {/* Inline draft proposal card when LLM offers a draft */}
+ {msg.draft && (
+
+ )}
+
+ );
+ })}
+
+ {/* Scroll anchor */}
+
+
+ );
+}
diff --git a/panel/src/components/prompter/confirm-dialog.tsx b/panel/src/components/prompter/confirm-dialog.tsx
new file mode 100644
index 00000000..1bd43112
--- /dev/null
+++ b/panel/src/components/prompter/confirm-dialog.tsx
@@ -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) => void;
+ onConfirm: () => Promise | void;
+ isLaunching: boolean;
+ isValid: boolean;
+}
+
+const WARNING_BANNER_ID = "prompter-warning-banner";
+
+export function ConfirmDialog({
+ open,
+ draft,
+ onClose,
+ onUpdate,
+ onConfirm,
+ isLaunching,
+ isValid,
+}: ConfirmDialogProps) {
+ return (
+
+ );
+}
diff --git a/panel/src/components/prompter/draft-proposal-card.tsx b/panel/src/components/prompter/draft-proposal-card.tsx
new file mode 100644
index 00000000..becd7516
--- /dev/null
+++ b/panel/src/components/prompter/draft-proposal-card.tsx
@@ -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 = {
+ 0: "Low",
+ 1: "Medium",
+ 2: "High",
+ 3: "Urgent",
+};
+
+export function DraftProposalCard({
+ draft,
+ onKeepChatting,
+ onOpenReview,
+}: DraftProposalCardProps) {
+ const priorityLabel = PRIORITY_LABELS[draft.priority ?? 2] ?? "High";
+
+ return (
+
+
+
+
+ {draft.title}
+
+
+ {draft.team && (
+
+ {draft.team}
+
+ )}
+
+ {priorityLabel}
+
+ {draft.task_type && (
+
+ {draft.task_type}
+
+ )}
+
+
+
+
+
+ {/* Description excerpt */}
+ {draft.description && (
+
+ {draft.description}
+
+ )}
+
+ {/* Acceptance criteria */}
+ {draft.acceptance_criteria.length > 0 && (
+
+
+ Acceptance criteria ({draft.acceptance_criteria.length})
+
+
+ {draft.acceptance_criteria.slice(0, 4).map((criterion, i) => (
+ -
+
+
+
+ {criterion}
+
+ ))}
+ {draft.acceptance_criteria.length > 4 && (
+ -
+ +{draft.acceptance_criteria.length - 4} more…
+
+ )}
+
+
+ )}
+
+
+
+
+
+
+
+ );
+}
diff --git a/panel/src/components/prompter/index.ts b/panel/src/components/prompter/index.ts
new file mode 100644
index 00000000..96d414a0
--- /dev/null
+++ b/panel/src/components/prompter/index.ts
@@ -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";
diff --git a/panel/src/components/prompter/success-card.tsx b/panel/src/components/prompter/success-card.tsx
new file mode 100644
index 00000000..8b95e151
--- /dev/null
+++ b/panel/src/components/prompter/success-card.tsx
@@ -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 (
+
+
+
+
+
+ Task Created Successfully
+
+
+
+
+
+ {taskTitle}
+
+
+ {team.replace("_", " ")}
+
+ ID: {taskId.slice(0, 8)}…
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/panel/src/hooks/use-prompter.ts b/panel/src/hooks/use-prompter.ts
new file mode 100644
index 00000000..22a773cf
--- /dev/null
+++ b/panel/src/hooks/use-prompter.ts
@@ -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("empty");
+ const [messages, setMessages] = useState([]);
+ const [sessionId, setSessionId] = useState(null);
+ const [isSending, setIsSending] = useState(false);
+ const [createdTaskId, setCreatedTaskId] = useState(null);
+ const [createdTaskTitle, setCreatedTaskTitle] = useState(null);
+ const [createdTaskTeam, setCreatedTaskTeam] = useState(null);
+
+ /** Draft as shown in the draft-preview card */
+ const [draftProposal, setDraftProposal] = useState(null);
+
+ /** Editable copy used in the confirmation dialog */
+ const [editableDraft, setEditableDraft] = useState({
+ 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(null);
+
+ // -----------------------------------------------------------------------
+ // Helpers
+ // -----------------------------------------------------------------------
+
+ const addMessage = useCallback((msg: Omit) => {
+ 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) => {
+ 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,
+ };
+}
diff --git a/panel/src/lib/api/prompter.ts b/panel/src/lib/api/prompter.ts
new file mode 100644
index 00000000..dad33237
--- /dev/null
+++ b/panel/src/lib/api/prompter.ts
@@ -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 => {
+ const { data } = await api.post("/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 => {
+ const { data } = await api.post(
+ `/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 => {
+ const { data } = await api.get<{ draft: DraftProposal | null }>(
+ `/prompter/sessions/${sessionId}/draft`
+ );
+ return data.draft;
+ },
+};