Files
learnharness/components/composer.tsx
T
amosblomqvist 5271ca0e4a composer steering: the general steering machinery (owner arc 2026-10-03)
STEERING, NOT FOLLOW-UPS — pi-agent-core's two rails, used deliberately:
agent.steer() injects at the next response boundary INSIDE the live run
(one logical turn, the model changes course); followUp() would only extend
an ended run — not built. The same rail job markers already ride
(steeringMode 'all', the 2026-09-26 doctrine).

- POST /api/chats/[id]/steer: 404/400 honest guards; no live turn → 409
  and the client silently falls back to a plain text send (the settle
  race); live → steerLiveChat hands the message to the live agent's
  steering queue AND parks it in the userSteers registry — the stranding
  safety net. A steer stranded behind a question termination or abort
  stays parked and rides the chat's NEXT run (handOffEligible), the same
  deliver-or-requeue lifecycle as job markers. Reconciliation at settle:
  delivered ids drop (reconcileUserSteers).
- Client: steer(text) queues + POSTs without flipping any turn state;
  the drain's verbatim message events upsert the real user card by the
  client-minted id (steer_<localid>) and the indicator fold drops the
  line. canSend no longer gates on turnInFlight — Enter mid-turn steers;
  Enter with an empty composer still stops; the square button stays Stop.
- The queued-steer indicator (owner spec): quiet one-liners above the
  composer shell — right-aligned to where its corner rounding starts,
  truncated with an ellipsis, fixed max width, ~half opacity, no
  background, no container. Tunable later.
- Verified from pi-agent-core source: steering drains per completed turn
  inside the loop; injected messages emit message_start/end verbatim;
  finishTurn {action:'end'} (our question stopper) exits BEFORE the
  steering poll — hence the registry.
2026-10-03 03:01:51 +03:00

120 lines
4.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { CornerDownLeftIcon, SquareIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { Hint } from "@/components/ui/hint";
/**
* 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: stop a busy stream,
* otherwise send the message. (The plan-verdict accept branch died
* 2026-09-26 with the verdict itself.) Pure so the static-render harness
* can pin the decision (key events can't be simulated in
* renderToStaticMarkup). */
export function enterAction(state: { busy: boolean; canSend: boolean }): "stop" | "send" | "none" {
// Composer steering (owner arc 2026-10-03): while a turn runs, typed
// text makes Enter a STEER (the queue-and-redirect send); Enter with
// an empty composer stays the stop shortcut. The button itself keeps
// the square — Stop stays one click away at all times.
if (state.busy) return state.canSend ? "send" : "stop";
if (state.canSend) return "send";
return "none";
}
export function Composer({
busy,
value,
onChange,
onSend,
onStop,
canSend,
autoFocus,
inputRef,
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 page's handle to the textarea — the dictation-end focus (owner,
* 2026-10-01): a run ending hands the cursor to the bare composer. */
inputRef?: React.RefObject<HTMLTextAreaElement | null>;
/** 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, canSend });
if (action === "stop") onStop();
else if (action === "send") onSend();
}}
>
<textarea
ref={inputRef}
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).
if (e.key === "Enter" && (e.metaKey || e.ctrlKey || !e.shiftKey)) {
e.preventDefault();
const action = enterAction({ busy, canSend });
if (action === "stop") onStop();
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"
/>
{/* No tooltip on the send square — the ↵ glyph is the
affordance and Enter is obvious (owner, subtlety
round 2026-09-25). */}
<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>
);
}