From 6e2b5a98ade41e170b4ddd2ef4058737f7cb2cc4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 6 Nov 2026 01:14:09 -0800 Subject: [PATCH] Cmd/Ctrl+Enter sends the first queued card after a Stop (#38877) * feat(native-chat): holding Cmd/Ctrl turns Send into what Cmd/Ctrl+Enter will do While the chord's modifier is held alone in a busy chat's composer, Send becomes a labelled Steer, Queue or Steer queued button, and with an empty composer the queued card it would steer is highlighted. Releasing the key, pressing any other key, leaving the composer or the window clears it. * fix(native-chat): the held-Cmd preview promises only what Cmd/Ctrl+Enter will do The queue offers its head-card steer only while that card can go (not being edited, no steer already on its way), Shift or Alt held first and an open picker show no preview, and a draft Send can't take keeps its button and reason. The card highlight follows the composer's Steer queued button in CSS, dropping the state and callback that carried it. * fix(native-chat): only the composer holding a preview listens for window blur Every busy chat's composer added window and document listeners; now only the one showing a held-Cmd preview does, and a hold that outlasts a Cmd+Tab never shows. * fix(native-chat): use the Button's own size for the held-Cmd pill, and clear its timer in cleanup * fix(native-chat): no held-Cmd preview while an IME composes The IME owns Enter during composition, so the chord shows nothing then. The listener-count test goes: the work it guarded no longer exists. * test(native-chat): clean up between held-Cmd preview tests * fix(native-chat): the held-Cmd delay timer lives in its own effect The key listener records whether the modifier is down; a separate effect owns the delay's timer and its cleanup, as React Doctor requires. * fix(native-chat): a double-click on the held-Cmd button acts once Sending a draft can turn the button into Steer queued before the second press, which would steer a queued card the person never chose. * fix(native-chat): the held-Cmd button is a pill as tall as Send * fix(native-chat): Cmd/Ctrl+Enter sends the first queued card after a Stop, as steering does mid-turn * test(native-chat): pin which composers send the head card on an empty Cmd/Ctrl+Enter; goal mode never does * test(native-chat): the send-state resolver takes only the transport fields it reads, so its test needs no cast --- .../native-chat/NativeChatComposerPane.tsx | 2 +- .../native-chat-composer-send-state.test.ts | 42 +++++++++++++++++++ .../native-chat-composer-send-state.ts | 25 +++++++---- .../use-native-chat-composer-keydown.test.tsx | 12 +++--- .../use-native-chat-composer-keydown.ts | 10 ++--- ...e-native-chat-steer-chord-preview.test.tsx | 14 ++++++- .../use-native-chat-steer-chord-preview.ts | 20 ++++++--- 7 files changed, 99 insertions(+), 26 deletions(-) create mode 100644 src/renderer/src/components/native-chat/native-chat-composer-send-state.test.ts diff --git a/src/renderer/src/components/native-chat/NativeChatComposerPane.tsx b/src/renderer/src/components/native-chat/NativeChatComposerPane.tsx index a2c09fe8f7f..1105ec4f3db 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposerPane.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposerPane.tsx @@ -343,7 +343,7 @@ export const NativeChatComposerPane = forwardRef 0, ...(!editSession && steerHead ? { steerHead } : {}), - alternateSteers: sendButton.alternateSend === 'steer', + chordSteersHead: sendButton.chordSteersHead, setActiveSuggestion, setDraft, setCaret diff --git a/src/renderer/src/components/native-chat/native-chat-composer-send-state.test.ts b/src/renderer/src/components/native-chat/native-chat-composer-send-state.test.ts new file mode 100644 index 00000000000..f10010e39a7 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-composer-send-state.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest' + +import { nativeChatComposerSendState } from './native-chat-composer-send-state' + +function chordSteersHead( + overrides: Partial[0]> & { + followUp?: 'queue' | 'steer' + } = {} +): boolean { + const { followUp = 'queue', ...input } = overrides + const working = input.isWorking ?? false + const structuredTransport = { sendFollowUp: { action: followUp, offersChoice: working } } + return nativeChatComposerSendState({ + draft: '', + messageDelivery: true, + imageAttachments: [], + pastedTextSaving: false, + disabled: false, + structuredTransport, + editSession: undefined, + isWorking: false, + canStop: true, + ...input + }).chordSteersHead +} + +describe('nativeChatComposerSendState chordSteersHead', () => { + it('sends the head card from an empty composer where steering means sending it now', () => { + // No turn runs (a paused queue after Stop): the card's own Send. + expect(chordSteersHead()).toBe(true) + // Mid-turn with follow-ups queued: the chord's other way is a steer. + expect(chordSteersHead({ isWorking: true })).toBe(true) + }) + + it.each([ + ['the chord queues mid-turn', { isWorking: true, followUp: 'steer' as const }], + ['a queued card is being edited', { editSession: { canSave: true } }], + ['the composer submits a goal, not a message', { messageDelivery: false }] + ])('never does where %s', (_case, overrides) => { + expect(chordSteersHead(overrides)).toBe(false) + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-composer-send-state.ts b/src/renderer/src/components/native-chat/native-chat-composer-send-state.ts index 0fd66d13ca3..c4e5a9831c8 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-send-state.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-send-state.ts @@ -17,6 +17,8 @@ export type NativeChatComposerSendState = { sendButtonDisabled: boolean /** While the agent works: what Cmd/Ctrl+Enter would do instead of a plain send, when it differs. */ alternateSend: NativeChatFollowUpBehavior | undefined + /** Cmd/Ctrl+Enter on an empty composer sends the head queued card now: into the running turn, or with none running (a paused queue after Stop), as its own. */ + chordSteersHead: boolean } /** What the composer's Send/Stop button offers now, and whether it can be pressed. */ @@ -26,7 +28,12 @@ export function nativeChatComposerSendState(input: { imageAttachments: readonly NativeChatComposerImageAttachment[] pastedTextSaving: boolean disabled: boolean - structuredTransport: NativeChatStructuredComposerTransport | undefined + structuredTransport: + | Pick< + NativeChatStructuredComposerTransport, + 'sendOut' | 'sendBlockedReason' | 'threadGoal' | 'sendFollowUp' + > + | undefined editSession: { canSave: boolean } | undefined isWorking: boolean canStop: boolean @@ -59,6 +66,12 @@ export function nativeChatComposerSendState(input: { : draftHeld || !hasDraft // The send's own resolver, so the hint withdraws wherever Cmd/Ctrl+Enter would send no differently. const followUp = transport?.sendFollowUp + const alternateSend: NativeChatFollowUpBehavior | undefined = + input.messageDelivery && !input.editSession && followUp?.offersChoice + ? followUp.action === 'queue' + ? 'steer' + : 'queue' + : undefined return { // The host's reason labels Send only while it holds this draft. sendBlockedReason: @@ -68,11 +81,9 @@ export function nativeChatComposerSendState(input: { sendHeld, hasDraft, sendButtonDisabled, - alternateSend: - input.messageDelivery && !input.editSession && followUp?.offersChoice - ? followUp.action === 'queue' - ? 'steer' - : 'queue' - : undefined + alternateSend, + // Never where the chord queues, or a second press would steer the card the first one just queued. + chordSteersHead: + input.messageDelivery && !input.editSession && (alternateSend === 'steer' || !input.isWorking) } } diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-keydown.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-composer-keydown.test.tsx index d43babad6fa..7c2962da1ee 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-keydown.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-keydown.test.tsx @@ -92,12 +92,12 @@ describe('useNativeChatComposerKeyDown', () => { describe('steer chord', () => { function Composer({ initialDraft, - alternateSteers, + chordSteersHead, send, steerHead }: { initialDraft: string - alternateSteers: boolean + chordSteersHead: boolean send: (intent?: { alternate?: boolean }) => void steerHead: () => boolean }): React.JSX.Element { @@ -115,7 +115,7 @@ describe('useNativeChatComposerKeyDown', () => { interrupt: vi.fn(), send, steerHead, - alternateSteers, + chordSteersHead, setActiveSuggestion: vi.fn(), setDraft, setCaret: vi.fn() @@ -127,14 +127,14 @@ describe('useNativeChatComposerKeyDown', () => { function pressModEnter( initialDraft: string, - { alternateSteers = true, ...init }: KeyboardEventInit & { alternateSteers?: boolean } = {} + { chordSteersHead = true, ...init }: KeyboardEventInit & { chordSteersHead?: boolean } = {} ) { const send = vi.fn() const steerHead = vi.fn(() => true) const field = render( @@ -150,7 +150,7 @@ describe('useNativeChatComposerKeyDown', () => { expect(send).not.toHaveBeenCalled() expect(pressModEnter('', { repeat: true }).steerHead).not.toHaveBeenCalled() // With follow-ups off the chord queues, so a second press never steers the card just queued. - expect(pressModEnter('', { alternateSteers: false }).steerHead).not.toHaveBeenCalled() + expect(pressModEnter('', { chordSteersHead: false }).steerHead).not.toHaveBeenCalled() expect(pressModEnter('steer this').steerHead).not.toHaveBeenCalled() }) }) diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-keydown.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-keydown.ts index b731c197e68..f9870d09d79 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-keydown.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-keydown.ts @@ -34,8 +34,8 @@ export type UseNativeChatComposerKeyDownArgs = { send: (intent?: NativeChatComposerSendIntent) => void /** Cmd/Ctrl+Shift+Enter steers the head queued card, whatever the composer holds. */ steerHead?: (() => boolean) | undefined - /** Cmd/Ctrl+Enter's other way is a steer right now, so on an empty composer it steers the head card too. */ - alternateSteers?: boolean + /** Cmd/Ctrl+Enter on an empty composer sends the head card now: where its other way is a steer, or no turn runs (a stopped, paused queue). */ + chordSteersHead?: boolean setActiveSuggestion: Dispatch> setDraft: Dispatch> setCaret: Dispatch> @@ -56,7 +56,7 @@ export function useNativeChatComposerKeyDown({ interrupt, send, steerHead, - alternateSteers = false, + chordSteersHead = false, setActiveSuggestion, setDraft, setCaret @@ -143,7 +143,7 @@ export function useNativeChatComposerKeyDown({ } else if (draft.trim() !== '' || hasAttachments) { // The other thing than the setting, for this one message. send({ alternate: true }) - } else if (alternateSteers && !event.repeat) { + } else if (chordSteersHead && !event.repeat) { // Nothing of its own to steer, so the head card goes; never where the chord means queue, // or a second press would steer the card the first one just queued. steerHead?.() @@ -193,7 +193,7 @@ export function useNativeChatComposerKeyDown({ mentionFiles, send, steerHead, - alternateSteers, + chordSteersHead, setActiveSuggestion, setCaret, setDraft diff --git a/src/renderer/src/components/native-chat/use-native-chat-steer-chord-preview.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-steer-chord-preview.test.tsx index b5fcf366971..959e0c461b1 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-steer-chord-preview.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-steer-chord-preview.test.tsx @@ -15,6 +15,7 @@ vi.mock('./native-chat-shortcut', () => ({ isMacPlatform: () => true })) type Props = { alternateSend: NativeChatFollowUpBehavior | undefined hasDraft: boolean + chordSteersHead: boolean steerHead: (() => boolean) | undefined send: () => void pickerOpen: boolean @@ -22,10 +23,10 @@ type Props = { function Composer(props: Props): React.JSX.Element { const root = useRef(null) - const { alternateSend, hasDraft, steerHead, send, pickerOpen } = props + const { alternateSend, hasDraft, chordSteersHead, steerHead, send, pickerOpen } = props const { preview, runPreview } = useNativeChatSteerChordPreview( root, - { alternateSend, hasDraft }, + { alternateSend, hasDraft, chordSteersHead }, steerHead, send, pickerOpen @@ -55,6 +56,7 @@ describe('useNativeChatSteerChordPreview', () => { const props: Props = { alternateSend: 'steer', hasDraft: false, + chordSteersHead: true, steerHead: vi.fn(() => true), send: vi.fn(), pickerOpen: false, @@ -93,4 +95,12 @@ describe('useNativeChatSteerChordPreview', () => { act(() => vi.advanceTimersByTime(NATIVE_CHAT_STEER_CHORD_HOLD_MS)) expect(preview()).toBe('') }) + + it('previews sending the head card after a Stop, when no turn runs to steer into', () => { + const { props, button, hold, preview } = setup({ alternateSend: undefined }) + hold() + expect(preview()).toBe('steer-queued') + fireEvent.click(button) + expect(props.steerHead).toHaveBeenCalledTimes(1) + }) }) diff --git a/src/renderer/src/components/native-chat/use-native-chat-steer-chord-preview.ts b/src/renderer/src/components/native-chat/use-native-chat-steer-chord-preview.ts index c74676dbe33..44a0c695f22 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-steer-chord-preview.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-steer-chord-preview.ts @@ -13,7 +13,16 @@ export const NATIVE_CHAT_STEER_CHORD_HOLD_MS = 150 export function useNativeChatSteerChordPreview( root: RefObject, /** The send's own resolver (`nativeChatComposerSendState`): `alternateSend` is undefined where the chord sends no differently. */ - { alternateSend, hasDraft }: { alternateSend?: NativeChatFollowUpBehavior; hasDraft: boolean }, + { + alternateSend, + hasDraft, + chordSteersHead + }: { + alternateSend?: NativeChatFollowUpBehavior + hasDraft: boolean + /** On an empty composer the chord sends the head card now (the keydown's own flag). */ + chordSteersHead: boolean + }, /** Present only while there is a head card to steer. */ steerHead: (() => boolean) | undefined, send: (intent?: NativeChatComposerSendIntent) => void, @@ -23,7 +32,8 @@ export function useNativeChatSteerChordPreview( // 'down' while the modifier is pressed alone and the delay runs; 'held' once it has passed. const [chord, setChord] = useState<'up' | 'down' | 'held'>('up') const held = chord === 'held' - const enabled = alternateSend !== undefined + const steersHead = chordSteersHead && steerHead !== undefined + const enabled = alternateSend !== undefined || steersHead useEffect(() => { const element = root.current if (!element || !enabled) { @@ -94,11 +104,11 @@ export function useNativeChatSteerChordPreview( }, [held]) const preview: NativeChatSteerChordPreview | null = - !held || !alternateSend || pickerOpen + !held || pickerOpen ? null : hasDraft - ? alternateSend - : alternateSend === 'steer' && steerHead + ? (alternateSend ?? null) + : steersHead ? 'steer-queued' : null