feat(desktop): restart the gateway from Cmd+K, with statusbar spinner feedback

Add a shared runGatewayRestart() (store/system-actions.ts) and wire it to a
new Cmd+K "Restart gateway" action. While a restart is in flight the
statusbar "Gateway" item swaps its icon for the TUI glyph spinner and reads
"restarting…", returning to its real state on completion — driven by a
$gatewayRestarting atom, not a transient toast or the generic "Agents
running" counter. The helper owns its error handling so fire-and-forget
callers can't leak an unhandled rejection; only a failure toasts.
This commit is contained in:
Brooklyn Nicholson 2026-06-19 10:02:54 -05:00
parent 6308d3416a
commit 553cf4f977
8 changed files with 80 additions and 3 deletions

View file

@ -30,6 +30,7 @@ import {
Package,
Palette,
Plus,
RefreshCw,
Settings,
Settings2,
Sun,
@ -41,6 +42,7 @@ import {
import { cn } from '@/lib/utils'
import { $commandPaletteOpen, closeCommandPalette, setCommandPaletteOpen } from '@/store/command-palette'
import { $bindings } from '@/store/keybinds'
import { runGatewayRestart } from '@/store/system-actions'
import { luminance } from '@/themes/color'
import { type ThemeMode, useTheme } from '@/themes/context'
import { isUserTheme, resolveTheme } from '@/themes/user-themes'
@ -360,6 +362,13 @@ export function CommandPalette() {
keywords: ['command center', 'usage', 'tokens', 'cost'],
label: cc.sections.usage,
run: go(`${COMMAND_CENTER_ROUTE}?section=usage`)
},
{
icon: RefreshCw,
id: 'cc-restart-gateway',
keywords: ['gateway', 'restart', 'messaging', 'reconnect', 'system'],
label: cc.restartGateway,
run: () => void runGatewayRestart()
}
]
},

View file

