fix: stop advertising during-run chords the run cannot answer yet

isSubmitting flips the moment the user sends, but the start POST installs the
generation epoch a beat later. Through that window steering, send-now and
interrupt all refuse, while the composer advertised them anyway: the hint read
"Enter queues · Ctrl+Enter send now · Alt+Enter interrupt & send" when only the
queue half worked, and the send slot showed the ordinary disabled send button
because it keyed off showStopButton, which the epoch also gates.

Give the hint canControlGeneration and let it name only the default action
until the run is reachable; queueing is local, so that part is honest
throughout. Key the send slot on whether the during-run slot rendered anything
instead of on showStopButton, so the control that queues is present exactly
when Enter queues. The slot already decides between the during-run button,
Stop, and nothing, so an empty one still falls through to send.

The queue rail's escalation control had the same shape of problem: it was
gated on canSteer, so it vanished for the window after the drain starts the
next run and reappeared once that epoch landed. Show it for the whole run and
disable it whenever steering cannot reach one, which also covers the approval
pause it already handled.

Found by watching a real run: the window is normally ~250ms, but any failure
that keeps the epoch from arriving strands the composer in it.
This commit is contained in:
Marco Beretta 2026-08-02 04:49:11 +02:00
parent 22018b8a01
commit 718eec3fbb
No known key found for this signature in database
GPG key ID: D918033D8E74CC11
5 changed files with 82 additions and 15 deletions

View file

