Three owner fixes for /chat/new (refinement round 4, 2026-09-24) —
the page must stop looking like a session that's already running:
1. NO PHASE CHIP: the header's <PhaseChip phase="probe" /> dies —
no session has started, so no phase exists yet. Active sessions
keep their chip (chat.tsx's derives from the live timeline).
2. TOP BAR STRUCTURE MATCHS SESSIONS: the header gains the same
border-b border-border as the chat header (h-15, lh-drag,
60px). The floating-bar look is gone; /chat/new now reads as
the same shell with empty content.
3. COMPOSER PLACEHOLDER = THE OPENING QUESTION: active sessions
keep "Ask, answer, or steer the lesson…" (now the DEFAULT of a
new Composer `placeholder` prop), but the fresh page asks what
the graph view's new-session input asks — "What do you want to
learn?" No lesson is in flight, so the composer must not read
like one is.
The ConversationEmptyState intro card ("The teacher will open by
probing…") stays, per owner confirmation.
Live-verified on :3001 /chat/new: no phase-chip node, computed
1px header border rgb(67,67,67) at h-15/60px, placeholder reads
"What do you want to learn?"; the empty-state card intact. Unit
test pins the default session placeholder AND the override.
903/903.
81 lines
3.4 KiB
TypeScript
81 lines
3.4 KiB
TypeScript
/**
|
||
* Composer render tests — the chat input row (content only; the persistent
|
||
* rounded shell lives in the chat view's overlay so it can morph into the
|
||
* question dock without remounting).
|
||
*
|
||
* Same tsx classic-JSX shim as render-cards.test.tsx.
|
||
*/
|
||
import React from "react";
|
||
(globalThis as Record<string, unknown>).React = React;
|
||
|
||
import assert from "node:assert/strict";
|
||
import { describe, it } from "node:test";
|
||
import { renderToStaticMarkup } from "react-dom/server";
|
||
import { Composer, enterAction } from "../components/composer.tsx";
|
||
|
||
function render(opts: { busy?: boolean; canSend?: boolean; placeholder?: string } = {}) {
|
||
return renderToStaticMarkup(
|
||
<Composer
|
||
busy={opts.busy ?? false}
|
||
value=""
|
||
onChange={() => {}}
|
||
onSend={() => {}}
|
||
onStop={() => {}}
|
||
canSend={opts.canSend ?? true}
|
||
{...(opts.placeholder ? { placeholder: opts.placeholder } : {})}
|
||
/>,
|
||
);
|
||
}
|
||
|
||
describe("Composer", () => {
|
||
it("chat placeholder + send button", () => {
|
||
const html = render();
|
||
assert.match(html, /Ask, answer, or steer the lesson/, "the default — active sessions");
|
||
assert.ok(html.includes('aria-label="Send"'), "send button present");
|
||
});
|
||
|
||
it("placeholder is overridable — the new-session page asks the graph view's question", () => {
|
||
const html = render({ placeholder: "What do you want to learn?" });
|
||
assert.match(html, /What do you want to learn\?/);
|
||
assert.ok(!html.includes("Ask, answer"), "the session placeholder must not ride along");
|
||
});
|
||
|
||
it("busy: send button becomes stop", () => {
|
||
const html = render({ busy: true });
|
||
assert.ok(html.includes('aria-label="Stop"'), "stop replaces send while streaming");
|
||
});
|
||
|
||
it("send button radius is 10px = 25% of the 40px button (concentric with the shell)", () => {
|
||
const html = render();
|
||
assert.ok(html.includes("rounded-[10px]"), "send radius = shell radius − row padding");
|
||
assert.ok(html.includes("size-10"), "40px send button (10px = 25%)");
|
||
});
|
||
|
||
it("disabled when nothing to send", () => {
|
||
const html = render({ canSend: false });
|
||
assert.ok(/aria-label="Send"[^>]*disabled|disabled[^>]*aria-label="Send"/.test(html), "send disabled");
|
||
});
|
||
});
|
||
|
||
// The Enter decision (user-decided 2026-09-19, docs/probe-teach-split.md §4):
|
||
// while the plan verdict pends, Enter must ACCEPT the plan — formerly it sent
|
||
// a plain message, leaving the verdict pending again on the next message.
|
||
// Pure function so the static-render harness can pin it (key events can't be
|
||
// simulated in renderToStaticMarkup).
|
||
describe("enterAction", () => {
|
||
it("busy stream: Enter stops — verdict or not", () => {
|
||
assert.equal(enterAction({ busy: true, verdictPending: false, canSend: true }), "stop");
|
||
assert.equal(enterAction({ busy: true, verdictPending: true, canSend: true }), "stop");
|
||
});
|
||
|
||
it("verdict pends: Enter ACCEPTS — with text (the note rides) and with an empty box alike", () => {
|
||
assert.equal(enterAction({ busy: false, verdictPending: true, canSend: true }), "accept", "typed text rides the acceptance note");
|
||
assert.equal(enterAction({ busy: false, verdictPending: true, canSend: false }), "accept", "empty box still accepts — Enter can never strand the verdict");
|
||
});
|
||
|
||
it("no verdict: send when there is text, nothing when there is not", () => {
|
||
assert.equal(enterAction({ busy: false, verdictPending: false, canSend: true }), "send");
|
||
assert.equal(enterAction({ busy: false, verdictPending: false, canSend: false }), "none");
|
||
});
|
||
});
|