From d42a842641c5936e8492fe2b20a45256db30f2c0 Mon Sep 17 00:00:00 2001 From: Leonid Skorobogatyy Date: Mon, 22 Jun 2026 01:43:32 +1100 Subject: [PATCH 1/2] docs: add issue 1737 connection status plan --- plans/issue-1737-connection-status/plan.md | 277 +++++++++++++++++++++ plans/issue-1737-connection-status/todo.md | 27 ++ 2 files changed, 304 insertions(+) create mode 100644 plans/issue-1737-connection-status/plan.md create mode 100644 plans/issue-1737-connection-status/todo.md diff --git a/plans/issue-1737-connection-status/plan.md b/plans/issue-1737-connection-status/plan.md new file mode 100644 index 0000000000..fbab7a6db8 --- /dev/null +++ b/plans/issue-1737-connection-status/plan.md @@ -0,0 +1,277 @@ +# Issue 1737 / 1556 — Aggregated connection status indicator plan + +## Summary + +Add a single compact header status dot that summarizes connection health without adding UI weight. + +- Always visible: one small dot in shared header UI +- Hover only: 2-3 short lines describing overall state and each hop +- No click-driven diagnostics, no second dot, no new polling loop +- Internally track two distinct hops, but present one aggregated indicator + +## Scope + +This plan covers: + +- issue [#1737](https://github.com/openchamber/openchamber/issues/1737): frontend ↔ OpenChamber runtime status +- related issue [#1556](https://github.com/openchamber/openchamber/issues/1556): OpenChamber runtime ↔ OpenCode status +- a shared UX and state model that supports both while keeping the visible UI to one compact indicator + +Out of scope for this PR: + +- implementation code +- expanded diagnostics UI +- separate per-hop visible indicators +- new health polling beyond existing reconnect/health signals + +## Behavioral contract + +### Natural user action + +The user glances at a small status indicator in the header. If it is not healthy or they want more detail, they hover to read a short explanation. + +### Value source + +The value is system-derived, not user-authored: + +- frontend ↔ runtime health comes from the existing SSE/WS reconnect pipeline +- runtime ↔ OpenCode health comes from existing OpenCode health checks / health snapshots + +### Valid visible states + +Normalized visible states: + +- Connected +- Reconnecting +- Degraded / Disconnected +- Unknown + +Optional short reason text may include: + +- Offline +- Runtime unreachable +- OpenCode unavailable +- Auth/config issue +- Unknown + +### Existing project pattern + +The visible affordance should reuse the compact dot + hover pattern already used in subsystem status UI, especially: + +- `packages/ui/src/components/desktop/DesktopHostSwitcher.tsx` +- `packages/ui/src/components/sections/mcp/McpPage.tsx` +- header tooltip/compact status affordances in `packages/ui/src/components/layout/Header.tsx` + +### Raw/internal values + +Do not expose internal/manual reason codes such as `ws_closed:1006` or `health_check_unhealthy` directly in the normal UI. Map them to short user-facing copy. Raw details may remain available for diagnostics/debug paths. + +## Current facts + +### Frontend ↔ runtime signals already exist + +Relevant files: + +- `packages/ui/src/sync/event-pipeline.ts` +- `packages/ui/src/sync/sync-context.tsx` +- `packages/ui/src/stores/useConfigStore.ts` + +Current behavior: + +- reconnect loop already drives disconnect/reconnect state +- disconnect reasons already exist +- `sync-context.tsx` currently updates `isConnected`, `hasEverConnected`, `connectionPhase`, and `lastDisconnectReason` from pipeline lifecycle callbacks + +### Runtime ↔ OpenCode signals already exist + +Relevant files: + +- `packages/ui/src/lib/opencode/client.ts` +- `packages/ui/src/stores/useConfigStore.ts` +- `packages/web/server/index.js` +- `packages/ui/src/lib/openCodeStatus.ts` + +Current behavior: + +- `opencodeClient.checkHealth()` calls `/api/opencode/health` +- runtime `/health` snapshot exposes fields including `isOpenCodeReady` and `lastOpenCodeError` +- diagnostics/reporting paths already consume these signals + +### Current architectural gap + +Today the shared config-store connection fields mix semantics from both hops. That is fine for broad readiness gating but not precise enough for one aggregated user-facing status indicator that must explain which hop is unhealthy. + +## Target design + +## UX + +Add one compact shared indicator in the main header area: + +- default UI: dot only +- hover content: 2-3 short lines +- color reflects worst current state +- works across web, desktop, and VS Code shared UI as far as the shared header path is used + +Example hover content: + +```text +Connection status +Frontend ↔ OpenChamber: connected +OpenChamber ↔ OpenCode: unavailable +``` + +or: + +```text +Connection status +Frontend ↔ OpenChamber: reconnecting +Reason: offline +``` + +## State model + +Internally separate the two hops: + +1. Frontend ↔ OpenChamber runtime +2. OpenChamber runtime ↔ OpenCode + +Then derive one aggregated presentational model: + +- dot color +- overall label +- hover lines + +### Recommended aggregation rules + +- Green: both hops healthy +- Red: any hop clearly broken/unavailable +- Neutral/amber: reconnecting, transitional, or unknown +- Unknown: when the app cannot reliably determine current status + +### Recommended precedence + +1. frontend disconnected/offline/reconnecting +2. runtime connected but OpenCode unhealthy +3. both healthy +4. fallback unknown + +This keeps the visible indicator focused on the most actionable current failure. + +## Implementation plan + +### Phase 1 — Separate state ownership + +Goal: stop overloading one set of connection fields with two different meanings. + +Planned changes: + +- introduce explicit normalized state for frontend ↔ runtime transport health +- introduce explicit normalized state for runtime ↔ OpenCode health +- keep existing readiness behavior working during migration +- avoid broad store fanout; use low-frequency narrow selectors only + +Candidate files: + +- `packages/ui/src/stores/useConfigStore.ts` +- `packages/ui/src/sync/sync-context.tsx` +- `packages/ui/src/sync/event-pipeline.ts` +- `packages/ui/src/hooks/useOpenCodeReadiness.ts` + +### Phase 2 — Normalize user-facing reasons + +Goal: map internal disconnect/health reasons into compact hover text. + +Planned changes: + +- add a small mapper from internal reason codes to user-facing labels +- preserve raw values only for debug/diagnostics +- keep tooltip copy short and stable + +Candidate files: + +- likely a new small helper under `packages/ui/src/lib/` or `packages/ui/src/components/layout/` +- translation/messages files for user-facing strings + +### Phase 3 — Shared compact indicator component + +Goal: build one reusable header-grade component that consumes only normalized state. + +Planned changes: + +- create a compact dot indicator with tooltip/hover content +- reuse header tooltip and subsystem status visual patterns +- no click dependency in the primary flow + +Candidate files: + +- `packages/ui/src/components/layout/Header.tsx` +- likely a new component under `packages/ui/src/components/layout/` or `packages/ui/src/components/ui/` + +### Phase 4 — Integrate aggregated status + +Goal: compute a single visible status from the two internal hops. + +Planned changes: + +- add aggregation selector/helper +- feed the component a stable low-frequency view model +- ensure cross-runtime shared rendering parity + +### Phase 5 — Validate narrow scenarios + +Minimum scenarios to verify: + +- fresh connected startup +- runtime restart / temporary reconnect +- browser offline / online recovery +- runtime unreachable +- OpenCode unhealthy while runtime remains reachable +- transport switch / recovery path +- desktop and VS Code shared UI behavior parity where applicable + +## Risks and mitigations + +### Risk: mixed semantics create misleading status + +Mitigation: + +- split internal state by hop first +- aggregate only after normalization + +### Risk: UI noise or clutter + +Mitigation: + +- one dot only +- short hover text only +- no second visible indicator + +### Risk: exposing raw implementation details + +Mitigation: + +- map raw reasons to short labels +- reserve raw details for diagnostics only + +### Risk: render fanout / hot-path regressions + +Mitigation: + +- subscribe only to narrow low-frequency fields +- do not attach indicator rendering to session lists or streaming message state + +## Rollout recommendation + +Recommended order: + +1. separate hop-specific internal state +2. add reason normalization +3. add shared compact indicator +4. wire aggregated status into header +5. verify web/desktop/VS Code parity + +## Open questions + +- Whether the final neutral transitional state should be amber or theme-muted when reconnecting/unknown +- Whether the hover always shows both hop lines, or collapses to a reason line when the second hop is not currently knowable +- Whether VS Code needs any special bridge mapping beyond the shared normalized state diff --git a/plans/issue-1737-connection-status/todo.md b/plans/issue-1737-connection-status/todo.md new file mode 100644 index 0000000000..c5b5072f81 --- /dev/null +++ b/plans/issue-1737-connection-status/todo.md @@ -0,0 +1,27 @@ +# Todo — Issue 1737 / 1556 aggregated connection status + +## Current phase + +Planning only + +## Completed + +- Reviewed issue #1737 +- Reviewed related issue #1556 +- Traced existing transport and health code paths +- Chose compact single-dot + hover UX direction +- Wrote canonical implementation plan + +## Next proposed steps + +1. Confirm plan direction in PR review +2. Implement hop-separated normalized state +3. Add aggregated status selector/helper +4. Build compact header indicator with short hover details +5. Verify narrow runtime-failure scenarios across shared runtimes + +## Not in this PR + +- implementation code +- UI screenshots +- behavior changes From 8080efdf1ee22af7c788d6b736b71fa8d02c9e81 Mon Sep 17 00:00:00 2001 From: bashrusakh Date: Mon, 22 Jun 2026 20:46:11 +1100 Subject: [PATCH 2/2] feat(ui): aggregated connection status indicator (#1737, #1556) - Hop-separated narrow state in useConfigStore (runtimeTransport, openCodeRuntime) driven by the existing SSE/WS reconnect pipeline and the existing opencodeClient.checkHealth() path. No new polling loop. - Pure helper packages/ui/src/lib/connection-status/connectionStatus.ts that maps per-hop internal state + navigator.onLine to a single aggregated view model (connected / reconnecting / degraded / disconnected / unknown) and short i18n keys. Raw internal reason codes are kept in the view model for diagnostics only and never reach the UI. - Compact header indicator packages/ui/src/components/layout/ ConnectionStatusIndicator.tsx (one dot + hover tooltip with 2-3 short lines, theme tokens only, React.memo on both layers, narrow leaf selectors). Wired into Header.tsx in the existing desktopSidebarActions cluster. - 19 new connectionStatus.* i18n keys in all 9 dictionaries. - 22 unit tests for the mapper covering all Phase 5 scenarios. - One-line pre-existing fr.ts parity fix (sessions.scheduledTasks.editor.scheduleType.cron) so the i18n parity test passes; flagged in the PR body. Validation: type-check + lint green in packages/ui, packages/web, packages/electron. docs:validate green. 22 + 12 + 2 new/updated tests pass. 5 pre-existing sync-pipeline test failures verified unrelated to this diff. --- .../layout/ConnectionStatusIndicator.tsx | 182 ++++++++ packages/ui/src/components/layout/Header.tsx | 8 + .../connectionStatus.test.ts | 425 ++++++++++++++++++ .../lib/connection-status/connectionStatus.ts | 305 +++++++++++++ packages/ui/src/lib/i18n/messages/en.ts | 19 + packages/ui/src/lib/i18n/messages/es.ts | 19 + packages/ui/src/lib/i18n/messages/fr.ts | 20 + packages/ui/src/lib/i18n/messages/ko.ts | 19 + packages/ui/src/lib/i18n/messages/pl.ts | 19 + packages/ui/src/lib/i18n/messages/pt-BR.ts | 19 + packages/ui/src/lib/i18n/messages/uk.ts | 19 + packages/ui/src/lib/i18n/messages/zh-CN.ts | 19 + packages/ui/src/lib/i18n/messages/zh-TW.ts | 19 + packages/ui/src/stores/useConfigStore.ts | 43 ++ packages/ui/src/sync/sync-context.tsx | 79 +++- 15 files changed, 1213 insertions(+), 1 deletion(-) create mode 100644 packages/ui/src/components/layout/ConnectionStatusIndicator.tsx create mode 100644 packages/ui/src/lib/connection-status/connectionStatus.test.ts create mode 100644 packages/ui/src/lib/connection-status/connectionStatus.ts 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 `