fix(desktop): rename "Restart messaging" → "Restart gateway", surface restarts in the statusbar, make logs selectable (#49094)

* fix(desktop): rename "Restart messaging" -> "Restart gateway"

The Command Center control restarts the whole messaging gateway, yet was
labelled "Restart messaging" while the status line above it reads "Messaging
gateway running/stopped". Rename the i18n key to match what it does, across
all 4 locales.

* 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.

* fix(desktop): offer a Restart gateway action on messaging save/toggle toasts

The "setup saved" and "platform enabled/disabled" toasts told users their
change needs a gateway restart but left it a separate hunt. Attach a "Restart
gateway" action (the shared runGatewayRestart), and reword the copy to state
the pending consequence ("...takes effect after a gateway restart") now that
the button carries the verb. Updated all 4 locales.

* fix(desktop): make rendered logs selectable so they can be copied

The global body { user-select: none } left log surfaces unselectable. Opt them
back in via the existing data-selectable-text convention — at the shared
LogView primitive (boot-failure + bootstrap install overlays) plus Command
Center recent logs, toolset post-setup output, notification detail, and
subagent stream/file lines.
This commit is contained in:
brooklyn! 2026-06-19 10:09:15 -05:00 committed by GitHub
commit 0e8b76532e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 117 additions and 24 deletions

View file

@ -357,7 +357,7 @@ function SubagentRow({ node, depth = 0, nowMs }: { node: SubagentNode; depth?: n
</button>
{visibleRows.length > 0 ? (
<div className="grid min-w-0 gap-1 pl-6">
<div className="grid min-w-0 gap-1 pl-6" data-selectable-text="true">
{visibleRows.map((entry, i) => (
<StreamLine
active={running && i === visibleRows.length - 1}
@ -371,7 +371,7 @@ function SubagentRow({ node, depth = 0, nowMs }: { node: SubagentNode; depth?: n
) : null}
{open && fileLines.length > 0 ? (
<div className="grid min-w-0 gap-0.5 pl-6">
<div className="grid min-w-0 gap-0.5 pl-6" data-selectable-text="true">
<p className="text-[0.58rem] font-medium tracking-wider text-muted-foreground/60 uppercase">
{t.agents.files}
</p>

View file

@ -395,7 +395,7 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on
</div>
<div className="flex shrink-0 items-center gap-1.5 whitespace-nowrap">
<Button onClick={() => void runSystemAction('restart')} size="xs" variant="text">
{cc.restartMessaging}
{cc.restartGateway}
</Button>
<Button onClick={() => void runSystemAction('update')} size="xs" variant="textStrong">
{cc.updateHermes}
@ -426,7 +426,10 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on
</span>
)}
</div>
<pre className="min-h-0 flex-1 overflow-auto whitespace-pre-wrap wrap-break-word rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-3 font-mono text-[0.65rem] leading-relaxed text-(--ui-text-tertiary)">
<pre
className="min-h-0 flex-1 overflow-auto whitespace-pre-wrap wrap-break-word rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-3 font-mono text-[0.65rem] leading-relaxed text-(--ui-text-tertiary)"
data-selectable-text="true"
>
{logs.length ? logs.join('\n') : cc.noLogs}
</pre>
</div>

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

@ -17,6 +17,7 @@ import { type Translations, useI18n } from '@/i18n'
import { AlertTriangle, ExternalLink, Save, Trash2 } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import { runGatewayRestart } from '@/store/system-actions'
import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
@ -97,6 +98,8 @@ function fieldCopy(field: MessagingEnvVarInfo, m: Translations['messaging']) {
export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...props }: MessagingViewProps) {
const { t } = useI18n()
const m = t.messaging
// Both save/toggle toasts offer the same one-click restart.
const restartGatewayAction = { label: t.commandCenter.restartGateway, onClick: () => void runGatewayRestart() }
const [platforms, setPlatforms] = useState<MessagingPlatformInfo[] | null>(null)
const [edits, setEdits] = useState<EditMap>({})
const [query, setQuery] = useState('')
@ -197,7 +200,8 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
notify({
kind: 'success',
title: enabled ? m.platformEnabled(platform.name) : m.platformDisabled(platform.name),
message: m.restartToApply
message: m.restartToApply,
action: restartGatewayAction
})
} catch (err) {
notifyError(err, m.failedUpdate(platform.name))
@ -222,7 +226,8 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
notify({
kind: 'success',
title: m.setupSaved(platform.name),
message: m.restartToReconnect
message: m.restartToReconnect,
action: restartGatewayAction
})
} catch (err) {
notifyError(err, m.failedSave(platform.name))

View file

@ -272,7 +272,10 @@ function PostSetupRunner({ toolset, postSetupKey, onComplete }: PostSetupRunnerP
</div>
{status && (status.lines.length > 0 || status.running) && (
<pre className="max-h-48 overflow-y-auto rounded-md bg-background px-2.5 py-1.5 font-mono text-[0.7rem] leading-relaxed text-muted-foreground whitespace-pre-wrap">
<pre
className="max-h-48 overflow-y-auto rounded-md bg-background px-2.5 py-1.5 font-mono text-[0.7rem] leading-relaxed text-muted-foreground whitespace-pre-wrap"
data-selectable-text="true"
>
{status.lines.length > 0 ? status.lines.join('\n') : copy.postSetupStarting}
</pre>
)}

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

@ -154,7 +154,10 @@ function NotificationDetail({ detail }: { detail: string }) {
<details className="mt-2 text-xs text-muted-foreground">
<summary className="select-none font-medium text-muted-foreground hover:text-foreground">{copy.details}</summary>
<div className="mt-1 rounded-md bg-background/65 p-2">
<pre className="max-h-32 whitespace-pre-wrap wrap-break-word font-mono text-[0.6875rem] leading-relaxed">
<pre
className="max-h-32 whitespace-pre-wrap wrap-break-word font-mono text-[0.6875rem] leading-relaxed"
data-selectable-text="true"
>
{detail}
</pre>
<CopyButton

View file

@ -4,6 +4,7 @@ import { cn } from '@/lib/utils'
// Shared raw-log viewer: no bg, hairline border, tight padding, small mono.
// One style everywhere we surface logs. Pass a max-h-* via className.
// Selectable by default — logs exist to be read and copied.
export function LogView({ className, ...props }: ComponentProps<'div'>) {
return (
<div
@ -11,6 +12,7 @@ export function LogView({ className, ...props }: ComponentProps<'div'>) {
'overflow-auto rounded-lg border border-(--ui-stroke-tertiary) px-2.5 py-1.5 font-mono text-[0.6875rem] leading-[1.5] whitespace-pre-wrap break-words text-(--ui-text-tertiary) [scrollbar-width:thin]',
className
)}
data-selectable-text="true"
{...props}
/>
)

View file

@ -763,7 +763,8 @@ export const en: Translations = {
gatewayRunning: 'Messaging gateway running',
gatewayStopped: 'Messaging gateway stopped',
hermesActiveSessions: (version, count) => `Hermes ${version} · Active sessions ${count}`,
restartMessaging: 'Restart messaging',
restartGateway: 'Restart gateway',
gatewayRestartFailed: 'Gateway restart failed.',
updateHermes: 'Update Hermes',
actionRunning: 'running',
actionDone: 'done',
@ -832,9 +833,9 @@ export const en: Translations = {
disableAria: name => `Disable ${name}`,
platformEnabled: name => `${name} enabled`,
platformDisabled: name => `${name} disabled`,
restartToApply: 'Restart the gateway for this change to take effect.',
restartToApply: 'This change takes effect after a gateway restart.',
setupSaved: name => `${name} setup saved`,
restartToReconnect: 'Restart the gateway to reconnect with the new credentials.',
restartToReconnect: 'New credentials take effect after a gateway restart.',
keyCleared: key => `${key} cleared`,
setupUpdated: name => `${name} setup was updated.`,
failedUpdate: name => `Failed to update ${name}`,
@ -1589,6 +1590,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

@ -883,7 +883,8 @@ export const ja = defineLocale({
gatewayRunning: 'メッセージングゲートウェイが実行中',
gatewayStopped: 'メッセージングゲートウェイが停止中',
hermesActiveSessions: (version, count) => `Hermes ${version} · アクティブセッション ${count}`,
restartMessaging: 'メッセージングを再起動',
restartGateway: 'ゲートウェイを再起動',
gatewayRestartFailed: 'ゲートウェイの再起動に失敗しました。',
updateHermes: 'Hermes を更新',
actionRunning: '実行中',
actionDone: '完了',
@ -953,9 +954,9 @@ export const ja = defineLocale({
disableAria: name => `${name} を無効にする`,
platformEnabled: name => `${name} を有効にしました`,
platformDisabled: name => `${name} を無効にしました`,
restartToApply: 'この変更を有効にするにはゲートウェイを再起動してください。',
restartToApply: 'この変更はゲートウェイの再起動後に有効になります。',
setupSaved: name => `${name} の設定を保存しました`,
restartToReconnect: '新しい認証情報で再接続するにはゲートウェイを再起動してください。',
restartToReconnect: '新しい認証情報はゲートウェイの再起動後に有効になります。',
keyCleared: key => `${key} をクリアしました`,
setupUpdated: name => `${name} の設定が更新されました。`,
failedUpdate: name => `${name} の更新に失敗しました`,
@ -1719,6 +1720,7 @@ export const ja = defineLocale({
gatewayChecking: '確認中',
gatewayConnecting: '接続中',
gatewayOffline: 'オフライン',
gatewayRestarting: '再起動中…',
gatewayTitle: 'Hermes 推論ゲートウェイのステータス',
agents: 'エージェント',
closeAgents: 'エージェントを閉じる',

View file

@ -627,7 +627,8 @@ export interface Translations {
gatewayRunning: string
gatewayStopped: string
hermesActiveSessions: (version: string, count: number) => string
restartMessaging: string
restartGateway: string
gatewayRestartFailed: string
updateHermes: string
actionRunning: string
actionDone: string
@ -1231,6 +1232,7 @@ export interface Translations {
gatewayChecking: string
gatewayConnecting: string
gatewayOffline: string
gatewayRestarting: string
gatewayTitle: string
agents: string
closeAgents: string

View file

@ -856,7 +856,8 @@ export const zhHant = defineLocale({
gatewayRunning: '訊息閘道執行中',
gatewayStopped: '訊息閘道已停止',
hermesActiveSessions: (version, count) => `Hermes ${version} · 活躍工作階段 ${count}`,
restartMessaging: '重新啟動訊息服務',
restartGateway: '重新啟動閘道',
gatewayRestartFailed: '閘道重新啟動失敗。',
updateHermes: '更新 Hermes',
actionRunning: '執行中',
actionDone: '完成',
@ -925,9 +926,9 @@ export const zhHant = defineLocale({
disableAria: name => `停用 ${name}`,
platformEnabled: name => `${name} 已啟用`,
platformDisabled: name => `${name} 已停用`,
restartToApply: '重新啟動閘道後此變更才會生效。',
restartToApply: '此變更將在閘道重新啟動後生效。',
setupSaved: name => `${name} 設定已儲存`,
restartToReconnect: '重新啟動閘道以使用新憑證重新連線。',
restartToReconnect: '新憑證將在閘道重新啟動後生效。',
keyCleared: key => `${key} 已清除`,
setupUpdated: name => `${name} 設定已更新。`,
failedUpdate: name => `更新 ${name} 失敗`,
@ -1663,6 +1664,7 @@ export const zhHant = defineLocale({
gatewayChecking: '檢查中',
gatewayConnecting: '連線中',
gatewayOffline: '離線',
gatewayRestarting: '重新啟動中…',
gatewayTitle: 'Hermes 推論閘道狀態',
agents: '代理',
closeAgents: '關閉代理',

View file

@ -953,7 +953,8 @@ export const zh: Translations = {
gatewayRunning: '消息网关运行中',
gatewayStopped: '消息网关已停止',
hermesActiveSessions: (version, count) => `Hermes ${version} · 活跃会话 ${count}`,
restartMessaging: '重启消息服务',
restartGateway: '重启网关',
gatewayRestartFailed: '网关重启失败。',
updateHermes: '更新 Hermes',
actionRunning: '运行中',
actionDone: '完成',
@ -1022,9 +1023,9 @@ export const zh: Translations = {
disableAria: name => `禁用 ${name}`,
platformEnabled: name => `${name} 已启用`,
platformDisabled: name => `${name} 已禁用`,
restartToApply: '重启网关后此更改才会生效。',
restartToApply: '此更改将在网关重启后生效。',
setupSaved: name => `${name} 设置已保存`,
restartToReconnect: '重启网关以使用新凭据重新连接。',
restartToReconnect: '新凭据将在网关重启后生效。',
keyCleared: key => `${key} 已清除`,
setupUpdated: name => `${name} 设置已更新。`,
failedUpdate: name => `更新 ${name} 失败`,
@ -1769,6 +1770,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)
}
}