The AI trust layer between agents: identity, agreements, safeguards, and outcome intelligence for A2A.

Viewing files at commit 39e235ac59f4Commit detailsView latest

apps/dashboard/src/main.tsx 2288 lines · 75.7 KB · tsx
1
import React, { useCallback, useEffect, useMemo, useState } from 'react';
2
import { createRoot } from 'react-dom/client';
3
import {
4
  applyPreviewRequest,
5
  createPreviewData,
6
  defaultPreviewSettings,
7
  disablePreview,
8
  enablePreview,
9
  isPreviewActive,
10
  previewSession,
11
} from './preview.js';
12
import './styles.css';
13
14
declare const __AUTH0_DOMAIN__: string;
15
declare const __AUTH0_CLIENT_ID__: string;
16
declare const __AUTH0_AUDIENCE__: string;
17
18
type Auth0User = { sub: string; name?: string; email?: string; picture?: string };
19
type AuthSession = { user: Auth0User };
20
type AuthTransaction = { state: string; nonce: string; verifier: string };
21
type Theme = 'dark' | 'light';
22
23
const authTransactionKey = 'openclasp.auth0.transaction';
24
const themeKey = 'openclasp.theme.v1';
25
26
type DashboardData = {
27
  agents: Record<string, any>[];
28
  projects: Record<string, any>[];
29
  installations: Record<string, any>[];
30
  setupRequests: Record<string, any>[];
31
  publications: Record<string, any>[];
32
  interactions: Record<string, any>[];
33
  federatedInteractions: Record<string, any>[];
34
  liveSessions: Record<string, any>[];
35
  hostedThreads: Record<string, any>[];
36
  events: Record<string, any>[];
37
  conflicts: Record<string, any>[];
38
  receipts: Record<string, any>[];
39
  profiles: Record<string, any>[];
40
  runtimes: Record<string, any>[];
41
  accessTokens: Record<string, any>[];
42
};
43
44
type Settings = {
45
  displayName: string;
46
  contributionEnabled: boolean;
47
  retentionDays: number;
48
  evidenceSharing: 'never' | 'ask' | 'contract_only';
49
  rawConversationsStored: false;
50
};
51
52
const emptyData: DashboardData = {
53
  agents: [],
54
  projects: [],
55
  installations: [],
56
  setupRequests: [],
57
  publications: [],
58
  interactions: [],
59
  federatedInteractions: [],
60
  liveSessions: [],
61
  hostedThreads: [],
62
  events: [],
63
  conflicts: [],
64
  receipts: [],
65
  profiles: [],
66
  runtimes: [],
67
  accessTokens: [],
68
};
69
const defaultSettings: Settings = {
70
  displayName: '',
71
  contributionEnabled: false,
72
  retentionDays: 30,
73
  evidenceSharing: 'ask',
74
  rawConversationsStored: false,
75
};
76
const pages = [
77
  'dashboard',
78
  'conversations',
79
  'history',
80
  'agents',
81
  'insights',
82
  'connect',
83
  'settings',
84
] as const;
85
type Page = (typeof pages)[number];
86
const pageMeta: Record<Page, { label: string; title: string; lede: string; eyebrow: string }> = {
87
  dashboard: {
88
    label: 'Overview',
89
    title: 'Overview',
90
    lede: 'Agent readiness, signed outcomes, and anything waiting on you.',
91
    eyebrow: 'Network',
92
  },
93
  history: {
94
    label: 'History',
95
    title: 'History',
96
    lede: 'Structured events, receipts, and shared contracts — never raw messages.',
97
    eyebrow: 'Audit',
98
  },
99
  conversations: {
100
    label: 'Conversations',
101
    title: 'Temporary conversations',
102
    lede: 'Hosted history for temporary chat identities. Direct runtime messages never appear here.',
103
    eyebrow: 'Inbox',
104
  },
105
  agents: {
106
    label: 'Agents',
107
    title: 'Agents',
108
    lede: 'Identities, runtimes, and the automation each agent is allowed to do.',
109
    eyebrow: 'Registry',
110
  },
111
  insights: {
112
    label: 'Insights',
113
    title: 'Insights',
114
    lede: 'Task-specific reliability from signed outcomes. No universal score.',
115
    eyebrow: 'Context',
116
  },
117
  connect: {
118
    label: 'Connect',
119
    title: 'Connect',
120
    lede: 'Add an agent once. Approve its identity, then OpenClasp handles the routine work.',
121
    eyebrow: 'Setup',
122
  },
123
  settings: {
124
    label: 'Settings',
125
    title: 'Settings',
126
    lede: 'Privacy, retention, and what this account may contribute to the network.',
127
    eyebrow: 'Account',
128
  },
129
};
130
131
function base64Url(bytes: Uint8Array) {
132
  let binary = '';
133
  for (const byte of bytes) binary += String.fromCharCode(byte);
134
  return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
135
}
136
137
function randomValue(size = 32) {
138
  return base64Url(crypto.getRandomValues(new Uint8Array(size)));
139
}
140
141
async function beginAuth(provider: 'google' | 'github') {
142
  const verifier = randomValue(48);
143
  const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
144
  const transaction: AuthTransaction = { state: randomValue(), nonce: randomValue(), verifier };
145
  sessionStorage.setItem(authTransactionKey, JSON.stringify(transaction));
146
  const parameters = new URLSearchParams({
147
    client_id: __AUTH0_CLIENT_ID__,
148
    redirect_uri: `${location.origin}/sso-callback`,
149
    response_type: 'code',
150
    scope: 'openid profile email',
151
    audience: __AUTH0_AUDIENCE__,
152
    state: transaction.state,
153
    nonce: transaction.nonce,
154
    code_challenge: base64Url(new Uint8Array(digest)),
155
    code_challenge_method: 'S256',
156
    connection: provider === 'google' ? 'google-oauth2' : 'github',
157
  });
158
  location.assign(`https://${__AUTH0_DOMAIN__}/authorize?${parameters}`);
159
}
160
161
async function signOut(preview: boolean) {
162
  if (preview) {
163
    disablePreview();
164
    location.replace('/login');
165
    return;
166
  }
167
  await fetch('/api/session', { method: 'DELETE', credentials: 'same-origin' });
168
  const parameters = new URLSearchParams({
169
    client_id: __AUTH0_CLIENT_ID__,
170
    returnTo: `${location.origin}/login`,
171
  });
172
  location.assign(`https://${__AUTH0_DOMAIN__}/v2/logout?${parameters}`);
173
}
174
175
async function remoteApi(path: string, init?: RequestInit) {
176
  const headers = new Headers(init?.headers);
177
  if (init?.body && !headers.has('content-type')) headers.set('content-type', 'application/json');
178
  return fetch(path, {
179
    ...init,
180
    credentials: 'same-origin',
181
    headers,
182
  }).then(async (response) => {
183
    if (!response.ok)
184
      throw new Error((await response.text()) || `Request failed: ${response.status}`);
185
    return response.json();
186
  });
187
}
188
189
function route(): Page {
190
  const value = location.pathname.slice(1) || 'dashboard';
191
  return pages.includes(value as Page) ? (value as Page) : 'dashboard';
192
}
193
194
function initialTheme(): Theme {
195
  const saved = localStorage.getItem(themeKey);
196
  if (saved === 'dark' || saved === 'light') return saved;
197
  return matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
198
}
199
200
function App() {
201
  const [session, setSession] = useState<AuthSession | null>();
202
  const [preview, setPreview] = useState(false);
203
  const [theme, setTheme] = useState<Theme>(initialTheme);
204
  const [page, setPage] = useState<Page>(route());
205
  const [data, setData] = useState<DashboardData>(emptyData);
206
  const [settings, setSettings] = useState<Settings>(defaultSettings);
207
  const [loading, setLoading] = useState(true);
208
  const [error, setError] = useState('');
209
  const api = useCallback(
210
    async (path: string, init?: RequestInit) => {
211
      if (preview) {
212
        const next = applyPreviewRequest(data, settings, path, init);
213
        setData(next.data);
214
        setSettings(next.settings);
215
        return next.result;
216
      }
217
      return remoteApi(path, init);
218
    },
219
    [data, preview, settings],
220
  );
221
  const refreshDashboard = useCallback(async () => {
222
    if (preview) return;
223
    setData((await remoteApi('/v0.1/dashboard')) as DashboardData);
224
  }, [preview]);
225
226
  useEffect(() => {
227
    document.title = session
228
      ? `${pageMeta[page].label} · OpenClasp`
229
      : session === null
230
        ? 'Sign in · OpenClasp'
231
        : 'OpenClasp';
232
  }, [page, session]);
233
234
  useEffect(() => {
235
    let cancelled = false;
236
    const boot = async () => {
237
      if (import.meta.env.DEV && isPreviewActive()) {
238
        if (cancelled) return;
239
        setPreview(true);
240
        setSession(previewSession);
241
        setData(createPreviewData());
242
        setSettings(defaultPreviewSettings);
243
        setLoading(false);
244
        return;
245
      }
246
      try {
247
        const response = await fetch('/api/session', { credentials: 'same-origin' });
248
        if (!response.ok) {
249
          if (!cancelled && !(import.meta.env.DEV && isPreviewActive())) setSession(null);
250
          return;
251
        }
252
        if (!cancelled && !(import.meta.env.DEV && isPreviewActive())) {
253
          setSession((await response.json()) as AuthSession);
254
        }
255
      } catch {
256
        if (!cancelled && !(import.meta.env.DEV && isPreviewActive())) setSession(null);
257
      }
258
    };
259
    void boot();
260
    return () => {
261
      cancelled = true;
262
    };
263
  }, []);
264
265
  useEffect(() => {
266
    document.documentElement.dataset.theme = theme;
267
    localStorage.setItem(themeKey, theme);
268
    const meta = document.querySelector('meta[name="theme-color"]');
269
    if (meta) meta.setAttribute('content', theme === 'light' ? '#f4f6f1' : '#0b0d0b');
270
  }, [theme]);
271
272
  useEffect(() => {
273
    const onPopState = () => setPage(route());
274
    addEventListener('popstate', onPopState);
275
    return () => removeEventListener('popstate', onPopState);
276
  }, []);
277
278
  useEffect(() => {
279
    if (session === undefined) return;
280
    if (!session && location.pathname !== '/login') history.replaceState({}, '', '/login');
281
    if (!session) return;
282
    if (location.pathname === '/login') history.replaceState({}, '', '/dashboard');
283
    setPage(route());
284
    if (preview) {
285
      setLoading(false);
286
      return;
287
    }
288
    Promise.all([remoteApi('/v0.1/dashboard'), remoteApi('/v0.1/settings')])
289
      .then(([dashboard, accountSettings]) => {
290
        setData(dashboard as DashboardData);
291
        setSettings(accountSettings as Settings);
292
      })
293
      .catch((reason: unknown) =>
294
        setError(reason instanceof Error ? reason.message : 'Load failed'),
295
      )
296
      .finally(() => setLoading(false));
297
  }, [preview, session]);
298
299
  useEffect(() => {
300
    if (!session || preview) return;
301
    const refresh = () => {
302
      if (document.visibilityState === 'visible') void refreshDashboard().catch(() => undefined);
303
    };
304
    const timer = window.setInterval(refresh, 10_000);
305
    document.addEventListener('visibilitychange', refresh);
306
    return () => {
307
      window.clearInterval(timer);
308
      document.removeEventListener('visibilitychange', refresh);
309
    };
310
  }, [preview, refreshDashboard, session]);
311
312
  const navigate = (next: Page) => {
313
    history.pushState({}, '', `/${next}`);
314
    setPage(next);
315
  };
316
  const pendingSetup = data.setupRequests.filter((request) => request.status === 'pending').length;
317
  const pendingInvites = data.federatedInteractions.filter(
318
    (interaction) => interaction.status === 'pending',
319
  ).length;
320
  const unreadThreads = data.hostedThreads.reduce(
321
    (total, thread) => total + Number(thread.unreadCount ?? 0),
322
    0,
323
  );
324
325
  if (session === undefined) return <Loading />;
326
  if (!session)
327
    return import.meta.env.DEV ? (
328
      <Login
329
        onPreview={() => {
330
          enablePreview();
331
          setPreview(true);
332
          setSession(previewSession);
333
          setData(createPreviewData());
334
          setSettings(defaultPreviewSettings);
335
          setLoading(false);
336
          history.replaceState({}, '', '/dashboard');
337
          setPage('dashboard');
338
        }}
339
      />
340
    ) : (
341
      <Login />
342
    );
343
344
  return (
345
    <div className="appShell">
346
      <a className="skipLink" href="#main">
347
        Skip to content
348
      </a>
349
      <aside>
350
        <div className="brand">
351
          <div className="mark">OC</div>
352
          <div>
353
            <strong>OpenClasp</strong>
354
            <small>ASSURANCE NETWORK</small>
355
          </div>
356
        </div>
357
        <nav aria-label="Dashboard">
358
          <Nav
359
            page="dashboard"
360
            active={page}
361
            onClick={navigate}
362
            label="Overview"
363
            icon="home"
364
            badge={pendingInvites || pendingSetup ? pendingInvites + pendingSetup : 0}
365
          />
366
          <Nav page="history" active={page} onClick={navigate} label="History" icon="history" />
367
          <Nav
368
            page="conversations"
369
            active={page}
370
            onClick={navigate}
371
            label="Conversations"
372
            icon="connect"
373
            badge={unreadThreads}
374
          />
375
          <Nav page="agents" active={page} onClick={navigate} label="Agents" icon="agents" />
376
          <Nav page="insights" active={page} onClick={navigate} label="Insights" icon="insights" />
377
          <Nav
378
            page="connect"
379
            active={page}
380
            onClick={navigate}
381
            label="Connect"
382
            icon="connect"
383
            badge={pendingSetup}
384
          />
385
          <Nav page="settings" active={page} onClick={navigate} label="Settings" icon="settings" />
386
        </nav>
387
        <button
388
          className="themeSwitch"
389
          type="button"
390
          onClick={() => setTheme((current) => (current === 'dark' ? 'light' : 'dark'))}
391
          aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} theme`}
392
        >
393
          <Icon name={theme === 'dark' ? 'sun' : 'moon'} />
394
          {theme === 'dark' ? 'Light theme' : 'Dark theme'}
395
        </button>
396
        <div className="privacyStamp">
397
          <span className="liveDot" />
398
          <div>
399
            <strong>Privacy separated</strong>
400
            <small>Direct messages never stored</small>
401
          </div>
402
        </div>
403
        <button
404
          className="account"
405
          type="button"
406
          onClick={() => void signOut(preview)}
407
          title="Sign out"
408
        >
409
          <span>{initials(session.user.name || session.user.email || 'OC')}</span>
410
          <div>
411
            <strong>{session.user.name || 'OpenClasp user'}</strong>
412
            <small>{session.user.email || 'Signed in'}</small>
413
          </div>
414
          <b>Sign out</b>
415
        </button>
416
      </aside>
417
      <div>
418
        <div className="mobileBar">
419
          <div className="brand">
420
            <div className="mark">OC</div>
421
            <div>
422
              <strong>OpenClasp</strong>
423
              <small>ASSURANCE</small>
424
            </div>
425
          </div>
426
          <div className="mobileActions">
427
            <button
428
              className="iconButton"
429
              type="button"
430
              aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} theme`}
