@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
@@ -3,143 +3,36 @@
3
3
  *
4
4
  * GET /api/provider/v1/models
5
5
  *
6
- * Returns models available through the Rev4a Gateway
7
- * in OpenAI-compatible format, filtered by the provider
8
- * identified from the Authorization token.
6
+ * Returns the models available through the Rev4a Gateway, in OpenAI-compatible
7
+ * format. This is the endpoint agent containers call to discover what they can
8
+ * ask for, so it must agree with what `gateway/sync.ts` writes into their config.
9
9
  *
10
- * The model list is read from models.config.json in the project root.
11
- * If the file doesn't exist, falls back to provider env config + static defaults.
10
+ * It did not. This route used to carry its own copy of the catalogue and its own
11
+ * loader, and the two diverged in ways that mattered:
12
+ *
13
+ * - **User toggles were ignored here.** Disabling a model from the Gateway page
14
+ * writes to `model-overrides.json`, which `loadModelsConfig()` applies. This
15
+ * route read `models.config.json` raw, so a model switched off in the UI was
16
+ * still advertised to agents.
17
+ * - **A read failure overwrote the catalogue.** If the file was missing or
18
+ * momentarily unparseable, the `catch` wrote a hardcoded 30-model default over
19
+ * it — replacing the real catalogue, user overrides included.
20
+ *
21
+ * Both are gone: the catalogue now comes from `lib/model-catalogue`, the single
22
+ * loader shared with the create route, the provider page and the sync.
12
23
  */
13
24
  import { NextResponse, NextRequest } from 'next/server';
14
- import { MODELS_CONFIG_FILE } from '@/lib/rev4a-paths';
15
- import * as fs from 'fs';
16
- import * as path from 'path';
17
- import { readProviderKeys } from '@/app/api/gateway/provider/keys';
25
+ import { isGatewayClient } from '@/app/api/provider/auth';
26
+ import { loadOfferedModels } from '@/lib/model-catalogue';
18
27
 
19
28
  export const dynamic = 'force-dynamic';
20
29
  export const runtime = 'nodejs';
21
30
 
