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',