feat(claw): a message into a live turn steers it

The resident dispatch's turn now runs under a steer select: the
messages route, after its ingest, signals a per-key interrupt registry,
the drop lands at the turn's current await (an in-flight shell command
dies with it), the dangling calls close, and the dispatch loops on the
same lease — the steered message runs as the next round, same session,
same stream. The interrupted turn closes on the stream with turn_done
carrying outcome "steered", and the shim's whole and streamed
answers both wait through steered end markers for the round that
actually replies.

The messages route answers 202 the moment the message is accepted; the
drain pass runs detached, so a turn's minutes never hold the request
open. The web composer leans on that: it never closes for a running
turn, the placeholder and the send button say that sending steers, a
steered outcome gets its own tone, and the sent-row echo dedup pops a
pending list instead of comparing the last item — a steered chain's
extra end marker can no longer double the row.
This commit is contained in:
vhaudiquet committed 2026-11-08 18:00:39 +01:00
1 parent 853c5a547c
commit 041240f706
11 files changed
+857 -40

No files matched your search

+29 -7
View File
@@ -1,9 +1,12 @@
<script setup lang="ts">
// The transcript: sent lines and streamed turns, the turn in flight at
// the bottom. The claw owns the conversation — this renders what the
// event stream delivered, nothing more. The header names the session
// and hides the dispatch internals behind an info modal; the composer
// chips mirror live claw state; the backend chip jumps to settings.
// event stream delivered, nothing more. The composer never closes for
// a running turn: sending while one streams steers it — the claw cuts
// the turn and answers the message instead (rhema-code's shape). The
// header names the session and hides the dispatch internals behind an
// info modal; the composer chips mirror live claw state; the backend
// chip jumps to settings.
import {
PASTE_INLINE_LINES,
chipLabel,
@@ -82,7 +85,9 @@ watch(
function submit(): void {
const text = draft.value.trim()
// The draft stays the gate: the claw 422s an empty text even with
// images aboard.
// images aboard. `busy` is only this send's POST in flight (the claw
// answers 202 at once) — a running turn never blocks the composer,
// because a send into one steers it.
if (!text || !props.canSend || props.busy) return
emit('send', { text: embedPastes(text, pastes.value), images: stagedImages.value })
stageGeneration++
@@ -391,7 +396,7 @@ function streamTone(status: StreamStatus): 'ok' | 'warn' | 'error' {
<div
v-if="item.turn.outcome"
class="outcome mono"
:class="{ failed: item.turn.outcome === 'failed' }"
:class="{ failed: item.turn.outcome === 'failed', steered: item.turn.outcome === 'steered' }"
>
{{ item.turn.outcome }}
</div>
@@ -465,13 +470,24 @@ function streamTone(status: StreamStatus): 'ok' | 'warn' | 'error' {
ref="prompt"
v-model="draft"
rows="1"
:placeholder="canSend ? `Message ${agentName}…` : 'only interactive:* sessions accept messages'"
:placeholder="
canSend
? live
? `Message ${agentName}… sending steers the running turn`
: `Message ${agentName}…`
: 'only interactive:* sessions accept messages'
"
:disabled="!canSend"
@keydown="onPromptKeydown"
@paste="onPaste"
@input="resizePrompt"
/>
<button class="primary send" :disabled="!canSend || busy || draft.trim() === ''" title="send" @click="submit">
<button
class="primary send"
:disabled="!canSend || busy || draft.trim() === ''"
:title="live ? 'send — interrupts the running turn' : 'send'"
@click="submit"
>
↑
</button>
<input
@@ -632,6 +648,12 @@ function streamTone(status: StreamStatus): 'ok' | 'warn' | 'error' {
color: var(--error);
}
/* A steered turn: cut short by the user's next message — not a
failure, the reply that replaced it streams right below. */
.outcome.steered {
color: var(--accent-text);
}
/* A consent card: the agent asking its human something — distinct
from a tool row, toned by its resolution. */
.consent-card {
+23 -10
View File
@@ -173,6 +173,14 @@ export function useTranscript() {
const live = ref<TurnBlock | null>(null)
let nextId = 1
/** Locally sent messages the stream has not echoed yet: `addSent`
* pushes here, the `user_message` handler pops the match. Text plus
* image count is the whole signature the stream carries, and that is
* enough — the echo dedupes against what this surface sent, not
* against whichever row happens to sit last: a steered turn's end
* marker can land between the optimistic row and its echo. */
const pendingSent: { text: string; images: number }[] = []
function ensureLive(): TurnBlock {
if (!live.value) live.value = { reply: '', entries: [], outcome: null }
return live.value
@@ -192,15 +200,16 @@ export function useTranscript() {
switch (event.type) {
case 'user_message': {
// The replayed question. A live echo of a message this surface
// just sent arrives after the optimistic `sent` row: identical
// text and the same image count as the last item means the row
// already stands in for it, previews and all.
const last = items.value[items.value.length - 1]
if (
last?.kind === 'sent' &&
last.text === event.text &&
last.images.length === event.images.length
) {
// just sent arrives after the optimistic `sent` row: the match
// pops the pending entry and the row already stands in for it,
// previews and all. Anything else (a replay, another surface's
// message, a steered echo after other events) renders as its
// own row.
const mine = pendingSent.findIndex(
(pending) => pending.text === event.text && pending.images === event.images.length,
)
if (mine !== -1) {
pendingSent.splice(mine, 1)
return
}
items.value.push({
@@ -320,6 +329,7 @@ export function useTranscript() {
* Its images render from the staged previews — the claw stores them
* and the stream's echo dedupes against this row. */
function addSent(text: string, images: SentImage[] = []): void {
pendingSent.push({ text, images: images.length })
items.value.push({ kind: 'sent', id: nextId++, text, images })
}
@@ -393,10 +403,13 @@ export function useTranscript() {
}
/** Session switched, or the stream reopened: the mirror is per-key,
* and the replay that follows rebuilds it either way. */
* and the replay that follows rebuilds it either way. The pending
* echoes go too — a replay carries every message, optimistic rows
* included. */
function reset(): void {
items.value = []
live.value = null
pendingSent.length = 0
}
return { items, live, applyEvent, addSent, resolveConsent, setAttachmentUrl, setSentImageUrl, reset }