From 64d4bbd2c644e3def8502b2f311d683340904f25 Mon Sep 17 00:00:00 2001 From: LiangshouX Date: Wed, 28 Oct 2026 18:22:01 +0800 Subject: [PATCH] refactor(dsh): stage the settings card with DSH's form model The memory settings card carried its own draft state machine: a Draft object, a parser that re-validated every field, a hand-written diff into path operations, and its own revision fence. DSH ships exactly that as SettingsFormModel, exported by the primitives package this plugin already depends on and injects since 0.1.7-rc.2. The card now builds the model once, reads shell()/field() while rendering and hands every edit to actions(), so what is left is the controls plus one field-spec table in settings-specs.ts. The revision fence, the dirty/invalid/saving/failed states, keeping drafts after a refused save, and clearing only the fields the user layer carries all come from the model. The one visible change: reset now stages clears for the fields the user layer actually overrides, which is what that control always meant. Tests: settings-specs.test.mjs covers every spec on its own, and the card test runs DSH's real SettingsFormModel (evaluated from the installed primitives bundle, which cannot be imported in Node because it loads CSS modules and icons). The packaged-plugin check exercises that against all six supported DSH releases. --- integrations/dsh/src/client/index.tsx | 497 ++++++------------ integrations/dsh/src/client/settings-specs.ts | 138 +++++ integrations/dsh/tests/bundle.test.mjs | 8 + .../dsh/tests/client-settings.test.mjs | 42 +- .../dsh/tests/settings-specs.test.mjs | 109 ++++ 5 files changed, 447 insertions(+), 347 deletions(-) create mode 100644 integrations/dsh/src/client/settings-specs.ts create mode 100644 integrations/dsh/tests/settings-specs.test.mjs 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)} />