agents-client.tsx868 lines · main
1'use client';
2
3import { motion } from 'motion/react';
4import { useCallback, useEffect, useState } from 'react';
5
6import { StepUpPrompt } from '@/components/step-up-prompt';
7import { BotIcon } from '@/components/ui/bot';
8import { TriangleAlertIcon } from '@/components/ui/triangle-alert';
9import { ZapIcon } from '@/components/ui/zap';
10
11import { EmptyState, EmptyStateButton } from '../_components/empty-state';
12import { Section } from '../_components/section';
13
14/* ─── payload types (mirror /v1/admin/agents) ────────────────────────────── */
15
16export type AgentScope = 'read' | 'read-write' | 'admin';
17export type AgentProvider =
18 | 'anthropic'
19 | 'openai'
20 | 'xai'
21 | 'zai'
22 | 'deepseek'
23 | 'ollama'
24 | 'flndrnai'
25 | 'custom';
26
27export interface MaskedAgent {
28 id: string;
29 name: string;
30 provider: string;
31 endpoint: string | null;
32 model: string;
33 scope: AgentScope;
34 enabled: boolean;
35 hasKey: boolean;
36 keyPrefix: string | null;
37 keySuffix: string | null;
38 createdAt: string;
39 updatedAt: string;
40}
41
42export interface AgentsPayload {
43 agents: MaskedAgent[];
44}
45
46interface TestResult {
47 agentId: string;
48 ok: boolean;
49 status: number;
50 message: string;
51}
52
53/* ─── small fetch helper with step-up + validation detection ─────────────── */
54
55type SendResult =
56 | { kind: 'ok'; data: unknown }
57 | { kind: 'step_up' }
58 | { kind: 'error'; message: string };
59
60interface ApiErrorBody {
61 code?: string;
62 message?: string;
63 issues?: Array<{ path?: Array<string | number>; message?: string }>;
64}
65
66async function send(
67 apiOrigin: string,
68 path: string,
69 method: 'POST' | 'PATCH' | 'DELETE',
70 body?: unknown,
71): Promise<SendResult> {
72 try {
73 const res = await fetch(`${apiOrigin}${path}`, {
74 method,
75 credentials: 'include',
76 headers:
77 body === undefined
78 ? { accept: 'application/json' }
79 : { 'content-type': 'application/json', accept: 'application/json' },
80 body: body === undefined ? undefined : JSON.stringify(body),
81 });
82 if (res.ok) return { kind: 'ok', data: await res.json().catch(() => ({})) };
83
84 const parsed = (await res.json().catch(() => null)) as ApiErrorBody | null;
85 if (res.status === 403 && parsed?.code === 'step_up_required') return { kind: 'step_up' };
86 if (parsed?.code === 'validation_failed' && Array.isArray(parsed.issues)) {
87 const details = parsed.issues
88 .map((i) =>
89 [Array.isArray(i.path) ? i.path.join('.') : '', i.message ?? '']
90 .filter((s) => s.length > 0)
91 .join(': '),
92 )
93 .filter((s) => s.length > 0)
94 .join(' · ');
95 return { kind: 'error', message: details || parsed.message || 'validation failed' };
96 }
97 return { kind: 'error', message: parsed?.message || `request failed: ${res.status}` };
98 } catch (err) {
99 return { kind: 'error', message: err instanceof Error ? err.message : 'request failed' };
100 }
101}
102
103/* ─── board ──────────────────────────────────────────────────────────────── */
104
105export function AgentsBoard({
106 apiOrigin,
107 initial,
108}: {
109 apiOrigin: string;
110 initial: AgentsPayload | null;
111}) {
112 const [agents, setAgents] = useState<MaskedAgent[] | null>(initial?.agents ?? null);
113 const [failed, setFailed] = useState(false);
114 const [showAdd, setShowAdd] = useState(false);
115
116 const load = useCallback(async () => {
117 try {
118 const res = await fetch(`${apiOrigin}/v1/admin/agents`, {
119 credentials: 'include',
120 headers: { accept: 'application/json' },
121 });
122 if (!res.ok) throw new Error(`agents failed: ${res.status}`);
123 const json = (await res.json()) as AgentsPayload;
124 setAgents(json.agents);
125 setFailed(false);
126 } catch {
127 setFailed(true);
128 }
129 }, [apiOrigin]);
130
131 useEffect(() => {
132 void load();
133 }, [load]);
134
135 if (agents === null) {
136 if (failed) {
137 return (
138 <EmptyState
139 icon={<TriangleAlertIcon size={28} />}
140 title="agent list unavailable"
141 message="the api didn't answer — it may be restarting or your session may have expired."
142 action={<EmptyStateButton onClick={() => void load()}>retry now</EmptyStateButton>}
143 />
144 );
145 }
146 return (
147 <EmptyState
148 icon={<BotIcon size={28} />}
149 title="loading agents…"
150 message="fetching the registered agents and their masked credentials."
151 />
152 );
153 }
154
155 return (
156 <div className="flex flex-col gap-10">
157 <header className="flex flex-wrap items-end justify-between gap-4">
158 <div className="flex flex-col gap-2">
159 <div className="flex items-center gap-2">
160 <span className="text-[var(--color-primary)]">
161 <BotIcon size={20} />
162 </span>
163 <h1 className="font-mono text-xl tracking-tight">ai agents</h1>
164 </div>
165 <p className="max-w-prose font-mono text-sm text-[var(--color-text-muted)]">
166 the control room for provider credentials — register an agent, store its api key
167 encrypted, and it never appears in full again. only the prefix…suffix hint remains.
168 </p>
169 </div>
170 {!showAdd ? (
171 <button
172 type="button"
173 onClick={() => setShowAdd(true)}
174 className="rounded-md bg-[var(--color-primary)] px-4 py-2 font-mono text-xs text-[var(--color-text-inverse)] transition hover:bg-[var(--color-primary-hover)]"
175 >
176 + add agent
177 </button>
178 ) : null}
179 </header>
180
181 {showAdd ? (
182 <Section title="register a new agent" icon={<BotIcon size={16} />}>
183 <AgentCreatePanel
184 apiOrigin={apiOrigin}
185 onDone={() => {
186 setShowAdd(false);
187 void load();
188 }}
189 onCancel={() => setShowAdd(false)}
190 />
191 </Section>
192 ) : null}
193
194 <Section
195 title={`registered agents · ${agents.length}`}
196 icon={<ZapIcon size={16} />}
197 >
198 {agents.length === 0 ? (
199 <EmptyState
200 icon={<BotIcon size={28} />}
201 title="no agents registered yet"
202 message="add your first agent to give the platform a brain — its api key is stored encrypted and shown never again."
203 action={
204 !showAdd ? (
205 <EmptyStateButton onClick={() => setShowAdd(true)}>
206 add your first agent
207 </EmptyStateButton>
208 ) : undefined
209 }
210 />
211 ) : (
212 <div className="flex flex-col gap-4">
213 {agents.map((agent) => (
214 <AgentCard
215 key={agent.id}
216 apiOrigin={apiOrigin}
217 agent={agent}
218 onChanged={() => void load()}
219 />
220 ))}
221 </div>
222 )}
223 </Section>
224 </div>
225 );
226}
227
228/* ─── shared form (create + edit) ────────────────────────────────────────── */
229
230interface AgentFormValues {
231 name: string;
232 provider: string;
233 endpoint: string;
234 apiKey: string;
235 model: string;
236 scope: AgentScope;
237 enabled: boolean;
238}
239
240const PROVIDERS: readonly AgentProvider[] = [
241 'anthropic',
242 'openai',
243 'xai',
244 'zai',
245 'deepseek',
246 'ollama',
247 'flndrnai',
248 'custom',
249];
250const SCOPES: readonly AgentScope[] = ['read', 'read-write', 'admin'];
251
252// Default endpoint per provider. Auto-filled when a provider is picked, but
253// only if the endpoint is empty or still equals a known preset — never
254// clobbering a value the operator hand-typed. 'custom' stays blank.
255const PROVIDER_ENDPOINTS: Record<AgentProvider, string> = {
256 anthropic: 'https://api.anthropic.com',
257 openai: 'https://api.openai.com/v1',
258 xai: 'https://api.x.ai/v1',
259 zai: 'https://api.z.ai/api/paas/v4',
260 deepseek: 'https://api.deepseek.com',
261 ollama: 'http://localhost:11434/v1',
262 flndrnai: 'https://ai.flndrn.com/v1',
263 custom: '',
264};
265
266// Every known preset value — used to decide whether an endpoint is still a
267// preset (safe to overwrite) versus a hand-typed value (must be preserved).
268const KNOWN_ENDPOINTS: ReadonlySet<string> = new Set(
269 Object.values(PROVIDER_ENDPOINTS).filter((v) => v.length > 0),
270);
271
272// A helpful model-id hint per provider, shown as the input placeholder.
273const PROVIDER_MODEL_HINTS: Record<AgentProvider, string> = {
274 anthropic: 'claude-sonnet-4-6',
275 openai: 'gpt-4o',
276 xai: 'grok-2',
277 zai: 'glm-4',
278 deepseek: 'deepseek-chat',
279 ollama: 'llama3.1',
280 flndrnai: 'llama3.1',
281 custom: 'e.g. claude-sonnet-4-6',
282};
283
284const INPUT_CLASS =
285 'rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] px-2.5 py-1.5 font-mono text-xs text-[var(--color-text)] outline-none focus:border-[var(--color-primary)]';
286const LABEL_CLASS =
287 'font-mono text-[10px] uppercase tracking-wider text-[var(--color-text-subtle)]';
288
289function AgentForm({
290 mode,
291 initial,
292 busy,
293 onSubmit,
294 onCancel,
295}: {
296 mode: 'create' | 'edit';
297 initial?: MaskedAgent;
298 busy: boolean;
299 onSubmit: (values: AgentFormValues) => void;
300 onCancel: () => void;
301}) {
302 const [name, setName] = useState(initial?.name ?? '');
303 const [provider, setProvider] = useState(initial?.provider ?? 'anthropic');
304 const [endpoint, setEndpoint] = useState(initial?.endpoint ?? '');
305 const [apiKey, setApiKey] = useState('');
306 const [model, setModel] = useState(initial?.model ?? '');
307 const [scope, setScope] = useState<AgentScope>(initial?.scope ?? 'read');
308 const [enabled, setEnabled] = useState(initial?.enabled ?? true);
309 const [localError, setLocalError] = useState<string | null>(null);
310
311 const endpointish = provider === 'custom' || provider === 'ollama';
312
313 // Switching provider auto-fills the endpoint from the preset — but only when
314 // the field is empty or still holds a known preset, so a hand-typed url is
315 // never overwritten. 'custom' has a blank preset, so it leaves things alone.
316 function handleProviderChange(next: string) {
317 setProvider(next);
318 const preset = PROVIDER_ENDPOINTS[next as AgentProvider] ?? '';
319 if (preset.length === 0) return;
320 const current = endpoint.trim();
321 if (current.length === 0 || KNOWN_ENDPOINTS.has(current)) {
322 setEndpoint(preset);
323 }
324 }
325
326 const modelHint = PROVIDER_MODEL_HINTS[provider as AgentProvider] ?? 'e.g. claude-sonnet-4-6';
327
328 function submit(e: React.FormEvent) {
329 e.preventDefault();
330 setLocalError(null);
331 if (name.trim().length === 0) {
332 setLocalError('name the agent first.');
333 return;
334 }
335 if (model.trim().length === 0) {
336 setLocalError('a model id is required — e.g. claude-sonnet-4-6.');
337 return;
338 }
339 if (apiKey.length > 0 && apiKey.length < 12) {
340 setLocalError('api key looks too short — real provider keys are at least 12 characters.');
341 return;
342 }
343 onSubmit({
344 name: name.trim(),
345 provider,
346 endpoint: endpoint.trim(),
347 apiKey,
348 model: model.trim(),
349 scope,
350 enabled,
351 });
352 // Never keep the plaintext around after submit.
353 setApiKey('');
354 }
355
356 return (
357 <form onSubmit={submit} className="flex flex-col gap-4">
358 <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
359 <label className="flex flex-col gap-1">
360 <span className={LABEL_CLASS}>name</span>
361 <input
362 value={name}
363 onChange={(e) => setName(e.currentTarget.value)}
364 placeholder="e.g. support-triage-bot"
365 maxLength={120}
366 disabled={busy}
367 className={INPUT_CLASS}
368 />
369 </label>
370
371 <label className="flex flex-col gap-1">
372 <span className={LABEL_CLASS}>provider</span>
373 <select
374 value={provider}
375 onChange={(e) => handleProviderChange(e.currentTarget.value)}
376 disabled={busy}
377 className={INPUT_CLASS}
378 >
379 {PROVIDERS.map((p) => (
380 <option key={p} value={p}>
381 {p}
382 </option>
383 ))}
384 </select>
385 </label>
386
387 <label className="flex flex-col gap-1 sm:col-span-2">
388 <span className={LABEL_CLASS}>endpoint url · optional</span>
389 <input
390 value={endpoint}
391 onChange={(e) => setEndpoint(e.currentTarget.value)}
392 placeholder="https://…"
393 type="url"
394 disabled={busy}
395 className={INPUT_CLASS}
396 />
397 {endpointish && endpoint.trim().length === 0 ? (
398 <span className="font-mono text-[10px] text-[var(--color-warning)]">
399 {provider} agents usually need an endpoint url — where should requests go?
400 </span>
401 ) : (
402 <span className="font-mono text-[10px] text-[var(--color-text-subtle)]">
403 auto-filled from the provider — edit if yours differs.
404 </span>
405 )}
406 {provider === 'flndrnai' ? (
407 <span className="font-mono text-[10px] text-[var(--color-text-subtle)]">
408 points at ai.flndrn.com — the operator&apos;s own ollama gateway.
409 </span>
410 ) : null}
411 </label>
412
413 <label className="flex flex-col gap-1 sm:col-span-2">
414 <span className={LABEL_CLASS}>api key</span>
415 <input
416 value={apiKey}
417 onChange={(e) => setApiKey(e.currentTarget.value)}
418 type="password"
419 autoComplete="off"
420 placeholder={mode === 'edit' ? '••••••••' : 'sk-…'}
421 disabled={busy}
422 className={INPUT_CLASS}
423 />
424 <span className="font-mono text-[10px] text-[var(--color-text-subtle)]">
425 {mode === 'edit'
426 ? 'leave empty to keep the current key — filling it rotates the key.'
427 : 'stored encrypted — shown never again.'}
428 </span>
429 </label>
430
431 <label className="flex flex-col gap-1">
432 <span className={LABEL_CLASS}>model</span>
433 <input
434 value={model}
435 onChange={(e) => setModel(e.currentTarget.value)}
436 placeholder={`e.g. ${modelHint}`}
437 maxLength={120}
438 disabled={busy}
439 className={INPUT_CLASS}
440 />
441 </label>
442
443 <div className="flex flex-col gap-1">
444 <span className={LABEL_CLASS}>scope</span>
445 <div className="flex overflow-hidden rounded-md border border-[var(--color-border)]">
446 {SCOPES.map((s) => (
447 <button
448 key={s}
449 type="button"
450 onClick={() => setScope(s)}
451 disabled={busy}
452 className={
453 scope === s
454 ? 'flex-1 bg-[var(--color-primary-subtle)] px-2.5 py-1.5 font-mono text-[10px] uppercase tracking-wider text-[var(--color-primary)]'
455 : 'flex-1 bg-[var(--color-surface-raised)] px-2.5 py-1.5 font-mono text-[10px] uppercase tracking-wider text-[var(--color-text-muted)] transition hover:text-[var(--color-text)]'
456 }
457 >
458 {s}
459 </button>
460 ))}
461 </div>
462 </div>
463 </div>
464
465 <div className="flex flex-wrap items-center gap-3">
466 <button
467 type="button"
468 onClick={() => setEnabled((v) => !v)}
469 disabled={busy}
470 className={
471 enabled
472 ? 'inline-flex items-center gap-1.5 rounded-md bg-[var(--color-primary-subtle)] px-2.5 py-1 font-mono text-[10px] uppercase tracking-wider text-[var(--color-primary)]'
473 : 'inline-flex items-center gap-1.5 rounded-md bg-[var(--color-surface-raised)] px-2.5 py-1 font-mono text-[10px] uppercase tracking-wider text-[var(--color-text-muted)]'
474 }
475 >
476 <span
477 className={`size-1.5 rounded-full ${
478 enabled ? 'bg-[var(--color-primary)]' : 'bg-[var(--color-text-subtle)]'
479 }`}
480 />
481 {enabled ? 'enabled' : 'disabled'}
482 </button>
483 <span className="font-mono text-[10px] text-[var(--color-text-subtle)]">
484 disabled agents keep their credentials but can&apos;t be used.
485 </span>
486 </div>
487
488 {localError ? (
489 <p className="font-mono text-[10px] text-[var(--color-error)]">{localError}</p>
490 ) : null}
491
492 <div className="flex items-center gap-3">
493 <button
494 type="submit"
495 disabled={busy}
496 className="rounded-md bg-[var(--color-primary)] px-4 py-2 font-mono text-xs text-[var(--color-text-inverse)] transition hover:bg-[var(--color-primary-hover)] disabled:opacity-50"
497 >
498 {busy ? 'saving…' : mode === 'create' ? 'register agent' : 'save changes'}
499 </button>
500 <button
501 type="button"
502 onClick={onCancel}
503 disabled={busy}
504 className="font-mono text-[10px] text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
505 >
506 cancel
507 </button>
508 </div>
509 </form>
510 );
511}
512
513/* ─── create panel ───────────────────────────────────────────────────────── */
514
515function AgentCreatePanel({
516 apiOrigin,
517 onDone,
518 onCancel,
519}: {
520 apiOrigin: string;
521 onDone: () => void;
522 onCancel: () => void;
523}) {
524 const [busy, setBusy] = useState(false);
525 const [error, setError] = useState<string | null>(null);
526 const [pending, setPending] = useState<null | { run: () => Promise<void>; reason: string }>(null);
527
528 async function create(values: AgentFormValues) {
529 const body: Record<string, unknown> = {
530 name: values.name,
531 provider: values.provider,
532 model: values.model,
533 scope: values.scope,
534 enabled: values.enabled,
535 };
536 if (values.endpoint.length > 0) body.endpoint = values.endpoint;
537 if (values.apiKey.length > 0) body.apiKey = values.apiKey;
538
539 const run = async () => {
540 setBusy(true);
541 setError(null);
542 const result = await send(apiOrigin, '/v1/admin/agents', 'POST', body);
543 setBusy(false);
544 if (result.kind === 'step_up') {
545 setPending({ run, reason: `registering agent ${values.name}.` });
546 return;
547 }
548 if (result.kind === 'error') {
549 setError(result.message);
550 return;
551 }
552 onDone();
553 };
554 await run();
555 }
556
557 return (
558 <motion.div
559 initial={{ opacity: 0, y: 8 }}
560 animate={{ opacity: 1, y: 0 }}
561 transition={{ duration: 0.4, ease: 'easeOut' }}
562 className="flex flex-col gap-4 rounded-xl border border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-6"
563 >
564 <AgentForm mode="create" busy={busy} onSubmit={(v) => void create(v)} onCancel={onCancel} />
565 {error ? <p className="font-mono text-[10px] text-[var(--color-error)]">{error}</p> : null}
566 {pending != null ? (
567 <StepUpPrompt
568 apiOrigin={apiOrigin}
569 reason={pending.reason}
570 onSuccess={async () => {
571 const job = pending;
572 setPending(null);
573 if (job) await job.run();
574 }}
575 onCancel={() => setPending(null)}
576 />
577 ) : null}
578 </motion.div>
579 );
580}
581
582/* ─── agent card ─────────────────────────────────────────────────────────── */
583
584function AgentCard({
585 apiOrigin,
586 agent,
587 onChanged,
588}: {
589 apiOrigin: string;
590 agent: MaskedAgent;
591 onChanged: () => void;
592}) {
593 const [busy, setBusy] = useState(false);
594 const [error, setError] = useState<string | null>(null);
595 const [pending, setPending] = useState<null | { run: () => Promise<void>; reason: string }>(null);
596 const [editing, setEditing] = useState(false);
597 const [confirmingDelete, setConfirmingDelete] = useState(false);
598 const [testResult, setTestResult] = useState<TestResult | null>(null);
599 const [testing, setTesting] = useState(false);
600
601 async function mutate(
602 reason: string,
603 method: 'POST' | 'PATCH' | 'DELETE',
604 path: string,
605 body: unknown | undefined,
606 after?: () => void,
607 ) {
608 const run = async () => {
609 setBusy(true);
610 setError(null);
611 const result = await send(apiOrigin, path, method, body);
612 setBusy(false);
613 if (result.kind === 'step_up') {
614 setPending({ run, reason });
615 return;
616 }
617 if (result.kind === 'error') {
618 setError(result.message);
619 return;
620 }
621 if (after) after();
622 onChanged();
623 };
624 await run();
625 }
626
627 async function toggleEnabled() {
628 await mutate(
629 `${agent.enabled ? 'disabling' : 'enabling'} agent ${agent.name}.`,
630 'PATCH',
631 `/v1/admin/agents/${agent.id}`,
632 { enabled: !agent.enabled },
633 );
634 }
635
636 async function saveEdit(values: AgentFormValues) {
637 const body: Record<string, unknown> = {
638 name: values.name,
639 provider: values.provider,
640 model: values.model,
641 scope: values.scope,
642 enabled: values.enabled,
643 endpoint: values.endpoint.length > 0 ? values.endpoint : null,
644 };
645 if (values.apiKey.length > 0) body.apiKey = values.apiKey;
646 await mutate(
647 `updating agent ${agent.name}.`,
648 'PATCH',
649 `/v1/admin/agents/${agent.id}`,
650 body,
651 () => setEditing(false),
652 );
653 }
654
655 async function remove() {
656 await mutate(
657 `deleting agent ${agent.name} and its stored credentials.`,
658 'DELETE',
659 `/v1/admin/agents/${agent.id}`,
660 undefined,
661 () => setConfirmingDelete(false),
662 );
663 }
664
665 async function test() {
666 setTesting(true);
667 setError(null);
668 setTestResult(null);
669 const result = await send(apiOrigin, `/v1/admin/agents/${agent.id}/test`, 'POST', {});
670 setTesting(false);
671 if (result.kind === 'step_up') {
672 setPending({
673 run: async () => {
674 await test();
675 },
676 reason: `testing agent ${agent.name}.`,
677 });
678 return;
679 }
680 if (result.kind === 'error') {
681 setError(result.message);
682 return;
683 }
684 setTestResult(result.data as TestResult);
685 }
686
687 const maskedKey = agent.hasKey
688 ? `${agent.keyPrefix ?? ''}…${agent.keySuffix ?? ''}`
689 : 'no key';
690
691 return (
692 <motion.div
693 initial={{ opacity: 0, y: 8 }}
694 animate={{ opacity: 1, y: 0 }}
695 transition={{ duration: 0.4, ease: 'easeOut' }}
696 className="flex flex-col gap-4 rounded-xl border border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-6"
697 >
698 <div className="flex flex-wrap items-center justify-between gap-3">
699 <div className="flex flex-wrap items-center gap-2.5">
700 <span className="font-mono text-sm text-[var(--color-text)]">{agent.name}</span>
701 <span className="rounded-md bg-[var(--color-surface-raised)] px-1.5 py-0.5 font-mono text-[10px] uppercase tracking-wider text-[var(--color-text-muted)]">
702 {agent.provider}
703 </span>
704 <ScopeBadge scope={agent.scope} />
705 <span
706 className={
707 agent.enabled
708 ? 'inline-flex items-center gap-1.5 rounded-md bg-[var(--color-primary-subtle)] px-2 py-0.5 font-mono text-[10px] uppercase tracking-wider text-[var(--color-primary)]'
709 : 'inline-flex items-center gap-1.5 rounded-md bg-[var(--color-surface-raised)] px-2 py-0.5 font-mono text-[10px] uppercase tracking-wider text-[var(--color-text-muted)]'
710 }
711 >
712 <span
713 className={`size-1.5 rounded-full ${
714 agent.enabled ? 'bg-[var(--color-primary)]' : 'bg-[var(--color-text-subtle)]'
715 }`}
716 />
717 {agent.enabled ? 'enabled' : 'disabled'}
718 </span>
719 </div>
720
721 <div className="flex items-center gap-2">
722 <button
723 type="button"
724 onClick={() => void test()}
725 disabled={busy || testing}
726 className="rounded-md border border-[var(--color-border)] px-2.5 py-1 font-mono text-[10px] text-[var(--color-text-muted)] transition hover:text-[var(--color-primary)] disabled:opacity-50"
727 >
728 {testing ? 'testing…' : 'test'}
729 </button>
730 <button
731 type="button"
732 onClick={() => void toggleEnabled()}
733 disabled={busy}
734 className="rounded-md border border-[var(--color-border)] px-2.5 py-1 font-mono text-[10px] text-[var(--color-text-muted)] transition hover:text-[var(--color-text)] disabled:opacity-50"
735 >
736 {agent.enabled ? 'disable' : 'enable'}
737 </button>
738 <button
739 type="button"
740 onClick={() => {
741 setEditing((v) => !v);
742 setConfirmingDelete(false);
743 }}
744 disabled={busy}
745 className="rounded-md border border-[var(--color-border)] px-2.5 py-1 font-mono text-[10px] text-[var(--color-text-muted)] transition hover:text-[var(--color-text)] disabled:opacity-50"
746 >
747 {editing ? 'close' : 'edit'}
748 </button>
749 {confirmingDelete ? (
750 <span className="flex items-center gap-2">
751 <button
752 type="button"
753 onClick={() => void remove()}
754 disabled={busy}
755 className="rounded-md bg-[var(--color-error)] px-2.5 py-1 font-mono text-[10px] text-[var(--color-text-inverse)] disabled:opacity-50"
756 >
757 {busy ? 'deleting…' : 'yes, delete'}
758 </button>
759 <button
760 type="button"
761 onClick={() => setConfirmingDelete(false)}
762 disabled={busy}
763 className="font-mono text-[10px] text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
764 >
765 cancel
766 </button>
767 </span>
768 ) : (
769 <button
770 type="button"
771 onClick={() => setConfirmingDelete(true)}
772 disabled={busy}
773 className="rounded-md border border-[var(--color-border)] px-2.5 py-1 font-mono text-[10px] text-[var(--color-text-muted)] transition hover:text-[var(--color-error)] disabled:opacity-50"
774 >
775 delete
776 </button>
777 )}
778 </div>
779 </div>
780
781 <div className="flex flex-wrap items-center gap-x-6 gap-y-1.5 font-mono text-[11px] text-[var(--color-text-muted)]">
782 <span>
783 <span className="text-[var(--color-text-subtle)]">model </span>
784 {agent.model}
785 </span>
786 <span>
787 <span className="text-[var(--color-text-subtle)]">key </span>
788 {agent.hasKey ? (
789 maskedKey
790 ) : (
791 <span className="text-[var(--color-text-subtle)]">no key</span>
792 )}
793 </span>
794 {agent.endpoint ? (
795 <span className="break-all">
796 <span className="text-[var(--color-text-subtle)]">endpoint </span>
797 {agent.endpoint}
798 </span>
799 ) : null}
800 <span>
801 <span className="text-[var(--color-text-subtle)]">added </span>
802 {formatDate(agent.createdAt)}
803 </span>
804 </div>
805
806 {testResult ? (
807 <p
808 className={`font-mono text-[10px] ${
809 testResult.ok ? 'text-[var(--color-primary)]' : 'text-[var(--color-error)]'
810 }`}
811 >
812 {testResult.ok ? 'ok' : 'fail'} · status {testResult.status} · {testResult.message}
813 </p>
814 ) : null}
815
816 {error ? <p className="font-mono text-[10px] text-[var(--color-error)]">{error}</p> : null}
817
818 {editing ? (
819 <div className="border-t border-[var(--color-border-subtle)] pt-4">
820 <AgentForm
821 mode="edit"
822 initial={agent}
823 busy={busy}
824 onSubmit={(v) => void saveEdit(v)}
825 onCancel={() => setEditing(false)}
826 />
827 </div>
828 ) : null}
829
830 {pending != null ? (
831 <StepUpPrompt
832 apiOrigin={apiOrigin}
833 reason={pending.reason}
834 onSuccess={async () => {
835 const job = pending;
836 setPending(null);
837 if (job) await job.run();
838 }}
839 onCancel={() => setPending(null)}
840 />
841 ) : null}
842 </motion.div>
843 );
844}
845
846/* ─── small pieces ───────────────────────────────────────────────────────── */
847
848function ScopeBadge({ scope }: { scope: AgentScope }) {
849 const cls =
850 scope === 'admin'
851 ? 'text-[var(--color-warning)]'
852 : scope === 'read-write'
853 ? 'text-[var(--color-text)]'
854 : 'text-[var(--color-text-subtle)]';
855 return (
856 <span
857 className={`inline-flex rounded-md bg-[var(--color-surface-raised)] px-1.5 py-0.5 font-mono text-[10px] uppercase tracking-wider ${cls}`}
858 >
859 {scope}
860 </span>
861 );
862}
863
864function formatDate(iso: string): string {
865 const d = new Date(iso);
866 if (Number.isNaN(d.getTime())) return iso;
867 return d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
868}