Merge upstream main into feat/hermes-relay-shared-metrics

This commit is contained in:
Alex Fournier 2026-07-27 15:13:47 -07:00
commit 5efeb73b9f
45 changed files with 980 additions and 79 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

@ -0,0 +1,180 @@
import type { Unstable_TriggerItem } from '@assistant-ui/core'
import { act, renderHook } from '@testing-library/react'
import { createRef } from 'react'
import { describe, expect, it, vi } from 'vitest'
import { useComposerTrigger } from './hooks/use-composer-trigger'
import { composerPlainText, renderComposerContents, RICH_INPUT_SLOT } from './rich-editor'
/**
* Folder navigation in the `@` popover, driven through the REAL hook against a
* real contentEditable.
*
* Tab and Enter used to be the same branch, so picking a folder always
* committed a chip and closed the menu — there was no way to walk into a
* subdirectory from the list. Tab now descends, Enter still commits, and
* Backspace climbs back out one segment.
*/
function folderItem(path: string): Unstable_TriggerItem {
return {
id: `@folder:${path}|0`,
type: 'folder',
label: path.split('/').filter(Boolean).pop() ?? path,
metadata: {
icon: 'folder',
display: path,
meta: 'dir',
rawText: `@folder:${path}`,
insertId: path
}
}
}
function setup(initialText: string) {
const editor = document.createElement('div')
editor.contentEditable = 'true'
// The real composer marks its editor with this slot; `composerPlainText`
// keys off it to decide whether a DIV contributes a trailing newline.
// Without it the harness would silently diverge from production text.
editor.dataset.slot = RICH_INPUT_SLOT
document.body.append(editor)
renderComposerContents(editor, initialText)
// Caret at the end, which is where a typed trigger always leaves it.
const range = document.createRange()
range.selectNodeContents(editor)
range.collapse(false)
const sel = window.getSelection()
sel?.removeAllRanges()
sel?.addRange(range)
const editorRef = createRef<HTMLDivElement>() as { current: HTMLDivElement | null }
editorRef.current = editor
const draftRef = { current: initialText }
const setComposerText = vi.fn()
const { result } = renderHook(() =>
useComposerTrigger({
at: { adapter: null, loading: false },
draftRef,
editorRef,
requestMainFocus: vi.fn(),
setComposerText,
slash: { adapter: null, loading: false }
})
)
act(() => {
result.current.refreshTrigger()
})
return { editor, result, setComposerText }
}
describe('@ folder navigation', () => {
it('Tab on a folder walks into it and keeps the popover open', () => {
const { editor, result } = setup('@app')
expect(result.current.trigger).toMatchObject({ kind: '@', query: 'app' })
act(() => {
result.current.replaceTriggerWithChip(folderItem('apps'), { descend: true })
})
// Plain text, not a chip — the token is still being typed.
expect(composerPlainText(editor)).toBe('@apps/')
expect(editor.querySelector('[data-ref-text]')).toBeNull()
})
it('descends repeatedly, one level per Tab', () => {
const { editor, result } = setup('@apps/desk')
act(() => {
result.current.replaceTriggerWithChip(folderItem('apps/desktop'), { descend: true })
})
expect(composerPlainText(editor)).toBe('@apps/desktop/')
})
it('Enter on a folder commits it as a chip instead of descending', () => {
const { editor, result } = setup('@app')
act(() => {
result.current.replaceTriggerWithChip(folderItem('apps'))
})
const chip = editor.querySelector('[data-ref-text]')
expect(chip).not.toBeNull()
expect(chip?.getAttribute('data-ref-kind')).toBe('folder')
expect(composerPlainText(editor)).toContain('@folder:')
})
it('only descends for `@` folders — a file pick still commits', () => {
const { editor, result } = setup('@main')
const file: Unstable_TriggerItem = {
id: '@file:src/main.tsx|0',
type: 'file',
label: 'main.tsx',
metadata: {
icon: 'file',
display: 'main.tsx',
meta: 'src',
rawText: '@file:src/main.tsx',
insertId: 'src/main.tsx'
}
}
act(() => {
result.current.replaceTriggerWithChip(file, { descend: true })
})
expect(editor.querySelector('[data-ref-kind="file"]')).not.toBeNull()
})
it('Backspace climbs out one segment at a time', () => {
const { editor, result } = setup('@apps/desktop/')
let handled = false
act(() => {
handled = result.current.ascendTriggerPath()
})
expect(handled).toBe(true)
expect(composerPlainText(editor)).toBe('@apps/')
})
it('Backspace drops a partially typed segment before its parent', () => {
const { editor, result } = setup('@apps/desk')
act(() => {
result.current.ascendTriggerPath()
})
expect(composerPlainText(editor)).toBe('@apps/')
})
it('leaves Backspace alone when there is no path to climb', () => {
const { result } = setup('@apps')
let handled = true
act(() => {
handled = result.current.ascendTriggerPath()
})
// No `/` in the query — normal character deletion must still happen.
expect(handled).toBe(false)
})
it('preserves text typed before the mention', () => {
const { editor, result } = setup('look at @app')
act(() => {
result.current.replaceTriggerWithChip(folderItem('apps'), { descend: true })
})
expect(composerPlainText(editor)).toBe('look at @apps/')
})
})

