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