diff --git a/packages/client/ui-primitives/src/ConnectionBanner.module.css b/packages/client/ui-primitives/src/ConnectionBanner.module.css
deleted file mode 100644
index 16908770bb..0000000000
--- a/packages/client/ui-primitives/src/ConnectionBanner.module.css
+++ /dev/null
@@ -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);
-}
diff --git a/packages/client/ui-primitives/src/ConnectionBanner.tsx b/packages/client/ui-primitives/src/ConnectionBanner.tsx
deleted file mode 100644
index 1cb38e99b2..0000000000
--- a/packages/client/ui-primitives/src/ConnectionBanner.tsx
+++ /dev/null
@@ -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
{label}
-}
diff --git a/packages/client/ui-primitives/src/ConnectionIndicator.module.css b/packages/client/ui-primitives/src/ConnectionIndicator.module.css
new file mode 100644
index 0000000000..9c46f7a6e4
--- /dev/null
+++ b/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;
+ }
+}
diff --git a/packages/client/ui-primitives/src/ConnectionIndicator.tsx b/packages/client/ui-primitives/src/ConnectionIndicator.tsx
new file mode 100644
index 0000000000..2c549661df
--- /dev/null
+++ b/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 = (
+ <>
+ {disconnectedLabel}
+ {reconnectLabel}
+
+ {connectingLabel}...
+
+ {recoveredLabel}
+ >
+ )
+ if (state === 'recovered') {
+ return (
+
+
+
+ {sizeLabels}
+ {recoveredLabel}
+
+
+ )
+ }
+
+ const connecting = state === 'connecting'
+ return (
+
+ )
+}
diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts
index 2c2643112d..c5f88e5eee 100644
--- a/packages/client/ui-primitives/src/index.ts
+++ b/packages/client/ui-primitives/src/index.ts
@@ -21,7 +21,8 @@ export { Modal } from './Modal.tsx'
export { OnboardingSurface } from './OnboardingSurface.tsx'
export { RiskConfirmation } 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 { BrandWordmark } from './BrandWordmark.tsx'
export type { BrandWordmarkProps } from './BrandWordmark.tsx'
diff --git a/packages/client/ui-settings-general/package.json b/packages/client/ui-settings-general/package.json
index 2a21623ef0..3f4de05dd4 100644
--- a/packages/client/ui-settings-general/package.json
+++ b/packages/client/ui-settings-general/package.json
@@ -34,6 +34,7 @@
"inject": [
"@deepseek-ai/dsh-client-ui-settings",
"@deepseek-ai/dsh-client-locale",
+ "@deepseek-ai/dsh-client-connection",
"@deepseek-ai/dsh-api-remotes",
"@deepseek-ai/dsh-client-ui-sidebar"
],
@@ -51,6 +52,7 @@
},
"peerDependencies": {
"@deepseek-ai/dsh-api-remotes": "workspace:^",
+ "@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
"@deepseek-ai/dsh-client-ui-sidebar": "workspace:^",
@@ -62,6 +64,7 @@
},
"devDependencies": {
"@deepseek-ai/dsh-api-remotes": "workspace:^",
+ "@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-store": "workspace:^",
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
diff --git a/packages/client/ui-settings-general/src/client/SettingsRoot.module.css b/packages/client/ui-settings-general/src/client/SettingsRoot.module.css
index d9c94a1781..f68b7a5d4e 100644
--- a/packages/client/ui-settings-general/src/client/SettingsRoot.module.css
+++ b/packages/client/ui-settings-general/src/client/SettingsRoot.module.css
@@ -1,11 +1,26 @@
-.trigger {
+.triggerRow {
flex: none;
display: flex;
align-items: center;
gap: 8px;
width: calc(100% + 4px);
- height: 42px;
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;
box-sizing: border-box;
border: none;
@@ -25,9 +40,10 @@
/* Rail trigger: the same 36x36 circle box as the other rail controls. */
.trigger.rail {
+ flex: none;
width: 36px;
height: 36px;
- margin: 8px 0 10px;
+ margin: 0;
justify-content: center;
gap: 0;
padding: 0;
diff --git a/packages/client/ui-settings-general/src/client/SettingsRoot.tsx b/packages/client/ui-settings-general/src/client/SettingsRoot.tsx
index 5c5498738e..d5b1df190a 100644
--- a/packages/client/ui-settings-general/src/client/SettingsRoot.tsx
+++ b/packages/client/ui-settings-general/src/client/SettingsRoot.tsx
@@ -10,15 +10,19 @@
* sessions-derived empty-Hero fact is active. Visible dialog chrome belongs
* 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 {
+ ConnectionIndicator,
IconAgentPresetOutline16, IconCloseOutline16, IconDataOutline16,
IconPersonalizationOutline16, IconSettingsOutline16,
} 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 css from './SettingsRoot.module.css'
+const RECOVERY_CONFIRMATION_MS = 2_000
+
/** Nav glyph by section id; unknown ids fall back to the settings gear. */
function navIcon(id: string) {
if (id === 'models') return
@@ -102,10 +106,13 @@ function SettingsPanel({ rows, renderSlot, activeId, onSelect, onClose }: PanelP
* @returns the settings shell element tree.
*/
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 [activeId, setActiveId] = useState(undefined)
const [completedOnboarding, setCompletedOnboarding] = useState>(() => new Set())
+ const [showRecovery, setShowRecovery] = useState(false)
const triggerButton = useRef(null)
const wasOpen = useRef(open)
const close = useCallback(() => {
@@ -126,6 +133,8 @@ export function SettingsRoot(props: SettingsRootComponentProps) {
// freshly localized text on locale change, and the trigger/header/close
// seats re-render through their own outlets' subscriptions.
const rows = useSections(s => s)
+ const connectionState = useConnectionState(state => state)
+ const previousConnectionState = useRef(connectionState)
const onboardingSteps = useOnboardingSteps(s => s)
const onboardingActive = useSessions(state =>
state.phase === 'ready'
@@ -139,6 +148,19 @@ export function SettingsRoot(props: SettingsRootComponentProps) {
setCompletedOnboarding(new Set())
}, [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) => {
setCompletedOnboarding((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 (
<>
-
+
+
+
+
{open && (
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`
// 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 onboardingSteps: readonly SettingsOnboardingStep[] = []
const shellInjected = (): SettingsRootInjected => ({
+ reconnect: () => { connection.reconnect() },
hooks: {
+ connectionState: connection.state,
sections: {
getSnapshot: () => {
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({
name: 'sidebar.settings',
+ locale: NS,
children: {
'settings.trigger': { kind: 'single', scope: 'root' },
'settings.header': { kind: 'single', scope: 'root' },
diff --git a/packages/client/ui-settings-general/src/client/shell-contract.ts b/packages/client/ui-settings-general/src/client/shell-contract.ts
index ad61b2111a..210f028711 100644
--- a/packages/client/ui-settings-general/src/client/shell-contract.ts
+++ b/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.
* 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)
// into every program that sees this contract.
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
- * 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 = {
+ /** Request a fresh logical generation and physical WebSocket immediately. */
+ reconnect: () => void
hooks: {
+ /** Connection-owned state for the current Host connection. */
+ connectionState: HostObservable
/** settings.section ledger projected into ordered nav rows. */
sections: HostObservable
/** settings.onboarding ledger projected into coordinator order. */
@@ -57,3 +64,4 @@ export type SettingsRootComponentProps =
| 'settings.onboarding'
>
& InjectFace
+ & PropsLocale<'settings'>
diff --git a/packages/client/ui-settings-general/tsconfig.json b/packages/client/ui-settings-general/tsconfig.json
index e13a2f4ff6..f72ed7ef99 100644
--- a/packages/client/ui-settings-general/tsconfig.json
+++ b/packages/client/ui-settings-general/tsconfig.json
@@ -32,6 +32,9 @@
{
"path": "../locale"
},
+ {
+ "path": "../connection/tsconfig.client.json"
+ },
{
"path": "../../settings/settings"
},