View file

@ -189,7 +189,7 @@ export function useComposerTrigger({
return true
}
const replaceTriggerWithChip = (item: Unstable_TriggerItem) => {
const replaceTriggerWithChip = (item: Unstable_TriggerItem, options?: { descend?: boolean }) => {
const editor = editorRef.current
if (!editor || !trigger) {
@ -219,6 +219,31 @@ export function useComposerTrigger({
const serialized = hermesDirectiveFormatter.serialize(item)
const starter = serialized.endsWith(':')
// Tab on a folder walks INTO it instead of committing it: re-type the
// token as the bare path so the next `complete.path` lists that folder's
// children, exactly as typing the path by hand would. Enter still commits
// the folder itself — the two intents are distinct, so the keys are too.
// Only `@` folders descend; a slash command's arg list has no hierarchy.
const descendInto =
options?.descend && trigger.kind === '@' && item.type === 'folder'
? String((item.metadata as { insertId?: unknown } | undefined)?.insertId ?? '')
: ''
if (descendInto) {
const path = descendInto.endsWith('/') ? descendInto : `${descendInto}/`
const current = composerPlainText(editor)
const prefix = current.slice(0, Math.max(0, current.length - trigger.tokenLength))
renderComposerContents(editor, `${prefix}@${path}`)
placeCaretEnd(editor)
draftRef.current = composerPlainText(editor)
setComposerText(draftRef.current)
requestMainFocus()
window.setTimeout(refreshTrigger, 0)
return
}
// Picking a bare arg-taking command (e.g. `/personality`) shouldn't commit
// it — expand to its options step so the popover shows the inline list, just
// as typing `/personality ` by hand would. A serialized value with a space is
@ -300,8 +325,37 @@ export function useComposerTrigger({
finish()
}
/** Backspace inside an `@` path drops the last segment (`a/b/` → `a/`)
* instead of one character. Descending is one Tab per level, so climbing
* back out should cost one key too rather than a held delete. Returns
* false when the caret isn't in a path, so keydown falls through. */
const ascendTriggerPath = () => {
const editor = editorRef.current
if (!editor || trigger?.kind !== '@' || !trigger.query.includes('/')) {
return false
}
// Trailing slash means we're listing a folder's children: drop that
// folder. Otherwise a partial segment is typed — drop just that.
const trimmed = trigger.query.replace(/\/$/, '')
const parent = trimmed.slice(0, trimmed.lastIndexOf('/') + 1)
const current = composerPlainText(editor)
const prefix = current.slice(0, Math.max(0, current.length - trigger.tokenLength))
renderComposerContents(editor, `${prefix}@${parent}`)
placeCaretEnd(editor)
draftRef.current = composerPlainText(editor)
setComposerText(draftRef.current)
window.setTimeout(refreshTrigger, 0)
return true
}
return {
argStageEmpty,
ascendTriggerPath,
closeTrigger,
commitTypedSlashDirective,
refreshTrigger,

View file

@ -299,6 +299,7 @@ export function ChatBar({
// this API; keyup uses triggerKeyConsumedRef to skip its refresh.
const {
argStageEmpty,
ascendTriggerPath,
closeTrigger,
commitTypedSlashDirective,
refreshTrigger,
@ -556,12 +557,24 @@ export function ChatBar({
const item = triggerItems[triggerActive]
if (item) {
replaceTriggerWithChip(item)
// Tab means "go deeper" on a folder; Enter means "I want this one".
// Everything else treats them alike.
replaceTriggerWithChip(item, { descend: event.key === 'Tab' })
}
return
}
// Backspace climbs out of an `@` path one segment at a time, mirroring
// Tab's one-key descent. Only when the caret sits at the end of the
// token — mid-token editing keeps normal character deletion.
if (event.key === 'Backspace' && !event.metaKey && !event.altKey && ascendTriggerPath()) {
event.preventDefault()
triggerKeyConsumedRef.current = true
return
}
if (event.key === 'Escape') {
event.preventDefault()
triggerKeyConsumedRef.current = true

View file

@ -51,6 +51,39 @@ describe('detectTrigger', () => {
expect(detectTrigger('and/or')).toBeNull()
})
it('keeps the at-mention live while walking into subfolders', () => {
// A `/` inside the query is path navigation, not the end of the token —
// the popover has to stay open so the next directory level can load.
expect(detectTrigger('@./')).toEqual({ kind: '@', query: './', tokenLength: 3 })
expect(detectTrigger('@./src')).toEqual({ kind: '@', query: './src', tokenLength: 6 })
expect(detectTrigger('@~/Desktop/')).toEqual({ kind: '@', query: '~/Desktop/', tokenLength: 11 })
expect(detectTrigger('@/usr/local')).toEqual({ kind: '@', query: '/usr/local', tokenLength: 11 })
expect(detectTrigger('@apps/desktop/src')).toEqual({
kind: '@',
query: 'apps/desktop/src',
tokenLength: 17
})
})
it('keeps the at-mention live for a typed ref kind with a path', () => {
expect(detectTrigger('@file:src/main.tsx')).toEqual({
kind: '@',
query: 'file:src/main.tsx',
tokenLength: 18
})
expect(detectTrigger('@folder:apps/')).toEqual({ kind: '@', query: 'folder:apps/', tokenLength: 13 })
})
it('still ends the at-mention token at whitespace', () => {
// The token is whitespace-delimited; a path doesn't change that.
expect(detectTrigger('@./src and more')).toBeNull()
expect(detectTrigger('look at @apps/desktop')).toEqual({
kind: '@',
query: 'apps/desktop',
tokenLength: 13
})
})
it('treats a mid-message slash as an inline reference', () => {
// Skills have to be reachable anywhere in a prompt, not just at position 0.
expect(detectTrigger('hello /')).toEqual({ kind: '/', inline: true, query: '', tokenLength: 1 })

View file

@ -10,7 +10,11 @@ export interface TriggerState {
}
// `@` triggers stop at the first whitespace — `@file:path` and `@diff` are
// single tokens. Restricting the slash command name to `[a-zA-Z][\w-]*` avoids
// single tokens, and a path is part of that token: `@./src/`, `@~/Desktop/`,
// and `@file:src/foo` all have to keep the popover live while the user walks
// into subdirectories. Excluding `/` from the query class would end the token
// at the first separator, which is exactly the "can't browse into a folder"
// bug. Restricting the slash command name to `[a-zA-Z][\w-]*` avoids
// matching file paths like `src/foo/bar`.
//
// `/` triggers fire in two shapes, because a slash means two different things
@ -27,7 +31,7 @@ export interface TriggerState {
// The inline shape is what makes skills reachable anywhere in a prompt. Both
// shapes need the trailing `$`: detection runs against the text BEFORE the
// caret, so the match must end where the user is typing.
const AT_TRIGGER_RE = /(?:^|[\s])(@)([^\s@/]*)$/
const AT_TRIGGER_RE = /(?:^|[\s])(@)([^\s@]*)$/
const SLASH_COMMAND_TRIGGER_RE = /^(\/)((?:[a-zA-Z][\w-]*(?:\s+\S*)*)?)$/
const SLASH_INLINE_TRIGGER_RE = /[\s](\/)([a-zA-Z][\w-]*)?$/

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

@ -113,7 +113,7 @@ const SLASH_CHIP_VARIANT: Record<SlashChipKind, string> = {
}
export const SLASH_CHIP_BASE_CLASS =
'mx-0.5 inline-flex max-w-64 items-center gap-1 rounded px-1.5 py-0.5 align-middle text-[0.86em] font-medium leading-none'
'mx-0.5 inline-flex max-w-64 items-center gap-1 rounded px-1.5 py-0.5 align-[-0.12em] text-[0.86em] font-medium leading-none'
export function slashChipClass(kind: SlashChipKind): string {
return `${SLASH_CHIP_BASE_CLASS} ${SLASH_CHIP_VARIANT[kind]}`
@ -145,9 +145,15 @@ const DirectiveIcon: FC<{ type: string; className?: string }> = ({
/** Shared chip styling — used by both the rendered <DirectiveChip> and the
* raw HTML composer chips in `rich-editor.ts`. Neutral subtle wash + plain
* muted-foreground text so chips read as quiet tags on any bubble color. */
* muted-foreground text so chips read as quiet tags on any bubble color.
*
* `align-[-0.12em]` rather than `align-middle`: `middle` centers the pill on
* the x-height midpoint, which sits above the center of the surrounding text
* box, so the chip visibly rides low next to the words it's nestled in. The
* em nudge lands the chip's own text baseline on the line's baseline (measured
* to within 0.08px) without growing the line box. */
export const DIRECTIVE_CHIP_CLASS =
'mx-0.5 inline-flex max-w-56 items-center gap-1 rounded px-1.5 py-0.5 align-middle text-[0.86em] font-normal leading-none bg-[color-mix(in_srgb,currentColor_8%,transparent)] text-muted-foreground'
'mx-0.5 inline-flex max-w-56 items-center gap-1 rounded px-1.5 py-0.5 align-[-0.12em] text-[0.86em] font-normal leading-none bg-[color-mix(in_srgb,currentColor_8%,transparent)] text-muted-foreground'
/**
* Parses our composer's `@type:value` references into directive segments

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;

View file

@ -277,3 +277,79 @@ def test_fuzzy_paths_relative_to_cwd_inside_subdir(tmp_path, monkeypatch):
readme_texts = [t for t, _, _ in _items("@README")]
assert not any("README.md" in t for t in readme_texts), readme_texts
# ── Fuzzy DIRECTORY matching ─────────────────────────────────────────────
# `@Desktop` used to return nothing: the fuzzy scanner ranks basenames from
# `_list_repo_files`, which lists FILES only, so a directory whose name no
# file inside it happens to match was unreachable without typing a `/`.
def test_fuzzy_finds_directory_by_name(tmp_path, monkeypatch):
"""A folder is reachable by bare name, with no trailing slash typed."""
monkeypatch.chdir(tmp_path)
(tmp_path / "Desktop" / "nested").mkdir(parents=True)
# Deliberately named so NO file basename fuzzy-matches "Desktop".
(tmp_path / "Desktop" / "nested" / "zzz.txt").write_text("x")
entries = _items("@Desktop")
texts = [t for t, _, _ in entries]
assert "@folder:Desktop/" in texts, texts
row = next(r for r in entries if r[0] == "@folder:Desktop/")
assert row[1] == "Desktop/"
assert row[2] == "dir"
def test_fuzzy_directory_prefix_match(tmp_path, monkeypatch):
"""Partial folder names match too — `@Desk` finds `Desktop/`."""
monkeypatch.chdir(tmp_path)
(tmp_path / "Desktop").mkdir()
(tmp_path / "Desktop" / "zzz.txt").write_text("x")
assert "@folder:Desktop/" in [t for t, _, _ in _items("@Desk")]
def test_fuzzy_ranks_folder_above_file_at_same_tier(tmp_path, monkeypatch):
"""At an equal match tier the folder leads: `@docs` means the directory."""
monkeypatch.chdir(tmp_path)
(tmp_path / "docs").mkdir()
(tmp_path / "docs" / "intro.md").write_text("x")
(tmp_path / "docs.md").write_text("x")
texts = [t for t, _, _ in _items("@docs")]
assert texts[0] == "@folder:docs/", texts
assert "@file:docs.md" in texts
def test_fuzzy_hides_dot_directories_unless_asked(tmp_path, monkeypatch):
"""Dot-folders follow the same rule as dotfiles."""
monkeypatch.chdir(tmp_path)
(tmp_path / ".config").mkdir()
(tmp_path / ".config" / "zzz.txt").write_text("x")
assert not any(".config" in t for t, _, _ in _items("@config"))
assert any(t.startswith("@folder:.config") for t, _, _ in _items("@.config"))
def test_fuzzy_finds_top_level_entries_outside_a_git_repo(tmp_path, monkeypatch):
"""Outside a repo the fallback walk can exhaust its file budget on one
deep subtree before reaching a sibling, hiding top-level folders. The
root listdir seed guarantees immediate children are always candidates.
"""
monkeypatch.chdir(tmp_path)
monkeypatch.setattr(server, "_FUZZY_CACHE_MAX_FILES", 5)
# A deep subtree that soaks up the entire (patched) file budget...
deep = tmp_path / "aaa_hog"
deep.mkdir()
for i in range(40):
(deep / f"f{i:03d}.txt").write_text("x")
# ...and the folder the user actually wants, sorted after it.
(tmp_path / "Desktop").mkdir()
(tmp_path / "Desktop" / "note.txt").write_text("x")
assert "@folder:Desktop/" in [t for t, _, _ in _items("@Desktop")]

View file

@ -16190,24 +16190,54 @@ def _(rid, params: dict) -> dict:
and "/" not in path_part
and prefix_tag != "folder"
):
ranked: list[tuple[tuple[int, int], str, str]] = []
for rel in _list_repo_files(root):
basename = os.path.basename(rel)
if basename.startswith(".") and not path_part.startswith("."):
continue
rank = _fuzzy_basename_rank(basename, path_part)
if rank is None:
continue
ranked.append((rank, rel, basename))
ranked: list[tuple[tuple[int, int], str, str, bool]] = []
walked_dirs: set[str] = set()
seen: set[str] = set()
want_hidden = path_part.startswith(".")
ranked.sort(key=lambda r: (r[0], len(r[1]), r[1]))
def _consider(rel: str, name: str, is_dir: bool) -> None:
if rel in seen or (name.startswith(".") and not want_hidden):
return
rank = _fuzzy_basename_rank(name, path_part)
if rank is not None:
seen.add(rel)
ranked.append((rank, rel, name, is_dir))
# Seed with root's immediate children. `_list_repo_files` is capped
# at _FUZZY_CACHE_MAX_FILES, and outside a git repo the fallback
# walk can burn that whole budget on one deep subtree before ever
# reaching a sibling — which is why `@Desk` in a non-repo $HOME
# found nothing. One listdir keeps the top level always reachable.
try:
for entry in os.listdir(root):
if entry not in _FUZZY_FALLBACK_EXCLUDES:
_consider(entry, entry, os.path.isdir(os.path.join(root, entry)))
except OSError:
pass
for rel in _list_repo_files(root):
_consider(rel, os.path.basename(rel), False)
# Directories are only implied by the file listing, so rank each
# ancestor too. Without this a bare `@Desktop` finds nothing —
# a folder with no name-matching file inside it is invisible to
# a file-only scan, which is the "can't @ a folder by name" bug.
parent = os.path.dirname(rel)
while parent and parent not in walked_dirs:
walked_dirs.add(parent)
_consider(parent, os.path.basename(parent), True)
parent = os.path.dirname(parent)
# Same rank tier: folders first, so `@Desktop` leads with the folder
# rather than a file that merely fuzzy-matches the same letters.
ranked.sort(key=lambda r: (r[0], not r[3], len(r[1]), r[1]))
tag = prefix_tag or "file"
for _, rel, basename in ranked[:30]:
for _, rel, basename, is_dir in ranked[:30]:
items.append(
{
"text": f"@{tag}:{rel}",
"display": basename,
"meta": os.path.dirname(rel),
"text": f"@{'folder' if is_dir else tag}:{rel}{'/' if is_dir else ''}",
"display": basename + ("/" if is_dir else ""),
"meta": "dir" if is_dir else os.path.dirname(rel),
}
)