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.
This commit is contained in:
+40
-5
@@ -2,6 +2,7 @@
|
||||
|
||||
import { createContext, Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { useChatSession } from "@/components/session";
|
||||
import { visibleQueuedSteers } from "@/components/session-core";
|
||||
import { SetupDialog } from "@/components/setup-dialog";
|
||||
import { Hint } from "@/components/ui/hint";
|
||||
import { useZeroKeys, ZeroKeysPill } from "@/components/setup-cta";
|
||||
@@ -22,6 +23,7 @@ import { AnsweredCard } from "@/components/answered-card";
|
||||
import { artifactRenderers } from "@/components/artifacts";
|
||||
import { SubagentSteerCard } from "@/components/artifacts/steer-card";
|
||||
import { Composer } from "@/components/composer";
|
||||
import { QueuedSteersBody, type QueuedSteerLine } from "@/components/queued-steers";
|
||||
import { DictationBar, joinDictation, useDictation } from "@/components/dictation";
|
||||
import { JobsContext } from "@/components/jobs-context";
|
||||
import { StatsBar } from "@/components/stats-bar";
|
||||
@@ -187,7 +189,7 @@ export function Chat({
|
||||
// (core/view.ts projectView); sends are ONE explicit input event each
|
||||
// ({chatId, input} — the stash indirection died with the SDK), and the
|
||||
// turn stream is the VERBATIM AgentEvent passthrough the server emits.
|
||||
const { messages, nativeMessages, send, submitAnswer, dismissQuestion, setMessages, adoptRecord, status, error, stop, clearError, resumeStream, setReattachSettled } =
|
||||
const { messages, nativeMessages, send, steer, steerQueue, submitAnswer, dismissQuestion, setMessages, adoptRecord, status, error, stop, clearError, resumeStream, setReattachSettled } =
|
||||
useChatSession({ chatId, initialMessages, initiallyInFlight });
|
||||
|
||||
// The reattach settled terminal → the sync-and-continue seam (below).
|
||||
@@ -507,7 +509,12 @@ export function Chat({
|
||||
// the spoken text is deferred in the run's buffer — Enter must be able
|
||||
// to trigger the finish-first send (see onSend), not be gated out by an
|
||||
// empty textarea. If nothing was said, the send is a harmless no-op.
|
||||
const canSend = (text.trim().length > 0 || dictation.active) && !pending && !turnInFlight;
|
||||
// Steering (2026-10-03): a turn in flight no longer gates the composer —
|
||||
// Enter while a turn runs STEERS it (the quiet queue above the composer);
|
||||
// a pending question still owns the interaction (the dock replaces the
|
||||
// composer, and there is no live turn to steer anyway — questions end
|
||||
// turns).
|
||||
const canSend = (text.trim().length > 0 || dictation.active) && !pending;
|
||||
|
||||
const syncFromServer = useCallback(async () => {
|
||||
const res = await fetch(`/api/chats/${chatId}`);
|
||||
@@ -893,16 +900,16 @@ export function Chat({
|
||||
});
|
||||
|
||||
const onSend = () => {
|
||||
if (busy || pending) return;
|
||||
if (pending) return;
|
||||
/* FINISH DICTATION FIRST (owner, 2026-10-01): a learner who keeps
|
||||
* talking right through Enter must not lose the tail — stop the run,
|
||||
* let the last finals land in the composer, then send the REF value
|
||||
* (the flush lands via setState, invisible to this closure). */
|
||||
if (!dictation.active) {
|
||||
doSend();
|
||||
doSteerOrSend();
|
||||
return;
|
||||
}
|
||||
void dictation.finish().then(doSend);
|
||||
void dictation.finish().then(doSteerOrSend);
|
||||
};
|
||||
const doSend = () => {
|
||||
if (busy || pending) return;
|
||||
@@ -913,6 +920,24 @@ export function Chat({
|
||||
void send({ type: "text", text: t });
|
||||
};
|
||||
|
||||
/* STEER VS SEND (owner arc 2026-10-03): Enter while a turn runs steers
|
||||
* it — the quiet queue + the live agent's steering rail; the turn's own
|
||||
* stream lands the card when pi drains it. Any other state (idle,
|
||||
* submitted microtask window, a wake turn between our renders) takes the
|
||||
* plain send — a steer POST with no live turn would 409 and fall back
|
||||
* to exactly that anyway, so we only branch on the state we trust. */
|
||||
const doSteerOrSend = () => {
|
||||
if (pending) return;
|
||||
const t = textRef.current.trim();
|
||||
if (!t) return;
|
||||
if (turnInFlight) {
|
||||
applyText("");
|
||||
void steer(t);
|
||||
return;
|
||||
}
|
||||
doSend();
|
||||
};
|
||||
|
||||
return (
|
||||
<ChatActionsContext.Provider
|
||||
value={{
|
||||
@@ -1218,6 +1243,7 @@ export function Chat({
|
||||
onReserve={setDockReserve}
|
||||
dictationSlot={<DictationBar dictation={dictation} />}
|
||||
dictationCancel={dictation.cancel}
|
||||
queuedSteers={visibleQueuedSteers(steerQueue, nativeMessages)}
|
||||
setupCta={
|
||||
zeroKeys ? (
|
||||
<ZeroKeysPill onOpen={() => setSetupOpen(true)} />
|
||||
@@ -1380,6 +1406,7 @@ function DockOverlay({
|
||||
dictationSlot,
|
||||
dictationCancel,
|
||||
setupCta,
|
||||
queuedSteers,
|
||||
}: {
|
||||
pending: ReturnType<typeof findPendingToolCall>;
|
||||
/** Teach-phase pending question: arrives collapsed behind the reveal button (W5). */
|
||||
@@ -1406,6 +1433,9 @@ function DockOverlay({
|
||||
/** Esc in a pending question's field stops dictation (the strip is the
|
||||
* stats bar's; the stop belongs to the page-owned hook, threaded down). */
|
||||
dictationCancel?: () => void;
|
||||
/** The queued-steer indicator's lines (composer steering, 2026-10-03):
|
||||
* already filtered by visibleQueuedSteers — only undrained steers. */
|
||||
queuedSteers: QueuedSteerLine[];
|
||||
/** The persistent setup CTA (BYOK, owner 2026-09-22): the same pill
|
||||
* home and /chat/new carry, above the composer while slots are unset. */
|
||||
setupCta?: React.ReactNode;
|
||||
@@ -1692,6 +1722,11 @@ function DockOverlay({
|
||||
<div ref={blockRef} className="relative bg-background pb-5">
|
||||
<div className={DOCK}>
|
||||
{setupCta}
|
||||
{/* The queued-steer indicator (owner spec 2026-10-03): quiet
|
||||
one-liners above the shell - right-aligned to where its corner
|
||||
rounding starts, truncated, no background, no container. A
|
||||
line dies when its card lands (visibleQueuedSteers). */}
|
||||
<QueuedSteersBody steers={queuedSteers} />
|
||||
<div ref={shellRef} className="pointer-events-auto overflow-hidden rounded-2xl border bg-card shadow-lg">
|
||||
<div key={mode} className={mode === initialModeRef.current ? undefined : "dock-variant"}>
|
||||
{pending ? (
|
||||
|
||||
@@ -18,7 +18,11 @@ import { Hint } from "@/components/ui/hint";
|
||||
* can pin the decision (key events can't be simulated in
|
||||
* renderToStaticMarkup). */
|
||||
export function enterAction(state: { busy: boolean; canSend: boolean }): "stop" | "send" | "none" {
|
||||
if (state.busy) return "stop";
|
||||
// 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";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* THE QUEUED-STEER INDICATOR (owner spec 2026-10-03): steers sent but not
|
||||
* yet drained render as quiet one-liners ABOVE the composer shell —
|
||||
* right-aligned to where the shell's corner rounding starts, truncated
|
||||
* with an ellipsis, no background, no container, roughly half opacity.
|
||||
* Visible but very quiet; layout tunable later.
|
||||
*
|
||||
* A line dies the moment its card lands in the transcript: the drain's
|
||||
* verbatim message events upsert the real user card by the client-minted
|
||||
* id, and visibleQueuedSteers (session-core, pure) filters the queue
|
||||
* against the message ids. A steer parked server-side (stranded behind a
|
||||
* question) keeps its line — an honest "waiting for the next run".
|
||||
*/
|
||||
export interface QueuedSteerLine {
|
||||
id: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** The pure body (SSR-pinnable): one right-aligned truncated line per
|
||||
* queued steer, stacked. */
|
||||
export function QueuedSteersBody({ steers }: { steers: QueuedSteerLine[] }) {
|
||||
if (steers.length === 0) return null;
|
||||
return (
|
||||
<div className="flex flex-col items-end gap-0.5 pb-1 pr-4" data-testid="queued-steers">
|
||||
{steers.map((s) => (
|
||||
<span
|
||||
key={s.id}
|
||||
title={s.text}
|
||||
className="block max-w-[240px] truncate font-ui text-[11px] text-muted-foreground/60"
|
||||
>
|
||||
{s.text}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -140,6 +140,29 @@ export interface SessionState {
|
||||
nativeMessages: LhMessage[];
|
||||
status: SessionStatus;
|
||||
error: { message: string } | null;
|
||||
/** Steers sent but not yet drained (composer steering, 2026-10-03):
|
||||
* the queued indicator's truth. An entry dies when its id lands in
|
||||
* the messages (the drain's verbatim events upsert the real card) —
|
||||
* visibleQueuedSteers is the pure fold. */
|
||||
steerQueue: SteerQueueItem[];
|
||||
}
|
||||
|
||||
/** One queued composer steer: the client-minted id (the upsert key) + text. */
|
||||
export interface SteerQueueItem {
|
||||
id: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** The indicator's fold (pure): a queued steer stays visible until its id
|
||||
* lands in the message list — from the live stream's upsert, the settle
|
||||
* sync, or a reload. A steer parked server-side (stranded behind a
|
||||
* question) keeps its line: honest "waiting" until the next run drains it. */
|
||||
export function visibleQueuedSteers(
|
||||
queue: readonly SteerQueueItem[],
|
||||
messages: readonly LhMessage[],
|
||||
): SteerQueueItem[] {
|
||||
const ids = new Set(messages.map((m) => (m as { id?: string }).id));
|
||||
return queue.filter((s) => !ids.has(s.id));
|
||||
}
|
||||
|
||||
export interface SessionModelOptions extends SessionModelDeps {
|
||||
@@ -156,6 +179,8 @@ export class SessionModel {
|
||||
readonly chatId: string;
|
||||
/** Injectable fetch (tests); defaults to the global. */
|
||||
fetch: typeof globalThis.fetch;
|
||||
/** Steers sent but not yet drained (the queued indicator's truth). */
|
||||
private steerQueueValue: SteerQueueItem[] = [];
|
||||
private readonly pollMs: number;
|
||||
private readonly waitMaxMs: number;
|
||||
private onReattachSettled?: () => void;
|
||||
@@ -209,6 +234,7 @@ export class SessionModel {
|
||||
nativeMessages: [...this.base, ...this.tail],
|
||||
status: this.statusValue,
|
||||
error: this.errorValue,
|
||||
steerQueue: this.steerQueueValue,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -506,6 +532,48 @@ export class SessionModel {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* STEER (composer steering, owner arc 2026-10-03): the learner's
|
||||
* mid-turn message for the RUNNING turn. NOT a turn — no optimistic
|
||||
* transcript card, no status flip: the message joins the queued
|
||||
* indicator (the quiet line above the composer) and POSTs to the
|
||||
* steer route, which hands it to the live agent's steering queue.
|
||||
* When pi drains it at the next response boundary, the verbatim
|
||||
* message events upsert the real card (the client-minted id is the
|
||||
* key) and the indicator's fold drops the line. A 409 means the turn
|
||||
* settled mid-send (the race): fall back to the plain text send —
|
||||
* the entry is dropped and send() pushes its own optimistic card.
|
||||
*/
|
||||
async steer(text: string): Promise<void> {
|
||||
const t = text.trim();
|
||||
if (!t) return;
|
||||
const id = `steer_${localId()}`;
|
||||
this.steerQueueValue = [...this.steerQueueValue, { id, text: t }];
|
||||
this.onChange?.();
|
||||
const dropSteer = () => {
|
||||
this.steerQueueValue = this.steerQueueValue.filter((s) => s.id !== id);
|
||||
this.onChange?.();
|
||||
};
|
||||
try {
|
||||
const res = await this.fetch(`/api/chats/${this.chatId}/steer`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ input: { type: "steer", id, text: t } }),
|
||||
});
|
||||
if (res.ok) return; // parked or delivered — the stream lands the card
|
||||
if (res.status === 409) {
|
||||
dropSteer();
|
||||
await this.send({ type: "text", text: t }); // the settle race: plain send
|
||||
return;
|
||||
}
|
||||
dropSteer();
|
||||
this.setError((await res.text()) || `The steer failed (${res.status}).`);
|
||||
} catch (err) {
|
||||
dropSteer();
|
||||
this.setError((err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
/** The answer path (the dock): the answer IS the question call's tool
|
||||
* result (fork 1), riding as its input event. */
|
||||
submitAnswer(tool: string, toolCallId: string, output: QuestionAnswerData): Promise<void> {
|
||||
|
||||
@@ -88,6 +88,7 @@ export function useChatSession({ chatId, initialMessages, initiallyInFlight }: U
|
||||
);
|
||||
|
||||
const send = useCallback((input: ChatInput) => void model.send(input), [model]);
|
||||
const steer = useCallback((text: string) => void model.steer(text), [model]);
|
||||
const submitAnswer = useCallback(
|
||||
(tool: string, toolCallId: string, output: QuestionAnswerData) =>
|
||||
void model.submitAnswer(tool, toolCallId, output),
|
||||
@@ -108,7 +109,9 @@ export function useChatSession({ chatId, initialMessages, initiallyInFlight }: U
|
||||
nativeMessages: state.nativeMessages,
|
||||
status: state.status,
|
||||
error: state.error,
|
||||
steerQueue: state.steerQueue,
|
||||
send,
|
||||
steer,
|
||||
submitAnswer,
|
||||
dismissQuestion,
|
||||
setMessages,
|
||||
|
||||
Reference in New Issue
Block a user