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
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
|
// Injected shell for Named Agent auto-save: owns the reactive per-card editing
// + save lifecycle (the thin rune wrapper over `logic/auto-save`), the debounce
// timer for name edits, and the monotonic sequence guard that stops an older
// save response clobbering a newer edit. No ambient state: ONE controller per
// card, created by the view and disposed on unmount. The network calls
// (create/update/delete) are INJECTED ports — this module never touches
// fetch/DOM.
//
// The pure model (`logic/auto-save`) does the status/validity reasoning; this
// controller only owns the effectful plumbing + the sequence guard. A live
// `isNameTaken` callback (supplied by the owning view against the CURRENT
// sibling list) keeps the controller from persisting a duplicate name.
import {
type AgentDraft,
type AgentSaved,
draftModel,
type SaveStatus,
saveStatus,
} from "../logic/auto-save";
import type {
CreateNamedAgent,
DeleteNamedAgent,
NamedAgent,
NamedAgentInput,
UpdateNamedAgent,
} from "../logic/types";
import { isUsableModel, normalizeName } from "../logic/validate";
/** Named-agent edit port bundle (injected by the composition root). */
export interface NamedAgentPorts {
create: CreateNamedAgent;
update: UpdateNamedAgent;
remove: DeleteNamedAgent;
/** Debounce delay (ms) for NAME edits only (provider/model saves are immediate). */
debounceMs?: number;
}
export interface CardSaveState {
/** The current reactive save status (drives the status line). */
readonly status: SaveStatus;
/** A save-failure error message, or null. */
readonly error: string | null;
}
/** A fully-wired Named Agent card controller (one per card row). */
export interface CardController {
/** The current reactive status + error. */
readonly save: CardSaveState;
/** Read the current draft (const snapshots already applied via the setter methods). */
readonly draft: AgentDraft;
/** Whether the card is currently awaiting a save response. */
readonly saving: boolean;
/** Bind a provider id into the draft (immediate save once a model is chosen). */
setProvider(provider: string): void;
/** Bind a model suffix under the current provider (immediate save). */
setModel(model: string): void;
/** Bind a raw name edit (debounced save when it becomes usable). */
setName(name: string): void;
/** Remove the Named Agent (for an existing card) — notifies via `onRemoved`. */
remove(): void;
/** Dispose timers; call on unmount. */
dispose(): void;
}
export interface CardControllerOptions {
/** The existing Named Agent this card edits, or null for a new draft. */
agent: NamedAgent | null;
/** The saved full `<provider>/<model>` ('' for a new draft). */
savedModel: string;
/**
* Live uniqueness predicate: true when `name` is already taken by a SIBLING
* card (the view EXCLUDES this card's own id). Drives the controller's
* usable-name gate so a duplicate is never persisted.
*/
isNameTaken: (name: string) => boolean;
ports: NamedAgentPorts;
/** Fired with the canonical saved record when a create/update settles. */
onChanged: (agent: NamedAgent) => void;
/** Fired when the card should leave the list (after a successful remove, or an unsaved draft). */
onRemoved: () => void;
}
/**
* A card controller bound to one Named Agent (existing) or a new draft.
*
* The controller owns ONLY this card's draft + save lifecycle; the sibling list
* (for uniqueness + reconciling the authoritative list after a save) is the
* parent view's. `onChanged` lets the parent replace the card's canonical record
* on success; `onRemoved` fires when the card is to be dropped.
*/
export function createCardController(opts: CardControllerOptions): CardController {
const debounceMs = opts.ports.debounceMs ?? 400;
// ── The draft: raw form controls (name/provider/model) owned reactively. ──
const { agent, savedModel } = opts;
let id = $state<string | null>(agent?.id ?? null);
let name = $state<string>(agent?.name ?? "");
let provider = $state<string>(savedModel === "" ? "" : (savedModel.split("/")[0] ?? ""));
let model = $state<string>(
savedModel === "" ? "" : savedModel.slice(savedModel.indexOf("/") + 1),
);
// ── Saved-state + in-flight/sequence tracking. ──
// `savedName`/`savedModelValue` = the last PERSISTED definition; `error` =
// last save failure. `editSeq` increments on every input change; a save
// captures the seq at dispatch time and applies its result ONLY if it is still
// the latest — an older response can never overwrite a newer edit (§5.4).
let savedName = $state<string>(agent?.name ?? "");
let savedModelValue = $state<string>(savedModel);
let saving = $state(false);
let error = $state<string | null>(null);
let editSeq = 0;
let nameTimer: ReturnType<typeof setTimeout> | null = null;
function currentDraft(): AgentDraft {
return { id, name, provider, model };
}
function bump(): number {
return ++editSeq;
}
function nameUsable(): boolean {
// A usable name is nonempty + not already taken by a sibling card.
const normalized = normalizeName(name);
return normalized !== "" && !opts.isNameTaken(normalized);
}
function usable(full: string): boolean {
return nameUsable() && isUsableModel(full);
}
// A save is owed + legal to dispatch right now when the card is usable and
// differs from the persisted definition (or is a fresh, usable draft).
function shouldSaveNow(): boolean {
const d = currentDraft();
const full = draftModel(d);
if (!usable(full)) return false;
if (id === null && savedName === "" && savedModelValue === "") return true; // fresh draft
return normalizeName(d.name) !== savedName || full !== savedModelValue;
}
function doSave(seq: number): void {
const d = currentDraft();
const full = draftModel(d);
if (!usable(full)) return;
saving = true;
error = null;
function settle(ok: boolean, saved?: NamedAgent, err?: string): void {
// Stale-response guard: only apply if this is still the latest edit.
if (seq !== editSeq) return;
saving = false;
if (ok && saved) {
savedName = saved.name;
savedModelValue = saved.model;
id = saved.id;
opts.onChanged(saved);
} else {
error = err ?? "Save failed";
}
}
const input: NamedAgentInput = { name: normalizeName(d.name), model: full };
if (id === null) {
void opts.ports.create(input).then(
(r) => (r.ok ? settle(true, r.agent) : settle(false, undefined, r.error)),
(e) => settle(false, undefined, String(e)),
);
} else {
void opts.ports.update(id, input).then(
(r) => (r.ok ? settle(true, r.agent) : settle(false, undefined, r.error)),
(e) => settle(false, undefined, String(e)),
);
}
}
function scheduleNameSave(): void {
if (nameTimer !== null) clearTimeout(nameTimer);
const seq = editSeq;
nameTimer = setTimeout(() => {
nameTimer = null;
if (shouldSaveNow()) doSave(seq);
}, debounceMs);
}
function saveImmediately(): void {
if (nameTimer !== null) {
clearTimeout(nameTimer);
nameTimer = null;
}
const seq = editSeq;
if (shouldSaveNow()) doSave(seq);
}
// Reactive derived status: from the current draft + saved + in-flight + error,
// via the PURE `saveStatus` (this shell supplies only the orchestration inputs).
const save = $derived.by<CardSaveState>(() => {
const d = currentDraft();
const saved: AgentSaved = { id, name: savedName, model: savedModelValue };
return { status: saveStatus(d, saved, saving, error), error };
});
return {
get save(): CardSaveState {
return save;
},
get draft(): AgentDraft {
return currentDraft();
},
get saving(): boolean {
return saving;
},
setProvider(next: string): void {
bump();
provider = next;
// Changing provider clears the prior model if it may not belong to the new
// one; the model dropdown re-populates from the new provider. Persist only
// once a valid model is selected (§5.5) → the model change saves immediately.
model = "";
},
setModel(next: string): void {
bump();
model = next;
saveImmediately();
},
setName(next: string): void {
bump();
name = next;
scheduleNameSave();
},
remove(): void {
if (id === null) {
// An unsaved draft — nothing server-side to delete.
opts.onRemoved();
return;
}
saving = true;
void opts.ports.remove(id).then(
() => {
saving = false;
opts.onRemoved();
},
(e) => {
saving = false;
error = String(e);
},
);
},
dispose(): void {
if (nameTimer !== null) clearTimeout(nameTimer);
nameTimer = null;
},
};
}
|