textarea]:h-auto",
-
- // Variants based on alignment.
- "has-[>[data-align=inline-start]]:[&>input]:pl-2",
- "has-[>[data-align=inline-end]]:[&>input]:pr-2",
- "has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3",
- "has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3",
-
- // Focus state.
- "has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-[3px] has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50",
-
- // Error state.
- "has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-destructive/20 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40",
-
- className
- )}
- {...props}
- />
- )
-}
-
-const inputGroupAddonVariants = cva(
- "flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
- {
- variants: {
- align: {
- "inline-start":
- "order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]",
- "inline-end":
- "order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]",
- "block-start":
- "order-first w-full justify-start px-3 pt-3 group-has-[>input]/input-group:pt-2.5 [.border-b]:pb-3",
- "block-end":
- "order-last w-full justify-start px-3 pb-3 group-has-[>input]/input-group:pb-2.5 [.border-t]:pt-3",
- },
- },
- defaultVariants: {
- align: "inline-start",
- },
- }
-)
-
-function InputGroupAddon({
- className,
- align = "inline-start",
- ...props
-}: React.ComponentProps<"div"> & VariantProps
) {
- return (
- {
- if ((e.target as HTMLElement).closest("button")) {
- return
- }
- e.currentTarget.parentElement?.querySelector("input")?.focus()
- }}
- {...props}
- />
- )
-}
-
-const inputGroupButtonVariants = cva(
- "flex items-center gap-2 text-sm shadow-none",
- {
- variants: {
- size: {
- xs: "h-6 gap-1 rounded-[calc(var(--radius)-5px)] px-2 has-[>svg]:px-2 [&>svg:not([class*='size-'])]:size-3.5",
- sm: "h-8 gap-1.5 rounded-md px-2.5 has-[>svg]:px-2.5",
- "icon-xs":
- "size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0",
- "icon-sm": "size-8 p-0 has-[>svg]:p-0",
- },
- },
- defaultVariants: {
- size: "xs",
- },
- }
-)
-
-function InputGroupButton({
- className,
- type = "button",
- variant = "ghost",
- size = "xs",
- ...props
-}: Omit, "size"> &
- VariantProps) {
- return (
-
- )
-}
-
-function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
- return (
-
- )
-}
-
-function InputGroupInput({
- className,
- ...props
-}: React.ComponentProps<"input">) {
- return (
-
- )
-}
-
-function InputGroupTextarea({
- className,
- ...props
-}: React.ComponentProps<"textarea">) {
- return (
-
- )
-}
-
-export {
- InputGroup,
- InputGroupAddon,
- InputGroupButton,
- InputGroupText,
- InputGroupInput,
- InputGroupTextarea,
-}
diff --git a/components/ui/select.tsx b/components/ui/select.tsx
deleted file mode 100644
index c0dc712..0000000
--- a/components/ui/select.tsx
+++ /dev/null
@@ -1,190 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
-import { Select as SelectPrimitive } from "radix-ui"
-
-import { cn } from "@/lib/utils"
-
-function Select({
- ...props
-}: React.ComponentProps) {
- return
-}
-
-function SelectGroup({
- ...props
-}: React.ComponentProps) {
- return
-}
-
-function SelectValue({
- ...props
-}: React.ComponentProps) {
- return
-}
-
-function SelectTrigger({
- className,
- size = "default",
- children,
- ...props
-}: React.ComponentProps & {
- size?: "sm" | "default"
-}) {
- return (
-
- {children}
-
-
-
-
- )
-}
-
-function SelectContent({
- className,
- children,
- position = "item-aligned",
- align = "center",
- ...props
-}: React.ComponentProps) {
- return (
-
-
-
-
- {children}
-
-
-
-
- )
-}
-
-function SelectLabel({
- className,
- ...props
-}: React.ComponentProps) {
- return (
-
- )
-}
-
-function SelectItem({
- className,
- children,
- ...props
-}: React.ComponentProps) {
- return (
-
-
-
-
-
-
- {children}
-
- )
-}
-
-function SelectSeparator({
- className,
- ...props
-}: React.ComponentProps) {
- return (
-
- )
-}
-
-function SelectScrollUpButton({
- className,
- ...props
-}: React.ComponentProps) {
- return (
-
-
-
- )
-}
-
-function SelectScrollDownButton({
- className,
- ...props
-}: React.ComponentProps) {
- return (
-
-
-
- )
-}
-
-export {
- Select,
- SelectContent,
- SelectGroup,
- SelectItem,
- SelectLabel,
- SelectScrollDownButton,
- SelectScrollUpButton,
- SelectSeparator,
- SelectTrigger,
- SelectValue,
-}
diff --git a/components/ui/spinner.tsx b/components/ui/spinner.tsx
deleted file mode 100644
index a70e713..0000000
--- a/components/ui/spinner.tsx
+++ /dev/null
@@ -1,16 +0,0 @@
-import { Loader2Icon } from "lucide-react"
-
-import { cn } from "@/lib/utils"
-
-function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
- return (
-
- )
-}
-
-export { Spinner }
diff --git a/src/core/pending.ts b/src/core/pending.ts
index f51856d..51c7390 100644
--- a/src/core/pending.ts
+++ b/src/core/pending.ts
@@ -8,6 +8,8 @@ import { isToolUIPart, type UIMessage } from "ai";
export interface PendingToolCall {
toolCallId: string;
tool: string;
+ /** Tool input (quiz/ask args) — lets the composer pick note vs answer mode. */
+ input: unknown;
}
export function findPendingToolCall(messages: UIMessage[]): PendingToolCall | null {
@@ -16,7 +18,7 @@ export function findPendingToolCall(messages: UIMessage[]): PendingToolCall | nu
for (let p = msg.parts.length - 1; p >= 0; p--) {
const part = msg.parts[p]!;
if (isToolUIPart(part) && part.state === "input-available") {
- return { toolCallId: part.toolCallId, tool: part.type.slice("tool-".length) };
+ return { toolCallId: part.toolCallId, tool: part.type.slice("tool-".length), input: part.input };
}
}
}
diff --git a/tests/chat-flow.test.ts b/tests/chat-flow.test.ts
index eebd9d8..16d573f 100644
--- a/tests/chat-flow.test.ts
+++ b/tests/chat-flow.test.ts
@@ -130,7 +130,9 @@ describe("teacher chat flow (scripted model)", () => {
// The pending-question gate sees it.
const pending = findPendingToolCall(saved1.messages);
- assert.deepEqual(pending, { toolCallId: "call-1", tool: "quiz" });
+ assert.equal(pending?.toolCallId, "call-1");
+ assert.equal(pending?.tool, "quiz");
+ assert.deepEqual(pending?.input, QUIZ_INPUT);
// ── The learner answers: client grades, output goes back on the part ──
const options = normalizeOptions(QUIZ_INPUT.options);
diff --git a/tests/render-cards.test.tsx b/tests/render-cards.test.tsx
index 0462e16..cfaa84f 100644
--- a/tests/render-cards.test.tsx
+++ b/tests/render-cards.test.tsx
@@ -44,7 +44,7 @@ function quizQD(): QuestionData {
describe("render smoke tests", () => {
it("QuizCard renders question, options, and the automatic 'I don't know'", () => {
const html = renderToStaticMarkup(
- {}} />,
+ {}} pendingText="" onPendingTextChange={() => {}} />,
);
assert.match(html, /Which planet is closest to the sun\?/);
assert.match(html, /Mercury/);
@@ -56,22 +56,25 @@ describe("render smoke tests", () => {
it("QuizCard never leaks the explanation or the correct answer before submit", () => {
const html = renderToStaticMarkup(
- {}} />,
+ {}} pendingText="" onPendingTextChange={() => {}} />,
);
assert.ok(!html.includes("innermost"), "explanation must not render pre-answer");
});
- it("ask card renders Other + note and no grading affordance", () => {
+ it("ask card renders Other, no note field (note lives in the composer), no grading", () => {
const html = renderToStaticMarkup(
{}}
+ pendingText=""
+ onPendingTextChange={() => {}}
/>,
);
assert.match(html, /Other/);
assert.ok(!html.includes("I don't know"), "ask has no 'I don't know'");
+ assert.ok(!html.includes("Add a note"), "note field moved to the composer blob");
});
it("AnsweredCard grades quiz answers (✓/✗ + explanation)", () => {
@@ -141,7 +144,7 @@ describe("math rendering (streamdown + KaTeX)", () => {
explanation: "The power rule gives $\\frac{dy}{dx} = 2x$.",
};
const pendingHtml = renderToStaticMarkup(
- {}} />,
+ {}} pendingText="" onPendingTextChange={() => {}} />,
);
assert.ok(pendingHtml.includes("katex"), "pending card: question/options math renders");
diff --git a/tests/render-composer.test.tsx b/tests/render-composer.test.tsx
new file mode 100644
index 0000000..7fef67d
--- /dev/null
+++ b/tests/render-composer.test.tsx
@@ -0,0 +1,60 @@
+/**
+ * Composer blob render tests — the morphing text field (chat / note / answer
+ * modes), concentric corner radii, and the stop-while-busy button.
+ *
+ * Same tsx classic-JSX shim as render-cards.test.tsx.
+ */
+import React from "react";
+(globalThis as Record).React = React;
+
+import assert from "node:assert/strict";
+import { describe, it } from "node:test";
+import { renderToStaticMarkup } from "react-dom/server";
+import { Composer } from "../components/composer.tsx";
+
+function render(mode: "chat" | "note" | "answer", opts: { busy?: boolean; canSend?: boolean } = {}) {
+ return renderToStaticMarkup(
+ {}}
+ onSend={() => {}}
+ onStop={() => {}}
+ canSend={opts.canSend ?? true}
+ />,
+ );
+}
+
+describe("Composer blob", () => {
+ it("chat mode: chat placeholder + send button", () => {
+ const html = render("chat");
+ assert.match(html, /Ask, answer, or steer the lesson/);
+ assert.ok(html.includes('aria-label="Send"'), "send button present");
+ });
+
+ it("note mode: note placeholder and NO send button (card submits)", () => {
+ const html = render("note");
+ assert.match(html, /Add a note/);
+ assert.ok(!html.includes('aria-label="Send"'), "no send in note mode");
+ assert.ok(!html.includes('aria-label="Stop"'), "no stop either");
+ });
+
+ it("answer mode: answer placeholder + send button (submits the answer)", () => {
+ const html = render("answer");
+ assert.match(html, /Type your answer/);
+ assert.ok(html.includes('aria-label="Send"'), "send present in answer mode");
+ });
+
+ it("busy: send button becomes stop", () => {
+ const html = render("chat", { busy: true });
+ assert.ok(html.includes('aria-label="Stop"'), "stop replaces send while streaming");
+ });
+
+ it("concentric corner radii: blob 16px, send 10px (25% of the 40px button)", () => {
+ const html = render("chat");
+ assert.ok(html.includes("rounded-2xl"), "blob radius");
+ assert.ok(html.includes("rounded-[10px]"), "send radius = blob radius − padding");
+ assert.ok(html.includes("size-10"), "40px send button (10px = 25%)");
+ });
+});