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
This commit is contained in:
parent
ce992b28f0
commit
692aa9649c
2 changed files with 254 additions and 0 deletions
21
ui/src/components/ChatHandoffIndicator.tsx
Normal file
21
ui/src/components/ChatHandoffIndicator.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { cn } from "../lib/utils";
|
||||
|
||||
interface ChatHandoffIndicatorProps {
|
||||
content: string;
|
||||
}
|
||||
|
||||
export function ChatHandoffIndicator({ content }: ChatHandoffIndicatorProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-3 py-2 text-[13px] text-muted-foreground",
|
||||
"motion-safe:animate-in motion-safe:fade-in"
|
||||
)}
|
||||
aria-label="Agent handoff from Brainstormer to PM"
|
||||
>
|
||||
<hr className="flex-1 border-border" aria-hidden="true" />
|
||||
<span className="whitespace-nowrap">{content}</span>
|
||||
<hr className="flex-1 border-border" aria-hidden="true" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
233
ui/src/components/ChatSpecCard.tsx
Normal file
233
ui/src/components/ChatSpecCard.tsx
Normal file
|
|
@ -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 (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue