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.
144 lines
5.5 KiB
TypeScript
144 lines
5.5 KiB
TypeScript
"use client";
|
||
|
||
import { CheckIcon, CornerDownLeftIcon, SquareIcon, XIcon } from "lucide-react";
|
||
import { cn } from "@/lib/utils";
|
||
|
||
/**
|
||
* The chat composer — content row ONLY (the persistent rounded shell lives in
|
||
* the chat view's overlay, so the shell can later morph chat → question
|
||
* without remounting).
|
||
*
|
||
* Corner-radius math (concentric with the shell): shell rounded-2xl (16px) −
|
||
* row padding p-1.5 (6px) = button rounded-[10px] = 25% of the 40px button.
|
||
*/
|
||
/** What one Enter press does, in precedence order (the plan-verdict
|
||
* behavior is user-decided 2026-09-19, docs/probe-teach-split.md §4):
|
||
* stop a busy stream; ACCEPT when the plan verdict pends — typed text
|
||
* rides the acceptance as the note (the green button's exact semantics;
|
||
* an empty box accepts too, so Enter can never strand the verdict);
|
||
* otherwise send the message. Pure so the static-render harness can pin
|
||
* the decision (key events can't be simulated in renderToStaticMarkup). */
|
||
export function enterAction(state: { busy: boolean; verdictPending: boolean; canSend: boolean }): "stop" | "accept" | "send" | "none" {
|
||
if (state.busy) return "stop";
|
||
if (state.verdictPending) return "accept";
|
||
if (state.canSend) return "send";
|
||
return "none";
|
||
}
|
||
|
||
export function Composer({
|
||
busy,
|
||
value,
|
||
onChange,
|
||
onSend,
|
||
onStop,
|
||
canSend,
|
||
autoFocus,
|
||
verdict,
|
||
onEscape,
|
||
placeholder = "Ask, answer, or steer the lesson…",
|
||
}: {
|
||
busy: boolean;
|
||
value: string;
|
||
onChange: (v: string) => void;
|
||
onSend: () => void;
|
||
onStop: () => void;
|
||
canSend: boolean;
|
||
autoFocus?: boolean;
|
||
/** The plan verdict gate: while the presented plan awaits the learner's
|
||
* word, the send square becomes two stacked buttons — green accept,
|
||
* red decline. Typed text rides the verdict as his note. */
|
||
verdict?: { onAccept: () => void; onDecline: () => void };
|
||
/** Escape while a dictation runs stops it (STT wave, 2026-09-17). No-op
|
||
* when absent — Esc otherwise means nothing in the composer. */
|
||
onEscape?: () => void;
|
||
/** The textarea's placeholder (owner refinement round, 2026-09-24):
|
||
* active sessions read "Ask, answer, or steer the lesson…"; the fresh
|
||
* new-session page asks the graph view's opening question instead. */
|
||
placeholder?: string;
|
||
}) {
|
||
return (
|
||
<form
|
||
data-testid="composer"
|
||
className="flex items-end gap-1.5 p-1.5"
|
||
onSubmit={(e) => {
|
||
e.preventDefault();
|
||
const action = enterAction({ busy, verdictPending: Boolean(verdict), canSend });
|
||
if (action === "stop") onStop();
|
||
else if (action === "accept") verdict?.onAccept();
|
||
else if (action === "send") onSend();
|
||
}}
|
||
>
|
||
<textarea
|
||
value={value}
|
||
onChange={(e) => onChange(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
// Esc ends dictation before anything else Esc might someday mean
|
||
// here (the dictate decisions: click-again or Esc to stop).
|
||
// stopPropagation claims the Esc (keyboard wave A): the
|
||
// window-level chain must not also abort a turn under us.
|
||
if (e.key === "Escape" && onEscape) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
onEscape();
|
||
return;
|
||
}
|
||
// Chat convention: Enter sends, Shift+Enter is a newline.
|
||
// ⌘/Ctrl+Enter also sends — the universal submit shortcut
|
||
// across every input in the app (thinking field, answers).
|
||
// While the plan verdict pends, Enter ACCEPTS the plan instead
|
||
// (enterAction — user-decided 2026-09-19).
|
||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey || !e.shiftKey)) {
|
||
e.preventDefault();
|
||
const action = enterAction({ busy, verdictPending: Boolean(verdict), canSend });
|
||
if (action === "stop") onStop();
|
||
else if (action === "accept") verdict?.onAccept();
|
||
else if (action === "send") onSend();
|
||
}
|
||
}}
|
||
placeholder={placeholder}
|
||
autoFocus={autoFocus}
|
||
rows={1}
|
||
className="field-sizing-content lh-input max-h-48 min-h-10 flex-1 resize-none bg-transparent px-2.5 py-2 outline-none placeholder:text-muted-foreground"
|
||
/>
|
||
{verdict && !busy ? (
|
||
// The plan verdict (docs/probe-teach-split.md §4): accept
|
||
// sanctions teaching (phase plan→teach); decline sends the
|
||
// revision request. Enter accepts too (user-decided 2026-09-19 —
|
||
// it formerly sent a plain message, which left the verdict
|
||
// pending on the next message; a side question now rides the
|
||
// acceptance as its note instead).
|
||
<div className="flex shrink-0 flex-col justify-end gap-1" data-testid="plan-verdict-buttons">
|
||
<button
|
||
type="button"
|
||
onClick={verdict.onAccept}
|
||
className="flex h-[19px] items-center justify-center gap-1 rounded-md bg-emerald-600 px-3 text-xs font-medium text-white transition-colors hover:bg-emerald-500"
|
||
>
|
||
<CheckIcon className="size-3" /> Accept plan
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={verdict.onDecline}
|
||
className="flex h-[19px] items-center justify-center gap-1 rounded-md bg-destructive px-3 text-xs font-medium text-destructive-foreground transition-colors hover:opacity-90"
|
||
>
|
||
<XIcon className="size-3" /> Decline
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<button
|
||
type="submit"
|
||
aria-label={busy ? "Stop" : "Send"}
|
||
disabled={!busy && !canSend}
|
||
className={cn(
|
||
"flex size-10 shrink-0 items-center justify-center rounded-[10px] transition-colors",
|
||
busy
|
||
? "bg-secondary text-foreground hover:bg-accent"
|
||
: "bg-primary text-primary-foreground hover:opacity-90 disabled:opacity-40",
|
||
)}
|
||
>
|
||
{busy ? <SquareIcon className="size-4" /> : <CornerDownLeftIcon className="size-5" />}
|
||
</button>
|
||
)}
|
||
</form>
|
||
);
|
||
}
|