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 (
+
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+ {isDraft && (
+
[Draft]
+ )}
+
+
+
+
+
Constraints
+
{spec.constraints}
+
+
+
Success
+
{spec.success}
+
+
+
+
+
+
+
+
+ );
+}