feat(desktop): quick-entry window — global hotkey capture to any session

Parity with Claude Desktop quick-entry-window / ChatGPT Quick Chat.
This commit is contained in:
teknium1 2026-07-26 16:51:43 -07:00 committed by Teknium
parent aa3121bb34
commit d6fdae6746
10 changed files with 550 additions and 131 deletions

View file

@ -8683,6 +8683,10 @@ const QUICK_ENTRY_CONFIG_PATH = path.join(app.getPath('userData'), 'quick-entry.
let quickEntryWindow = null
// Latest state push from the primary renderer (connection + recent sessions),
// replayed to a quick window that spawns after the push happened.
let quickEntryLastState = null
function readQuickEntrySettings() {
try {
return sanitizeQuickEntrySettings(JSON.parse(fs.readFileSync(QUICK_ENTRY_CONFIG_PATH, 'utf8')))
@ -8772,6 +8776,15 @@ function spawnQuickEntryWindow() {
}
})
// Replay the last known gateway state as soon as the page can hear it — a
// freshly spawned quick window must not sit "disconnected" when the primary
// renderer already reported a live gateway.
win.webContents.on('did-finish-load', () => {
if (!win.isDestroyed() && quickEntryLastState) {
win.webContents.send('hermes:quick-entry:state', quickEntryLastState)
}
})
win.loadURL(quickEntryUrl())
return win
@ -10180,13 +10193,15 @@ ipcMain.handle('hermes:quick-entry:settings:set', async (_event, patch) => {
})
// Quick window → main → PRIMARY renderer. We never submit here: the renderer
// owns the one prompt-submit path, and forwarding keeps it that way.
ipcMain.on('hermes:quick-entry:submit', (_event, text) => {
// owns the one prompt-submit path, and forwarding keeps it that way. The
// payload is `{ target, text }` — target routing (current chat / a picked
// session / new) is the renderer's job too.
ipcMain.on('hermes:quick-entry:submit', (_event, payload) => {
hideQuickEntryWindow()
const prompt = typeof text === 'string' ? text.trim() : ''
const text = typeof payload?.text === 'string' ? payload.text.trim() : ''
if (!prompt) {
if (!text) {
return
}
@ -10198,7 +10213,21 @@ ipcMain.on('hermes:quick-entry:submit', (_event, text) => {
// Deliberately does NOT raise/focus the main window — the user asked to fire
// a prompt from wherever they were, not to be yanked into the app.
mainWindow.webContents.send('hermes:quick-entry:submit', prompt)
mainWindow.webContents.send('hermes:quick-entry:submit', {
target: typeof payload?.target === 'string' && payload.target ? payload.target : 'current',
text
})
})
// Primary renderer → main → quick window: gateway connection state + the
// recent-session list for the target picker. Cached so a quick window spawned
// AFTER the last push still boots from truth instead of "disconnected".
ipcMain.on('hermes:quick-entry:state', (_event, payload) => {
quickEntryLastState = payload ?? null
if (quickEntryWindow && !quickEntryWindow.isDestroyed()) {
quickEntryWindow.webContents.send('hermes:quick-entry:state', payload)
}
})
ipcMain.on('hermes:quick-entry:dismiss', () => hideQuickEntryWindow())

View file

@ -43,11 +43,22 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
quickEntry: {
getSettings: () => ipcRenderer.invoke('hermes:quick-entry:settings:get'),
setSettings: patch => ipcRenderer.invoke('hermes:quick-entry:settings:set', patch),
submit: text => ipcRenderer.send('hermes:quick-entry:submit', text),
submit: payload => ipcRenderer.send('hermes:quick-entry:submit', payload),
dismiss: () => ipcRenderer.send('hermes:quick-entry:dismiss'),
// Main → primary renderer: text captured by the quick window.
// Primary renderer → main → quick window: gateway connection state + the
// recent-session options the target picker offers. Main caches the latest
// payload so a freshly spawned quick window starts from truth.
pushState: payload => ipcRenderer.send('hermes:quick-entry:state', payload),
// Quick window subscribes to those pushes.
onState: callback => {
const listener = (_event, payload) => callback(payload)
ipcRenderer.on('hermes:quick-entry:state', listener)
return () => ipcRenderer.removeListener('hermes:quick-entry:state', listener)
},
// Main → primary renderer: a submit captured by the quick window.
onSubmit: callback => {
const listener = (_event, text) => callback(text)
const listener = (_event, payload) => callback(payload)
ipcRenderer.on('hermes:quick-entry:submit', listener)
return () => ipcRenderer.removeListener('hermes:quick-entry:submit', listener)

View file

@ -235,6 +235,6 @@ describe('quickEntryWindowBounds', () => {
})
it('falls back to the origin without a work area', () => {
expect(quickEntryWindowBounds()).toEqual({ height: 132, width: 640, x: 0, y: 0 })
expect(quickEntryWindowBounds()).toEqual({ height: 168, width: 640, x: 0, y: 0 })
})
})

View file

@ -19,10 +19,10 @@
const DEFAULT_QUICK_ENTRY_SHORTCUT = 'CommandOrControl+Shift+Space'
// Compact capture surface: wide enough for a sentence, short enough to read as
// a HUD rather than a second app window. Height is the composer's collapsed
// height; the renderer never grows the OS window in v1.
// a HUD rather than a second app window. Height covers the composer row plus
// the session-target picker row; the renderer never grows the OS window in v1.
const QUICK_ENTRY_WINDOW_WIDTH = 640
const QUICK_ENTRY_WINDOW_HEIGHT = 132
const QUICK_ENTRY_WINDOW_HEIGHT = 168
// Spotlight-ish placement: horizontally centered on the active display, a
// comfortable fraction down from the top rather than dead center.

View file

@ -1,33 +1,95 @@
import { useEffect, useRef } from 'react'
import { initQuickEntryBridge, setQuickEntrySubmitHandler } from '@/store/quick-entry'
import {
initQuickEntryBridge,
QUICK_TARGET_CURRENT,
QUICK_TARGET_NEW,
type QuickEntrySessionOption,
setQuickEntrySubmitHandler
} from '@/store/quick-entry'
import { $gatewayState, $sessions } from '@/store/session'
import { sessionTileDelegate } from '@/store/session-states'
import { isSecondaryWindow } from '@/store/windows'
interface QuickEntryBridgeParams {
startFreshSessionDraft: () => void
submitText: (text: string) => Promise<unknown> | unknown
}
// The picker is a capture aid, not a session browser — a handful of recent
// rows is the whole point.
const QUICK_ENTRY_SESSION_OPTIONS = 5
function sessionOptions(): QuickEntrySessionOption[] {
return $sessions
.get()
.filter(session => !session.archived)
.slice(0, QUICK_ENTRY_SESSION_OPTIONS)
.map(session => ({
id: session.id,
title: session.title?.trim() || session.preview?.trim() || session.id
}))
}
/**
* Wires the global-hotkey Quick Entry window back into the app: text captured
* there is submitted through THIS window's normal prompt path (`submitText`), so
* there is exactly one submit pipeline and no bespoke gateway RPC.
* Wires the global-hotkey Quick Entry window back into the app, both ways:
*
* The handler registers ONCE through a ref tracking the latest callback
* - **Inbound:** text captured there is routed by target and submitted through
* THIS window's normal prompt machinery current chat rides `submitText`, a
* picked stored session rides the session-tile delegate (resume + submit,
* background, without touching the primary view the same path tiled
* sessions use), and "new session" is a fresh draft + submit, exactly what
* clicking New Chat and typing does. One submit pipeline, no bespoke RPC.
* - **Outbound:** gateway connection state + the recent-session list are pushed
* to the quick window (via main, which caches the latest push), so its input
* disables with a reconnect hint whenever the backend is unreachable.
*
* Handlers register ONCE through refs tracking the latest callbacks
* re-registering on identity churn leaves a nulled-handler window that can drop
* a submit (the same bug shape use-pet-bridge guards). Primary window only: a
* secondary session window must not also claim the global capture channel, or
* one keystroke would send N prompts.
*/
export function useQuickEntryBridge({ submitText }: QuickEntryBridgeParams): void {
export function useQuickEntryBridge({ startFreshSessionDraft, submitText }: QuickEntryBridgeParams): void {
const submitTextRef = useRef(submitText)
submitTextRef.current = submitText
const startFreshRef = useRef(startFreshSessionDraft)
startFreshRef.current = startFreshSessionDraft
useEffect(() => {
if (isSecondaryWindow()) {
return
}
setQuickEntrySubmitHandler(text => void submitTextRef.current(text))
setQuickEntrySubmitHandler(({ target, text }) => {
if (target === QUICK_TARGET_NEW) {
// Same as the user clicking New Chat and typing: fresh draft, then the
// normal submit creates the backend session.
startFreshRef.current()
void submitTextRef.current(text)
return
}
if (target !== QUICK_TARGET_CURRENT) {
// A picked stored session: resume + submit in the background through
// the session-tile delegate so the primary view stays where it is.
const delegate = sessionTileDelegate()
if (delegate) {
void delegate
.resumeTile(target)
.then(runtimeId => delegate.submitToSession(runtimeId, text))
// A dead/undeliverable target must not swallow the prompt.
.catch(() => void submitTextRef.current(text))
return
}
}
void submitTextRef.current(text)
})
const dispose = initQuickEntryBridge()
return () => {
@ -35,4 +97,32 @@ export function useQuickEntryBridge({ submitText }: QuickEntryBridgeParams): voi
dispose()
}
}, [])
// Push gateway truth into the quick window whenever it changes: connection
// state gates its input; the recent-session list feeds its target picker.
useEffect(() => {
if (isSecondaryWindow()) {
return
}
const api = window.hermesDesktop?.quickEntry
if (!api?.pushState) {
return
}
const push = () => {
api.pushState({ connected: $gatewayState.get() === 'open', sessions: sessionOptions() })
}
push()
const offGateway = $gatewayState.listen(push)
const offSessions = $sessions.listen(push)
return () => {
offGateway()
offSessions()
}
}, [])
}

View file

@ -610,8 +610,9 @@ export function ContribWiring({ children }: { children: ReactNode }) {
usePetBridge({ requestGateway, resumeSession, submitText })
// The global-hotkey Quick Entry window's bridge: its captured text rides the
// SAME submitText the normal composer uses.
useQuickEntryBridge({ submitText })
// SAME submit machinery the normal composer uses (current chat / picked
// session / new session), and it hears gateway truth from this window.
useQuickEntryBridge({ startFreshSessionDraft, submitText })
// Clear a failed turn's red error banner. Errors are renderer-local (never
// persisted): a bare error placeholder is dropped entirely; a partial-output

View file

@ -2,6 +2,8 @@ import { useEffect, useReducer, useRef } from 'react'
import {
initialQuickComposerState,
QUICK_TARGET_CURRENT,
QUICK_TARGET_NEW,
type QuickComposerEvent,
quickComposerReducer,
type QuickComposerState
@ -9,23 +11,26 @@ import {
/**
* The Quick Entry composer the whole renderer surface of the global-hotkey
* mini window. Deliberately one input and nothing else: this is a capture
* surface, not a second chat.
* mini window. Deliberately one input plus a session-target picker and nothing
* else: this is a capture surface, not a second chat.
*
* All behavior rides `quickComposerReducer` (pure, unit-tested): submit sends
* the trimmed text through the shell and asks to hide; an empty submit does
* neither so a stray Enter can't make the window vanish; Escape and losing
* focus dismiss without sending.
* the trimmed text + target through the shell and asks to hide; an empty submit
* does neither so a stray Enter can't make the window vanish; Escape and losing
* focus dismiss without sending; a dead gateway disables the input entirely
* (the reducer refuses the send AND the input paints the reconnect hint).
*
* The window itself has no gateway connection. Text goes to the main process,
* which forwards it to the primary renderer's normal prompt-submit path.
* The window itself has no gateway connection. Its view of backend truth is
* the gateway up, which recent sessions exist is pushed in by the primary
* renderer through main (`onState`), and its text goes back the same road to
* the primary renderer's normal prompt-submit path.
*/
export function QuickEntryApp() {
const inputRef = useRef<HTMLInputElement>(null)
// The reducer returns { send, state }; this wrapper performs the side effect
// (hand the text to the shell, ask to hide) and stores the next state, so the
// decision stays pure and testable while the effects stay in one place.
// (hand the payload to the shell, ask to hide) and stores the next state, so
// the decision stays pure and testable while the effects stay in one place.
const [state, dispatch] = useReducer((current: QuickComposerState, event: QuickComposerEvent) => {
const { send, state: next } = quickComposerReducer(current, event)
const api = window.hermesDesktop?.quickEntry
@ -40,16 +45,30 @@ export function QuickEntryApp() {
}, initialQuickComposerState)
// Re-summoned by the chord: the shell reuses the window, so reset the draft
// and take the keyboard back for a fresh capture.
// and take the keyboard back for a fresh capture. Also adopt gateway-state
// pushes (connection + recent sessions) relayed from the primary renderer.
useEffect(() => {
const off = window.hermesDesktop?.quickEntry?.onShown(() => {
const api = window.hermesDesktop?.quickEntry
const offShown = api?.onShown(() => {
dispatch({ type: 'shown' })
requestAnimationFrame(() => inputRef.current?.focus())
})
const offState = api?.onState(payload => {
dispatch({
connected: payload?.connected === true,
sessions: Array.isArray(payload?.sessions) ? payload.sessions : [],
type: 'state'
})
})
inputRef.current?.focus()
return off
return () => {
offShown?.()
offState?.()
}
}, [])
return (
@ -66,60 +85,112 @@ export function QuickEntryApp() {
>
<div
style={{
alignItems: 'center',
background: 'var(--ui-bg-elevated, var(--background))',
border: '1px solid var(--ui-stroke-secondary, rgba(127,127,127,0.35))',
borderRadius: 12,
boxShadow: '0 18px 48px rgba(0,0,0,0.38)',
display: 'flex',
gap: 10,
flexDirection: 'column',
gap: 8,
padding: '10px 14px',
width: '100%'
}}
>
<span
aria-hidden
style={{
color: 'var(--muted-foreground, #8a8a8a)',
flexShrink: 0,
fontSize: 15,
lineHeight: 1,
userSelect: 'none'
}}
>
</span>
<input
aria-label="Quick Entry"
autoCapitalize="off"
autoComplete="off"
autoCorrect="off"
onBlur={() => dispatch({ type: 'blur' })}
onChange={event => dispatch({ draft: event.target.value, type: 'edit' })}
onKeyDown={event => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
dispatch({ type: 'submit' })
} else if (event.key === 'Escape') {
event.preventDefault()
dispatch({ type: 'dismiss' })
}
}}
placeholder="Ask Hermes…"
ref={inputRef}
spellCheck={false}
style={{
background: 'transparent',
border: 'none',
color: 'var(--foreground, #eee)',
flex: 1,
fontFamily: 'inherit',
fontSize: 15,
minWidth: 0,
outline: 'none'
}}
value={state.draft}
/>
<div style={{ alignItems: 'center', display: 'flex', gap: 10 }}>
<span
aria-hidden
style={{
color: 'var(--muted-foreground, #8a8a8a)',
flexShrink: 0,
fontSize: 15,
lineHeight: 1,
userSelect: 'none'
}}
>
</span>
<input
aria-label="Quick Entry"
autoCapitalize="off"
autoComplete="off"
autoCorrect="off"
disabled={!state.connected}
onBlur={event => {
// Moving focus to the target picker is not leaving the window.
if (!event.relatedTarget) {
dispatch({ type: 'blur' })
}
}}
onChange={event => dispatch({ draft: event.target.value, type: 'edit' })}
onKeyDown={event => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
dispatch({ type: 'submit' })
} else if (event.key === 'Escape') {
event.preventDefault()
dispatch({ type: 'dismiss' })
}
}}
placeholder={state.connected ? 'Ask Hermes…' : 'Not connected — open Hermes to reconnect'}
ref={inputRef}
spellCheck={false}
style={{
background: 'transparent',
border: 'none',
color: 'var(--foreground, #eee)',
flex: 1,
fontFamily: 'inherit',
fontSize: 15,
minWidth: 0,
opacity: state.connected ? 1 : 0.55,
outline: 'none'
}}
value={state.draft}
/>
</div>
<div style={{ alignItems: 'center', display: 'flex', gap: 8 }}>
<label
htmlFor="quick-entry-target"
style={{
color: 'var(--muted-foreground, #8a8a8a)',
flexShrink: 0,
fontSize: 11,
userSelect: 'none'
}}
>
Send to
</label>
<select
aria-label="Target session"
disabled={!state.connected}
id="quick-entry-target"
onChange={event => dispatch({ target: event.target.value, type: 'target' })}
onKeyDown={event => {
if (event.key === 'Escape') {
event.preventDefault()
dispatch({ type: 'dismiss' })
}
}}
style={{
background: 'transparent',
border: '1px solid var(--ui-stroke-secondary, rgba(127,127,127,0.35))',
borderRadius: 6,
color: 'var(--foreground, #eee)',
fontSize: 11,
maxWidth: 320,
padding: '2px 6px'
}}
value={state.target}
>
<option value={QUICK_TARGET_CURRENT}>Current chat</option>
<option value={QUICK_TARGET_NEW}>New session</option>
{state.sessions.map(session => (
<option key={session.id} value={session.id}>
{session.title}
</option>
))}
</select>
</div>
</div>
</div>
)

View file

@ -6,7 +6,7 @@ import type {
PetOverlayOpenRequest,
PetOverlayStatePayload
} from './store/pet-overlay'
import type { QuickEntryStatus } from './store/quick-entry'
import type { QuickEntryStatePush, QuickEntryStatus, QuickEntrySubmitPayload } from './store/quick-entry'
export {}
@ -66,13 +66,20 @@ declare global {
// `error: 'taken'` when another app already owns the chord, so a failed
// registration surfaces in Settings instead of failing silently.
setSettings: (patch: { enabled?: boolean; shortcut?: string }) => Promise<QuickEntryStatus>
// Quick window → main: send this text (main forwards it to the primary
// renderer, which submits it through the normal prompt path) and hide.
submit: (text: string) => void
// Quick window → main: send this payload (main forwards it to the
// primary renderer, which routes it to the target session and submits
// through the normal prompt path) and hide.
submit: (payload: QuickEntrySubmitPayload) => void
// Quick window → main: hide without sending (Escape / blur).
dismiss: () => void
// Primary renderer subscribes to text captured by the quick window.
onSubmit: (callback: (text: string) => void) => () => void
// Primary renderer → main → quick window: gateway connection state +
// the recent-session options. Main caches the latest push and replays
// it to a quick window spawned later.
pushState: (payload: QuickEntryStatePush) => void
// Quick window subscribes to those pushes.
onState: (callback: (payload: QuickEntryStatePush) => void) => () => void
// Primary renderer subscribes to submits captured by the quick window.
onSubmit: (callback: (payload: QuickEntrySubmitPayload | string) => void) => () => void
// Quick window subscribes to "you were just summoned" so it can reset
// its draft and re-focus the input on every open.
onShown: (callback: () => void) => () => void

View file

@ -2,15 +2,18 @@ import { describe, expect, it } from 'vitest'
import {
initialQuickComposerState,
QUICK_TARGET_CURRENT,
QUICK_TARGET_NEW,
type QuickComposerEvent,
quickComposerReducer,
type QuickComposerState
type QuickComposerState,
type QuickEntrySubmitPayload
} from './quick-entry'
// Drive the reducer like the window does, collecting every send it asked for.
function run(events: QuickComposerEvent[], from: QuickComposerState = initialQuickComposerState) {
let state = from
const sent: string[] = []
const sent: QuickEntrySubmitPayload[] = []
for (const event of events) {
const transition = quickComposerReducer(state, event)
@ -24,46 +27,136 @@ function run(events: QuickComposerEvent[], from: QuickComposerState = initialQui
return { sent, state }
}
// Most flows only make sense once the primary renderer has reported a live
// gateway — this is the push the quick window receives on open.
const connect: QuickComposerEvent = {
connected: true,
sessions: [
{ id: 's1', title: 'Fix the build' },
{ id: 's2', title: 'Research trip' }
],
type: 'state'
}
describe('quickComposerReducer', () => {
it('starts visible with an empty draft', () => {
expect(initialQuickComposerState).toEqual({ draft: '', submitting: false, visible: true })
it('starts visible, empty, DISCONNECTED, and targeting the current chat', () => {
expect(initialQuickComposerState).toEqual({
connected: false,
draft: '',
sessions: [],
submitting: false,
target: QUICK_TARGET_CURRENT,
visible: true
})
})
it('submit sends the trimmed draft, clears it, and hides', () => {
const { sent, state } = run([{ draft: ' ship it ', type: 'edit' }, { type: 'submit' }])
it('submit sends the trimmed draft with the target, clears it, and hides', () => {
const { sent, state } = run([connect, { draft: ' ship it ', type: 'edit' }, { type: 'submit' }])
expect(sent).toEqual(['ship it'])
expect(state).toEqual({ draft: '', submitting: true, visible: false })
expect(sent).toEqual([{ target: QUICK_TARGET_CURRENT, text: 'ship it' }])
expect(state.draft).toBe('')
expect(state.submitting).toBe(true)
expect(state.visible).toBe(false)
})
it('an empty or whitespace-only submit sends nothing and stays open', () => {
const blank = run([{ type: 'submit' }])
const blank = run([connect, { type: 'submit' }])
expect(blank.sent).toEqual([])
expect(blank.state.visible).toBe(true)
const spaces = run([{ draft: ' ', type: 'edit' }, { type: 'submit' }])
const spaces = run([connect, { draft: ' ', type: 'edit' }, { type: 'submit' }])
expect(spaces.sent).toEqual([])
// A stray Enter must not make the window vanish out from under the user.
expect(spaces.state.visible).toBe(true)
expect(spaces.state.draft).toBe(' ')
})
it('a second submit while already submitting cannot double-send', () => {
const { sent, state } = run([{ draft: 'hello', type: 'edit' }, { type: 'submit' }, { type: 'submit' }])
it('submit is DISABLED while disconnected — the draft survives for the reconnect', () => {
const { sent, state } = run([{ draft: 'hello?', type: 'edit' }, { type: 'submit' }])
expect(sent).toEqual(['hello'])
expect(sent).toEqual([])
expect(state.visible).toBe(true)
expect(state.draft).toBe('hello?')
// The gateway comes back: the same draft now sends.
const after = run([connect, { type: 'submit' }], state)
expect(after.sent).toEqual([{ target: QUICK_TARGET_CURRENT, text: 'hello?' }])
})
it('a disconnect push mid-composition keeps the draft but blocks the send', () => {
const { sent, state } = run([
connect,
{ draft: 'almost done', type: 'edit' },
{ connected: false, sessions: [], type: 'state' },
{ type: 'submit' }
])
expect(sent).toEqual([])
expect(state.connected).toBe(false)
expect(state.draft).toBe('almost done')
})
it('a second submit while already submitting cannot double-send', () => {
const { sent, state } = run([connect, { draft: 'hello', type: 'edit' }, { type: 'submit' }, { type: 'submit' }])
expect(sent).toEqual([{ target: QUICK_TARGET_CURRENT, text: 'hello' }])
expect(state.submitting).toBe(true)
})
it('Escape dismisses without sending and discards the draft', () => {
const { sent, state } = run([{ draft: 'never mind', type: 'edit' }, { type: 'dismiss' }])
it('a picked session target rides the submit payload', () => {
const { sent } = run([
connect,
{ target: 's2', type: 'target' },
{ draft: 'send this there', type: 'edit' },
{ type: 'submit' }
])
expect(sent).toEqual([{ target: 's2', text: 'send this there' }])
})
it('the new-session target rides the submit payload', () => {
const { sent } = run([
connect,
{ target: QUICK_TARGET_NEW, type: 'target' },
{ draft: 'fresh start', type: 'edit' },
{ type: 'submit' }
])
expect(sent).toEqual([{ target: QUICK_TARGET_NEW, text: 'fresh start' }])
})
it('a picked session that vanishes from the pushed list falls back to current', () => {
const { state } = run([
connect,
{ target: 's2', type: 'target' },
{ connected: true, sessions: [{ id: 's1', title: 'Fix the build' }], type: 'state' }
])
expect(state.target).toBe(QUICK_TARGET_CURRENT)
})
it('a state push that still contains the picked session keeps it', () => {
const { state } = run([connect, { target: 's1', type: 'target' }, connect])
expect(state.target).toBe('s1')
})
it('Escape dismisses without sending, discards the draft, and resets the target', () => {
const { sent, state } = run([
connect,
{ target: 's1', type: 'target' },
{ draft: 'never mind', type: 'edit' },
{ type: 'dismiss' }
])
expect(sent).toEqual([])
expect(state).toEqual({ draft: '', submitting: false, visible: false })
expect(state.draft).toBe('')
expect(state.target).toBe(QUICK_TARGET_CURRENT)
expect(state.visible).toBe(false)
})
it('blur dismisses without sending', () => {
const { sent, state } = run([{ draft: 'clicked away', type: 'edit' }, { type: 'blur' }])
const { sent, state } = run([connect, { draft: 'clicked away', type: 'edit' }, { type: 'blur' }])
expect(sent).toEqual([])
expect(state.visible).toBe(false)
@ -71,22 +164,29 @@ describe('quickComposerReducer', () => {
})
it('the blur that follows a submit does not re-send or resurrect the draft', () => {
const { sent, state } = run([{ draft: 'go', type: 'edit' }, { type: 'submit' }, { type: 'blur' }])
const { sent, state } = run([connect, { draft: 'go', type: 'edit' }, { type: 'submit' }, { type: 'blur' }])
expect(sent).toEqual(['go'])
expect(state).toEqual({ draft: '', submitting: false, visible: false })
expect(sent).toEqual([{ target: QUICK_TARGET_CURRENT, text: 'go' }])
expect(state.draft).toBe('')
expect(state.submitting).toBe(false)
expect(state.visible).toBe(false)
})
it('being re-summoned resets to a fresh capture surface', () => {
const afterSubmit = run([{ draft: 'first', type: 'edit' }, { type: 'submit' }]).state
it('being re-summoned resets the capture surface but KEEPS the pushed gateway truth', () => {
const afterSubmit = run([connect, { draft: 'first', type: 'edit' }, { type: 'submit' }]).state
const { sent, state } = run([{ type: 'shown' }], afterSubmit)
expect(sent).toEqual([])
expect(state).toEqual(initialQuickComposerState)
expect(state.draft).toBe('')
expect(state.target).toBe(QUICK_TARGET_CURRENT)
expect(state.visible).toBe(true)
// The gateway did not disconnect just because the window was re-opened.
expect(state.connected).toBe(true)
expect(state.sessions).toHaveLength(2)
})
it('re-summoning after a dismiss never carries the old draft back', () => {
const dismissed = run([{ draft: 'stale text', type: 'edit' }, { type: 'dismiss' }]).state
const dismissed = run([connect, { draft: 'stale text', type: 'edit' }, { type: 'dismiss' }]).state
const reopened = quickComposerReducer(dismissed, { type: 'shown' }).state
expect(reopened.draft).toBe('')
@ -95,20 +195,22 @@ describe('quickComposerReducer', () => {
it('editing keeps the window open and never sends', () => {
const { sent, state } = run([
connect,
{ draft: 'a', type: 'edit' },
{ draft: 'ab', type: 'edit' },
{ draft: 'abc', type: 'edit' }
])
expect(sent).toEqual([])
expect(state).toEqual({ draft: 'abc', submitting: false, visible: true })
expect(state.draft).toBe('abc')
expect(state.visible).toBe(true)
})
it('a full summon → type → submit → summon cycle sends exactly once per round', () => {
const first = run([{ draft: 'one', type: 'edit' }, { type: 'submit' }])
const first = run([connect, { draft: 'one', type: 'edit' }, { type: 'submit' }])
const second = run([{ type: 'shown' }, { draft: 'two', type: 'edit' }, { type: 'submit' }], first.state)
expect(first.sent).toEqual(['one'])
expect(second.sent).toEqual(['two'])
expect(first.sent).toEqual([{ target: QUICK_TARGET_CURRENT, text: 'one' }])
expect(second.sent).toEqual([{ target: QUICK_TARGET_CURRENT, text: 'two' }])
})
})

View file

@ -99,17 +99,53 @@ export async function saveQuickEntrySettings(patch: { enabled?: boolean; shortcu
// ── Quick window submit state machine ───────────────────────────────────────
/** A recent session the quick window can target (pushed by the primary). */
export interface QuickEntrySessionOption {
id: string
title: string
}
/** Send into whatever chat the main window currently has in front. */
export const QUICK_TARGET_CURRENT = 'current'
/** Start a brand-new session for this prompt. */
export const QUICK_TARGET_NEW = 'new'
/**
* The primary renderer's push into the quick window: is the gateway usable, and
* which recent sessions can be targeted. The quick window has NO gateway of its
* own, so this pushed copy is its only view of backend truth it starts
* disconnected (input disabled) until the first push proves otherwise.
*/
export interface QuickEntryStatePush {
connected: boolean
sessions: QuickEntrySessionOption[]
}
/** What a quick-window submit carries back to the primary renderer. */
export interface QuickEntrySubmitPayload {
/** QUICK_TARGET_CURRENT, QUICK_TARGET_NEW, or a stored session id. */
target: string
text: string
}
/**
* The quick window's own composer state. Deliberately a tiny pure reducer: the
* behavior that would actually break a user an empty submit must not send but
* must still not hide the window, a real submit clears the draft AND hides, and
* a double-fire while already submitting must not send twice is the part worth
* proving, and none of it needs React or Electron.
* must still not hide the window, a real submit clears the draft AND hides, a
* double-fire while already submitting must not send twice, and a dead gateway
* must disable sending entirely is the part worth proving, and none of it
* needs React or Electron.
*/
export interface QuickComposerState {
/** Last pushed gateway truth. False (the initial value) disables submit. */
connected: boolean
draft: string
/** Recent sessions the picker offers, pushed by the primary renderer. */
sessions: QuickEntrySessionOption[]
/** True between a send and the window actually hiding. Blocks a double-send. */
submitting: boolean
/** Where a submit lands: current / new / a stored session id. */
target: string
/** Whether the window should be visible. False asks the shell to hide. */
visible: boolean
}
@ -119,15 +155,26 @@ export type QuickComposerEvent =
| { type: 'dismiss' }
| { type: 'edit'; draft: string }
| { type: 'shown' }
| { type: 'state'; connected: boolean; sessions: QuickEntrySessionOption[] }
| { type: 'submit' }
| { type: 'target'; target: string }
export interface QuickComposerTransition {
/** Text to send through the real prompt-submit path, or null for none. */
send: null | string
/** Payload to send through the real prompt-submit path, or null for none. */
send: null | QuickEntrySubmitPayload
state: QuickComposerState
}
export const initialQuickComposerState: QuickComposerState = { draft: '', submitting: false, visible: true }
export const initialQuickComposerState: QuickComposerState = {
// Disconnected until the primary renderer's first push proves otherwise — a
// capture window that accepts text it can never deliver is a lie.
connected: false,
draft: '',
sessions: [],
submitting: false,
target: QUICK_TARGET_CURRENT,
visible: true
}
export function quickComposerReducer(state: QuickComposerState, event: QuickComposerEvent): QuickComposerTransition {
switch (event.type) {
@ -135,7 +182,10 @@ export function quickComposerReducer(state: QuickComposerState, event: QuickComp
case 'dismiss': {
// Escape / focus loss discards without sending. A dismiss mid-submit still
// hides — the send already left for the main process.
return { send: null, state: { draft: '', submitting: false, visible: false } }
return {
send: null,
state: { ...state, draft: '', submitting: false, target: QUICK_TARGET_CURRENT, visible: false }
}
}
case 'edit': {
@ -143,20 +193,51 @@ export function quickComposerReducer(state: QuickComposerState, event: QuickComp
}
case 'shown': {
// Re-summoned: a fresh capture surface every time, never a stale draft.
return { send: null, state: { ...initialQuickComposerState } }
// Re-summoned: a fresh capture surface every time — never a stale draft or
// a leftover target — but the pushed gateway truth carries over.
return {
send: null,
state: { ...state, draft: '', submitting: false, target: QUICK_TARGET_CURRENT, visible: true }
}
}
case 'state': {
// Adopt the pushed truth. A selected session that no longer exists in the
// pushed list must not silently swallow the prompt — fall back to current.
const targetStillValid =
event.connected &&
(state.target === QUICK_TARGET_CURRENT ||
state.target === QUICK_TARGET_NEW ||
event.sessions.some(session => session.id === state.target))
return {
send: null,
state: {
...state,
connected: event.connected,
sessions: event.sessions,
target: targetStillValid ? state.target : QUICK_TARGET_CURRENT
}
}
}
case 'submit': {
const text = state.draft.trim()
// Nothing to send: stay open so the user can type instead of the window
// vanishing on a stray Enter.
if (!text || state.submitting) {
// Nothing to send — or nowhere to send it (gateway down): stay open and
// keep the draft so a stray Enter can't make the text vanish.
if (!text || state.submitting || !state.connected) {
return { send: null, state }
}
return { send: text, state: { draft: '', submitting: true, visible: false } }
return {
send: { target: state.target, text },
state: { ...state, draft: '', submitting: true, visible: false }
}
}
case 'target': {
return { send: null, state: { ...state, target: event.target } }
}
default: {
@ -167,17 +248,42 @@ export function quickComposerReducer(state: QuickComposerState, event: QuickComp
// ── Primary-renderer bridge ────────────────────────────────────────────────
let submitHandler: ((text: string) => void) | null = null
let submitHandler: ((payload: QuickEntrySubmitPayload) => void) | null = null
let unsubscribeSubmit: (() => void) | null = null
/**
* Register the handler that turns a quick-window submit into a real send. The
* primary window points this at `usePromptActions().submitText`.
* primary window routes it by target: current chat `submitText`, a stored
* session id resume + submit, new fresh draft + submit.
*/
export function setQuickEntrySubmitHandler(fn: ((text: string) => void) | null): void {
export function setQuickEntrySubmitHandler(fn: ((payload: QuickEntrySubmitPayload) => void) | null): void {
submitHandler = fn
}
function normalizeSubmitPayload(raw: unknown): null | QuickEntrySubmitPayload {
// Tolerate the v1 bare-string wire shape (an older quick window after a
// partial update) by treating it as "send to the current chat".
if (typeof raw === 'string') {
return raw.trim() ? { target: QUICK_TARGET_CURRENT, text: raw } : null
}
if (!raw || typeof raw !== 'object') {
return null
}
const record = raw as Record<string, unknown>
const text = typeof record.text === 'string' ? record.text : ''
if (!text.trim()) {
return null
}
return {
target: typeof record.target === 'string' && record.target ? record.target : QUICK_TARGET_CURRENT,
text
}
}
/**
* Wire the quick-window primary-renderer submit channel once. Returns a
* disposer. Idempotent a second call while wired is a no-op.
@ -189,9 +295,11 @@ export function initQuickEntryBridge(): () => void {
return () => {}
}
unsubscribeSubmit = api.onSubmit(text => {
if (typeof text === 'string' && text.trim()) {
submitHandler?.(text)
unsubscribeSubmit = api.onSubmit(raw => {
const payload = normalizeSubmitPayload(raw)
if (payload) {
submitHandler?.(payload)
}
})