---
title: Quick Launch Modal (part 3)
---

# Quick Launch Modal (part 3)

## What it is

This is part 3 of the [Quick Launch Modal](quick-launch-modal.md) page. The [main page](quick-launch-modal.md) covers what Quick Launch is, how to open it, how to compose and send, and its settings; [part 2](quick-launch-modal-part-2.md) 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](../../src/main/services/quick/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 only `show()`'d (never reloaded), a silent renderer mount failure used to leave it permanently blank until an app restart. The renderer reports `QUICK_LAUNCH_RENDERER_READY` on mount, and real BrowserWindows must also emit Electron `ready-to-show` before reveal; a hidden→shown open that is not reveal-ready reloads once, waits for readiness, or discards before any `show()` 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-frame `did-fail-load` also discard the window so the next hotkey lazy-recreates it. Full invariants (I11/I22): [quick-launch-entrance-animation-contract.md](../../.claude/memory/contracts/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 and `win.setOpacity()` re-introduces the fragile layered window v11 deleted — so bounds are the only lever. Main runs a `setBounds` frame-tween (~130 ms); the renderer content scale is a **`requestAnimationFrame` follower 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 via `translate(-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's `setInterval` bounds-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. Then `hide()`s + restores full bounds. Re-entrant, `setWindowHeight` no-ops while closing, and a re-open mid-shrink aborts cleanly. Full invariants (I17/I18): [quick-launch-entrance-animation-contract.md](../../.claude/memory/contracts/quick-launch-entrance-animation-contract.md).
- **Theme-matched window background (open-flash fix)**: the opaque BrowserWindow's native `backgroundColor` is seeded at `#18181b` (bare-dark surface-100), but the panel paints the ACTIVE theme's `bg-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-100` on every theme apply/change (`QUICK_LAUNCH_SET_WINDOW_BG`) and main keeps the native `backgroundColor` matched via `win.setBackgroundColor` — the window stays fully OPAQUE (never `transparent:true` / `setOpacity`), only its solid color follows the theme. Full invariants (I15): [quick-launch-entrance-animation-contract.md](../../.claude/memory/contracts/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](../../src/shared/global-hotkeys.ts) (a `'quick launch tab'` candidate per assigned tab, appended after the fixed four + email recipients; gated on `quickLaunchHotkeyEnabled`). `registerGlobalHotkeys()` in [src/main/index.ts](../../src/main/index.ts) first runs `selectQuickLaunchTabHotkeys(settings.quickLaunchTabHotkeys, isEnabled)` to drop disabled/unknown tabs — its `isEnabled` predicate 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 does `await toggleShow()` + `emitPush(QUICK_LAUNCH_OPEN_TO_TAB, { actionId })`. The modal listens for that validated push; both its `QUICK_LAUNCH_OPEN_TO_TAB` handler and the `visibleTabIds` fold gate on `isTabVisible(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 in `QUICK_LAUNCH_ALLOWLIST` ([detached-push-filter.ts](../../src/main/services/detached-push-filter.ts)) and have a modal listener (both pinned by [quick-launch-push-listener-coverage.test.ts](../../tests/unit/lint/quick-launch-push-listener-coverage.test.ts)). Invariants `hotkeys-register-last` … `repo-tabs-hotkey-capable`: [quick-launch-action-registry-contract.md](../../.claude/memory/contracts/quick-launch-action-registry-contract.md).
- **Renderer entry**: [src/renderer/quick-launch.html](../../src/renderer/quick-launch.html) + [src/renderer/src/quick-launch.tsx](../../src/renderer/src/quick-launch.tsx) — third rollup entry alongside `index.html` and `recorder-host.html`. Loaded via `ELECTRON_RENDERER_URL` in dev, from disk in production. On mount it fires `QUICK_LAUNCH_RENDERER_READY` from 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](../../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 `AddTabMenu` dropdown) live here; `Ctrl+Tab` / `Ctrl+Shift+Tab` cycling **and `Ctrl+1`…`Ctrl+9` direct tab-jump** share one **window-level** `keydown` listener (a `useEffect` on `window`, not a panel-scoped `onKeyDown`) 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_-th `visibleTabIds` entry (no-op past the 9th or out of range), and only `preventDefault()`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 with `aria-keyshortcuts` (no raw `title=`). No collision with the main window's universal `Ctrl+1`…`Ctrl+0` project/inbox switcher: the Quick Launch window is a separate React root ([quick-launch.tsx](../../src/renderer/src/quick-launch.tsx)) that never mounts `useKeyboardShortcuts`, so that dispatcher isn't present here. The "+" lists `getAvailableToAddActions(pinnedActionIds, enabledByActionId)` and pins a choice by optimistically updating local `pinnedActionIds` then persisting via `IPC.SETTINGS_UPDATE` (rollback + inline error on failure) — no new IPC. The strip mounts when `>1` tab is visible **or** there is something to add, so the "+" stays reachable from a single-tab install. Right-clicking a tab opens `TabContextMenu` (opaque, cursor-positioned via `containToViewport`) — it now opens for **every** tab, Session included. At the top is an inline **Global hotkey** capture field (the shared `QuickLaunchTabHotkeyField`, `autoFocus`ed, `pretty`-displayed) that persists via `handleSetTabHotkey` → `SETTINGS_UPDATE` (optimistic + rollback); below it a **Remove from Quick Launch** action calls `handleRemoveTab` — strikes the id from `pinnedActionIds`, persists via `SETTINGS_UPDATE` (rollback + inline error), reselects `firstVisibleTabId` when the removed tab was active — shown for every tab **except Session** (Remove suppressed for Session, contract **`remove-unpins-in-place`**; Session's menu is hotkey-only). Locked by [QuickLaunchModal-remove-tab.test.tsx](../../tests/unit/features/quick-launch/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 by `aria-label`, deliberately NOT `data-ui-anchor` to stay off the ui-anchor registry lint), vertical-wheel→horizontal scroll, and Ctrl+Tab scroll-into-view; the **"+"** and gear sit in a `flex-shrink-0` cluster 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](../../src/renderer/src/features/quick-launch/quick-launch-tab-scroll.ts); full invariants in [quick-launch-tab-overflow-contract.md](../../.claude/memory/contracts/quick-launch-tab-overflow-contract.md). Behavior locked by [tests/unit/features/quick-launch/QuickLaunchModal-add-tab.test.tsx](../../tests/unit/features/quick-launch/QuickLaunchModal-add-tab.test.tsx) + the helper's tests in [tests/unit/shared/quick-launch-actions.test.ts](../../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](../../src/shared/quick-launch-actions.ts) — declares `QUICK_LAUNCH_ACTIONS` (a `ReadonlyArray<QuickLaunchAction>` with `id`, `label`, `iconName`, `enabledSelector(settings)`) plus `QUICK_LAUNCH_ACTION_IDS` and `DEFAULT_QUICK_LAUNCH_PINNED_ACTION_IDS`. Kept pure-data so the main process can validate `quickLaunchPinnedActionIds` settings against it without pulling in React.
- **Action components (renderer-side)**: [src/renderer/src/features/quick-launch/quick-launch-action-components.ts](../../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](../../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](../../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](../../src/renderer/src/features/quick-launch/QuickLaunchProjectIcon.tsx) wraps the shared `<ProjectIcon>`; when that would render empty (a real folder with no custom `iconPath` and no virtual-sentinel `__…__` folderPath) it substitutes a first-letter glyph. The tile color falls back to `tintForProjectName()` — a djb2 hash passed through a MurmurHash3-style `mix32` finalizer so the low bits consumed by `% palette.length` distribute well (plain `djb2 % 8` keyed 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](../../tests/unit/features/quick-launch/QuickLaunchSessionTab-project-icon.test.tsx).
- **Session-tab textarea card**: [src/renderer/src/features/quick-launch/QuickLaunchSessionTab.tsx](../../src/renderer/src/features/quick-launch/QuickLaunchSessionTab.tsx) wraps just the textarea in a `rounded-xl` inset card (`bg-surface-950/60` + border, content edges aligned with the tab strip) and carries the focus indicator on the card via `focus-within:ring`. The textarea sets `focus:shadow-none` to suppress the global `textarea:focus` accent box-shadow (globals.css) that would otherwise paint a square halo inside the rounded card — see [gotchas-frontend.md](../../.claude/memory/gotchas-frontend.md).
- **Voice mic**: [src/renderer/src/features/quick-launch/QuickLaunchVoiceMic.tsx](../../src/renderer/src/features/quick-launch/QuickLaunchVoiceMic.tsx) — own `getUserMedia` + `MediaRecorder` + IPC chunking (mirrors `<VoiceInput>`'s recording side). **Rendered only when `voiceEnabled` is on** — the QL window keeps no settings store, so the flag is mirrored in via `GET_INITIAL_STATE` (`state.voiceEnabled`, default OFF), the same master Voice-Commands switch the main composer mic gates on; the Session tab renders the mic behind `state.voiceEnabled ? … : null`. Push-event subscriptions (`voice:partial-transcript`, `voice:final-transcript`, `voice:status-changed`, `voice:error`) are gated on `isRecording` so 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 via `ipc.invoke` (no Zustand store; QL is its own renderer process and shares no stores with the main window), and calls the modal's `animateOutAndClose` on 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](../../src/renderer/src/features/quick-launch/QuickLaunchSessionSearchTab.tsx), [QuickLaunchAskAmcTab.tsx](../../src/renderer/src/features/quick-launch/QuickLaunchAskAmcTab.tsx), and [QuickLaunchAutomationHelperTab.tsx](../../src/renderer/src/features/quick-launch/QuickLaunchAutomationHelperTab.tsx)) route through the modal's existing **submit** action with a virtual-project sentinel — the main-process [QUICK_LAUNCH_SUBMIT handler](../../src/main/ipc/quick-launch-handlers.ts) detects `projectId === SEARCH_PROJECT_ID`, `ASK_AMC_PROJECT_ID`, or `AUTOMATION_HELPER_PROJECT_ID` (the closed `SUBMIT_VIRTUAL_SENTINELS` set) and calls `prepareSearchSessionsWorkDir()` / `ensureAskAmcProject()` / `ensureAutomationHelperProject()` to provision the right workdir + lazily seed the project before spawning. Alarm tab is gated on `settings.alarmsEnabled`; Ask Omniscio on `settings.askAmcEnabled`; Automation on `settings.automationHelperEnabled`. Like Search / Ask Omniscio, the **Automation** tab is now special-cased for the shared attachment slice (Contract `standalone-props-only`) so a screenshot/PDF of what to automate rides along — `QUICK_LAUNCH_SUBMIT` already carries `images`/`documents` and forwards them to `createSessionWithPrompt`, so this was front-end-only. The Automation Helper's "builder" persona is applied by **project id** at spawn time (`spawn-cluster-manager`'s `isAutomationHelper` → `bootstrapAutomationHelperWorkdir()`), so spawning it with `source: 'quick-launch'` behaves identically to the gallery's `automation-preset` launch.
- **Shared composer field (`QuickLaunchComposer`)**: every "type-and-submit" tab (Automation, Ask Omniscio, KMS Note, Alarm, SMS, DM, Task) renders ONE shared [QuickLaunchComposer](../../src/renderer/src/features/quick-launch/QuickLaunchComposer.tsx) for its input instead of hand-rolling a `<textarea>` — it composes the isolated no-typing-lag box ([QuickLaunchComposerInput](../../src/renderer/src/features/quick-launch/QuickLaunchComposerInput.tsx)) + 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, with `aboveBox`/`belowBox` slots 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](../../tests/unit/lint/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](../../.claude/memory/contracts/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). `QuickLaunchModal` special-cases `id === 'session'`, `id === 'session-search'`, `id === 'ask-amc'`, and `id === 'automation-helper'` to inject `attachments` / `onRemoveAttachment` / `onPaste` / `onOpenFilePicker` from 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](../../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](../../.claude/memory/contracts/quick-launch-action-registry-contract.md).
- **File picker (paperclip)**: click-to-browse routes through the main-process [`DIALOG_PICK_ATTACHMENTS`](../../src/main/ipc/dialog-handlers.ts) handler (not renderer-side `<input type="file">`, which crashes the Quick Launch renderer via `chrome.mojom.UtilWin` on some Windows builds). Two QL-window-manager pieces cooperate to keep the modal alive while the native dialog is open:
  - **`fileDialogOpen` flag** ([src/main/services/quick-launch-window.ts](../../src/main/services/quick/quick-launch-window.ts)) — the modal calls `IPC.QUICK_LAUNCH_SET_FILE_DIALOG_OPEN { active: true }` before invoking the picker. The window's blur handler short-circuits when `oauthFlowActive || fileDialogOpen` (independent gates, both can be set; blur only un-suppresses when **both** clear). `hide()` defensively clears the flag so a stuck `true` can't wedge dismissal.
  - **alwaysOnTop drop** — while `fileDialogOpen` is set, the QL window's `alwaysOnTop` level is removed and restored on release, so the native picker doesn't render behind the QL composer. The renderer's `fileDialogInFlightRef` guards against double-open from a frantic paperclip click.
    Both pieces are pinned by [tests/unit/services/quick-launch-window.test.ts](../../tests/unit/services/quick-launch-window.test.ts) — six tests cover the blur gate, alwaysOnTop drop/restore, post-release blur-hides-again, defensive `hide()` 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](../../src/main/services/window/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-preserving `forceForegroundWindow` instead 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](../../.claude/memory/contracts/hud-blur-dismiss-contract.md).
- **Clickable attachment chips (preview / copy / Save-As / reorder)**: the chip strip [QuickLaunchAttachmentChips.tsx](../../src/renderer/src/features/quick-launch/QuickLaunchAttachmentChips.tsx) is pure-presentational — it reports gestures up via `onPreview` / `onContextMenu` / `onReorder` / `onRemove` and reuses [useDragReorder](../../src/renderer/src/hooks/useDragReorder.ts) (`orientation: 'horizontal'`). `QuickLaunchModal` owns `pendingAttachments` and renders the **reused** [PendingImageLightbox](../../src/renderer/src/features/sessions/PendingImageLightbox.tsx) + [PendingImageContextMenu](../../src/renderer/src/components/ui/ImageContextMenu.tsx) verbatim (both self-contained, no `sessionId`, 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](../../src/main/services/quick/quick-launch-window.ts) holds a `lightboxOpen` flag and its `before-input-event` handler 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 emits `QUICK_LAUNCH_LIGHTBOX_DISMISS` instead of `hide()` (the lightbox's own `useEscapeClose` never fires; main swallows the key first). The renderer drives the flag via `QUICK_LAUNCH_SET_LIGHTBOX_OPEN`. **Save-As** wraps `IMAGE_SAVE_DATA_AS` in the SAME `fileDialogInFlightRef` + `QUICK_LAUNCH_SET_FILE_DIALOG_OPEN` guard 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 rides `QUICK_LAUNCH_ALLOWLIST` ([detached-push-filter.ts](../../src/main/services/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](../../.claude/memory/contracts/quick-launch-attachment-affordances-contract.md).
- **Enablement + active-tab flow**: `enabledByActionId` — built by the shared `buildEnabledByActionId(settings)` (one builder, also used by the Settings card so strip-visibility and the editor hint can't disagree) — is surfaced on `QUICK_LAUNCH_GET_INITIAL_STATE` (no separate IPC), read on mount + refreshed on focus. Tab visibility runs through the shared `isTabVisible(id, enabledByActionId, existingProjectIds)` predicate (registry flag OR repo-project-exists). The tab active on open is the **first visible tab in saved order** via `firstVisibleTabId(...)` (repo tabs included; falls back to `'session'`) — applied identically at mount, blur-reset, and submit-reset so they can't drift (contract **`opens-first-visible`**). `quick-email` is gated by `quickEmailEnabled` alone (recipients no longer required); each gated action carries a `disabledHint` the Settings card shows when it's off.
- **Repo tabs (`repo:<projectId>`)**: a per-repo tab is a pinned id of the form `repo:<projectId>` (not a registry action) — it opens the Session composer locked to that project. Encode/decode via `makeRepoTabId` / `parseRepoTabId` / `isRepoTabId` in [src/shared/quick-launch-actions.ts](../../src/shared/quick-launch-actions.ts). `QuickLaunchModal` gives repo ids a project-exists visibility gate (self-heal on delete), renders them with the project icon + name, routes them to `QuickLaunchSessionTab` with `locked` (chip omitted, no picker, Ctrl+P suppressed), and strips the prefix at submit so the bare UUID reaches the unchanged `QUICK_LAUNCH_SUBMIT` handler. The "+" menu's "New session in a repo…" item opens an add-repo picker (reused `QuickLaunchProjectPicker` over the spawnable, unpinned repos). Full rules + invariants in [.claude/memory/contracts/quick-launch-action-registry-contract.md](../../.claude/memory/contracts/quick-launch-action-registry-contract.md).
- **Settings card**: [src/renderer/src/features/settings/sections/quick-launch-tabs/QuickLaunchTabsCard.tsx](../../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 shared `isActionDisabled` predicate) renders grayed (`opacity-50`) with a `disabled` checkbox 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](../../tests/unit/renderer/settings/QuickLaunchTabsCard.test.tsx)). Resolves pinned `repo:<id>` entries to project rows (name + icon) and lazily prunes dead repo ids — from both `quickLaunchPinnedActionIds` and `quickLaunchTabHotkeys` — (guarded on the project list being loaded). Its **Tab hotkeys** sub-section renders one shared `QuickLaunchTabHotkeyField` per 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 are `addableRepoProjects(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-side `allProjects` already excludes; contract **`repo-picker-addables`**, locked by [QuickLaunchTabsCard.test.tsx](../../tests/unit/renderer/settings/QuickLaunchTabsCard.test.tsx)). The composer's gear icon round-trips here via the `QUICK_LAUNCH_OPEN_SETTINGS` IPC handler ([src/main/ipc/quick-launch-handlers.ts](../../src/main/ipc/quick-launch-handlers.ts)) — the gear invokes it with `{ section: 'quick-launch' }` — which hides QL, foregrounds the main window, and emits a `SETTINGS_DEEP_LINK` push the main renderer listens for (in [usePluginBridgePushListeners.ts](../../src/renderer/src/app/usePluginBridgePushListeners.ts)), calling `setPendingSettingId('quick-launch-tabs')` + `navigateToSettingsProject({ section: 'quick-launch' })`. The `useScrollToSetting` hook 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](../../src/main/services/voice/voice-service.ts) — `quickLaunchFocused` boolean flipped by `setQuickLaunchFocused()` on the QL window's `focus` / `blur` events. While true, `handleFinalTranscriptForMode` early-returns BEFORE invoking the intent parser, so main-window voice commands stay dormant. The `voice:final-transcript` push event still fires (the QL mic needs it).
- **IPC**: Session-tab submit goes through the same `IPC.SESSION_CREATE_WITH_PROMPT` path 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 KMS `createNote` service — primary-vault resolution + `Quick Notes/` path derivation live in [quick-note-service.ts](../../src/main/services/kms/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, default `true`), `quickLaunchDefaultProjectId` (string \| null, default `null`), `quickLaunchPinnedActionIds` (string[], default = every action ID in registry order), and `quickLaunchTabHotkeys` (`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 via `applyDefaultsDeep`'s per-key merge). The pinned-IDs list uses plain `string[]` 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; `quickLaunchTabHotkeys` is 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](../../src/shared/ipc-schemas/settings/hotkeys-quicklaunch-settings.ts).

### Design decisions worth knowing

1. **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).

2. **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.

3. **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.)

4. **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.

5. **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](quick-launch-modal-part-2.md) is the tab surfaces these components render, and the [main page](quick-launch-modal.md) is the user-facing half. The contracts named above hold the invariants behind each piece, and [keyboard-shortcuts.md](keyboard-shortcuts.md) is the global shortcut catalog Quick Launch registers into.
