diff --git a/integrations/dsh/src/client/index.tsx b/integrations/dsh/src/client/index.tsx index 26e46f79..5a1e83b9 100644 --- a/integrations/dsh/src/client/index.tsx +++ b/integrations/dsh/src/client/index.tsx @@ -1,8 +1,9 @@ -import { useState, useSyncExternalStore } from "react"; +import { useEffect, useReducer, useState, useSyncExternalStore } from "react"; +import { SettingsFormModel } from "@deepseek-ai/dsh-client-ui-primitives"; +import type { SettingsFieldState } from "@deepseek-ai/dsh-client-ui-primitives"; import type { ConfigForm } from "@deepseek-ai/dsh-client-ui-settings/client"; import type { ReMeSettings } from "../types.js"; -import { validEndpoint } from "../endpoint.js"; import { ReMeEnvironmentTab, envEn, @@ -17,36 +18,19 @@ import { type StatusRpc, type StatusTranslator, } from "./status-page.js"; +import { SETTINGS_SPECS } from "./settings-specs.js"; import { styles } from "./styles.js"; const NS = "reme.settings"; const STATUS_NS = "reme.status"; const ENV_NS = "reme.environment"; const SETTINGS_NS = "reme-memory"; -const FIELDS = [ - "endpoint", - "requestTimeoutMs", - "backgroundTimeoutMs", - "shutdownTimeoutMs", - "autoMemoryRetryMs", - "autoMemoryRetryMaxMs", - "autoMemoryEnabled", - "autoMemoryInterval", - "autoDreamEnabled", - "dreamCron", - "dreamHint", - "rootAgentsOnly", - "language", - "searchLimit", - "timezone", -] as const satisfies readonly (keyof ReMeSettings)[]; -type Field = (typeof FIELDS)[number]; type Translator = (key: keyof typeof en) => string; interface ClientContext { get(name: string): { rpc: StatusRpc }; - effect(factory: () => (() => void) | void, label: string): void; + effect(factory: () => void | (() => void), label: string): void; locale: { bind(namespace: typeof NS): Translator; bind(namespace: typeof STATUS_NS): StatusTranslator; @@ -72,23 +56,8 @@ interface ClientContext { }; } -interface Draft { - endpoint: string; - requestTimeoutMs: string; - backgroundTimeoutMs: string; - shutdownTimeoutMs: string; - autoMemoryRetryMs: string; - autoMemoryRetryMaxMs: string; - autoMemoryEnabled: boolean; - autoMemoryInterval: string; - autoDreamEnabled: boolean; - dreamTime: string; - dreamHint: string; - rootAgentsOnly: boolean; - language: "en" | "zh"; - searchLimit: string; - timezone: string; -} +/** One control as the model currently stages it. */ +type FieldState = SettingsFieldState; interface ReMeCardProps { scope: ConfigForm; @@ -324,79 +293,62 @@ function ReMeSettingsCard({ (listener) => scope.subscribe(listener), () => scope.getSnapshot(), ); - const [draftOverride, setDraft] = useState<{ - value: Draft; - revision: number | undefined; - }>(); - const [resetAll, setResetAll] = useState(false); - const [saving, setSaving] = useState(false); - const [saveState, setSaveState] = useState<"" | "saved" | "failed">(""); + // DSH's form model stages the drafts, marks the ones that are invalid and + // writes every staged edit as one revision-fenced mutation, so this page owns + // only its controls and the field specs behind them. + const [model] = useState( + () => new SettingsFormModel(scope, SETTINGS_SPECS), + ); + const [, publish] = useReducer((tick: number) => tick + 1, 0); + const [busy, setBusy] = useState(false); + const [saved, setSaved] = useState(false); const [tab, setTab] = useState<"memory" | "environment">("memory"); - const value = snapshot.value; - const draft = - draftOverride?.value ?? - (value === undefined ? undefined : draftFrom(value)); + useEffect(() => () => model.dispose(), [model]); if (view === "summary") return <>{t("description")}; if (snapshot.status === "unavailable") return null; - const dirty = - resetAll || - (value !== undefined && - draft !== undefined && - !sameDraft(draft, draftFrom(value))); - const validation = draft === undefined ? undefined : parseDraft(draft); - const update = (field: K, next: Draft[K]) => { - if (value === undefined) return; - setDraft((current) => ({ - value: { ...(current?.value ?? draftFrom(value)), [field]: next }, - revision: current ? current.revision : snapshot.revision, - })); - setResetAll(false); - setSaveState(""); + const value = snapshot.value; + const shell = model.shell(); + const actions = model.actions(); + const field = (name: string): FieldState => model.field(name); + // "The maximum retry delay is at least the initial one" is a relation between + // two fields, which a per-field spec cannot state, so the page keeps it. + const retryMax = field("autoMemoryRetryMaxMs").text.trim(); + const retryBase = field("autoMemoryRetryMs").text.trim(); + const inconsistent = + retryMax !== "" && retryBase !== "" && Number(retryMax) < Number(retryBase); + const blocked = shell.invalid || inconsistent; + const edit = (name: string, text: string): void => { + setSaved(false); + actions.edit(name, text); + // The model publishes to its bound stores; this page reads it directly, so + // every staged edit repaints the controls itself. + publish(); }; - - const save = async () => { - if ( - value === undefined || - draft === undefined || - validation === undefined || - !snapshot.writable || - !dirty || - saving - ) - return; - setSaving(true); - setSaveState(""); + const save = async (): Promise => { + if (!shell.writable || !shell.dirty || blocked || busy) return; + setSaved(false); + setBusy(true); try { - const ops = resetAll - ? FIELDS.map((field) => ({ op: "unset" as const, path: [field] })) - : FIELDS.flatMap((field) => - Object.is(value[field], validation[field]) - ? [] - : [ - { - op: "set" as const, - path: [field], - value: validation[field], - }, - ], - ); - const success = await scope.mutate( - ops, - draftOverride?.revision ?? snapshot.revision, - ); - setSaveState(success ? "saved" : "failed"); - if (success) { - setDraft(undefined); - setResetAll(false); - } - } catch { - setSaveState("failed"); + await model.save(); + const after = model.shell(); + setSaved(!after.failed && !after.dirty); } finally { - setSaving(false); + setBusy(false); + publish(); } }; + const reset = (): void => { + setSaved(false); + for (const spec of SETTINGS_SPECS) actions.resetField(spec.field); + publish(); + }; + const discard = (): void => { + setSaved(false); + actions.discard(); + publish(); + }; return (
@@ -427,89 +379,63 @@ function ReMeSettingsCard({ ) : (

{t("loading")}

) + ) : value === undefined ? ( +

{t("loading")}

) : ( <> - {dirty ? ( + {shell.dirty ? (

{t("unsaved")}

) : null} - {snapshot.status === "loading" || - draft === undefined || - value === undefined ? ( -

{t("loading")}

- ) : ( - <> -
- -
- {!snapshot.writable ? ( -

- {t("readOnly")} -

+
+ +
+ {!shell.writable ? ( +

+ {t("readOnly")} +

+ ) : null} + {blocked ? ( +

+ {t("invalid")} +

+ ) : null} +
+ + + {shell.failed ? ( + {t("saveFailed")} ) : null} - {validation === undefined ? ( -

- {t("invalid")} -

+ {saved && !shell.dirty ? ( + {t("saved")} ) : null} -
- - - {saveState === "failed" ? ( - - {t("saveFailed")} - - ) : null} - {saveState === "saved" ? ( - - {t("saved")} - - ) : null} - - - -
- - )} +
+ + +
)}
@@ -517,15 +443,19 @@ function ReMeSettingsCard({ ); } +/** The memory-settings controls, all reading and writing through the model. */ function SettingsForm({ draft, - update, + edit, t, }: { - draft: Draft; - update: (field: K, value: Draft[K]) => void; + draft: (name: string) => FieldState; + edit: (name: string, text: string) => void; t: Translator; }): JSX.Element { + const text = (name: string): string => draft(name).text; + const invalid = (name: string): boolean => draft(name).invalid; + const checked = (name: string): boolean => draft(name).text === "true"; return ( <>
@@ -533,17 +463,15 @@ function SettingsForm({
update("endpoint", event.target.value)} + value={text("endpoint")} + aria-invalid={invalid("endpoint")} + onChange={(event) => edit("endpoint", event.target.value)} /> - update("autoMemoryInterval", event.target.value) + edit("autoMemoryInterval", event.target.value) } /> update("timezone", event.target.value)} + value={text("timezone")} + aria-invalid={invalid("timezone")} + onChange={(event) => edit("timezone", event.target.value)} />
@@ -609,20 +537,22 @@ function SettingsForm({
update("autoDreamEnabled", value)} + checked={checked("autoDreamEnabled")} + onChange={(next) => + edit("autoDreamEnabled", next ? "true" : "false") + } /> update("dreamTime", event.target.value)} + value={text("dreamCron")} + onChange={(event) => edit("dreamCron", event.target.value)} />