From aeef28d4d9424df46bf57f09be575ede85a4f809 Mon Sep 17 00:00:00 2001 From: Amos Date: Sat, 22 Aug 2026 05:56:52 +0300 Subject: [PATCH] UI iteration 4: dock morph animation, sticky-in-flow dock, scrollbar, indicators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Animation: the blob's shell morphs height (WAAPI, 180ms) on every variant change — chat ↔ question ↔ collapsed bar — plus a quick fade/rise (160ms) on the swapped content. Gotcha pinned in code: never measure the shell while a morph is in flight (getBoundingClientRect reads the ANIMATED value and corrupts the next morph's start height); cancel running morphs before measuring, refresh settled height only when no morph is running. - Gradient-over-scrollbar fixed structurally: the dock is no longer an absolute overlay on the conversation root — it's sticky bottom-0 INSIDE the scroller (in flow). The transcript scrolls fully clear of the dock, the fade never touches the scrollbar lane, and the pb-hack clearances are gone. - New question while at the bottom scrolls to reveal the dock; scrolled up → collapsed bar, no yank. - Thin dark native scrollbar app-wide (8px webkit + scrollbar-width:thin, dark thumb, transparent track). Note: use-stick-to-bottom sets scrollbar-gutter: stable both-edges on its scroller — the lane is outside the content flow by design. - 'Writing a question…' transcript shimmer while a tool call streams its input (same slot as 'Thinking…'). Protocol pinned by a test asserting tool-input-start precedes tool-input-available; scripted mock now streams tool input as deltas like real providers do. (OpenRouter/ Anthropic often delivers the whole input in ONE delta — then the dock appears instantly and no indicator is needed or shown.) Verified live: grow 54→349px and shrink 349→54px fire with correct from/to; collapse/expand intact; gradient stops before the gutter; Thinking… observed in-page; 58/58 tests. --- app/globals.css | 43 ++++++ components/chat.tsx | 286 ++++++++++++++++++++++------------- components/question-dock.tsx | 40 ++--- tests/chat-flow.test.ts | 23 +++ tests/render-cards.test.tsx | 8 +- 5 files changed, 265 insertions(+), 135 deletions(-) diff --git a/app/globals.css b/app/globals.css index 937c93b..602696b 100644 --- a/app/globals.css +++ b/app/globals.css @@ -84,3 +84,46 @@ body { background: var(--color-background); color: var(--color-foreground); } + +/* Thin dark native scrollbar (app-wide): dark thumb, transparent track. */ +* { + scrollbar-width: thin; + scrollbar-color: var(--color-border) transparent; +} + +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: var(--color-border); + border-radius: 8px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--color-muted-foreground); +} + +/* Dock variant swap — quick fade/rise on content change; the shell's HEIGHT + morph is separate (WAAPI in DockOverlay). Re-applied via class toggling, no + remount, so collapsed-question state survives. */ +@keyframes dock-variant-in { + from { + opacity: 0; + transform: translateY(6px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +.dock-variant { + animation: dock-variant-in 160ms cubic-bezier(0.2, 0.8, 0.2, 1); +} diff --git a/components/chat.tsx b/components/chat.tsx index 6a1e4b1..94ab2da 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -1,8 +1,9 @@ "use client"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useLayoutEffect, useRef, useState } from "react"; import { useChat } from "@ai-sdk/react"; import { DefaultChatTransport, isToolUIPart, lastAssistantMessageIsCompleteWithToolCalls } from "ai"; +import { useStickToBottomContext } from "use-stick-to-bottom"; import { Conversation, ConversationContent, @@ -24,19 +25,17 @@ import { findPendingToolCall } from "@/src/core/pending"; /** One width for everything: transcript, cards, and the bottom blob share this column. */ const COLUMN = "mx-auto w-full max-w-3xl px-4"; +/** useLayoutEffect that is silent during SSR (client components still render on the server in Next). */ +const useIsoLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect; + /** * The teaching session. All chat state lives in useChat; the server persists * the canonical transcript after every turn. * - * 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. + * The bottom blob is ONE persistent shell whose contents swap (chat composer ↔ + * question dock ↔ collapsed bar). The shell is sticky INSIDE the scroller — + * never overlapping the transcript at rest, never covering the scrollbar — + * and DockOverlay animates its height on variant changes. */ export function Chat({ chatId, @@ -71,8 +70,8 @@ export function Chat({ const pending = findPendingToolCall(messages); const busy = status === "submitted" || status === "streaming"; - // A tool call still streaming its input (question being generated): the - // blob shows a shimmer instead of the composer. + // A tool call still streaming its input: the teacher is writing a question. + // Shown as a transcript shimmer, exactly like the thinking indicator. const questionStreaming = !pending && messages.at(-1)?.role === "assistant" && @@ -95,108 +94,187 @@ export function Chat({
{title}
- {/* Transcript scrolls full-height; the blob floats on top of it. */}
- - {messages.length === 0 && !busy && ( - - )} + +
+ {messages.length === 0 && !busy && ( + + )} - {messages.map((m, mi) => ( - - - {m.role === "user" ? ( - // User text renders plain: the model's LaTeX pipeline is not - // applied to learner-authored text ($ stays $, no surprises). -
- {m.parts.filter((p) => p.type === "text").map((p) => p.text).join("\n")} -
- ) : ( - m.parts.map((part, pi) => { - const isLastPart = mi === messages.length - 1 && pi === m.parts.length - 1; - switch (part.type) { - case "text": - return {part.text}; - case "reasoning": - return ( - - - {part.text} - - ); - case "step-start": - return pi > 0 ? : null; - case "tool-quiz": - case "tool-ask_user_question": { - // Pending/streaming questions live in the dock, not the - // transcript; answered ones render as graded records. - if (part.state === "output-available") { - return ; + {messages.map((m, mi) => ( + + + {m.role === "user" ? ( + // User text renders plain: the model's LaTeX pipeline is not + // applied to learner-authored text ($ stays $, no surprises). +
+ {m.parts.filter((p) => p.type === "text").map((p) => p.text).join("\n")} +
+ ) : ( + m.parts.map((part, pi) => { + const isLastPart = mi === messages.length - 1 && pi === m.parts.length - 1; + switch (part.type) { + case "text": + return {part.text}; + case "reasoning": + return ( + + + {part.text} + + ); + case "step-start": + return pi > 0 ? : null; + case "tool-quiz": + case "tool-ask_user_question": { + // Pending/streaming questions live in the dock, not the + // transcript; answered ones render as graded records. + if (part.state === "output-available") { + return ; + } + return null; } - return null; + default: + return null; } - default: - return null; - } - }) - )} -
-
- ))} + }) + )} +
+
+ ))} - {status === "submitted" && Thinking…} - {error && ( -
- Something went wrong: {error.message} -
- )} + {status === "submitted" && Thinking…} + {questionStreaming && Writing a question…} + {error && ( +
+ Something went wrong: {error.message} +
+ )} +
+ + stop()} + canSend={canSend} + /> + } + onSubmitAnswer={(output) => { + if (!pending) return; + void addToolOutput({ + tool: pending.tool as "quiz" | "ask_user_question", + toolCallId: pending.toolCallId, + output, + }); + }} + />
- - {/* 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} - /> - )} -
-
-
-
); } + +/** + * The floating blob: ONE persistent shell, sticky at the bottom of the + * scroller (in flow — the transcript scrolls fully clear of it, and it never + * covers the scrollbar). Contents swap between the chat composer and the + * question dock; shell height morphs between variants (WAAPI, quick). + * + * Collapse: scroll up and the dock folds into a slim bar; click it (or scroll + * back to the bottom) to re-expand. The question form stays MOUNTED while + * collapsed (hidden, not unmounted) so a half-made selection survives. + */ +function DockOverlay({ + pending, + composer, + onSubmitAnswer, +}: { + pending: ReturnType; + composer: React.ReactNode; + onSubmitAnswer: (output: QuestionAnswerData) => void; +}) { + const { isAtBottom, scrollToBottom } = useStickToBottomContext(); + const [forceOpen, setForceOpen] = useState(false); + useEffect(() => { + if (!isAtBottom) setForceOpen(false); + }, [isAtBottom]); + const expanded = isAtBottom || forceOpen; + + const mode = pending ? `question:${pending.toolCallId}` : "chat"; + + // A new question while the learner is at the bottom: scroll so the dock + // never covers the transcript tail. (Scrolled up → collapsed bar, no yank.) + const pendingId = pending?.toolCallId ?? null; + useEffect(() => { + if (pendingId && isAtBottom) void scrollToBottom(); + // eslint-disable-next-line react-hooks/exhaustive-deps -- fire only when a NEW question lands + }, [pendingId]); + + // Height morph: animate the shell from its previous height to its new one + // whenever the variant (chat ↔ question ↔ collapsed bar) changes. Typing + // growth keeps the same stateKey and is NOT animated. + const shellRef = useRef(null); + const stateKey = `${mode}:${pending ? expanded : "-"}`; + const prevRef = useRef<{ key: string; height: number } | null>(null); + useIsoLayoutEffect(() => { + const el = shellRef.current; + if (!el) return; + const p = prevRef.current; + if (p && p.key === stateKey) { + // Same variant: keep the settled height fresh, but never measure while + // a morph is in flight — getBoundingClientRect would read the ANIMATED + // value and corrupt the start height of the next morph. + if (el.getAnimations().length === 0) p.height = el.getBoundingClientRect().height; + return; + } + el.getAnimations().forEach((a) => a.cancel()); // flush to natural height before measuring + const h = el.getBoundingClientRect().height; + if (p && Math.abs(p.height - h) > 1) { + el.animate([{ height: `${p.height}px` }, { height: `${h}px` }], { + duration: 180, + easing: "cubic-bezier(0.2, 0.8, 0.2, 1)", + }); + } + prevRef.current = { key: stateKey, height: h }; + }); + + return ( +
+
+
+
+
+
+ {pending ? ( + { + setForceOpen(true); + void scrollToBottom(); + }} + onSubmit={onSubmitAnswer} + /> + ) : ( + composer + )} +
+
+
+
+
+ ); +} diff --git a/components/question-dock.tsx b/components/question-dock.tsx index fa37f7c..a585a74 100644 --- a/components/question-dock.tsx +++ b/components/question-dock.tsx @@ -1,8 +1,7 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; +import { useMemo, useState } from "react"; import { ArrowDownIcon } from "lucide-react"; -import { useStickToBottomContext } from "use-stick-to-bottom"; import { Button } from "@/components/ui/button"; import { MessageResponse } from "@/components/ai-elements/message"; import { cn } from "@/lib/utils"; @@ -26,40 +25,27 @@ import { * the bottom field (note for select modes, the answer for free-text asks, * "Other" text when Other is picked) plus the Submit button send together. * - * Collapse: scroll up and the dock folds into a slim bar (question one-liner); - * click it (or scroll back to the bottom) to re-expand. The expanded content - * stays MOUNTED while collapsed (hidden, not unmounted) so a half-made - * selection survives scrolling. - * - * Rendered inside to read the stick-to-bottom context. - * Content only — the persistent shell lives in the chat view's overlay. + * Collapse behavior (scroll-up → slim bar) is owned by the DockOverlay in the + * chat view, which also owns the persistent shell and its height morph. This + * component is content only. The expanded form stays MOUNTED while collapsed + * (hidden, not unmounted) so a half-made selection survives scrolling. */ export interface QuestionDockProps { toolName: "quiz" | "ask_user_question"; toolCallId: string; input: QuizToolInput | AskToolInput; + /** Collapse state, owned by the DockOverlay (it drives the shell's height morph). */ + expanded: boolean; + onExpand: () => void; onSubmit: (output: QuestionAnswerData) => void; } -export function QuestionDock({ toolName, toolCallId, input, onSubmit }: QuestionDockProps) { - const { isAtBottom, scrollToBottom } = useStickToBottomContext(); - const [forceOpen, setForceOpen] = useState(false); - useEffect(() => { - if (!isAtBottom) setForceOpen(false); - }, [isAtBottom]); - const expanded = isAtBottom || forceOpen; - - const expand = () => { - setForceOpen(true); - void scrollToBottom(); - }; - +export function QuestionDock({ toolName, toolCallId, input, expanded, onExpand, onSubmit }: QuestionDockProps) { return ( <> - {!expanded && } - {/* Stays mounted while collapsed — selection state survives scrolling. */} -
+ {!expanded && } +
{toolName === "quiz" ? "Quiz" : "Question"} @@ -111,7 +97,7 @@ export function QuestionDockBar({ } /** The expanded question: label, question, options, and the unified bottom row. */ -function QuestionDockForm({ toolName, toolCallId, input, onSubmit }: QuestionDockProps) { +function QuestionDockForm({ toolName, toolCallId, input, onSubmit }: Omit) { const kind = toolName === "quiz" ? ("quiz" as const) : ("ask" as const); // Canonical QuestionData, built once per question. Quiz options are diff --git a/tests/chat-flow.test.ts b/tests/chat-flow.test.ts index 16d573f..0588064 100644 --- a/tests/chat-flow.test.ts +++ b/tests/chat-flow.test.ts @@ -57,6 +57,12 @@ function scriptedModel(calls: string[][]) { { type: "reasoning-start", id: "r1" }, { type: "reasoning-delta", id: "r1", delta: "Probe the basics first." }, { type: "reasoning-end", id: "r1" }, + // Same shape real providers use: input streams as deltas, + // then the completed call. (This is what makes the client's + // 'Writing a question…' indicator possible.) + { type: "tool-input-start", id: "call-1", toolName: "quiz" }, + { type: "tool-input-delta", id: "call-1", delta: JSON.stringify(QUIZ_INPUT) }, + { type: "tool-input-end", id: "call-1" }, { type: "tool-call", toolCallId: "call-1", @@ -188,6 +194,23 @@ describe("teacher chat flow (scripted model)", () => { assert.equal(res.status, 404); }); + it("streams tool-input-start before tool-input-available (protocol pin for the 'Writing a question…' indicator)", async () => { + // The client's writing indicator keys off tool parts in state + // 'input-streaming', which the stream processor sets from the + // tool-input-start chunk. If a transport change ever stopped emitting + // it, the indicator would silently die — pin the protocol. + const agent = createTeacherAgent(scriptedModel([])); + const chat = createChat("Test: protocol pin"); + createdChats.push(chat.id); + const firstUser = { id: "u1", role: "user", parts: [{ type: "text", text: "quiz me" }] }; + const body = await drain(await handleChatRequest(post(chat.id, [firstUser]), agent)); + const startIdx = body.indexOf('"type":"tool-input-start"'); + const availIdx = body.indexOf('"type":"tool-input-available"'); + assert.ok(startIdx !== -1, "tool-input-start chunk present in the stream"); + assert.ok(availIdx !== -1, "tool-input-available chunk present in the stream"); + assert.ok(startIdx < availIdx, "start precedes available"); + }); + it("persists even when the stream is aborted (resume keeps partial state)", async () => { const calls: string[][] = []; const agent = createTeacherAgent(scriptedModel(calls)); diff --git a/tests/render-cards.test.tsx b/tests/render-cards.test.tsx index 5174931..99ffc78 100644 --- a/tests/render-cards.test.tsx +++ b/tests/render-cards.test.tsx @@ -16,7 +16,6 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; import { renderToStaticMarkup } from "react-dom/server"; import { MessageResponse } from "../components/ai-elements/message.tsx"; -import { StickToBottom } from "use-stick-to-bottom"; import { QuestionDock } from "../components/question-dock.tsx"; import { AnsweredCard } from "../components/answered-card.tsx"; import { buildAnswer, coerceCorrectAnswer, normalizeOptions, resolveCorrect, type QuestionData } from "../src/core/question-core.ts"; @@ -44,10 +43,11 @@ function quizQD(): QuestionData { function renderDock(props: { toolName: "quiz" | "ask_user_question"; toolCallId: string; input: never; onSubmit?: () => void }) { - // The dock reads the stick-to-bottom scroll context; wrap it. In static - // render the context starts at isAtBottom=true → the expanded form renders. + // expanded=true renders the full form (the collapsed bar is covered by the + // QuestionDockBar test below). No scroll context needed — the DockOverlay + // in the chat view owns that. return renderToStaticMarkup( - {})} />, + {}} onSubmit={props.onSubmit ?? (() => {})} />, ); }