@ -4,6 +4,7 @@ import { useCallback, useMemo } from 'react'
import type { CommandCenterSection } from '@/app/command-center'
import { $terminalTakeover, setTerminalTakeover } from '@/app/right-sidebar/store'
import { GatewayMenuPanel } from '@/app/shell/gateway-menu-panel'
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
import { useI18n } from '@/i18n'
import {
Activity,
@ -35,6 +36,7 @@ import {
setYoloActive
} from '@/store/session'
import { $subagentsBySession, activeSubagentCount } from '@/store/subagents'
import { $gatewayRestarting } from '@/store/system-actions'
import {
$backendUpdateApply,
$backendUpdateStatus,
@ -89,6 +91,7 @@ export function useStatusbarItems({
const busy = useStore($busy)
const currentUsage = useStore($currentUsage)
const desktopActionTasks = useStore($desktopActionTasks)
const gatewayRestarting = useStore($gatewayRestarting)
const previewServerRestartStatus = useStore($previewServerRestartStatus)
const sessionStartedAt = useStore($sessionStartedAt)
const turnStartedAt = useStore($turnStartedAt)
@ -299,9 +302,15 @@ export function useStatusbarItems({
variant: 'action'
},
{
className: gatewayClassName,
detail: gatewayDetail,
icon: inferenceReady ? <Activity className="size-3" /> : <AlertCircle className="size-3" />,
className: gatewayRestarting ? undefined : gatewayClassName,
detail: gatewayRestarting ? copy.gatewayRestarting : gatewayDetail,
icon: gatewayRestarting ? (
<GlyphSpinner ariaLabel={copy.gatewayRestarting} className="size-3" />
) : inferenceReady ? (
<Activity className="size-3" />
) : (
<AlertCircle className="size-3" />
),
id: 'gateway-health',
label: copy.gateway,
menuClassName: 'w-72',
@ -354,6 +363,7 @@ export function useStatusbarItems({
gatewayMenuContent,
gatewayClassName,
gatewayDetail,
gatewayRestarting,
inferenceReady,
inferenceStatus?.reason,
openAgents,

View file

@ -762,6 +762,7 @@ export const en: Translations = {
gatewayStopped: 'Messaging gateway stopped',
hermesActiveSessions: (version, count) => `Hermes ${version} · Active sessions ${count}`,
restartGateway: 'Restart gateway',
gatewayRestartFailed: 'Gateway restart failed.',
updateHermes: 'Update Hermes',
actionRunning: 'running',
actionDone: 'done',
@ -1587,6 +1588,7 @@ export const en: Translations = {
gatewayChecking: 'checking',
gatewayConnecting: 'connecting',
gatewayOffline: 'offline',
gatewayRestarting: 'restarting…',
gatewayTitle: 'Hermes inference gateway status',
agents: 'Agents',
closeAgents: 'Close agents',

View file

@ -882,6 +882,7 @@ export const ja = defineLocale({
gatewayStopped: 'メッセージングゲートウェイが停止中',
hermesActiveSessions: (version, count) => `Hermes ${version} · アクティブセッション ${count}`,
restartGateway: 'ゲートウェイを再起動',
gatewayRestartFailed: 'ゲートウェイの再起動に失敗しました。',
updateHermes: 'Hermes を更新',
actionRunning: '実行中',
actionDone: '完了',
@ -1717,6 +1718,7 @@ export const ja = defineLocale({
gatewayChecking: '確認中',
gatewayConnecting: '接続中',
gatewayOffline: 'オフライン',
gatewayRestarting: '再起動中…',
gatewayTitle: 'Hermes 推論ゲートウェイのステータス',
agents: 'エージェント',
closeAgents: 'エージェントを閉じる',

View file

@ -626,6 +626,7 @@ export interface Translations {
gatewayStopped: string
hermesActiveSessions: (version: string, count: number) => string
restartGateway: string
gatewayRestartFailed: string
updateHermes: string
actionRunning: string
actionDone: string
@ -1229,6 +1230,7 @@ export interface Translations {
gatewayChecking: string
gatewayConnecting: string
gatewayOffline: string
gatewayRestarting: string
gatewayTitle: string
agents: string
closeAgents: string

View file

@ -855,6 +855,7 @@ export const zhHant = defineLocale({
gatewayStopped: '訊息閘道已停止',
hermesActiveSessions: (version, count) => `Hermes ${version} · 活躍工作階段 ${count}`,
restartGateway: '重新啟動閘道',
gatewayRestartFailed: '閘道重新啟動失敗。',
updateHermes: '更新 Hermes',
actionRunning: '執行中',
actionDone: '完成',
@ -1661,6 +1662,7 @@ export const zhHant = defineLocale({
gatewayChecking: '檢查中',
gatewayConnecting: '連線中',
gatewayOffline: '離線',
gatewayRestarting: '重新啟動中…',
gatewayTitle: 'Hermes 推論閘道狀態',
agents: '代理',
closeAgents: '關閉代理',

View file

@ -952,6 +952,7 @@ export const zh: Translations = {
gatewayStopped: '消息网关已停止',
hermesActiveSessions: (version, count) => `Hermes ${version} · 活跃会话 ${count}`,
restartGateway: '重启网关',
gatewayRestartFailed: '网关重启失败。',
updateHermes: '更新 Hermes',
actionRunning: '运行中',
actionDone: '完成',
@ -1767,6 +1768,7 @@ export const zh: Translations = {
gatewayChecking: '检查中',
gatewayConnecting: '连接中',
gatewayOffline: '离线',
gatewayRestarting: '重启中…',
gatewayTitle: 'Hermes 推理网关状态',
agents: '代理',
closeAgents: '关闭代理',

View file

@ -0,0 +1,48 @@
import { atom } from 'nanostores'
import { getActionStatus, restartGateway } from '@/hermes'
import { translateNow } from '@/i18n'
import { notifyError } from '@/store/notifications'
import type { ActionResponse } from '@/types/hermes'
const POLL_ATTEMPTS = 18
const POLL_INTERVAL_MS = 1200
const POLL_TIMEOUT_S = 180
// True while a gateway restart is in flight — drives the statusbar gateway
// indicator (glyph spinner) so the restart shows up where users already look,
// instead of a toast that vanishes or a generic "Agents running" counter.
export const $gatewayRestarting = atom(false)
// Poll a backend action to completion (or a bounded window), throwing on a
// non-zero exit so the caller can surface the failure.
async function awaitAction(started: ActionResponse): Promise<void> {
for (let attempt = 0; attempt < POLL_ATTEMPTS; attempt += 1) {
await new Promise(resolve => window.setTimeout(resolve, POLL_INTERVAL_MS))
const status = await getActionStatus(started.name, POLL_TIMEOUT_S)
if (!status.running) {
if (status.exit_code != null && status.exit_code !== 0) {
throw new Error(translateNow('commandCenter.gatewayRestartFailed'))
}
return
}
}
}
// Restart the messaging gateway, surfacing progress in the statusbar gateway
// indicator. Self-contained and never rejects, so every trigger — Cmd+K, the
// messaging save/toggle toasts — gets identical feedback from a plain
// `void runGatewayRestart()`, and a failure is the only thing that toasts.
export async function runGatewayRestart(): Promise<void> {
$gatewayRestarting.set(true)
try {
await awaitAction(await restartGateway())
} catch (err) {
notifyError(err, translateNow('commandCenter.gatewayRestartFailed'))
} finally {
$gatewayRestarting.set(false)
}
}