Files
dsh-superagent/src/client/provider.tsx
T
2026-10-31 20:05:39 +08:00

109 lines
5.3 KiB
TypeScript

/** Shared panel React contexts, state polling, restart detection, and action runner hook. */
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
import { caller, usePoll, type ApiMessages, type Call, type SetupView, type StateView } from './api.ts'
import type { Route } from './route.ts'
import STYLES from './client.css?raw'
import { errorText, setupText, valueLabel, type T } from './text.ts'
export interface PanelContext {
t: T
call: Call
navigate: (route: Route, replace?: boolean) => void
back: () => void
backLabel: string
openSession: (sessionId: string) => Promise<void>
notify: (text: string) => void
restart: () => void
}
export interface PanelData {
state: StateView | undefined
error: Error | undefined
reload: () => Promise<void>
setup: SetupView | undefined
/** Executor Sessions in an active turn, which a restart of `dsh-superagent` interrupts. */
running: number
/** Incremented each time dsh-superagent recovers from a restart so dependent views refetch. */
restarts: number
}
const Panel = createContext<PanelContext | undefined>(undefined)
const PanelState = createContext<PanelData | undefined>(undefined)
export function usePanel(): PanelContext {
const value = useContext(Panel)
if (!value) throw new Error('dsh-superagent page rendered outside its panel')
return value
}
export function usePanelData(): PanelData {
const value = useContext(PanelState)
if (!value) throw new Error('dsh-superagent page rendered outside its panel')
return value
}
/** Wrap an async action with re-entrancy protection, busy state, optional reload, and error toast reporting. */
export function useAction<A extends unknown[]>(action: (...args: A) => Promise<unknown>,
{ reload }: { reload?: () => Promise<unknown> } = {}) {
const { notify } = usePanel()
const [busy, setBusy] = useState(false)
const running = useRef(false)
const run = async (...args: A): Promise<boolean> => {
if (running.current) return false
running.current = true
setBusy(true)
try { await action(...args); await reload?.(); return true }
catch (failure) { notify(errorText(failure)); return false }
finally { running.current = false; setBusy(false) }
}
return { run, busy }
}
const messages = (t: T): ApiMessages => ({
offline: t('offline'), notRunning: t('hostNotRunning'), serverError: status => t('serverError', { status }),
requestFailed: status => t('requestFailed', { status }),
setup: problem => problem.values.output ? `${setupText(t, problem)}\n${problem.values.output}` : setupText(t, problem),
// A refusal names a prompt part by its label.
user: ({ code, values }) => t(`userError.${code}`, values.part ? { ...values, part: valueLabel(t, 'promptPart', values.part) } : values),
})
function Toast({ text, onDone, holdMs = 3000 }: { text: string; onDone: () => void; holdMs?: number }) {
const done = useRef(onDone)
done.current = onDone
useEffect(() => { const timer = setTimeout(() => done.current(), holdMs + 1000); return () => clearTimeout(timer) }, [holdMs])
return createPortal(<div className="dt-toast" role="status" style={{ ['--dt-toast-hold' as string]: `${holdMs}ms` }}>{text}</div>,
document.body)
}
const stay = () => {}
const noSession = async () => {}
export function PanelProvider({ t, interval = 5000, openSession = noSession, navigate = stay, back = stay, backLabel = '', children }: {
t: T; interval?: number; openSession?: (sessionId: string) => Promise<void>; navigate?: PanelContext['navigate']; back?: () => void
backLabel?: string
children: ReactNode
}) {
// Read the current translator via ref so locale changes do not recreate `call` and restart polling.
const copy = useRef(t)
copy.current = t
const call = useMemo(() => caller(() => messages(copy.current)), [])
const [toast, setToast] = useState<{ text: string; id: number }>()
const notify = useCallback((text: string) => setToast({ text, id: Date.now() }), [])
// Poll at 1s during a restart until the server goes down and answers again, or time out after 30s if the restart completed between polls.
const [restarting, setRestarting] = useState<{ down: boolean }>()
const [restarts, setRestarts] = useState(0)
const restart = useCallback(() => setRestarting({ down: false }), [])
const { data: state, error, reload } = usePoll(() => call('GET state'), [call], restarting ? 1000 : interval)
if (restarting && !!error !== restarting.down) {
setRestarting(error ? { down: true } : undefined)
if (!error) setRestarts(restarts + 1)
}
useEffect(() => {
if (!restarting) return
const timer = setTimeout(() => setRestarting(undefined), 30_000)
return () => clearTimeout(timer)
}, [restarting])
const context = useMemo(() => ({ t, call, navigate, back, backLabel, openSession, notify, restart }), [t, call, navigate, back, backLabel,
openSession, notify, restart])
const data = useMemo(() => ({ state, error, reload, setup: state?.setup, running: state?.load.running ?? 0, restarts }), [state, error,
reload, restarts])
return <Panel.Provider value={context}><PanelState.Provider value={data}>
<style>{STYLES}</style>
{children}
{toast && <Toast key={toast.id} text={toast.text} onDone={() => setToast(undefined)} />}
</PanelState.Provider></Panel.Provider>
}