From 718eec3fbb679479d31a93640bf07bc7b208521c Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Sun, 2 Aug 2026 04:49:11 +0200 Subject: [PATCH] fix: stop advertising during-run chords the run cannot answer yet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- client/src/components/Chat/Input/ChatForm.tsx | 9 ++++- .../components/Chat/Input/Composer/Queue.tsx | 14 ++++--- .../Input/Composer/__tests__/Queue.spec.tsx | 7 ++++ .../Input/__tests__/useComposerHint.spec.ts | 39 +++++++++++++++++++ client/src/hooks/Input/useComposerHint.ts | 28 +++++++++---- 5 files changed, 82 insertions(+), 15 deletions(-) diff --git a/client/src/components/Chat/Input/ChatForm.tsx b/client/src/components/Chat/Input/ChatForm.tsx index f665a4c05e..3ce3bbd4b0 100644 --- a/client/src/components/Chat/Input/ChatForm.tsx +++ b/client/src/components/Chat/Input/ChatForm.tsx @@ -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 && ( steering.sendQueuedNow(message, { preempt: true })} /> )} diff --git a/client/src/components/Chat/Input/Composer/__tests__/Queue.spec.tsx b/client/src/components/Chat/Input/Composer/__tests__/Queue.spec.tsx index 9d3f5c8417..9ff68a70e8 100644 --- a/client/src/components/Chat/Input/Composer/__tests__/Queue.spec.tsx +++ b/client/src/components/Chat/Input/Composer/__tests__/Queue.spec.tsx @@ -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(); diff --git a/client/src/hooks/Input/__tests__/useComposerHint.spec.ts b/client/src/hooks/Input/__tests__/useComposerHint.spec.ts index dd4a463f1a..e9d0a87bd4 100644 --- a/client/src/hooks/Input/__tests__/useComposerHint.spec.ts +++ b/client/src/hooks/Input/__tests__/useComposerHint.spec.ts @@ -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', () => { diff --git a/client/src/hooks/Input/useComposerHint.ts b/client/src/hooks/Input/useComposerHint.ts index 3071bff1b0..6218905e42 100644 --- a/client/src/hooks/Input/useComposerHint.ts +++ b/client/src/hooks/Input/useComposerHint.ts @@ -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',