mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
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:
parent
aa3121bb34
commit
d6fdae6746
10 changed files with 550 additions and 131 deletions
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}, [])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
19
apps/desktop/src/global.d.ts
vendored
19
apps/desktop/src/global.d.ts
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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' }])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue