Merge pull request #72897 from NousResearch/bb/desktop-drift-fixes

Desktop: fix diff color drift, replayed notifications, stall timing, and quit-on-active-work
This commit is contained in:
brooklyn! 2026-07-27 17:12:49 -05:00 committed by GitHub
commit 42c308ecdc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 563 additions and 58 deletions

View file

@ -192,7 +192,12 @@ Notes:
semantics when unifying appearance.
- Respect `AppShell` overlay ownership. Persistent terminal/content layers,
route overlays, dialogs, and boot surfaces must not compete through ad-hoc
z-index literals.
z-index literals. Pick a rung of the ladder in `styles.css` instead —
`--z-modal-backdrop` / `--z-modal` / `--z-modal-popover`, `--z-over-modal`
(toasts, tooltips, command surfaces) and `--z-over-modal-content`,
`--z-switcher-backdrop` / `--z-switcher`, then the boot chain
`--z-connecting``--z-onboarding``--z-setup``--z-crash`. Plain
`z-10`/`z-20` are still right for stacking *within* one component.
## Iconography & brand

View file

@ -230,6 +230,10 @@ export function buildAppEnv(sandbox: Sandbox, extra: Record<string, string> = {}
HERMES_DESKTOP_IGNORE_EXISTING: '1',
HERMES_DESKTOP_HERMES_ROOT: REPO_ROOT,
HERMES_DESKTOP_APP_NAME: `HermesE2E-${Date.now()}`,
// `app.close()` in teardown must exit even when a spec leaves a turn
// mid-flight — otherwise the quit confirmation waits on a click that no
// one is there to make, and the worker dies on a teardown timeout.
HERMES_DESKTOP_SKIP_QUIT_CONFIRM: '1',
// Clear dev-server override — we want the built dist/, not a vite server.
// The dev-server check in main.ts looks for this env var; if it's set,
// it loads from the vite URL instead of the local file.

View file

@ -146,6 +146,7 @@ import { rehomePrimaryConnection } from './primary-connection-rehome'
import { decideProfileDeleteAction, profileNameFromDeleteRequest, resolveRouteProfile } from './profile-delete-routing'
import { fetchPrimaryProfileSessions } from './profile-session-routing'
import { createQuickEntryShortcut, quickEntryWindowBounds, sanitizeQuickEntrySettings } from './quick-entry'
import { type ActiveWork, mergeActiveWork, normalizeActiveWork, quitPromptFor } from './quit-guard'
import * as remoteLifecycle from './remote-lifecycle'
import {
RemoteLivenessTracker,
@ -607,6 +608,10 @@ const DESKTOP_LOG_DISCARD_BYTES = DESKTOP_LOG_MAX_BYTES * 4
const desktopLogBackupPath = n => `${DESKTOP_LOG_PATH}.${n}`
const BOOT_FAKE_MODE = process.env.HERMES_DESKTOP_BOOT_FAKE === '1'
const BOOT_FAKE_ERROR = process.env.HERMES_DESKTOP_BOOT_FAKE_ERROR || ''
// Automated teardown (Playwright's app.close(), harness scripts) quits with
// nobody to answer a modal, so the active-work confirmation would hang the
// caller instead of letting the process exit. Force quits set this.
const SKIP_QUIT_CONFIRM = process.env.HERMES_DESKTOP_SKIP_QUIT_CONFIRM === '1'
const BOOT_FAKE_STEP_MS = (() => {
const raw = Number.parseInt(String(process.env.HERMES_DESKTOP_BOOT_FAKE_STEP_MS || ''), 10)
@ -2510,6 +2515,12 @@ let updateInFlight = false
// actually dies and the hand-off script can proceed immediately.
let isQuittingForHandoff = false
// Quit-guard latches: one while the confirmation is on screen (a second
// Cmd-Q must not stack dialogs), one after the user has said "quit anyway"
// (the app.quit() that follows re-enters before-quit and must pass through).
let quitPromptOpen = false
let quitConfirmedWithActiveWork = false
// Resolve the staged updater binary. The Tauri installer copies itself to
// HERMES_HOME/hermes-setup.exe on a successful install (see
// apps/bootstrap-installer paths::copy_self_to_hermes_home). That binary owns
@ -10134,6 +10145,20 @@ ipcMain.handle('hermes:watchPreviewFile', (_event, url) => watchPreviewFile(Stri
ipcMain.handle('hermes:stopPreviewFileWatch', (_event, id) => stopPreviewFileWatch(String(id || '')))
// Each renderer reports the turns it has in flight; the quit guard reads the
// merged picture. Keyed by webContents id so a closed window stops counting.
const activeWorkByWebContents = new Map<number, ActiveWork>()
ipcMain.on('hermes:active-work', (event, payload) => {
const id = event.sender.id
if (!activeWorkByWebContents.has(id)) {
event.sender.once('destroyed', () => activeWorkByWebContents.delete(id))
}
activeWorkByWebContents.set(id, normalizeActiveWork(payload))
})
ipcMain.on('hermes:titlebar-theme', (_event, payload) => {
if (!payload || !isHexColor(payload.background) || !isHexColor(payload.foreground)) {
return
@ -11399,7 +11424,58 @@ function configureSpellChecker() {
}
}
// Ask before a quit kills a turn in flight. True when the quit was intercepted
// and the confirmation is on screen; "Quit Anyway" re-enters before-quit with
// the latch set and falls straight through to the teardown below.
function heldQuitForActiveWork(event: Electron.Event): boolean {
if (SKIP_QUIT_CONFIRM || quitConfirmedWithActiveWork || quitPromptOpen) {
return false
}
const prompt = quitPromptFor(mergeActiveWork(activeWorkByWebContents.values()), isQuittingForHandoff)
const parent = BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows()[0]
if (!prompt || !parent || parent.isDestroyed()) {
return false
}
event.preventDefault()
quitPromptOpen = true
void dialog
.showMessageBox(parent, {
buttons: ['Keep Running', 'Quit Anyway'],
cancelId: 0,
defaultId: 0,
detail: prompt.detail,
message: prompt.message,
type: 'question'
})
.then(({ response }) => {
quitPromptOpen = false
if (response === 1) {
quitConfirmedWithActiveWork = true
app.quit()
}
})
.catch(() => {
// A dialog we can't show must not become a quit we can't perform.
quitPromptOpen = false
quitConfirmedWithActiveWork = true
app.quit()
})
return true
}
app.on('before-quit', event => {
// Runs ahead of every teardown below, so "Keep Running" leaves the app
// exactly as it was.
if (heldQuitForActiveWork(event)) {
return
}
if ((sshConnections.size > 0 || sshBootstrapCoordinator.promises().length > 0) && !sshQuitTeardownDone) {
event.preventDefault()
sshBootstrapCoordinator.cancelAll()

View file

@ -114,6 +114,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
normalizePreviewTarget: (target, baseDir) => ipcRenderer.invoke('hermes:normalizePreviewTarget', target, baseDir),
watchPreviewFile: url => ipcRenderer.invoke('hermes:watchPreviewFile', url),
stopPreviewFileWatch: id => ipcRenderer.invoke('hermes:stopPreviewFileWatch', id),
setActiveWork: payload => ipcRenderer.send('hermes:active-work', payload),
setTitleBarTheme: payload => ipcRenderer.send('hermes:titlebar-theme', payload),
setNativeTheme: mode => ipcRenderer.send('hermes:native-theme', mode),
setTranslucency: payload => ipcRenderer.send('hermes:translucency', payload),

View file

@ -0,0 +1,62 @@
import assert from 'node:assert/strict'
import { test } from 'vitest'
import { mergeActiveWork, normalizeActiveWork, quitPromptFor } from './quit-guard'
test('normalizeActiveWork drops junk and keeps the count at least the title count', () => {
assert.deepEqual(normalizeActiveWork(null), { count: 0, titles: [] })
assert.deepEqual(normalizeActiveWork({ count: 'many', titles: 'nope' }), { count: 0, titles: [] })
assert.deepEqual(normalizeActiveWork({ count: -3, titles: [' Fix login ', '', 7] }), {
count: 1,
titles: ['Fix login']
})
})
test('normalizeActiveWork keeps untitled sessions in the count', () => {
assert.deepEqual(normalizeActiveWork({ count: 3, titles: ['Fix login'] }), { count: 3, titles: ['Fix login'] })
})
test('mergeActiveWork de-dupes a session two windows both report', () => {
const merged = mergeActiveWork([
{ count: 2, titles: ['Fix login', 'Ship docs'] },
{ count: 1, titles: ['Fix login'] }
])
assert.deepEqual(merged, { count: 2, titles: ['Fix login', 'Ship docs'] })
})
test('quitPromptFor stays out of the way when nothing is running', () => {
assert.equal(quitPromptFor({ count: 0, titles: [] }, false), null)
})
test('quitPromptFor stays out of the way during an update handoff', () => {
assert.equal(quitPromptFor({ count: 2, titles: ['Fix login'] }, true), null)
})
test('quitPromptFor names the running chats', () => {
const prompt = quitPromptFor({ count: 2, titles: ['Fix login', 'Ship docs'] }, false)
assert.ok(prompt)
assert.equal(prompt.message, 'Hermes is still working on 2 chats.')
assert.ok(prompt.detail.includes('• Fix login'))
assert.ok(prompt.detail.includes('• Ship docs'))
})
test('quitPromptFor summarizes past the list cap and counts untitled work', () => {
const prompt = quitPromptFor({ count: 9, titles: ['a', 'b', 'c', 'd', 'e', 'f'] }, false)
assert.ok(prompt)
assert.equal(prompt.message, 'Hermes is still working on 9 chats.')
assert.ok(prompt.detail.includes('• d'))
assert.ok(!prompt.detail.includes('• e'))
assert.ok(prompt.detail.includes('• 5 more'))
})
test('quitPromptFor speaks singular for one chat', () => {
const prompt = quitPromptFor({ count: 1, titles: [] }, false)
assert.ok(prompt)
assert.equal(prompt.message, 'Hermes is still working on 1 chat.')
assert.ok(prompt.detail.includes('mid-turn'))
})

View file

@ -0,0 +1,92 @@
// Quitting with a turn in flight kills the backend mid-tool-call: the work is
// lost, and anything the agent had half-written to disk stays half-written.
// Renderers publish what they're running; the main process asks before it lets
// that go. The decision + copy live here (pure, testable) so main.ts only owns
// the IPC and the dialog call.
const MAX_LISTED = 4
export interface ActiveWork {
/** Titles of sessions running a turn. Untitled sessions contribute a count only. */
titles: string[]
/** Running turns, including untitled ones — always >= titles.length. */
count: number
}
export const NO_ACTIVE_WORK: ActiveWork = { count: 0, titles: [] }
/** Coerce an IPC payload from an untrusted renderer into an ActiveWork. */
export function normalizeActiveWork(payload: unknown): ActiveWork {
if (!payload || typeof payload !== 'object') {
return NO_ACTIVE_WORK
}
const raw = payload as { count?: unknown; titles?: unknown }
const titles = Array.isArray(raw.titles)
? raw.titles
.filter((title): title is string => typeof title === 'string')
.map(title => title.trim())
.filter(Boolean)
: []
const count = typeof raw.count === 'number' && Number.isFinite(raw.count) ? Math.max(0, Math.floor(raw.count)) : 0
return { count: Math.max(count, titles.length), titles }
}
/** Merge every window's report into one. Windows can show the same session. */
export function mergeActiveWork(reports: Iterable<ActiveWork>): ActiveWork {
const titles: string[] = []
let count = 0
for (const report of reports) {
count = Math.max(count, report.count)
for (const title of report.titles) {
if (!titles.includes(title)) {
titles.push(title)
}
}
}
return { count: Math.max(count, titles.length), titles }
}
export interface QuitPrompt {
detail: string
message: string
}
/**
* The confirmation to show, or null when quitting should just proceed.
*
* `quittingForHandoff` covers the update / swap / uninstall relaunches: those
* are the app replacing itself, not the user walking away, and a modal there
* would strand the detached script waiting on a PID that never exits.
*/
export function quitPromptFor(work: ActiveWork, quittingForHandoff: boolean): null | QuitPrompt {
if (quittingForHandoff || work.count < 1) {
return null
}
const listed = work.titles.slice(0, MAX_LISTED)
const remaining = work.count - listed.length
const lines = listed.map(title => `${title}`)
if (remaining > 0) {
lines.push(remaining === 1 ? '• 1 more' : `${remaining} more`)
}
return {
detail: [
lines.join('\n'),
lines.length > 0 ? '' : null,
'Quitting stops the agent mid-turn. Any work it has not finished writing is lost.'
]
.filter(line => line !== null)
.join('\n')
.trim(),
message: work.count === 1 ? 'Hermes is still working on 1 chat.' : `Hermes is still working on ${work.count} chats.`
}
}

View file

@ -119,7 +119,7 @@ export function BaseBranchPicker({
<span className="shrink-0">{parts.after}</span>
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="z-[140] min-w-(--radix-popover-trigger-width) p-0">
<PopoverContent align="start" className="z-(--z-modal-popover) min-w-(--radix-popover-trigger-width) p-0">
<Command filter={(searchValue, search) => (searchValue.toLowerCase().includes(search.toLowerCase()) ? 1 : 0)}>
<CommandInput autoFocus placeholder={p.baseBranchPlaceholder} />
<CommandList className="max-h-64">

View file

@ -875,13 +875,13 @@ export function CommandPalette() {
<DialogPrimitive.Root onOpenChange={setCommandPaletteOpen} open={open}>
<DialogPrimitive.Portal>
{/* Transparent overlay: keeps click-away + focus trap, but no dim/blur. */}
<DialogPrimitive.Overlay className="fixed inset-0 z-[200]" />
<DialogPrimitive.Overlay className="fixed inset-0 z-(--z-over-modal)" />
<DialogPrimitive.Content
aria-describedby={undefined}
className={cn(
HUD_POSITION,
HUD_SURFACE,
'z-[210] w-[min(34rem,calc(100vw-2rem))] overflow-hidden duration-150 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:slide-in-from-top-2 data-[state=open]:zoom-in-95'
'z-(--z-over-modal-content) w-[min(34rem,calc(100vw-2rem))] overflow-hidden duration-150 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:slide-in-from-top-2 data-[state=open]:zoom-in-95'
)}
>
<DialogPrimitive.Title className="sr-only">{t.commandCenter.paletteTitle}</DialogPrimitive.Title>

View file

@ -31,9 +31,9 @@ export function ProviderPicker() {
<ChevronDown className="size-3" />
</button>
</DropdownMenuTrigger>
{/* The picker lives inside the pet-gen Dialog (z-130) and portals to body,
so lift its menu above the dialog or it opens behind it. */}
<DropdownMenuContent align="start" className="z-[140]">
{/* The picker lives inside the pet-gen Dialog and portals to body, so its
menu needs the rung above the modal or it opens behind the dialog. */}
<DropdownMenuContent align="start" className="z-(--z-modal-popover)">
{providers.map(provider => (
<DropdownMenuItem
className="flex items-center gap-1.5"

View file

@ -46,7 +46,7 @@ export function SessionSwitcher() {
<>
{/* Transparent click-catcher: click-away closes, but no dim/blur. */}
<div
className="fixed inset-0 z-[219]"
className="fixed inset-0 z-(--z-switcher-backdrop)"
onMouseDown={e => {
e.preventDefault()
closeSwitcher()
@ -56,7 +56,7 @@ export function SessionSwitcher() {
className={cn(
HUD_POSITION,
HUD_SURFACE,
'dt-portal-scrollbar z-[220] max-h-[min(22rem,64vh)] w-[min(19rem,calc(100vw-2rem))] select-none overflow-y-auto p-1'
'dt-portal-scrollbar z-(--z-switcher) max-h-[min(22rem,64vh)] w-[min(19rem,calc(100vw-2rem))] select-none overflow-y-auto p-1'
)}
>
{sessions.map((session, i) => {

View file

@ -142,7 +142,11 @@ export const StreamStallIndicator: FC = () => {
return `${s.message.content.length}:${textLength}`
})
const [stalled, setStalled] = useState(false)
// Timestamp of the activity that preceded the current quiet spell, set once
// the spell qualifies as a stall. Holding the timestamp (not a boolean) is
// what lets the timer read "quiet for 12s" rather than the age of this
// component, which is the whole turn so far.
const [quietSince, setQuietSince] = useState<number | undefined>(undefined)
const compacting = useStore($compactionActive)
const turnTimerKey = useActiveTurnTimerKey()
// A pending clarify / approval / sudo / secret means the turn is paused on the
@ -151,14 +155,18 @@ export const StreamStallIndicator: FC = () => {
const awaitingInput = useStore($activeSessionAwaitingInput)
useEffect(() => {
setStalled(false)
const id = window.setTimeout(() => setStalled(true), STREAM_STALL_S * 1000)
setQuietSince(undefined)
const seenAt = Date.now()
const id = window.setTimeout(() => setQuietSince(seenAt), STREAM_STALL_S * 1000)
return () => window.clearTimeout(id)
}, [activity])
const active = (stalled || compacting) && !awaitingInput
const elapsed = useElapsedSeconds(active, compacting ? turnTimerKey : undefined)
const active = (quietSince !== undefined || compacting) && !awaitingInput
// Compaction owns the whole turn, so it keeps counting from the turn's start;
// a plain stall counts from the last thing the stream produced.
const elapsed = useElapsedSeconds(active, compacting ? turnTimerKey : undefined, compacting ? undefined : quietSince)
if (!active) {
return null

View file

@ -284,7 +284,7 @@ export function BootFailureOverlay() {
if (view === 'connect') {
return (
<div className="fixed inset-0 z-[1400] flex items-center justify-center bg-(--ui-chat-surface-background) p-6">
<div className="fixed inset-0 z-(--z-setup) flex items-center justify-center bg-(--ui-chat-surface-background) p-6">
<div className="flex max-h-[86vh] w-full max-w-[46rem] flex-col overflow-hidden rounded-xl border border-(--stroke-nous) bg-(--ui-chat-bubble-background) shadow-nous">
{/* Subtle back affordance (projects/overlay idiom): muted foreground
on hover, no divider. */}
@ -307,7 +307,7 @@ export function BootFailureOverlay() {
}
return (
<div className="fixed inset-0 z-[1400] flex items-center justify-center bg-(--ui-chat-surface-background) p-6">
<div className="fixed inset-0 z-(--z-setup) flex items-center justify-center bg-(--ui-chat-surface-background) p-6">
<div className="w-full max-w-[40rem] overflow-hidden rounded-xl border border-(--stroke-nous) bg-(--ui-chat-bubble-background) shadow-nous">
<div className="flex items-start gap-3 px-5 py-4">
<ErrorIcon className="mt-0.5" size="1.25rem" />

View file

@ -3,8 +3,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { __resetElapsedTimerRegistryForTests, useElapsedSeconds } from './activity-timer'
function Probe({ active, timerKey }: { active: boolean; timerKey?: string }) {
const elapsed = useElapsedSeconds(active, timerKey)
function Probe({ active, since, timerKey }: { active: boolean; since?: number; timerKey?: string }) {
const elapsed = useElapsedSeconds(active, timerKey, since)
return <span data-testid="elapsed">{elapsed}</span>
}
@ -40,4 +40,30 @@ describe('useElapsedSeconds', () => {
expect(screen.getByTestId('elapsed').textContent).toBe('8')
})
it('counts from an explicit epoch rather than mount time', () => {
const mountedAt = Date.now()
act(() => {
vi.advanceTimersByTime(30_000)
})
render(<Probe active since={mountedAt + 28_000} />)
expect(screen.getByTestId('elapsed').textContent).toBe('2')
})
it('re-anchors when the epoch moves', () => {
const { rerender } = render(<Probe active since={Date.now()} />)
act(() => {
vi.advanceTimersByTime(10_000)
})
expect(screen.getByTestId('elapsed').textContent).toBe('10')
rerender(<Probe active since={Date.now()} />)
expect(screen.getByTestId('elapsed').textContent).toBe('0')
})
})

View file

@ -30,13 +30,22 @@ export function formatElapsed(seconds: number): string {
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`
}
export function useElapsedSeconds(active = true, timerKey?: string): number {
const start = useRef(startedAt(timerKey))
/**
* Seconds since the timer's origin, reported once a second while `active`.
*
* Origin, in order: an explicit `since` timestamp, else the `timerKey`'s
* registry entry (survives unmount/remount), else mount time. Pass `since` when
* the thing being measured started at a moment the caller knows and that moment
* isn't the mount — otherwise an anonymous timer reports the component's age,
* which is only the same number by accident.
*/
export function useElapsedSeconds(active = true, timerKey?: string, since?: number): number {
const start = useRef(since ?? startedAt(timerKey))
const lastKey = useRef(timerKey)
const [elapsed, setElapsed] = useState(() => Math.max(0, Math.floor((Date.now() - start.current) / 1000)))
if (lastKey.current !== timerKey) {
start.current = startedAt(timerKey)
start.current = since ?? startedAt(timerKey)
lastKey.current = timerKey
}
@ -46,7 +55,9 @@ export function useElapsedSeconds(active = true, timerKey?: string): number {
return
}
if (timerKey) {
if (since !== undefined) {
start.current = since
} else if (timerKey) {
start.current = startedAt(timerKey)
}
@ -55,7 +66,7 @@ export function useElapsedSeconds(active = true, timerKey?: string): number {
const id = window.setInterval(tick, 1000)
return () => window.clearInterval(id)
}, [active, timerKey])
}, [active, since, timerKey])
return elapsed
}

View file

@ -41,15 +41,15 @@ interface ParsedHunk {
// plain renderer; the Shiki path omits it so syntax colors win, layering only
// the background + border.
const DIFF_KIND_TINT: Record<DiffKind, string> = {
add: 'border-emerald-500 bg-emerald-500/12',
add: 'border-(--ui-diff-add-border) bg-(--ui-diff-add-background)',
context: 'border-transparent',
remove: 'border-rose-500 bg-rose-500/12'
remove: 'border-(--ui-diff-remove-border) bg-(--ui-diff-remove-background)'
}
const DIFF_KIND_TEXT: Record<DiffKind, string> = {
add: 'text-emerald-800 dark:text-emerald-200',
add: 'text-(--ui-diff-add-foreground)',
context: '',
remove: 'text-rose-800 dark:text-rose-200'
remove: 'text-(--ui-diff-remove-foreground)'
}
const DIFF_LINE_BASE = 'block min-w-max whitespace-pre border-l-2 px-2.5 py-px'
@ -537,7 +537,10 @@ function DiffOverviewRuler({ lines }: { lines: DiffLine[] }) {
<div className="relative w-full" style={{ height: `min(100%, ${lines.length * PREVIEW_LINE_PX}px)` }}>
{runs.map((run, index) => (
<div
className={cn('absolute inset-x-0', run.kind === 'add' ? 'bg-(--ui-green)' : 'bg-(--ui-red)')}
className={cn(
'absolute inset-x-0',
run.kind === 'add' ? 'bg-(--ui-diff-add-border)' : 'bg-(--ui-diff-remove-border)'
)}
key={index}
style={{ height: `max(0.125rem, ${run.sizePct}%)`, top: `${run.startPct}%` }}
/>

View file

@ -398,7 +398,7 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
if (state.setupChoice) {
return (
<div className="fixed inset-0 z-[1400] flex items-center justify-center bg-background/90 p-4 backdrop-blur-md">
<div className="fixed inset-0 z-(--z-setup) flex items-center justify-center bg-background/90 p-4 backdrop-blur-md">
<div className="w-full max-w-2xl rounded-xl border border-(--stroke-nous) bg-card p-8 shadow-nous">
<div className="flex items-start gap-4">
<BrandMark className="size-11 shrink-0" />
@ -478,7 +478,7 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
const platformLabel = ups.platform === 'darwin' ? 'macOS' : ups.platform === 'linux' ? 'Linux' : ups.platform
return (
<div className="fixed inset-0 z-[1400] flex items-center justify-center bg-background/90 backdrop-blur-md">
<div className="fixed inset-0 z-(--z-setup) flex items-center justify-center bg-background/90 backdrop-blur-md">
<div className="w-full max-w-xl rounded-xl border border-(--stroke-nous) bg-card p-8 shadow-nous">
<h2 className="text-xl font-semibold tracking-tight">{copy.oneTimeTitle}</h2>
<p className="mt-2 text-sm text-muted-foreground">{copy.unsupportedDesc(platformLabel)}</p>
@ -547,7 +547,7 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
const currentElapsed = typeof currentStartedAt === 'number' ? formatElapsed(now - currentStartedAt) : ''
return (
<div className="fixed inset-0 z-[1400] flex items-center justify-center bg-background/90 backdrop-blur-md p-4">
<div className="fixed inset-0 z-(--z-setup) flex items-center justify-center bg-background/90 backdrop-blur-md p-4">
<div className="flex w-full max-w-2xl max-h-[90vh] flex-col rounded-xl border border-(--stroke-nous) bg-card shadow-nous">
{/* Header -- always visible, never scrolls */}
<div className="flex flex-shrink-0 items-start gap-4 p-8 pb-4">

View file

@ -56,7 +56,7 @@ function RootErrorFallback({ error, reset }: ErrorBoundaryFallbackProps) {
const { t } = useI18n()
return (
<div className="fixed inset-0 z-[1500] grid place-items-center bg-(--ui-chat-surface-background) p-6">
<div className="fixed inset-0 z-(--z-crash) grid place-items-center bg-(--ui-chat-surface-background) p-6">
<ErrorState
className="w-full max-w-[28rem]"
description={error.message || t.errors.boundaryDesc}

View file

@ -222,7 +222,7 @@ export function FirstRunRemoteForm({ onBack }: FirstRunRemoteFormProps) {
}
return (
<div className="fixed inset-0 z-[1400] flex items-center justify-center bg-background/90 p-4 backdrop-blur-md">
<div className="fixed inset-0 z-(--z-setup) flex items-center justify-center bg-background/90 p-4 backdrop-blur-md">
<div className="flex w-full max-w-xl flex-col rounded-xl border border-(--stroke-nous) bg-card p-8 shadow-nous">
<div className="flex items-start gap-4">
<BrandMark className="size-11 shrink-0" />

View file

@ -10,7 +10,7 @@ import { BootFailureOverlay } from './boot-failure-overlay'
import { GatewayConnectingOverlay } from './gateway-connecting-overlay'
// Repro for the "remote gateway → stuck on CONNECTING, no way to settings"
// report. The connecting overlay (z-1200, full-screen, pointer-events on) used
// report. The connecting overlay (full-screen, pointer-events on) used
// to be shown whenever `gatewayState !== 'open' && !boot.error`. The ONLY escape
// hatch — BootFailureOverlay, which has "Use local gateway" / "Sign in" /
// "Retry" — only renders when `boot.error` is set.

View file

@ -141,7 +141,7 @@ export function GatewayConnectingOverlay() {
return (
<div
className={cn(
'fixed inset-0 z-[1200] grid place-items-center bg-(--ui-chat-surface-background) transition-opacity duration-500 ease-out',
'fixed inset-0 z-(--z-connecting) grid place-items-center bg-(--ui-chat-surface-background) transition-opacity duration-500 ease-out',
overlayHidden ? 'pointer-events-none opacity-0' : 'opacity-100'
)}
>

View file

@ -28,10 +28,10 @@ interface ModelPickerDialogProps {
onSelect: (selection: { provider: string; model: string }) => void
profile?: string
/**
* Optional class to apply to DialogContent. Use to override z-index when
* stacking the picker on top of another fixed overlay (e.g. the desktop
* onboarding overlay, which sits at z-1300; the default Dialog z-130 ends
* up rendering underneath and blocks pointer events).
* Optional class for DialogContent. Use it to lift the picker onto a higher
* rung of the overlay ladder when it opens over another fixed overlay (the
* desktop onboarding overlay, say) on the default modal rung it renders
* underneath and blocks pointer events.
*/
contentClassName?: string
}
@ -85,7 +85,7 @@ export function ModelPickerDialog({
// Open the full onboarding provider selector to add/switch a provider.
// Reuses the entire onboarding flow (OAuth rows, API-key form, device-code,
// model-confirm) instead of duplicating provider UI here. Closes the picker
// so the onboarding overlay (z-1300) isn't rendered underneath it.
// so the onboarding overlay isn't rendered underneath it.
const addProvider = () => {
startManualOnboarding()
onOpenChange(false)

View file

@ -92,9 +92,9 @@ export function NotificationStack() {
)
}
// Portaled to <body> with a z above the Radix dialog layer (overlay z-[120],
// content z-[130]) — see the top-center variant below for why.
const REGION_BASE = 'pointer-events-none fixed z-[200] flex gap-2'
// Portaled to <body> on the over-modal rung so a toast clears an open dialog —
// see the top-center variant below for why.
const REGION_BASE = 'pointer-events-none fixed z-(--z-over-modal) flex gap-2'
// Primary stack: top-center, collapsed to the latest toast with a "+N more"
// expander + clear-all — the noisy/important surface (errors, warnings,

View file

@ -308,15 +308,14 @@ function ConfirmingModelPanel({
</div>
{/*
ModelPickerDialog defaults to z-130 on its content, which renders
UNDER the onboarding overlay (z-1300) and breaks pointer events.
Bump it above with z-[1310] so the picker sits on top of the
onboarding panel. The dialog's own dim-backdrop layer stays at
its default z-120 the onboarding overlay is already dimming
the rest of the screen, so we don't want a second backdrop.
ModelPickerDialog's content sits on the modal rung, which is below the
onboarding overlay it would render underneath and swallow pointer
events. Lift it to the rung above onboarding. Its own dim-backdrop
layer stays on the modal-backdrop rung: onboarding already dims the
rest of the screen, so a second backdrop would double up.
*/}
<ModelPickerDialog
contentClassName="z-[1310]"
contentClassName="z-(--z-onboarding-popover)"
currentModel={flow.currentModel}
currentProvider={flow.providerSlug}
onOpenChange={setPickerOpen}

View file

@ -302,7 +302,7 @@ export function DesktopOnboardingOverlay({
return (
<div
className={cn(
'fixed inset-0 z-1300 flex items-center justify-center bg-(--ui-chat-surface-background) p-6 transition-opacity duration-[520ms] ease-out',
'fixed inset-0 z-(--z-onboarding) flex items-center justify-center bg-(--ui-chat-surface-background) p-6 transition-opacity duration-[520ms] ease-out',
// On the bare confirm screen, hold the surface (text-out + hold) so the
// per-element exit plays before it dissolves.
bare && leaving ? '[transition-delay:660ms]' : '',

View file

@ -45,10 +45,10 @@ export function SessionPickerDialog({ activeStoredSessionId, onOpenChange, onRes
return (
<DialogPrimitive.Root onOpenChange={onOpenChange} open={open}>
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay className="fixed inset-0 z-[200] bg-black/15 backdrop-blur-[1px] data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0" />
<DialogPrimitive.Overlay className="fixed inset-0 z-(--z-over-modal) bg-black/15 backdrop-blur-[1px] data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0" />
<DialogPrimitive.Content
aria-describedby={undefined}
className="fixed left-1/2 top-[14vh] z-[210] w-[min(40rem,calc(100vw-2rem))] -translate-x-1/2 overflow-hidden rounded-xl border border-(--ui-stroke-secondary) bg-(--ui-chat-bubble-background) shadow-lg duration-150 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:slide-in-from-top-2 data-[state=open]:zoom-in-95"
className="fixed left-1/2 top-[14vh] z-(--z-over-modal-content) w-[min(40rem,calc(100vw-2rem))] -translate-x-1/2 overflow-hidden rounded-xl border border-(--ui-stroke-secondary) bg-(--ui-chat-bubble-background) shadow-lg duration-150 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:slide-in-from-top-2 data-[state=open]:zoom-in-95"
>
<DialogPrimitive.Title className="sr-only">{t.commandCenter.sections.sessions}</DialogPrimitive.Title>
<Command className="bg-transparent" loop>

View file

@ -28,7 +28,7 @@ function DialogOverlay({ className, ...props }: React.ComponentProps<typeof Dial
return (
<DialogPrimitive.Overlay
className={cn(
'fixed inset-0 z-[120] pointer-events-auto bg-black/22 backdrop-blur-[0.125rem] data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0',
'fixed inset-0 z-(--z-modal-backdrop) pointer-events-auto bg-black/22 backdrop-blur-[0.125rem] data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0',
className
)}
data-slot="dialog-overlay"
@ -130,7 +130,7 @@ function DialogContent({
<DialogOverlay />
<DialogPrimitive.Content
className={cn(
'fixed left-1/2 top-1/2 z-[130] pointer-events-auto flex max-h-[85vh] -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-xl bg-(--ui-chat-bubble-background) text-[length:var(--conversation-text-font-size)] text-foreground shadow-nous duration-200 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
'fixed left-1/2 top-1/2 z-(--z-modal) pointer-events-auto flex max-h-[85vh] -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-xl bg-(--ui-chat-bubble-background) text-[length:var(--conversation-text-font-size)] text-foreground shadow-nous duration-200 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
widthClass,
className,
// Callers often pass `gap-*` for the no-banner grid layout — suppress
@ -174,7 +174,7 @@ function DialogContent({
// Cap height at 85vh and let long content scroll inside the dialog
// instead of overflowing off-screen (long cron titles, tool detail
// dumps, etc.). Individual dialogs can still override via className.
'fixed left-1/2 top-1/2 z-[130] pointer-events-auto grid max-h-[85vh] -translate-x-1/2 -translate-y-1/2 gap-3 overflow-y-auto rounded-xl border border-(--stroke-nous) bg-(--ui-chat-bubble-background) p-4 text-[length:var(--conversation-text-font-size)] text-foreground shadow-nous duration-200 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
'fixed left-1/2 top-1/2 z-(--z-modal) pointer-events-auto grid max-h-[85vh] -translate-x-1/2 -translate-y-1/2 gap-3 overflow-y-auto rounded-xl border border-(--stroke-nous) bg-(--ui-chat-bubble-background) p-4 text-[length:var(--conversation-text-font-size)] text-foreground shadow-nous duration-200 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
widthClass,
className
)}

View file

@ -53,7 +53,7 @@ function SelectContent({
<SelectPrimitive.Portal container={container}>
<SelectPrimitive.Content
className={cn(
'relative z-[140] max-h-72 min-w-32 overflow-hidden rounded-md border border-border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=top]:slide-in-from-bottom-2 data-[side=right]:slide-in-from-left-2',
'relative z-(--z-modal-popover) max-h-72 min-w-32 overflow-hidden rounded-md border border-border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=top]:slide-in-from-bottom-2 data-[side=right]:slide-in-from-left-2',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className

View file

@ -82,7 +82,7 @@ function TooltipContent({
// rectangular dead space). Instant, no transition (delayDuration=0).
// pointer-events-none: the tip must never steal hover/clicks from the
// chrome underneath (titlebar tools, adjacent tabs, etc.).
className={cn('pointer-events-none z-[200] w-fit max-w-64 select-none', className)}
className={cn('pointer-events-none z-(--z-over-modal) w-fit max-w-64 select-none', className)}
data-slot="tooltip-content"
sideOffset={sideOffset}
{...props}

View file

@ -124,6 +124,7 @@ declare global {
normalizePreviewTarget: (target: string, baseDir?: string) => Promise<HermesPreviewTarget | null>
watchPreviewFile: (url: string) => Promise<HermesPreviewWatch>
stopPreviewFileWatch: (id: string) => Promise<boolean>
setActiveWork?: (payload: HermesActiveWork) => void
setTitleBarTheme?: (payload: HermesTitleBarTheme) => void
setNativeTheme?: (mode: 'dark' | 'light' | 'system') => void
setTranslucency?: (payload: { intensity: number }) => void
@ -458,6 +459,12 @@ export interface HermesTitleBarTheme {
foreground: string
}
/** Turns in flight, so the main process can confirm before a quit kills them. */
export interface HermesActiveWork {
count: number
titles: string[]
}
export interface HermesWindowState {
isFullscreen: boolean
isMinimized?: boolean

View file

@ -1,4 +1,6 @@
import './styles.css'
// Side-effect: reports in-flight turns to the main process for the quit guard.
import './store/active-work'
// Side-effect: applies the persisted window translucency on load.
import './store/translucency'
// Dev-only render/state churn counters. MUST precede the `react-dom` import

View file

@ -0,0 +1,60 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ClientSessionState } from '@/app/types'
import { $sessions } from './session'
import { clearAllSessionStates, publishSessionState } from './session-states'
const desktopWindow = window as unknown as { hermesDesktop?: Window['hermesDesktop'] }
const setActiveWork = vi.fn()
const busy = (storedSessionId: string, isBusy: boolean) =>
({ busy: isBusy, needsInput: false, storedSessionId }) as ClientSessionState
const session = (id: string, title: null | string) => ({ id, title }) as (typeof $sessions.value)[number]
beforeAll(async () => {
desktopWindow.hermesDesktop = { setActiveWork } as unknown as Window['hermesDesktop']
// Subscribes at import time, so the bridge has to exist first.
await import('./active-work')
})
beforeEach(() => {
clearAllSessionStates()
$sessions.set([])
setActiveWork.mockClear()
})
describe('active work bridge', () => {
it('reports a busy session by title', () => {
$sessions.set([session('s1', 'Fix login'), session('s2', 'Idle chat')])
publishSessionState('runtime-1', busy('s1', true))
expect(setActiveWork).toHaveBeenLastCalledWith({ count: 1, titles: ['Fix login'] })
})
it('counts an untitled busy session without inventing a title', () => {
$sessions.set([session('s1', null)])
publishSessionState('runtime-1', busy('s1', true))
expect(setActiveWork).toHaveBeenLastCalledWith({ count: 1, titles: [] })
})
it('drops back to nothing when the turn ends', () => {
$sessions.set([session('s1', 'Fix login')])
publishSessionState('runtime-1', busy('s1', true))
publishSessionState('runtime-1', busy('s1', false))
expect(setActiveWork).toHaveBeenLastCalledWith({ count: 0, titles: [] })
})
it('does not re-send an unchanged summary', () => {
$sessions.set([session('s1', 'Fix login')])
publishSessionState('runtime-1', busy('s1', true))
setActiveWork.mockClear()
$sessions.set([session('s1', 'Fix login'), session('s2', 'Something else')])
expect(setActiveWork).not.toHaveBeenCalled()
})
})

View file

@ -0,0 +1,42 @@
/**
* Mirror of "which chats are mid-turn" to the main process.
*
* The renderer is the only side that knows a turn is in flight, and the main
* process is the only side that can intercept a quit. This module bridges the
* two: it publishes a small summary on every membership change, and
* `electron/quit-guard.ts` turns that into the confirmation dialog.
*
* Imported for its side effect from `main.tsx`, alongside `store/translucency`.
*/
import { computed } from 'nanostores'
import type { HermesActiveWork } from '@/global'
import { $sessions } from '@/store/session'
import { $workingSessionIds } from '@/store/session-states'
const $activeWork = computed([$workingSessionIds, $sessions], (workingIds, sessions): HermesActiveWork => {
const titleById = new Map(sessions.map(session => [session.id, session.title?.trim() ?? '']))
return {
count: workingIds.length,
titles: workingIds.map(id => titleById.get(id) ?? '').filter(Boolean)
}
})
if (typeof window !== 'undefined') {
// `$sessions` republishes on unrelated churn (previews, heartbeats), so only
// send when the summary itself moved — this crosses a process boundary.
let lastSent = ''
$activeWork.subscribe(work => {
const next = JSON.stringify(work)
if (next === lastSent) {
return
}
lastSent = next
window.hermesDesktop?.setActiveWork?.(work)
})
}

View file

@ -2,6 +2,7 @@ import { type ConnectionState, type GatewayEvent, resolveGatewayWsUrl } from '@h
import { atom } from 'nanostores'
import { HermesGateway } from '@/hermes'
import { markNativeNotifyBaseline } from '@/store/notify-baseline'
import { setGatewayState } from '@/store/session'
// ── Multi-profile gateway routing ──────────────────────────────────────────
@ -136,6 +137,12 @@ export function activeGateway(): HermesGateway | null {
// composer reflect the active profile's socket without a background reconnect
// flipping the foreground enabled/disabled state.
function reportGatewayState(profile: string, state: ConnectionState): void {
// Any socket opening replays parked prompts; hold OS notifications so a
// launch/reconnect doesn't alert about state that already existed.
if (state === 'open') {
markNativeNotifyBaseline()
}
if (normKey(profile) === g.activeKey) {
setGatewayState(state)
}

View file

@ -9,6 +9,7 @@ import {
setNativeNotifyEnabled,
setNativeNotifyKind
} from './native-notifications'
import { __resetNativeNotifyBaselineForTests, markNativeNotifyBaseline } from './notify-baseline'
import { $approvalRequest, setApprovalRequest } from './prompts'
import { $activeSessionId, setActiveSessionId } from './session'
@ -43,6 +44,7 @@ beforeEach(() => {
setActiveSessionId(null)
setWindowState({ focused: false, hidden: true })
__resetNativeNotifyBaselineForTests()
})
afterEach(() => {
@ -139,6 +141,35 @@ describe('dispatchNativeNotification preferences', () => {
})
})
describe('dispatchNativeNotification post-connect baseline', () => {
it('suppresses a prompt replayed right after a socket opens', () => {
markNativeNotifyBaseline()
dispatchNativeNotification({ kind: 'approval', sessionId: freshSession(), title: 'approve' })
expect(notify).not.toHaveBeenCalled()
})
it('suppresses a completion replayed right after a socket opens', () => {
const sessionId = freshSession()
setActiveSessionId(sessionId)
markNativeNotifyBaseline()
dispatchNativeNotification({ kind: 'turnDone', sessionId, title: 'done' })
expect(notify).not.toHaveBeenCalled()
})
it('fires again once the window has passed', () => {
vi.useFakeTimers()
try {
markNativeNotifyBaseline()
vi.advanceTimersByTime(5000)
dispatchNativeNotification({ kind: 'approval', sessionId: freshSession(), title: 'approve' })
expect(notify).toHaveBeenCalledTimes(1)
} finally {
vi.useRealTimers()
}
})
})
describe('dispatchNativeNotification throttle', () => {
it('collapses duplicate kind+session within the throttle window', () => {
const sessionId = freshSession()

View file

@ -3,6 +3,7 @@ import { atom } from 'nanostores'
import { persistString, storedString } from '@/lib/storage'
import { $gateway } from './gateway'
import { withinNativeNotifyBaseline } from './notify-baseline'
import { clearApprovalRequest } from './prompts'
import { $activeSessionId } from './session'
@ -160,6 +161,10 @@ export function dispatchNativeNotification(input: NativeNotificationInput): void
return
}
if (withinNativeNotifyBaseline()) {
return
}
if (!shouldFire(input.kind, input.sessionId, input.global)) {
return
}

View file

@ -0,0 +1,30 @@
// Post-connect quiet window for native (OS) notifications.
//
// A socket opening replays state that already existed: a session parked on an
// approval re-emits its request so the UI can render the prompt. Those are not
// things that just happened, so launching Hermes — or any reconnect, profile
// switch, or gateway-mode apply — would otherwise fire an OS notification for a
// prompt the user has known about for an hour. The in-app surfaces (sidebar
// row, inline approval bar) still show the prompt immediately; only the OS
// notification is held.
//
// Lives in its own leaf module so `store/gateway` (which marks the baseline)
// and `store/native-notifications` (which reads it) don't import each other.
const SEED_QUIET_MS = 4000
let quietUntil = 0
/** Called on every gateway `open`. Opens the quiet window. */
export function markNativeNotifyBaseline(): void {
quietUntil = Date.now() + SEED_QUIET_MS
}
/** True while replayed post-connect state should not raise an OS notification. */
export function withinNativeNotifyBaseline(): boolean {
return Date.now() < quietUntil
}
export function __resetNativeNotifyBaselineForTests(): void {
quietUntil = 0
}

View file

@ -188,6 +188,38 @@
--context-usage-subagents: color-mix(in srgb, var(--ui-blue) 70%, var(--ui-cyan));
--context-usage-memory: color-mix(in srgb, var(--ui-orange) 80%, var(--ui-yellow));
--context-usage-conversation: var(--ui-cyan);
/* Diff add/remove, derived from the semantic palette so every diff surface
(tool cards, preview, review pane) tracks the theme's green/red. Only the
foregrounds need a dark override they mix toward the page instead of
away from it. */
--ui-diff-add-border: var(--ui-green);
--ui-diff-add-background: color-mix(in srgb, var(--ui-green) 12%, transparent);
--ui-diff-add-foreground: color-mix(in srgb, var(--ui-green) 70%, #000);
--ui-diff-remove-border: var(--ui-red);
--ui-diff-remove-background: color-mix(in srgb, var(--ui-red) 12%, transparent);
--ui-diff-remove-foreground: color-mix(in srgb, var(--ui-red) 70%, #000);
/* Overlay ladder. DESIGN.md: app-wide surfaces must not compete through
ad-hoc z-index literals pick the rung that describes the surface.
Values are deliberately sparse so a one-off can slot between two rungs
without a renumber. Local stacking inside a component (a sticky header
over its own scroll area) stays on plain `z-10`/`z-20`; these rungs are
only for surfaces that float over the app. */
--z-modal-backdrop: 120;
--z-modal: 130;
/* A select/dropdown/popover opened from inside a modal, portaled to body. */
--z-modal-popover: 140;
/* Must clear any open modal: toasts, tooltips, command-surface backdrops. */
--z-over-modal: 200;
--z-over-modal-content: 210;
--z-switcher-backdrop: 219;
--z-switcher: 220;
/* Boot and blocking states, in the order they can stack. */
--z-connecting: 1200;
--z-onboarding: 1300;
--z-onboarding-popover: 1310;
--z-setup: 1400;
--z-crash: 1500;
--ui-bg-chrome: color-mix(
in srgb,
var(--theme-background-seed) var(--theme-mix-chrome),
@ -444,6 +476,8 @@
--ui-red: #e75e78;
--ui-green: #55a583;
--ui-cyan: #6f9ba6;
--ui-diff-add-foreground: color-mix(in srgb, var(--ui-green) 62%, #fff);
--ui-diff-remove-foreground: color-mix(in srgb, var(--ui-red) 62%, #fff);
--sidebar-edge-border: color-mix(in srgb, var(--ui-base) 12%, transparent);
--composer-ring-strength: 1.3;