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),
}
)