22
- interface ModelEntry {
23
- id: string;
24
- object: string;
25
- created: number;
26
- owned_by: string;
27
- permission: string[];
28
- root: string;
29
- }
30
-
31
- interface ModelsConfigEntry {
32
- id: string;
33
- name: string;
34
- provider: string;
35
- enabled: boolean;
36
- }
37
-
38
- const CONFIG_PATH = MODELS_CONFIG_FILE;
39
-
40
- /** Default model catalogue (known models for each provider) */
41
- const DEFAULT_CATALOGUE: ModelsConfigEntry[] = [
42
- // OpenAI
43
- { id: 'openai/gpt-5.6-sol', name: 'GPT-5.6 Sol', provider: 'openai', enabled: true },
44
- { id: 'openai/gpt-5.6-terra', name: 'GPT-5.6 Terra', provider: 'openai', enabled: true },
45
- { id: 'openai/gpt-5.6-luna', name: 'GPT-5.6 Luna', provider: 'openai', enabled: true },
46
- { id: 'openai/gpt-5.5', name: 'GPT-5.5', provider: 'openai', enabled: true },
47
- { id: 'openai/gpt-5.4', name: 'GPT-5.4', provider: 'openai', enabled: true },
48
- { id: 'openai/gpt-5.4-mini', name: 'GPT-5.4 Mini', provider: 'openai', enabled: true },
49
- // Anthropic
50
- { id: 'anthropic/claude-fable-5', name: 'Claude Fable 5', provider: 'anthropic', enabled: false },
51
- { id: 'anthropic/claude-opus-5', name: 'Claude Opus 5', provider: 'anthropic', enabled: false },
52
- { id: 'anthropic/claude-sonnet-5', name: 'Claude Sonnet 5', provider: 'anthropic', enabled: false },
53
- { id: 'anthropic/claude-haiku-4-5', name: 'Claude Haiku 4.5', provider: 'anthropic', enabled: false },
54
- // DeepSeek
55
- { id: 'deepseek/deepseek-v4-pro', name: 'DeepSeek V4 Pro', provider: 'deepseek', enabled: true },
56
- { id: 'deepseek/deepseek-v4-flash', name: 'DeepSeek V4 Flash', provider: 'deepseek', enabled: true },
57
- // OpenRouter
58
- { id: 'openrouter/auto', name: 'OpenRouter Auto', provider: 'openrouter', enabled: true },
59
- { id: 'openrouter/deepseek/deepseek-v4-flash', name: 'DeepSeek V4 Flash (OR)', provider: 'openrouter', enabled: true },
60
- { id: 'openrouter/google/gemini-2.5-flash', name: 'Gemini 2.5 Flash (OR)', provider: 'openrouter', enabled: true },
61
- { id: 'openrouter/google/gemini-2.5-pro', name: 'Gemini 2.5 Pro (OR)', provider: 'openrouter', enabled: true },
62
- { id: 'openrouter/anthropic/claude-sonnet-4.6', name: 'Claude Sonnet 4.6 (OR)', provider: 'openrouter', enabled: false },
63
- { id: 'openrouter/deepseek/deepseek-r1-0528', name: 'DeepSeek R1 0528 (OR)', provider: 'openrouter', enabled: false },
64
- // Groq
65
- { id: 'groq/llama-3.1-8b-instant', name: 'Llama 3.1 8B (Groq)', provider: 'groq', enabled: false },
66
- { id: 'groq/llama-3.3-70b-versatile', name: 'Llama 3.3 70B (Groq)', provider: 'groq', enabled: false },
67
- // Kimi
68
- { id: 'kimi/kimi-k3', name: 'Kimi K3', provider: 'kimi', enabled: true },
69
- { id: 'kimi/kimi-k2.7-code', name: 'Kimi K2.7 Code', provider: 'kimi', enabled: false },
70
- { id: 'kimi/kimi-k2.7-code-highspeed', name: 'Kimi K2.7 Code HighSpeed', provider: 'kimi', enabled: false },
71
- { id: 'kimi/kimi-k2.6', name: 'Kimi K2.6', provider: 'kimi', enabled: false },
72
- // GLM
73
- { id: 'glm/glm-5.2', name: 'GLM-5.2', provider: 'glm', enabled: false },
74
- { id: 'glm/glm-5.1', name: 'GLM-5.1', provider: 'glm', enabled: false },
75
- { id: 'glm/glm-5', name: 'GLM-5', provider: 'glm', enabled: false },
76
- { id: 'glm/glm-4.7', name: 'GLM-4.7', provider: 'glm', enabled: false },
77
- { id: 'glm/glm-4.7-flash', name: 'GLM-4.7 Flash', provider: 'glm', enabled: false },
78
- { id: 'glm/glm-4.7-flashx', name: 'GLM-4.7 FlashX', provider: 'glm', enabled: false },
79
- { id: 'glm/glm-5v-turbo', name: 'GLM-5V Turbo', provider: 'glm', enabled: false },
80
- { id: 'glm/glm-4.6v-flash', name: 'GLM-4.6V Flash', provider: 'glm', enabled: false },
81
- // Qwen
82
- { id: 'qwen/qwen3.7-max', name: 'Qwen 3.7 Max', provider: 'qwen', enabled: false },
83
- { id: 'qwen/qwen3.7-plus', name: 'Qwen 3.7 Plus', provider: 'qwen', enabled: true },
84
- { id: 'qwen/qwen3.7-flash', name: 'Qwen 3.7 Flash', provider: 'qwen', enabled: false },
85
- { id: 'qwen/qwen3-coder-plus', name: 'Qwen 3 Coder Plus', provider: 'qwen', enabled: false },
86
- { id: 'qwen/qwen-long', name: 'Qwen Long', provider: 'qwen', enabled: false },
87
- // Rev4a aliases (convenience models for the Rev4a proxy)
88
- { id: 'rev4a/deepseek-v4-flash', name: 'DeepSeek V4 Flash (Rev4a)', provider: 'rev4a', enabled: true },
89
- { id: 'rev4a/deepseek-v4-pro', name: 'DeepSeek V4 Pro (Rev4a)', provider: 'rev4a', enabled: true },
90
- { id: 'rev4a/fast', name: 'Rev4a Fast', provider: 'rev4a', enabled: false },
91
- { id: 'rev4a/code', name: 'Rev4a Code', provider: 'rev4a', enabled: false },
92
- { id: 'rev4a/reason', name: 'Rev4a Reason', provider: 'rev4a', enabled: false },
93
- { id: 'rev4a/fallback-general', name: 'Rev4a Fallback General', provider: 'rev4a', enabled: false },
94
- ];
95
-
96
- /** Load the models config from file, or create from defaults */
97
- function loadConfig(): ModelsConfigEntry[] {
98
- try {
99
- const raw = fs.readFileSync(CONFIG_PATH, 'utf-8');
100
- const parsed = JSON.parse(raw);
101
- if (Array.isArray(parsed.models)) {
102
- return parsed.models as ModelsConfigEntry[];
103
- }
104
- return DEFAULT_CATALOGUE;
105
- } catch {
106
- // File doesn't exist or invalid — write defaults
107
- try {
108
- fs.writeFileSync(CONFIG_PATH, JSON.stringify({ models: DEFAULT_CATALOGUE }, null, 2), 'utf-8');
109
- } catch {
110
- // Can't write, fall back to defaults in memory
111
- }
112
- return DEFAULT_CATALOGUE;
113
- }
114
- }
115
-
116
- /** Resolve the provider from the Authorization token by matching against provider-keys.json */
117
- function resolveProviderFromToken(authHeader: string): string | null {
118
- const token = authHeader.replace(/^Bearer\s+/i, '').trim();
119
- if (!token) return null;
120
-
121
- const providerKeys = readProviderKeys();
122
-
123
- // Match the token value against stored provider keys
124
- for (const [, key] of Object.entries(providerKeys)) {
125
- if (token === key) return 'authenticated';
126
- }
127
-
128
- // Also try matching the rev4a API key env var (legacy compat)
129
- if (token === process.env.REV4A_API_KEY) return 'all';
130
-
131
- return null;
132
- }
133
-
134
31
  export async function GET(request: NextRequest) {
135
- const allModels = loadConfig();
136
-
137
- // Determine provider from Authorization token
138
- const authHeader = request.headers.get('Authorization') || '';
139
- const provider = resolveProviderFromToken(authHeader);
140
-
141
- if (!provider) {
142
- // No valid token — return empty list to avoid leaking model info
32
+ if (!isGatewayClient(request)) {
33
+ // An empty list rather than a 401: this endpoint should not confirm the
34
+ // catalogue to a caller that cannot use it. The cost is that a container
35
+ // configured with the wrong key sees "no models" instead of an auth error.
143
36
  return NextResponse.json({
144
37
  object: 'list',
145
38
  total: 0,
@@ -147,10 +40,10 @@ export async function GET(request: NextRequest) {
147
40
  });
148
41
  }
149
42
 
150
- let activeModels: ModelsConfigEntry[];
151
-
152
- // Token authenticated — return all enabled models
153
- activeModels = allModels.filter((m) => m.enabled);
43
+ // Same set as gateway/sync.ts writes into the containers, so what an agent can
44
+ // discover matches what it was actually given. This route used to skip the
45
+ // provider-key half of that test and advertise models nobody could be billed for.
46
+ const activeModels = loadOfferedModels();
154
47
 
155
48
  return NextResponse.json({
156
49
  object: 'list',
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { useState, useRef, useEffect, useCallback } from 'react';
4
4
  import { usePathname } from 'next/navigation';
5
+ import { useModels } from '../lib/models-context';
5
6
  import Link from 'next/link';
6
7
 
7
8
  const STORAGE_KEY = 'rev4a_pulse_chat';
@@ -79,51 +80,36 @@ export default function PulseChat() {
79
80
  const [input, setInput] = useState('');
80
81
  const [streaming, setStreaming] = useState(false);
81
82
  const [selectedModel, setSelectedModel] = useState<string>('');
82
- const [modelGroups, setModelGroups] = useState<ModelGroup[]>([]);
83
- const [modelsLoading, setModelsLoading] = useState(true);
83
+ // The shared catalogue, not a private copy. It refreshes when the Gateway page
84
+ // changes what is enabled, so this select cannot go stale mid-session.
85
+ const { providers: modelGroups, loaded: modelsLoaded } = useModels();
86
+ const modelsLoading = !modelsLoaded;
84
87
  const bottomRef = useRef<HTMLDivElement>(null);
85
88
  const inputRef = useRef<HTMLTextAreaElement>(null);
86
89
  const abortRef = useRef<AbortController | null>(null);
87
90
 
88
- // Fetch available models from backend on mount
91
+ // Keep the choice valid against the current catalogue.
92
+ //
93
+ // Runs whenever the shared list changes, which includes the moment a model is
94
+ // unchecked on the Gateway page: a selection that just became unavailable is
95
+ // replaced rather than left to fail at send time. The saved preference is only
96
+ // honoured while it is still on offer.
89
97
  useEffect(() => {
90
- let cancelled = false;
91
-
92
- async function fetchModels() {
93
- try {
94
- const res = await fetch('/api/models');
95
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
96
- const data: ModelGroup[] = await res.json();
97
- if (cancelled) return;
98
-
99
- setModelGroups(data);
100
-
101
- const allIds = data.flatMap((g) => g.models.map((m) => m.id));
102
-
103
- // Restore saved model if valid, otherwise pick first
104
- try {
105
- const saved = localStorage.getItem(MODEL_STORAGE_KEY);
106
- if (saved && allIds.includes(saved)) {
107
- setSelectedModel(saved);
108
- } else if (allIds.length > 0) {
109
- setSelectedModel(allIds[0]);
110
- }
111
- } catch {
112
- if (allIds.length > 0) setSelectedModel(allIds[0]);
113
- }
114
- } catch {
115
- // API unavailable — leave selectedModel empty, select stays disabled
116
- } finally {
117
- if (!cancelled) setModelsLoading(false);
118
- }
119
- }
120
-
121
- fetchModels();
122
- return () => { cancelled = true; };
123
- }, []);
124
-
125
- // Save model preference on change
98
+ if (!modelsLoaded) return;
99
+ const allIds = modelGroups.flatMap((g) => g.models.map((m) => m.id));
100
+ if (allIds.length === 0) { setSelectedModel(''); return; }
101
+ if (selectedModel && allIds.includes(selectedModel)) return;
102
+
103
+ let saved: string | null = null;
104
+ try { saved = localStorage.getItem(MODEL_STORAGE_KEY); } catch { /* blocked */ }
105
+ setSelectedModel(saved && allIds.includes(saved) ? saved : allIds[0]);
106
+ }, [modelGroups, modelsLoaded, selectedModel]);
107
+
108
+ // Save model preference on change. Not while empty: on mount the selection is ''
109
+ // until the list arrives, and saving that erased the preference before it could
110
+ // be read back.
126
111
  useEffect(() => {
112
+ if (!selectedModel) return;
127
113
  try {
128
114
  localStorage.setItem(MODEL_STORAGE_KEY, selectedModel);
129
115
  } catch {
@@ -202,3 +202,135 @@ export function CardRowSkeleton({ count = 3, height = 56 }: { count?: number; he
202
202
  </div>
203
203
  );
204
204
  }
205
+
206
+ /**
207
+ * Credential provider cards.
208
+ *
209
+ * The credentials page renders one card per entry in CREDENTIAL_PROVIDERS,
210
+ * which is a static array of five — so the card count here is known, not
211
+ * guessed, and the grid will not reflow when the data lands.
212
+ *
213
+ * Each shape mirrors the real text it stands in for: the label and category
214
+ * widths track the actual provider names ("Cloud" is short, "Project
215
+ * Management" is not), and the description line count follows how long each
216
+ * provider's blurb actually is. That is what keeps the shimmer from jumping
217
+ * when it is replaced.
218
+ *
219
+ * `configuredCards` is the one genuine unknown: whether a provider has stored
220
+ * profiles is only known once /api/credentials answers. Cards with a profile
221
+ * are markedly taller, so rendering none would guarantee a jump downward for
222
+ * anyone who has set some up. Two of five is the middle of the realistic range
223
+ * and keeps the error small in both directions.
224
+ */
225
+ export function CredentialCardsSkeleton({ configuredCards = 2 }: { configuredCards?: number }) {
226
+ // Ordered as CREDENTIAL_PROVIDERS: GitHub, Trello, Vercel, Supabase, Notion.
227
+ const cards = [
228
+ { label: 46, cat: 84, pill: 64, desc: ['100%', '58%'], delivery: '56%' },
229
+ { label: 44, cat: 98, pill: 54, desc: ['92%'], delivery: '82%' },
230
+ { label: 44, cat: 34, pill: 64, desc: ['100%', '64%'], delivery: '90%' },
231
+ { label: 58, cat: 50, pill: 54, desc: ['100%', '86%'], delivery: '78%' },
232
+ { label: 44, cat: 98, pill: 54, desc: ['100%', '92%'], delivery: '72%' },
233
+ ];
234
+
235
+ return (
236
+ <div
237
+ aria-hidden="true"
238
+ style={{
239
+ display: 'grid',
240
+ gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))',
241
+ gap: 'clamp(12px, 3vw, 16px)',
242
+ }}
243
+ >
244
+ {cards.map((c, i) => (
245
+ <div
246
+ key={i}
247
+ style={{
248
+ background: 'var(--bg2)',
249
+ border: '1px solid var(--border)',
250
+ display: 'flex',
251
+ flexDirection: 'column',
252
+ }}
253
+ >
254
+ {/* Header: provider badge, name over category, status pill */}
255
+ <div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '12px 14px', borderBottom: '1px solid var(--border)' }}>
256
+ <Skeleton width={28} height={28} accent style={{ flexShrink: 0 }} />
257
+ <div style={{ flex: 1, minWidth: 0 }}>
258
+ <Skeleton width={c.label} height={12} />
259
+ <Skeleton width={c.cat} height={9} style={{ marginTop: 4 }} />
260
+ </div>
261
+ <Skeleton width={c.pill} height={16} style={{ flexShrink: 0 }} />
262
+ </div>
263
+
264
+ {/* Description */}
265
+ <div style={{ padding: '10px 14px', borderBottom: '1px solid var(--border)' }}>
266
+ {c.desc.map((w, j) => (
267
+ <Skeleton key={j} width={w} height={9} style={{ marginTop: j === 0 ? 0 : 5 }} />
268
+ ))}
269
+ </div>
270
+
271
+ {/* Delivery method — one mono line */}
272
+ <div style={{ padding: '8px 14px', borderBottom: '1px solid var(--border)' }}>
273
+ <Skeleton width={c.delivery} height={9} />
274
+ </div>
275
+
276
+ {/* A stored profile: label, masked secret + status + sync time, actions,
277
+ then the per-agent sync rows underneath. */}
278
+ {i < configuredCards && (
279
+ <div style={{ padding: '10px 14px', borderBottom: '1px solid var(--border)' }}>
280
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
281
+ <div style={{ flex: 1, minWidth: 0 }}>
282
+ <Skeleton width="52%" height={11} />
283
+ <div style={{ display: 'flex', gap: 10, marginTop: 5 }}>
284
+ <Skeleton width={76} height={9} />
285
+ <Skeleton width={8} height={9} />
286
+ <Skeleton width={62} height={9} />
287
+ </div>
288
+ </div>
289
+ <Skeleton width={38} height={22} style={{ flexShrink: 0 }} />
290
+ <Skeleton width={22} height={22} style={{ flexShrink: 0 }} />
291
+ </div>
292
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 5, marginTop: 9 }}>
293
+ <Skeleton width="74%" height={9} />
294
+ <Skeleton width="66%" height={9} />
295
+ </div>
296
+ </div>
297
+ )}
298
+
299
+ {/* Footer action, pinned to the bottom as in the real card */}
300
+ <div style={{ padding: '10px 14px', marginTop: 'auto' }}>
301
+ <Skeleton width={i < configuredCards ? 92 : 74} height={26} />
302
+ </div>
303
+ </div>
304
+ ))}
305
+ </div>
306
+ );
307
+ }
308
+
309
+ /**
310
+ * Per-agent sync rows inside a credential profile.
311
+ *
312
+ * Stands in for the block fed by /api/credentials/detect, which shells into
313
+ * every agent container and takes seconds. The agent list itself comes from that
314
+ * same response, so before it lands the count is unknown — three rows is a
315
+ * placeholder, not a prediction, and the block is deliberately the only thing
316
+ * that waits: the cards, the stored profiles and the Add buttons all come from
317
+ * the cheap endpoint and render immediately.
318
+ *
319
+ * No Sync/De-sync button is drawn here on purpose. Their label depends on
320
+ * whether the credential is installed, which is exactly what is still unknown —
321
+ * a button rendered now would have to guess, and guessing wrong invites a click
322
+ * that does the opposite of what it says.
323
+ */
324
+ export function AgentSyncRowsSkeleton({ rows = 3 }: { rows?: number }) {
325
+ const widths = ['62%', '48%', '55%'];
326
+ return (
327
+ <div aria-hidden="true" style={{ marginTop: 8, display: 'flex', flexDirection: 'column', gap: 3 }}>
328
+ {Array.from({ length: rows }).map((_, i) => (
329
+ <div key={i} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
330
+ <Skeleton width={8} height={8} variant="circle" style={{ flexShrink: 0 }} />
331
+ <Skeleton width={widths[i % widths.length]} height={9} />
332
+ </div>
333
+ ))}
334
+ </div>
335
+ );
336
+ }
@@ -0,0 +1,46 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * Remove an item from a list.
5
+ *
6
+ * A small `×` at the end of a row, rather than a "Remove" label: the word reads
7
+ * as an action already carried out, which is wrong wherever the change only
8
+ * lands on save.
9
+ *
10
+ * One style everywhere — the `danger` treatment the credentials page already
11
+ * used. Whether the removal is immediate or pending is said in the tooltip, not
12
+ * in the colour: two tones for the same gesture made the button look like two
13
+ * different controls.
14
+ *
15
+ * Deliberately separate from dismissing a dialog, which looks similar and means
16
+ * something else. See rule 8 in docs/FRONTEND-ARCHITECTURE.md.
17
+ */
18
+ import { Button } from './Button';
19
+
20
+ export function RemoveButton({
21
+ onClick,
22
+ label,
23
+ disabled,
24
+ title,
25
+ }: {
26
+ onClick: () => void;
27
+ /** What is being removed. Becomes the accessible name: "Remove <label>". */
28
+ label: string;
29
+ disabled?: boolean;
30
+ /** Override when the consequence is worth spelling out, e.g. a pending edit. */
31
+ title?: string;
32
+ }) {
33
+ return (
34
+ <Button
35
+ type="button"
36
+ variant="danger"
37
+ size="sm"
38
+ disabled={disabled}
39
+ aria-label={`Remove ${label}`}
40
+ title={title ?? `Remove ${label}`}
41
+ onClick={onClick}
42
+ >
43
+ ×
44
+ </Button>
45
+ );
46
+ }
@@ -5,7 +5,8 @@ import { useId, type SelectHTMLAttributes, type ReactNode } from 'react';
5
5
  interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
6
6
  label?: string;
7
7
  error?: string;
8
- options: { value: string; label: ReactNode }[];
8
+ /** `disabled` shows a value that exists but may not be chosen, e.g. a current setting no longer on offer. */
9
+ options: { value: string; label: ReactNode; disabled?: boolean }[];
9
10
  }
10
11
 
11
12
  export function Select({ label, error, options, id, className = '', ...rest }: SelectProps) {
@@ -17,7 +18,7 @@ export function Select({ label, error, options, id, className = '', ...rest }: S
17
18
  {label && <label className="ui-field__label" htmlFor={selectId}>{label}</label>}
18
19
  <select id={selectId} className={`ui-select ${error ? 'ui-select--error' : ''} ${className}`} {...rest}>
19
20
  {options.map((o) => (
20
- <option key={o.value} value={o.value}>{o.label}</option>
21
+ <option key={o.value} value={o.value} disabled={o.disabled}>{o.label}</option>
21
22
  ))}
22
23
  </select>
23
24
  {error && <div className="ui-field__error">{error}</div>}
@@ -1,6 +1,7 @@
1
1
  export * from './ConfirmModal';
2
2
  export * from './Badge';
3
3
  export * from './Button';
4
+ export * from './RemoveButton';
4
5
  export * from './FilterBar';
5
6
  export * from './Input';
6
7
  export * from './LoadingSpinner';