diff --git a/components/editor/ai-chat-cards.tsx b/components/editor/ai-chat-cards.tsx new file mode 100644 index 0000000..0b4c4c4 --- /dev/null +++ b/components/editor/ai-chat-cards.tsx @@ -0,0 +1,304 @@ +"use client"; + +import { FormEvent, useId, useState } from "react"; +import { Check, ChevronDown } from "lucide-react"; + +import type { ClarifyAnswer, ClarifyQuestion, DesignPlan, DesignResultSummary } from "@/lib/ai/agent-schema"; +import { cn } from "@/lib/utils"; + +// Assistant-side cards for the AI Architect chat: clarifying questions, a +// proposed plan, and a generation result. Only the newest open card is +// interactive; older ones render read-only as part of the transcript. + +const focusClass = "outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink/60"; + +const kickerClass = "font-mono text-chrome tracking-chrome text-ink-soft uppercase"; + +const cardClass = "w-full space-y-3 rounded-paper border border-ink/20 bg-paper-bright px-3.5 py-3 font-brand text-sm text-ink"; + +const primaryButtonClass = cn( + "flex h-9 cursor-pointer items-center justify-center rounded-paper bg-ink px-3.5 font-brand text-sm font-semibold text-paper-cream", + "transition-[translate] duration-(--duration-press) active:translate-y-px disabled:cursor-not-allowed disabled:opacity-40", + focusClass, +); + +/** Components listed before the rest are folded behind "Show all". */ +const COMPONENTS_PREVIEW_COUNT = 6; + +// --------------------------------------------------------------------------- +// Questions +// --------------------------------------------------------------------------- + +interface QuestionsCardProps { + intro: string; + questions: ClarifyQuestion[]; + /** Answers the user already sent for these questions, if any. */ + answers: ClarifyAnswer[] | null; + /** True for the latest open questions while no reply is pending. */ + interactive: boolean; + onSubmit: (answers: ClarifyAnswer[]) => void; + onSkip: () => void; +} + +export function QuestionsCard({ intro, questions, answers, interactive, onSubmit, onSkip }: QuestionsCardProps) { + const idPrefix = useId(); + const [chosen, setChosen] = useState>({}); + const [typed, setTyped] = useState>({}); + + // A typed answer wins over a picked option. + const draftAnswers: ClarifyAnswer[] = questions.flatMap((question) => { + const answer = (typed[question.id] ?? "").trim() || chosen[question.id]; + return answer ? [{ questionId: question.id, answer }] : []; + }); + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + if (interactive && draftAnswers.length > 0) { + onSubmit(draftAnswers); + } + }; + + return ( +
+

+ {questions.length === 1 ? "A quick question" : `${questions.length} quick questions`} +

+ {intro ?

{intro}

: null} + +
    + {questions.map((question, index) => { + const labelId = `${idPrefix}-q${index}`; + const sent = answers?.find((entry) => entry.questionId === question.id)?.answer; + const selected = interactive ? (typed[question.id] ?? "").trim() ? undefined : chosen[question.id] : sent; + const sentIsCustom = !interactive && sent !== undefined && !question.options.includes(sent); + + return ( +
  1. +
    +

    + {index + 1}. {question.question} +

    + {question.why ?

    {question.why}

    : null} +
    + + {question.options.length > 0 ? ( +
    + {question.options.map((option) => { + const active = selected === option; + return ( + + ); + })} +
    + ) : null} + + {interactive ? ( + setTyped((previous) => ({ ...previous, [question.id]: event.target.value }))} + className={cn( + "block h-8 w-full rounded-paper border border-ink/25 bg-paper-cream px-2.5 text-xs text-ink placeholder:text-ink-soft/70", + "focus-visible:border-ink", + focusClass, + )} + /> + ) : null} + + {sentIsCustom ?

    → {sent}

    : null} +
  2. + ); + })} +
+ + {interactive ? ( +
+ + +
+ ) : null} +
+ ); +} + +// --------------------------------------------------------------------------- +// Plan +// --------------------------------------------------------------------------- + +interface PlanCardProps { + intro: string; + plan: DesignPlan; + /** True for the latest plan while no reply is pending. */ + interactive: boolean; + onGenerate: () => void; +} + +function Section({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+

{title}

