diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 92a487ee890..cf38981f182 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -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()) diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index f045f2024e3..99df85abd4c 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -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) diff --git a/apps/desktop/electron/quick-entry.test.ts b/apps/desktop/electron/quick-entry.test.ts index 572cae419d2..fa8039791d4 100644 --- a/apps/desktop/electron/quick-entry.test.ts +++ b/apps/desktop/electron/quick-entry.test.ts @@ -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 }) }) }) diff --git a/apps/desktop/electron/quick-entry.ts b/apps/desktop/electron/quick-entry.ts index f436b1e3139..db2975696bd 100644 --- a/apps/desktop/electron/quick-entry.ts +++ b/apps/desktop/electron/quick-entry.ts @@ -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. diff --git a/apps/desktop/src/app/contrib/hooks/use-quick-entry-bridge.ts b/apps/desktop/src/app/contrib/hooks/use-quick-entry-bridge.ts index 99362298437..dbd886e18fa 100644 --- a/apps/desktop/src/app/contrib/hooks/use-quick-entry-bridge.ts +++ b/apps/desktop/src/app/contrib/hooks/use-quick-entry-bridge.ts @@ -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 } +// 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() + } + }, []) } diff --git a/apps/desktop/src/app/contrib/wiring.tsx b/apps/desktop/src/app/contrib/wiring.tsx index 37ada98ef82..de2e0becffd 100644 --- a/apps/desktop/src/app/contrib/wiring.tsx +++ b/apps/desktop/src/app/contrib/wiring.tsx @@ -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 diff --git a/apps/desktop/src/app/quick-entry/quick-entry-app.tsx b/apps/desktop/src/app/quick-entry/quick-entry-app.tsx index d45bae84637..3f5d54835d6 100644 --- a/apps/desktop/src/app/quick-entry/quick-entry-app.tsx +++ b/apps/desktop/src/app/quick-entry/quick-entry-app.tsx @@ -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(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() { >
- - › - - 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} - /> +
+ + › + + { + // 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} + /> +
+
+ + +
) diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index 51e8b988a5f..c3405e404a2 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -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 - // 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 diff --git a/apps/desktop/src/store/quick-entry.test.ts b/apps/desktop/src/store/quick-entry.test.ts index f8117eb4bd4..0657cdd5fb0 100644 --- a/apps/desktop/src/store/quick-entry.test.ts +++ b/apps/desktop/src/store/quick-entry.test.ts @@ -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' }]) }) }) diff --git a/apps/desktop/src/store/quick-entry.ts b/apps/desktop/src/store/quick-entry.ts index 75bd2fd53d1..54916083240 100644 --- a/apps/desktop/src/store/quick-entry.ts +++ b/apps/desktop/src/store/quick-entry.ts @@ -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 + 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) } })