Selaa lähdekoodia

feat(web): add connection recovery indicator

imccyu 3 viikkoa sitten
vanhempi
sitoutus
19b4d7f26c

+ 0 - 13
packages/client/ui-primitives/src/ConnectionBanner.module.css

@@ -1,13 +0,0 @@
-.banner {
-  position: fixed;
-  top: 0;
-  left: 0;
-  right: 0;
-  z-index: 100;
-  padding: 4px 12px;
-  text-align: center;
-  font-size: 12px;
-  line-height: 18px;
-  background: var(--dsw-alias-state-error-primary);
-  color: var(--dsw-alias-label-primary-foreground);
-}

+ 0 - 16
packages/client/ui-primitives/src/ConnectionBanner.tsx

@@ -1,16 +0,0 @@
-import css from './ConnectionBanner.module.css'
-
-/**
- * Render the reconnecting banner.
- * @param props.reconnecting - true while the connection is in backoff/retry.
- * @param props.label - banner text; the owner passes localized copy (this
- * package is cordis-free, so copy arrives via props).
- * @returns the banner, or null when connected.
- */
-export function ConnectionBanner({ reconnecting, label }: {
-  reconnecting: boolean
-  label: string
-}) {
-  if (!reconnecting) return null
-  return <div className={css.banner}>{label}</div>
-}

+ 106 - 0
packages/client/ui-primitives/src/ConnectionIndicator.module.css

@@ -0,0 +1,106 @@
+.indicator {
+  flex: none;
+  display: inline-grid;
+  grid-template-columns: 14px max-content;
+  align-items: center;
+  column-gap: 4px;
+  height: 32px;
+  padding: 0 10px;
+  box-sizing: border-box;
+  border: none;
+  border-radius: 8px;
+  font-family: inherit;
+  font-size: 12px;
+  font-weight: 500;
+  line-height: 18px;
+  white-space: nowrap;
+  transition: background-color 160ms ease-out, color 160ms ease-out;
+}
+
+.warning {
+  background: var(--dsw-alias-state-warn-tertiary);
+  color: var(--dsw-alias-state-warn-label);
+  cursor: pointer;
+}
+
+.warning:active {
+  background: color-mix(
+    in srgb,
+    var(--dsw-alias-state-warn-tertiary),
+    var(--dsw-alias-state-warn-primary) 10%
+  );
+}
+
+.warning:focus-visible {
+  outline: 2px solid var(--dsw-alias-state-warn-label);
+  outline-offset: 2px;
+}
+
+.success {
+  background: var(--dsw-alias-state-success-tertiary);
+  color: var(--dsw-alias-state-success-primary);
+}
+
+.icon {
+  display: grid;
+  place-items: center;
+  width: 14px;
+  height: 14px;
+}
+
+.label {
+  display: grid;
+}
+
+.stateLabel,
+.hoverLabel,
+.sizeLabel {
+  grid-area: 1 / 1;
+}
+
+.sizeLabel {
+  visibility: hidden;
+}
+
+.warning:is(:hover, :focus-visible) .stateLabel {
+  visibility: hidden;
+}
+
+.hoverLabel {
+  visibility: hidden;
+}
+
+.warning:is(:hover, :focus-visible) .hoverLabel {
+  visibility: visible;
+}
+
+.dots {
+  display: inline-block;
+  width: 1.5em;
+  text-align: left;
+}
+
+.secondDot {
+  animation: reveal-second-dot 1.5s step-end infinite;
+}
+
+.thirdDot {
+  animation: reveal-third-dot 1.5s step-end infinite;
+}
+
+@keyframes reveal-second-dot {
+  0%, 33.32% { visibility: hidden; }
+  33.33%, 100% { visibility: visible; }
+}
+
+@keyframes reveal-third-dot {
+  0%, 66.65% { visibility: hidden; }
+  66.66%, 100% { visibility: visible; }
+}
+
+@media (prefers-reduced-motion: reduce) {
+  .secondDot,
+  .thirdDot {
+    animation: none;
+  }
+}

+ 94 - 0
packages/client/ui-primitives/src/ConnectionIndicator.tsx

