feat(web): add connection recovery indicator

This commit is contained in:
imccyu
2026-08-29 11:18:58 +08:00
parent ccfbbb443a
commit 19b4d7f26c
11 changed files with 299 additions and 49 deletions
@@ -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);
}
@@ -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>
}
@@ -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;
}
}
@@ -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
View File
@@ -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'
@@ -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:^",
@@ -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;
@@ -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 <IconDataOutline16 className={css.navIcon} size={16} />
@@ -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<string | undefined>(undefined)
const [completedOnboarding, setCompletedOnboarding] = useState<ReadonlySet<string>>(() => new Set())
const [showRecovery, setShowRecovery] = useState(false)
const triggerButton = useRef<HTMLButtonElement | null>(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 (
<>
<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 && (
<SettingsPanel
rows={rows}
@@ -10,6 +10,7 @@
import type { Context as ClientContext } from '@deepseek-ai/cordis'
// Type-only: pulls the ctx.remote merge and its fixed Host facts.
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'
// Type-only: the settings slot declarations plus the ctx.settingsScope Context
// 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
* 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
@@ -65,6 +66,7 @@ export const inject = ['slots', 'locale', 'remote', 'remote.settings', 'settings
*/
export function apply(ctx: ClientContext): void {
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`
// 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' },
@@ -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<ConnectionState | undefined>
/** settings.section ledger projected into ordered nav rows. */
sections: HostObservable<readonly SettingsSectionRow[]>
/** settings.onboarding ledger projected into coordinator order. */
@@ -57,3 +64,4 @@ export type SettingsRootComponentProps =
| 'settings.onboarding'
>
& InjectFace<SettingsRootInjected>
& PropsLocale<'settings'>
@@ -32,6 +32,9 @@
{
"path": "../locale"
},
{
"path": "../connection/tsconfig.client.json"
},
{
"path": "../../settings/settings"
},