nexus/ui/src/components/ChatSpecCard.tsx
Nexus Dev a424e96dd7 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
2026-04-04 03:55:47 +00:00

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>
);
}