@@ -0,0 +1,94 @@
+import { IconCheckOutline16, IconWarningOutline16 } from './icons/index.tsx'
+import css from './ConnectionIndicator.module.css'
+
+/** Visual state rendered by {@link ConnectionIndicator}. */
+export type ConnectionIndicatorState =
+  | 'disconnected'
+  | 'connecting'
+  | 'recovered'
+
+/**
+ * Render an inline connection-recovery control.
+ * @param props.state - visible outage, retry-attempt, or recovered state.
+ * @param props.disconnectedLabel - localized outage text.
+ * @param props.reconnectLabel - localized action text shown on hover or focus.
+ * @param props.connectingLabel - localized retry text followed by the attempt dots.
+ * @param props.recoveredLabel - localized recovery confirmation.
+ * @param props.reconnectActionLabel - accessible label for the outage action.
+ * @param props.restartActionLabel - accessible label for replacing an active attempt.
+ * @param props.onReconnect - request an immediate reconnect attempt.
+ * @returns the indicator, or null when no connection feedback is active.
+ */
+export function ConnectionIndicator({
+  state,
+  disconnectedLabel,
+  reconnectLabel,
+  connectingLabel,
+  recoveredLabel,
+  reconnectActionLabel,
+  restartActionLabel,
+  onReconnect,
+}: {
+  state: ConnectionIndicatorState | undefined
+  disconnectedLabel: string
+  reconnectLabel: string
+  connectingLabel: string
+  recoveredLabel: string
+  reconnectActionLabel: string
+  restartActionLabel: string
+  onReconnect: () => void
+}) {
+  if (state === undefined) return null
+  const sizeLabels = (
+    <>
+      <span className={css.sizeLabel} aria-hidden="true">{disconnectedLabel}</span>
+      <span className={css.sizeLabel} aria-hidden="true">{reconnectLabel}</span>
+      <span className={css.sizeLabel} aria-hidden="true">
+        {connectingLabel}<span className={css.dots}>...</span>
+      </span>
+      <span className={css.sizeLabel} aria-hidden="true">{recoveredLabel}</span>
+    </>
+  )
+  if (state === 'recovered') {
+    return (
+      <div className={`${css.indicator} ${css.success}`} role="status" aria-label={recoveredLabel}>
+        <span className={css.icon} aria-hidden="true"><IconCheckOutline16 size={14} /></span>
+        <span className={css.label}>
+          {sizeLabels}
+          <span className={css.stateLabel}>{recoveredLabel}</span>
+        </span>
+      </div>
+    )
+  }
+
+  const connecting = state === 'connecting'
+  return (
+    <button
+      type="button"
+      className={`${css.indicator} ${css.warning}`}
+      data-phase={state}
+      aria-label={connecting ? restartActionLabel : reconnectActionLabel}
+      onClick={onReconnect}
+    >
+      <span className={css.icon} aria-hidden="true"><IconWarningOutline16 size={14} /></span>
+      <span className={css.label}>
+        {sizeLabels}
+        <span className={css.stateLabel}>
+          {connecting
+            ? (
+              <>
+                {connectingLabel}
+                <span className={css.dots} aria-hidden="true">
+                  <span>.</span>
+                  <span className={css.secondDot}>.</span>
+                  <span className={css.thirdDot}>.</span>
+                </span>
+              </>
+            )
+            : disconnectedLabel}
+        </span>
+        <span className={css.hoverLabel}>{reconnectLabel}</span>
+      </span>
+    </button>
+  )
+}

+ 2 - 1
packages/client/ui-primitives/src/index.ts

@@ -21,7 +21,8 @@ export { Modal } from './Modal.tsx'
 export { OnboardingSurface } from './OnboardingSurface.tsx'
 export { OnboardingSurface } from './OnboardingSurface.tsx'
 export { RiskConfirmation } from './RiskConfirmation.tsx'
 export { RiskConfirmation } from './RiskConfirmation.tsx'
 export type { RiskConfirmationProps } from './RiskConfirmation.tsx'
 export type { RiskConfirmationProps } from './RiskConfirmation.tsx'
-export { ConnectionBanner } from './ConnectionBanner.tsx'
+export { ConnectionIndicator } from './ConnectionIndicator.tsx'
+export type { ConnectionIndicatorState } from './ConnectionIndicator.tsx'
 export { FishLogo, FISH_LOGO_PATH, FISH_LOGO_VIEWBOX } from './FishLogo.tsx'
 export { FishLogo, FISH_LOGO_PATH, FISH_LOGO_VIEWBOX } from './FishLogo.tsx'
 export { BrandWordmark } from './BrandWordmark.tsx'
 export { BrandWordmark } from './BrandWordmark.tsx'
 export type { BrandWordmarkProps } from './BrandWordmark.tsx'
 export type { BrandWordmarkProps } from './BrandWordmark.tsx'

