mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-09-09 04:02:35 +00:00
feat(web): add connection recovery indicator
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user