+ {children} +
+ ); +} + +export function PlanCard({ intro, plan, interactive, onGenerate }: PlanCardProps) { + const [showAllComponents, setShowAllComponents] = useState(false); + const hiddenComponents = Math.max(0, plan.components.length - COMPONENTS_PREVIEW_COUNT); + const components = showAllComponents ? plan.components : plan.components.slice(0, COMPONENTS_PREVIEW_COUNT); + + return ( +
+

Proposed plan

+ {intro ?

{intro}

: null} +

{plan.summary}

+ + {plan.decisions.length > 0 ? ( +
+
    + {plan.decisions.map((decision) => ( +
  • +

    {decision.title}

    +

    {decision.choice}

    +

    {decision.rationale}

    + {decision.alternatives.length > 0 ? ( +

    Considered: {decision.alternatives.join(" · ")}

    + ) : null} +
  • + ))} +
+
+ ) : null} + +
+
    + {components.map((component) => ( +
  • +
    + {component.name} + {component.role} +
    +

    {component.responsibility}

    +
  • + ))} +
+ {hiddenComponents > 0 ? ( + + ) : null} +
+ + {plan.flows.length > 0 ? ( +
+
    + {plan.flows.map((flow) => ( +
  1. {flow}
  2. + ))} +
+
+ ) : null} + + {plan.assumptions.length > 0 ? ( +
+
    + {plan.assumptions.map((assumption) => ( +
  • {assumption}
  • + ))} +
+
+ ) : null} + + {interactive ? ( +
+ +

Or describe changes below

+
+ ) : null} +
+ ); +} + +// --------------------------------------------------------------------------- +// Result +// --------------------------------------------------------------------------- + +interface ResultCardProps { + text: string; + result: DesignResultSummary; +} + +export function ResultCard({ text, result }: ResultCardProps) { + return ( +
+

+

+ + {result.decisions.length > 0 ? ( +
+ + Decisions · {result.decisions.length} + +
    + {result.decisions.map((decision) => ( +
  • + {decision.title}:{" "} + {decision.choice} +
  • + ))} +
+
+ ) : null} +
+ ); +} diff --git a/components/editor/ai-sidebar.tsx b/components/editor/ai-sidebar.tsx index 97963a5..b77f231 100644 --- a/components/editor/ai-sidebar.tsx +++ b/components/editor/ai-sidebar.tsx @@ -4,8 +4,16 @@ import { FormEvent, KeyboardEvent, useEffect, useRef, useState } from "react"; import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"; import { ArrowRight, ChevronDown, Download, FileText, History, Loader2, Plus, Sparkle, X } from "lucide-react"; +import { PlanCard, QuestionsCard, ResultCard } from "@/components/editor/ai-chat-cards"; import { AiSessionHistory } from "@/components/editor/ai-session-history"; -import { useAiSession } from "@/hooks/use-ai-session"; +import { useAiSession, type AiChatMessage } from "@/hooks/use-ai-session"; +import { + readAnswersPayload, + readPlanPayload, + readQuestionsPayload, + readReplyPayload, + readResultPayload, +} from "@/lib/ai/agent-schema"; import { cn } from "@/lib/utils"; interface AiSidebarProps { @@ -30,6 +38,14 @@ const tabClass = cn( focusClass, ); +/** + * The newest assistant reply that did not fail. The server treats questions + * as open, and a plan as the one to draw, only while they are that reply. + */ +function latestAssistantReply(messages: readonly AiChatMessage[]): AiChatMessage | undefined { + return messages.findLast((message) => message.role === "assistant" && !message.isError); +} + function SparkleMark({ className }: { className?: string }) { return ( { if (!showHistory) { @@ -246,7 +272,7 @@ export function AiSidebar({ open, onClose, projectId }: AiSidebarProps) { ) : null} - {messages.map((message) => { + {messages.map((message, index) => { if (message.role === "user") { return (
@@ -257,6 +283,48 @@ export function AiSidebar({ open, onClose, projectId }: AiSidebarProps) { ); } + const isLatestReply = message.id === latestReply?.id && !isRunning; + + if (!message.isError && message.kind === "QUESTIONS") { + const questions = readQuestionsPayload(message.payload); + if (questions && questions.length > 0) { + const next = messages[index + 1]; + return ( + sendTurn({ type: "answers", answers })} + onSkip={() => sendTurn({ type: "skip" })} + /> + ); + } + } + + if (!message.isError && message.kind === "PLAN") { + const plan = readPlanPayload(message.payload); + if (plan) { + return ( + sendTurn({ type: "generate" })} + /> + ); + } + } + + if (!message.isError && message.kind === "RESULT") { + const result = readResultPayload(message.payload); + if (result) { + return ; + } + } + return (