Quick Launch Modal (part 3)
The third part of the Quick Launch page, for readers working in the codebase: how the floating window is managed, how its global and per-tab hotkeys register, how each tab maps to a component, the voice-suppression seam that keeps main-window voice commands dormant, and the design decisions behind the modal.
What it is
This is part 3 of the Quick Launch Modal page. The main page covers what Quick Launch is, how to open it, how to compose and send, and its settings; part 2 covers every other tab and the mobile sheet. This half is the implementation view: how the window, the hotkeys and the tabs are built, and why they are built that way.
Where to find it
There is no separate UI surface here — this half is for someone working in the codebase. Everything it describes is reached at runtime through the same floating window and the same Settings → Quick Launch page the other two pages cover.
How it behaves
What a reader can observe of the choices below: the modal pops up over whatever is on screen but never covers an exclusive-fullscreen game or video player, it hides rather than closes so an unsent draft survives, and its window is frameless and cannot be moved or resized.
For agents
How it works (for repo-aware readers)
- Window manager: quick-launch-window.ts owns the BrowserWindow lifecycle as an explicit state machine (
Cold,PreWarming,WarmHidden,Opening,Shown,Closing,Hidden) — lazy creation on first open, "kept alive" across opens, primary-display centering recomputed every show, lifecycle listeners (focus/blur/closed) wire up voice-service suppression and click-outside-to-dismiss. It also self-heals a blank pre-warmed window: because the window is loaded once and onlyshow()'d (never reloaded), a silent renderer mount failure used to leave it permanently blank until an app restart. The renderer reportsQUICK_LAUNCH_RENDERER_READYon mount, and real BrowserWindows must also emit Electronready-to-showbefore reveal; a hidden→shown open that is not reveal-ready reloads once, waits for readiness, or discards before anyshow()call if readiness times out (fall-through-to-recreate immediate if the reload itself hard-fails — the healthy path stays await-free).render-process-gone/ a main-framedid-fail-loadalso discard the window so the next hotkey lazy-recreates it. Full invariants (I11/I22): quick-launch-entrance-animation-contract.md. - Graceful close (v14): every dismiss — Escape / Ctrl+W, click-away, and submit — routes through
animateCloseAndHide(), which shrinks the window's BOUNDS toward its centre and glides it away rather than hard-cutting the opaque rectangle (which read as "the dark background stays up / two windows"). An opaque window can't fade — a panel-opacity fade reads as a blank rectangle andwin.setOpacity()re-introduces the fragile layered window v11 deleted — so bounds are the only lever. Main runs asetBoundsframe-tween (~130 ms); the renderer content scale is arequestAnimationFramefollower that re-reads the LIVE window size every frame (QUICK_LAUNCH_CLOSING→quick-launch-exit, opacity held at 1, over a pixel-frozen wrapper), NOT an independent scale-out keyframe. v14 CENTRES that wrapper in the live viewport (position:fixed;left/top:50%; scaled viatranslate(-50%, -50%) scale(...)), so the collapse lands on the window centre = screen centre by construction. v13 scaled the wrapper about its TOP-LEFT, so a lagging (too-big) frame was clipped only at the bottom-right and its visible centre drifted up-and-left — the "hides to the left" bug (reproduced 2026-07-12). (The earlier v12 drift was worse: two unsynchronized clocks — main'ssetIntervalbounds-shrink about CENTRE + a renderer wall-clock CSS keyframe about TOP-LEFT — "collapsed in a different direction each time.") A centre-anchored scale/size mismatch is a symmetric dark hairline on all sides (invisible), never a corner pin. Thenhide()s + restores full bounds. Re-entrant,setWindowHeightno-ops while closing, and a re-open mid-shrink aborts cleanly. Full invariants (I17/I18): quick-launch-entrance-animation-contract.md. - Theme-matched window background (open-flash fix): the opaque BrowserWindow's native
backgroundColoris seeded at#18181b(bare-dark surface-100), but the panel paints the ACTIVE theme'sbg-surface-100(glassmorphism#0e0e16, ~1.7× darker). On open, a compositor re-raster momentarily drops the renderer's paint — including the wrapper — so for ~1 frame the fixed, lighter bg showed as a full-modal light/white flash before the dark content painted (worst under glassmorphism, the default theme). Fix: the renderer reports the live--color-surface-100on every theme apply/change (QUICK_LAUNCH_SET_WINDOW_BG) and main keeps the nativebackgroundColormatched viawin.setBackgroundColor— the window stays fully OPAQUE (nevertransparent:true/setOpacity), only its solid color follows the theme. Full invariants (I15): quick-launch-entrance-animation-contract.md. - Hotkey registration: Electron
globalShortcut.register(accelerator, () => quickLaunchWindow.toggleShow())— re-registered on every settings change so a new value takes effect without an app restart. - Per-tab hotkeys: planned by
planHotkeyRegistrations()in src/shared/global-hotkeys.ts (a'quick launch tab'candidate per assigned tab, appended after the fixed four + email recipients; gated onquickLaunchHotkeyEnabled).registerGlobalHotkeys()in src/main/index.ts first runsselectQuickLaunchTabHotkeys(settings.quickLaunchTabHotkeys, isEnabled)to drop disabled/unknown tabs — itsisEnabledpredicate accepts a repo tab id (parseRepoTabId(id) !== null) as well as an enabled registry action, so repo tabs get a hotkey too (their project existence is validated at fire time, not in the pure planner). Each fired hotkey doesawait toggleShow()+emitPush(QUICK_LAUNCH_OPEN_TO_TAB, { actionId }). The modal listens for that validated push; both itsQUICK_LAUNCH_OPEN_TO_TABhandler and thevisibleTabIdsfold gate onisTabVisible(actionId, enabledByActionId, existingProjectIds)— the same dual gate that covers repo tabs — so a forced target (registry OR repo) selects, renders, cycles, and reverts on dismiss (a repo target whose project was deleted just falls through to the default tab). The push channel must be inQUICK_LAUNCH_ALLOWLIST(detached-push-filter.ts) and have a modal listener (both pinned by quick-launch-push-listener-coverage.test.ts). Invariantshotkeys-register-last…repo-tabs-hotkey-capable: quick-launch-action-registry-contract.md. - Renderer entry: src/renderer/quick-launch.html + src/renderer/src/quick-launch.tsx — third rollup entry alongside
index.htmlandrecorder-host.html. Loaded viaELECTRON_RENDERER_URLin dev, from disk in production. On mount it firesQUICK_LAUNCH_RENDERER_READYfrom a top-level effect (so a render throw never signals ready) — the blank-renderer recovery handshake the window manager uses to tell a healthy window from one that came up blank. - Modal component: src/renderer/src/features/quick-launch/QuickLaunchModal.tsx — textarea + chip row + send button + inline error strip. Owns the textarea state; Esc / submit / blur clear it. The tab strip + gear icon + the "+" add-tab button (with its
AddTabMenudropdown) live here;Ctrl+Tab/Ctrl+Shift+Tabcycling andCtrl+1…Ctrl+9direct tab-jump share one window-levelkeydownlistener (auseEffectonwindow, not a panel-scopedonKeyDown) so they fire whenever the modal window is focused regardless of where focus sits inside it. The digit branch guards on!e.altKey(so AltGr / odd-layout combos can't hijack), maps digit n → the n-thvisibleTabIdsentry (no-op past the 9th or out of range), and onlypreventDefault()s a key it acts on. Each of the first nine tabs surfaces its number on hover via the shared<Tooltip>and exposes it to assistive tech witharia-keyshortcuts(no rawtitle=). No collision with the main window's universalCtrl+1…Ctrl+0project/inbox switcher: the Quick Launch window is a separate React root (quick-launch.tsx) that never mountsuseKeyboardShortcuts, so that dispatcher isn't present here. The "+" listsgetAvailableToAddActions(pinnedActionIds, enabledByActionId)and pins a choice by optimistically updating localpinnedActionIdsthen persisting viaIPC.SETTINGS_UPDATE(rollback + inline error on failure) — no new IPC. The strip mounts when>1tab is visible or there is something to add, so the "+" stays reachable from a single-tab install. Right-clicking a tab opensTabContextMenu(opaque, cursor-positioned viacontainToViewport) — it now opens for every tab, Session included. At the top is an inline Global hotkey capture field (the sharedQuickLaunchTabHotkeyField,autoFocused,pretty-displayed) that persists viahandleSetTabHotkey→SETTINGS_UPDATE(optimistic + rollback); below it a Remove from Quick Launch action callshandleRemoveTab— strikes the id frompinnedActionIds, persists viaSETTINGS_UPDATE(rollback + inline error), reselectsfirstVisibleTabIdwhen the removed tab was active — shown for every tab except Session (Remove suppressed for Session, contractremove-unpins-in-place; Session's menu is hotkey-only). Locked by QuickLaunchModal-remove-tab.test.tsx. When the pinned tabs overflow the fixed-width window, the tab row is a horizontally-scrolling region (overflow-x-auto, scrollbar hidden via the modal's inline<style>) with fade-in ◀/▶ chevron buttons (found byaria-label, deliberately NOTdata-ui-anchorto stay off the ui-anchor registry lint), vertical-wheel→horizontal scroll, and Ctrl+Tab scroll-into-view; the "+" and gear sit in aflex-shrink-0cluster OUTSIDE the scroller so they can never be clipped. The arrow-visibility + scroll-into-view geometry is the pure, unit-tested quick-launch-tab-scroll.ts; full invariants in quick-launch-tab-overflow-contract.md. Behavior locked by tests/unit/features/quick-launch/QuickLaunchModal-add-tab.test.tsx + the helper's tests in tests/unit/shared/quick-launch-actions.test.ts. Per-tab bodies are leaf components looked up via the action-component registry. - Action registry (shared, React-free): src/shared/quick-launch-actions.ts — declares
QUICK_LAUNCH_ACTIONS(aReadonlyArray<QuickLaunchAction>withid,label,iconName,enabledSelector(settings)) plusQUICK_LAUNCH_ACTION_IDSandDEFAULT_QUICK_LAUNCH_PINNED_ACTION_IDS. Kept pure-data so the main process can validatequickLaunchPinnedActionIdssettings against it without pulling in React. - Action components (renderer-side): src/renderer/src/features/quick-launch/quick-launch-action-components.ts —
QUICK_LAUNCH_ACTION_COMPONENTS: Record<QuickLaunchActionId, ReactComponent>maps each registry ID to the React form that renders when that tab is active. The two registries must stay in lockstep — enforced by tests/unit/lint/quick-launch-action-registry-completeness.test.ts (every action ID has a matching component, every registered component points at a known ID, every action has a non-empty label + icon, IDs are unique). - Project picker: src/renderer/src/features/quick-launch/QuickLaunchProjectPicker.tsx — Ctrl+P fuzzy-search overlay; mounted/unmounted via a renderer flag that also tells the main process to swallow Esc inside the picker only.
- Project icon + initial-letter fallback: src/renderer/src/features/quick-launch/QuickLaunchProjectIcon.tsx wraps the shared
<ProjectIcon>; when that would render empty (a real folder with no customiconPathand no virtual-sentinel__…__folderPath) it substitutes a first-letter glyph. The tile color falls back totintForProjectName()— a djb2 hash passed through a MurmurHash3-stylemix32finalizer so the low bits consumed by% palette.lengthdistribute well (plaindjb2 % 8keyed off only the low 3 bits and clustered real projects like Omniscio/Nothari onto one color). The 8-color palette + the pinned five-name tint mapping are locked by tests/unit/features/quick-launch/QuickLaunchSessionTab-project-icon.test.tsx. - Session-tab textarea card: src/renderer/src/features/quick-launch/QuickLaunchSessionTab.tsx wraps just the textarea in a
rounded-xlinset card (bg-surface-950/60+ border, content edges aligned with the tab strip) and carries the focus indicator on the card viafocus-within:ring. The textarea setsfocus:shadow-noneto suppress the globaltextarea:focusaccent box-shadow (globals.css) that would otherwise paint a square halo inside the rounded card — see gotchas-frontend.md. - Voice mic: src/renderer/src/features/quick-launch/QuickLaunchVoiceMic.tsx — own
getUserMedia+MediaRecorder+ IPC chunking (mirrors<VoiceInput>'s recording side). Rendered only whenvoiceEnabledis on — the QL window keeps no settings store, so the flag is mirrored in viaGET_INITIAL_STATE(state.voiceEnabled, default OFF), the same master Voice-Commands switch the main composer mic gates on; the Session tab renders the mic behindstate.voiceEnabled ? … : null. Push-event subscriptions (voice:partial-transcript,voice:final-transcript,voice:status-changed,voice:error) are gated onisRecordingso a stray transcript from the main window's own session can't bleed in. - Per-tab body components: each tab's body lives in its own file under
src/renderer/src/features/quick-launch/(e.g.QuickLaunchAlarmTab.tsx,QuickLaunchTaskTab.tsx, etc.). Each tab is self-contained — owns its own form state, calls its target IPC directly viaipc.invoke(no Zustand store; QL is its own renderer process and shares no stores with the main window), and calls the modal'sanimateOutAndCloseon successful save. Four "quick-add" tabs (Alarm/Drip/Bookmark/Calendar) target their existing CREATE/UPDATE IPC handlers directly; the Task tab calls a dedicated main-side router (IPC.QUICK_LAUNCH_CREATE_TASK) that files into Tasks (the default / Inbox list) when v2 is enabled, else a v1 task. The three "spawn a session" tabs (QuickLaunchSessionSearchTab.tsx, QuickLaunchAskAmcTab.tsx, and QuickLaunchAutomationHelperTab.tsx) route through the modal's existing submit action with a virtual-project sentinel — the main-process QUICK_LAUNCH_SUBMIT handler detectsprojectId === SEARCH_PROJECT_ID,ASK_AMC_PROJECT_ID, orAUTOMATION_HELPER_PROJECT_ID(the closedSUBMIT_VIRTUAL_SENTINELSset) and callsprepareSearchSessionsWorkDir()/ensureAskAmcProject()/ensureAutomationHelperProject()to provision the right workdir + lazily seed the project before spawning. Alarm tab is gated onsettings.alarmsEnabled; Ask Omniscio onsettings.askAmcEnabled; Automation onsettings.automationHelperEnabled. Like Search / Ask Omniscio, the Automation tab is now special-cased for the shared attachment slice (Contractstandalone-props-only) so a screenshot/PDF of what to automate rides along —QUICK_LAUNCH_SUBMITalready carriesimages/documentsand forwards them tocreateSessionWithPrompt, so this was front-end-only. The Automation Helper's "builder" persona is applied by project id at spawn time (spawn-cluster-manager'sisAutomationHelper→bootstrapAutomationHelperWorkdir()), so spawning it withsource: 'quick-launch'behaves identically to the gallery'sautomation-presetlaunch. - Shared composer field (
QuickLaunchComposer): every "type-and-submit" tab (Automation, Ask Omniscio, KMS Note, Alarm, SMS, DM, Task) renders ONE shared QuickLaunchComposer for its input instead of hand-rolling a<textarea>— it composes the isolated no-typing-lag box (QuickLaunchComposerInput) + opt-in attachments/image-paste (only where the tab's backend carries them — Automation + Ask Omniscio, the modal-owned attachment slice) + an opt-in send-later picker (Task) + a consistent Cancel/Submit footer +'enter'or'ctrl_enter'submit-key mode, withaboveBox/belowBoxslots for each tab's own chrome (the SMS/DM recipient pickers, the Alarm parsed preview, the Task list picker). Capabilities are backend-gated so a tab never shows an affordance the wire drops; a build guard (quick-launch-composer-adoption.test.ts) blocks any new tab from hand-rolling its own box. The Email tab keeps its own composer by design (it's a full email form). Invariants (I1–I6): quick-launch-composer-adoption-contract.md. - Special-cased tabs (Contract
standalone-props-only): the action registry is pure data, but four tabs need state outside the per-tab leaf (textarea + attachments + project chip for Session; attachments + query for Session Search; attachments + question for Ask Omniscio; attachments + description for Automation).QuickLaunchModalspecial-casesid === 'session',id === 'session-search',id === 'ask-amc', andid === 'automation-helper'to injectattachments/onRemoveAttachment/onPaste/onOpenFilePickerfrom the shared modal state. Ask Omniscio + Automation reuse the Session Search attachment wiring verbatim (each is just a session spawned into a different virtual project). The lockstep lint test tests/unit/lint/quick-launch-action-registry-completeness.test.ts still enforces every ID has a component; the contract for these two special-cased IDs is documented in .claude/memory/contracts/quick-launch-action-registry-contract.md. - File picker (paperclip): click-to-browse routes through the main-process
DIALOG_PICK_ATTACHMENTShandler (not renderer-side<input type="file">, which crashes the Quick Launch renderer viachrome.mojom.UtilWinon some Windows builds). Two QL-window-manager pieces cooperate to keep the modal alive while the native dialog is open:fileDialogOpenflag (src/main/services/quick-launch-window.ts) — the modal callsIPC.QUICK_LAUNCH_SET_FILE_DIALOG_OPEN { active: true }before invoking the picker. The window's blur handler short-circuits whenoauthFlowActive || fileDialogOpen(independent gates, both can be set; blur only un-suppresses when both clear).hide()defensively clears the flag so a stucktruecan't wedge dismissal.- alwaysOnTop drop — while
fileDialogOpenis set, the QL window'salwaysOnToplevel is removed and restored on release, so the native picker doesn't render behind the QL composer. The renderer'sfileDialogInFlightRefguards against double-open from a frantic paperclip click. Both pieces are pinned by tests/unit/services/quick-launch-window.test.ts — six tests cover the blur gate, alwaysOnTop drop/restore, post-release blur-hides-again, defensivehide()clear, pre-window safety, and the OAuth/file-dialog independence.
- Blur-dismiss survives transient focus-steals: AFTER the
oauthFlowActive/fileDialogOpen/ sibling-HUD short-circuits above, the blur handler no longer hides synchronously — it routes through the shared two-stage discriminator hud-blur-dismiss.ts. On Windows it defers ~200 ms, then re-checks after another ~120 ms, reading the OS foreground: a deliberate switch (another app or an Omniscio window owns the foreground) dismisses as before, but a transient Win32 console that flashed and vanished during a session spawn (the user runs 100+) leaves the foreground empty at BOTH samples → the launcher stays open and reclaims focus via a topmost-preservingforceForegroundWindowinstead of vanishing mid-type. Reuses FocusGuard's win32 primitives (isForeignForegroundWindow/forceForegroundWindow); the two-stage confirm avoids stealing focus back on a real Alt+Tab whose foreground momentarily reads empty. Off-Windows it keeps today's immediate dismiss. Shared with the Clipboard History picker; invariants + tests in hud-blur-dismiss-contract.md. - Clickable attachment chips (preview / copy / Save-As / reorder): the chip strip QuickLaunchAttachmentChips.tsx is pure-presentational — it reports gestures up via
onPreview/onContextMenu/onReorder/onRemoveand reuses useDragReorder (orientation: 'horizontal').QuickLaunchModalownspendingAttachmentsand renders the reused PendingImageLightbox + PendingImageContextMenu verbatim (both self-contained, nosessionId, base64-driven); the lightbox indexes only the image subset so nav never lands on a document. Escape two-step lives in main: quick-launch-window.ts holds alightboxOpenflag and itsbefore-input-eventhandler routes Escape / Ctrl+W open popup → snippet picker → picker → lightbox → hide (the popup flag mirrors the page's open-menu/popup registry, so a menu opened over the preview closes first) — while a preview is open it emitsQUICK_LAUNCH_LIGHTBOX_DISMISSinstead ofhide()(the lightbox's ownuseEscapeClosenever fires; main swallows the key first). The renderer drives the flag viaQUICK_LAUNCH_SET_LIGHTBOX_OPEN. Save-As wrapsIMAGE_SAVE_DATA_ASin the SAMEfileDialogInFlightRef+QUICK_LAUNCH_SET_FILE_DIALOG_OPENguard as the paperclip (the menu never calls the save IPC itself — the caller owns it so it can raise the guard), and the preview overlays INSIDE the fixed-size window (the old open-grow/close-restore height requests are deleted — the window never resizes; entrance-animation contract I21). The dismiss push ridesQUICK_LAUNCH_ALLOWLIST(detached-push-filter.ts). Full invariants (documents-are-not-previewable…the-dismiss-push-stays-in-the-quick-launch-renderer) + tests: quick-launch-attachment-affordances-contract.md. - Enablement + active-tab flow:
enabledByActionId— built by the sharedbuildEnabledByActionId(settings)(one builder, also used by the Settings card so strip-visibility and the editor hint can't disagree) — is surfaced onQUICK_LAUNCH_GET_INITIAL_STATE(no separate IPC), read on mount + refreshed on focus. Tab visibility runs through the sharedisTabVisible(id, enabledByActionId, existingProjectIds)predicate (registry flag OR repo-project-exists). The tab active on open is the first visible tab in saved order viafirstVisibleTabId(...)(repo tabs included; falls back to'session') — applied identically at mount, blur-reset, and submit-reset so they can't drift (contractopens-first-visible).quick-emailis gated byquickEmailEnabledalone (recipients no longer required); each gated action carries adisabledHintthe Settings card shows when it's off. - Repo tabs (
repo:<projectId>): a per-repo tab is a pinned id of the formrepo:<projectId>(not a registry action) — it opens the Session composer locked to that project. Encode/decode viamakeRepoTabId/parseRepoTabId/isRepoTabIdin src/shared/quick-launch-actions.ts.QuickLaunchModalgives repo ids a project-exists visibility gate (self-heal on delete), renders them with the project icon + name, routes them toQuickLaunchSessionTabwithlocked(chip omitted, no picker, Ctrl+P suppressed), and strips the prefix at submit so the bare UUID reaches the unchangedQUICK_LAUNCH_SUBMIThandler. The "+" menu's "New session in a repo…" item opens an add-repo picker (reusedQuickLaunchProjectPickerover the spawnable, unpinned repos). Full rules + invariants in .claude/memory/contracts/quick-launch-action-registry-contract.md. - Settings card: src/renderer/src/features/settings/sections/quick-launch-tabs/QuickLaunchTabsCard.tsx — two-column pinned/available card mounted in Settings → Quick Launch. In the Available column a row whose feature is off (
enabledByActionId[id] === false, via the sharedisActionDisabledpredicate) renders grayed (opacity-50) with adisabledcheckbox so an off tab can't be pinned until its feature is on; the Pinned column deliberately keeps its checkbox interactive so a pinned-but-off tab is still removable (both locked by QuickLaunchTabsCard.test.tsx). Resolves pinnedrepo:<id>entries to project rows (name + icon) and lazily prunes dead repo ids — from bothquickLaunchPinnedActionIdsandquickLaunchTabHotkeys— (guarded on the project list being loaded). Its Tab hotkeys sub-section renders one sharedQuickLaunchTabHotkeyFieldper registry action plus one per pinned repo tab (the same field the composer's right-click menu uses, so the two surfaces can't drift). Below the columns it offers an Add a repo session tab<Select>whose options areaddableRepoProjects(pinnedActionIds, projects)— spawnable, not-already-pinned, and!isDeleted(the card receives the full settings-store project list, which can include soft-deleted rows the composer's main-sideallProjectsalready excludes; contractrepo-picker-addables, locked by QuickLaunchTabsCard.test.tsx). The composer's gear icon round-trips here via theQUICK_LAUNCH_OPEN_SETTINGSIPC handler (src/main/ipc/quick-launch-handlers.ts) — the gear invokes it with{ section: 'quick-launch' }— which hides QL, foregrounds the main window, and emits aSETTINGS_DEEP_LINKpush the main renderer listens for (in usePluginBridgePushListeners.ts), callingsetPendingSettingId('quick-launch-tabs')+navigateToSettingsProject({ section: 'quick-launch' }). TheuseScrollToSettinghook then polls the DOM for[data-setting-id="quick-launch-tabs"]and scrolls + flashes the card. - Voice suppression seam: src/main/services/voice-service.ts —
quickLaunchFocusedboolean flipped bysetQuickLaunchFocused()on the QL window'sfocus/blurevents. While true,handleFinalTranscriptForModeearly-returns BEFORE invoking the intent parser, so main-window voice commands stay dormant. Thevoice:final-transcriptpush event still fires (the QL mic needs it). - IPC: Session-tab submit goes through the same
IPC.SESSION_CREATE_WITH_PROMPTpath as the main composer's "+ New Session" — Zod-validated, account-routed, attachment-aware. Most non-Session tabs target their own existing IPC (IPC.ALARMS_CREATE,IPC.DRIP_CREATE,IPC.BOOKMARKS_CREATE,IPC.CALENDAR_CREATE_EVENT) — front-ending existing write paths. Two tabs add a dedicated handler: KMS Note (IPC.KMS_QUICK_NOTE_CREATE, wrapping the existing KMScreateNoteservice — primary-vault resolution +Quick Notes/path derivation live in quick-note-service.ts) and Task (IPC.QUICK_LAUNCH_CREATE_TASK, a router that creates in Tasks / Inbox when v2 is enabled, else a v1 task — so the storeless QL window never decides which task system is active). - Settings: Five fields on
AppSettings—quickLaunchHotkey(string, default'CommandOrControl+Space'),quickLaunchHotkeyEnabled(boolean, defaulttrue),quickLaunchDefaultProjectId(string | null, defaultnull),quickLaunchPinnedActionIds(string[], default = every action ID in registry order), andquickLaunchTabHotkeys(Record<actionId, accelerator>, default{ calendar: 'CommandOrControl+Alt+J', task: 'Alt+Space' }— the per-tab global hotkeys; a newly-shipped default backfills into an existing install viaapplyDefaultsDeep's per-key merge). The pinned-IDs list uses plainstring[]instead of a narrowed action-ID union for forward compatibility — older Omniscio builds reading newer settings will silently drop unknown IDs rather than fail to load;quickLaunchTabHotkeysis likewise loose (Record<string, string>) and unknown ids are dropped at registration time. Zod schema slice in src/shared/ipc-schemas/settings/hotkeys-quicklaunch-settings.ts.
Design decisions worth knowing
Separate BrowserWindow, not a modal inside the main window: The main window can be minimized / on another monitor / completely hidden when you hit the hotkey, and Quick Launch still has to pop up. A standalone window with its own React tree is the cleanest way to satisfy that. Cost: a second renderer process at idle (~50 MB).
Always-on-top, but only "normal" level: Omniscio explicitly does NOT use Electron's
'screen-saver'window level — that would let Quick Launch overlay exclusive-fullscreen games and video players, which is jarring. The trade-off is that exclusive-fullscreen apps eat the hotkey; you have to alt-tab out first.Hide, not destroy, on dismiss: The React tree mounts once on first open and stays mounted across subsequent opens. The composer is cleared on submit — and on Esc/blur only when Remember unsent Quick Launch text is off; by default an unsent draft (text + pasted-text chips + staged attachments) is kept across opens because the live window state survives. Everything else (project picker state, voice service connection, etc.) persists too. This is the same "keep-alive pool" pattern Omniscio uses for hidden SessionPanels. (Because the draft rides that in-memory tree, it is not written to disk — a full app restart starts blank.)
Primary-display centering, recomputed every show: Caching the centered coordinates would be wrong — the user might dock/undock a monitor between opens, making the cached position land off-screen. Cost is one
screen.getPrimaryDisplay()call per open, which is cheap.No menubar / titlebar / window controls: Frameless + transparent + non-resizable + non-movable. This is a composer, not an app — it should feel like a system overlay, not a window you manage.
Related
part 2 is the tab surfaces these components render, and the main page is the user-facing half. The contracts named above hold the invariants behind each piece, and keyboard-shortcuts.md is the global shortcut catalog Quick Launch registers into.
Last verified 2026-09-26