+ 3 - 0
packages/client/ui-settings-general/package.json

@@ -34,6 +34,7 @@
       "inject": [
       "inject": [
         "@deepseek-ai/dsh-client-ui-settings",
         "@deepseek-ai/dsh-client-ui-settings",
         "@deepseek-ai/dsh-client-locale",
         "@deepseek-ai/dsh-client-locale",
+        "@deepseek-ai/dsh-client-connection",
         "@deepseek-ai/dsh-api-remotes",
         "@deepseek-ai/dsh-api-remotes",
         "@deepseek-ai/dsh-client-ui-sidebar"
         "@deepseek-ai/dsh-client-ui-sidebar"
       ],
       ],
@@ -51,6 +52,7 @@
   },
   },
   "peerDependencies": {
   "peerDependencies": {
     "@deepseek-ai/dsh-api-remotes": "workspace:^",
     "@deepseek-ai/dsh-api-remotes": "workspace:^",
+    "@deepseek-ai/dsh-client-connection": "workspace:^",
     "@deepseek-ai/dsh-client-locale": "workspace:^",
     "@deepseek-ai/dsh-client-locale": "workspace:^",
     "@deepseek-ai/dsh-client-ui-settings": "workspace:^",
     "@deepseek-ai/dsh-client-ui-settings": "workspace:^",
     "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^",
     "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^",
@@ -62,6 +64,7 @@
   },
   },
   "devDependencies": {
   "devDependencies": {
     "@deepseek-ai/dsh-api-remotes": "workspace:^",
     "@deepseek-ai/dsh-api-remotes": "workspace:^",
+    "@deepseek-ai/dsh-client-connection": "workspace:^",
     "@deepseek-ai/dsh-client-locale": "workspace:^",
     "@deepseek-ai/dsh-client-locale": "workspace:^",
     "@deepseek-ai/dsh-client-store": "workspace:^",
     "@deepseek-ai/dsh-client-store": "workspace:^",
     "@deepseek-ai/dsh-client-test-runtime": "workspace:^",
     "@deepseek-ai/dsh-client-test-runtime": "workspace:^",

+ 19 - 3
packages/client/ui-settings-general/src/client/SettingsRoot.module.css

@@ -1,11 +1,26 @@
-.trigger {
+.triggerRow {
   flex: none;
   flex: none;
   display: flex;
   display: flex;
   align-items: center;
   align-items: center;
   gap: 8px;
   gap: 8px;
   width: calc(100% + 4px);
   width: calc(100% + 4px);
-  height: 42px;
   margin: 4px -2px;
   margin: 4px -2px;
+}
+
+.triggerRow.railRow {
+  width: 36px;
+  margin: 8px 0 10px;
+}
+
+.trigger {
+  flex: 1;
+  min-width: 0;
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  width: auto;
+  height: 42px;
+  margin: 0;
   padding: 0 10px 0 8px;
   padding: 0 10px 0 8px;
   box-sizing: border-box;
   box-sizing: border-box;
   border: none;
   border: none;
@@ -25,9 +40,10 @@
 
 
 /* Rail trigger: the same 36x36 circle box as the other rail controls. */
 /* Rail trigger: the same 36x36 circle box as the other rail controls. */
 .trigger.rail {
 .trigger.rail {
+  flex: none;
   width: 36px;
   width: 36px;
   height: 36px;
   height: 36px;
-  margin: 8px 0 10px;
+  margin: 0;
   justify-content: center;
   justify-content: center;
   gap: 0;
   gap: 0;
   padding: 0;
   padding: 0;

+ 55 - 12
packages/client/ui-settings-general/src/client/SettingsRoot.tsx

@@ -10,15 +10,19 @@
  * sessions-derived empty-Hero fact is active. Visible dialog chrome belongs
  * sessions-derived empty-Hero fact is active. Visible dialog chrome belongs
  * to the step, so a mounted-but-deciding step paints nothing here.
  * to the step, so a mounted-but-deciding step paints nothing here.
  */
  */
-import { useCallback, useEffect, useId, useRef, useState } from 'react'
+import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from 'react'
 import clsx from 'clsx'
 import clsx from 'clsx'
 import {
 import {
+  ConnectionIndicator,
   IconAgentPresetOutline16, IconCloseOutline16, IconDataOutline16,
   IconAgentPresetOutline16, IconCloseOutline16, IconDataOutline16,
   IconPersonalizationOutline16, IconSettingsOutline16,
   IconPersonalizationOutline16, IconSettingsOutline16,
 } from '@deepseek-ai/dsh-client-ui-primitives'
 } from '@deepseek-ai/dsh-client-ui-primitives'
+import type { ConnectionIndicatorState } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { SettingsRootComponentProps, SettingsSectionRow } from './shell-contract.ts'
 import type { SettingsRootComponentProps, SettingsSectionRow } from './shell-contract.ts'
 import css from './SettingsRoot.module.css'
 import css from './SettingsRoot.module.css'
 
 
+const RECOVERY_CONFIRMATION_MS = 2_000
+
 /** Nav glyph by section id; unknown ids fall back to the settings gear. */
 /** Nav glyph by section id; unknown ids fall back to the settings gear. */
 function navIcon(id: string) {
 function navIcon(id: string) {
   if (id === 'models') return <IconDataOutline16 className={css.navIcon} size={16} />
   if (id === 'models') return <IconDataOutline16 className={css.navIcon} size={16} />
@@ -102,10 +106,13 @@ function SettingsPanel({ rows, renderSlot, activeId, onSelect, onClose }: PanelP
  * @returns the settings shell element tree.
  * @returns the settings shell element tree.
  */
  */
 export function SettingsRoot(props: SettingsRootComponentProps) {
 export function SettingsRoot(props: SettingsRootComponentProps) {
-  const { wide, useSections, useOnboardingSteps, useSessions, renderSlot } = props
+  const {
+    wide, reconnect, useConnectionState, useSections, useOnboardingSteps, useSessions, renderSlot, t,
+  } = props
   const [open, setOpen] = useState(false)
   const [open, setOpen] = useState(false)
   const [activeId, setActiveId] = useState<string | undefined>(undefined)
   const [activeId, setActiveId] = useState<string | undefined>(undefined)
   const [completedOnboarding, setCompletedOnboarding] = useState<ReadonlySet<string>>(() => new Set())
   const [completedOnboarding, setCompletedOnboarding] = useState<ReadonlySet<string>>(() => new Set())
+  const [showRecovery, setShowRecovery] = useState(false)
   const triggerButton = useRef<HTMLButtonElement | null>(null)
   const triggerButton = useRef<HTMLButtonElement | null>(null)
   const wasOpen = useRef(open)
   const wasOpen = useRef(open)
   const close = useCallback(() => {
   const close = useCallback(() => {
@@ -126,6 +133,8 @@ export function SettingsRoot(props: SettingsRootComponentProps) {
   // freshly localized text on locale change, and the trigger/header/close
   // freshly localized text on locale change, and the trigger/header/close
   // seats re-render through their own outlets' subscriptions.
   // seats re-render through their own outlets' subscriptions.
   const rows = useSections(s => s)
   const rows = useSections(s => s)
+  const connectionState = useConnectionState(state => state)
+  const previousConnectionState = useRef(connectionState)
   const onboardingSteps = useOnboardingSteps(s => s)
   const onboardingSteps = useOnboardingSteps(s => s)
   const onboardingActive = useSessions(state =>
   const onboardingActive = useSessions(state =>
     state.phase === 'ready'
     state.phase === 'ready'
@@ -139,6 +148,19 @@ export function SettingsRoot(props: SettingsRootComponentProps) {
     setCompletedOnboarding(new Set())
     setCompletedOnboarding(new Set())
   }, [onboardingActive])
   }, [onboardingActive])
 
 
+  useLayoutEffect(() => {
+    const previous = previousConnectionState.current
+    previousConnectionState.current = connectionState
+    if (connectionState !== 'connected') {
+      setShowRecovery(false)
+      return
+    }
+    if (previous !== 'disconnected' && previous !== 'connecting') return
+    setShowRecovery(true)
+    const timeout = window.setTimeout(() => { setShowRecovery(false) }, RECOVERY_CONFIRMATION_MS)
+    return () => { window.clearTimeout(timeout) }
+  }, [connectionState])
+
   const completeOnboardingStep = useCallback((id: string) => {
   const completeOnboardingStep = useCallback((id: string) => {
     setCompletedOnboarding((previous) => {
     setCompletedOnboarding((previous) => {
       if (previous.has(id)) return previous
       if (previous.has(id)) return previous
@@ -146,18 +168,39 @@ export function SettingsRoot(props: SettingsRootComponentProps) {
     })
     })
   }, [])
   }, [])
 
 
+  let connectionIndicator: ConnectionIndicatorState | undefined
+  if (connectionState === 'disconnected') {
+    connectionIndicator = 'disconnected'
+  } else if (connectionState === 'connecting') {
+    connectionIndicator = 'connecting'
+  } else if (showRecovery) {
+    connectionIndicator = 'recovered'
+  }
+
   return (
   return (
     <>
     <>
-      <button
-        ref={triggerButton}
-        type="button"
-        className={clsx(css.trigger, !wide && css.rail)}
-        aria-haspopup="dialog"
-        aria-expanded={open}
-        onClick={() => { setOpen(true) }}
-      >
-        {renderSlot('settings.trigger', { wide })}
-      </button>
+      <div className={clsx(css.triggerRow, !wide && css.railRow)}>
+        <button
+          ref={triggerButton}
+          type="button"
+          className={clsx(css.trigger, !wide && css.rail)}
+          aria-haspopup="dialog"
+          aria-expanded={open}
+          onClick={() => { setOpen(true) }}
+        >
+          {renderSlot('settings.trigger', { wide })}
+        </button>
+        <ConnectionIndicator
+          state={wide ? connectionIndicator : undefined}
+          disconnectedLabel={t('connection.error')}
+          reconnectLabel={t('connection.retry')}
+          connectingLabel={t('connection.connecting')}
+          recoveredLabel={t('connection.connected')}
+          reconnectActionLabel={t('connection.reconnect')}
+          restartActionLabel={t('connection.restart')}
+          onReconnect={reconnect}
+        />
+      </div>
       {open && (
       {open && (
         <SettingsPanel
         <SettingsPanel
           rows={rows}
           rows={rows}

+ 6 - 1
packages/client/ui-settings-general/src/client/index.ts

@@ -10,6 +10,7 @@
 import type { Context as ClientContext } from '@deepseek-ai/cordis'
 import type { Context as ClientContext } from '@deepseek-ai/cordis'
 // Type-only: pulls the ctx.remote merge and its fixed Host facts.
 // Type-only: pulls the ctx.remote merge and its fixed Host facts.
 import type {} from '@deepseek-ai/dsh-api-remotes/client'
 import type {} from '@deepseek-ai/dsh-api-remotes/client'
+import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
 import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
 import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
 // Type-only: the settings slot declarations plus the ctx.settingsScope Context
 // Type-only: the settings slot declarations plus the ctx.settingsScope Context
 // merge. Cross-plugin collaboration goes through the service, never a value
 // merge. Cross-plugin collaboration goes through the service, never a value
@@ -56,7 +57,7 @@ const NS = 'settings'
  * ui-settings' apply, whose activation order relative to this one is NOT
  * ui-settings' apply, whose activation order relative to this one is NOT
  * constrained; registrations depend on their slots through `slots.inject()`.
  * constrained; registrations depend on their slots through `slots.inject()`.
  */
  */
-export const inject = ['slots', 'locale', 'remote', 'remote.settings', 'settingsScope']
+export const inject = ['slots', 'locale', 'connection', 'remote', 'remote.settings', 'settingsScope']
 
 
 /**
 /**
  * Register the `settings` dictionaries, the chrome content, and the General
  * Register the `settings` dictionaries, the chrome content, and the General
@@ -65,6 +66,7 @@ export const inject = ['slots', 'locale', 'remote', 'remote.settings', 'settings
  */
  */
 export function apply(ctx: ClientContext): void {
 export function apply(ctx: ClientContext): void {
   ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-settings-general: dictionaries')
   ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-settings-general: dictionaries')
+  const connection = ctx.get('connection') as ConnectionHandle
 
 
   // Copy freshness is framework-owned: components read the standard `t`
   // Copy freshness is framework-owned: components read the standard `t`
   // seat, and the nav label is a thunk the owner resolves per render — no
   // seat, and the nav label is a thunk the owner resolves per render — no
@@ -92,7 +94,9 @@ export function apply(ctx: ClientContext): void {
   let onboardingVersion = -1
   let onboardingVersion = -1
   let onboardingSteps: readonly SettingsOnboardingStep[] = []
   let onboardingSteps: readonly SettingsOnboardingStep[] = []
   const shellInjected = (): SettingsRootInjected => ({
   const shellInjected = (): SettingsRootInjected => ({
+    reconnect: () => { connection.reconnect() },
     hooks: {
     hooks: {
+      connectionState: connection.state,
       sections: {
       sections: {
         getSnapshot: () => {
         getSnapshot: () => {
           const version = ctx.slots.getVersion('settings.section')
           const version = ctx.slots.getVersion('settings.section')
@@ -141,6 +145,7 @@ export function apply(ctx: ClientContext): void {
   })
   })
   ctx.slots.inject('sidebar.settings', () => ctx.slots.register({
   ctx.slots.inject('sidebar.settings', () => ctx.slots.register({
     name: 'sidebar.settings',
     name: 'sidebar.settings',
+    locale: NS,
     children: {
     children: {
       'settings.trigger': { kind: 'single', scope: 'root' },
       'settings.trigger': { kind: 'single', scope: 'root' },
       'settings.header': { kind: 'single', scope: 'root' },
       'settings.header': { kind: 'single', scope: 'root' },

+ 11 - 3
packages/client/ui-settings-general/src/client/shell-contract.ts

@@ -6,7 +6,10 @@
  * reference graph closes a cycle through ui-sidebar → ui-layout → ui-theme.
  * reference graph closes a cycle through ui-sidebar → ui-layout → ui-theme.
  * The settings SLOT types (what registrants contribute) stay in ui-settings.
  * The settings SLOT types (what registrants contribute) stay in ui-settings.
  */
  */
-import type { HostObservable, InjectFace, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
+import type { ConnectionState } from '@deepseek-ai/dsh-client-connection/client'
+import type {
+  HostObservable, InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime,
+} from '@deepseek-ai/dsh-client-ui-slots'
 // Type-only: pulls ui-sidebar's SlotMap merge (the 'sidebar.settings' entry)
 // Type-only: pulls ui-sidebar's SlotMap merge (the 'sidebar.settings' entry)
 // into every program that sees this contract.
 // into every program that sees this contract.
 import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client'
 import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client'
@@ -28,11 +31,15 @@ export interface SettingsOnboardingStep {
 
 
 /**
 /**
  * Registrant-private injected share of the settings shell (assembled in
  * Registrant-private injected share of the settings shell (assembled in
- * apply): the ledger's nav-row projection as a hooks-compartment source —
- * the shell reads no locale state and subscribes through the bound hook.
+ * apply): connection state and ledger projections arrive as hook-compartment
+ * sources, while the reconnect command remains a plain callback.
  */
  */
 export type SettingsRootInjected = {
 export type SettingsRootInjected = {
+  /** Request a fresh logical generation and physical WebSocket immediately. */
+  reconnect: () => void
   hooks: {
   hooks: {
+    /** Connection-owned state for the current Host connection. */
+    connectionState: HostObservable<ConnectionState | undefined>
     /** settings.section ledger projected into ordered nav rows. */
     /** settings.section ledger projected into ordered nav rows. */
     sections: HostObservable<readonly SettingsSectionRow[]>
     sections: HostObservable<readonly SettingsSectionRow[]>
     /** settings.onboarding ledger projected into coordinator order. */
     /** settings.onboarding ledger projected into coordinator order. */
@@ -57,3 +64,4 @@ export type SettingsRootComponentProps =
     | 'settings.onboarding'
     | 'settings.onboarding'
   >
   >
   & InjectFace<SettingsRootInjected>
   & InjectFace<SettingsRootInjected>
+  & PropsLocale<'settings'>

+ 3 - 0
packages/client/ui-settings-general/tsconfig.json

@@ -32,6 +32,9 @@
     {
     {
       "path": "../locale"
       "path": "../locale"
     },
     },
+    {
+      "path": "../connection/tsconfig.client.json"
+    },
     {
     {
       "path": "../../settings/settings"
       "path": "../../settings/settings"
     },
     },