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 = ( + <> + + + + + + ) + 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" },