From cd789c11b1c0055d9b7a1f0b7ec9591a0f842376 Mon Sep 17 00:00:00 2001 From: Amos Date: Sat, 22 Aug 2026 05:02:15 +0300 Subject: [PATCH] =?UTF-8?q?UI=20iteration=203:=20question=20dock=20?= =?UTF-8?q?=E2=80=94=20pending=20questions=20morph=20the=20composer=20blob?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A pending quiz/ask now REPLACES the chat composer: one persistent rounded-2xl shell in the transcript overlay swaps contents (composer ↔ question ↔ collapsed bar), never remounting — the structure a future grow-morph animation needs. - Unified bottom row inside the question: the note field (select modes), the answer field (text-mode asks), or the Other text — one field, one 'Submit answer' button, Enter submits. - Collapse on scroll: uses useStickToBottomContext — scroll up and the dock folds to a slim bar (kind chip + LaTeX-flattened question teaser + 'tap to answer'); click scrolls to bottom and re-expands. The expanded form stays mounted-hidden so a half-made selection survives. - Pending/streaming tool parts no longer render inline; answered parts land in the transcript as graded cards on submit. Question generation shows a shimmer in the shell. - Composer back to pure chat (no mode machinery); quiz-card.tsx and the pendingText/registerSubmit bridge deleted; render tests re-pointed at the dock (wrapped in StickToBottom). Verified live: docked quiz submit (note+selection), ask-text Enter submit, collapse/expand with selection survival, bar teaser text. --- components/chat.tsx | 154 +++++++--------- components/composer.tsx | 82 +++------ components/question-dock.tsx | 325 +++++++++++++++++++++++++++++++++ components/quiz-card.tsx | 242 ------------------------ tests/render-cards.test.tsx | 48 ++--- tests/render-composer.test.tsx | 41 ++--- 6 files changed, 461 insertions(+), 431 deletions(-) create mode 100644 components/question-dock.tsx delete mode 100644 components/quiz-card.tsx diff --git a/components/chat.tsx b/components/chat.tsx index 9047040..6a1e4b1 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from "react"; import { useChat } from "@ai-sdk/react"; -import { DefaultChatTransport, lastAssistantMessageIsCompleteWithToolCalls } from "ai"; +import { DefaultChatTransport, isToolUIPart, lastAssistantMessageIsCompleteWithToolCalls } from "ai"; import { Conversation, ConversationContent, @@ -13,26 +13,30 @@ import { Message, MessageContent, MessageResponse } from "@/components/ai-elemen import { Reasoning, ReasoningContent, ReasoningTrigger } from "@/components/ai-elements/reasoning"; import { Shimmer } from "@/components/ai-elements/shimmer"; import { Separator } from "@/components/ui/separator"; -import { QuizCard, type QuizCardSubmitApi } from "@/components/quiz-card"; import { AnsweredCard } from "@/components/answered-card"; -import { Composer, type ComposerMode } from "@/components/composer"; +import { Composer } from "@/components/composer"; +import { QuestionDock } from "@/components/question-dock"; import { SidebarToggle } from "@/components/sidebar"; import type { TeacherUIMessage } from "@/src/agent/ui-message"; -import type { AskToolInput } from "@/src/agent/tools"; import type { QuestionAnswerData } from "@/src/core/question-core"; import { findPendingToolCall } from "@/src/core/pending"; -/** One width for everything: transcript, cards, composer all share this column. */ +/** One width for everything: transcript, cards, and the bottom blob share this column. */ const COLUMN = "mx-auto w-full max-w-3xl px-4"; /** * The teaching session. All chat state lives in useChat; the server persists - * the canonical transcript after every turn. A pending quiz/ask is just an - * unanswered tool part — so reload/resume mid-question works for free. + * the canonical transcript after every turn. * - * The composer morphs while a question is pending (one text field on screen): - * select-mode question → the blob is the note field; free-text ask → the blob - * is the answer field and its send submits via the card's registered API. + * The bottom blob is ONE persistent shell (never remounts — this is what a + * future chat→question morph animation will animate). Its contents swap: + * - no pending question → the chat Composer + * - question streaming in → "Preparing a question…" shimmer + * - pending quiz/ask → the QuestionDock (question + options + the + * unified note/answer field + Submit answer) + * A pending question is always the transcript tail (the lesson can't continue + * before it's answered), so docking it at the bottom loses nothing; on submit + * it lands in the transcript as an AnsweredCard. */ export function Chat({ chatId, @@ -67,43 +71,23 @@ export function Chat({ const pending = findPendingToolCall(messages); const busy = status === "submitted" || status === "streaming"; - // The single free-text field: chat input normally; note/answer while pending. + // A tool call still streaming its input (question being generated): the + // blob shows a shimmer instead of the composer. + const questionStreaming = + !pending && + messages.at(-1)?.role === "assistant" && + (messages.at(-1)?.parts.some((p) => isToolUIPart(p) && p.state === "input-streaming") ?? false); + const [text, setText] = useState(""); - // The pending card registers its submit so the composer can send on Enter. - const cardApi = useRef(null); - - // Composer mode: free-text ask → "answer"; any other pending question → "note". - const pendingIsTextMode = - pending?.tool === "ask_user_question" && - (() => { - const ask = pending.input as AskToolInput; - return ask.mode === "text" || !ask.options?.length; - })(); - const mode: ComposerMode = pending ? (pendingIsTextMode ? "answer" : "note") : "chat"; - - // canSend mirrors the card's own predicate (text-mode: non-empty text). - // Reading cardApi.current here would lag one commit behind — the card - // re-registers its api in an effect AFTER this render pass. - const canSend = mode === "chat" ? text.trim().length > 0 && !pending : text.trim().length > 0; + const canSend = text.trim().length > 0 && !pending; const onSend = () => { - if (mode === "chat") { - const t = text.trim(); - if (!t || busy || pending) return; - setText(""); - void sendMessage({ text: t }); - } else if (mode === "answer") { - cardApi.current?.submit(); - } + const t = text.trim(); + if (!t || busy || pending) return; + setText(""); + void sendMessage({ text: t }); }; - const hint = - mode === "note" - ? "Answering the question above — text here becomes a note on your answer." - : mode === "answer" - ? "Type your answer to the question above." - : null; - return (
@@ -111,10 +95,10 @@ export function Chat({
{title}
- {/* Transcript scrolls full-height; the composer floats on top. */} + {/* Transcript scrolls full-height; the blob floats on top of it. */}
- + {messages.length === 0 && !busy && ( 0 ? : null; case "tool-quiz": case "tool-ask_user_question": { - const tool = part.type === "tool-quiz" ? ("quiz" as const) : ("ask_user_question" as const); + // Pending/streaming questions live in the dock, not the + // transcript; answered ones render as graded records. if (part.state === "output-available") { return ; } - if (part.state === "input-available") { - const isPending = pending?.toolCallId === part.toolCallId; - return ( - {}} - registerSubmit={isPending ? (api) => (cardApi.current = api) : undefined} - onSubmit={(output) => { - setText(""); - void addToolOutput({ tool, toolCallId: part.toolCallId, output }); - }} - /> - ); - } - // input-streaming: the question is still being generated - return ( -
- Preparing a question… -
- ); + return null; } default: return null; @@ -194,28 +156,46 @@ export function Chat({ )}
-
- {/* Floating composer: content fades out behind it. */} -
-
-
-
- {hint &&
{hint}
} -
- stop()} - canSend={canSend} - /> + {/* The floating blob. ONE persistent shell — contents swap by mode. + Content fades out behind it. */} +
+
+
+
+
+ {pending ? ( + + void addToolOutput({ + tool: pending.tool as "quiz" | "ask_user_question", + toolCallId: pending.toolCallId, + output, + }) + } + /> + ) : questionStreaming ? ( +
+ Preparing a question… +
+ ) : ( + stop()} + canSend={canSend} + /> + )} +
-
+
); diff --git a/components/composer.tsx b/components/composer.tsx index f41c0b4..353c158 100644 --- a/components/composer.tsx +++ b/components/composer.tsx @@ -4,31 +4,14 @@ import { CornerDownLeftIcon, SquareIcon } from "lucide-react"; import { cn } from "@/lib/utils"; /** - * The floating composer blob. Sits on top of the transcript (the parent - * overlays it), persists at the bottom while scrolling, content fades out - * behind it. + * The chat composer — content row ONLY (the persistent rounded shell lives in + * the chat view's overlay, so the shell can later morph chat → question + * without remounting). * - * Corner-radius math (concentric rounding — outer = inner + gap): - * blob rounded-2xl (16px) − padding p-1.5 (6px) = button rounded-[10px]. - * 10px is 25% of the 40px send button — matches the reference. - * - * Modes (one text field on screen, always): - * chat — normal message composer. - * note — a question is pending; text here becomes the answer's note. - * No send action (the card's Submit sends); Enter = newline. - * answer — a free-text ask is pending; this field IS the answer and the - * send button submits it. + * Corner-radius math (concentric with the shell): shell rounded-2xl (16px) − + * row padding p-1.5 (6px) = button rounded-[10px] = 25% of the 40px button. */ -export type ComposerMode = "chat" | "note" | "answer"; - -const PLACEHOLDERS: Record = { - chat: "Ask, answer, or steer the lesson…", - note: "Add a note (optional) — anything you're unsure about", - answer: "Type your answer…", -}; - export function Composer({ - mode, busy, value, onChange, @@ -37,7 +20,6 @@ export function Composer({ canSend, autoFocus, }: { - mode: ComposerMode; busy: boolean; value: string; onChange: (v: string) => void; @@ -46,52 +28,44 @@ export function Composer({ canSend: boolean; autoFocus?: boolean; }) { - const showSend = mode !== "note"; - - const handleKeyDown = (e: React.KeyboardEvent) => { - // Enter sends (chat/answer); Shift+Enter newline. Note mode: no send. - if (e.key === "Enter" && !e.shiftKey && mode !== "note") { - e.preventDefault(); - if (busy) onStop(); - else if (canSend) onSend(); - } - }; - return (
{ e.preventDefault(); if (busy) onStop(); - else if (canSend && showSend) onSend(); + else if (canSend) onSend(); }} >