- 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
233 lines
7.9 KiB
TypeScript
233 lines
7.9 KiB
TypeScript
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 (
|
|
<div className="text-destructive text-[13px]">Could not render spec.</div>
|
|
);
|
|
}
|
|
|
|
return <ChatSpecCardInner spec={parsedSpec} messageId={messageId} conversationId={conversationId} onHandoff={onHandoff} />;
|
|
}
|
|
|
|
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<SpecContent>(initialSpec);
|
|
const [editedSpec, setEditedSpec] = useState<SpecContent>(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 (
|
|
<div
|
|
role="region"
|
|
aria-label="Specification"
|
|
className={cn(
|
|
"motion-safe:animate-in motion-safe:fade-in motion-safe:slide-in-from-bottom-2",
|
|
"rounded-lg border border-border bg-card p-4 max-w-[480px]"
|
|
)}
|
|
>
|
|
<div className="space-y-4">
|
|
<div>
|
|
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">What</p>
|
|
<textarea
|
|
value={editedSpec.what}
|
|
onChange={(e) => setEditedSpec((s) => ({ ...s, what: e.target.value }))}
|
|
aria-label="What to build"
|
|
placeholder="What should be built?"
|
|
className="mt-1 w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm resize-none min-h-[60px] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
|
tabIndex={1}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Escape") handleDiscard();
|
|
}}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">Why</p>
|
|
<textarea
|
|
value={editedSpec.why}
|
|
onChange={(e) => setEditedSpec((s) => ({ ...s, why: e.target.value }))}
|
|
aria-label="Why it matters"
|
|
placeholder="Why is this important?"
|
|
className="mt-1 w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm resize-none min-h-[60px] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
|
tabIndex={2}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Escape") handleDiscard();
|
|
}}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">Constraints</p>
|
|
<textarea
|
|
value={editedSpec.constraints}
|
|
onChange={(e) => setEditedSpec((s) => ({ ...s, constraints: e.target.value }))}
|
|
aria-label="Constraints"
|
|
placeholder="Any constraints or requirements?"
|
|
className="mt-1 w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm resize-none min-h-[60px] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
|
tabIndex={3}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Escape") handleDiscard();
|
|
}}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">Success</p>
|
|
<textarea
|
|
value={editedSpec.success}
|
|
onChange={(e) => setEditedSpec((s) => ({ ...s, success: e.target.value }))}
|
|
aria-label="Success criteria"
|
|
placeholder="How will success be measured?"
|
|
className="mt-1 w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm resize-none min-h-[60px] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
|
tabIndex={4}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Escape") handleDiscard();
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="flex gap-2 pt-4 border-t border-border mt-4">
|
|
<Button
|
|
variant="default"
|
|
size="sm"
|
|
disabled={allFieldsEmpty}
|
|
onClick={handleSaveChanges}
|
|
tabIndex={5}
|
|
>
|
|
Save changes
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={handleDiscard}
|
|
tabIndex={6}
|
|
>
|
|
Discard
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
role="region"
|
|
aria-label="Specification"
|
|
aria-busy={isSubmitting ? "true" : undefined}
|
|
aria-disabled={isSubmitting ? "true" : undefined}
|
|
className={cn(
|
|
"motion-safe:animate-in motion-safe:fade-in motion-safe:slide-in-from-bottom-2",
|
|
"rounded-lg border border-border bg-card p-4 max-w-[480px]"
|
|
)}
|
|
>
|
|
{isDraft && (
|
|
<p className="text-[11px] text-muted-foreground ml-2 mb-2">[Draft]</p>
|
|
)}
|
|
<div className="space-y-4">
|
|
<div>
|
|
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">What</p>
|
|
<p className="text-[15px] font-normal text-foreground leading-relaxed">{spec.what}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">Why</p>
|
|
<p className="text-[15px] font-normal text-foreground leading-relaxed">{spec.why}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">Constraints</p>
|
|
<p className="text-[15px] font-normal text-foreground leading-relaxed">{spec.constraints}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">Success</p>
|
|
<p className="text-[15px] font-normal text-foreground leading-relaxed">{spec.success}</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex gap-2 pt-4 border-t border-border mt-4">
|
|
<Button
|
|
variant="default"
|
|
size="sm"
|
|
disabled={isSubmitting}
|
|
onClick={handleSendToPM}
|
|
aria-disabled={isSubmitting ? "true" : undefined}
|
|
>
|
|
Send to PM
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
disabled={isSubmitting}
|
|
onClick={() => {
|
|
setEditedSpec(spec);
|
|
setIsEditing(true);
|
|
}}
|
|
>
|
|
Edit
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
disabled={isSubmitting}
|
|
onClick={() => setIsDraft(true)}
|
|
>
|
|
Save as Draft
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|