@ -365,9 +365,14 @@ const ChatForm = memo(function ChatForm({
/* Memoized for `memo(Bar)`: an inline element is a new identity every render,
and this component re-renders on every keystroke. */
/* Gated on the slot having something to show rather than on `showStopButton`:
that flag only flips once the start POST installs the generation epoch, and
until then Enter already queues while this slot still offered the ordinary
send button, disabled. The slot decides for itself between the during-run
control, Stop, and nothing, so an empty one falls through to send. */
const actionSlot = useMemo(
() =>
isSubmitting && showStopButton && !answerMode.active
isSubmitting && !answerMode.active && duringRunSlot != null
? duringRunSlot
: endpoint && (
<SendButton
@ -388,7 +393,6 @@ const ChatForm = memo(function ChatForm({
disableInputs,
isNotAppendable,
isSubmitting,
showStopButton,
answerMode.active,
methods.control,
],
@ -624,6 +628,7 @@ const ChatForm = memo(function ChatForm({
hasText={(textValue?.trim() ?? '') !== ''}
isSubmitting={isSubmitting}
duringRunActive={steering.duringRunActive}
canControlGeneration={steering.canControlGeneration}
duringRunAction={steering.effectiveAction}
answerModeActive={answerMode.active}
uploadingCount={uploadingCount}

View file

@ -150,11 +150,13 @@ function QueueRow({
/* A recovered item is consumed atomically only when it starts a normal
generation. Escalating it would leave or duplicate the parked source. */
const isRecovered = message.recoverySteerId != null;
/** `canSteer` is false while paused on approval, but the escalation control
* stays visible-and-disabled there: hiding it during the pause is exactly
* the discoverability gap this button closes. */
const showEscalate =
!isRecovered && (steering.pausedOnApproval || (steering.duringRunActive && steering.canSteer));
/** Shown for the whole run, disabled whenever steering cannot reach it
* an approval pause, or the window before the start POST installs the
* generation epoch. Both are states the control must sit out, and hiding it
* through them is the discoverability gap this button closes: the pause is
* exactly when cutting the reply short is wanted, and the epoch window is
* short enough that appearing and vanishing again just reads as a flicker. */
const showEscalate = !isRecovered && (steering.pausedOnApproval || steering.duringRunActive);
return (
<div
@ -232,7 +234,7 @@ function QueueRow({
<EscalateNowButton
surface="queued"
messageText={message.text}
disabled={steering.pausedOnApproval || interruptPending}
disabled={!steering.canSteer || interruptPending}
onClick={() => steering.sendQueuedNow(message, { preempt: true })}
/>
)}

View file

@ -363,6 +363,13 @@ describe('Queue', () => {
expect(screen.getByTestId('queued-interrupt-now')).toBeDisabled();
});
/* The drain starts the next run before its epoch lands, so a control that
hid itself here would appear and vanish between queued sends. */
it('stays visible but disabled before the generation epoch lands', () => {
renderQueue([queued()], steeringWith({ canSteer: false }));
expect(screen.getByTestId('queued-interrupt-now')).toBeDisabled();
});
it('offers nothing once the run is over', () => {
renderQueue([queued()], steeringWith({ duringRunActive: false, canSteer: false }));
expect(screen.queryByTestId('queued-interrupt-now')).not.toBeInTheDocument();

View file

@ -9,6 +9,9 @@ const baseState: ComposerHintState = {
hasText: false,
isSubmitting: false,
duringRunActive: false,
/** The common case: the epoch has landed, so the run is reachable. The
* pre-epoch window is exercised explicitly below. */
canControlGeneration: true,
duringRunAction: 'queue' as const,
answerModeActive: false,
uploadingCount: 0,
@ -80,6 +83,42 @@ describe('composeHint', () => {
'⌘ ⇧ X com_ui_composer_hint_stop',
);
});
/* `isSubmitting` flips the moment the user sends, but the start POST
installs the generation epoch a beat later. Through that window every
chord that reaches the live run refuses, so naming them would point at
keys that do nothing. Queueing is local and keeps working. */
describe('before the generation epoch lands', () => {
const preEpoch = {
duringRunActive: true,
hasText: true,
isSubmitting: true,
canControlGeneration: false,
};
it('promises only the queue, which is the one action that still works', () => {
expect(hint(preEpoch)).toBe('com_ui_composer_hint_queue_default');
});
it('names no chord that would refuse', () => {
const result = hint(preEpoch);
expect(result).not.toContain('com_ui_composer_hint_send_now');
expect(result).not.toContain('com_ui_composer_hint_interrupt');
expect(result).not.toContain('⌥⏎');
});
it('still names the chord when it IS the queue action', () => {
expect(hint({ ...preEpoch, enterToSend: false })).toBe(
'⌘⏎ com_ui_composer_hint_queue_verb',
);
});
it('restores the full line once the epoch arrives', () => {
const result = hint({ ...preEpoch, canControlGeneration: true });
expect(result).toContain('com_ui_composer_hint_send_now');
expect(result).toContain('com_ui_composer_hint_interrupt');
});
});
});
describe('with Enter bound to a newline', () => {

View file

@ -8,6 +8,11 @@ export interface ComposerHintState {
isSubmitting: boolean;
/** Enter steers or queues instead of starting a turn. */
duringRunActive: boolean;
/** Whether the run can be reached yet. `isSubmitting` flips as soon as the
* user sends, but the start POST installs the generation epoch a moment
* later, and until it lands every chord that touches the live run refuses.
* Queueing is local, so it works throughout. */
canControlGeneration: boolean;
/** Which action Enter takes during a run, per the effective setting. */
duringRunAction: 'steer' | 'queue';
/** The composer is the answer box for a paused `ask_user_question`. */
@ -72,16 +77,25 @@ export function composeHint(
/* With plain Enter bound to a newline, the chord IS the default action and
there is no second chord left to reach the alternate one, so the hint
names only what the composer will actually do. */
const defaultParts = isSteer
? [localize('com_ui_composer_hint_steer'), `${mod} ${localize('com_ui_composer_hint_queue')}`]
: [
localize('com_ui_composer_hint_queue_default'),
`${mod} ${localize('com_ui_composer_hint_send_now')}`,
];
const defaultAction = isSteer
? localize('com_ui_composer_hint_steer')
: localize('com_ui_composer_hint_queue_default');
const alternateAction = isSteer
? `${mod} ${localize('com_ui_composer_hint_queue')}`
: `${mod} ${localize('com_ui_composer_hint_send_now')}`;
const chordVerb = isSteer
? 'com_ui_composer_hint_steer_verb'
: 'com_ui_composer_hint_queue_verb';
const parts = state.enterToSend ? defaultParts : [`${mod} ${localize(chordVerb)}`];
const parts = state.enterToSend
? [defaultAction, alternateAction]
: [`${mod} ${localize(chordVerb)}`];
/* Until the start POST installs the generation epoch, every chord that
reaches the live run refuses only the default action survives, because
queueing is local. Naming the others through that window advertises keys
that do nothing, the same failure as pointing at an unbound shortcut. */
if (!state.canControlGeneration) {
return { text: parts[0], kind: 'state' };
}
return {
text: [...parts, `${alt} ${localize('com_ui_composer_hint_interrupt')}`].join(SEPARATOR),
kind: 'state',