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 ( -
    - -
  • ), 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'] {