Improve desktop runtime UX by surfacing inference readiness in gateway status and hardening WSL link opening.

This also stabilizes markdown code/table block spacing and adds root-install guards so desktop dev runs use a healthy workspace dependency tree.
This commit is contained in:
Brooklyn Nicholson
2026-05-15 16:33:04 -05:00
parent d0c20708ce
commit c30550c552
12 changed files with 188 additions and 95 deletions
@@ -867,7 +867,7 @@ export function CommandCenterView({
)}
/>
<span className="font-medium text-foreground">
{status.gateway_running ? 'Gateway running' : 'Gateway not running'}
{status.gateway_running ? 'Messaging gateway running' : 'Messaging gateway stopped'}
</span>
</div>
<div className="mt-1 text-xs text-muted-foreground">
@@ -876,7 +876,7 @@ export function CommandCenterView({
</div>
<div className="flex shrink-0 items-center gap-1.5 whitespace-nowrap">
<OverlayActionButton className="h-7 px-2.5" onClick={() => void runSystemAction('restart')}>
Restart gateway
Restart messaging
</OverlayActionButton>
<OverlayActionButton className="h-7 px-2.5" onClick={() => void runSystemAction('update')}>
Update Hermes
+3 -1
View File
@@ -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,
+2 -2
View File
@@ -33,7 +33,7 @@ const STATE_LABELS: Record<string, string> = {
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({
<SetupPill active={platform.configured}>
{platform.configured ? 'Credentials set' : 'Needs setup'}
</SetupPill>
{!platform.gateway_running && <SetupPill active={false}>Gateway stopped</SetupPill>}
{!platform.gateway_running && <SetupPill active={false}>Messaging gateway stopped</SetupPill>}
</div>
<PlatformHint platform={platform} />
</div>
@@ -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 (
<div className="text-sm">
<div className="flex items-center justify-between gap-2 px-3 py-2.5">
<div className="flex min-w-0 items-center gap-2">
{gatewayRunning ? (
{inferenceReady ? (
<Activity className="size-3.5 text-primary" />
) : (
<AlertCircle className="size-3.5 text-destructive" />
<AlertCircle className={cn('size-3.5', gatewayOpen ? 'text-amber-600' : 'text-destructive')} />
)}
<span className="font-medium">Gateway</span>
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
<StatusDot tone={gatewayRunning ? 'good' : 'bad'} />
{stateLabel}
<StatusDot tone={inferenceReady ? 'good' : gatewayOpen ? 'warn' : 'bad'} />
{inferenceLabel}
</span>
</div>
<div className="flex items-center">
<Button
aria-label={restarting ? 'Restarting gateway' : 'Restart gateway'}
className="size-7 text-muted-foreground hover:text-foreground"
disabled={restarting}
onClick={onRestart}
size="icon-sm"
title={restarting ? 'Restarting gateway' : 'Restart gateway'}
variant="ghost"
>
<RefreshCw className={cn(restarting && 'animate-spin')} />
</Button>
<Button
aria-label="Open system panel"
className="size-7 text-muted-foreground hover:text-foreground"
@@ -83,6 +81,11 @@ export function GatewayMenuPanel({
</div>
</div>
<div className="border-t border-border/50 px-3 py-2 text-xs text-muted-foreground">
<div>Connection: {connectionLabel}</div>
{inferenceStatus?.reason && <div className="mt-1 line-clamp-3">{inferenceStatus.reason}</div>}
</div>
{recentLogs.length > 0 && (
<div className="border-t border-border/50 px-3 py-2">
<SectionLabel>Recent activity</SectionLabel>
@@ -109,7 +112,7 @@ export function GatewayMenuPanel({
{platforms.length > 0 && (
<div className="border-t border-border/50 px-3 py-2">
<SectionLabel>Platforms</SectionLabel>
<SectionLabel>Messaging platforms</SectionLabel>
<ul className="mt-1.5 space-y-1">
{platforms.map(([name, platform]) => (
<li className="flex items-center justify-between gap-2 text-xs" key={name}>
@@ -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 = <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T>
export function useStatusSnapshot(gatewayState: string | undefined, requestGateway: GatewayRequester) {
const [statusSnapshot, setStatusSnapshot] = useState<StatusResponse | null>(null)
const [gatewayLogLines, setGatewayLogLines] = useState<string[]>([])
const [inferenceStatus, setInferenceStatus] = useState<RuntimeReadinessResult | null>(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 }
}
@@ -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(
() => (
<GatewayMenuPanel
logLines={gatewayLogLines}
onOpenSystem={() => 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<StatusbarItem>(() => {
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 ? <Activity className="size-3" /> : <AlertCircle className="size-3" />,
className: gatewayClassName,
detail: gatewayDetail,
icon: inferenceReady ? <Activity className="size-3" /> : <AlertCircle className="size-3" />,
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
]
@@ -53,9 +53,8 @@ function CodeHeader({ language, code }: { language?: string; code?: string }) {
const label = cleanLanguage && cleanLanguage !== 'unknown' ? cleanLanguage : ''
return (
<div className="m-0 flex items-stretch justify-between gap-2 rounded-t-md border border-b-0 border-border bg-muted/60 pr-3 text-xs text-muted-foreground">
<span className="flex items-center gap-2.5 py-1.5 pl-0 font-mono uppercase tracking-[0.16em]">
<span aria-hidden="true" className="self-stretch w-[2px] -my-1.5 bg-midground/60" />
<div className="aui-code-header m-0 flex items-stretch justify-between gap-2 rounded-t-md border border-b-0 border-border bg-muted/60 pr-3 text-xs text-muted-foreground">
<span className="flex items-center gap-2.5 py-1.5 pl-3 font-mono uppercase tracking-[0.16em]">
<span className="text-midground/85">{label || 'code'}</span>
</span>
<CopyButton appearance="inline" iconClassName="size-3" label="Copy code" text={normalizedCode}>
@@ -288,10 +287,10 @@ const MarkdownTextImpl = () => {
<li className={cn('leading-(--dt-line-height)', className)} {...props} />
),
table: ({ className, ...props }: ComponentProps<'table'>) => (
<div className="max-w-full overflow-x-auto rounded-md border border-border">
<div className="aui-md-table my-3 max-w-full overflow-x-auto rounded-md border border-border">
<table
className={cn(
'w-full border-collapse text-sm [&_tr]:border-b [&_tr]:border-border last:[&_tr]:border-0',
'm-0 w-full border-collapse text-sm [&_tr]:border-b [&_tr]:border-border last:[&_tr]:border-0',
className
)}
{...props}
@@ -299,12 +298,12 @@ const MarkdownTextImpl = () => {
</div>
),
thead: ({ className, ...props }: ComponentProps<'thead'>) => (
<thead className={cn('bg-muted/50 text-foreground', className)} {...props} />
<thead className={cn('m-0 bg-muted/50 text-foreground', className)} {...props} />
),
th: ({ className, ...props }: ComponentProps<'th'>) => (
<th
className={cn(
'h-9 px-3 text-left align-middle text-xs font-semibold uppercase tracking-[0.16em] text-midground/75',
'px-3 py-1.5 text-left align-middle text-xs font-semibold uppercase tracking-[0.16em] text-midground/75',
className
)}
{...props}
@@ -29,6 +29,9 @@ export const SyntaxHighlighter: FC<HermesSyntaxHighlighterProps> = ({
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<HermesSyntaxHighlighterProps> = ({
if (defer) {
return (
<Pre className="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">
<Pre className={preClassName}>
<code className="block whitespace-pre">{trimmed}</code>
</Pre>
)
}
return (
<Pre className="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">
<Pre className={preClassName}>
<ShikiHighlighter
addDefaultStyles={false}
as="div"
+16 -8
View File
@@ -457,17 +457,25 @@ canvas {
white-space: inherit;
}
[data-slot='aui_assistant-message-content'] .aui-md [data-streamdown='code-block'] {
padding: 0 !important;
gap: 0 !important;
border: 0 !important;
background: transparent !important;
border-radius: 0 !important;
margin: 1rem 0 !important;
[data-slot='aui_assistant-message-content'] .aui-md :where(.aui-code-header, .aui-shiki, .aui-shiki > 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'] {