diff --git a/apps/desktop/src/app/command-center/index.tsx b/apps/desktop/src/app/command-center/index.tsx index 9eacc0f41ee..80b0e9690c3 100644 --- a/apps/desktop/src/app/command-center/index.tsx +++ b/apps/desktop/src/app/command-center/index.tsx @@ -9,7 +9,7 @@ import { getActionStatus, getLogs, getStatus, getUsageAnalytics, restartGateway, import type { ActionStatusResponse, AnalyticsResponse, StatusResponse } from '@/hermes' import { useI18n } from '@/i18n' import { sessionTitle } from '@/lib/chat-runtime' -import { Activity, AlertCircle, BarChart3, Bookmark, BookmarkFilled, Download, Pin, Trash2 } from '@/lib/icons' +import { Activity, AlertCircle, BarChart3, Bookmark, BookmarkFilled, Download, MessageCircle, Trash2 } from '@/lib/icons' import { exportSession } from '@/lib/session-export' import { cn } from '@/lib/utils' import { upsertDesktopActionTask } from '@/store/activity' @@ -263,7 +263,7 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on {SECTIONS.map(value => ( setSection(value)} @@ -361,7 +361,7 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on /> ) : (
-
+
{status ? (
@@ -406,7 +406,7 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on )}
-
+
{cc.recentLogs} @@ -503,7 +503,7 @@ function UsagePanel({ error, loading, onRefresh, period, usage }: UsagePanelProp )} -
+
-
+
({ diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx index f9dc2d8320b..854abadda5a 100644 --- a/apps/desktop/src/app/desktop-controller.tsx +++ b/apps/desktop/src/app/desktop-controller.tsx @@ -581,7 +581,7 @@ export function DesktopController() { } }, []) - const { gatewayLogLines, inferenceStatus, statusSnapshot } = useStatusSnapshot(gatewayState, requestGateway) + const { inferenceStatus, statusSnapshot } = useStatusSnapshot(gatewayState, requestGateway) const updateActiveSessionRuntimeInfo = useCallback( (info: { branch?: string; cwd?: string }) => { @@ -1061,7 +1061,6 @@ export function DesktopController() { commandCenterOpen, extraLeftItems: statusbarItemGroups.flat.left, extraRightItems: statusbarItemGroups.flat.right, - gatewayLogLines, gatewayState, inferenceStatus, openAgents, diff --git a/apps/desktop/src/app/shell/gateway-menu-panel.tsx b/apps/desktop/src/app/shell/gateway-menu-panel.tsx index 72a26d33e54..64f3f7563d1 100644 --- a/apps/desktop/src/app/shell/gateway-menu-panel.tsx +++ b/apps/desktop/src/app/shell/gateway-menu-panel.tsx @@ -1,20 +1,68 @@ +import { useEffect, useRef, useState } from 'react' + import { StatusDot, type StatusTone } from '@/components/status-dot' import { Button } from '@/components/ui/button' +import { LogView } from '@/components/ui/log-view' import { Tip } from '@/components/ui/tooltip' +import { getLogs } from '@/hermes' import { useI18n } from '@/i18n' -import { Activity, AlertCircle, LayoutDashboard } from '@/lib/icons' +import { LayoutDashboard, RefreshCw } from '@/lib/icons' import type { RuntimeReadinessResult } from '@/lib/runtime-readiness' -import { cn } from '@/lib/utils' +import { runGatewayRestart } from '@/store/system-actions' import type { StatusResponse } from '@/types/hermes' interface GatewayMenuPanelProps { gatewayState: string inferenceStatus: RuntimeReadinessResult | null - logLines: readonly string[] + onClose: () => void onOpenSystem: () => void statusSnapshot: StatusResponse | null } +const LOG_TAIL = 120 +const LOG_VISIBLE = 40 +const LOG_POLL_MS = 3_000 + +// Per-connection WebSocket churn (accept/close/heartbeat) drowns out anything +// useful — strip it so the tail reads as real gateway activity at a glance. +const LOG_NOISE_RE = /\bws (?:accepted|closed|response sent|ping|pong)\b/i + +// Live tail while the popover is mounted (i.e. open): poll on a tight cadence +// and stop on unmount, instead of a global always-on status poll. +function useGatewayLogTail(): string[] { + const [lines, setLines] = useState([]) + + useEffect(() => { + let cancelled = false + + const load = () => + getLogs({ file: 'gui', lines: LOG_TAIL }) + .then(res => { + if (cancelled) { + return + } + + setLines( + res.lines + .map(line => line.trim()) + .filter(line => line && !LOG_NOISE_RE.test(line)) + .slice(-LOG_VISIBLE) + ) + }) + .catch(() => {}) + + void load() + const timer = window.setInterval(load, LOG_POLL_MS) + + return () => { + cancelled = true + window.clearInterval(timer) + } + }, []) + + return lines +} + const PLATFORM_TONE: Record = { connected: 'good', connecting: 'warn', @@ -35,12 +83,27 @@ const trimLogLine = (raw: string) => raw.trim().replace(TIMESTAMP_RE, '').replac export function GatewayMenuPanel({ gatewayState, inferenceStatus, - logLines, + onClose, onOpenSystem, statusSnapshot }: GatewayMenuPanelProps) { const { t } = useI18n() const copy = t.shell.gatewayMenu + + // Both jumps open the system panel, which owns the full view — so dismiss the + // little status popover on the way out. + const openSystem = () => { + onClose() + onOpenSystem() + } + + // Shared restart helper: never rejects and surfaces progress in the statusbar + // gateway indicator, so just fire and close. + const restart = () => { + onClose() + void runGatewayRestart() + } + const gatewayOpen = gatewayState === 'open' const gatewayConnecting = gatewayState === 'connecting' const inferenceReady = gatewayOpen && inferenceStatus?.ready === true @@ -60,30 +123,50 @@ export function GatewayMenuPanel({ : copy.disconnected const platforms = Object.entries(statusSnapshot?.gateway_platforms || {}).sort(([l], [r]) => l.localeCompare(r)) - const recentLogs = logLines.slice(-5) + const recentLogs = useGatewayLogTail() + + // Keep the tail pinned to the latest line as it streams. + const logScrollRef = useRef(null) + + useEffect(() => { + const el = logScrollRef.current + + if (el) { + el.scrollTop = el.scrollHeight + } + }, [recentLogs]) return (
-
-
- {inferenceReady ? ( - - ) : ( - - )} - {copy.gateway} - +
+
+ + + {connectionLabel} + + {inferenceLabel}
-
+
+ + +
-
-
{copy.connection(connectionLabel)}
- {inferenceStatus?.reason &&
{inferenceStatus.reason}
} -
+ {inferenceStatus?.reason && ( +
+
{inferenceStatus.reason}
+
+ )} {recentLogs.length > 0 && ( -
- {copy.recentActivity} -
    - {recentLogs.map((line, index) => ( - -
  • - {trimLogLine(line) || '\u00A0'} -
  • -
    - ))} -
- +
+
+ {copy.recentActivity} + +
+ + {recentLogs.map(trimLogLine).join('\n')} +
)} 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 f644fe48c0a..0cdbc5b98e2 100644 --- a/apps/desktop/src/app/shell/hooks/use-status-snapshot.ts +++ b/apps/desktop/src/app/shell/hooks/use-status-snapshot.ts @@ -1,17 +1,15 @@ import { useEffect, useState } from 'react' -import { getLogs, getStatus } from '@/hermes' +import { 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 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(() => { @@ -19,9 +17,8 @@ export function useStatusSnapshot(gatewayState: string | undefined, requestGatew const refresh = async () => { try { - const [next, logs, inference] = await Promise.all([ + const [next, inference] = await Promise.all([ getStatus(), - getLogs({ file: 'gui', lines: LOG_TAIL }).catch(() => ({ lines: [] })), gatewayState === 'open' ? evaluateRuntimeReadiness(requestGateway).catch(error => ({ checksDisagree: false, @@ -37,7 +34,6 @@ export function useStatusSnapshot(gatewayState: string | undefined, requestGatew } setStatusSnapshot(next) - setGatewayLogLines(logs.lines.map(line => line.trim()).filter(Boolean)) setInferenceStatus(inference) } catch { // Keep last snapshot through transient gateway flaps. @@ -53,5 +49,5 @@ export function useStatusSnapshot(gatewayState: string | undefined, requestGatew } }, [gatewayState, requestGateway]) - return { gatewayLogLines, inferenceStatus, statusSnapshot } + return { 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 f51889ebcfe..b6328be6543 100644 --- a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx +++ b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx @@ -43,7 +43,6 @@ interface StatusbarItemsOptions { commandCenterOpen: boolean extraLeftItems: readonly StatusbarItem[] extraRightItems: readonly StatusbarItem[] - gatewayLogLines: readonly string[] gatewayState: string inferenceStatus: RuntimeReadinessResult | null openAgents: () => void @@ -60,7 +59,6 @@ export function useStatusbarItems({ commandCenterOpen, extraLeftItems, extraRightItems, - gatewayLogLines, gatewayState, inferenceStatus, openAgents, @@ -131,16 +129,16 @@ export function useStatusbarItems({ const showYoloToggle = gatewayState === 'open' && (!!activeSessionId || freshDraftReady) const gatewayMenuContent = useMemo( - () => ( + () => (close: () => void) => ( openCommandCenterSection('system')} statusSnapshot={statusSnapshot} /> ), - [gatewayLogLines, gatewayState, inferenceStatus, openCommandCenterSection, statusSnapshot] + [gatewayState, inferenceStatus, openCommandCenterSection, statusSnapshot] ) // The indicator must speak the same scope as the Spawn-tree panel it opens: diff --git a/apps/desktop/src/app/shell/statusbar-controls.tsx b/apps/desktop/src/app/shell/statusbar-controls.tsx index 20f2f52e514..f33099a6f82 100644 --- a/apps/desktop/src/app/shell/statusbar-controls.tsx +++ b/apps/desktop/src/app/shell/statusbar-controls.tsx @@ -1,4 +1,4 @@ -import type { ComponentProps, ReactNode } from 'react' +import { type ComponentProps, type ReactNode, useState } from 'react' import { useNavigate } from 'react-router-dom' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' @@ -34,7 +34,8 @@ export interface StatusbarItem { href?: string menuAlign?: 'center' | 'end' | 'start' menuClassName?: string - menuContent?: ReactNode + // A render fn receives a `close()` to dismiss the popover from inside the content. + menuContent?: ((close: () => void) => ReactNode) | ReactNode menuItems?: readonly StatusbarMenuItem[] onSelect?: (modifiers: StatusbarSelectModifiers) => void title?: string @@ -88,6 +89,8 @@ export function StatusbarControls({ className, leftItems = [], items = [], ...pr } function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate: ReturnType }) { + const [menuOpen, setMenuOpen] = useState(false) + const content = ( <> {item.icon} @@ -99,7 +102,7 @@ function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate: if (item.variant === 'menu' && (item.menuContent || (item.menuItems && item.menuItems.length > 0))) { return ( - +