431
              onClick={() => setTheme((current) => (current === 'dark' ? 'light' : 'dark'))}
432
            >
433
              <Icon name={theme === 'dark' ? 'sun' : 'moon'} />
434
            </button>
435
            <button
436
              className="iconButton"
437
              type="button"
438
              aria-label="Sign out"
439
              onClick={() => void signOut(preview)}
440
            >
441
              <span>{initials(session.user.name || session.user.email || 'OC')}</span>
442
            </button>
443
          </div>
444
        </div>
445
        <main id="main">
446
          {preview && (
447
            <div className="previewBanner">Local preview with sample data. Changes stay here.</div>
448
          )}
449
          {error && (
450
            <div className="errorBar" role="alert">
451
              {error}
452
            </div>
453
          )}
454
          {loading ? (
455
            <Loading compact />
456
          ) : (
457
            <PageContent
458
              page={page}
459
              data={data}
460
              settings={settings}
461
              setSettings={setSettings}
462
              navigate={navigate}
463
              refreshDashboard={refreshDashboard}
464
              api={api}
465
            />
466
          )}
467
        </main>
468
      </div>
469
    </div>
470
  );
471
}
472
473
function Login({ onPreview }: { onPreview?: () => void }) {
474
  const [error, setError] = useState('');
475
  const continueWith = async (provider: 'google' | 'github') => {
476
    setError('');
477
    try {
478
      await beginAuth(provider);
479
    } catch {
480
      setError(`${provider === 'google' ? 'Google' : 'GitHub'} sign-in is not configured yet.`);
481
    }
482
  };
483
  return (
484
    <div className="loginPage">
485
      <section className="loginPitch">
486
        <div className="brand">
487
          <div className="mark">OC</div>
488
          <strong>OpenClasp</strong>
489
        </div>
490
        <div>
491
          <p className="eyebrow">AGENT-TO-AGENT ASSURANCE</p>
492
          <h1>Know who your agent is dealing with.</h1>
493
          <p>
494
            Verified identities, signed outcomes, private warnings, and contextual
495
            reliability—without using message bodies for scoring.
496
          </p>
497
        </div>
498
        <div className="loginProof">
499
          <span>01</span> User-owned history
500
          <span>02</span> No universal trust score
501
          <span>03</span> Explicit network consent
502
        </div>
503
      </section>
504
      <section className="loginCard">
505
        <div>
506
          <p className="eyebrow">SECURE ACCESS</p>
507
          <h2>Sign in to OpenClasp</h2>
508
          <p>
509
            Manage connected agents, review signed history, and control what contributes to the
510
            network.
511
          </p>
512
        </div>
513
        <div className="socialButtons">
514
          <button type="button" onClick={() => void continueWith('google')}>
515
            <GoogleMark /> Continue with Google
516
          </button>
517
          <button type="button" onClick={() => void continueWith('github')}>
518
            <GitHubMark /> Continue with GitHub
519
          </button>
520
          {onPreview && (
521
            <button type="button" onClick={() => void onPreview()}>
522
              Continue with local preview
523
            </button>
524
          )}
525
        </div>
526
        {error && (
527
          <div className="loginError" role="alert">
528
            {error}
529
          </div>
530
        )}
531
        <small>
532
          Google and GitHub handle authentication. OpenClasp never receives your password.
533
        </small>
534
      </section>
535
    </div>
536
  );
537
}
538
539
let callbackStarted = false;
540
function AuthCallback() {
541
  const [error, setError] = useState('');
542
  useEffect(() => {
543
    if (callbackStarted) return;
544
    callbackStarted = true;
545
    const finish = async () => {
546
      const query = new URLSearchParams(location.search);
547
      const oauthError = query.get('error_description') ?? query.get('error');
548
      if (oauthError) throw new Error(oauthError);
549
      const code = query.get('code');
550
      const returnedState = query.get('state');
551
      const rawTransaction = sessionStorage.getItem(authTransactionKey);
552
      if (!code || !returnedState || !rawTransaction) throw new Error('Missing OAuth transaction');
553
      const transaction = JSON.parse(rawTransaction) as AuthTransaction;
554
      sessionStorage.removeItem(authTransactionKey);
555
      if (returnedState !== transaction.state) throw new Error('OAuth state mismatch');
556
      const response = await fetch('/api/session', {
557
        method: 'POST',
558
        credentials: 'same-origin',
559
        headers: { 'content-type': 'application/json' },
560
        body: JSON.stringify({
561
          code,
562
          codeVerifier: transaction.verifier,
563
        }),
564
      });
565
      if (!response.ok) throw new Error('Auth0 token exchange failed');
566
      location.replace('/dashboard');
567
    };
568
    void finish().catch((reason: unknown) =>
569
      setError(reason instanceof Error ? reason.message : 'Authentication failed'),
570
    );
571
  }, []);
572
  if (error)
573
    return (
574
      <div className="loading">
575
        <span className="mark">OC</span>
576
        <p>{error}</p>
577
        <a href="/login">Return to login</a>
578
      </div>
579
    );
580
  return <Loading />;
581
}
582
583
function PageContent({
584
  page,
585
  data,
586
  settings,
587
  setSettings,
588
  navigate,
589
  refreshDashboard,
590
  api,
591
}: {
592
  page: Page;
593
  data: DashboardData;
594
  settings: Settings;
595
  setSettings: React.Dispatch<React.SetStateAction<Settings>>;
596
  navigate: (page: Page) => void;
597
  refreshDashboard: () => Promise<void>;
598
  api: (path: string, init?: RequestInit) => Promise<unknown>;
599
}) {
600
  if (page === 'history') return <History data={data} />;
601
  if (page === 'conversations')
602
    return <Conversations data={data} refreshDashboard={refreshDashboard} api={api} />;
603
  if (page === 'agents')
604
    return <Agents data={data} navigate={navigate} refreshDashboard={refreshDashboard} api={api} />;
605
  if (page === 'insights') return <Insights data={data} />;
606
  if (page === 'connect')
607
    return <Connect data={data} refreshDashboard={refreshDashboard} api={api} />;
608
  if (page === 'settings')
609
    return <SettingsPage settings={settings} setSettings={setSettings} api={api} />;
610
  return <Overview data={data} navigate={navigate} refreshDashboard={refreshDashboard} api={api} />;
611
}
612
613
function Overview({
614
  data,
615
  navigate,
616
  refreshDashboard,
617
  api,
618
}: {
619
  data: DashboardData;
620
  navigate: (page: Page) => void;
621
  refreshDashboard: () => Promise<void>;
622
  api: (path: string, init?: RequestInit) => Promise<unknown>;
623
}) {
624
  const completed = data.receipts.filter((item) => item.outcome === 'success').length;
625
  const warnings = data.events.filter((item) =>
626
    ['policy_warning', 'policy_violation', 'objection'].includes(String(item.eventType)),
627
  ).length;
628
  const publishedIds = new Set(
629
    data.publications
630
      .filter((publication) => publication.published)
631
      .map((publication) => String(publication.agentId)),
632
  );
633
  const runtimeIds = new Set(
634
    data.runtimes
635
      .filter((runtime) => runtime.status === 'verified')
636
      .map((runtime) => String(runtime.agentId)),
637
  );
638
  const modeOf = (agent: Record<string, any>) =>
639
    agent.agentMode ?? (agent.a2aEndpoint ? 'persistent_runtime' : 'temporary_chat');
640
  const readyAgents = data.agents.filter(
641
    (agent) =>
642
      publishedIds.has(String(agent.agentId)) &&
643
      (modeOf(agent) === 'temporary_chat' || runtimeIds.has(String(agent.agentId))),
644
  ).length;
645
  const activeTemporaryChats = data.agents.filter(
646
    (agent) => modeOf(agent) === 'temporary_chat' && agent.presence?.status === 'online',
647
  ).length;
648
  const pendingInvitations = data.federatedInteractions.filter(
649
    (interaction) => interaction.status === 'pending',
650
  ).length;
651
  const pendingSetup = data.setupRequests.filter((request) => request.status === 'pending').length;
652
  const reviewCount = warnings + pendingInvitations;
653
  return (
654
    <>
655
      <PageHead page="dashboard" action="Connect agent" onAction={() => navigate('connect')} />
656
      {pendingSetup > 0 && (
657
        <button className="attentionBanner" type="button" onClick={() => navigate('connect')}>
658
          <div>
659
            <span className="statusOrb">{pendingSetup}</span>
660
            <div>
661
              <strong>
662
                {pendingSetup} setup request{pendingSetup === 1 ? '' : 's'} waiting
663
              </strong>
664
              <small>Approve the proposed identity before the agent can be bound.</small>
665
            </div>
666
          </div>
667
        </button>
668
      )}
669
      <section
670
        className={`readiness ${readyAgents === data.agents.length && readyAgents ? 'ready' : ''}`}
671
      >
672
        <div>
673
          <span className="statusOrb">{readyAgents ? '✓' : '!'}</span>
674
          <div>
675
            <strong>
676
              {readyAgents === data.agents.length && readyAgents
677
                ? 'Your agent network is ready'
678
                : `${readyAgents} of ${data.agents.length} agents can receive A2A work`}
679
            </strong>
680
            <small>
681
              Safe matching requests activate automatically. Everything else waits for approval.
682
            </small>
683
          </div>
684
        </div>
685
        <button className="secondary" type="button" onClick={() => navigate('agents')}>
686
          Manage automation
687
        </button>
688
      </section>
689
      <section className="metrics">
690
        <Metric
691
          label="Active temporary chats"
692
          value={activeTemporaryChats}
693
          note={`${runtimeIds.size} persistent runtimes connected`}
694
        />
695
        <Metric
696
          label="Interactions"
697
          value={data.interactions.length + data.federatedInteractions.length}
698
          note="local and shared"
699
        />
700
        <Metric label="Successful outcomes" value={completed} note="receipt-backed" />
701
        <Metric
702
          label="Needs review"
703
          value={reviewCount}
704
          note="warnings or approvals"
705
          warn={reviewCount > 0}
706
        />
707
      </section>
708
      <Invitations data={data} refreshDashboard={refreshDashboard} api={api} />
709
      <section className="contentGrid">
710
        <Panel title="Recent activity" subtitle="Structured events and signed outcomes">
711
          <Timeline events={data.events.slice(-6).reverse()} />
712
          <TextButton onClick={() => navigate('history')}>View complete history</TextButton>
713
        </Panel>
714
        <Panel
715
          title="Reliability context"
716
          subtitle="Task-specific profiles, never a universal score"
717
        >
718
          {data.profiles.length ? (
719
            data.profiles
720
              .slice(0, 4)
721
              .map((profile) => (
722
                <Profile key={`${profile.agentId}-${profile.taskCategory}`} profile={profile} />
723
              ))
724
          ) : (
725
            <Empty
726
              title="No reliability profile yet"
727
              text="Profiles appear after signed receipts and eligible bilateral feedback."
728
            />
729
          )}
730
        </Panel>
731
      </section>
732
    </>
733
  );
734
}
735
736
type HistoryFilter = 'all' | 'interaction' | 'event' | 'receipt' | 'dispute';
737
738
function Conversations({
739
  data,
740
  refreshDashboard,
741
  api,
742
}: {
743
  data: DashboardData;
744
  refreshDashboard: () => Promise<void>;
745
  api: (path: string, init?: RequestInit) => Promise<unknown>;
746
}) {
747
  const [selected, setSelected] = useState<Record<string, any> | null>(null);
748
  const [selectedAgentId, setSelectedAgentId] = useState('');
749
  const [draft, setDraft] = useState('');
750
  const [working, setWorking] = useState(false);
751
  const [error, setError] = useState('');
752
  const temporaryAgentFor = (thread: Record<string, any>) =>
753
    data.agents.find(
754
      (agent) =>
755
        thread.participantAgentIds?.includes(agent.agentId) &&
756
        (agent.agentMode ?? (agent.a2aEndpoint ? 'persistent_runtime' : 'temporary_chat')) ===
757
          'temporary_chat',
758
    );
759
  const openThread = async (thread: Record<string, any>) => {
760
    const agent = temporaryAgentFor(thread);
761
    if (!agent) return;
762
    setWorking(true);
763
    setError('');
764
    try {
765
      const [detail] = (await Promise.all([
766
        api(
767
          `/v0.1/agents/${encodeURIComponent(agent.agentId)}/threads/${encodeURIComponent(thread.threadId)}`,
768
        ),
769
        api(
770
          `/v0.1/agents/${encodeURIComponent(agent.agentId)}/threads/${encodeURIComponent(thread.threadId)}/read`,
771
          { method: 'POST' },
772
        ),
773
      ])) as [Record<string, any>, unknown];
774
      setSelected(detail);
775
      setSelectedAgentId(String(agent.agentId));
776
      await refreshDashboard();
777
    } catch (reason) {
778
      setError(reason instanceof Error ? reason.message : 'Could not open conversation');
779
    } finally {
780
      setWorking(false);
781
    }
782
  };
783
  const reply = async () => {
784
    if (!selected || !draft.trim() || !selectedAgentId) return;
785
    setWorking(true);
786
    setError('');
787
    try {
788
      await api(`/v0.1/agents/${encodeURIComponent(selectedAgentId)}/messages`, {
789
        method: 'POST',
790
        body: JSON.stringify({
791
          interactionId: selected.thread.interactionId,
792
          content: draft.trim(),
793
        }),
794
      });
795
      setDraft('');
796
      await openThread(selected.thread);
797
    } catch (reason) {
798
      setError(reason instanceof Error ? reason.message : 'Could not send reply');
799
      setWorking(false);
800
    }
801
  };
802
  return (
803
    <>
804
      <PageHead page="conversations" />
805
      <div className="notice">
806
        <strong>Hosted temporary mode.</strong> OpenClasp processes these messages and encrypts them
807
        at rest for 30 days. Messages between persistent runtimes remain direct and are never shown
808
        here.
809
      </div>
810
      {error ? (
811
        <div className="errorBar" role="alert">
812
          {error}
813
        </div>
814
      ) : null}
815
      <section className="conversationLayout">
816
        <Panel title="Threads" subtitle="Temporary chat identities only">
817
          <div className="threadList">
818
            {data.hostedThreads.length ? (
819
              data.hostedThreads.map((thread) => {
820
                const agent = temporaryAgentFor(thread);
821
                const peer = thread.participantAgentIds?.find(
822
                  (value: string) => value !== agent?.agentId,
823
                );
824
                return (
825
                  <button
826
                    type="button"
827
                    key={thread.threadId}
828
                    className={selected?.thread?.threadId === thread.threadId ? 'active' : ''}
829
                    onClick={() => void openThread(thread)}
830
                    disabled={working}
831
                  >
832
                    <span>
833
                      <strong>{peer ?? 'Counterparty'}</strong>
834
                      <small>{agent?.name ?? agent?.agentId}</small>
835
                    </span>
836
                    <b>{thread.unreadCount ? `${thread.unreadCount} NEW` : thread.status}</b>
837
                  </button>
838
                );
839
              })
840
            ) : (
841
              <Empty
842
                title="No temporary conversations"
843
                text="Messages appear after a temporary chat identity accepts an interaction with a persistent runtime."
844
              />
845
            )}
846
          </div>
847
        </Panel>
848
        <Panel
849
          title={selected ? 'Conversation' : 'Select a thread'}
850
          subtitle="Private insights are attributable and task-specific"
851
        >
852
          {selected ? (
853
            <div className="conversationDetail">
854
              {!!selected.insights?.length && (
855
                <div className="conversationInsights">
856
                  {selected.insights.map((insight: Record<string, any>) => (
857
                    <p key={insight.code}>
858
                      <b>{String(insight.severity).toUpperCase()}</b> {insight.message}
859
                    </p>
860
                  ))}
861
                </div>
862
              )}
863
              <div className="messageList">
864
                {selected.messages.map((message: Record<string, any>) => (
865
                  <article
866
                    key={message.messageId}
867
                    className={message.senderAgentId === selectedAgentId ? 'sent' : 'received'}
868
                  >
869
                    <small>{message.senderAgentId === selectedAgentId ? 'You' : 'Peer'}</small>
870
                    <p>{message.content}</p>
871
                    <span>{new Date(message.createdAt).toLocaleString()}</span>
872
                  </article>
873
                ))}
874
              </div>
875
              <div className="replyBox">
876
                <textarea
877
                  aria-label="Temporary chat reply"
878
                  value={draft}
879
                  onChange={(event) => setDraft(event.target.value)}
880
                  maxLength={20_000}
881
                  placeholder="Reply through the temporary OpenClasp A2A adapter"
882
                />
883
                <button
884
                  className="primary"
885
                  type="button"
886
                  disabled={working || !draft.trim() || selected.thread.status !== 'open'}
887
                  onClick={() => void reply()}
888
                >
889
                  {working ? 'Sending…' : 'Send reply'}
890
                </button>
891
              </div>
892
            </div>
893
          ) : (
894
            <Empty title="Nothing selected" text="Choose a thread to read its hosted history." />
895
          )}
896
        </Panel>
897
      </section>
898
    </>
899
  );
900
}
901
902
function History({ data }: { data: DashboardData }) {
903
  const [query, setQuery] = useState('');
904
  const [filter, setFilter] = useState<HistoryFilter>('all');
905
  const records = useMemo(
906
    () =>
907
      [
908
        ...data.interactions.map((value) => ({ ...value, _kind: 'interaction' })),
909
        ...data.federatedInteractions.map((value) => ({
910
          ...value,
911
          _kind: 'federated interaction',
912
        })),
913
        ...data.events.map((value) => ({ ...value, _kind: 'event' })),
914
        ...data.receipts.map((value) => ({ ...value, _kind: 'receipt' })),
915
        ...data.conflicts.map((value) => ({ ...value, _kind: 'dispute' })),
916
      ].sort((a, b) => Date.parse(timestamp(b)) - Date.parse(timestamp(a))),
917
    [data],
918
  );
919
  const filtered = useMemo(() => {
920
    const needle = query.trim().toLowerCase();
921
    return records.filter((item) => {
922
      const kind = String(item._kind);
923
      if (filter === 'interaction' && !kind.includes('interaction')) return false;
924
      if (filter === 'event' && kind !== 'event') return false;
925
      if (filter === 'receipt' && kind !== 'receipt') return false;
926
      if (filter === 'dispute' && kind !== 'dispute') return false;
927
      if (!needle) return true;
928
      const record = item as Record<string, any>;
929
      const hay = [
930
        record.eventType,
931
        record._kind,
932
        record.agentId,
933
        record.interactionId,
934
        record.receiptId,
935
        record.outcome,
936
        record.status,
937
        record.visibility,
938
        record.contract?.purpose,
939
      ]
940
        .filter(Boolean)
941
        .join(' ')
942
        .toLowerCase();
943
      return hay.includes(needle);
944
    });
945
  }, [filter, query, records]);
946
  const count = (value: HistoryFilter) =>
947
    value === 'all'
948
      ? records.length
949
      : records.filter((item) =>
950
          value === 'interaction'
951
            ? String(item._kind).includes('interaction')
952
            : item._kind === value,
953
        ).length;
954
  return (
955
    <>
956
      <PageHead page="history" />
957
      <Panel
958
        title="Account history"
959
        subtitle="Only structured metadata, signatures, hashes, and permitted evidence are hosted"
960
      >
961
        <div className="toolbar">
962
          <input
963
            className="searchField"
964
            value={query}
965
            onChange={(event) => setQuery(event.target.value)}
966
            placeholder="Search agents, contracts, or statuses"
967
            aria-label="Search history"
968
          />
969
          <div className="filterRow" role="tablist" aria-label="History filters">
970
            {(
971
              [
972
                ['all', 'All'],
973
                ['interaction', 'Interactions'],
974
                ['event', 'Events'],
975
                ['receipt', 'Receipts'],
976
                ['dispute', 'Disputes'],
977
              ] as const
978
            ).map(([value, label]) => (
979
              <button
980
                key={value}
981
                className={filter === value ? 'filterChip active' : 'filterChip'}
982
                type="button"
983
                aria-pressed={filter === value}
984
                onClick={() => setFilter(value)}
985
              >
986
                {label} {count(value)}
987
              </button>
988
            ))}
989
          </div>
990
        </div>
991
        {filtered.length ? (
992
          filtered.map((item, index) => (
993
            <HistoryRow
994
              key={`${String(item._kind)}-${String(
995
                (item as Record<string, any>).eventId ??
996
                  (item as Record<string, any>).receiptId ??
997
                  (item as Record<string, any>).conflictId ??
998
                  (item as Record<string, any>).interactionId ??
999
                  index,
1000
              )}`}
1001
              item={item}
1002
            />
1003
          ))
1004
        ) : (
1005
          <Empty
1006
            title={records.length ? 'No matching records' : 'No interactions recorded'}
1007
            text={
1008
              records.length
1009
                ? 'Try another filter or clear the search.'
1010
                : 'Connect an agent and start an assured interaction. Raw message bodies will not appear here.'
1011
            }
1012
          />
1013
        )}
1014
      </Panel>
1015
    </>
1016
  );
1017
}
1018
1019
function Agents({
1020
  data,
1021
  navigate,
1022
  refreshDashboard,
1023
  api,
1024
}: {
1025
  data: DashboardData;
1026
  navigate: (page: Page) => void;
1027
  refreshDashboard: () => Promise<void>;
1028
  api: (path: string, init?: RequestInit) => Promise<unknown>;
1029
}) {
1030
  const [working, setWorking] = useState('');
1031
  const [error, setError] = useState('');
1032
  const saveAutomation = async (
1033
    agentId: string,
1034
    value: {
1035
      autoPublish: boolean;
1036
      autoAcceptPolicy: 'off' | 'safe_matching';
1037
      autoAcceptTaskCategories: string[];
1038
    },
1039
  ) => {
1040
    setWorking(agentId);
1041
    setError('');
1042
    try {
1043
      await api(`/v0.1/agents/${encodeURIComponent(agentId)}/automation`, {
1044
        method: 'PUT',
1045
        body: JSON.stringify(value),
1046
      });
1047
      await refreshDashboard();
1048
      return true;
1049
    } catch (reason) {
1050
      setError(reason instanceof Error ? reason.message : 'Directory update failed');
1051
      return false;
1052
    } finally {
1053
      setWorking('');
1054
    }
1055
  };
1056
  const saveRuntime = async (agentId: string, endpoint: string) => {
1057
    setWorking(`runtime:${agentId}`);
1058
    setError('');
1059
    try {
1060
      await api(`/v0.1/agents/${encodeURIComponent(agentId)}/runtime`, {
1061
        method: 'PUT',
1062
        body: JSON.stringify({ endpoint }),
1063
      });
1064
      await refreshDashboard();
1065
    } catch (reason) {
1066
      setError(reason instanceof Error ? reason.message : 'Runtime verification failed');
1067
    } finally {
1068
      setWorking('');
1069
    }
1070
  };
1071
  const disableRuntime = async (agentId: string) => {
1072
    setWorking(`runtime:${agentId}`);
1073
    setError('');
1074
    try {
1075
      await api(`/v0.1/agents/${encodeURIComponent(agentId)}/runtime`, { method: 'DELETE' });
1076
      await refreshDashboard();
1077
    } catch (reason) {
1078
      setError(reason instanceof Error ? reason.message : 'Runtime update failed');
1079
    } finally {
1080
      setWorking('');
1081
    }
1082
  };
1083
  const createAccessToken = async (agentId: string, name: string, expiresInDays: number) => {
1084
    setWorking(`token:${agentId}`);
1085
    setError('');
1086
    try {
1087
      const created = (await api(`/v0.1/agents/${encodeURIComponent(agentId)}/access-tokens`, {
1088
        method: 'POST',
1089
        body: JSON.stringify({ name, expiresInDays }),
1090
      })) as { token?: string };
1091
      await refreshDashboard();
1092
      if (!created.token) throw new Error('The token was not returned');
1093
      return created.token;
1094
    } catch (reason) {
1095
      setError(reason instanceof Error ? reason.message : 'Could not create agent access token');
1096
      return undefined;
1097
    } finally {
1098
      setWorking('');
1099
    }
1100
  };
1101
  const revokeAccessToken = async (agentId: string, tokenId: string) => {
1102
    setWorking(`token:${agentId}`);
1103
    setError('');
1104
    try {
1105
      await api(
1106
        `/v0.1/agents/${encodeURIComponent(agentId)}/access-tokens/${encodeURIComponent(tokenId)}`,
1107
        { method: 'DELETE' },
1108
      );
1109
      await refreshDashboard();
1110
    } catch (reason) {
1111
      setError(reason instanceof Error ? reason.message : 'Could not revoke agent access token');
1112
    } finally {
1113
      setWorking('');
1114
    }
1115
  };
1116
  const deleteAgent = async (agent: Record<string, any>) => {
1117
    const agentId = String(agent.agentId);
1118
    if (
1119
      !window.confirm(
1120
        `Delete “${String(agent.name ?? agentId)}”?\n\nIts runtime, publication, presence, MCP binding and hosted temporary messages will be removed. Signed interaction and receipt history will be retained.`,
1121
      )
1122
    )
1123
      return;
1124
    setWorking(`delete:${agentId}`);
1125
    setError('');
1126
    try {
1127
      await api(`/v0.1/agents/${encodeURIComponent(agentId)}`, { method: 'DELETE' });
1128
      await refreshDashboard();
1129
    } catch (reason) {
1130
      setError(reason instanceof Error ? reason.message : 'Agent deletion failed');
1131
    } finally {
1132
      setWorking('');
1133
    }
1134
  };
1135
  return (
1136
    <>
1137
      <PageHead page="agents" action="Connect agent" onAction={() => navigate('connect')} />
1138
      <div className="notice">
1139
        <strong>Two explicit modes.</strong> Persistent runtimes talk directly over A2A. Temporary
1140
        chats use an OpenClasp-hosted endpoint with encrypted-at-rest history. There is no offline
1141
        relay for persistent runtimes.
1142
      </div>
1143
      {error && (
1144
        <div className="errorBar" role="alert">
1145
          {error}
1146
        </div>
1147
      )}
1148
      <section className="agentGrid">
1149
        {data.agents.length ? (
1150
          data.agents.map((agent) => (
1151
            <AgentCard
1152
              key={agent.agentId}
1153
              agent={agent}
1154
              projectName={
1155
                data.projects.find((project) => project.projectId === agent.projectId)?.name
1156
              }
1157
              published={data.publications.some(
1158
                (publication) => publication.agentId === agent.agentId && publication.published,
1159
              )}
1160
              working={working === agent.agentId}
1161
              onSave={(value) => saveAutomation(agent.agentId, value)}
1162
              runtime={data.runtimes.find((runtime) => runtime.agentId === agent.agentId)}
1163
              runtimeWorking={working === `runtime:${agent.agentId}`}
1164
              deleteWorking={working === `delete:${agent.agentId}`}
1165
              onRuntime={(endpoint) => saveRuntime(agent.agentId, endpoint)}
1166
              onDisableRuntime={() => disableRuntime(agent.agentId)}
1167
              accessTokens={data.accessTokens.filter(
1168
                (token) =>
1169
                  token.agentId === agent.agentId &&
1170
                  !token.revokedAt &&
1171
                  Date.parse(String(token.expiresAt)) > Date.now(),
1172
              )}
1173
              accessTokenWorking={working === `token:${agent.agentId}`}
1174
              onCreateAccessToken={(name, expiresInDays) =>
1175
                createAccessToken(agent.agentId, name, expiresInDays)
1176
              }
1177
              onRevokeAccessToken={(tokenId) => revokeAccessToken(agent.agentId, tokenId)}
1178
              onDelete={() => deleteAgent(agent)}
1179
            />
1180
          ))
1181
        ) : (
1182
          <Empty
1183
            title="No agents connected"
1184
            text="Install the remote MCP endpoint in an agent, sign in, then create and register its identity."
1185
            action="Open connection guide"
1186
            onAction={() => navigate('connect')}
1187
          />
1188
        )}
1189
      </section>
1190
    </>
1191
  );
1192
}
1193
1194
function Invitations({
1195
  data,
1196
  refreshDashboard,
1197
  api,
1198
}: {
1199
  data: DashboardData;
1200
  refreshDashboard: () => Promise<void>;
1201
  api: (path: string, init?: RequestInit) => Promise<unknown>;
1202
}) {
1203
  const [working, setWorking] = useState('');
1204
  const [error, setError] = useState('');
1205
  const ownedAgentIds = useMemo(
1206
    () => new Set(data.agents.map((agent) => String(agent.agentId))),
1207
    [data.agents],
1208
  );
1209
  const incoming = data.federatedInteractions.filter(
1210
    (interaction) =>
1211
      interaction.status === 'pending' && ownedAgentIds.has(String(interaction.responderAgentId)),
1212
  );
1213
  const respond = async (interactionId: string, agentId: string, decision: 'accept' | 'reject') => {
1214
    setWorking(interactionId);
1215
    setError('');
1216
    try {
1217
      await api(`/v0.1/federated-interactions/${encodeURIComponent(interactionId)}/respond`, {
1218
        method: 'POST',
1219
        body: JSON.stringify({ agentId, decision }),
1220
      });
1221
      await refreshDashboard();
1222
    } catch (reason) {
1223
      setError(reason instanceof Error ? reason.message : 'Could not respond to invitation');
1224
    } finally {
1225
      setWorking('');
1226
    }
1227
  };
1228
  if (!incoming.length && !data.federatedInteractions.length) return null;
1229
  return (
1230
    <Panel
1231
      title={incoming.length ? `Agent invitations (${incoming.length})` : 'Shared interactions'}
1232
      subtitle="Both accounts see the same immutable contract and acceptance state"
1233
    >
1234
      {error ? (
1235
        <div className="errorBar" role="alert">
1236
          {error}
1237
        </div>
1238
      ) : null}
1239
      {(incoming.length ? incoming : data.federatedInteractions.slice(0, 5)).map((interaction) => (
1240
        <article className="setupRequest" key={interaction.interactionId}>
1241
          <div>
1242
            <strong>{interaction.contract?.purpose ?? interaction.interactionId}</strong>
1243
            <small>
1244
              {interaction.initiatorAgentId} → {interaction.responderAgentId} · {interaction.status}
1245
            </small>
1246
            <small>Contract: {String(interaction.termsHash).slice(0, 16)}…</small>
1247
            {data.liveSessions.find(
1248
              (session) => session.interactionId === interaction.interactionId,
1249
            ) ? (
1250
              <small className="autoAccepted">
1251
                Live session:{' '}
1252
                {
1253
                  data.liveSessions.find(
1254
                    (session) => session.interactionId === interaction.interactionId,
1255
                  )?.status
1256
                }
1257
              </small>
1258
            ) : null}
1259
            {interaction.status === 'active' &&
1260
            interaction.acceptances?.[interaction.responderAgentId]?.method ===
1261
              'policy_auto_accept' ? (
1262
              <small className="autoAccepted">✓ Auto-approved by the responder's safe policy</small>
1263
            ) : null}
1264
          </div>
1265
          {interaction.status === 'pending' &&
1266
          ownedAgentIds.has(String(interaction.responderAgentId)) ? (
1267
            <div className="decisionButtons">
1268
              <button
1269
                className="secondary"
1270
                type="button"
1271
                disabled={working === interaction.interactionId}
1272
                onClick={() =>
1273
                  void respond(interaction.interactionId, interaction.responderAgentId, 'reject')
1274
                }
1275
              >
1276
                Reject
1277
              </button>
1278
              <button
1279
                className="primary"
1280
                type="button"
1281
                disabled={working === interaction.interactionId}
1282
                onClick={() =>
1283
                  void respond(interaction.interactionId, interaction.responderAgentId, 'accept')
1284
                }
1285
              >
1286
                {working === interaction.interactionId ? 'Working…' : 'Accept contract'}
1287
              </button>
1288
            </div>
1289
          ) : null}
1290
        </article>
1291
      ))}
1292
    </Panel>
1293
  );
1294
}
1295
1296
function Insights({ data }: { data: DashboardData }) {
1297
  return (
1298
    <>
1299
      <PageHead page="insights" />
1300
      <div className="notice">
1301
        <strong>Context matters.</strong> OpenClasp profiles an agent by task category and version.
1302
        It does not produce a single universal trust score.
1303
      </div>
1304
      <section className="agentGrid">
1305
        {data.profiles.length ? (
1306
          data.profiles.map((profile) => (
1307
            <Panel
1308
              key={`${profile.agentId}-${profile.taskCategory}`}
1309
              title={profile.agentId}
1310
              subtitle={`${profile.taskCategory} · v${profile.agentVersion}`}
1311
            >
1312
              <Profile profile={profile} expanded />
1313
            </Panel>
1314
          ))
1315
        ) : (
1316
          <Empty
1317
            title="Not enough verified evidence"
1318
            text="Insights grow only from signed outcomes, eligible feedback, corrections, disputes, and version-aware history."
1319
          />
1320
        )}
1321
      </section>
1322
    </>
1323
  );
1324
}
1325
1326
function Connect({
1327
  data,
1328
  refreshDashboard,
1329
  api,
1330
}: {
1331
  data: DashboardData;
1332
  refreshDashboard: () => Promise<void>;
1333
  api: (path: string, init?: RequestInit) => Promise<unknown>;
1334
}) {
1335
  const endpoint = 'https://openclasp.vercel.app/mcp';
1336
  const [copied, setCopied] = useState(false);
1337
  const [working, setWorking] = useState('');
1338
  const [decisionError, setDecisionError] = useState('');
1339
  const pending = data.setupRequests.filter((request) => request.status === 'pending');
1340
  useEffect(() => {
1341
    const poll = () => void refreshDashboard().catch(() => undefined);
1342
    poll();
1343
    const timer = window.setInterval(poll, 5000);
1344
    return () => window.clearInterval(timer);
1345
  }, [refreshDashboard]);
1346
  const decide = async (requestId: string, decision: 'approve' | 'reject') => {
1347
    setWorking(requestId);
1348
    setDecisionError('');
1349
    try {
1350
      await api(`/v0.1/onboarding/${encodeURIComponent(requestId)}/${decision}`, {
1351
        method: 'POST',
1352
      });
1353
      await refreshDashboard();
1354
    } catch (reason) {
1355
      setDecisionError(reason instanceof Error ? reason.message : 'Could not update setup request');
1356
    } finally {
1357
      setWorking('');
1358
    }
1359
  };
1360
  return (
1361
    <>
1362
      <PageHead page="connect" />
1363
      {pending.length > 0 && (
1364
        <section className="setupRequests">
1365
          <div>
1366
            <p className="eyebrow">CONFIRMATION REQUIRED</p>
1367
            <h2>Approve agent setup</h2>
1368
            <p>
1369
              An agent proposed this identity. Confirm it before OpenClasp binds the installation.
1370
            </p>
1371
          </div>
1372
          {pending.map((request) => (
1373
            <article className="setupRequest" key={request.requestId}>
1374
              <div>
1375
                <strong>{request.agentName ?? 'Switch installation'}</strong>
1376
                <small>
1377
                  {request.projectName ?? request.existingAgentId} · {request.framework}
1378
                </small>
1379
                {!!request.capabilities?.length && (
1380
                  <div className="tags">
1381
                    {request.capabilities.slice(0, 5).map((capability: string) => (
1382
                      <span key={capability}>{capability}</span>
1383
                    ))}
1384
                  </div>
1385
                )}
1386
                {!!request.limitations?.length && (
1387
                  <small>Limitations: {request.limitations.join(' · ')}</small>
1388
                )}
1389
                <div className="automationPreview">
1390
                  <span>{request.autoPublish ? 'Public after approval' : 'Private'}</span>
1391
                  <span>
1392
                    {(request.agentMode ?? 'temporary_chat') === 'temporary_chat'
1393
                      ? 'Hosted temporary A2A · encrypted history'
1394
                      : 'Persistent runtime · direct A2A'}
1395
                  </span>
1396
                  <span>
1397
                    {request.autoAcceptPolicy === 'safe_matching'
1398
                      ? 'Auto-accept safe matches'
1399
                      : 'Review every request'}
1400
                  </span>
1401
                </div>
1402
              </div>
1403
              <div className="decisionButtons">
1404
                <button
1405
                  className="secondary"
1406
                  type="button"
1407
                  disabled={working === request.requestId}
1408
                  onClick={() => void decide(request.requestId, 'reject')}
1409
                >
1410
                  Reject
1411
                </button>
1412
                <button
1413
                  className="primary"
1414
                  type="button"
1415
                  disabled={working === request.requestId}
1416
                  onClick={() => void decide(request.requestId, 'approve')}
1417
                >
1418
                  {working === request.requestId ? 'Working…' : 'Approve & automate'}
1419
                </button>
1420
              </div>
1421
            </article>
1422
          ))}
1423
          {decisionError && (
1424
            <div className="loginError" role="alert">
1425
              {decisionError}
1426
            </div>
1427
          )}
1428
        </section>
1429
      )}
1430
      <section className="connectLayout">
1431
        <Panel title="Connect once" subtitle="OpenClasp handles the routine steps afterward">
1432
          <div className="endpoint">
1433
            <code>{endpoint}</code>
1434
            <button
1435
              type="button"
1436
              onClick={() => {
1437
                void navigator.clipboard.writeText(endpoint);
1438
                setCopied(true);
1439
                window.setTimeout(() => setCopied(false), 2000);
1440
              }}
1441
            >
1442
              {copied ? 'Copied' : 'Copy'}
1443
            </button>
1444
          </div>
1445
          <div className="connectSteps">
1446
            <div className="connectStep">
1447
              <b>1</b>
1448
              <span>Add this MCP URL and complete the OAuth sign-in.</span>
1449
            </div>
1450
            <div className="connectStep">
1451
              <b>2</b>
1452
              <span>
1453
                Tell the agent: <code>Set yourself up on OpenClasp</code>.
1454
              </span>
1455
            </div>
1456
            <div className="connectStep">
1457
              <b>3</b>
1458
              <span>
1459
                Approve its identity, temporary/direct mode, privacy and automation policy here.
1460
              </span>
1461
            </div>
1462
          </div>
1463
        </Panel>
1464
        <Panel
1465
          title="What becomes automatic"
1466
          subtitle="Safe defaults with deterministic approval boundaries"
1467
        >
1468
          <ul className="checkList">
1469
            <li>Agent Card publication and discovery</li>
1470
            <li>Contract defaults inferred from the task</li>
1471
            <li>Safe matching invitations accepted immediately</li>
1472
            <li>Ready-to-send A2A endpoint, headers, and metadata</li>
1473
            <li>Hosted encrypted history only for temporary chat identities</li>
1474
            <li>Risky requests routed to human review</li>
1475
          </ul>
1476
        </Panel>
1477
      </section>
1478
    </>
1479
  );
1480
}
1481
1482
function SettingsPage({
1483
  settings,
1484
  setSettings,
1485
  api,
1486
}: {
1487
  settings: Settings;
1488
  setSettings: React.Dispatch<React.SetStateAction<Settings>>;
1489
  api: (path: string, init?: RequestInit) => Promise<unknown>;
1490
}) {
1491
  const [saving, setSaving] = useState(false);
1492
  const [saved, setSaved] = useState(false);
1493
  const [error, setError] = useState('');
1494
  const save = useCallback(async () => {
1495
    setSaving(true);
1496
    setSaved(false);
1497
    setError('');
1498
    try {
1499
      const updated = (await api('/v0.1/settings', {
1500
        method: 'PUT',
1501
        body: JSON.stringify(settings),
1502
      })) as Settings;
1503
      setSettings(updated);
1504
      setSaved(true);
1505
    } catch (reason) {
1506
      setError(reason instanceof Error ? reason.message : 'Could not save settings');
1507
    } finally {
1508
      setSaving(false);
1509
    }
1510
  }, [api, settings, setSettings]);
1511
  return (
1512
    <>
1513
      <PageHead page="settings" />
1514
      <section className="settingsCard">
1515
        <Setting label="Display name" description="Shown only inside your OpenClasp account.">
1516
          <input
1517
            value={settings.displayName}
1518
            onChange={(event) =>
1519
              setSettings((value) => ({ ...value, displayName: event.target.value }))
1520
            }
1521
            placeholder="Team or operator name"
1522
          />
1523
        </Setting>
1524
        <Setting
1525
          label="Network contribution"
1526
          description="Allow eligible structured aggregates to improve contextual intelligence. Raw conversations are never included."
1527
        >
1528
          <Toggle
1529
            checked={settings.contributionEnabled}
1530
            label="Network contribution"
1531
            onChange={(checked) =>
1532
              setSettings((value) => ({ ...value, contributionEnabled: checked }))
1533
            }
1534
          />
1535
        </Setting>
1536
        <Setting
1537
          label="Evidence sharing"
1538
          description="Controls when permitted evidence references may be shared with interaction participants."
1539
        >
1540
          <select
1541
            value={settings.evidenceSharing}
1542
            onChange={(event) =>
1543
              setSettings((value) => ({
1544
                ...value,
1545
                evidenceSharing: event.target.value as Settings['evidenceSharing'],
1546
              }))
1547
            }
1548
          >
1549
            <option value="never">Never</option>
1550
            <option value="ask">Ask every time</option>
1551
            <option value="contract_only">When contract permits</option>
1552
          </select>
1553
        </Setting>
1554
        <Setting
1555
          label="Structured record retention"
1556
          description="Retention for hosted account records. Signed protocol records may require separate revocation markers."
1557
        >
1558
          <select
1559
            value={settings.retentionDays}
1560
            onChange={(event) =>
1561
              setSettings((value) => ({ ...value, retentionDays: Number(event.target.value) }))
1562
            }
1563
          >
1564
            <option value={7}>7 days</option>
1565
            <option value={30}>30 days</option>
1566
            <option value={90}>90 days</option>
1567
            <option value={365}>1 year</option>
1568
            <option value={0}>No automatic deletion</option>
1569
          </select>
1570
        </Setting>
1571
        <Setting
1572
          label="Raw agent messages"
1573
          description="Persistent runtimes remain direct and are not stored. Temporary chat messages pass through OpenClasp and are encrypted at rest for 30 days."
1574
        >
1575
          <span className="locked">MODE DEPENDENT</span>
1576
        </Setting>
1577
        <div className="saveRow">
1578
          <button className="primary" type="button" onClick={() => void save()} disabled={saving}>
1579
            {saving ? 'Saving…' : 'Save settings'}
1580
          </button>
1581
          {saved && <span role="status">Saved</span>}
1582
          {error && (
1583
            <span className="loginError" role="alert">
1584
              {error}
1585
            </span>
1586
          )}
1587
        </div>
1588
      </section>
1589
    </>
1590
  );
1591
}
1592
1593
function PageHead({
1594
  page,
1595
  action,
1596
  onAction,
1597
}: {
1598
  page: Page;
1599
  action?: string;
1600
  onAction?: () => void;
1601
}) {
1602
  const meta = pageMeta[page];
1603
  return (
1604
    <header className="pageHead">
1605
      <div>
1606
        <p className="eyebrow">{meta.eyebrow}</p>
1607
        <h1>{meta.title}</h1>
1608
        <p className="lede">{meta.lede}</p>
1609
      </div>
1610
      {action && (
1611
        <button className="primary" type="button" onClick={onAction}>
1612
          {action}
1613
        </button>
1614
      )}
1615
    </header>
1616
  );
1617
}
1618
function Nav({
1619
  page,
1620
  active,
1621
  onClick,
1622
  label,
1623
  icon,
1624
  badge = 0,
1625
}: {
1626
  page: Page;
1627
  active: Page;
1628
  onClick: (page: Page) => void;
1629
  label: string;
1630
  icon: IconName;
1631
  badge?: number;
1632
}) {
1633
  return (
1634
    <button
1635
      className={active === page ? 'active' : ''}
1636
      type="button"
1637
      aria-current={active === page ? 'page' : undefined}
1638
      onClick={() => onClick(page)}
1639
    >
1640
      <Icon name={icon} />
1641
      {label}
1642
      {badge > 0 ? <span className="navBadge">{badge}</span> : null}
1643
    </button>
1644
  );
1645
}
1646
function Metric({
1647
  label,
1648
  value,
1649
  note,
1650
  warn,
1651
}: {
1652
  label: string;
1653
  value: number;
1654
  note: string;
1655
  warn?: boolean;
1656
}) {
1657
  return (
1658
    <article className={warn ? 'metric warn' : 'metric'}>
1659
      <span>{label}</span>
1660
      <strong>{value}</strong>
1661
      <small>{note}</small>
1662
    </article>
1663
  );
1664
}
1665
function Panel({
1666
  title,
1667
  subtitle,
1668
  children,
1669
}: React.PropsWithChildren<{ title: string; subtitle: string }>) {
1670
  return (
1671
    <section className="panel">
1672
      <div className="panelHead">
1673
        <div>
1674
          <h2>{title}</h2>
1675
          <p>{subtitle}</p>
1676
        </div>
1677
      </div>
1678
      {children}
1679
    </section>
1680
  );
1681
}
1682
function Timeline({ events }: { events: Record<string, any>[] }) {
1683
  return events.length ? (
1684
    <div className="timeline">
1685
      {events.map((event) => (
1686
        <HistoryRow key={event.eventId} item={{ ...event, _kind: 'event' }} />
1687
      ))}
1688
    </div>
1689
  ) : (
1690
    <Empty
1691
      title="Nothing recorded yet"
1692
      text="Activity appears after your connected agents submit signed structured events."
1693
    />
1694
  );
1695
}
1696
function HistoryRow({ item }: { item: Record<string, any> }) {
1697
  const kind = String(item.eventType ?? item._kind ?? 'record').replaceAll('_', ' ');
1698
  const id = String(item.agentId ?? item.interactionId ?? item.receiptId ?? 'OpenClasp');
1699
  return (
1700
    <article className="historyRow">
1701
      <span className={`eventDot ${item.eventType ?? item._kind}`} />
1702
      <div>
1703
        <strong>{kind}</strong>
1704
        <small>
1705
          {id} · {new Date(timestamp(item)).toLocaleString()}
1706
        </small>
1707
      </div>
1708
      <b>{item.visibility ?? item.outcome ?? item.status ?? 'SIGNED'}</b>
1709
    </article>
1710
  );
1711
}
1712
function AgentCard({
1713
  agent,
1714
  projectName,
1715
  published,
1716
  working,
1717
  onSave,
1718
  runtime,
1719
  runtimeWorking,
1720
  deleteWorking,
1721
  onRuntime,
1722
  onDisableRuntime,
1723
  accessTokens,
1724
  accessTokenWorking,
1725
  onCreateAccessToken,
1726
  onRevokeAccessToken,
1727
  onDelete,
1728
}: {
1729
  agent: Record<string, any>;
1730
  projectName?: string;
1731
  published: boolean;
1732
  working: boolean;
1733
  onSave: (value: {
1734
    autoPublish: boolean;
1735
    autoAcceptPolicy: 'off' | 'safe_matching';
1736
    autoAcceptTaskCategories: string[];
1737
  }) => Promise<boolean>;
1738
  runtime: Record<string, any> | undefined;
1739
  runtimeWorking: boolean;
1740
  deleteWorking: boolean;
1741
  onRuntime: (endpoint: string) => void;
1742
  onDisableRuntime: () => void;
1743
  accessTokens: Record<string, any>[];
1744
  accessTokenWorking: boolean;
1745
  onCreateAccessToken: (name: string, expiresInDays: number) => Promise<string | undefined>;
1746
  onRevokeAccessToken: (tokenId: string) => Promise<void>;
1747
  onDelete: () => void;
1748
}) {
1749
  const [editing, setEditing] = useState(false);
1750
  const [autoPublish, setAutoPublish] = useState(Boolean(agent.autoPublish ?? published));
1751
  const [autoAcceptPolicy, setAutoAcceptPolicy] = useState<'off' | 'safe_matching'>(
1752
    agent.autoAcceptPolicy === 'safe_matching' ? 'safe_matching' : 'off',
1753
  );
1754
  const [categories, setCategories] = useState<string>(
1755
    (agent.autoAcceptTaskCategories?.length
1756
      ? agent.autoAcceptTaskCategories
1757
      : (agent.capabilities ?? [])
1758
    ).join(', '),
1759
  );
1760
  const [runtimeEndpoint, setRuntimeEndpoint] = useState(String(runtime?.endpoint ?? ''));
1761
  const [tokenName, setTokenName] = useState('Botpress');
1762
  const [tokenLifetime, setTokenLifetime] = useState(365);
1763
  const [generatedToken, setGeneratedToken] = useState('');
1764
  const [tokenCopied, setTokenCopied] = useState(false);
1765
  const mode = agent.agentMode ?? (agent.a2aEndpoint ? 'persistent_runtime' : 'temporary_chat');
1766
  const temporary = mode === 'temporary_chat';
1767
  const ready = published && (temporary || runtime?.status === 'verified');
1768
  const online = agent.presence?.status === 'online';
1769
  const endpoint = String(
1770
    temporary
1771
      ? `/a2a/temporary/${encodeURIComponent(agent.agentId)}`
1772
      : (runtime?.a2aEndpoint ?? agent.a2aEndpoint ?? 'Connect a runtime first'),
1773
  );
1774
  const identityLabel = agent.revoked
1775
    ? 'REVOKED'
1776
    : agent.identityMode === 'oauth_installation'
1777
      ? 'AUTHENTICATED'
1778
      : 'VERIFIED';
1779
  return (
1780
    <article className="agentCard">
1781
      <div className="agentTop">
1782
        <span className="agentGlyph">
1783
          <Icon name="agents" />
1784
        </span>
1785
        <div className="agentBadges">
1786
          <b className={online && temporary ? 'onlineBadge' : 'offlineBadge'}>
1787
            {temporary
1788
              ? online
1789
                ? 'CHAT ACTIVE'
1790
                : 'CHAT IDLE'
1791
              : runtime?.status === 'verified'
1792
                ? 'ENDPOINT VERIFIED'
1793
                : 'ENDPOINT MISSING'}
1794
          </b>
1795
          <b className={agent.revoked ? 'bad' : ''}>{identityLabel}</b>
1796
          <b className={ready ? 'readyBadge' : 'needsBadge'}>{ready ? 'READY' : 'SETUP NEEDED'}</b>
1797
        </div>
1798
      </div>
1799
      <h2>{agent.name ?? agent.agentId}</h2>
1800
      <p>{projectName ?? `Version ${agent.agentVersion ?? '1.0.0'}`}</p>
1801
      <div className="tags">
1802
        {(agent.capabilities ?? []).slice(0, 4).map((capability: string) => (
1803
          <span key={capability}>{capability}</span>
1804
        ))}
1805
      </div>
1806
      <small>
1807
        {agent.framework ? `${agent.framework} · ` : ''}
1808
        {agent.identityMode === 'oauth_installation' ? 'OAuth-bound · ' : 'Ed25519 · '}Created{' '}
1809
        {new Date(agent.createdAt).toLocaleDateString()}
1810
      </small>
1811
      <small>
1812
        {agent.presence?.lastSeenAt
1813
          ? `Last agent activity ${new Date(agent.presence.lastSeenAt).toLocaleString()}`
1814
          : 'No agent activity recorded yet'}
1815
      </small>
1816
      {published ? (
1817
        <a
1818
          href={`/agents/${encodeURIComponent(agent.agentId)}/card.json`}
1819
          target="_blank"
1820
          rel="noreferrer"
1821
        >
1822
          Public Agent Card
1823
        </a>
1824
      ) : null}
1825
      <div className="automationSummary">
1826
        <span>{published ? 'Public discovery' : 'Private'}</span>
1827
        <span>{temporary ? 'Hosted temporary A2A' : 'Direct agent-owned A2A'}</span>
1828
        <span>
1829
          {agent.autoAcceptPolicy === 'safe_matching' ? 'Safe tasks automatic' : 'Manual approval'}
1830
        </span>
1831
      </div>
1832
      <div className="runtimeBox">
1833
        <div>
1834
          <strong>{temporary ? 'Temporary chat endpoint' : 'Autonomous runtime'}</strong>
1835
          <span
1836
            className={
1837
              temporary || runtime?.status === 'verified' ? 'runtimeLive' : 'runtimeMissing'
1838
            }
1839
          >
1840
            {temporary
1841
              ? 'HOSTED BY OPENCLASP'
1842
              : runtime?.status === 'verified'
1843
                ? 'VERIFIED'
1844
                : 'NOT CONNECTED'}
1845
          </span>
1846
        </div>
1847
        <p>
1848
          {temporary
1849
            ? 'OpenClasp receives A2A for this temporary identity and encrypts its history at rest. Connect a runtime below to switch to direct A2A.'
1850
            : 'Connect the worker running on your cloud. OpenClasp prepares the live session, then messages travel directly between agents.'}
1851
        </p>
1852
        <input
1853
          type="url"
1854
          value={runtimeEndpoint}
1855
          onChange={(event) => setRuntimeEndpoint(event.target.value)}
1856
          placeholder="https://agent.example.com/openclasp"
1857
        />
1858
        <div className="agentActions">
1859
          {runtime?.status === 'verified' ? (
1860
            <button
1861
              className="secondary"
1862
              type="button"
1863
              disabled={runtimeWorking}
1864
              onClick={onDisableRuntime}
1865
            >
1866
              Disable runtime
1867
            </button>
1868
          ) : null}
1869
          <button
1870
            className="primary"
1871
            type="button"
1872
            disabled={runtimeWorking || !runtimeEndpoint.trim()}
1873
            onClick={() => onRuntime(runtimeEndpoint.trim())}
1874
          >
1875
            {runtimeWorking ? 'Verifying…' : runtime ? 'Rotate connection' : 'Verify & connect'}
1876
          </button>
1877
        </div>
1878
        {runtime?.lastError ? <small>Last session error: {runtime.lastError}</small> : null}
1879
      </div>
1880
      <div className="accessTokenBox">
1881
        <div className="accessTokenHead">
1882
          <div>
1883
            <strong>Hosted-provider MCP access</strong>
1884
            <span>{accessTokens.length} ACTIVE</span>
1885
          </div>
1886
          <p>
1887
            Use this when Botpress or another provider accepts a static Bearer token instead of
1888
            OAuth. Each token is locked to this agent.
1889
          </p>
1890
        </div>
1891
        {generatedToken ? (
1892
          <div className="generatedToken" role="status">
1893
            <strong>Copy this token now. It will not be shown again.</strong>
1894
            <code>{generatedToken}</code>
1895
            <div className="agentActions">
1896
              <button
1897
                className="secondary"
1898
                type="button"
1899
                onClick={() => {
1900
                  void navigator.clipboard.writeText(generatedToken);
1901
                  setTokenCopied(true);
1902
                  window.setTimeout(() => setTokenCopied(false), 2000);
1903
                }}
1904
              >
1905
                {tokenCopied ? 'Copied' : 'Copy token'}
1906
              </button>
1907
              <button className="primary" type="button" onClick={() => setGeneratedToken('')}>
1908
                Done
1909
              </button>
1910
            </div>
1911
          </div>
1912
        ) : (
1913
          <div className="tokenCreateRow">
1914
            <label>
1915
              <span>Connection name</span>
1916
              <input
1917
                value={tokenName}
1918
                maxLength={100}
1919
                onChange={(event) => setTokenName(event.target.value)}
1920
                placeholder="Botpress"
1921
              />
1922
            </label>
1923
            <label>
1924
              <span>Expires</span>
1925
              <select
1926
                value={tokenLifetime}
1927
                onChange={(event) => setTokenLifetime(Number(event.target.value))}
1928
              >
1929
                <option value={30}>30 days</option>
1930
                <option value={90}>90 days</option>
1931
                <option value={365}>1 year</option>
1932
              </select>
1933
            </label>
1934
            <button
1935
              className="primary"
1936
              type="button"
1937
              disabled={accessTokenWorking || !tokenName.trim() || agent.status === 'revoked'}
1938
              onClick={() =>
1939
                void onCreateAccessToken(tokenName.trim(), tokenLifetime).then((token) => {
1940
                  if (token) setGeneratedToken(token);
1941
                })
1942
              }
1943
            >
1944
              {accessTokenWorking ? 'Creating…' : 'Generate token'}
1945
            </button>
1946
          </div>
1947
        )}
1948
        {accessTokens.length ? (
1949
          <div className="tokenList">
1950
            {accessTokens.map((token) => (
1951
              <div key={token.tokenId}>
1952
                <span>
1953
                  <strong>{token.name}</strong>
1954
                  <small>
1955
                    Created {new Date(token.createdAt).toLocaleDateString()} · expires{' '}
1956
                    {new Date(token.expiresAt).toLocaleDateString()}
1957
                    {token.lastUsedAt
1958
                      ? ` · used ${new Date(token.lastUsedAt).toLocaleString()}`
1959
                      : ' · never used'}
1960
                  </small>
1961
                </span>
1962
                <button
1963
                  className="secondary dangerButton"
1964
                  type="button"
1965
                  disabled={accessTokenWorking}
1966
                  onClick={() => {
1967
                    if (window.confirm(`Revoke “${String(token.name)}”? Botpress will disconnect.`))
1968
                      void onRevokeAccessToken(String(token.tokenId));
1969
                  }}
1970
                >
1971
                  Revoke
1972
                </button>
1973
              </div>
1974
            ))}
1975
          </div>
1976
        ) : null}
1977
      </div>
1978
      {editing ? (
1979
        <div className="automationForm">
1980
          <label>
1981
            <span>
1982
              {temporary ? 'OpenClasp temporary A2A endpoint' : 'Agent-owned A2A endpoint'}
1983
            </span>
1984
            <input type="url" value={endpoint} readOnly />
1985
          </label>
1986
          <label>
1987
            <span>Invitation policy</span>
1988
            <select
1989
              value={autoAcceptPolicy}
1990
              onChange={(event) =>
1991
                setAutoAcceptPolicy(event.target.value as 'off' | 'safe_matching')
1992
              }
1993
            >
1994
              <option value="safe_matching">Auto-accept safe matches</option>
1995
              <option value="off">Review every request</option>
1996
            </select>
1997
          </label>
1998
          <label>
1999
            <span>Safe task categories</span>
2000
            <input
2001
              value={categories}
2002
              onChange={(event) => setCategories(event.target.value)}
2003
              placeholder="research, planning, coding"
2004
            />
2005
          </label>
2006
          <label className="checkControl">
2007
            <input
2008
              type="checkbox"
2009
              checked={autoPublish}
2010
              onChange={(event) => setAutoPublish(event.target.checked)}
2011
            />
2012
            <span>Publish and keep Agent Card updated</span>
2013
          </label>
2014
          <div className="agentActions">
2015
            <button className="secondary" type="button" onClick={() => setEditing(false)}>
2016
              Cancel
2017
            </button>
2018
            <button
2019
              className="primary"
2020
              type="button"
2021
              disabled={working || agent.status === 'revoked'}
2022
              onClick={() =>
2023
                void onSave({
2024
                  autoPublish,
2025
                  autoAcceptPolicy,
2026
                  autoAcceptTaskCategories: categories
2027
                    .split(',')
2028
                    .map((value) => value.trim())
2029
                    .filter(Boolean),
2030
                }).then((ok) => {
2031
                  if (ok) setEditing(false);
2032
                })
2033
              }
2034
            >
2035
              {working ? 'Saving…' : 'Save automation'}
2036
            </button>
2037
          </div>
2038
        </div>
2039
      ) : (
2040
        <div className="agentActions agentManagementActions">
2041
          <button
2042
            className="secondary dangerButton"
2043
            type="button"
2044
            disabled={working || runtimeWorking || deleteWorking}
2045
            onClick={onDelete}
2046
          >
2047
            {deleteWorking ? 'Deleting…' : 'Delete agent'}
2048
          </button>
2049
          <button
2050
            className="secondary"
2051
            type="button"
2052
            disabled={working || runtimeWorking || deleteWorking || agent.status === 'revoked'}
2053
            onClick={() => setEditing(true)}
2054
          >
2055
            Configure automation
2056
          </button>
2057
        </div>
2058
      )}
2059
    </article>
2060
  );
2061
}
2062
function Profile({ profile, expanded }: { profile: Record<string, any>; expanded?: boolean }) {
2063
  const dimensions = [
2064
    ['Completion', profile.completion],
2065
    ['Scope', profile.scope],
2066
    ['Evidence', profile.evidence],
2067
    ...(expanded
2068
      ? [
2069
          ['Communication', profile.communication],
2070
          ['Deadline', profile.deadline],
2071
        ]
2072
      : []),
2073
  ] as [string, number][];
2074
  return (
2075
    <article className="profile">
2076
      <div>
2077
        <strong>{profile.agentId}</strong>
2078
        <small>
2079
          {profile.taskCategory} · {profile.sampleSize} outcome(s)
2080
        </small>
2081
      </div>
2082
      {dimensions.map(([label, value]) => (
2083
        <Meter key={label} label={label} value={value ?? 0} />
2084
      ))}
2085
    </article>
2086
  );
2087
}
2088
function Meter({ label, value }: { label: string; value: number }) {
2089
  return (
2090
    <div className="meter">
2091
      <span>{label}</span>
2092
      <i>
2093
        <b style={{ width: `${Math.round(value * 100)}%` }} />
2094
      </i>
2095
      <strong>{Math.round(value * 100)}%</strong>
2096
    </div>
2097
  );
2098
}
2099
function Empty({
2100
  title,
2101
  text,
2102
  action,
2103
  onAction,
2104
}: {
2105
  title: string;
2106
  text: string;
2107
  action?: string;
2108
  onAction?: () => void;
2109
}) {
2110
  return (
2111
    <div className="empty">
2112
      <Icon name="agents" />
2113
      <strong>{title}</strong>
2114
      <p>{text}</p>
2115
      {action && (
2116
        <button className="secondary" type="button" onClick={onAction}>
2117
          {action}
2118
        </button>
2119
      )}
2120
    </div>
2121
  );
2122
}
2123
function TextButton({ children, onClick }: React.PropsWithChildren<{ onClick: () => void }>) {
2124
  return (
2125
    <button className="textButton" type="button" onClick={onClick}>
2126
      {children}
2127
    </button>
2128
  );
2129
}
2130
function Setting({
2131
  label,
2132
  description,
2133
  children,
2134
}: React.PropsWithChildren<{ label: string; description: string }>) {
2135
  return (
2136
    <div className="setting">
2137
      <div>
2138
        <strong>{label}</strong>
2139
        <p>{description}</p>
2140
      </div>
2141
      <div>{children}</div>
2142
    </div>
2143
  );
2144
}
2145
function Toggle({
2146
  checked,
2147
  onChange,
2148
  label,
2149
}: {
2150
  checked: boolean;
2151
  onChange: (checked: boolean) => void;
2152
  label?: string;
2153
}) {
2154
  return (
2155
    <button
2156
      className={checked ? 'toggle on' : 'toggle'}
2157
      type="button"
2158
      role="switch"
2159
      aria-checked={checked}
2160
      aria-label={label}
2161
      onClick={() => onChange(!checked)}
2162
    >
2163
      <span />
2164
    </button>
2165
  );
2166
}
2167
function Loading({ compact }: { compact?: boolean }) {
2168
  return (
2169
    <div className={compact ? 'loading compact' : 'loading'}>
2170
      <span className="spinner" aria-hidden="true" />
2171
      <p>Verifying session…</p>
2172
    </div>
2173
  );
2174
}
2175
2176
type IconName =
2177
  'home' | 'history' | 'agents' | 'insights' | 'connect' | 'settings' | 'sun' | 'moon';
