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({