mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 02:17:31 +00:00
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
This commit is contained in:
@@ -343,7 +343,7 @@ export const NativeChatComposerPane = forwardRef<ComposerHandle, C.NativeChatCom
|
||||
send,
|
||||
hasAttachments: imageAttachments.length > 0,
|
||||
...(!editSession && steerHead ? { steerHead } : {}),
|
||||
alternateSteers: sendButton.alternateSend === 'steer',
|
||||
chordSteersHead: sendButton.chordSteersHead,
|
||||
setActiveSuggestion,
|
||||
setDraft,
|
||||
setCaret
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { nativeChatComposerSendState } from './native-chat-composer-send-state'
|
||||
|
||||
function chordSteersHead(
|
||||
overrides: Partial<Parameters<typeof nativeChatComposerSendState>[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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
<Composer
|
||||
initialDraft={initialDraft}
|
||||
alternateSteers={alternateSteers}
|
||||
chordSteersHead={chordSteersHead}
|
||||
send={send}
|
||||
steerHead={steerHead}
|
||||
/>
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<SetStateAction<number>>
|
||||
setDraft: Dispatch<SetStateAction<string>>
|
||||
setCaret: Dispatch<SetStateAction<number>>
|
||||
@@ -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
|
||||
|
||||
+12
-2
@@ -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<HTMLDivElement>(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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,7 +13,16 @@ export const NATIVE_CHAT_STEER_CHORD_HOLD_MS = 150
|
||||
export function useNativeChatSteerChordPreview(
|
||||
root: RefObject<HTMLElement | null>,
|
||||
/** 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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user