fix(desktop): triage batch of GUI quality-of-life fixes (#37536)

* fix(desktop): triage 24 GUI quality-of-life fixes across sidebar, composer, tool cards, messaging, and platform plumbing

A grab-bag of high-leverage UX fixes plus a few backend touches that the
GUI needs to behave correctly on Windows.

Sidebar / sessions
- Decrement $sessionsTotal on delete + archive so "Load N more" stops
  claiming removed rows are still on the server.
- Hide the "Group by workspace" toggle when no unpinned sessions exist.
- Accept Cmd/Ctrl+N as a "new session" accelerator (in addition to bare
  Shift+N), and render the kbd hint per-platform.
- Switch the statusbar to overflow-x-clip so untitled sessions don't
  paint a horizontal scrollbar at the bottom of the window.

Messaging + Cron
- Add [-webkit-app-region: no-drag] to the page-search input so clicks
  reach the field instead of routing to the OS window-drag handler.
- Replace single-letter PlatformAvatar with brand glyphs from
  @icons-pack/react-simple-icons (telegram, discord, matrix, signal,
  whatsapp, mattermost, wechat, qq, ...). Letter monogram fallback for
  Slack / Dingtalk / Feishu / WeCom (removed from Simple Icons at brand
  owner request).
- Drop the duplicate "Create first cron" button in the empty state.

Composer
- Dedupe pasted images by (name, size, lastModified, type) instead of
  Blob identity; Chromium hands us the same screenshot via both
  clipboard.items and clipboard.files with fresh File instances.
- Enable spellcheck on the contentEditable, configure Chromium's
  spellchecker with the system locale on whenReady, and add
  replaceMisspelling + "Add to dictionary" entries to the context menu.
- Render user messages through a minimal markdown pipeline (inline
  backtick code + fenced ``` blocks) while keeping @file:/@image:
  directive chips intact.
- max-h-[60vh] overflow-y-auto + collisionPadding on the prompt-snippet
  submenu.
- Bake cursor-pointer into the <Button> primitive (with
  disabled:cursor-default) and into titlebarButtonClass.

Dialogs + tabs + version
- Default DialogContent now has max-h-[85vh] overflow-y-auto so long
  bodies scroll instead of falling off-screen.
- Right-rail preview tabs close on middle-click (button === 1), with an
  onMouseDown swallow to suppress Chromium autoscroll.
- New refreshDesktopVersion() helper called from About mount, after
  every update check, and on throttled window focus so About reflects
  the just-installed binary.

Keys + Artifacts + Terminal
- Drop the global "Show advanced" toggle in KeysSettings. Provider
  groups now default-expand when they have any key set.
- Extend openExternalUrl to handle file:// via shell.openPath, with
  showItemInFolder fallback when the OS can't open the file.
- New lib/ansi.ts SGR parser + <AnsiText> component, applied to
  terminal/execute_code tool output.
- ToolView gained stdout / stderr / rendersAnsi; tool-fallback renders
  the two streams as separate labeled blocks with stderr in a neutral
  tone (not destructive — many CLIs log info on stderr).
- Drop 'stderr' from ERROR_MSG_KEYS in tool-result-summary.

Paths + platform
- resolveHermesCwd skips process.cwd() when packaged and prefers a
  user-configurable default project directory.
- New hermes:setting:defaultProjectDir:{get,set,pick} IPC handlers +
  preload bridge + global.d.ts typing + a "Default project directory"
  row in Sessions settings.
- FileOperations.delete_path(path, recursive=True) on the abstract
  base; ShellFileOperations.delete_file rewritten to run a cross-
  platform python3 -c snippet so deletes work on Windows shells (which
  have no rm/rm -rf). Fallback to `python` when `python3` isn't on PATH.
- README troubleshooting block split into macOS/Linux + Windows
  PowerShell recipes.
- Tightened renderer favicon links in index.html + added color-scheme
  and theme-color meta.

Backend lifecycle (renderer-side mitigation)
- New noteSessionActivity() heartbeat + session.ts watchdog: an
  8-minute silence on the stream auto-clears stuck $workingSessionIds
  entries so "Session Busy" never gets permanently wedged. Wired into
  useSessionStateCache so every state update refreshes the timer.

i18n spike
- docs/desktop-i18n-rfc.md scoping a future language-switcher PR
  (recommends react-intl, audits IME/RTL/CJK in the composer +
  chat bubbles, 4-PR rollout plan, ~3-4 eng-weeks for the first
  non-English locale).

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(desktop): replace native OS scrollbar in portaled dropdown menus

Radix's DropdownMenuPrimitive.Portal renders content under document.body,
outside the `.scrollbar-dt` scope on #root. Whenever a menu's max-height
clipped its content (even by a pixel — common for the composer "+" menu
that opens upward near the bottom of the window), the user saw the OS's
chunky native scrollbar painted across the whole menu.

Bake a thin, slot-styled scrollbar onto DropdownMenuContent and
DropdownMenuSubContent via [scrollbar-width:thin] + WebKit pseudo-element
arbitrary variants. The submenu also gets a max-h tied to
--radix-dropdown-menu-content-available-height so long snippet lists scroll
cleanly instead of running off the bottom of the viewport. Drop the now-
redundant max-h-[60vh] override on the prompt-snippet submenu.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(desktop): unbork dropdown menu — submenu opens, parent isn't a circle

Two regressions from the previous dropdown-scrollbar fix:

- The parent menu rendered as a rounded oval. Long Tailwind v4 arbitrary-
  variant strings like [&::-webkit-scrollbar-thumb]:rounded-full inside a
  cn() call were being mis-resolved so the `rounded-full` leaked onto the
  menu container itself. Replaced the whole tower of arbitrary variants
  with a real `.dt-portal-scrollbar` class in styles.css that mirrors what
  `.scrollbar-dt` already does for #root descendants. Plain CSS, no Tailwind
  parser ambiguity.
- The Prompt snippets submenu didn't open. Radix publishes
  --radix-dropdown-menu-content-available-height on Content but NOT on
  SubContent, so the `max-h` bound to that variable computed to 0 and the
  submenu collapsed to zero height. Switched SubContent to a fixed
  max-h-80 (≈20rem) which is plenty for a snippet list and never collapses.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(desktop): promote prompt snippets from Radix submenu to a real Dialog

The submenu refused to open when the parent dropdown was anchored at the
bottom of the window (composer "+" button) — Radix's collision detection +
SubContent positioning was fighting us. Rather than keep tuning side /
sideOffset / collisionPadding / max-h until something stuck, replace the
DropdownMenuSub with a clicked DropdownMenuItem that opens a proper
Dialog.

Side benefits over the submenu:
- Each snippet gets a description line, so a glance is enough to pick one.
- Focus management is handled by Dialog automatically.
- Easy to grow (search, custom user snippets, categories) without
  another round of Radix positioning bugs.

Also extract types/interfaces to the bottom of the file per workspace
convention.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(desktop): move cron 'New cron' button off the top bar into the body

Reverses the previous direction on cron empty-state dedup. The body
button is more discoverable for first-time users (it's anchored next to
the "No scheduled jobs yet" copy that explains the feature) and frees
the top bar from a global CTA that wasn't pulling its weight.

- Empty (zero jobs): EmptyState renders the "Create first cron" button
  again, like the original design.
- Empty (search filtered out all jobs): no button, just "Try a broader
  search query" copy.
- Has jobs: small inline header above the list shows `N/M active` plus
  a single "New cron" button (right-aligned). The rows themselves
  already cover edit/pause/trigger/delete, so this is the only "create"
  affordance.

Also drop the dead `<div className="hidden">…</div>` enabledCount line
the previous patch left behind; the count is now visible in the new
header instead of hidden.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(desktop): address Copilot review on PR 37536

- sessions-settings: guard the WHOLE bridge call rather than chaining
  `?.settings.foo().then(...)` — the latter throws when
  `window.hermesDesktop` is undefined (non-Electron / Vitest contexts)
  because the chain short-circuits to `undefined.then(...)`.
- file_operations: drop `Path.unlink(missing_ok=True)` (Py>=3.8) so the
  generated delete snippet still works on remote backends running
  Python 3.7. The existing FileNotFoundError handler covers the same
  case and works back to 3.4.
- ansi.test.ts: add focused Vitest coverage for the SGR parser
  (basic/bright colors, bold toggles, default-fg reset, coalescing,
  256-color / truecolor arg consumption, non-SGR CSI drop, empty SGR
  full-reset) so future refactors can't silently regress terminal
  rendering.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(desktop/updates): swallow refreshDesktopVersion bridge errors

`refreshDesktopVersion()` is called best-effort with `void` from
`checkUpdates()`, `startUpdatePoller()`, and the window focus handler.
If the IPC bridge rejects (main process shutting down during reload,
bridge not yet ready on first paint), the rejection surfaces as an
unhandled promise rejection in the renderer. Wrap the call in try/catch
and return null on failure so callers can keep the existing
fire-and-forget pattern safely.

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore(desktop): drop work duplicated by other in-flight PRs

- composer/text-utils.ts: revert paste-image dedupe — PR #37596
  ships the same fix with a cleaner content-key approach and a
  Vitest file (text-utils.test.ts). Letting that PR own the change.
- docs/desktop-i18n-rfc.md: delete the i18n scoping RFC — PR #37568
  has already shipped a working i18n surface (homegrown nanostores
  `t()` helper over en/zh dictionaries), so the RFC's framework
  recommendation (`react-intl`) is now obsolete and would just
  contradict the implementation that's actually landing.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Austin Pickett
2026-06-02 16:33:22 -04:00
committed by GitHub
parent 31c40c72c0
commit ac76bbe21f
38 changed files with 1594 additions and 2183 deletions

View File

@ -111,15 +111,28 @@ npm run test:desktop:all
Boot logs land in `HERMES_HOME/logs/desktop.log` (includes backend output and recent Python tracebacks) — check it first if the app reports a boot failure.
**macOS / Linux:**
```bash
# Force a clean first-launch setup
rm "$HOME/.hermes/hermes-agent/.hermes-bootstrap-complete" # macOS/Linux
rm "$HOME/.hermes/hermes-agent/.hermes-bootstrap-complete"
# Rebuild a broken Python venv
rm -rf "$HOME/.hermes/hermes-agent/venv" # macOS/Linux
# Reset a stuck macOS microphone prompt
rm -rf "$HOME/.hermes/hermes-agent/venv"
# Reset a stuck macOS microphone prompt (macOS only)
tccutil reset Microphone com.nousresearch.hermes
```
**Windows (PowerShell):**
```powershell
# Force a clean first-launch setup
Remove-Item "$env:LOCALAPPDATA\hermes\hermes-agent\.hermes-bootstrap-complete"
# Rebuild a broken Python venv
Remove-Item -Recurse -Force "$env:LOCALAPPDATA\hermes\hermes-agent\venv"
```
> The default Hermes home on Windows is `%LOCALAPPDATA%\hermes`. Set the `HERMES_HOME` env var if you've relocated it.
---
## Community

View File

@ -535,6 +535,39 @@ function openExternalUrl(rawUrl) {
return false
}
// `file://` URLs come from the artifacts panel (the renderer can't open
// them itself because Chromium blocks file:// navigation from the app
// origin). Hand them to `shell.openPath`, which dispatches to the OS
// file association. If the OS can't open it (`error` is a non-empty
// string), fall back to revealing the file in the system file manager.
if (parsed.protocol === 'file:') {
let localPath
try {
localPath = fileURLToPath(parsed.toString())
} catch {
return false
}
void shell
.openPath(localPath)
.then(error => {
if (!error) {
return
}
rememberLog(`[file] openPath failed: ${error}; revealing in folder instead`)
try {
shell.showItemInFolder(localPath)
} catch (revealError) {
rememberLog(`[file] showItemInFolder failed: ${revealError.message}`)
}
})
.catch(error => rememberLog(`[file] openPath rejected: ${error.message}`))
return true
}
if (!['http:', 'https:', 'mailto:'].includes(parsed.protocol)) {
return false
}
@ -1526,10 +1559,18 @@ function resolveRendererIndex() {
}
function resolveHermesCwd() {
// In a packaged build, `process.cwd()` resolves to the install root (e.g.
// `…/win-unpacked` on Windows or `/Applications/Hermes.app/Contents/...`
// on macOS). Sessions spawned there leave files inside the app bundle
// and bewilder users when "where did my files go?" is the install dir.
// The user-configurable default project directory wins over everything,
// followed by env hints (only honored when packaged if they point at a
// real directory), then the home dir.
const candidates = [
readDefaultProjectDir(),
process.env.HERMES_DESKTOP_CWD,
process.env.INIT_CWD,
process.cwd(),
IS_PACKAGED ? null : process.cwd(),
!IS_PACKAGED ? SOURCE_REPO_ROOT : null,
app.getPath('home')
]
@ -1543,6 +1584,48 @@ function resolveHermesCwd() {
return app.getPath('home')
}
// Persisted "Default project directory" — surfaced as a setting in the
// renderer (see app/settings/sessions-settings.tsx). Stored as JSON in
// userData so it survives self-updates without bleeding into the new
// install. `null` means "no preference, fall back to the usual chain".
const DEFAULT_PROJECT_DIR_CONFIG_FILENAME = 'project-dir.json'
function defaultProjectDirConfigPath() {
return path.join(app.getPath('userData'), DEFAULT_PROJECT_DIR_CONFIG_FILENAME)
}
function readDefaultProjectDir() {
try {
const raw = fs.readFileSync(defaultProjectDirConfigPath(), 'utf8')
const parsed = JSON.parse(raw)
if (parsed && typeof parsed.dir === 'string' && parsed.dir.trim()) {
const resolved = path.resolve(parsed.dir)
if (directoryExists(resolved)) {
return resolved
}
}
} catch {
// Missing / unreadable / malformed → fall through to the rest of the
// candidate chain.
}
return null
}
function writeDefaultProjectDir(dir) {
const target = defaultProjectDirConfigPath()
const payload = dir ? JSON.stringify({ dir: path.resolve(dir) }, null, 2) : JSON.stringify({}, null, 2)
try {
fs.mkdirSync(path.dirname(target), { recursive: true })
fs.writeFileSync(target, payload, 'utf8')
} catch (error) {
rememberLog(`[settings] write default project dir failed: ${error.message}`)
}
}
function createPythonBackend(root, label, dashboardArgs, options = {}) {
const python = findPythonForRoot(root)
if (!python) return null
@ -2702,6 +2785,28 @@ function installContextMenu(window) {
)
}
// Spell-check suggestions for the misspelled word under the caret.
// Chromium surfaces them on `params.dictionarySuggestions`; we offer the
// top 5 plus a "Add to dictionary" affordance.
const suggestions = Array.isArray(params.dictionarySuggestions) ? params.dictionarySuggestions : []
if (isEditable && params.misspelledWord && suggestions.length > 0) {
if (template.length) template.push({ type: 'separator' })
for (const suggestion of suggestions.slice(0, 5)) {
template.push({
label: suggestion,
click: () => window.webContents.replaceMisspelling(suggestion)
})
}
template.push({ type: 'separator' })
template.push({
label: 'Add to dictionary',
click: () => window.webContents.session.addWordToSpellCheckerDictionary(params.misspelledWord)
})
}
if (hasSelection || isEditable) {
if (template.length) template.push({ type: 'separator' })
if (isEditable) {
@ -3497,6 +3602,45 @@ ipcMain.handle('hermes:openExternal', (_event, url) => {
}
})
// User-configurable default project directory. The renderer reads this on
// settings mount and seeds the value into the picker; writing back persists
// it via writeDefaultProjectDir so resolveHermesCwd picks it up on the next
// session spawn (no app restart needed).
ipcMain.handle('hermes:setting:defaultProjectDir:get', async () => ({
dir: readDefaultProjectDir(),
defaultLabel: path.join(app.getPath('home'), 'hermes-projects')
}))
ipcMain.handle('hermes:setting:defaultProjectDir:set', async (_event, dir) => {
const next = typeof dir === 'string' && dir.trim() ? dir.trim() : null
if (next) {
try {
fs.mkdirSync(next, { recursive: true })
} catch (error) {
throw new Error(`Could not create directory: ${error.message}`)
}
}
writeDefaultProjectDir(next)
return { dir: next }
})
ipcMain.handle('hermes:setting:defaultProjectDir:pick', async () => {
const result = await dialog.showOpenDialog({
title: 'Choose default project directory',
properties: ['openDirectory', 'createDirectory'],
defaultPath: readDefaultProjectDir() || app.getPath('home')
})
if (result.canceled || result.filePaths.length === 0) {
return { canceled: true, dir: null }
}
return { canceled: false, dir: result.filePaths[0] }
})
ipcMain.handle('hermes:fetchLinkTitle', (_event, url) => fetchLinkTitle(url))
ipcMain.handle('hermes:logs:reveal', async () => {
@ -3806,6 +3950,7 @@ app.whenReady().then(() => {
installMediaPermissions()
registerMediaProtocol()
ensureWslWindowsFonts()
configureSpellChecker()
createWindow()
app.on('activate', () => {
@ -3813,6 +3958,29 @@ app.whenReady().then(() => {
})
})
// Seed Chromium's spellchecker with the system locale (falling back to en-US).
// On macOS Electron uses the native spellchecker which ignores this list, but
// on Windows/Linux Chromium downloads Hunspell dictionaries on demand and
// won't enable any without an explicit language.
function configureSpellChecker() {
try {
const defaultSession = session.defaultSession
if (!defaultSession || typeof defaultSession.setSpellCheckerLanguages !== 'function') {
return
}
const available = defaultSession.availableSpellCheckerLanguages || []
const locale = (app.getLocale && app.getLocale()) || 'en-US'
const candidates = [locale, locale.split('-')[0], 'en-US', 'en']
const chosen = candidates.find(lang => available.includes(lang)) || 'en-US'
defaultSession.setSpellCheckerLanguages([chosen])
} catch (error) {
rememberLog(`Spellchecker setup failed: ${error.message}`)
}
}
app.on('before-quit', () => {
// Quitting mid-install should stop the installer, not orphan it.
if (bootstrapAbortController) {

View File

@ -31,6 +31,11 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
setPreviewShortcutActive: active => ipcRenderer.send('hermes:previewShortcutActive', Boolean(active)),
openExternal: url => ipcRenderer.invoke('hermes:openExternal', url),
fetchLinkTitle: url => ipcRenderer.invoke('hermes:fetchLinkTitle', url),
settings: {
getDefaultProjectDir: () => ipcRenderer.invoke('hermes:setting:defaultProjectDir:get'),
setDefaultProjectDir: dir => ipcRenderer.invoke('hermes:setting:defaultProjectDir:set', dir),
pickDefaultProjectDir: () => ipcRenderer.invoke('hermes:setting:defaultProjectDir:pick')
},
revealLogs: () => ipcRenderer.invoke('hermes:logs:reveal'),
getRecentLogs: () => ipcRenderer.invoke('hermes:logs:recent'),
readDir: dirPath => ipcRenderer.invoke('hermes:fs:readDir', dirPath),

View File

@ -3,8 +3,11 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="/apple-touch-icon.png" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<meta name="color-scheme" content="light dark" />
<meta name="theme-color" content="#0a0a0a" />
<link rel="icon" type="image/png" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="shortcut icon" href="/apple-touch-icon.png" />
<title>Hermes</title>
</head>
<body>

View File

@ -50,6 +50,7 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hermes/shared": "file:../shared",
"@icons-pack/react-simple-icons": "^13.13.0",
"@nanostores/react": "^1.1.0",
"@nous-research/ui": "^0.13.0",
"@radix-ui/react-slot": "^1.2.4",

View File

@ -1,14 +1,20 @@
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Clipboard, FileText, FolderOpen, type IconComponent, ImageIcon, Link, MessageSquareText } from '@/lib/icons'
@ -17,6 +23,24 @@ import { cn } from '@/lib/utils'
import { GHOST_ICON_BTN } from './controls'
import type { ChatBarState } from './types'
const PROMPT_SNIPPETS: readonly PromptSnippet[] = [
{
description: 'Audit the current change for regressions, dropped edge cases, and missing tests.',
label: 'Code review',
text: 'Please review this for bugs, regressions, and missing tests.'
},
{
description: 'Outline an approach before touching code so the diff stays focused.',
label: 'Implementation plan',
text: 'Please make a concise implementation plan before changing code.'
},
{
description: 'Walk through how the selected code works and link to the key files.',
label: 'Explain this',
text: 'Please explain how this works and point me to the key files.'
}
]
export function ContextMenu({
state,
onInsertText,
@ -25,81 +49,114 @@ export function ContextMenu({
onPickFiles,
onPickFolders,
onPickImages
}: {
state: ChatBarState
onInsertText: (text: string) => void
onOpenUrlDialog: () => void
onPasteClipboardImage?: () => void
onPickFiles?: () => void
onPickFolders?: () => void
onPickImages?: () => void
}) {
}: ContextMenuProps) {
// Prompt snippets used to be a Radix submenu. That submenu didn't open
// reliably when the parent menu was positioned at the bottom of the
// window (composer "+" anchor), so we promoted it to a real Dialog —
// easier to grow with search / descriptions, and no positioning math.
const [snippetsOpen, setSnippetsOpen] = useState(false)
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label={state.tools.label}
className={cn(
GHOST_ICON_BTN,
'data-[state=open]:bg-(--chrome-action-hover) data-[state=open]:text-foreground'
)}
disabled={!state.tools.enabled}
size="icon"
title={state.tools.label}
type="button"
variant="ghost"
>
<Codicon name="add" size="1rem" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-60" side="top" sideOffset={10}>
<DropdownMenuLabel className="text-[0.7rem] font-medium uppercase tracking-wide text-muted-foreground/85">
Attach
</DropdownMenuLabel>
<ContextMenuItem disabled={!onPickFiles} icon={FileText} onSelect={onPickFiles}>
Files
</ContextMenuItem>
<ContextMenuItem disabled={!onPickFolders} icon={FolderOpen} onSelect={onPickFolders}>
Folder
</ContextMenuItem>
<ContextMenuItem disabled={!onPickImages} icon={ImageIcon} onSelect={onPickImages}>
Images
</ContextMenuItem>
<ContextMenuItem disabled={!onPasteClipboardImage} icon={Clipboard} onSelect={onPasteClipboardImage}>
Paste image
</ContextMenuItem>
<ContextMenuItem icon={Link} onSelect={onOpenUrlDialog}>
URL
</ContextMenuItem>
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label={state.tools.label}
className={cn(
GHOST_ICON_BTN,
'data-[state=open]:bg-(--chrome-action-hover) data-[state=open]:text-foreground'
)}
disabled={!state.tools.enabled}
size="icon"
title={state.tools.label}
type="button"
variant="ghost"
>
<Codicon name="add" size="1rem" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-60" side="top" sideOffset={10}>
<DropdownMenuLabel className="text-[0.7rem] font-medium uppercase tracking-wide text-muted-foreground/85">
Attach
</DropdownMenuLabel>
<ContextMenuItem disabled={!onPickFiles} icon={FileText} onSelect={onPickFiles}>
Files
</ContextMenuItem>
<ContextMenuItem disabled={!onPickFolders} icon={FolderOpen} onSelect={onPickFolders}>
Folder
</ContextMenuItem>
<ContextMenuItem disabled={!onPickImages} icon={ImageIcon} onSelect={onPickImages}>
Images
</ContextMenuItem>
<ContextMenuItem disabled={!onPasteClipboardImage} icon={Clipboard} onSelect={onPasteClipboardImage}>
Paste image
</ContextMenuItem>
<ContextMenuItem icon={Link} onSelect={onOpenUrlDialog}>
URL
</ContextMenuItem>
<DropdownMenuSeparator />
<DropdownMenuSeparator />
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<MessageSquareText />
<span>Prompt snippets</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-72">
{[
{ label: 'Code review', text: 'Please review this for bugs, regressions, and missing tests.' },
{ label: 'Implementation plan', text: 'Please make a concise implementation plan before changing code.' },
{ label: 'Explain this', text: 'Please explain how this works and point me to the key files.' }
].map(snippet => (
<ContextMenuItem icon={MessageSquareText} key={snippet.label} onSelect={() => onInsertText(snippet.text)}>
{snippet.label}
</ContextMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
<ContextMenuItem icon={MessageSquareText} onSelect={() => setSnippetsOpen(true)}>
Prompt snippets
</ContextMenuItem>
<DropdownMenuSeparator />
<DropdownMenuSeparator />
<div className="px-2 py-1 text-[0.7rem] text-muted-foreground/80">
Tip: type <kbd className="rounded bg-muted/70 px-1 py-px font-mono text-[0.65rem]">@</kbd> to reference files
inline.
</div>
</DropdownMenuContent>
</DropdownMenu>
<div className="px-2 py-1 text-[0.7rem] text-muted-foreground/80">
Tip: type <kbd className="rounded bg-muted/70 px-1 py-px font-mono text-[0.65rem]">@</kbd> to reference files
inline.
</div>
</DropdownMenuContent>
</DropdownMenu>
<PromptSnippetsDialog
onInsertText={onInsertText}
onOpenChange={setSnippetsOpen}
open={snippetsOpen}
snippets={PROMPT_SNIPPETS}
/>
</>
)
}
function PromptSnippetsDialog({
onInsertText,
onOpenChange,
open,
snippets
}: PromptSnippetsDialogProps) {
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent className="max-w-md gap-3">
<DialogHeader>
<DialogTitle>Prompt snippets</DialogTitle>
<DialogDescription>Pick a starter prompt to drop into the composer.</DialogDescription>
</DialogHeader>
<ul className="grid gap-1">
{snippets.map(snippet => (
<li key={snippet.label}>
<button
className="group/snippet flex w-full cursor-pointer items-start gap-2.5 rounded-md border border-transparent px-2.5 py-2 text-left transition-colors hover:border-(--ui-stroke-tertiary) hover:bg-(--ui-control-hover-background) focus-visible:border-(--ui-stroke-tertiary) focus-visible:bg-(--ui-control-hover-background) focus-visible:outline-none"
onClick={() => {
onInsertText(snippet.text)
onOpenChange(false)
}}
type="button"
>
<MessageSquareText className="mt-0.5 size-3.5 shrink-0 text-(--ui-text-tertiary) group-hover/snippet:text-foreground" />
<span className="grid min-w-0 gap-0.5">
<span className="text-sm font-medium text-foreground">{snippet.label}</span>
<span className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{snippet.description}
</span>
</span>
</button>
</li>
))}
</ul>
</DialogContent>
</Dialog>
)
}
@ -108,12 +165,7 @@ export function ContextMenuItem({
disabled,
icon: Icon,
onSelect
}: {
children: string
disabled?: boolean
icon: IconComponent
onSelect?: () => void
}) {
}: ContextMenuItemProps) {
return (
<DropdownMenuItem disabled={disabled} onSelect={onSelect}>
<Icon />
@ -121,3 +173,33 @@ export function ContextMenuItem({
</DropdownMenuItem>
)
}
interface ContextMenuItemProps {
children: string
disabled?: boolean
icon: IconComponent
onSelect?: () => void
}
interface ContextMenuProps {
onInsertText: (text: string) => void
onOpenUrlDialog: () => void
onPasteClipboardImage?: () => void
onPickFiles?: () => void
onPickFolders?: () => void
onPickImages?: () => void
state: ChatBarState
}
interface PromptSnippet {
description: string
label: string
text: string
}
interface PromptSnippetsDialogProps {
onInsertText: (text: string) => void
onOpenChange: (open: boolean) => void
open: boolean
snippets: readonly PromptSnippet[]
}

View File

@ -1024,6 +1024,8 @@ export function ChatBar({
<div className={cn('relative', stacked ? 'w-full' : 'min-w-(--composer-input-inline-min-width) flex-1')}>
<div
aria-label="Message"
autoCorrect="off"
autoCapitalize="off"
className={cn(
'min-h-(--composer-input-min-height) max-h-(--composer-input-max-height) overflow-y-auto bg-transparent pb-1 pr-1 pt-1 leading-normal text-foreground outline-none disabled:cursor-not-allowed',
'empty:before:content-[attr(data-placeholder)] empty:before:text-muted-foreground/60',
@ -1045,6 +1047,7 @@ export function ChatBar({
onPaste={handlePaste}
ref={editorRef}
role="textbox"
spellCheck="true"
suppressContentEditableWarning
/>
{/* assistant-ui requires ComposerPrimitive.Input somewhere in the tree

View File

@ -97,6 +97,17 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP
: 'border-r border-(--ui-stroke-quaternary) text-(--ui-text-tertiary) [--tab-bg:var(--ui-sidebar-surface-background)] hover:bg-(--chrome-action-hover) hover:text-foreground'
)}
key={tab.id}
// Middle-click closes the tab, matching browser/IDE muscle
// memory. `onMouseDown` swallows the middle-button press so
// Chromium doesn't switch into autoscroll mode.
onAuxClick={event => {
if (event.button !== 1) return
event.preventDefault()
closeRightRailTab(tab.id)
}}
onMouseDown={event => {
if (event.button === 1) event.preventDefault()
}}
>
{active && (
<span aria-hidden="true" className="absolute inset-x-0 top-0 h-px bg-(--ui-stroke-primary)" />

View File

@ -67,6 +67,12 @@ import { VirtualSessionList } from './virtual-session-list'
const VIRTUALIZE_THRESHOLD = 25
// Render the modifier key the user actually presses on this platform. The
// global accelerator is bound to both Cmd+N (macOS) and Ctrl+N (everywhere
// else) in desktop-controller.tsx, but the hint should match muscle memory.
const NEW_SESSION_KBD: readonly string[] =
typeof navigator !== 'undefined' && navigator.platform.toLowerCase().includes('mac') ? ['⌘', 'N'] : ['Ctrl', 'N']
const SIDEBAR_NAV: SidebarNavItem[] = [
{
id: 'new-session',
@ -438,7 +444,7 @@ export function ChatSidebar({
<>
<span className="min-w-0 flex-1 truncate max-[46.25rem]:hidden">{item.label}</span>
{item.id === 'new-session' && (
<KbdGroup className="ml-auto max-[46.25rem]:hidden" keys={['⇧', 'N']} />
<KbdGroup className="ml-auto max-[46.25rem]:hidden" keys={[...NEW_SESSION_KBD]} />
)}
</>
)}
@ -540,23 +546,28 @@ export function ChatSidebar({
forceEmptyState={showSessionSkeletons}
groups={agentsGrouped ? agentGroups : undefined}
headerAction={
<Button
aria-label={agentsGrouped ? 'Show sessions as a single list' : 'Group sessions by workspace'}
className={cn(
'cursor-pointer text-(--ui-text-tertiary) opacity-70 hover:bg-(--ui-control-hover-background) hover:text-foreground hover:opacity-100 focus-visible:opacity-100',
agentsGrouped && 'bg-(--ui-control-active-background) text-foreground opacity-100'
)}
onClick={event => {
event.stopPropagation()
setSidebarRecentsOpen(true)
setSidebarAgentsGrouped(!agentsGrouped)
}}
size="icon-xs"
title={agentsGrouped ? 'Ungroup sessions' : 'Group by workspace'}
variant="ghost"
>
<Codicon name={agentsGrouped ? 'list-unordered' : 'root-folder'} size="0.75rem" />
</Button>
// Grouping operates on unpinned recents; if everything is
// pinned the toggle does nothing visible, so hide it to avoid
// a phantom click target.
agentSessions.length > 0 ? (
<Button
aria-label={agentsGrouped ? 'Show sessions as a single list' : 'Group sessions by workspace'}
className={cn(
'cursor-pointer text-(--ui-text-tertiary) opacity-70 hover:bg-(--ui-control-hover-background) hover:text-foreground hover:opacity-100 focus-visible:opacity-100',
agentsGrouped && 'bg-(--ui-control-active-background) text-foreground opacity-100'
)}
onClick={event => {
event.stopPropagation()
setSidebarRecentsOpen(true)
setSidebarAgentsGrouped(!agentsGrouped)
}}
size="icon-xs"
title={agentsGrouped ? 'Ungroup sessions' : 'Group by workspace'}
variant="ghost"
>
<Codicon name={agentsGrouped ? 'list-unordered' : 'root-folder'} size="0.75rem" />
</Button>
) : null
}
label="Sessions"
labelMeta={countLabel(agentSessions.length, knownSessionTotal)}
@ -633,7 +644,7 @@ function SidebarPinnedEmptyState() {
<span className="grid w-3.5 shrink-0 place-items-center text-(--ui-text-quaternary)">
<Codicon name="pin" size="0.75rem" />
</span>
<span>Shift click to pin a chat</span>
<span>Shift-click a chat to pin · drag to reorder</span>
</div>
)
}

View File

@ -428,14 +428,6 @@ export function CronView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...pro
return (
<PageSearchShell
{...props}
filters={
<div className="flex flex-wrap items-center justify-center gap-2">
<Button onClick={() => setEditor({ mode: 'create' })} size="sm">
<Codicon name="add" />
New cron
</Button>
</div>
}
onSearchChange={setQuery}
searchPlaceholder="Search cron jobs..."
searchTrailingAction={
@ -457,6 +449,10 @@ export function CronView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...pro
{!jobs ? (
<PageLoader label="Loading cron jobs..." />
) : visibleJobs.length === 0 ? (
// Empty state owns the primary "create" CTA — we used to also have
// one in the filters bar but it was redundant. Only show the button
// when there are zero jobs total; the search-empty case ("No
// matches") just asks the user to broaden their query.
<EmptyState
actionLabel={totalCount === 0 ? 'Create first cron' : undefined}
description={
@ -469,6 +465,19 @@ export function CronView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...pro
/>
) : (
<div className="h-full overflow-y-auto px-4 py-3">
{/* Inline header replaces the old top-bar "New cron" button. We
still need a single, always-visible affordance to add a job
when the list is non-empty (rows themselves only expose
edit/pause/trigger/delete). */}
<div className="mb-2 flex items-center justify-between">
<span className="text-[0.7rem] uppercase tracking-wide text-muted-foreground">
{enabledCount}/{totalCount} active
</span>
<Button onClick={() => setEditor({ mode: 'create' })} size="sm">
<Codicon name="add" />
New cron
</Button>
</div>
<div className="divide-y divide-border/40 rounded-lg border border-border/40 bg-background/70">
{visibleJobs.map(job => (
<CronJobRow
@ -484,8 +493,6 @@ export function CronView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...pro
</div>
</div>
)}
<div className="hidden">{totalCount === 0 ? 'No scheduled jobs' : `${enabledCount}/${totalCount} active`}</div>
<CronEditorDialog editor={editor} onClose={() => setEditor({ mode: 'closed' })} onSave={handleEditorSave} />
<Dialog onOpenChange={open => !open && !deleting && setPendingDelete(null)} open={pendingDelete !== null}>

View File

@ -372,14 +372,22 @@ export function DesktopController() {
target instanceof HTMLTextAreaElement ||
target instanceof HTMLSelectElement
if (editing || event.defaultPrevented || event.repeat || event.altKey || event.ctrlKey || event.metaKey) {
if (event.defaultPrevented || event.repeat || event.altKey || event.code !== 'KeyN') {
return
}
if (event.shiftKey && event.code === 'KeyN') {
event.preventDefault()
startFreshSessionDraft()
// Two accelerators for "new session":
// - Cmd/Ctrl+N (browser-like, works while typing in any input)
// - Shift+N (single-key, only when no input is focused)
const accelerator = event.metaKey || event.ctrlKey
const singleKey = !accelerator && !editing && event.shiftKey
if (!accelerator && !singleKey) {
return
}
event.preventDefault()
startFreshSessionDraft()
}
window.addEventListener('keydown', onKeyDown)

View File

@ -21,6 +21,8 @@ import { useRouteEnumParam } from '../hooks/use-route-enum-param'
import { PageSearchShell } from '../page-search-shell'
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
import { PlatformAvatar } from './platform-icon'
interface MessagingViewProps extends React.ComponentProps<'section'> {
setStatusbarItemGroup?: SetStatusbarItemGroup
}
@ -39,29 +41,6 @@ const STATE_LABELS: Record<string, string> = {
startup_failed: 'Startup failed'
}
const PLATFORM_TINTS: Record<string, string> = {
telegram: 'bg-sky-500/15 text-sky-600 dark:text-sky-300',
discord: 'bg-indigo-500/15 text-indigo-600 dark:text-indigo-300',
slack: 'bg-violet-500/15 text-violet-600 dark:text-violet-300',
mattermost: 'bg-blue-500/15 text-blue-600 dark:text-blue-300',
matrix: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-300',
signal: 'bg-cyan-500/15 text-cyan-600 dark:text-cyan-300',
whatsapp: 'bg-green-500/15 text-green-600 dark:text-green-300',
bluebubbles: 'bg-blue-500/15 text-blue-600 dark:text-blue-300',
homeassistant: 'bg-teal-500/15 text-teal-600 dark:text-teal-300',
email: 'bg-amber-500/15 text-amber-600 dark:text-amber-300',
sms: 'bg-rose-500/15 text-rose-600 dark:text-rose-300',
dingtalk: 'bg-blue-500/15 text-blue-600 dark:text-blue-300',
feishu: 'bg-cyan-500/15 text-cyan-600 dark:text-cyan-300',
wecom: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-300',
wecom_callback: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-300',
weixin: 'bg-green-500/15 text-green-600 dark:text-green-300',
qqbot: 'bg-amber-500/15 text-amber-600 dark:text-amber-300',
yuanbao: 'bg-orange-500/15 text-orange-600 dark:text-orange-300',
api_server: 'bg-slate-500/15 text-slate-600 dark:text-slate-300',
webhook: 'bg-zinc-500/15 text-zinc-600 dark:text-zinc-300'
}
const PILL_TONE: Record<StatusTone, string> = {
good: 'bg-primary/10 text-primary',
muted: 'bg-muted text-muted-foreground',
@ -442,19 +421,6 @@ function PlatformRow({
)
}
function PlatformAvatar({ platformId, platformName }: { platformId: string; platformName: string }) {
return (
<span
className={cn(
'inline-flex size-6 shrink-0 items-center justify-center rounded-md text-[length:var(--conversation-caption-font-size)] font-medium',
PLATFORM_TINTS[platformId] || 'bg-(--ui-bg-tertiary) text-(--ui-text-tertiary)'
)}
>
{platformName.charAt(0).toUpperCase()}
</span>
)
}
function PlatformDetail({
edits,
onClear,

View File

@ -0,0 +1,97 @@
import type { ComponentType, SVGProps } from 'react'
import {
SiApple,
SiBilibili,
SiDiscord,
SiGmail,
SiHomeassistant,
SiMatrix,
SiMattermost,
SiQq,
SiSignal,
SiTelegram,
SiWechat,
SiWhatsapp
} from '@icons-pack/react-simple-icons'
import { Globe, Link as LinkIcon, MessageSquareText } from '@/lib/icons'
import { cn } from '@/lib/utils'
// We render simpleicons.org brand glyphs for platforms whose owners publish a
// usable mark (telegram, discord, matrix, ...). A few brands — Slack, Dingtalk,
// Feishu, WeCom — have been removed from Simple Icons at the brand owner's
// request, so we fall back to a colored letter monogram for those.
//
// `iconColor` is the brand's hex from simpleicons.org so we can paint each
// glyph in its native color on top of a soft tint. The fallback monogram uses
// the same hex to keep visual consistency.
type IconKind = 'brand' | 'generic'
interface PlatformIconSpec {
Icon: ComponentType<SVGProps<SVGSVGElement>>
color: string
kind: IconKind
}
const PLATFORM_ICONS: Record<string, PlatformIconSpec> = {
telegram: { Icon: SiTelegram, color: '#26A5E4', kind: 'brand' },
discord: { Icon: SiDiscord, color: '#5865F2', kind: 'brand' },
// Slack removed from Simple Icons by Salesforce request — letter monogram.
mattermost: { Icon: SiMattermost, color: '#0058CC', kind: 'brand' },
matrix: { Icon: SiMatrix, color: '#000000', kind: 'brand' },
signal: { Icon: SiSignal, color: '#3A76F0', kind: 'brand' },
whatsapp: { Icon: SiWhatsapp, color: '#25D366', kind: 'brand' },
bluebubbles: { Icon: SiApple, color: '#0BD318', kind: 'brand' },
homeassistant: { Icon: SiHomeassistant, color: '#18BCF2', kind: 'brand' },
email: { Icon: SiGmail, color: '#EA4335', kind: 'brand' },
sms: { Icon: MessageSquareText, color: '#F43F5E', kind: 'generic' },
webhook: { Icon: LinkIcon, color: '#71717A', kind: 'generic' },
api_server: { Icon: Globe, color: '#64748B', kind: 'generic' },
weixin: { Icon: SiWechat, color: '#07C160', kind: 'brand' },
qqbot: { Icon: SiQq, color: '#EB1923', kind: 'brand' },
yuanbao: { Icon: SiBilibili, color: '#FB7299', kind: 'brand' }
}
interface PlatformAvatarProps {
platformId: string
platformName: string
className?: string
}
export function PlatformAvatar({ className, platformId, platformName }: PlatformAvatarProps) {
const spec = PLATFORM_ICONS[platformId]
const baseClass = cn(
'inline-grid size-6 shrink-0 place-items-center rounded-md text-[length:var(--conversation-caption-font-size)] font-medium',
className
)
if (!spec) {
return (
<span
aria-hidden="true"
className={cn(baseClass, 'bg-(--ui-bg-tertiary) text-(--ui-text-tertiary)')}
>
{platformName.charAt(0).toUpperCase()}
</span>
)
}
const { Icon, color } = spec
return (
<span
aria-hidden="true"
className={baseClass}
style={{
// 16% tint of the brand color so the glyph reads against any surface
// without the avatar dominating the row.
backgroundColor: `color-mix(in srgb, ${color} 16%, transparent)`,
color
}}
>
<Icon className="size-3.5" />
</span>
)
}

View File

@ -28,10 +28,16 @@ export function PageSearchShell({
{...props}
className={cn('flex h-full min-w-0 flex-col overflow-hidden bg-(--ui-chat-surface-background)', className)}
>
<div className="relative z-10 grid gap-2 border-b border-(--ui-stroke-tertiary) px-3 py-2.5">
{/*
This header sits in the titlebar row, so it overlaps the OS window-drag
region painted by the shell. Without `-webkit-app-region: no-drag` on
the search row, mousedown on the input gets intercepted as a window-
drag start and the input never receives focus (visible as "I can't
click the search box" on the messaging/cron/etc pages).
*/}
<div className="relative z-10 grid gap-2 border-b border-(--ui-stroke-tertiary) px-3 py-2.5 [-webkit-app-region:no-drag]">
{/* Reserve the top-right titlebar tools + native window-controls
footprint so the full-width search input never slides under them
(this header sits in the titlebar row at the window top). */}
footprint so the full-width search input never slides under them. */}
<div
style={{
paddingRight:

View File

@ -33,6 +33,7 @@ import {
setMessages,
setSelectedStoredSessionId,
setSessions,
setSessionsTotal,
setSessionStartedAt,
setTurnStartedAt
} from '@/store/session'
@ -689,6 +690,9 @@ export function useSessionActions({
const previousPinned = $pinnedSessionIds.get()
setSessions(prev => prev.filter(s => s.id !== storedSessionId))
// Keep $sessionsTotal in sync so the sidebar's "Load N more" footer
// doesn't keep claiming the removed row is still on the server.
setSessionsTotal(prev => Math.max(0, prev - 1))
$pinnedSessionIds.set(previousPinned.filter(id => id !== storedSessionId))
// Tear down before awaiting so the route effect can't resume the
@ -711,6 +715,7 @@ export function useSessionActions({
} catch (err) {
if (removed) {
setSessions(prev => [removed, ...prev])
setSessionsTotal(prev => prev + 1)
}
$pinnedSessionIds.set(previousPinned)
@ -763,6 +768,10 @@ export function useSessionActions({
// Soft-hide: drop from the sidebar immediately, keep the data.
setSessions(prev => prev.filter(s => s.id !== storedSessionId))
// Archived sessions are hidden by the listSessions(min_messages=1) query
// on the next refresh, so they count as "removed" for the load-more
// footer math.
setSessionsTotal(prev => Math.max(0, prev - 1))
$pinnedSessionIds.set(previousPinned.filter(id => id !== storedSessionId))
if (wasSelected) {
@ -775,6 +784,7 @@ export function useSessionActions({
} catch (err) {
if (archived) {
setSessions(prev => [archived, ...prev.filter(s => s.id !== storedSessionId)])
setSessionsTotal(prev => prev + 1)
}
$pinnedSessionIds.set(previousPinned)

View File

@ -4,7 +4,7 @@ import { type MutableRefObject, useCallback, useEffect, useRef } from 'react'
import type { ChatMessage } from '@/lib/chat-messages'
import { preserveLocalAssistantErrors } from '@/lib/chat-messages'
import { createClientSessionState } from '@/lib/chat-runtime'
import { $busy, $messages, setSessionWorking } from '@/store/session'
import { $busy, $messages, noteSessionActivity, setSessionWorking } from '@/store/session'
import type { ClientSessionState } from '../../types'
@ -140,6 +140,13 @@ export function useSessionStateCache({
}
setSessionWorking(next.storedSessionId, next.busy)
// Every state update is effectively a "still alive" heartbeat for
// streaming events. The session-store watchdog uses this to keep the
// working flag alive during long-running turns and to clear it once
// the stream goes silent.
if (next.busy) {
noteSessionActivity(next.storedSessionId)
}
syncSessionStateToView(sessionId, next)
return next

View File

@ -1,5 +1,5 @@
import { useStore } from '@nanostores/react'
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import { CheckCircle2, ExternalLink, Loader2, RefreshCw, Sparkles } from '@/lib/icons'
@ -10,7 +10,8 @@ import {
$updateChecking,
$updateStatus,
checkUpdates,
openUpdatesWindow
openUpdatesWindow,
refreshDesktopVersion
} from '@/store/updates'
import { ListRow, SectionHeading, SettingsContent } from './primitives'
@ -46,6 +47,14 @@ export function AboutSettings() {
const checking = useStore($updateChecking)
const [justChecked, setJustChecked] = useState(false)
// The version atom is loaded once at app boot, which makes About show a
// stale number after a self-update (the running binary is current, the
// displayed string is not). Re-read on mount so opening About always
// reflects the running build.
useEffect(() => {
void refreshDesktopVersion()
}, [])
const behind = status?.behind ?? 0
const supported = status?.supported !== false
const applying = apply.applying || apply.stage === 'restart'

View File

@ -22,8 +22,6 @@ import {
import { LoadingState, Pill, SectionHeading, SettingsContent } from './primitives'
import type { EnvPatch, EnvRowProps, ProviderGroup, SearchProps } from './types'
const SHOW_ADVANCED_STORAGE_KEY = 'desktop.settings.keys.show_advanced'
interface EnvActionsProps {
varKey: string
info: EnvVarInfo
@ -186,8 +184,11 @@ function EnvProviderGroup({
group: ProviderGroup
rowProps: Omit<EnvRowProps, 'varKey' | 'info'>
}) {
const [expanded, setExpanded] = useState(false)
const setCount = group.entries.filter(([, info]) => info.is_set).length
// Default-expand providers that already have at least one key set; the
// user is much more likely to be coming back to edit those than to start
// configuring a fresh provider from scratch.
const [expanded, setExpanded] = useState(setCount > 0)
return (
<div className="overflow-hidden rounded-xl bg-background/60">
@ -222,27 +223,17 @@ export function KeysSettings({ query }: SearchProps) {
const [revealed, setRevealed] = useState<Record<string, string>>({})
const [saving, setSaving] = useState<string | null>(null)
const [showAdvanced, setShowAdvanced] = useState<boolean>(() => {
try {
const stored = window.localStorage.getItem(SHOW_ADVANCED_STORAGE_KEY)
if (stored === null) {
return false
}
return stored === 'true'
} catch {
return false
}
})
// We used to hide ~80% of rows behind a global "Show advanced" toggle, but
// everything in this view is configuration-level — "advanced" was a poor
// distinction. The full list is rendered now and provider groups
// default-collapsed-unless-set keep the surface manageable.
useEffect(() => {
try {
window.localStorage.setItem(SHOW_ADVANCED_STORAGE_KEY, showAdvanced ? 'true' : 'false')
window.localStorage.removeItem('desktop.settings.keys.show_advanced')
} catch {
// Ignore persistence failures and keep in-memory preference.
// Ignore — old key cleanup is best-effort.
}
}, [showAdvanced])
}, [])
useEffect(() => {
let cancelled = false
@ -262,28 +253,21 @@ export function KeysSettings({ query }: SearchProps) {
return () => void (cancelled = true)
}, [])
const filterEnv = useCallback(
(info: EnvVarInfo, key: string, q: string, cat: string, extra?: string) => {
if (asText(info.category) !== cat) {
return false
}
const filterEnv = useCallback((info: EnvVarInfo, key: string, q: string, cat: string, extra?: string) => {
if (asText(info.category) !== cat) {
return false
}
if (!showAdvanced && Boolean(info.advanced)) {
return false
}
if (!q) {
return true
}
if (!q) {
return true
}
return (
key.toLowerCase().includes(q) ||
includesQuery(info.description, q) ||
Boolean(extra && extra.toLowerCase().includes(q))
)
},
[showAdvanced]
)
return (
key.toLowerCase().includes(q) ||
includesQuery(info.description, q) ||
Boolean(extra && extra.toLowerCase().includes(q))
)
}, [])
const providerGroups = useMemo<ProviderGroup[]>(() => {
if (!vars) {
@ -415,12 +399,6 @@ export function KeysSettings({ query }: SearchProps) {
return (
<SettingsContent>
<div className="mb-4 flex justify-end">
<Button onClick={() => setShowAdvanced(s => !s)} size="sm" variant="outline">
{showAdvanced ? 'Hide advanced' : 'Show advanced'}
</Button>
</div>
<div className="mb-6">
<SectionHeading
icon={Zap}

View File

@ -4,7 +4,7 @@ import { Button } from '@/components/ui/button'
import { deleteSession, listSessions, setSessionArchived } from '@/hermes'
import { sessionTitle } from '@/lib/chat-runtime'
import { triggerHaptic } from '@/lib/haptics'
import { Archive, ArchiveOff, Loader2, Trash2 } from '@/lib/icons'
import { Archive, ArchiveOff, FolderOpen, Loader2, Trash2 } from '@/lib/icons'
import { notify, notifyError } from '@/store/notifications'
import { setSessions } from '@/store/session'
import type { SessionInfo } from '@/types/hermes'
@ -105,6 +105,8 @@ export function SessionsSettings({ query }: SearchProps) {
return (
<SettingsContent>
<DefaultProjectDirSetting />
<SectionHeading
icon={Archive}
meta={sessions.length ? String(sessions.length) : undefined}
@ -166,3 +168,104 @@ export function SessionsSettings({ query }: SearchProps) {
</SettingsContent>
)
}
// Lets the user pin the default cwd for new sessions. Without this, packaged
// builds on Windows used to spawn sessions in the install dir (`win-unpacked`
// / Program Files), which buried any files Hermes wrote there.
function DefaultProjectDirSetting() {
const [dir, setDir] = useState<null | string>(null)
const [fallback, setFallback] = useState<string>('')
const [busy, setBusy] = useState(false)
useEffect(() => {
// The bridge is only present when running inside Electron. In a Vitest
// / Storybook / non-Electron context `window.hermesDesktop` is
// undefined, so guard the WHOLE call chain rather than chaining
// `?.settings.getDefaultProjectDir().then(...)` (the latter would
// short-circuit to `undefined.then(...)` and throw at runtime).
const settings = window.hermesDesktop?.settings
if (!settings) {
return
}
let alive = true
void settings.getDefaultProjectDir().then(result => {
if (!alive) return
setDir(result.dir)
setFallback(result.defaultLabel)
})
return () => {
alive = false
}
}, [])
const choose = useCallback(async () => {
const settings = window.hermesDesktop?.settings
if (!settings) return
setBusy(true)
try {
const picked = await settings.pickDefaultProjectDir()
if (picked.canceled || !picked.dir) {
return
}
const result = await settings.setDefaultProjectDir(picked.dir)
setDir(result.dir)
notify({ durationMs: 2_000, kind: 'success', message: 'Default project directory updated' })
} catch (err) {
notifyError(err, 'Could not update default directory')
} finally {
setBusy(false)
}
}, [])
const clear = useCallback(async () => {
const settings = window.hermesDesktop?.settings
if (!settings) return
setBusy(true)
try {
await settings.setDefaultProjectDir(null)
setDir(null)
} catch (err) {
notifyError(err, 'Could not clear default directory')
} finally {
setBusy(false)
}
}, [])
return (
<div className="mb-6">
<SectionHeading icon={FolderOpen} title="Default project directory" />
<p className="mb-2 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
New sessions start in this folder unless you pick another. Leave it unset to use your home directory.
</p>
<ListRow
action={
<div className="flex items-center gap-1.5">
<Button disabled={busy} onClick={() => void choose()} size="sm" type="button" variant="outline">
<FolderOpen className="size-3.5" />
<span>{dir ? 'Change' : 'Choose'}</span>
</Button>
{dir && (
<Button disabled={busy} onClick={() => void clear()} size="sm" type="button" variant="ghost">
Clear
</Button>
)}
</div>
}
description={dir || `Defaults to ${fallback || '~/hermes-projects'}.`}
title={dir ? dir : 'Not set'}
/>
</div>
)
}

View File

@ -54,14 +54,18 @@ export function StatusbarControls({ className, leftItems = [], items = [], ...pr
)}
{...props}
>
<div className="flex min-w-0 items-stretch gap-0.5 overflow-x-auto">
{/* `overflow-x-clip` (not `overflow-x-auto`) so a wide status item — for
example "Connecting…" on a fresh/untitled session — can't paint a
horizontal scrollbar across the bottom of the window. Items already
`truncate` their labels, so clipping is the right behavior. */}
<div className="flex min-w-0 items-stretch gap-0.5 overflow-x-clip">
{leftItems
.filter(item => !item.hidden)
.map(item => (
<StatusbarItemView item={item} key={`left:${item.id}`} navigate={navigate} />
))}
</div>
<div className="flex min-w-0 items-stretch gap-0.5 overflow-x-auto">
<div className="flex min-w-0 items-stretch gap-0.5 overflow-x-clip">
{items
.filter(item => !item.hidden)
.map(item => (

View File

@ -13,7 +13,7 @@ export const TITLEBAR_FALLBACK_WINDOW_BUTTON_X = 24
export const TITLEBAR_EDGE_INSET = 14
export const titlebarButtonClass =
'h-[var(--titlebar-control-height)] w-[var(--titlebar-control-size)] rounded-md text-muted-foreground/85 transition-colors hover:bg-(--ui-control-hover-background) hover:text-foreground'
'h-[var(--titlebar-control-height)] w-[var(--titlebar-control-size)] cursor-pointer rounded-md text-muted-foreground/85 transition-colors hover:bg-(--ui-control-hover-background) hover:text-foreground'
export const titlebarHeaderBaseClass =
'pointer-events-none relative z-3 flex h-(--titlebar-height) shrink-0 items-center justify-start gap-3 border-b border-(--ui-stroke-tertiary) bg-(--ui-chat-surface-background) px-[max(0.75rem,var(--titlebar-content-inset,0rem))]'

View File

@ -0,0 +1,34 @@
import type { FC } from 'react'
import { useMemo } from 'react'
import { ansiColorClass, hasAnsiCodes, parseAnsi } from '@/lib/ansi'
import { cn } from '@/lib/utils'
interface AnsiTextProps {
text: string
className?: string
}
/** Renders text with embedded ANSI SGR codes as colored / bold spans. Falls
* back to a plain string node when no codes are present so the parser cost
* is paid only when there's something to colorize. */
export const AnsiText: FC<AnsiTextProps> = ({ className, text }) => {
const segments = useMemo(() => (hasAnsiCodes(text) ? parseAnsi(text) : null), [text])
if (!segments) {
return <span className={className}>{text}</span>
}
return (
<span className={className}>
{segments.map((segment, index) => (
<span
className={cn(segment.bold && 'font-semibold', segment.fg && ansiColorClass(segment.fg))}
key={`ansi-${index}`}
>
{segment.text}
</span>
))}
</span>
)
}

View File

@ -48,7 +48,8 @@ import { detectTrigger, textBeforeCaret, type TriggerState } from '@/app/chat/co
import { ComposerTriggerPopover } from '@/app/chat/composer/trigger-popover'
import { extractDroppedFiles, HERMES_PATHS_MIME } from '@/app/chat/hooks/use-composer-actions'
import { ClarifyTool } from '@/components/assistant-ui/clarify-tool'
import { DirectiveContent, DirectiveText } from '@/components/assistant-ui/directive-text'
import { DirectiveContent } from '@/components/assistant-ui/directive-text'
import { UserMessageText } from '@/components/assistant-ui/user-message-text'
import { hermesDirectiveFormatter } from '@/components/assistant-ui/directive-text'
import { MarkdownText } from '@/components/assistant-ui/markdown-text'
import { VirtualizedThread } from '@/components/assistant-ui/thread-virtualizer'
@ -703,9 +704,10 @@ const UserMessage: FC<{
</span>
)}
{hasBody && (
<span className="wrap-anywhere block whitespace-pre-line">
<MessagePrimitive.Parts components={{ Text: DirectiveText }} />
</span>
// Render the user's text through a minimal markdown pipeline:
// backtick `code` and ``` fenced ``` blocks, with directive chips
// (`@file:` etc.) still resolved inside the plain-text spans.
<UserMessageText className="wrap-anywhere" text={messageText} />
)}
</>
)

View File

@ -35,7 +35,18 @@ export interface ToolView {
previewTarget?: string
rawArgs: string
rawResult: string
/** Set for tools whose output naturally contains ANSI escape codes
* (terminal/execute_code) so the renderer knows to run them through
* the ANSI parser instead of printing them as literals. */
rendersAnsi?: boolean
searchHits?: SearchResultRow[]
/** When the backend reports stderr as a separate stream (terminal /
* execute_code), the renderer shows it as its own labeled, neutrally
* tinted block under stdout — distinct from an error tone. */
stderr?: string
/** When set, the renderer uses stdout+stderr as separate sections and
* ignores the merged `detail`. */
stdout?: string
status: ToolStatus
subtitle: string
title: string
@ -1002,6 +1013,10 @@ function toolDetailText(
}
if (part.toolName === 'terminal' || part.toolName === 'execute_code') {
// Streams are split out into ToolView.stdout / ToolView.stderr by
// buildToolView so the renderer can label them separately. The merged
// fallback here is only used when the backend doesn't expose either
// stream individually.
const output = firstStringField(resultRecord, ['output', 'stdout', 'stderr'])
const lines = Array.isArray(resultRecord.lines)
@ -1209,6 +1224,18 @@ export function buildToolView(part: ToolPart, inlineDiff: string): ToolView {
const resultCount = status === 'error' ? null : toolResultCount(part, argsRecord, resultRecord)
// For shell/code tools we surface stdout and stderr as separate labeled
// streams in the renderer. Many CLIs use stderr for informational
// messages (npm progress, git hints), so we deliberately don't paint
// stderr destructively even though it's tagged.
const rendersAnsi = part.toolName === 'terminal' || part.toolName === 'execute_code'
const stdout = rendersAnsi ? firstStringField(resultRecord, ['stdout']) : ''
const stderrRaw = rendersAnsi ? firstStringField(resultRecord, ['stderr']) : ''
// Only attach stderr when the backend actually returned it as its own
// field — otherwise the merged `detail` already covers it and double-
// rendering would duplicate output.
const hasSplitStreams = rendersAnsi && (Boolean(stdout) || Boolean(stderrRaw))
return {
countLabel: resultCount ? formatCountLabel(resultCount) : undefined,
detail,
@ -1220,7 +1247,10 @@ export function buildToolView(part: ToolPart, inlineDiff: string): ToolView {
previewTarget: toolPreviewTarget(part.toolName, argsRecord, resultRecord),
rawArgs: prettyJson(part.args),
rawResult: prettyJson(part.result),
rendersAnsi: rendersAnsi || undefined,
searchHits: searchHits?.length ? searchHits : undefined,
stderr: hasSplitStreams ? stderrRaw || undefined : undefined,
stdout: hasSplitStreams ? stdout || undefined : undefined,
status,
subtitle,
title,

View File

@ -5,6 +5,7 @@ import { useStore } from '@nanostores/react'
import { createContext, type FC, type PropsWithChildren, type ReactNode, useContext, useMemo } from 'react'
import { useShallow } from 'zustand/shallow'
import { AnsiText } from '@/components/assistant-ui/ansi-text'
import { useElapsedSeconds } from '@/components/chat/activity-timer'
import { ActivityTimerText } from '@/components/chat/activity-timer-text'
import { CompactMarkdown } from '@/components/chat/compact-markdown'
@ -344,11 +345,41 @@ function ToolEntry({ part }: ToolEntryProps) {
)}
</div>
) : null
) : view.stdout || view.stderr ? (
// Stdout + stderr split: render both as labeled blocks. stderr
// is intentionally NOT painted destructive — many CLIs log
// informational output there.
<div className="max-w-full text-xs leading-relaxed text-(--ui-text-secondary)">
{view.detailLabel && <p className={TOOL_SECTION_LABEL_CLASS}>{view.detailLabel}</p>}
{view.stdout && (
<div className="space-y-0.5">
{view.stderr && <p className={TOOL_SECTION_LABEL_CLASS}>stdout</p>}
<pre className={cn(TOOL_SECTION_PRE_CLASS, 'whitespace-pre-wrap wrap-anywhere')}>
{view.rendersAnsi ? <AnsiText text={view.stdout} /> : view.stdout}
</pre>
</div>
)}
{view.stderr && (
<div className={cn('space-y-0.5', view.stdout && 'mt-1.5')}>
<p className={TOOL_SECTION_LABEL_CLASS}>stderr</p>
<pre
className={cn(
TOOL_SECTION_PRE_CLASS,
'whitespace-pre-wrap wrap-anywhere text-(--ui-text-tertiary)'
)}
>
{view.rendersAnsi ? <AnsiText text={view.stderr} /> : view.stderr}
</pre>
</div>
)}
</div>
) : (
<div className="max-w-full text-xs leading-relaxed text-(--ui-text-secondary)">
{view.detailLabel && <p className={TOOL_SECTION_LABEL_CLASS}>{view.detailLabel}</p>}
{renderDetailAsCode ? (
<pre className={cn(TOOL_SECTION_PRE_CLASS, 'whitespace-pre-wrap wrap-anywhere')}>{view.detail}</pre>
<pre className={cn(TOOL_SECTION_PRE_CLASS, 'whitespace-pre-wrap wrap-anywhere')}>
{view.rendersAnsi ? <AnsiText text={view.detail} /> : view.detail}
</pre>
) : (
<CompactMarkdown className={cn(TOOL_SECTION_SURFACE_CLASS, 'wrap-anywhere')} text={view.detail} />
)}

View File

@ -0,0 +1,150 @@
import type { FC } from 'react'
import { Fragment, useMemo } from 'react'
import { DirectiveContent } from '@/components/assistant-ui/directive-text'
import { cn } from '@/lib/utils'
// User messages should render the bare-minimum of markdown: backtick `code`
// spans and ``` fenced blocks. We deliberately don't pull in the full
// assistant Markdown pipeline (Streamdown + KaTeX + syntax highlighter)
// because user input rarely contains structured docs and the heavy pipeline
// adds a lot of runtime cost per bubble.
//
// Directive chips (`@file:`, `@image:`, ...) still resolve via DirectiveContent
// inside the plain-text segments.
interface FenceSegment {
kind: 'fence'
code: string
lang: string | null
}
interface InlineSegment {
kind: 'inline'
text: string
}
interface InlineCodeSegment {
kind: 'inline-code'
code: string
}
interface InlineTextSegment {
kind: 'inline-text'
text: string
}
type TopSegment = FenceSegment | InlineSegment
type InlineNode = InlineCodeSegment | InlineTextSegment
const FENCE_RE = /```([^\n`]*)\n([\s\S]*?)```/g
// Greedy backtick run length so ``code with `backticks` inside`` works.
const INLINE_CODE_RE = /(`+)([^`\n][\s\S]*?)\1/g
function splitFences(text: string): TopSegment[] {
const segments: TopSegment[] = []
let cursor = 0
for (const match of text.matchAll(FENCE_RE)) {
const start = match.index ?? 0
if (start > cursor) {
segments.push({ kind: 'inline', text: text.slice(cursor, start) })
}
segments.push({
kind: 'fence',
lang: (match[1] || '').trim() || null,
code: match[2] ?? ''
})
cursor = start + match[0].length
}
if (cursor < text.length) {
segments.push({ kind: 'inline', text: text.slice(cursor) })
}
return segments
}
function splitInlineCode(text: string): InlineNode[] {
const nodes: InlineNode[] = []
let cursor = 0
for (const match of text.matchAll(INLINE_CODE_RE)) {
const start = match.index ?? 0
if (start > cursor) {
nodes.push({ kind: 'inline-text', text: text.slice(cursor, start) })
}
nodes.push({ kind: 'inline-code', code: match[2] })
cursor = start + match[0].length
}
if (cursor < text.length) {
nodes.push({ kind: 'inline-text', text: text.slice(cursor) })
}
return nodes
}
interface UserMessageTextProps {
text: string
className?: string
}
export const UserMessageText: FC<UserMessageTextProps> = ({ className, text }) => {
const top = useMemo(() => splitFences(text), [text])
return (
<span className={cn('block', className)} data-slot="aui_user-message-text">
{top.map((segment, segmentIndex) => {
if (segment.kind === 'fence') {
return (
<pre
className="my-1.5 max-w-full overflow-x-auto rounded-md border border-border/45 bg-[color-mix(in_srgb,currentColor_5%,transparent)] px-2.5 py-2 font-mono text-[0.86em] leading-snug"
data-slot="aui_user-fence"
key={`fence-${segmentIndex}`}
>
<code className="block whitespace-pre">{segment.code}</code>
</pre>
)
}
return (
<Fragment key={`inline-${segmentIndex}`}>
<InlineSegmentView text={segment.text} />
</Fragment>
)
})}
</span>
)
}
const InlineSegmentView: FC<{ text: string }> = ({ text }) => {
const nodes = useMemo(() => splitInlineCode(text), [text])
return (
<span className="wrap-anywhere block whitespace-pre-line">
{nodes.map((node, nodeIndex) =>
node.kind === 'inline-code' ? (
<code
className="mx-px rounded bg-[color-mix(in_srgb,currentColor_8%,transparent)] px-1 py-px font-mono text-[0.92em]"
data-slot="aui_user-inline-code"
key={`code-${nodeIndex}`}
>
{node.code}
</code>
) : (
// Pass plain-text bits through DirectiveContent so @file:/@url: chips
// still render. DirectiveContent already preserves whitespace.
<Fragment key={`text-${nodeIndex}`}>
<DirectiveContent text={node.text} />
</Fragment>
)
)}
</span>
)
}

View File

@ -5,7 +5,7 @@ import * as React from 'react'
import { cn } from '@/lib/utils'
const buttonVariants = cva(
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[0.1875rem] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[0.1875rem] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-default disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {

View File

@ -46,7 +46,10 @@ function DialogContent({
<DialogOverlay />
<DialogPrimitive.Content
className={cn(
'fixed left-1/2 top-1/2 z-[130] pointer-events-auto grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-3 rounded-xl border border-(--ui-stroke-secondary) bg-(--ui-chat-bubble-background) p-4 text-[length:var(--conversation-text-font-size)] text-foreground shadow-md 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',
// 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] w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-3 overflow-y-auto rounded-xl border border-(--ui-stroke-secondary) bg-(--ui-chat-bubble-background) p-4 text-[length:var(--conversation-text-font-size)] text-foreground shadow-md 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',
className
)}
data-slot="dialog-content"

View File

@ -24,8 +24,11 @@ function DropdownMenuContent({
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
// `dt-portal-scrollbar` reproduces the thin themed scrollbar from
// `.scrollbar-dt` for portaled overlays (Radix renders this under
// document.body, outside #root's scope). See styles.css.
className={cn(
'z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-36 origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg border border-(--ui-stroke-secondary) bg-[color-mix(in_srgb,var(--ui-bg-elevated)_96%,transparent)] p-1 text-[length:var(--conversation-text-font-size)] text-popover-foreground shadow-md backdrop-blur-md data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1 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',
'dt-portal-scrollbar z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-36 origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg border border-(--ui-stroke-secondary) bg-[color-mix(in_srgb,var(--ui-bg-elevated)_96%,transparent)] p-1 text-[length:var(--conversation-text-font-size)] text-popover-foreground shadow-md backdrop-blur-md data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1 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',
className
)}
data-slot="dropdown-menu-content"
@ -188,8 +191,13 @@ function DropdownMenuSubContent({
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
// SubContent inherits the same portal/scrollbar issue as Content (Radix
// renders it under document.body), so apply `dt-portal-scrollbar`. Use
// a fixed `max-h-80` rather than the Radix available-height variable:
// that variable is only published on Content, NOT SubContent — using
// it here collapses the submenu to 0px height.
className={cn(
'z-50 min-w-36 origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-lg border border-(--ui-stroke-secondary) bg-[color-mix(in_srgb,var(--ui-bg-elevated)_96%,transparent)] p-1 text-[length:var(--conversation-text-font-size)] text-popover-foreground shadow-md backdrop-blur-md data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1 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',
'dt-portal-scrollbar z-50 max-h-80 min-w-36 origin-(--radix-dropdown-menu-content-transform-origin) overflow-y-auto rounded-lg border border-(--ui-stroke-secondary) bg-[color-mix(in_srgb,var(--ui-bg-elevated)_96%,transparent)] p-1 text-[length:var(--conversation-text-font-size)] text-popover-foreground shadow-md backdrop-blur-md data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1 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',
className
)}
data-slot="dropdown-menu-sub-content"

View File

@ -27,6 +27,11 @@ declare global {
setPreviewShortcutActive?: (active: boolean) => void
openExternal: (url: string) => Promise<void>
fetchLinkTitle: (url: string) => Promise<string>
settings: {
getDefaultProjectDir: () => Promise<{ defaultLabel: string; dir: null | string }>
pickDefaultProjectDir: () => Promise<{ canceled: boolean; dir: null | string }>
setDefaultProjectDir: (dir: null | string) => Promise<{ dir: null | string }>
}
revealLogs: () => Promise<{ ok: boolean; path: string; error?: string }>
getRecentLogs: () => Promise<{ path: string; lines: string[] }>
readDir: (path: string) => Promise<HermesReadDirResult>

View File

@ -0,0 +1,123 @@
import { describe, expect, it } from 'vitest'
import { ansiColorClass, hasAnsiCodes, parseAnsi } from './ansi'
const ESC = '\x1b'
describe('parseAnsi', () => {
it('returns a single default segment for plain text', () => {
expect(parseAnsi('hello world')).toEqual([{ bold: false, fg: null, text: 'hello world' }])
})
it('returns nothing for an empty string', () => {
expect(parseAnsi('')).toEqual([])
})
it('parses a basic foreground color sequence and resets', () => {
const input = `${ESC}[31merror${ESC}[0m ok`
expect(parseAnsi(input)).toEqual([
{ bold: false, fg: 'red', text: 'error' },
{ bold: false, fg: null, text: ' ok' }
])
})
it('treats bold (1) and bold-off (22) as toggles without affecting fg', () => {
const input = `${ESC}[1mloud${ESC}[22m quiet`
expect(parseAnsi(input)).toEqual([
{ bold: true, fg: null, text: 'loud' },
{ bold: false, fg: null, text: ' quiet' }
])
})
it('treats default-fg (39) as a foreground-only reset (keeps bold)', () => {
const input = `${ESC}[1;31mboth${ESC}[39mbold-only`
expect(parseAnsi(input)).toEqual([
{ bold: true, fg: 'red', text: 'both' },
{ bold: true, fg: null, text: 'bold-only' }
])
})
it('handles bright colors via the 90-97 range', () => {
expect(parseAnsi(`${ESC}[92mgreen`)).toEqual([{ bold: false, fg: 'bright-green', text: 'green' }])
})
it('coalesces adjacent runs with the same style', () => {
const input = `${ESC}[31ma${ESC}[31mb${ESC}[31mc`
expect(parseAnsi(input)).toEqual([{ bold: false, fg: 'red', text: 'abc' }])
})
it('skips 256-color (38;5) trailing args without painting fg or leaking the params as text', () => {
// 256-color and truecolor aren't rendered (FG_BY_CODE doesn't cover them),
// but the parser must consume the trailing `;5;<n>` / `;2;r;g;b` args so
// they never bleed into the visible segment text.
const segments = parseAnsi(`${ESC}[38;5;208morange${ESC}[0m`)
expect(segments).toHaveLength(1)
expect(segments[0].fg).toBe(null)
expect(segments[0].text).toBe('orange')
})
it('skips truecolor (38;2;r;g;b) trailing args', () => {
const segments = parseAnsi(`${ESC}[38;2;10;20;30mrgb${ESC}[0m`)
expect(segments).toHaveLength(1)
expect(segments[0].fg).toBe(null)
expect(segments[0].text).toBe('rgb')
})
it('drops non-SGR CSI sequences (cursor motion, erase) without consuming surrounding text', () => {
const input = `before${ESC}[2Jmiddle${ESC}[10;5Hafter`
expect(parseAnsi(input)).toEqual([{ bold: false, fg: null, text: 'beforemiddleafter' }])
})
it('treats an empty SGR parameter (ESC[m) as a full reset', () => {
const input = `${ESC}[1;31mfoo${ESC}[mbar`
expect(parseAnsi(input)).toEqual([
{ bold: true, fg: 'red', text: 'foo' },
{ bold: false, fg: null, text: 'bar' }
])
})
})
describe('hasAnsiCodes', () => {
it('returns false for plain text', () => {
expect(hasAnsiCodes('hello world')).toBe(false)
})
it('returns true when any CSI introducer is present', () => {
expect(hasAnsiCodes(`${ESC}[31mred`)).toBe(true)
})
})
describe('ansiColorClass', () => {
it('returns a non-empty Tailwind class string for every supported color', () => {
const colors = [
'black',
'red',
'green',
'yellow',
'blue',
'magenta',
'cyan',
'white',
'bright-black',
'bright-red',
'bright-green',
'bright-yellow',
'bright-blue',
'bright-magenta',
'bright-cyan',
'bright-white'
] as const
for (const color of colors) {
expect(ansiColorClass(color)).toMatch(/\S/)
}
})
})

View File

@ -0,0 +1,175 @@
// Minimal ANSI SGR parser for rendering terminal output inside chat tool
// cards. Only handles the SGR codes that show up in practice (color, bold,
// reset); cursor motions and other CSI sequences are dropped silently.
//
// Returns a flat array of styled segments so callers can render them as
// React spans without each consumer having to re-implement the parser.
export interface AnsiSegment {
bold: boolean
/** Tailwind text-color class or null for the default foreground. */
fg: AnsiColor | null
text: string
}
export type AnsiColor =
| 'black'
| 'red'
| 'green'
| 'yellow'
| 'blue'
| 'magenta'
| 'cyan'
| 'white'
| 'bright-black'
| 'bright-red'
| 'bright-green'
| 'bright-yellow'
| 'bright-blue'
| 'bright-magenta'
| 'bright-cyan'
| 'bright-white'
const FG_BY_CODE: Record<number, AnsiColor> = {
30: 'black',
31: 'red',
32: 'green',
33: 'yellow',
34: 'blue',
35: 'magenta',
36: 'cyan',
37: 'white',
90: 'bright-black',
91: 'bright-red',
92: 'bright-green',
93: 'bright-yellow',
94: 'bright-blue',
95: 'bright-magenta',
96: 'bright-cyan',
97: 'bright-white'
}
// CSI = ESC '[' params 'final'. We only care about SGR (final == 'm'); other
// final bytes are matched and consumed so they don't leak into the rendered
// text. Range covers the common CSI command set (A-Z / a-z / @).
// eslint-disable-next-line no-control-regex
const CSI_RE = /\x1b\[([\d;]*)([\x40-\x7e])/g
// Other escape sequences (single-char OSC/SS3/etc.) — strip silently.
// eslint-disable-next-line no-control-regex
const OTHER_ESCAPE_RE = /\x1b[@-Z\\-_]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g
export function parseAnsi(input: string): AnsiSegment[] {
if (!input) {
return []
}
// Strip non-CSI escapes upfront — none of them carry text we want to keep
// and CSI_RE wouldn't match them.
const cleaned = input.replace(OTHER_ESCAPE_RE, '')
const segments: AnsiSegment[] = []
let cursor = 0
let bold = false
let fg: AnsiColor | null = null
const pushText = (text: string) => {
if (!text) {
return
}
const last = segments.at(-1)
if (last && last.bold === bold && last.fg === fg) {
last.text += text
return
}
segments.push({ bold, fg, text })
}
CSI_RE.lastIndex = 0
let match: RegExpExecArray | null
while ((match = CSI_RE.exec(cleaned)) !== null) {
const start = match.index
if (start > cursor) {
pushText(cleaned.slice(cursor, start))
}
if (match[2] === 'm') {
const codes = match[1]
.split(';')
.map(part => (part === '' ? 0 : Number(part)))
.filter(value => Number.isFinite(value))
for (let i = 0; i < codes.length; i += 1) {
const code = codes[i]
if (code === 0) {
bold = false
fg = null
} else if (code === 1) {
bold = true
} else if (code === 22) {
bold = false
} else if (code === 39) {
fg = null
} else if (code in FG_BY_CODE) {
fg = FG_BY_CODE[code]
} else if (code === 38) {
// 256-color / truecolor — skip the trailing args we don't render.
if (codes[i + 1] === 5) {
i += 2
} else if (codes[i + 1] === 2) {
i += 4
}
}
// Background colors (40-47, 100-107) and effects we don't render are
// intentionally ignored — the segment keeps the prior bold/fg state.
}
}
cursor = CSI_RE.lastIndex
}
if (cursor < cleaned.length) {
pushText(cleaned.slice(cursor))
}
return segments
}
const TAILWIND_BY_COLOR: Record<AnsiColor, string> = {
// Tuned for legibility against the muted bg-(--ui-bg-tertiary) surface used
// in tool cards. We don't paint pure ANSI colors (#000, #fff) because they
// disappear into the surface.
'black': 'text-zinc-700 dark:text-zinc-300',
'red': 'text-red-700 dark:text-red-300',
'green': 'text-emerald-700 dark:text-emerald-300',
'yellow': 'text-amber-700 dark:text-amber-300',
'blue': 'text-blue-700 dark:text-blue-300',
'magenta': 'text-fuchsia-700 dark:text-fuchsia-300',
'cyan': 'text-cyan-700 dark:text-cyan-300',
'white': 'text-zinc-600 dark:text-zinc-200',
'bright-black': 'text-zinc-500 dark:text-zinc-400',
'bright-red': 'text-rose-600 dark:text-rose-300',
'bright-green': 'text-emerald-600 dark:text-emerald-200',
'bright-yellow': 'text-amber-600 dark:text-amber-200',
'bright-blue': 'text-sky-600 dark:text-sky-300',
'bright-magenta': 'text-pink-600 dark:text-pink-300',
'bright-cyan': 'text-teal-600 dark:text-teal-200',
'bright-white': 'text-zinc-500 dark:text-zinc-100'
}
export function ansiColorClass(color: AnsiColor): string {
return TAILWIND_BY_COLOR[color]
}
/** Returns true if the input contains at least one CSI sequence. Cheap check
* so callers can skip the parser for plain-ASCII output. */
export function hasAnsiCodes(input: string): boolean {
// eslint-disable-next-line no-control-regex
return /\x1b\[/.test(input)
}

View File

@ -20,7 +20,11 @@ const PRIORITY_KEYS = [
] as const
const ERROR_KEYS = ['error', 'errors', 'failure', 'exception'] as const
const ERROR_MSG_KEYS = ['message', 'reason', 'detail', 'stderr'] as const
// 'stderr' deliberately excluded: many CLIs emit informational lines on
// stderr (npm progress, git's hint:, gcc's `In file included from`) that
// aren't errors. Treating those as error signal flipped tool cards into
// destructive styling for healthy commands.
const ERROR_MSG_KEYS = ['message', 'reason', 'detail'] as const
const NON_ERROR_TEXT = new Set(['', '0', 'false', 'none', 'null', 'nil', 'ok', 'success', 'n/a', 'na'])
type Json = Record<string, unknown>

View File

@ -97,6 +97,53 @@ export const setIntroSeed = (next: Updater<number>) => updateAtom($introSeed, ne
export const setContextSuggestions = (next: Updater<ContextSuggestion[]>) => updateAtom($contextSuggestions, next)
export const setModelPickerOpen = (next: Updater<boolean>) => updateAtom($modelPickerOpen, next)
// Watchdog tracking — when does a "working" session count as stuck?
// Long-running tool calls (LLM inference, long shell commands, web fetches)
// can take a few minutes legitimately. We allow 8 minutes of complete
// silence on the stream before clearing the working flag; in practice this
// catches gateway hangs and dropped streams without false-positive-clearing
// real long turns.
const SESSION_WATCHDOG_TIMEOUT_MS = 8 * 60 * 1000
const sessionWatchdogTimers = new Map<string, ReturnType<typeof setTimeout>>()
function armSessionWatchdog(sessionId: string) {
const existing = sessionWatchdogTimers.get(sessionId)
if (existing) {
clearTimeout(existing)
}
const timer = setTimeout(() => {
sessionWatchdogTimers.delete(sessionId)
// Re-check the latest state at fire-time. If the user already navigated
// away or the session genuinely finished, the timer is a no-op.
if ($workingSessionIds.get().includes(sessionId)) {
setWorkingSessionIds(current => current.filter(id => id !== sessionId))
}
}, SESSION_WATCHDOG_TIMEOUT_MS)
sessionWatchdogTimers.set(sessionId, timer)
}
function clearSessionWatchdog(sessionId: string) {
const existing = sessionWatchdogTimers.get(sessionId)
if (existing) {
clearTimeout(existing)
sessionWatchdogTimers.delete(sessionId)
}
}
/** Call when a streaming event for a session lands. Refreshes the watchdog
* so the session keeps its "working" status as long as data keeps coming. */
export function noteSessionActivity(sessionId: string | null | undefined) {
if (!sessionId || !$workingSessionIds.get().includes(sessionId)) {
return
}
armSessionWatchdog(sessionId)
}
export function setSessionWorking(sessionId: string | null | undefined, working: boolean) {
if (!sessionId) {
return
@ -111,4 +158,13 @@ export function setSessionWorking(sessionId: string | null | undefined, working:
return alreadyWorking ? current.filter(id => id !== sessionId) : current
})
// Bookend the watchdog: arm it whenever a session enters "working",
// disarm it whenever it leaves. A subsequent noteSessionActivity() from
// a streaming event will refresh the timer.
if (working) {
armSessionWatchdog(sessionId)
} else {
clearSessionWatchdog(sessionId)
}
}

View File

@ -145,6 +145,34 @@ export function openUpdatesWindow(): void {
void checkUpdates()
}
/** Re-read the running app's version from the Electron main process and
* publish it on `$desktopVersion`. Called when the About panel mounts, the
* update flow finishes, and the window regains focus, so the About text
* stays in sync with the just-installed binary instead of frozen at the
* value captured at first-load. */
export async function refreshDesktopVersion(): Promise<DesktopVersionInfo | null> {
if (typeof window === 'undefined') {
return null
}
// Best-effort UI sync: callers (checkUpdates, startUpdatePoller, window
// focus handler) all kick this off with `void refreshDesktopVersion()`,
// so any rejection from the IPC bridge (e.g. main process shutting down
// mid-reload, or the bridge not yet ready on first paint) would surface
// as an unhandled promise rejection in the renderer. Swallow it.
try {
const next = await window.hermesDesktop?.getVersion?.()
if (next) {
$desktopVersion.set(next)
}
return next ?? null
} catch {
return null
}
}
export async function checkUpdates(): Promise<DesktopUpdateStatus | null> {
const bridge = window.hermesDesktop?.updates
@ -158,6 +186,10 @@ export async function checkUpdates(): Promise<DesktopUpdateStatus | null> {
const status = await bridge.check()
$updateStatus.set(status)
maybeNotifyUpdateAvailable(status)
// The update check pulls the latest hermes_cli + bundled package metadata
// into place. Re-read the running version so About reflects the now-fresh
// checkout rather than the one captured at process start.
void refreshDesktopVersion()
return status
} catch (error) {
@ -249,7 +281,7 @@ export function startUpdatePoller(): void {
pollerStarted = true
void checkUpdates()
void window.hermesDesktop?.getVersion?.().then(info => $desktopVersion.set(info))
void refreshDesktopVersion()
bridge.onProgress(ingestProgress)
window.addEventListener('focus', onFocus)
@ -275,4 +307,8 @@ function onFocus() {
lastFocusAt = now
void checkUpdates()
// Cheap and safe to re-read on every (throttled) focus: the user may have
// updated Hermes from another window/CLI between focuses, and About should
// catch up without forcing a restart.
void refreshDesktopVersion()
}

View File

@ -641,6 +641,41 @@ canvas {
.scrollbar-dt *::-webkit-scrollbar-button {
display: none;
}
/* Variant for portaled overlays (Radix DropdownMenu, Popover, etc.) that
render under document.body, outside the `.scrollbar-dt` scope on
#root. Same visual treatment, applied directly to the overlay
container so its (and only its) internal scrollbar is themed. */
.dt-portal-scrollbar {
scrollbar-width: thin;
scrollbar-color: color-mix(in srgb, var(--dt-midground) 28%, transparent) transparent;
}
.dt-portal-scrollbar::-webkit-scrollbar {
width: 0.375rem;
height: 0.375rem;
}
.dt-portal-scrollbar::-webkit-scrollbar-track,
.dt-portal-scrollbar::-webkit-scrollbar-corner {
background: transparent;
}
.dt-portal-scrollbar::-webkit-scrollbar-thumb {
background: color-mix(in srgb, var(--dt-midground) 28%, transparent);
border-radius: 9999rem;
border: 0.0625rem solid transparent;
background-clip: padding-box;
}
.dt-portal-scrollbar::-webkit-scrollbar-thumb:hover {
background: color-mix(in srgb, var(--dt-midground) 50%, transparent);
background-clip: padding-box;
}
.dt-portal-scrollbar::-webkit-scrollbar-button {
display: none;
}
}
/* Bottom clearance lives on [data-slot='aui_composer-clearance'] —

2084
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -353,6 +353,16 @@ class FileOperations(ABC):
"""Delete a file. Returns WriteResult with .error set on failure."""
...
def delete_path(self, path: str, recursive: bool = False) -> WriteResult:
"""Cross-platform delete that handles files and (with recursive=True)
directory trees. Default implementation delegates to ``delete_file``
for the non-recursive case; backends with native recursive support
should override.
"""
if recursive:
return WriteResult(error="Recursive delete not implemented for this backend")
return self.delete_file(path)
@abstractmethod
def move_file(self, src: str, dst: str) -> WriteResult:
"""Move/rename a file from src to dst. Returns WriteResult with .error set on failure."""
@ -1065,13 +1075,64 @@ class ShellFileOperations(FileOperations):
)
def delete_file(self, path: str) -> WriteResult:
"""Delete a file via rm."""
"""Delete a single file.
Cross-platform: runs via ``python -c`` against the terminal env's
Python so it works on Windows shells (``cmd.exe``/PowerShell) that
don't ship ``rm``. Directories are rejected here — use
``delete_path(recursive=True)`` for trees.
"""
return self._python_delete(path, recursive=False)
def delete_path(self, path: str, recursive: bool = False) -> WriteResult:
"""Cross-platform delete that handles files and (with recursive=True)
directory trees. Always preferred over emitting ``rm -rf`` /
``Remove-Item -Recurse`` directly so the same tool call works on
every backend (local / docker / ssh / Windows).
"""
return self._python_delete(path, recursive=recursive)
def _python_delete(self, path: str, recursive: bool) -> WriteResult:
path = self._expand_path(path)
if _is_write_denied(path):
return WriteResult(error=f"Delete denied: {path} is a protected path")
result = self._exec(f"rm -f {self._escape_shell_arg(path)}")
# We can't shell out to ``rm`` here — it doesn't exist on Windows
# ``cmd.exe`` or PowerShell, so this code path is what's left when
# the backend's terminal is a Windows shell. Path is baked into the
# snippet via ``repr()`` so quoting is correct on every shell.
snippet = (
"import shutil, pathlib, sys\n"
f"p = pathlib.Path({path!r})\n"
f"recursive = {bool(recursive)!r}\n"
"try:\n"
" if p.is_dir() and not p.is_symlink():\n"
" if recursive:\n"
" shutil.rmtree(p)\n"
" else:\n"
" print('is a directory: ' + str(p), file=sys.stderr); sys.exit(2)\n"
" else:\n"
# NOTE: avoid ``unlink(missing_ok=True)`` — that kwarg lands in
# Python 3.8 and the remote interpreter (docker/ssh) may still
# be 3.7 on older distros. The FileNotFoundError handler below
# covers the same case and works back to 3.4.
" p.unlink()\n"
"except FileNotFoundError:\n"
" pass\n"
"except Exception as exc:\n"
" print(str(exc), file=sys.stderr); sys.exit(1)\n"
)
result = self._exec(f"python3 -c {self._escape_shell_arg(snippet)}")
# Fall back to ``python`` (Windows / older systems where there's no
# ``python3`` symlink but a ``python`` binary is on PATH).
if result.exit_code != 0 and "python3" in (result.stdout or ""):
result = self._exec(f"python -c {self._escape_shell_arg(snippet)}")
if result.exit_code != 0:
return WriteResult(error=f"Failed to delete {path}: {result.stdout}")
return WriteResult(error=f"Failed to delete {path}: {(result.stdout or '').strip() or 'unknown error'}")
return WriteResult()
def move_file(self, src: str, dst: str) -> WriteResult: