diff --git a/packages/ui/src/components/layout/ConnectionStatusIndicator.tsx b/packages/ui/src/components/layout/ConnectionStatusIndicator.tsx new file mode 100644 index 0000000000..3235d95860 --- /dev/null +++ b/packages/ui/src/components/layout/ConnectionStatusIndicator.tsx @@ -0,0 +1,182 @@ +import React from 'react'; + +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import { useI18n, type I18nKey, type I18nParams } from '@/lib/i18n'; +import { cn } from '@/lib/utils'; +import { useConfigStore } from '@/stores/useConfigStore'; +import { + buildConnectionStatusViewModel, + type ConnectionTone, + type ConnectionStatusViewModel, +} from '@/lib/connection-status/connectionStatus'; + +/** + * Compact header-grade button styles. Mirrors the icon-button visual rhythm of + * `HeaderIconActionButton` in `Header.tsx` (no drag, hover highlight, focus + * ring) but uses a slightly smaller `h-7 w-7` footprint so the dot can sit + * alongside the existing icon cluster without pushing other controls. + */ +const CONNECTION_INDICATOR_BUTTON_CLASS = + 'app-region-no-drag inline-flex h-7 w-7 items-center justify-center rounded-md hover:bg-interactive-hover transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary'; + +/** The dot itself — a small status pill. Color is supplied via the tone token. */ +const CONNECTION_DOT_CLASS = 'h-2 w-2 rounded-full'; + +/** + * Map a view-model tone to a Tailwind theme-token class. This matches the + * pattern in `DesktopHostSwitcher.tsx` (lines 84-93): `bg-status-*` for the + * meaningful states, `bg-muted-foreground/40` for the muted / unknown state. + * No hardcoded colors and no Tailwind palette classes — the values are + * project theme tokens. + */ +const toneToDotClass = (tone: ConnectionTone): string => { + switch (tone) { + case 'ok': + return 'bg-status-success'; + case 'warn': + return 'bg-status-warning'; + case 'error': + return 'bg-status-error'; + case 'muted': + return 'bg-muted-foreground/40'; + default: { + // Defensive: `ConnectionTone` is a closed union, but TypeScript cannot + // prove exhaustiveness without `never`. Fall through to muted. + const _exhaustive: never = tone; + void _exhaustive; + return 'bg-muted-foreground/40'; + } + } +}; + +type ConnectionStatusIndicatorBodyProps = { + viewModel: ConnectionStatusViewModel; + /** + * The i18n `t` function, passed down from the parent so the body can be + * memoized purely on `viewModel`. When the locale changes, the parent + * re-renders with a new `t` reference and the body picks it up. + */ + t: (key: I18nKey, params?: I18nParams) => string; +}; + +/** + * Inner renderer for the connection status indicator. Split out from the + * public component so that the narrow `useConfigStore` selectors live in + * exactly one place and the dot itself can be `React.memo`'d on + * `viewModel` only. This keeps the dot from re-rendering when unrelated + * state (sessions, streaming deltas, etc.) changes upstream. + */ +const ConnectionStatusIndicatorBody = React.memo(function ConnectionStatusIndicatorBody({ + viewModel, + t, +}: ConnectionStatusIndicatorBodyProps) { + const dotClass = toneToDotClass(viewModel.tone); + const stateLabel = t(viewModel.overallLabelKey as I18nKey); + const ariaLabel = t('connectionStatus.aria.indicator', { state: stateLabel }); + + // Translate each tooltip line. The view model always emits exactly three + // lines: title + hop1 + hop2. When a line carries a `reasonKey` param, + // resolve the reason separately and compose it as + // "". The separator is a non-translated presenter + // concern (per the i18n mapping note in the plan: the reason is itself a + // complete message, not a grammar fragment). + const translatedLines = viewModel.tooltipLines.map((line) => { + const text = t(line.key as I18nKey); + const reasonKey = line.params && typeof line.params.reasonKey === 'string' + ? line.params.reasonKey + : null; + if (reasonKey !== null) { + const reason = t(reasonKey as I18nKey); + return `${text} — ${reason}`; + } + return text; + }); + + return ( + + + + + +
+ {translatedLines.map((line, index) => ( +

+ {line} +

+ ))} +
+
+
+ ); +}); + +/** + * Compact header-grade dot that summarizes connection health across two hops: + * 1. Frontend ↔ OpenChamber runtime + * 2. OpenChamber runtime ↔ OpenCode + * + * Default UI is the dot only. The hover / focus tooltip shows 2-3 short + * lines (title + one per hop). The dot is keyboard-reachable (renders as + * a `