Supermail (part 2)
Three refinements to the inbox list (the first two apply to the dense single-line rows shown when the reading pane is off): Soft edge fade instead of a trailing "…". When a row's text is too long for one line it now fades out softly at the right edge rather than ending in a "…".
What it is
This is part 2 of the Supermail page. It carries the next stretch of the material on that page, moved here because a single page is capped at 40,000 characters.
Where to find it
Reach this part through Supermail — it lists every part and explains where the feature lives in the product. Everything below is reached from the same place.
Where it is wired in Omniscio
- Sidebar registration:
src/shared/integrations/supermail.ts(id: 'supermail',featureFlag: 'supermailEnabled') +src/renderer/src/integrations/ui-registry.ts(icon + lazy panel,panelOwnsLayout: true). - Virtual-project sentinel:
SUPERMAIL_PLUGIN_PROJECT_ID = '__supermail__'insrc/shared/virtual-project-ids.ts(the native__supermail__form is used so the boot-loop seeder doesn't filter it out as a marketplace-plugin shim; the legacy__plugin_supermail__rows were RETIRED by migration20260619214747). - Sidebar hide-when-off gate:
SIDEBAR_RENDER_GATESinsrc/renderer/src/stores/project-visibility.ts. - Panel host (Omniscio glue):
src/renderer/src/features/supermail/SupermailPanel.tsx. - Settings toggle + search:
sections/supermail/SupermailSettings.tsx(supermailEnabled+supermailInboxEnabled). - Find Email Quick Launch tab (Supermail mode): when
supermailEnabled, that tab searches Supermail's backend (SUPERMAIL_SEARCH→ hosted/search, JWT stays in Main:src/main/services/supermail/supermail-search-service.ts) and opens the pick in Supermail viaSUPERMAIL_QUICK_FIND_OPEN, which REUSES the notification-open path (foreground the main window + emitPLUGIN_DEEPLINK/thread/<id>→openSupermailDeepLink) — no deep-link-router change and no vendored-UI edit. Handlers:src/main/ipc/supermail-quick-find-handlers.ts. See quick-launch-modal.md (the Find Email tab). - Protocol deep-link (
omniscio://supermail/thread/<id>): a clickable/shareable link that opens a specific email. Unlike the quick-find/notificationPLUGIN_DEEPLINKpath above, this one DOES route through the generic deep-link parser + router (open-supermail-thread→openSupermailDeepLink), so it is also a recognized destination inGET /deep-link/resolve. Theagentmc://supermail/authsibling (the backend's primary auth scheme;omniscio://accepted as an alias) stays a main-process OAuth-credential callback, never a navigation (both entry points gate onisSupermailAuthDeepLink). See deep-links.md.
How it behaves
Everything below is the behaviour, detail and edge cases that belong to this stretch of the Supermail page.
Inbox rows: soft edges + optional hover actions
Three refinements to the inbox list (the first two apply to the dense single-line rows shown when the reading pane is off):
- Soft edge fade instead of a trailing "…". When a row's text is too long for one
line it now fades out softly at the right edge rather than ending in a "…". The
old ellipsis inherited the weight of whatever it cut — a bold subject gave heavy dots,
the thin preview gave light ones — so the trailing dots looked inconsistent from row
to row, and an essentially-empty row could still show them. The fade is uniform across
read/unread and a row with no preview text shows nothing. Pure CSS (
.sm-edge-fade, a background-agnosticmask-imageinindex.css), with a plain "…" fallback on engines withoutmask-imageand the fade on the start edge under RTL. - "Show quick actions on hover" (Settings → General → Personalization, ON by default). The
Mark Done / Remind Me buttons that appear on the right of a row on hover can be
turned off for a cleaner list. Off hides the whole strip — the message time stays
visible on hover (no blank edge) and the
E(Done) /H(Remind me) keyboard shortcuts still work. It is the single discoverable switch; the finer-grained Settings → Reading → Toolbar buttons → Message rows controls (Mark done / Snooze) still apply on top of it. Device-local (supermail:row-hover-actions,features/settings/row-hover-actions-setting.ts). Invariants:.claude/memory/contracts/supermail-toolbar-controls-contract.md(master-row-hover-actions-toggle/row-single-line-truncation). - Archiving glides the row out instead of snapping shut. Mark Done / archive (and
trash / mute) now fade the row while collapsing its height, so the rows below glide
up to fill the gap — a subtle, quick exit rather than the row sliding hard off-screen and
the list then snapping shut. Applies to every list row (dense and preview). Pure CSS
(
grid-template-rows: 1fr → 0fr+ opacity inthread-list.tsx), reduced-motion-aware, and timed to finish exactly as the store removes the row (EXIT_ANIMATION_MS, kept in sync betweenthread-list.tsxandinbox-store-mutators.ts). - Snoozed rows show a clock + "Snoozed until <time>". A snoozed thread used to look
identical to any other email — you could only tell from the Snoozed folder it listed
under. Snoozed rows now carry a small clock glyph, with the wake time next to it
(compact in the dense layout's date slot, full in the preview layout and on hover);
when the wake time hasn't loaded yet only the clock shows, and once the time passes
the marker disappears (the existing amber "woke up" reminder dot takes over). Pure
rendering — the snooze store already tracked the wake time, rows just never read it
(
thread-list-item-icons.tsx,thread-row-format.ts).
Toolbar buttons (make each control optional)
Every action button in Supermail's toolbars is optional, so you can tighten the UI to exactly what you use. In Settings → Reading → Toolbar buttons each control has a three-way choice:
- Shown (default) — the button is visible and its keyboard shortcut /
Ctrl/Cmd+Kcommand work, exactly as today. - Keyboard only — the button is hidden, but its keyboard shortcut and command-palette entry still work.
- Off — the button is hidden AND its keyboard shortcut + command-palette entry are disabled.
Everything defaults to Shown, so an untouched install is unchanged; hiding buttons reflows the toolbars cleanly so the UI genuinely tightens up. The controls are grouped by where they live:
- Reading pane — Reply, Filter like these, Focus mode.
- Message rows — Star, Mark done, Snooze. Mark done + Snooze are per-row hover actions; the
star is a persistent accent-coloured indicator at the row's left edge (fills when
starred; a hollow outline reveals on hover / keyboard focus). The list is hotkey-first: row
selection is the
xkey (there is no hover checkbox), the keyboard-focused row shows a bold accent "cursor" highlight (tint + inset ring), and a?button in the inbox header opens the keyboard-shortcuts drawer (the same one the?key opens). These same actions — plus Mark read/unread and "Filter messages like these" — are also on a right-click context menu for any message row; the menu respects these same per-action toggles (a control set to Off is dropped from the menu too).
A control governs an action, so one setting applies everywhere that button appears. The
inbox toolbar's own view prefs (sort · group · unread) are NOT here — they moved to the
always-present View options menu (above). And Compose + Search were removed entirely
as toolbar buttons (reachable only via c / / + Ctrl/Cmd+K), so they are no longer
listed either. The already-adjustable surfaces are left as-is: the bottom triage bar
keeps its own "Customize actions", and the sidebar / reading pane / contact pane keep
their own toggles.
Device-local (supermail:toolbar-controls), like the other display prefs, so it doesn't
sync to the mail.jls.dev web client. "Off" hides the button, and with it the palette
entry — but it does NOT disable the keyboard shortcut. The palette entry goes through each
action's isAvailable, backed by isActionEnabled(); that helper's own doc says it "gates
only those VISIBLE surfaces … it does NOT gate the keyboard shortcut, which always fires".
app-shortcuts.tsx binds the handler unconditionally — a hidden button never disables its
hotkey (owner directive 2026-08-17), never by touching the shared keyboard core. Store +
settings UI:
features/settings/toolbar-controls-setting.ts + features/settings/settings-page.tsx.
Invariants: .claude/memory/contracts/supermail-toolbar-controls-contract.md.
Sidebar: pinned vs overlay (Superhuman-style)
The Supermail nav has two modes, driven by the in-app Settings → General →
Personalization → "Show sidebar" toggle (Supermail's own settings page, not
Omniscio's). The flag is the hidden field of features/layout/sidebar-store.ts
(hidden = !pinned); it persists to supermail:nav-hidden and defaults to
overlay mode so a fresh install matches Superhuman.
- Pinned ("Show sidebar" on,
hidden=false): the nav is an always-visible column rendered in Omniscio's sub-sidebar slot (PersistentNavSidebarinAmcSupermailSidebarApp). Still collapsible to the 44px icon rail. - Overlay ("Show sidebar" off, the default,
hidden=true): the sub-sidebar slot is empty and the mail panel spans full width with a hamburger top-left. Clicking it sets the transientoverlayOpenflag, mountingOverlayNavDrawer(in the panel tree,AppInner) — a drawer that slides in over a dimmed list. It tucks away on mouse-leave (220ms grace), backdrop click, or Escape.overlayOpenis never persisted (resting state is always the hamburger).
Both modes share NavMenuBody (one flat, header-less folder list). The account
row at its top opens the command-palette-style account selector
(features/auth/account-switcher.tsx, opened via useAccountSwitcherStore or
the "Switch Account" palette command); it replaces the old dropdown, shows the
active mailbox with a check + Alt N hint, and an "Add account" row. Quick-switch
is Alt+1..9. "Add account" opens the chooser described just below rather than
jumping straight to Google. Below the folders, a Settings row (gear glyph → /settings)
sits directly in the nav — a visible entry, not tucked away — while the header's ⋯
overflow menu now holds only Hide sidebar + Sign out.
Adding a second mailbox — Google or IMAP
All three "Add account" entry points (the account selector, the account menu, and the
add-account palette command) open one shared dialog,
features/auth/add-account-modal.tsx, held open by useAddAccountModalStore and mounted
once in AppInner. Step 1 offers Google or Other (IMAP); step 2 renders the
existing <ImapForm> — the same component the signed-out sign-in screen uses, with its
ISPDB autoconfig — behind a back button. Google still calls authStore.addAccount()
unchanged.
The IMAP side is not the sign-in route. authStore.linkImapMailbox() posts to the
authenticated POST /auth/mailboxes/imap, which attaches the mailbox to the user its
bearer token identifies and returns no session:
body.emailis mailbox data, never an identity. It can't resolve, create, or mutate another account; themailboxes_ownerRLSWITH CHECKenforces the same boundary a layer down.- Adding a mailbox never changes who you are signed in as — the action writes no
user/jwt/status. Its siblingloginWithImap(publicPOST /auth/imap) deliberately does replace the session; reusing it here would have signed the user out of their Gmail. Both halves of that contrast are pinned by tests inauth-store.test.ts. - Both hosts pass the shared SSRF guard and a live IMAP and SMTP probe before anything
is written, so a bad credential leaves no half-configured mailbox. The probe code lives
once in
backend/src/shared/mail/imap/probe.ts, shared with the sign-in route. - Re-linking an address already connected over IMAP refreshes its credentials in place. Re-linking one already connected through Google is refused with a 409 — never silently converted, which would orphan that mailbox's OAuth tokens. The 409 is load-bearing: it is the only one this route emits, and the UI keys on it to show the refusal verbatim instead of humanizing it (the message contains "connected", which would otherwise be rewritten as unrelated advice about the server address).
- The new mailbox becomes primary and an
imap.pollfirst-sync is enqueued fire-and-forget, so the UI reports that mail is on its way rather than showing an empty inbox with no explanation.
GET /auth/me resolves the account kind from the primary mailbox
(desc(isPrimary), asc(id), limit 1). It previously read an unordered single row, so a
user holding both a Gmail and an IMAP mailbox got whichever the planner happened to
return.
The sidebar footer carries the sync-status line, the account/sign-out menu, and — between
them — an always-visible "Reconnect" button (features/sync-status/reconnect-session-button.tsx
→ authStore.reconnectMailbox(), the same re-OAuth the conditional
sync-status/reconnect-banner.tsx uses). It is present in every signed-in state (not gated on
needsReconnect() or user), so a stuck/dead session is one click from recovered without hunting
for the account-menu "Sign out" (which is gated on user and can vanish exactly when it's needed).
Reading pane — theming, glass, and Focus mode
The open-conversation reader follows Omniscio's active theme AND any custom theme
automatically. Its message cards render on the app's panel tokens (--panel-bg /
--panel-border / --panel-blur, bridged into .supermail-scope in index.css), so
the pane is glassy under a glass theme (e.g. Glassmorphism — a one-tap built-in
theme) and flat under a flat one, with no hardcoded per-component colour. There is no
separate colour/theme picker inside Supermail — choose a theme (or a custom theme) in
Settings → Appearance and the reader follows it. Designed-HTML ("isolated") emails
render in a white-canvas iframe (they're authored for light); in dark mode they're
smart-inverted to match the pane — driven by the app's REAL dark state (Omniscio's own
theme in-app, NOT the OS prefers-color-scheme), with an auto-skip that leaves an
already-dark email alone and a per-email "Show original (light)" toggle. Full
behavior + invariants: supermail-email-render-contract.md.
- Nested thread (Superhuman-style). A thread opens with only the latest message
expanded; every older message collapses to a single scannable row — avatar ·
sender · one-line snippet · time — so a long thread reads as a compact stack, not a
wall of full-height cards. Click a row (or
o) to expand it; click an expanded message's header (oro) to collapse it back to its row. Every message carries a coloured initials avatar. Both states live inmessage-view.tsx(the collapsed early-return row + the expanded card); only-latest-expanded is seeded inconversation-store.ts. - Centered, pinned subject. The subject sits centered above the email frame, aligned
to the email's own reading width, and stays pinned to the top as you scroll a long thread
(it never scrolls away with the body). In Full View the header reserves side space so the
subject clears the floating hamburger and the corner controls. Built in
thread-view.tsx. - Clean by default — toolbars reveal on demand. The reader's chrome — the top action
cluster (Reply + Filter / Focus; Compose + Search were removed) and the bottom
triage bar — is hidden by default so the email itself is the focus. Move to the top or
bottom edge and it fades/slides in on hover (a small grip marks the bottom zone); a pin
toggle locks both open. Every keyboard shortcut keeps working the whole time (the hide is purely
visual), and on a touch device the toolbars stay shown (no hover there). Reveal logic:
use-chrome-reveal.ts+ thechromePinnedreading pref; the bottom bar is wrapped intriage-reveal-zone.tsx. - Always-visible "Start a session". One button stays put in the top-right (inside Omniscio) to spin up a Claude session seeded with the whole thread — see "Start a session" above.
- Attachment chips show just the file size at rest; the old "✓ Ready" badge is gone (a static attachment is not a task). Images still preview as thumbnails when fetchable; an in-flight download shows "Downloading…", a server-rejected one "Failed".
- Focus mode (hide chrome, keyboard-first). Toggle it with the Focus button in
the reader's top-right controls or the
Zshortcut. It hides the pane's OWN chrome — the triage bar, the right-hand contact rail, and the team comment bar — for a distraction-free read; every reading-pane shortcut stays live so you can work entirely by hotkey. It does NOT touch Omniscio's outer chrome. Device-local (supermail-reading-prefs, off by default). Store:features/layout/reading-prefs-store.ts. - Reading layout. Settings → General → Personalization → Reading layout picks how the open
email's reading column is sized: Standard (a wider column), Centered (narrower), or
Editorial (narrowest). It sizes the message BODY column — the subject lives in
the pinned header (above), so the heading style no longer varies by layout. The reply
composer opens at this SAME width, so a reply is never wider than the email it answers
(shared
readingLayoutWidthClassinreading-prefs-store.ts, applied in boththread-view.tsxandcompose-page.tsx). Device-local (supermail-reading-prefs, Standard by default).
Compose — keep moving through mail with a draft open (desktop)
Opening a reply / reply-all / forward on desktop no longer traps the keyboard. The inbox navigation keys — J/K + arrows, plus W/S when left-handed nav is on — save the draft (autosave) and jump to the previous/next email instead of being swallowed by the composer, so a draft never blocks triage.
- Navigate mode on desktop. To free the bare keys, a desktop reply opens WITHOUT
the body auto-grabbing the caret — press Enter (or click) to start typing and
Esc to step back out to navigation. Touch devices keep tap-to-type autofocus (no
physical keyboard to navigate with), detected via
isCoarsePointer. - Typing still types. The nav keys are registered in the
composecontext and fire only when the caret is NOT in a compose field (the global keyboard listener drops bare keys while a text input is focused), so typing j/k/w/s INTO the draft still types. - Safe by construction. No-ops at the first/last email and for a brand-new (non-thread) message; the draft is flushed to autosave before the composer closes.
- When a Gmail draft is minted. Autosave needs a recipient AND a subject (the backend
rejects less) — and for a reply / reply-all, which opens with both already filled from the
source message, it ALSO needs typed body text. Opening a reply and backing out (Esc, Back,
or clicking away) leaves nothing behind; before this gate an untouched reply minted an
empty Gmail draft on the thread, which other mail clients showed as a blank message from
you. Code:
features/compose/use-draft-autosave.ts(hasAuthoredContent). - Code:
features/compose/use-compose-thread-nav.ts(+is-coarse-pointer.ts), wired incompose-page.tsx; the Shortcuts drawer lists it (shortcut-catalog.ts). Invariants are locked by the sub-app's own vitest tests (use-compose-thread-nav.test.ts) — the vendored app is outside Omniscio's contract system by design.
For agents
CLI control surface (settings + commands)
The CLI control server exposes four routes that let agents read and write Supermail's device settings and dispatch any Supermail UI command without the panel being open.
Auth and gating
Unlike the inbox/corpus routes (which require the global full-trust cli-token), these four routes accept both the global token and scoped agent-session tokens. This means a Claude session can tune Supermail settings and fire commands without the global cli-token. A subset of operations is approval-gated (see below).
The four endpoints
| Method | Path | Body | Gating | Purpose |
|---|---|---|---|---|
GET |
/supermail/settings |
— | read |
Return the cached settings snapshot (works while panel is closed) |
PATCH |
/supermail/settings |
{ id: string, value: unknown } |
see below | Write one setting by base storage key |
GET |
/supermail/commands |
— | read |
List available commands from the cached command registry |
POST |
/supermail/command |
{ id: string, args?: Record<string,unknown> } |
see below | Dispatch a command by its action-registry id |
Open ONE thread in its own window
POST /supermail/threads/:id/open is a fifth agent-accessible route, and the only one
in the /supermail/threads/* family that is not cliTokenOnly. It opens (or focuses)
a desktop window showing that single thread so the user can reply in place.
curl -s -X POST -H "Authorization: Bearer $TOKEN" \
http://127.0.0.1:19519/supermail/threads/<threadId>/open
# → { "ok": true, "data": { "threadId": "...", "window": "newly-created" } }
Why a scoped agent token is safe here, when every other route in that file needs the global one: this route reads no mail. It returns no subject, sender or body, and it never checks that the thread exists. Supermail thread ids are sequential, so an existence check would hand any agent a mailbox-enumeration oracle — which is exactly why the corpus routes are cliTokenOnly. Reading nothing is what makes it safe, so do not "helpfully" add a subject to the response. An unknown id simply lands the window on Supermail's own "couldn't load this conversation" state.
Returns 409 when 8 windows are already open (close one), and 403 when Supermail is
turned off. Re-opening a thread that already has a window focuses it and returns
already-open — that always succeeds, even at the cap, because it opens nothing.
Invariants: supermail-thread-window-contract.md.
# Read all settings (panel can be closed)
curl -s -H "Authorization: Bearer $TOKEN" http://127.0.0.1:19519/supermail/settings | jq .
# Write a non-sensitive setting immediately
curl -s -X PATCH -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"id":"supermail:hover-act","value":false}' \
http://127.0.0.1:19519/supermail/settings
# List available commands
curl -s -H "Authorization: Bearer $TOKEN" http://127.0.0.1:19519/supermail/commands | jq .
# Dispatch a non-destructive command
curl -s -X POST -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "X-AMC-Source-Session-Id: $AMC_SESSION_ID" \
-d '{"id":"inbox-focus"}' \
http://127.0.0.1:19519/supermail/command
Settings registry (29 keys)
The 29 CLI-reachable settings are registered in
src/plugins/supermail/ui/src/features/settings/control-settings-registry.ts. Each
entry exposes read() (returns the live store value) and write(value) (delegates to
the store's existing setter — no new persistence). For the AppSettings bridge that PROMOTES
these settings into Omniscio + the per-identity migration pattern, see
supermail-ui-settings-cli.md. Key behaviors:
- Identity-scoped keys (
supermail:filters:enabled,supermail:ai-filter:mode,supermail:ai-filter:instruction) resolve the active Supermail account internally; callers always use the base key without any per-account suffix. - Object-valued settings (
supermail:toolbar-controls,supermail-reading-prefs,supermail-inbox-view-prefs) perform a shallow merge on PATCH, so you only need to send the fields you want to change. - Array-valued settings (
supermail-triage-actions,supermail:pinned-label-ids) replace the full array when a valid array is supplied.
Command dispatch model
POST /supermail/command is generic: it resolves the given id against the
vendored action registry and fires it. There is no allowlist — every registered
command is CLI-reachable by construction. The GET /supermail/commands response lists
all commands currently in the registry (requires the panel to have been open at least
once since the last app start; otherwise returns the persisted cache from the last
session).
A 409 response from either the settings write or the command dispatch means the
Supermail panel is not currently mounted. Settings writes that time out are queued and
applied on the next panel mount.
Approval gating
Two subsets route through the standard CLI approval gate (a 202 inbox card that
applies only after the user approves):
Sensitive setting writes — the three keys in SUPERMAIL_SENSITIVE_SETTINGS:
supermail:ai-filter:instruction— a plain-language rule passed to an AI model (prompt-injection risk from email content)supermail:ai-filter:mode— switches AI filtering between preview and livesupermail:filters:enabled— master kill-switch for automatic filter rules
Destructive command dispatches — the 13 ids in SUPERMAIL_DESTRUCTIVE_COMMANDS
(all send variants, trash, spam, mute, sign-out, discard, rp-trash, rp-spam,
rp-remove-all-labels). These are irreversible or send real email.
Offline reads and pending writes
The main-side control store (src/main/services/supermail/supermail-control-store.ts)
caches the last settings + command list sent by the vendored panel via
SUPERMAIL_CONTROL_SYNC, and persists them to supermail-control.json under userData.
This means GET /supermail/settings and GET /supermail/commands return data even when
the Supermail panel is closed. A PATCH that cannot reach the panel returns 409; the
store enqueues the write and drains it automatically when the panel next mounts.
Parity guard
A build-failing guard pair ensures this surface never regresses silently:
- tests/unit/lint/supermail-settings-cli-parity.test.ts — every persisted Supermail setting key is CLI-registered XOR explicitly exempted.
- tests/unit/lint/supermail-command-cli-parity.test.ts — every destructive command is in
SUPERMAIL_DESTRUCTIVE_COMMANDS.
Contract: .claude/memory/contracts/supermail-cli-parity-contract.md.
How it is built (for agents)
Developing Supermail (run / test / land / deploy each half)? See supermail-cross-repo-development.md - the in-repo UI + backend map, how they connect, and how each half lands and deploys.
The vendored Supermail source lives in src/plugins/supermail/ui/ and is
deliberately isolated from Omniscio's tooling (its own ESLint/Prettier ignores, its
own tsconfig.json via npm run typecheck:supermail, and its own scoped
Tailwind build).
Key decisions:
Routing. Supermail uses URL routes. Inside Omniscio those run in an in-memory router scoped to the panel; Omniscio's renderer still owns the real browser URL. The composition root is
src/plugins/supermail/ui/src/amc/AmcSupermailApp.tsx.Styling collision. Supermail and Omniscio both define utility tokens. Supermail gets its own scoped Tailwind build — every utility is namespaced under a
.supermail-scopewrapper.supermail.generated.cssis a build OUTPUT — not committed (gitignored). It is regenerated on demand at the renderer-build boundary:scripts/ensure-renderer-build.js'srunBuild()regenerates it right before the vite renderer build, so every dev rebuild path gets a fresh sheet —predevstartup AND thedev-restart-supervisor's in-session relaunch — andscripts/electron-build.jsdoes the same fornpm run build/npm run package; the vitest runtime stubs the import so tests need neither the file nor the Tailwind toolchain. (Coupling the regen to the boundary — not only to thepredevchain — is what stops an in-session relaunch from bundling a stale sheet: the.sm-edge-faderow-overlap fix.) Regenerate manually withnpm run build:supermail-css. Removing it from git ended the committed-artifact drift that used to dirty the tree and pause the auto-lander (guarded bytests/unit/lint/supermail-generated-css-not-committed.test.ts).GOTCHA — editing supermail CSS is NOT live-reloaded. The source of truth is
src/plugins/supermail/ui/src/index.css, but the app loads the generatedsupermail.generated.css. The generator runs at the renderer-build boundary —npm run devstartup AND any in-session dev-restart rebuild — but nothing re-runs it on a plainindex.csssave. So a rule you add or change inindex.csswhile dev is already up is invisible (the app keeps serving stale CSS) until you regenerate: runnpm run build:supermail-css, keepnpm run dev:supermail-css(--watch) running alongside dev, or restartnpm run dev. This is the opposite of supermail TSX, which the Omniscio renderer's own Vite build compiles and hot-reloads live — so TSX edits appear instantly while CSS edits silently don't. (This exact trap hid the split-tab focus-ring fix: theindex.cssrule was correct but never reached the running app until the regen ran. Note the app renders supermail inline, not in a webview —SupermailPanel.tsximports the scoped stylesheet directly — so Omniscio's renderer-global*:focus-visibleoutline reaches supermail's own focused elements, which is why that ring appeared on the tabs in the first place.)Runtime bridge. Inside Omniscio, the panel installs a synchronous
window.__AMC_SUPERMAIL__bridge that reads/writes Omniscio stores directly. The same runtime falls back to browser storage, browser navigation, and no-op host hooks for the standalonemail.jls.devbuild - no webview boundary.Auth. The OAuth flow lands in main via the
agentmc://supermail/authdeep link (the backend's primary scheme;omniscio://supermail/authis an accepted alias). Main writes the backend-issued JWT tosettings.supermailJwt(encrypted at rest viaENCRYPTED_APP_SETTINGS_KEYS) and emits an IPC event; the renderer's bridge hydrates the token synchronously. Alternative (opt-in): withsupermailUseAmcGmailON, mail is instead read through Omniscio's shared Google grant via thegmail:api-requestrelay (token stays in Main) — see "Reading mail through your Omniscio Google connection" above andsupermail-shared-gmail-grant-contract.md.Resilience. Supermail's own React root is wrapped in error boundaries (root + per-message) from
src/plugins/supermail/ui/src/shared/ui/error-boundary.tsx, so a render-time crash shows a contained "Something went wrong" card (or a small per-message placeholder) instead of blanking the whole mail app — which it used to, since supermail historically had none (Sentry 7572518402). Invariants live in.claude/memory/contracts/supermail-resilience-contract.md.Standalone web deploy. The root workflow
.github/workflows/deploy-supermail-frontend.ymlbuildssrc/plugins/supermail/ui/Dockerfileand deploys Cloud Run servicesupermail-frontendformail.jls.dev. The backend deploy is the sibling.github/workflows/deploy-supermail-backend.yml.
Related
The overview, the other parts, and everything else worth reading next all sit on Supermail.
Last verified 2026-10-06