UI iteration 4: dock morph animation, sticky-in-flow dock, scrollbar, indicators
- 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.
This commit is contained in:
@@ -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));
|
||||
|
||||
@@ -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(
|
||||
<StickToBottom><QuestionDock {...props} onSubmit={props.onSubmit ?? (() => {})} /></StickToBottom>,
|
||||
<QuestionDock {...props} expanded={true} onExpand={() => {}} onSubmit={props.onSubmit ?? (() => {})} />,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user