mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-20 15:33:54 +00:00
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:
parent
31c40c72c0
commit
ac76bbe21f
38 changed files with 1594 additions and 2183 deletions
123
apps/desktop/src/lib/ansi.test.ts
Normal file
123
apps/desktop/src/lib/ansi.test.ts
Normal 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/)
|
||||
}
|
||||
})
|
||||
})
|
||||
175
apps/desktop/src/lib/ansi.ts
Normal file
175
apps/desktop/src/lib/ansi.ts
Normal 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)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue