@flame0510/project-aether 1.1.15 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +2 -1
  2. package/app/agents/ModelSection.tsx +313 -0
  3. package/app/agents/PageClient.tsx +83 -4
  4. package/app/agents/create/page.tsx +8 -21
  5. package/app/api/agents/[id]/model/route.ts +113 -0
  6. package/app/api/agents/[id]/recreate/route.ts +10 -34
  7. package/app/api/agents/[id]/route.ts +10 -29
  8. package/app/api/agents/create/route.ts +59 -57
  9. package/app/api/agents/models-summary/route.ts +163 -0
  10. package/app/api/assistant/route.ts +36 -15
  11. package/app/api/credentials/[id]/sync/route.ts +3 -3
  12. package/app/api/credentials/detect/route.ts +126 -176
  13. package/app/api/credentials/route.ts +3 -0
  14. package/app/api/gateway/agent/route.ts +23 -6
  15. package/app/api/gateway/provider/keys.ts +13 -1
  16. package/app/api/gateway/provider/route.ts +43 -12
  17. package/app/api/gateway/sync.ts +248 -72
  18. package/app/api/models/route.ts +28 -34
  19. package/app/api/provider/auth.ts +65 -0
  20. package/app/api/provider/upstream.ts +9 -2
  21. package/app/api/provider/v1/chat/completions/route.ts +22 -16
  22. package/app/api/provider/v1/models/route.ts +26 -133
  23. package/app/components/PulseChat.tsx +25 -39
  24. package/app/components/Skeleton.tsx +132 -0
  25. package/app/components/ui/RemoveButton.tsx +46 -0
  26. package/app/components/ui/Select.tsx +3 -2
  27. package/app/components/ui/index.ts +1 -0
  28. package/app/credentials/PageClient.tsx +461 -140
  29. package/app/credentials/loading.tsx +19 -5
  30. package/app/gateway/PageClient.tsx +257 -673
  31. package/app/globals.css +8 -0
  32. package/app/lib/models-context.tsx +43 -7
  33. package/app/wizard/useWizard.ts +6 -1
  34. package/bin/rev4a.js +73 -9
  35. package/docs/ARCHITECTURE.md +92 -33
  36. package/docs/FRONTEND-ARCHITECTURE.md +36 -6
  37. package/docs/REV4A.md +62 -30
  38. package/docs/dev/API-REFERENCE.md +490 -227
  39. package/docs/dev/DATABASE.md +8 -3
  40. package/docs/dev/GATEWAY.md +236 -92
  41. package/docs/dev/PROVIDERS.md +44 -44
  42. package/docs/rag/DATA-FRESHNESS.md +57 -28
  43. package/docs/rag/GLOSSARY.md +20 -18
  44. package/docs/rag/REV4A-OVERVIEW.md +28 -32
  45. package/docs/rag/WHAT-I-CAN-ANSWER.md +10 -12
  46. package/instrumentation.ts +9 -1
  47. package/lib/agent-readiness.ts +110 -0
  48. package/lib/channelManager.ts +64 -22
  49. package/lib/container-file.ts +27 -0
  50. package/lib/credentials/delivery.ts +212 -119
  51. package/lib/credentials/detect.ts +229 -97
  52. package/lib/credentials/providers.ts +38 -7
  53. package/lib/credentials/vault.ts +78 -13
  54. package/lib/docker-exec.ts +50 -14
  55. package/lib/model-catalogue.ts +140 -27
  56. package/lib/rev4a-paths.ts +0 -21
  57. package/model-pricing.json +118 -110
  58. package/models.config.json +27 -12
  59. package/package.json +1 -1
  60. package/app/api/gateway/route.ts +0 -191
@@ -1,7 +1,8 @@
1
1
  'use client';
2
2
 
3
- import { useState, useEffect, useCallback } from 'react';
4
- import { Page, PageHeader, Button, Modal, Input, Pill } from '@/app/components/ui';
3
+ import { useState, useEffect, useCallback, useRef } from 'react';
4
+ import { Page, PageHeader, Button, Modal, Input, Pill, RemoveButton } from '@/app/components/ui';
5
+ import { CredentialCardsSkeleton, AgentSyncRowsSkeleton } from '@/app/components/Skeleton';
5
6
  import type { CredentialProfile } from '@/lib/credentials/vault';
6
7
  import type { CredentialProviderDef } from '@/lib/credentials/providers';
7
8
 
@@ -27,13 +28,21 @@ const PROVIDER_COLOR: Record<string, string> = {
27
28
  notion: '#050505',
28
29
  };
29
30
 
30
- function Checkbox({ checked, onChange, label }: { checked: boolean; onChange: () => void; label: string }) {
31
- return (
32
- <label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', fontSize: 12, padding: '4px 0' }}>
33
- <input type="checkbox" checked={checked} onChange={onChange} style={{ accentColor: 'var(--violet)' }} />
34
- <span style={{ fontFamily: 'var(--font-mono)', color: 'var(--text)' }}>{label}</span>
35
- </label>
36
- );
31
+ /**
32
+ * Read a mutation's JSON body, failing on anything that is not a 2xx.
33
+ *
34
+ * Testing `json.error` alone trusted the response to be JSON and to carry that
35
+ * field. A proxy 502, or Next's own HTML error page, parses to `{}` — no
36
+ * `error` — so the check passed and the caller announced a success for a
37
+ * request that never happened. The credentials routes do return `{ error }` on
38
+ * every path they control, which is exactly why this went unnoticed: the gap is
39
+ * everything in front of them.
40
+ */
41
+ async function readMutation<T>(res: Response): Promise<T> {
42
+ const json = await res.json().catch(() => ({})) as T & { error?: string };
43
+ if (!res.ok) throw new Error(json.error ?? `HTTP ${res.status}`);
44
+ if (json.error) throw new Error(json.error);
45
+ return json;
37
46
  }
38
47
 
39
48
  function formatRelative(ts: number | null): string {
@@ -69,43 +78,190 @@ export default function CredentialsPageClient() {
69
78
  trelloCliInstalled: boolean;
70
79
  notionLoggedIn: boolean;
71
80
  matchedProfiles?: { profileId: string; providerId: string; label: string }[];
81
+ installedProviders?: string[];
82
+ /** Present instead of the fields above when the probe failed for this
83
+ * container. Every other property is absent, so it must be checked first. */
84
+ error?: string;
72
85
  }[]>([]);
73
86
 
74
87
  const [configureProviderId, setConfigureProviderId] = useState<string | null>(null);
75
88
  const [editProfile, setEditProfile] = useState<CredentialProfile | null>(null);
76
89
  const [deleteId, setDeleteId] = useState<string | null>(null);
77
90
  const [toast, setToast] = useState<string | null>(null);
91
+ const [detectRefreshing, setDetectRefreshing] = useState(false);
92
+
93
+ // Whether detect has ever answered, success or failure. A one-way latch, and
94
+ // deliberately not reset per call: every mutation fires a single page-wide
95
+ // `loadDetect()`, so clearing it each time would hide every delete button on
96
+ // the page for the duration of any one sync.
97
+ const [detectLoaded, setDetectLoaded] = useState(false);
98
+
99
+ // Distinct from `detectLoaded`, which only records that the call finished.
100
+ // A failed call finishes too, leaving `detectData` empty — indistinguishable
101
+ // from a fleet that genuinely holds nothing, which is what unlocked Delete on
102
+ // every credential the moment the session expired or Docker went down.
103
+ const [detectFailed, setDetectFailed] = useState(false);
104
+
105
+ // One key identifies the single mutation allowed in flight, e.g.
106
+ // 'delete:cred_123' or 'sync:cred_123:agent_ab'. Replaces the scattered
107
+ // per-action booleans: the previous `syncingContainer` held only a container
108
+ // name, but the per-agent rows are rendered once per profile, so syncing one
109
+ // profile put every other profile's button for that container into a
110
+ // spinner too. Including the profile id in the key removes that entirely.
111
+ const [busy, setBusy] = useState<string | null>(null);
112
+
113
+ // The ref is the authoritative guard, the state only drives rendering.
114
+ // `busy` is read from the render that produced the click, so two clicks
115
+ // landing before React commits the disabled button would both see null and
116
+ // both fire. A ref is updated synchronously, so the second one cannot.
117
+ const busyRef = useRef<string | null>(null);
118
+ const claimBusy = (key: string): boolean => {
119
+ if (busyRef.current) return false;
120
+ busyRef.current = key;
121
+ setBusy(key);
122
+ return true;
123
+ };
124
+ const releaseBusy = () => {
125
+ busyRef.current = null;
126
+ setBusy(null);
127
+ };
128
+
129
+ // Held so a second message cannot be cleared by the first one's timer: two
130
+ // syncs 2.5s apart left the later toast on screen for half a second before an
131
+ // older, unowned timeout fired and wiped it.
132
+ const toastTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
133
+ const showToast = (msg: string) => {
134
+ setToast(msg);
135
+ if (toastTimer.current) clearTimeout(toastTimer.current);
136
+ toastTimer.current = setTimeout(() => setToast(null), 3000);
137
+ };
138
+ useEffect(() => () => {
139
+ if (toastTimer.current) clearTimeout(toastTimer.current);
140
+ }, []);
141
+
142
+ /**
143
+ * Apply a confirmed sync/de-sync to the detected state right away.
144
+ *
145
+ * The Sync / De-sync label is driven by `matchedProfiles`, which only detect
146
+ * fills in — and detect takes seconds. Waiting for it left the button live
147
+ * again but still showing the state from before the click, so it invited a
148
+ * second, pointless press. This is not a guess: the server has already
149
+ * confirmed the change, so the new state is known. The detect refresh that
150
+ * follows just corroborates it.
151
+ */
152
+ const applyMatchLocally = (
153
+ profile: CredentialProfile,
154
+ container: string,
155
+ matched: boolean,
156
+ ) => {
157
+ setDetectData((prev) => prev.map((d) => {
158
+ if (d.container !== container) return d;
159
+ // Keyed on the provider, not the profile. A container holds one credential
160
+ // per provider, and both operations act on the provider as a whole:
161
+ // delivery cleans it before writing, de-sync removes its config outright.
162
+ // So no sibling profile can survive either one, and filtering by profile id
163
+ // left the previous holder still marked as installed — two rows showing a
164
+ // green tick, each offering a De-sync that would take the other's token.
165
+ const without = (d.matchedProfiles ?? []).filter((m) => m.providerId !== profile.providerId);
166
+
167
+ // `installedProviders` has to move together with `matchedProfiles`.
168
+ // Updating only the latter left the row reading the two against each
169
+ // other — provider still listed as installed, no profile accounting for
170
+ // it — which is exactly the ◆ "unknown credential" state, held for the
171
+ // whole seconds-long gap until detect answered and cleared it. The de-sync
172
+ // path removes the provider's credential from the container outright, so
173
+ // dropping it here is what the container actually looks like; the guard
174
+ // keeps it if some other profile of the same provider is still matched.
175
+ // `without` can no longer hold this provider, so there is nothing left to
176
+ // check before dropping it: the guard that used to stand here was dead the
177
+ // moment the filter above became provider-keyed.
178
+ const installed = new Set(d.installedProviders ?? []);
179
+ if (matched) installed.add(profile.providerId);
180
+ else installed.delete(profile.providerId);
181
+
182
+ return {
183
+ ...d,
184
+ matchedProfiles: matched
185
+ ? [...without, { profileId: profile.id, providerId: profile.providerId, label: profile.label }]
186
+ : without,
187
+ installedProviders: [...installed],
188
+ };
189
+ }));
190
+ };
191
+
192
+ /**
193
+ * The two endpoints are split because they cost wildly different amounts and
194
+ * carry different things. /api/credentials is a single SQLite read (~3ms) and
195
+ * holds everything the cards need; /api/credentials/detect shells into every
196
+ * agent container and takes seconds, and only feeds the per-agent rows.
197
+ *
198
+ * Keeping them apart is what lets a create or a delete show up immediately:
199
+ * the mutation paths refresh the cheap one and let the expensive one catch up
200
+ * behind, instead of making the user wait on work their change did not need.
201
+ */
202
+ const loadProfiles = useCallback(async (signal?: AbortSignal) => {
203
+ const res = await fetch('/api/credentials', { signal });
204
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
205
+ setData(await res.json() as ApiData);
206
+ }, []);
207
+
208
+ // Only the most recently started detect may apply its answer. Two can easily
209
+ // overlap: a mutation fires one without awaiting it, then releases the lock
210
+ // within milliseconds, so a second mutation starts another while the first is
211
+ // still running. Whichever returned last used to win — often the older one,
212
+ // which knows nothing of the newer change.
213
+ const detectSeq = useRef(0);
214
+ const detectAbortRef = useRef<AbortController | null>(null);
215
+
216
+ const loadDetect = useCallback(async (signal?: AbortSignal) => {
217
+ // Cancel any detect still running: it costs seconds of work inside every
218
+ // agent container, and its answer would be discarded by the sequence guard
219
+ // below anyway. The guard stays — abortion frees the work, the guard is what
220
+ // makes a late reply harmless.
221
+ detectAbortRef.current?.abort();
222
+ const controller = new AbortController();
223
+ detectAbortRef.current = controller;
224
+ signal?.addEventListener('abort', () => controller.abort(), { once: true });
225
+
226
+ const seq = ++detectSeq.current;
227
+ setDetectRefreshing(true);
228
+ try {
229
+ const res = await fetch('/api/credentials/detect', { signal: controller.signal });
230
+ // An error response is still valid JSON, so without this check a 401 or a
231
+ // 500 body flowed through `json.agents ?? []` and became an empty fleet —
232
+ // reported as success. The page then stated, as fact, that no agent holds
233
+ // any credential. Throwing here keeps whatever data was already on screen.
234
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
235
+ const json = await res.json() as { agents?: unknown[] };
236
+ if (seq === detectSeq.current) {
237
+ setDetectData((json.agents ?? []) as typeof detectData);
238
+ setDetectFailed(false);
239
+ }
240
+ } catch {
241
+ // Only the newest call may report; a superseded one losing is not a
242
+ // failure, and an abort is our own doing.
243
+ if (seq === detectSeq.current && !controller.signal.aborted) setDetectFailed(true);
244
+ // Best-effort: every card renders without this. Detect used to be awaited
245
+ // inside the page-wide try, so one failure there blanked a page whose real
246
+ // data had already arrived.
247
+ } finally {
248
+ if (!controller.signal.aborted) setDetectLoaded(true);
249
+ // A superseded call must not clear the flag — the newer one is still
250
+ // running, and the rows it feeds are still stale.
251
+ if (seq === detectSeq.current && !controller.signal.aborted) setDetectRefreshing(false);
252
+ }
253
+ }, []);
78
254
 
255
+ /**
256
+ * Only the cheap call gates the first paint. Detect shells into every agent
257
+ * container and takes seconds; awaiting it here held back the whole page —
258
+ * cards, stored profiles, the Add buttons — for work none of them need. It is
259
+ * started without being awaited, and the per-agent rows show a skeleton until
260
+ * it lands.
261
+ */
79
262
  const load = useCallback(async (signal?: AbortSignal) => {
80
263
  try {
81
- const [credsRes, detectRes] = await Promise.all([
82
- fetch('/api/credentials', { signal }),
83
- fetch('/api/credentials/detect', { signal }),
84
- ]);
85
- if (!credsRes.ok) throw new Error(`HTTP ${credsRes.status}`);
86
- const json = await credsRes.json() as ApiData;
87
- const detectJson = await detectRes.json() as { agents?: {
88
- container: string;
89
- agentId: string;
90
- displayName?: string;
91
- githubLoggedIn: boolean;
92
- githubUser: string;
93
- githubCliInstalled: boolean;
94
- vercelLoggedIn: boolean;
95
- vercelUser: string;
96
- vercelTeam: string;
97
- vercelCliInstalled: boolean;
98
- supabaseLoggedIn: boolean;
99
- supabaseLinked: boolean;
100
- supabaseProjectRef: string;
101
- supabaseCliInstalled: boolean;
102
- trelloLoggedIn: boolean;
103
- trelloCliInstalled: boolean;
104
- notionLoggedIn: boolean;
105
- matchedProfiles?: { profileId: string; providerId: string; label: string }[];
106
- }[] };
107
- setData(json);
108
- setDetectData(detectJson.agents ?? []);
264
+ await loadProfiles(signal);
109
265
  setError(null);
110
266
  } catch (e: unknown) {
111
267
  if ((e as Error)?.name === 'AbortError') return;
@@ -113,7 +269,28 @@ export default function CredentialsPageClient() {
113
269
  } finally {
114
270
  if (!signal?.aborted) setLoading(false);
115
271
  }
116
- }, []);
272
+ void loadDetect(signal);
273
+ }, [loadProfiles, loadDetect]);
274
+
275
+ /**
276
+ * Refresh after a create, edit or delete.
277
+ *
278
+ * Awaits only the cheap call, so the caller can keep its button spinning for
279
+ * the few milliseconds until the change is actually on screen, then closes.
280
+ * Detect is deliberately not awaited.
281
+ *
282
+ * Never rejects. The mutation has already succeeded by the time this runs, so
283
+ * letting a failed re-read propagate would surface as "save failed" on a saved
284
+ * credential — and invite the user to create it a second time.
285
+ */
286
+ const refreshAfterChange = useCallback(async () => {
287
+ try {
288
+ await loadProfiles();
289
+ } catch (e: unknown) {
290
+ showToast(`Saved, but the list could not be refreshed: ${(e as Error).message}`);
291
+ }
292
+ void loadDetect();
293
+ }, [loadProfiles, loadDetect]);
117
294
 
118
295
  useEffect(() => {
119
296
  const controller = new AbortController();
@@ -121,75 +298,81 @@ export default function CredentialsPageClient() {
121
298
  return () => controller.abort();
122
299
  }, [load]);
123
300
 
124
- const refreshDetectOnly = async () => {
125
- try {
126
- const res = await fetch('/api/credentials/detect');
127
- const json = await res.json() as { agents?: unknown[] };
128
- setDetectData((json.agents ?? []) as typeof detectData);
129
- } catch { /* best-effort */ }
130
- };
131
-
132
- const showToast = (msg: string) => {
133
- setToast(msg);
134
- setTimeout(() => setToast(null), 3000);
135
- };
136
-
137
- const [syncingContainer, setSyncingContainer] = useState<string | null>(null);
138
-
139
- const handleSyncOne = async (profileId: string, container: string, label?: string) => {
140
- setSyncingContainer(container);
301
+ const handleSyncOne = async (profile: CredentialProfile, container: string, label?: string) => {
302
+ if (!claimBusy(`sync:${profile.id}:${container}`)) return;
141
303
  try {
142
- const res = await fetch(`/api/credentials/${profileId}/sync`, {
304
+ const res = await fetch(`/api/credentials/${profile.id}/sync`, {
143
305
  method: 'POST',
144
306
  headers: { 'Content-Type': 'application/json' },
145
307
  body: JSON.stringify({ containers: [container] }),
146
308
  });
147
- const json = await res.json() as { results?: { container: string; status: string }[]; error?: string };
148
- if (json.error) throw new Error(json.error);
149
- showToast(json.results?.[0]?.status === 'ok' ? `Synced to ${label || container}` : 'Sync error');
150
- await refreshDetectOnly();
309
+ const json = await readMutation<{ results?: { container: string; status: string }[] }>(res);
310
+ const ok = json.results?.[0]?.status === 'ok';
311
+ if (ok) applyMatchLocally(profile, container, true);
312
+ showToast(ok ? `Synced to ${label || container}` : 'Sync error');
151
313
  } catch (e: unknown) {
152
314
  showToast((e as Error).message);
153
315
  } finally {
154
- setSyncingContainer(null);
316
+ releaseBusy();
155
317
  }
318
+ // Refreshed outside the busy window on purpose. Detect takes seconds, and
319
+ // `disabled={!!busy}` is page-wide, so awaiting it here left every button
320
+ // on the page dead for that whole time after each sync. The dimmed rows
321
+ // already say the detected state is being re-read.
322
+ void loadDetect();
156
323
  };
157
324
 
158
- const handleDeSyncOne = async (profileId: string, container: string, label?: string) => {
159
- setSyncingContainer(container);
325
+ const handleDeSyncOne = async (profile: CredentialProfile, container: string, label?: string) => {
326
+ if (!claimBusy(`desync:${profile.id}:${container}`)) return;
160
327
  try {
161
- const res = await fetch(`/api/credentials/${profileId}/sync`, {
328
+ const res = await fetch(`/api/credentials/${profile.id}/sync`, {
162
329
  method: 'DELETE',
163
330
  headers: { 'Content-Type': 'application/json' },
164
331
  body: JSON.stringify({ container }),
165
332
  });
166
- const json = await res.json() as { ok?: boolean; error?: string };
167
- if (json.error) throw new Error(json.error);
333
+ await readMutation<{ ok?: boolean }>(res);
334
+ applyMatchLocally(profile, container, false);
168
335
  showToast(`Removed from ${label || container}`);
169
- await refreshDetectOnly();
170
336
  } catch (e: unknown) {
171
337
  showToast((e as Error).message);
172
338
  } finally {
173
- setSyncingContainer(null);
339
+ releaseBusy();
174
340
  }
341
+ // Same reasoning as handleSyncOne above.
342
+ void loadDetect();
175
343
  };
176
344
 
177
345
  const handleDelete = async (id: string) => {
346
+ // Without this the confirm dialog stayed open and clickable for the whole
347
+ // request — `setDeleteId(null)` ran only after the await — so the button
348
+ // could be pressed repeatedly, firing a DELETE, a toast and a full reload
349
+ // each time.
350
+ if (!claimBusy(`delete:${id}`)) return;
178
351
  try {
179
- await fetch(`/api/credentials/${id}`, { method: 'DELETE' });
352
+ const res = await fetch(`/api/credentials/${id}`, { method: 'DELETE' });
353
+ await readMutation<Record<string, never>>(res);
180
354
  setDeleteId(null);
181
355
  showToast('Credential deleted');
182
- void load();
356
+ await refreshAfterChange();
183
357
  } catch (e: unknown) {
184
358
  showToast((e as Error).message);
359
+ } finally {
360
+ releaseBusy();
185
361
  }
186
362
  };
187
363
 
188
364
  if (loading) {
365
+ // The header carries the same description as the loaded state on purpose:
366
+ // omitting it here made the title block grow by one line the moment the
367
+ // data arrived, shifting the whole grid down.
189
368
  return (
190
369
  <Page>
191
- <PageHeader eyebrow="" title="Credentials" />
192
- <p style={{ color: 'var(--text-dim)', fontSize: 13 }}>Loading…</p>
370
+ <PageHeader
371
+ eyebrow=""
372
+ title="Credentials"
373
+ description="Store third-party credentials once, sync to all agent containers."
374
+ />
375
+ <CredentialCardsSkeleton />
193
376
  </Page>
194
377
  );
195
378
  }
@@ -300,17 +483,79 @@ export default function CredentialsPageClient() {
300
483
  </div>
301
484
  </div>
302
485
  <div style={{ display: 'flex', gap: 4, flexShrink: 0 }}>
303
- <Button type="button" variant="ghost" size="sm" onClick={() => setEditProfile(p)}>Edit</Button>
304
- {detectData.some((d) => d.matchedProfiles?.some((m) => m.profileId === p.id)) ? null : (
305
- <Button type="button" variant="danger" size="sm" onClick={() => setDeleteId(p.id)}>×</Button>
486
+ <Button type="button" variant="ghost" size="sm" disabled={!!busy} onClick={() => setEditProfile(p)}>Edit</Button>
487
+ {/* Shown only once detect has actually answered AND reports no
488
+ match. `.some()` on an empty array is false, so before this
489
+ guard a fleet we simply had not heard from yet — or an error
490
+ response — read as "synced nowhere", offering a delete the UI
491
+ means to withhold for a credential that is in use. */}
492
+ {/* Withheld unless detect has answered for *every* agent and
493
+ none of them holds this credential. A container whose probe
494
+ failed reports no `matchedProfiles` at all, which read as
495
+ "not installed here" — so one unreachable agent was enough
496
+ to offer deleting a credential that may well be installed
497
+ on it, leaving it live in a container with no vault record. */}
498
+ {detectLoaded
499
+ && !detectFailed
500
+ && !detectData.some((d) => d.error)
501
+ && !detectData.some((d) => d.matchedProfiles?.some((m) => m.profileId === p.id)) && (
502
+ <RemoveButton label={p.label ?? p.id} disabled={!!busy} onClick={() => setDeleteId(p.id)} />
306
503
  )}
307
504
  </div>
308
505
  </div>
309
506
  {/* Agent detect status — all containers shown for every provider */}
310
- {detectData.length > 0 && (
507
+ {/* Three states, kept distinct on purpose: not answered
508
+ yet (skeleton), answered with no agents (nothing), and
509
+ answered with agents (the rows). Collapsing the first
510
+ two would present "no agent holds this credential" as
511
+ fact while we are still asking. */}
512
+ {!detectLoaded && <AgentSyncRowsSkeleton />}
513
+ {/* Said out loud rather than rendered as an empty fleet.
514
+ Silence here reads as "no agent holds this", which is
515
+ the one conclusion a failed probe cannot support. */}
516
+ {detectLoaded && detectFailed && (
517
+ <div style={{ marginTop: 8, fontSize: 11, color: 'var(--red)' }}>
518
+ Agent status unavailable
519
+ {detectData.length > 0 ? ' — showing the last known state' : ''}
520
+ </div>
521
+ )}
522
+ {detectLoaded && detectData.length > 0 && (
311
523
  <div style={{ marginTop: 8, display: 'flex', flexDirection: 'column', gap: 3 }}>
312
524
  {detectData.map((d) => {
525
+ // Checked before anything else: on an error entry every other field is
526
+ // undefined, which made `cliOk` falsy and had the row announce "CLI not
527
+ // installed" — sending the user to rebuild a base image over what is
528
+ // really a container that did not answer.
529
+ if (d.error) {
530
+ return (
531
+ <div key={d.container || d.agentId} style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, fontFamily: 'var(--font-mono)' }}>
532
+ <span style={{ color: 'var(--red)', fontSize: 12, fontWeight: 700 }}>!</span>
533
+ <span style={{ color: 'var(--text-dim)', flex: 1 }}>
534
+ {d.displayName || d.agentId}
535
+ <span style={{ marginLeft: 6, color: 'var(--red)', fontSize: 10 }}>unreachable</span>
536
+ </span>
537
+ </div>
538
+ );
539
+ }
540
+
313
541
  const matchByProfile = d.matchedProfiles?.some((m) => m.profileId === p.id);
542
+ // A credential is installed for this provider, but it is not
543
+ // this profile's — and not any stored profile's either. It is
544
+ // what an edited-then-not-resynced credential looks like, and
545
+ // also a token installed by hand or left by a deleted profile.
546
+ // The slot holds someone else's credential. Syncing this
547
+ // profile evicts it, so the button has to say so — whether or
548
+ // not the vault can name the occupant.
549
+ const providerOccupied =
550
+ !matchByProfile
551
+ && (d.installedProviders?.includes(p.providerId) ?? false);
552
+ // The vault CAN name it: another stored profile of the same
553
+ // provider matches. Not foreign, just not this one.
554
+ const heldByOther = providerOccupied
555
+ ? (d.matchedProfiles ?? []).find((m) => m.providerId === p.providerId)
556
+ : undefined;
557
+ // Nobody accounts for it — the ◆ case, kept narrow.
558
+ const foreignCredential = providerOccupied && !heldByOther;
314
559
 
315
560
  // Determine CLI installed status for this provider on this agent
316
561
  const cliOk = isGithub
@@ -319,9 +564,11 @@ export default function CredentialsPageClient() {
319
564
  ? d.vercelCliInstalled
320
565
  : isSupabase
321
566
  ? d.supabaseCliInstalled
322
- : isTrello
323
- ? d.trelloCliInstalled
324
- : true; // notion has no CLI — always available
567
+ // Trello and Notion are both used through their REST
568
+ // API, so there is no CLI whose absence could block
569
+ // them; `trelloCliInstalled` stays in the response for
570
+ // shape compatibility but no longer gates anything.
571
+ : true;
325
572
 
326
573
  let extraInfo = '';
327
574
  if (matchByProfile) {
@@ -347,14 +594,39 @@ export default function CredentialsPageClient() {
347
594
 
348
595
  return (
349
596
  <div key={d.container || d.agentId} style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, fontFamily: 'var(--font-mono)' }}>
350
- <span style={{ color: matchByProfile ? 'var(--green)' : 'var(--text-dim)', fontSize: 12, fontWeight: 700 }}>
351
- {matchByProfile ? '✓' : '○'}
597
+ {/* Only the detected state fades while detect is
598
+ re-read — never the buttons. Dimming their
599
+ ancestor made them read as disabled while they
600
+ were still perfectly clickable. */}
601
+ <span style={{
602
+ color: matchByProfile
603
+ ? 'var(--green)'
604
+ : foreignCredential ? 'var(--yellow)' : 'var(--text-dim)',
605
+ fontSize: 12, fontWeight: 700,
606
+ opacity: detectRefreshing ? 0.4 : 1,
607
+ transition: 'opacity 0.15s',
608
+ }}>
609
+ {matchByProfile ? '✓' : foreignCredential ? '◆' : '○'}
352
610
  </span>
353
- <span style={{ color: 'var(--text-dim)', flex: 1 }}>
611
+ <span style={{
612
+ color: 'var(--text-dim)', flex: 1,
613
+ opacity: detectRefreshing ? 0.4 : 1,
614
+ transition: 'opacity 0.15s',
615
+ }}>
354
616
  {d.displayName || d.agentId}
355
617
  {extraInfo && (
356
618
  <span style={{ marginLeft: 6, color: 'var(--green)', fontSize: 10 }}>{extraInfo}</span>
357
619
  )}
620
+ {foreignCredential && (
621
+ <span style={{ marginLeft: 6, color: 'var(--yellow)', fontSize: 10 }}>
622
+ unknown credential
623
+ </span>
624
+ )}
625
+ {heldByOther && (
626
+ <span style={{ marginLeft: 6, color: 'var(--text-dim)', fontSize: 10 }}>
627
+ held by {heldByOther.label}
628
+ </span>
629
+ )}
358
630
  {showCliMissing && (
359
631
  <span style={{ marginLeft: 6, color: 'var(--red)', fontSize: 10 }}>CLI not installed</span>
360
632
  )}
@@ -368,21 +640,26 @@ export default function CredentialsPageClient() {
368
640
  <Button
369
641
  variant='danger'
370
642
  size='sm'
371
- loading={syncingContainer === d.container}
372
- disabled={syncingContainer === d.container}
373
- onClick={() => handleDeSyncOne(p.id, d.container, d.displayName || d.agentId)}
643
+ loading={busy === `desync:${p.id}:${d.container}`}
644
+ disabled={!!busy}
645
+ onClick={() => handleDeSyncOne(p, d.container, d.displayName || d.agentId)}
374
646
  >
375
- De-sync
647
+ {busy === `desync:${p.id}:${d.container}` ? 'Removing…' : 'De-sync'}
376
648
  </Button>
377
649
  ) : (
378
650
  <Button
379
651
  variant='primary'
380
652
  size='sm'
381
- loading={syncingContainer === d.container}
382
- disabled={syncingContainer === d.container}
383
- onClick={() => handleSyncOne(p.id, d.container, d.displayName || d.agentId)}
653
+ loading={busy === `sync:${p.id}:${d.container}`}
654
+ disabled={!!busy}
655
+ title={heldByOther
656
+ ? `Replaces the credential from “${heldByOther.label}” on this agent`
657
+ : providerOccupied
658
+ ? 'Replaces the credential currently installed on this agent'
659
+ : undefined}
660
+ onClick={() => handleSyncOne(p, d.container, d.displayName || d.agentId)}
384
661
  >
385
- Sync
662
+ {busy === `sync:${p.id}:${d.container}` ? 'Syncing…' : 'Sync'}
386
663
  </Button>
387
664
  )}
388
665
  </div>
@@ -403,6 +680,7 @@ export default function CredentialsPageClient() {
403
680
  type="button"
404
681
  variant={isConfigured ? 'ghost' : 'primary'}
405
682
  size="sm"
683
+ disabled={!!busy}
406
684
  onClick={() => setConfigureProviderId(provider.id)}
407
685
  >
408
686
  {isConfigured ? '+ Add another' : 'Configure'}
@@ -419,7 +697,7 @@ export default function CredentialsPageClient() {
419
697
  providers={providers}
420
698
  initialProviderId={configureProviderId ?? undefined}
421
699
  onClose={() => setConfigureProviderId(null)}
422
- onSaved={() => { setConfigureProviderId(null); showToast('Credential saved'); void load(); }}
700
+ onSaved={async () => { await refreshAfterChange(); setConfigureProviderId(null); showToast('Credential saved'); }}
423
701
  />
424
702
 
425
703
  {/* Edit Modal */}
@@ -429,18 +707,27 @@ export default function CredentialsPageClient() {
429
707
  providers={providers}
430
708
  editProfile={editProfile}
431
709
  onClose={() => setEditProfile(null)}
432
- onSaved={() => { setEditProfile(null); showToast('Credential updated'); void load(); }}
710
+ onSaved={async () => { await refreshAfterChange(); setEditProfile(null); showToast('Credential updated'); }}
433
711
  />
434
712
  )}
435
713
 
436
714
  {/* Delete Confirm Modal */}
437
- <Modal open={!!deleteId} onClose={() => setDeleteId(null)} title="Delete Credential" maxWidth={440}>
715
+ <Modal open={!!deleteId} onClose={() => { if (!busy) setDeleteId(null); }} title="Delete Credential" maxWidth={440}>
438
716
  <p style={{ fontSize: 13, color: 'var(--text)', marginBottom: 20 }}>
439
717
  This will permanently delete the credential and its secret. This cannot be undone.
440
718
  </p>
441
719
  <div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
442
- <Button type="button" variant="secondary" size="sm" onClick={() => setDeleteId(null)}>Cancel</Button>
443
- <Button type="button" variant="danger" size="sm" onClick={() => { if (deleteId) void handleDelete(deleteId); }}>Delete</Button>
720
+ <Button type="button" variant="secondary" size="sm" disabled={!!busy} onClick={() => setDeleteId(null)}>Cancel</Button>
721
+ <Button
722
+ type="button"
723
+ variant="danger"
724
+ size="sm"
725
+ loading={busy === `delete:${deleteId}`}
726
+ disabled={!!busy}
727
+ onClick={() => { if (deleteId) void handleDelete(deleteId); }}
728
+ >
729
+ {busy === `delete:${deleteId}` ? 'Deleting…' : 'Delete'}
730
+ </Button>
444
731
  </div>
445
732
  </Modal>
446
733
 
@@ -463,7 +750,9 @@ interface AddEditModalProps {
463
750
  initialProviderId?: string;
464
751
  editProfile?: CredentialProfile | null;
465
752
  onClose: () => void;
466
- onSaved: () => void;
753
+ /** May be async: the modal keeps its Save button spinning until it settles,
754
+ * so the dialog closes once the new data is on screen, not before. */
755
+ onSaved: () => void | Promise<void>;
467
756
  }
468
757
 
469
758
  function AddEditModal({ open, providers, initialProviderId, editProfile, onClose, onSaved }: AddEditModalProps) {
@@ -471,6 +760,10 @@ function AddEditModal({ open, providers, initialProviderId, editProfile, onClose
471
760
  const [label, setLabel] = useState(editProfile?.label ?? '');
472
761
  const [fields, setFields] = useState<FieldValues>({});
473
762
  const [saving, setSaving] = useState(false);
763
+ // Rule 6a: `saving` is read from the render that produced the click, so two
764
+ // clicks landing before React commits the disabled button both see false and
765
+ // both POST — and `createProfile` mints a fresh id each time, so both persist.
766
+ const savingRef = useRef(false);
474
767
  const [error, setError] = useState<string | null>(null);
475
768
 
476
769
  const providerId = editProfile?.providerId ?? initialProviderId ?? providers[0]?.id ?? '';
@@ -494,6 +787,8 @@ function AddEditModal({ open, providers, initialProviderId, editProfile, onClose
494
787
  if (missingField) { setError(`${missingField.label} is required`); return; }
495
788
  }
496
789
 
790
+ if (savingRef.current) return;
791
+ savingRef.current = true;
497
792
  setSaving(true);
498
793
  setError(null);
499
794
  try {
@@ -503,21 +798,20 @@ function AddEditModal({ open, providers, initialProviderId, editProfile, onClose
503
798
  headers: { 'Content-Type': 'application/json' },
504
799
  body: JSON.stringify({ label: label.trim(), secret: fields }),
505
800
  });
506
- const json = await res.json() as { error?: string };
507
- if (json.error) throw new Error(json.error);
801
+ await readMutation<Record<string, never>>(res);
508
802
  } else {
509
803
  const res = await fetch('/api/credentials', {
510
804
  method: 'POST',
511
805
  headers: { 'Content-Type': 'application/json' },
512
806
  body: JSON.stringify({ providerId, label: label.trim(), secret: fields }),
513
807
  });
514
- const json = await res.json() as { error?: string };
515
- if (json.error) throw new Error(json.error);
808
+ await readMutation<{ id?: string }>(res);
516
809
  }
517
- onSaved();
810
+ await onSaved();
518
811
  } catch (e: unknown) {
519
812
  setError((e as Error).message);
520
813
  } finally {
814
+ savingRef.current = false;
521
815
  setSaving(false);
522
816
  }
523
817
  };
@@ -542,43 +836,70 @@ function AddEditModal({ open, providers, initialProviderId, editProfile, onClose
542
836
  placeholder={`e.g. Production ${provider?.label ?? ''}`}
543
837
  />
544
838
 
545
- {provider?.fields.map((field) => (
546
- <div key={field.key}>
547
- <Input
548
- label={field.label}
549
- type={field.type}
550
- value={fields[field.key] ?? ''}
551
- onChange={(e) => setField(field.key, e.target.value)}
552
- placeholder={isEdit ? '(leave empty to keep current)' : field.placeholder}
553
- autoComplete="off"
554
- />
555
- {field.hint && (
556
- <div style={{ fontSize: 11, color: 'var(--text-dim)', marginTop: 4 }}>{field.hint}</div>
557
- )}
558
- {provider.id === 'trello' && field.key === 'apiKey' && fields['apiKey'] && (
559
- <button
560
- onClick={() =>
561
- window.open(
562
- `https://trello.com/1/authorize?key=${encodeURIComponent(fields['apiKey'])}&response_type=token&scope=read,write&expiration=never&name=Rev4a`,
563
- '_blank',
564
- )
565
- }
566
- style={{
567
- marginTop: 6,
568
- fontSize: 11,
569
- padding: '4px 10px',
570
- background: 'var(--accent)',
571
- color: '#fff',
572
- border: 'none',
573
- borderRadius: 0,
574
- cursor: 'pointer',
575
- }}
576
- >
577
- Generate Trello Token
578
- </button>
579
- )}
580
- </div>
581
- ))}
839
+ {provider?.fields.map((field) => {
840
+ // Trello is the only provider whose second value is minted rather than
841
+ // copied: the token comes back from an authorize URL built out of the
842
+ // API key, so the control lives under the token field it fills, and
843
+ // stays visible-but-disabled until the key it needs is there. Hiding
844
+ // it until then made it appear out of nowhere, and left the token
845
+ // field's hint pointing at a button that was not on screen.
846
+ const isTrelloToken = provider.id === 'trello' && field.key === 'token';
847
+ const apiKeyReady = (fields['apiKey'] ?? '').trim().length > 0;
848
+
849
+ return (
850
+ <div key={field.key}>
851
+ <Input
852
+ label={field.label}
853
+ type={field.type}
854
+ value={fields[field.key] ?? ''}
855
+ onChange={(e) => setField(field.key, e.target.value)}
856
+ placeholder={isEdit ? '(leave empty to keep current)' : field.placeholder}
857
+ autoComplete="off"
858
+ />
859
+ {(field.hint || field.docsUrl) && (
860
+ <div style={{ fontSize: 11, color: 'var(--text-dim)', marginTop: 4 }}>
861
+ {field.hint}
862
+ {field.docsUrl && (
863
+ <a
864
+ href={field.docsUrl}
865
+ target="_blank"
866
+ rel="noopener noreferrer"
867
+ style={{ color: 'var(--violet)', marginLeft: field.hint ? 6 : 0 }}
868
+ >
869
+ {field.docsLabel ?? 'docs'} ↗
870
+ </a>
871
+ )}
872
+ </div>
873
+ )}
874
+ {isTrelloToken && (
875
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 6 }}>
876
+ <Button
877
+ type="button"
878
+ variant="primary"
879
+ size="sm"
880
+ disabled={!apiKeyReady}
881
+ onClick={() =>
882
+ window.open(
883
+ `https://trello.com/1/authorize?key=${encodeURIComponent(fields['apiKey'] ?? '')}&response_type=token&scope=read,write&expiration=never&name=Rev4a`,
884
+ '_blank',
885
+ 'noopener,noreferrer',
886
+ )
887
+ }
888
+ >
889
+ Generate Trello Token ↗
890
+ </Button>
891
+ {!apiKeyReady && (
892
+ <span style={{ fontSize: 11, color: 'var(--text-dim)' }}>
893
+ {isEdit
894
+ ? 'Re-enter the API Key to generate a new token'
895
+ : 'Fill in the API Key first'}
896
+ </span>
897
+ )}
898
+ </div>
899
+ )}
900
+ </div>
901
+ );
902
+ })}
582
903
 
583
904
  {error && (
584
905
  <div style={{ fontSize: 12, color: 'var(--red)', padding: '6px 10px', border: '1px solid var(--red)' }}>
@@ -587,9 +908,9 @@ function AddEditModal({ open, providers, initialProviderId, editProfile, onClose
587
908
  )}
588
909
 
589
910
  <div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
590
- <Button type="button" variant="secondary" size="sm" onClick={onClose}>Cancel</Button>
911
+ <Button type="button" variant="secondary" size="sm" disabled={saving} onClick={onClose}>Cancel</Button>
591
912
  <Button type="button" variant="primary" size="sm" loading={saving} onClick={() => void handleSave()}>
592
- {isEdit ? 'Save Changes' : 'Save'}
913
+ {saving ? 'Saving…' : (isEdit ? 'Save Changes' : 'Save')}
593
914
  </Button>
594
915
  </div>
595
916
  </div>