1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
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";
}
|