From a424e96dd7c2c33426fe738d7079dc121daa196c Mon Sep 17 00:00:00 2001 From: Nexus Dev Date: Wed, 1 Apr 2026 21:39:50 +0000 Subject: [PATCH] feat(23-02): add ChatSpecCard and ChatHandoffIndicator components - ChatSpecCard renders 4-field spec (What/Why/Constraints/Success) with edit mode - ChatSpecCard action row: Send to PM, Edit, Save as Draft buttons - ChatSpecCard edit mode with aria-labels, Escape key discard, tab order - ChatHandoffIndicator separator-style with flanking hr and aria-label --- ui/src/components/ChatHandoffIndicator.tsx | 21 ++ ui/src/components/ChatSpecCard.tsx | 233 +++++++++++++++++++++ 2 files changed, 254 insertions(+) create mode 100644 ui/src/components/ChatHandoffIndicator.tsx create mode 100644 ui/src/components/ChatSpecCard.tsx diff --git a/ui/src/components/ChatHandoffIndicator.tsx b/ui/src/components/ChatHandoffIndicator.tsx new file mode 100644 index 00000000..ff6b4c8d --- /dev/null +++ b/ui/src/components/ChatHandoffIndicator.tsx @@ -0,0 +1,21 @@ +import { cn } from "../lib/utils"; + +interface ChatHandoffIndicatorProps { + content: string; +} + +export function ChatHandoffIndicator({ content }: ChatHandoffIndicatorProps) { + return ( +
+ + {content} + +
+ ); +} diff --git a/ui/src/components/ChatSpecCard.tsx b/ui/src/components/ChatSpecCard.tsx new file mode 100644 index 00000000..0a329e1c --- /dev/null +++ b/ui/src/components/ChatSpecCard.tsx @@ -0,0 +1,233 @@ +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { cn } from "../lib/utils"; +import { chatApi } from "../api/chat"; + +interface SpecContent { + what: string; + why: string; + constraints: string; + success: string; +} + +interface ChatSpecCardProps { + content: string; + messageId?: string; + conversationId?: string; + onHandoff?: (spec: SpecContent) => void; +} + +export function ChatSpecCard({ content, messageId, conversationId, onHandoff }: ChatSpecCardProps) { + let parsedSpec: SpecContent | null = null; + try { + parsedSpec = JSON.parse(content) as SpecContent; + } catch { + return ( +
Could not render spec.
+ ); + } + + return ; +} + +function ChatSpecCardInner({ + spec: initialSpec, + messageId, + conversationId, + onHandoff, +}: { + spec: SpecContent; + messageId?: string; + conversationId?: string; + onHandoff?: (spec: SpecContent) => void; +}) { + const [isEditing, setIsEditing] = useState(false); + const [isDraft, setIsDraft] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const [spec, setSpec] = useState(initialSpec); + const [editedSpec, setEditedSpec] = useState(initialSpec); + + const allFieldsEmpty = + !editedSpec.what.trim() && + !editedSpec.why.trim() && + !editedSpec.constraints.trim() && + !editedSpec.success.trim(); + + function handleDiscard() { + setEditedSpec(spec); + setIsEditing(false); + } + + async function handleSaveChanges() { + setSpec(editedSpec); + setIsEditing(false); + if (conversationId && messageId) { + await chatApi.editMessage(conversationId, messageId, JSON.stringify(editedSpec)); + } + } + + async function handleSendToPM() { + setIsSubmitting(true); + try { + onHandoff?.(spec); + } finally { + setIsSubmitting(false); + } + } + + if (isEditing) { + return ( +
+
+
+

What

+