summaryrefslogtreecommitdiffhomepage
path: root/src/features/named-agents/logic/auto-save.ts
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-08-20 19:36:12 +0900
committerAdam Malczewski <[email protected]>2026-08-20 19:36:12 +0900
commitfb7eb3105e1fbbdefdad9348f9132815b5bbe52d (patch)
treedeb95e97a917424ea67470b168b91349a41d1c08 /src/features/named-agents/logic/auto-save.ts
parent63588c3c69b32d180dc78a2cc187cc8aa22ad7e0 (diff)
downloaddispatch-web-fb7eb3105e1fbbdefdad9348f9132815b5bbe52d.tar.gz
dispatch-web-fb7eb3105e1fbbdefdad9348f9132815b5bbe52d.zip
feat(named-agents): sidebar view, synthetic provider, auto-save, snapshot semantics
Implement the frontend of the Named Agents feature (plan §5-§9): - New features/named-agents module: pure logic (types re-exported from @dispatch/wire + transport-contract, validation, provider/model options, snapshot helpers, network normalizers, auto-save status model) + an injected auto-save controller (name debounce, immediate provider/model save, monotonic stale-response guard) + the collapsible sidebar card view. - Store: /named-agents CRUD + boot seed; selectModel(model, snapshot?) persists the raw model WITH the namedAgent snapshot; draft promotion carries it; refreshModel restores ModelResponse.namedAgent. - Chat store setNamedAgent (sends namedAgent on chat.send); Tab.snapshot + setSnapshot (retained across rename/remove, persisted with tabs). - Model selector: synthetic Named Agents provider ALWAYS first; selecting one resolves to the raw model + records a snapshot; a retained snapshot of a renamed/removed agent still displays as unavailable (plan §7). - Composition root wires the view (viewKinds + loaded-modules manifest) + feeds the selector. Tests: logic + controller + selector + app-store CRUD (1246 pass), typecheck 0, biome clean (only pre-existing config infos pending a future migrate), build OK.
Diffstat (limited to 'src/features/named-agents/logic/auto-save.ts')
-rw-r--r--src/features/named-agents/logic/auto-save.ts138
1 files changed, 138 insertions, 0 deletions
diff --git a/src/features/named-agents/logic/auto-save.ts b/src/features/named-agents/logic/auto-save.ts
new file mode 100644
index 0000000..c89d12e
--- /dev/null
+++ b/src/features/named-agents/logic/auto-save.ts
@@ -0,0 +1,138 @@
+/**
+ * Pure auto-save model for a Named Agent card (plan §5.3/§5.4).
+ *
+ * A card has NO explicit Save button: provider/model changes save immediately,
+ * a name edit saves after a short debounce, and the card shows a status
+ * (`Not saved` / `Saving…` / `Saved` / `Save failed` + retry). This module is
+ * the PURE model of that: given the card's current DRAFT (the raw input
+ * controls), the last PERSISTED state, whether a save is in flight, and an
+ * error, it derives the card's display status and what a save would persist.
+ *
+ * The effectful coordination (debounce timers, dispatching saves, and the
+ * monotonic sequence that stops an older response clobbering a newer edit)
+ * lives in the component-wired controller (`ui/controller.svelte.ts`), which
+ * is itself thin over this purity.
+ */
+
+import { isUsableModel, isUsableName, normalizeName } from "./validate";
+
+/**
+ * The four auto-save lifecycle states a card reports. `"idle"` = a brand-new
+ * unsaved draft (never persisted yet) — shown as "Not saved". `"dirty"` = an
+ * existing card whose input differs from what was last saved — also "Not saved"
+ * until the (debounced/immediate) save lands. `"saving"` = a save is in flight.
+ * `"saved"` = current input matches the last persisted value. `"error"` = the
+ * last save failed (a retry is offered). A card that is not yet usable hovers
+ * in `"idle"` (never issued a save).
+ */
+export type SaveStatus = "idle" | "dirty" | "saving" | "saved" | "error";
+
+/** A card's current EDITOR state (the raw form controls). */
+export interface AgentDraft {
+ /** The persisted id once the card has been created; null for a new draft. */
+ readonly id: string | null;
+ /** The raw name input (may be unterminated/unedited). */
+ readonly name: string;
+ /** Selected provider id ("" when none). */
+ readonly provider: string;
+ /** Selected model suffix under `provider` ("" when none). */
+ readonly model: string;
+}
+
+/** The card's last PERSISTED definition (the server's canonical record). */
+export interface AgentSaved {
+ readonly id: string | null;
+ readonly name: string;
+ readonly model: string;
+}
+
+/** The full canonical `<provider>/<model>` name a draft would persist. */
+export function draftModel(draft: AgentDraft): string {
+ return draft.model === "" ? draft.provider : `${draft.provider}/${draft.model}`;
+}
+
+/** The full `<provider>/<model>` name a draft's model suffix maps to. */
+export interface DraftValidity {
+ readonly nameOk: boolean;
+ readonly modelOk: boolean;
+ /** True when the card is fully usable (name nonempty+unique, provider+model chosen). */
+ readonly usable: boolean;
+ /** A message for the first invalid field (for inline hints), or null when usable. */
+ readonly message: string | null;
+}
+
+/**
+ * Validate a draft against its sibling names (the OTHER agents' names, EXCLUDING
+ * this card's own id — uniqueness is among distinct agents).
+ */
+export function draftValidity(draft: AgentDraft, otherNames: readonly string[]): DraftValidity {
+ const nameMessage = nameErrorMessage(draft.name, otherNames);
+ const nameOk = nameMessage === null;
+ const modelOk = draft.provider !== "" && isUsableModel(draftModel(draft));
+ const usable = nameOk && modelOk;
+ const message = usable ? null : (nameMessage ?? modelMessage(draft));
+ return { nameOk, modelOk, usable, message };
+}
+
+function nameErrorMessage(name: string, otherNames: readonly string[]): string | null {
+ if (normalizeName(name).length === 0) return "Name is required.";
+ return isUsableName(name, otherNames) ? null : "A named agent with this name already exists.";
+}
+
+function modelMessage(draft: AgentDraft): string | null {
+ if (draft.provider === "") return "Select a provider.";
+ if (draft.model === "") return "Select a model.";
+ return "Select a valid model.";
+}
+
+/**
+ * Whether the current draft differs from what was last persisted — i.e. a save
+ * is owed. Draft is compared by its NAME (normalized) and full MODEL string.
+ */
+export function isDirty(draft: AgentDraft, saved: AgentSaved): boolean {
+ return (
+ normalizeName(draft.name) !== normalizeName(saved.name) || draftModel(draft) !== saved.model
+ );
+}
+
+/**
+ * The card's display status, purely from its orchestration inputs:
+ * - No id yet → a never-persisted draft → `"idle"` while unusable or unsaved,
+ * `"error"` if a create attempt failed.
+ * - In-flight save → `"saving"`.
+ * - Last save errored → `"error"`.
+ * - No id and clean (nothing to save) → `"idle"`.
+ * - Dirty (not yet (re)saved) → `"dirty"`.
+ * - Clean + has an id → `"saved"`.
+ */
+export function saveStatus(
+ draft: AgentDraft,
+ saved: AgentSaved,
+ saving: boolean,
+ error: string | null,
+): SaveStatus {
+ if (saving) return "saving";
+ if (error !== null) return "error";
+ const hasId = draft.id !== null || saved.id !== null;
+ if (!hasId) return "idle";
+ return isDirty(draft, saved) ? "dirty" : "saved";
+}
+
+/** Short human label for a save status (the card's status line). */
+export function saveStatusLabel(status: SaveStatus): string {
+ switch (status) {
+ case "saving":
+ return "Saving…";
+ case "saved":
+ return "Saved";
+ case "error":
+ return "Save failed";
+ default:
+ return "Not saved";
+ }
+}
+
+/** True iff a status warrants showing the card body as "saved clean" (no warning tint). */
+export function isClean(status: SaveStatus): boolean {
+ return status === "saved";
+}