2178
2179
function Icon({ name }: { name: IconName }) {
2180
  return (
2181
    <svg
2182
      viewBox="0 0 24 24"
2183
      fill="none"
2184
      stroke="currentColor"
2185
      strokeWidth="1.8"
2186
      strokeLinecap="round"
2187
      strokeLinejoin="round"
2188
      aria-hidden="true"
2189
    >
2190
      {name === 'home' && <path d="M4 11 12 4l8 7v9a1 1 0 0 1-1 1h-5v-6H10v6H5a1 1 0 0 1-1-1z" />}
2191
      {name === 'history' && (
2192
        <>
2193
          <path d="M4 12a8 8 0 1 0 2.2-5.5" />
2194
          <path d="M4 4v5h5" />
2195
          <path d="M12 8v5l3 2" />
2196
        </>
2197
      )}
2198
      {name === 'agents' && (
2199
        <>
2200
          <path d="M8 11a3 3 0 1 0 0-6 3 3 0 0 0 0 6" />
2201
          <path d="M16 11a3 3 0 1 0 0-6 3 3 0 0 0 0 6" />
2202
          <path d="M4 20v-1a4 4 0 0 1 4-4h1" />
2203
          <path d="M20 20v-1a4 4 0 0 0-4-4h-1" />
2204
        </>
2205
      )}
2206
      {name === 'insights' && (
2207
        <>
2208
          <path d="M4 19h16" />
2209
          <path d="M7 16V9" />
2210
          <path d="M12 16V5" />
2211
          <path d="M17 16v-6" />
2212
        </>
2213
      )}
2214
      {name === 'connect' && (
2215
        <>
2216
          <path d="M12 5v14" />
2217
          <path d="M5 12h14" />
2218
        </>
2219
      )}
2220
      {name === 'settings' && (
2221
        <>
2222
          <circle cx="12" cy="12" r="3" />
2223
          <path d="M12 3v2M12 19v2M3 12h2M19 12h2M5.6 5.6l1.4 1.4M16.9 16.9l1.5 1.5M5.6 18.4l1.4-1.4M16.9 7.1l1.5-1.5" />
2224
        </>
2225
      )}
2226
      {name === 'sun' && (
2227
        <>
2228
          <circle cx="12" cy="12" r="4" />
2229
          <path d="M12 3v2M12 19v2M4.2 4.2l1.4 1.4M18.4 18.4l1.4 1.4M3 12h2M19 12h2M4.2 19.8l1.4-1.4M18.4 5.6l1.4-1.4" />
2230
        </>
2231
      )}
2232
      {name === 'moon' && <path d="M16 13a6 6 0 0 1-7.8-7.8A7 7 0 1 0 16 13" />}
2233
    </svg>
2234
  );
2235
}
2236
2237
function GoogleMark() {
2238
  return (
2239
    <svg viewBox="0 0 24 24" aria-hidden="true">
2240
      <path
2241
        fill="#4285F4"
2242
        d="M22.6 12.2c0-.8-.1-1.6-.2-2.3H12v4.4h5.9c-.3 1.4-1 2.6-2.2 3.4v2.8h3.6c2.1-1.9 3.3-4.8 3.3-8.3"
2243
      />
2244
      <path
2245
        fill="#34A853"
2246
        d="M12 23c3 0 5.5-1 7.3-2.7l-3.6-2.8c-1 .7-2.3 1.1-3.7 1.1-2.8 0-5.2-1.9-6.1-4.4H2.2v2.9C4 20.5 7.7 23 12 23"
2247
      />
2248
      <path
2249
        fill="#FBBC05"
2250
        d="M5.9 14.2c-.2-.7-.4-1.4-.4-2.2s.1-1.5.4-2.2V7H2.2C1.4 8.6 1 10.3 1 12s.4 3.4 1.2 5z"
2251
      />
2252
      <path
2253
        fill="#EA4335"
2254
        d="M12 4.8c1.6 0 3.1.6 4.2 1.7l3.2-3.2C17.5 1.5 15 0.5 12 .5 7.7.5 4 3 2.2 7l3.7 2.9C6.8 6.7 9.2 4.8 12 4.8"
2255
      />
2256
    </svg>
2257
  );
2258
}
2259
2260
function GitHubMark() {
2261
  return (
2262
    <svg viewBox="0 0 24 24" aria-hidden="true">
2263
      <path
2264
        fill="currentColor"
2265
        d="M12 2a10 10 0 0 0-3.2 19.5c.5.1.7-.2.7-.5v-1.7c-2.8.6-3.4-1.2-3.4-1.2-.5-1.1-1.1-1.4-1.1-1.4-.9-.6.1-.6.1-.6 1 .1 1.5 1 1.5 1 .9 1.5 2.4 1.1 3 .8.1-.6.4-1.1.6-1.3-2.2-.3-4.6-1.1-4.6-5a3.9 3.9 0 0 1 1-2.7 3.6 3.6 0 0 1 .1-2.6s.8-.3 2.7 1a9.3 9.3 0 0 1 5 0c1.9-1.3 2.7-1 2.7-1a3.6 3.6 0 0 1 .1 2.6 3.9 3.9 0 0 1 1 2.7c0 3.9-2.3 4.7-4.6 5 .4.3.7.9.7 1.9v2.8c0 .3.2.6.7.5A10 10 0 0 0 12 2"
2266
      />
2267
    </svg>
2268
  );
2269
}
2270
2271
function initials(value: string) {
2272
  return value
2273
    .split(/\s+|@/)
2274
    .slice(0, 2)
2275
    .map((part) => part[0]?.toUpperCase())
2276
    .join('');
2277
}
2278
function timestamp(item: Record<string, any>) {
2279
  return String(item.timestamp ?? item.completedAt ?? item.createdAt ?? new Date(0).toISOString());
2280
}
2281
2282
if (!__AUTH0_DOMAIN__ || !__AUTH0_CLIENT_ID__ || !__AUTH0_AUDIENCE__)
2283
  throw new Error('Auth0 public configuration is incomplete');
2284
createRoot(document.getElementById('root')!).render(
2285
  <React.StrictMode>
2286
    {location.pathname === '/sso-callback' ? <AuthCallback /> : <App />}
2287
  </React.StrictMode>,
2288
);