|
@@ -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}
|