diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs
index 099c11cceb6a..9cd1b07068e4 100644
--- a/apps/desktop/electron/main.cjs
+++ b/apps/desktop/electron/main.cjs
@@ -352,6 +352,44 @@ function rememberLog(chunk) {
scheduleDesktopLogFlush()
}
+function openExternalUrl(rawUrl) {
+ const raw = String(rawUrl || '').trim()
+ if (!raw) return false
+
+ let parsed
+ try {
+ parsed = new URL(raw)
+ } catch {
+ return false
+ }
+
+ if (!['http:', 'https:', 'mailto:'].includes(parsed.protocol)) {
+ return false
+ }
+
+ const url = parsed.toString()
+
+ if (IS_WSL) {
+ rememberLog(`[link] opening via WSL→Windows: ${url}`)
+ const proc = spawn('cmd.exe', ['/c', 'start', '""', url], {
+ detached: true,
+ stdio: 'ignore',
+ windowsHide: true
+ })
+ proc.on('error', error => {
+ rememberLog(`[link] cmd.exe start failed: ${error.message}; falling back to xdg-open`)
+ shell.openExternal(url).catch(fallback => rememberLog(`[link] xdg-open failed: ${fallback.message}`))
+ })
+ proc.unref()
+
+ return true
+ }
+
+ shell.openExternal(url).catch(error => rememberLog(`[link] openExternal failed: ${error.message}`))
+
+ return true
+}
+
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms))
}
@@ -2067,7 +2105,7 @@ function installContextMenu(window) {
label: 'Open Image',
click: () => {
if (params.srcURL && !params.srcURL.startsWith('data:')) {
- void shell.openExternal(params.srcURL)
+ openExternalUrl(params.srcURL)
}
},
enabled: !params.srcURL.startsWith('data:')
@@ -2096,7 +2134,7 @@ function installContextMenu(window) {
template.push(
{
label: 'Open Link',
- click: () => void shell.openExternal(params.linkURL)
+ click: () => openExternalUrl(params.linkURL)
},
{
label: 'Copy Link',
@@ -2570,6 +2608,19 @@ function createWindow() {
installPreviewShortcut(mainWindow)
installDevToolsShortcut(mainWindow)
installContextMenu(mainWindow)
+ mainWindow.webContents.setWindowOpenHandler(details => {
+ openExternalUrl(details.url)
+
+ return { action: 'deny' }
+ })
+ mainWindow.webContents.on('will-navigate', (event, url) => {
+ if ((DEV_SERVER && url.startsWith(DEV_SERVER)) || (!DEV_SERVER && url.startsWith('file:'))) {
+ return
+ }
+
+ event.preventDefault()
+ openExternalUrl(url)
+ })
if (DEV_SERVER) {
mainWindow.loadURL(DEV_SERVER)
@@ -2731,7 +2782,11 @@ ipcMain.on('hermes:titlebar-theme', (_event, payload) => {
mainWindow?.setTitleBarOverlay?.(getTitleBarOverlayOptions())
})
-ipcMain.handle('hermes:openExternal', (_event, url) => shell.openExternal(url))
+ipcMain.handle('hermes:openExternal', (_event, url) => {
+ if (!openExternalUrl(url)) {
+ throw new Error('Invalid external URL')
+ }
+})
ipcMain.handle('hermes:fetchLinkTitle', (_event, url) => fetchLinkTitle(url))
diff --git a/apps/desktop/package.json b/apps/desktop/package.json
index a9975e82c90f..a70afbc7979c 100644
--- a/apps/desktop/package.json
+++ b/apps/desktop/package.json
@@ -10,12 +10,12 @@
"scripts": {
"dev": "concurrently -k \"npm:dev:renderer\" \"npm:dev:electron\"",
"dev:fake-boot": "cross-env HERMES_DESKTOP_BOOT_FAKE=1 HERMES_DESKTOP_BOOT_FAKE_STEP_MS=650 npm run dev",
- "dev:renderer": "vite --host 127.0.0.1 --port 5174",
- "dev:electron": "wait-on http://127.0.0.1:5174 && cross-env HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron .",
- "profile:main": "wait-on http://127.0.0.1:5174 && cross-env HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron --inspect=9229 .",
- "profile:main:cpu": "wait-on http://127.0.0.1:5174 && cross-env NODE_OPTIONS=--cpu-prof HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron .",
+ "dev:renderer": "node scripts/assert-root-install.cjs && vite --host 127.0.0.1 --port 5174",
+ "dev:electron": "wait-on http://127.0.0.1:5174 && cross-env XCURSOR_SIZE=24 HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron .",
+ "profile:main": "wait-on http://127.0.0.1:5174 && cross-env XCURSOR_SIZE=24 HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron --inspect=9229 .",
+ "profile:main:cpu": "wait-on http://127.0.0.1:5174 && cross-env XCURSOR_SIZE=24 NODE_OPTIONS=--cpu-prof HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron .",
"start": "npm run build && electron .",
- "build": "tsc -b && vite build",
+ "build": "node scripts/assert-root-install.cjs && tsc -b && vite build",
"stage:hermes": "node scripts/stage-hermes-payload.mjs",
"builder": "cross-env NODE_OPTIONS=--max-old-space-size=16384 electron-builder",
"pack": "npm run build && npm run stage:hermes && npm run builder -- --dir",
@@ -38,7 +38,7 @@
"fmt": "prettier --write 'src/**/*.{ts,tsx}' 'electron/**/*.{js,cjs}' 'vite.config.ts'",
"fix": "npm run lint:fix && npm run fmt",
"test:ui": "vitest run --environment jsdom",
- "preview": "vite preview --host 127.0.0.1 --port 4174"
+ "preview": "node scripts/assert-root-install.cjs && vite preview --host 127.0.0.1 --port 4174"
},
"dependencies": {
"@assistant-ui/react": "^0.12.28",
diff --git a/apps/desktop/scripts/assert-root-install.cjs b/apps/desktop/scripts/assert-root-install.cjs
new file mode 100644
index 000000000000..26433ca9be7e
--- /dev/null
+++ b/apps/desktop/scripts/assert-root-install.cjs
@@ -0,0 +1,13 @@
+"use strict"
+
+const fs = require("fs")
+const path = require("path")
+
+const root = path.resolve(__dirname, "..", "..", "..")
+
+try {
+ fs.accessSync(path.join(root, "node_modules", "vite", "package.json"))
+} catch {
+ console.error(`Run from repo root: cd ${root} && npm ci`)
+ process.exit(1)
+}
diff --git a/apps/desktop/src/app/command-center/index.tsx b/apps/desktop/src/app/command-center/index.tsx
index 9a691caa098c..30d09973aca9 100644
--- a/apps/desktop/src/app/command-center/index.tsx
+++ b/apps/desktop/src/app/command-center/index.tsx
@@ -867,7 +867,7 @@ export function CommandCenterView({
)}
/>
- {status.gateway_running ? 'Gateway running' : 'Gateway not running'}
+ {status.gateway_running ? 'Messaging gateway running' : 'Messaging gateway stopped'}
@@ -876,7 +876,7 @@ export function CommandCenterView({
void runSystemAction('restart')}>
- Restart gateway
+ Restart messaging
void runSystemAction('update')}>
Update Hermes
diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx
index 1a7990d9843e..0fad6f588ca4 100644
--- a/apps/desktop/src/app/desktop-controller.tsx
+++ b/apps/desktop/src/app/desktop-controller.tsx
@@ -208,7 +208,7 @@ export function DesktopController() {
}
}, [])
- const { gatewayLogLines, statusSnapshot } = useStatusSnapshot(gatewayState)
+ const { gatewayLogLines, inferenceStatus, statusSnapshot } = useStatusSnapshot(gatewayState, requestGateway)
const { browseSessionCwd, changeSessionCwd, refreshProjectBranch } = useCwdActions({
activeSessionId,
@@ -393,6 +393,8 @@ export function DesktopController() {
extraLeftItems: statusbarItemGroups.flat.left,
extraRightItems: statusbarItemGroups.flat.right,
gatewayLogLines,
+ gatewayState,
+ inferenceStatus,
openAgents,
openCommandCenterSection,
statusSnapshot,
diff --git a/apps/desktop/src/app/messaging/index.tsx b/apps/desktop/src/app/messaging/index.tsx
index b05df9783ecf..63b2040f422f 100644
--- a/apps/desktop/src/app/messaging/index.tsx
+++ b/apps/desktop/src/app/messaging/index.tsx
@@ -33,7 +33,7 @@ const STATE_LABELS: Record = {
connecting: 'Connecting',
disabled: 'Disabled',
fatal: 'Error',
- gateway_stopped: 'Gateway stopped',
+ gateway_stopped: 'Messaging gateway stopped',
not_configured: 'Needs setup',
pending_restart: 'Restart needed',
retrying: 'Retrying',
@@ -502,7 +502,7 @@ function PlatformDetail({
{platform.configured ? 'Credentials set' : 'Needs setup'}
- {!platform.gateway_running && Gateway stopped}
+ {!platform.gateway_running && Messaging gateway stopped}
diff --git a/apps/desktop/src/app/shell/gateway-menu-panel.tsx b/apps/desktop/src/app/shell/gateway-menu-panel.tsx
index 6587747bf047..e9f43a20f4c0 100644
--- a/apps/desktop/src/app/shell/gateway-menu-panel.tsx
+++ b/apps/desktop/src/app/shell/gateway-menu-panel.tsx
@@ -2,15 +2,16 @@ import { IconLayoutDashboard } from '@tabler/icons-react'
import { StatusDot, type StatusTone } from '@/components/status-dot'
import { Button } from '@/components/ui/button'
-import { Activity, AlertCircle, RefreshCw } from '@/lib/icons'
+import { Activity, AlertCircle } from '@/lib/icons'
+import type { RuntimeReadinessResult } from '@/lib/runtime-readiness'
import { cn } from '@/lib/utils'
import type { StatusResponse } from '@/types/hermes'
interface GatewayMenuPanelProps {
+ gatewayState: string
+ inferenceStatus: RuntimeReadinessResult | null
logLines: readonly string[]
onOpenSystem: () => void
- onRestart: () => void
- restarting: boolean
statusSnapshot: StatusResponse | null
}
@@ -32,44 +33,41 @@ const RUNTIME_BRACKET_RE = /^\[[^\]]+]\s+/
const trimLogLine = (raw: string) => raw.trim().replace(TIMESTAMP_RE, '').replace(RUNTIME_BRACKET_RE, '')
export function GatewayMenuPanel({
+ gatewayState,
+ inferenceStatus,
logLines,
onOpenSystem,
- onRestart,
- restarting,
statusSnapshot
}: GatewayMenuPanelProps) {
- const gatewayRunning = Boolean(statusSnapshot?.gateway_running)
+ const gatewayOpen = gatewayState === 'open'
+ const inferenceReady = gatewayOpen && inferenceStatus?.ready === true
+ const connectionLabel = gatewayOpen ? 'Connected' : prettyState(gatewayState || 'offline')
+ const inferenceLabel = gatewayOpen
+ ? inferenceStatus
+ ? inferenceReady
+ ? 'Inference ready'
+ : 'Inference not ready'
+ : 'Checking inference'
+ : 'Disconnected'
const platforms = Object.entries(statusSnapshot?.gateway_platforms || {}).sort(([l], [r]) => l.localeCompare(r))
- const stateLabel = gatewayRunning ? prettyState(statusSnapshot?.gateway_state || 'online') : 'Offline'
const recentLogs = logLines.slice(-5)
return (
- {gatewayRunning ? (
+ {inferenceReady ? (
) : (
-
+
)}
Gateway
-
- {stateLabel}
+
+ {inferenceLabel}
-
+
+
Connection: {connectionLabel}
+ {inferenceStatus?.reason &&
{inferenceStatus.reason}
}
+
+
{recentLogs.length > 0 && (
Recent activity
@@ -109,7 +112,7 @@ export function GatewayMenuPanel({
{platforms.length > 0 && (
-
Platforms
+
Messaging platforms
{platforms.map(([name, platform]) => (
-
diff --git a/apps/desktop/src/app/shell/hooks/use-status-snapshot.ts b/apps/desktop/src/app/shell/hooks/use-status-snapshot.ts
index 687a8436e9cf..f644fe48c0ad 100644
--- a/apps/desktop/src/app/shell/hooks/use-status-snapshot.ts
+++ b/apps/desktop/src/app/shell/hooks/use-status-snapshot.ts
@@ -1,23 +1,35 @@
import { useEffect, useState } from 'react'
import { getLogs, getStatus } from '@/hermes'
+import { evaluateRuntimeReadiness, type RuntimeReadinessResult } from '@/lib/runtime-readiness'
import type { StatusResponse } from '@/types/hermes'
const REFRESH_MS = 15_000
const LOG_TAIL = 12
-export function useStatusSnapshot(gatewayState: string | undefined) {
+type GatewayRequester = (method: string, params?: Record) => Promise
+
+export function useStatusSnapshot(gatewayState: string | undefined, requestGateway: GatewayRequester) {
const [statusSnapshot, setStatusSnapshot] = useState(null)
const [gatewayLogLines, setGatewayLogLines] = useState([])
+ const [inferenceStatus, setInferenceStatus] = useState(null)
useEffect(() => {
let cancelled = false
const refresh = async () => {
try {
- const [next, logs] = await Promise.all([
+ const [next, logs, inference] = await Promise.all([
getStatus(),
- getLogs({ file: 'gateway', lines: LOG_TAIL }).catch(() => ({ lines: [] }))
+ getLogs({ file: 'gui', lines: LOG_TAIL }).catch(() => ({ lines: [] })),
+ gatewayState === 'open'
+ ? evaluateRuntimeReadiness(requestGateway).catch(error => ({
+ checksDisagree: false,
+ ready: false,
+ reason: error instanceof Error ? error.message : String(error),
+ source: 'fallback' as const
+ }))
+ : Promise.resolve(null)
])
if (cancelled) {
@@ -26,6 +38,7 @@ export function useStatusSnapshot(gatewayState: string | undefined) {
setStatusSnapshot(next)
setGatewayLogLines(logs.lines.map(line => line.trim()).filter(Boolean))
+ setInferenceStatus(inference)
} catch {
// Keep last snapshot through transient gateway flaps.
}
@@ -38,7 +51,7 @@ export function useStatusSnapshot(gatewayState: string | undefined) {
cancelled = true
window.clearInterval(timer)
}
- }, [gatewayState])
+ }, [gatewayState, requestGateway])
- return { gatewayLogLines, statusSnapshot }
+ return { gatewayLogLines, inferenceStatus, statusSnapshot }
}
diff --git a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx
index 0f1d4c788737..18ad18935fb0 100644
--- a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx
+++ b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx
@@ -1,14 +1,13 @@
import { useStore } from '@nanostores/react'
-import { useCallback, useMemo, useState } from 'react'
+import { useMemo } from 'react'
import type { CommandCenterSection } from '@/app/command-center'
import { GatewayMenuPanel } from '@/app/shell/gateway-menu-panel'
-import { restartGateway } from '@/hermes'
import { Activity, AlertCircle, Clock, Command, Cpu, FolderOpen, GitBranch, Hash, Loader2, Sparkles } from '@/lib/icons'
+import type { RuntimeReadinessResult } from '@/lib/runtime-readiness'
import { compactPath, contextBarLabel, LiveDuration, usageContextLabel } from '@/lib/statusbar'
import { cn } from '@/lib/utils'
import { $desktopActionTasks } from '@/store/activity'
-import { notify, notifyError } from '@/store/notifications'
import { $previewServerRestartStatus } from '@/store/preview'
import {
$busy,
@@ -36,6 +35,8 @@ interface StatusbarItemsOptions {
extraLeftItems: readonly StatusbarItem[]
extraRightItems: readonly StatusbarItem[]
gatewayLogLines: readonly string[]
+ gatewayState: string
+ inferenceStatus: RuntimeReadinessResult | null
openAgents: () => void
openCommandCenterSection: (section: CommandCenterSection) => void
statusSnapshot: StatusResponse | null
@@ -49,6 +50,8 @@ export function useStatusbarItems({
extraLeftItems,
extraRightItems,
gatewayLogLines,
+ gatewayState,
+ inferenceStatus,
openAgents,
openCommandCenterSection,
statusSnapshot,
@@ -73,40 +76,17 @@ export function useStatusbarItems({
const contextUsage = useMemo(() => usageContextLabel(currentUsage), [currentUsage])
const contextBar = useMemo(() => contextBarLabel(currentUsage), [currentUsage])
- const [restartingGateway, setRestartingGateway] = useState(false)
-
- const handleRestartGateway = useCallback(async () => {
- if (restartingGateway) {
- return
- }
-
- setRestartingGateway(true)
-
- try {
- await restartGateway()
- notify({
- kind: 'success',
- title: 'Gateway restart requested',
- message: 'Status will update once the gateway reconnects.'
- })
- } catch (err) {
- notifyError(err, 'Failed to restart gateway')
- } finally {
- setRestartingGateway(false)
- }
- }, [restartingGateway])
-
const gatewayMenuContent = useMemo(
() => (
openCommandCenterSection('system')}
- onRestart={() => void handleRestartGateway()}
- restarting={restartingGateway}
+ gatewayState={gatewayState}
+ inferenceStatus={inferenceStatus}
statusSnapshot={statusSnapshot}
/>
),
- [gatewayLogLines, handleRestartGateway, openCommandCenterSection, restartingGateway, statusSnapshot]
+ [gatewayLogLines, gatewayState, inferenceStatus, openCommandCenterSection, statusSnapshot]
)
const { bgFailed, bgRunning, subagentsRunning } = useMemo(() => {
@@ -124,7 +104,22 @@ export function useStatusbarItems({
}
}, [desktopActionTasks, previewServerRestartStatus, subagentsBySession, workingSessionIds])
- const gatewayUp = Boolean(statusSnapshot?.gateway_running)
+ const gatewayOpen = gatewayState === 'open'
+ const inferenceReady = gatewayOpen && inferenceStatus?.ready === true
+ const gatewayDetail = gatewayOpen
+ ? inferenceStatus
+ ? inferenceReady
+ ? 'ready'
+ : 'needs setup'
+ : 'checking'
+ : gatewayState === 'connecting'
+ ? 'connecting'
+ : 'offline'
+ const gatewayClassName = inferenceReady
+ ? undefined
+ : gatewayOpen || gatewayState === 'connecting'
+ ? 'text-amber-600 hover:text-amber-600'
+ : 'text-destructive hover:text-destructive'
const versionItem = useMemo(() => {
const appVersion = desktopVersion?.appVersion
@@ -182,14 +177,14 @@ export function useStatusbarItems({
variant: 'action'
},
{
- className: gatewayUp ? undefined : 'text-destructive hover:text-destructive',
- detail: gatewayUp ? statusSnapshot?.gateway_state || 'online' : 'offline',
- icon: gatewayUp ? : ,
+ className: gatewayClassName,
+ detail: gatewayDetail,
+ icon: inferenceReady ? : ,
id: 'gateway-health',
label: 'Gateway',
menuClassName: 'w-72',
menuContent: gatewayMenuContent,
- title: 'Gateway and platform health',
+ title: inferenceStatus?.reason || 'Hermes inference gateway status',
variant: 'menu'
},
{
@@ -234,9 +229,11 @@ export function useStatusbarItems({
bgRunning,
commandCenterOpen,
gatewayMenuContent,
- gatewayUp,
+ gatewayClassName,
+ gatewayDetail,
+ inferenceReady,
+ inferenceStatus?.reason,
openAgents,
- statusSnapshot?.gateway_state,
subagentsRunning,
toggleCommandCenter
]
diff --git a/apps/desktop/src/components/assistant-ui/markdown-text.tsx b/apps/desktop/src/components/assistant-ui/markdown-text.tsx
index 0ab147a6c457..8e2fbfc8f848 100644
--- a/apps/desktop/src/components/assistant-ui/markdown-text.tsx
+++ b/apps/desktop/src/components/assistant-ui/markdown-text.tsx
@@ -53,9 +53,8 @@ function CodeHeader({ language, code }: { language?: string; code?: string }) {
const label = cleanLanguage && cleanLanguage !== 'unknown' ? cleanLanguage : ''
return (
-
-
-
+
+
{label || 'code'}
@@ -288,10 +287,10 @@ const MarkdownTextImpl = () => {
),
table: ({ className, ...props }: ComponentProps<'table'>) => (
-
+
{
),
thead: ({ className, ...props }: ComponentProps<'thead'>) => (
-
+
),
th: ({ className, ...props }: ComponentProps<'th'>) => (
= ({
code,
defer = false
}) => {
+ const preClassName =
+ 'aui-shiki m-0 overflow-hidden rounded-b-md border border-t-0 border-border bg-card font-mono text-sm leading-relaxed [&_pre]:m-0 [&_pre]:overflow-x-auto [&_pre]:bg-transparent! [&_pre]:px-4 [&_pre]:py-3 [&_pre]:font-mono [&_pre]:leading-relaxed'
+
// Streamdown may hand us fence contents with edge newlines. Strip blank
// fence padding without touching indentation on the first real line.
const trimmed = (code ?? '').replace(/^\n+/, '').trimEnd()
@@ -45,14 +48,14 @@ export const SyntaxHighlighter: FC = ({
if (defer) {
return (
-
+
{trimmed}
)
}
return (
-
+
pre) {
+ margin: 0 !important;
+ margin-block-start: 0 !important;
+ margin-block-end: 0 !important;
}
-[data-slot='aui_assistant-message-content'] .aui-md [data-streamdown='code-block'] > * {
+[data-slot='aui_assistant-message-content'] .aui-md .aui-md-table {
+ border-spacing: 0;
+}
+
+[data-slot='aui_assistant-message-content'] .aui-md .aui-md-table > table,
+[data-slot='aui_assistant-message-content'] .aui-md .aui-md-table thead,
+[data-slot='aui_assistant-message-content'] .aui-md .aui-md-table tbody,
+[data-slot='aui_assistant-message-content'] .aui-md .aui-md-table tr,
+[data-slot='aui_assistant-message-content'] .aui-md .aui-md-table th,
+[data-slot='aui_assistant-message-content'] .aui-md .aui-md-table td {
margin: 0 !important;
+ margin-block-start: 0 !important;
+ margin-block-end: 0 !important;
}
[data-slot='tool-block'] + [data-slot='tool-block'] {
|