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

Viewing files at commit 18b70a0f11a5Commit detailsView latest

apps/dashboard/src/main.tsx 3039 lines · 104.6 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
  counterpartyBriefs: Record<string, any>[];
41
  completionReports: Record<string, any>[];
42
  feedbackRequests: Record<string, any>[];
43
  interactionFeedback: Record<string, any>[];
44
  interactionConclusions: Record<string, any>[];
45
  learningEligibility: Record<string, any>[];
46
  profileDeltas: Record<string, any>[];
47
  runtimes: Record<string, any>[];
48
  accessTokens: Record<string, any>[];
49
};
50
51
type Settings = {
52
  displayName: string;
53
  contributionEnabled: boolean;
54
  retentionDays: number;
55
  evidenceSharing: 'never' | 'ask' | 'contract_only';
56
  rawConversationsStored: false;
57
};
58
59
const emptyData: DashboardData = {
60
  agents: [],
61
  projects: [],
62
  installations: [],
63
  setupRequests: [],
64
  publications: [],
65
  interactions: [],
66
  federatedInteractions: [],
67
  liveSessions: [],
68
  hostedThreads: [],
69
  events: [],
70
  conflicts: [],
71
  receipts: [],
72
  profiles: [],
73
  counterpartyBriefs: [],
74
  completionReports: [],
75
  feedbackRequests: [],
76
  interactionFeedback: [],
77
  interactionConclusions: [],
78
  learningEligibility: [],
79
  profileDeltas: [],
80
  runtimes: [],
81
  accessTokens: [],
82
};
83
const defaultSettings: Settings = {
84
  displayName: '',
85
  contributionEnabled: false,
86
  retentionDays: 30,
87
  evidenceSharing: 'ask',
88
  rawConversationsStored: false,
89
};
90
91
function normalizeDashboard(value: unknown): DashboardData {
92
  return { ...emptyData, ...(value as Partial<DashboardData>) };
93
}
94
const pages = [
95
  'dashboard',
96
  'conversations',
97
  'history',
98
  'agents',
99
  'insights',
100
  'connect',
101
  'settings',
102
] as const;
103
type Page = (typeof pages)[number];
104
const pageMeta: Record<Page, { label: string; title: string; lede: string; eyebrow: string }> = {
105
  dashboard: {
106
    label: 'Overview',
107
    title: 'Overview',
108
    lede: 'Agent readiness, signed outcomes, and anything waiting on you.',
109
    eyebrow: 'Network',
110
  },
111
  history: {
112
    label: 'History',
113
    title: 'History',
114
    lede: 'Structured events, receipts, and shared contracts — never raw messages.',
115
    eyebrow: 'Audit',
116
  },
117
  conversations: {
118
    label: 'Conversations',
119
    title: 'Temporary conversations',
120
    lede: 'Hosted history for temporary chat identities. Direct runtime messages never appear here.',
121
    eyebrow: 'Inbox',
122
  },
123
  agents: {
124
    label: 'Agents',
125
    title: 'Agents',
126
    lede: 'Identities, runtimes, and the automation each agent is allowed to do.',
127
    eyebrow: 'Registry',
128
  },
129
  insights: {
130
    label: 'Insights',
131
    title: 'Insights',
132
    lede: 'Task-specific reliability from signed outcomes. No universal score.',
133
    eyebrow: 'Context',
134
  },
135
  connect: {
136
    label: 'Connect',
137
    title: 'Connect',
138
    lede: 'Add an agent once. Approve its identity, then OpenClasp handles the routine work.',
139
    eyebrow: 'Setup',
140
  },
141
  settings: {
142
    label: 'Settings',
143
    title: 'Settings',
144
    lede: 'Privacy, retention, and what this account may contribute to the network.',
145
    eyebrow: 'Account',
146
  },
147
};
148
149
function base64Url(bytes: Uint8Array) {
150
  let binary = '';
151
  for (const byte of bytes) binary += String.fromCharCode(byte);
152
  return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
153
}
154
155
function randomValue(size = 32) {
156
  return base64Url(crypto.getRandomValues(new Uint8Array(size)));
157
}
158
159
async function beginAuth(provider: 'google' | 'github') {
160
  const verifier = randomValue(48);
161
  const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
162
  const transaction: AuthTransaction = { state: randomValue(), nonce: randomValue(), verifier };
163
  sessionStorage.setItem(authTransactionKey, JSON.stringify(transaction));
164
  const parameters = new URLSearchParams({
165
    client_id: __AUTH0_CLIENT_ID__,
166
    redirect_uri: `${location.origin}/sso-callback`,
167
    response_type: 'code',
168
    scope: 'openid profile email',
169
    audience: __AUTH0_AUDIENCE__,
170
    state: transaction.state,
171
    nonce: transaction.nonce,
172
    code_challenge: base64Url(new Uint8Array(digest)),
173
    code_challenge_method: 'S256',
174
    connection: provider === 'google' ? 'google-oauth2' : 'github',
175
  });
176
  location.assign(`https://${__AUTH0_DOMAIN__}/authorize?${parameters}`);
177
}
178
179
async function signOut(preview: boolean) {
180
  if (preview) {
181
    disablePreview();
182
    location.replace('/login');
183
    return;
184
  }
185
  await fetch('/api/session', { method: 'DELETE', credentials: 'same-origin' });
186
  const parameters = new URLSearchParams({
187
    client_id: __AUTH0_CLIENT_ID__,
188
    returnTo: `${location.origin}/login`,
189
  });
190
  location.assign(`https://${__AUTH0_DOMAIN__}/v2/logout?${parameters}`);
191
}
192
193
async function remoteApi(path: string, init?: RequestInit) {
194
  const headers = new Headers(init?.headers);
195
  if (init?.body && !headers.has('content-type')) headers.set('content-type', 'application/json');
196
  return fetch(path, {
197
    ...init,
198
    credentials: 'same-origin',
199
    headers,
200
  }).then(async (response) => {
201
    if (!response.ok)
202
      throw new Error((await response.text()) || `Request failed: ${response.status}`);
203
    return response.json();
204
  });
205
}
206
207
function route(): Page {
208
  const value = location.pathname.slice(1) || 'dashboard';
209
  return pages.includes(value as Page) ? (value as Page) : 'dashboard';
210
}
211
212
function initialTheme(): Theme {
213
  const saved = localStorage.getItem(themeKey);
214
  if (saved === 'dark' || saved === 'light') return saved;
215
  return matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
216
}
217
218
function App() {
219
  const [session, setSession] = useState<AuthSession | null>();
220
  const [preview, setPreview] = useState(false);
221
  const [theme, setTheme] = useState<Theme>(initialTheme);
222
  const [page, setPage] = useState<Page>(route());
223
  const [data, setData] = useState<DashboardData>(emptyData);
224
  const [settings, setSettings] = useState<Settings>(defaultSettings);
225
  const [loading, setLoading] = useState(true);
226
  const [error, setError] = useState('');
227
  const api = useCallback(
228
    async (path: string, init?: RequestInit) => {
229
      if (preview) {
230
        const next = applyPreviewRequest(data, settings, path, init);
231
        setData(next.data);
232
        setSettings(next.settings);
233
        return next.result;
234
      }
235
      return remoteApi(path, init);
236
    },
237
    [data, preview, settings],
238
  );
239
  const refreshDashboard = useCallback(async () => {
240
    if (preview) return;
241
    setData(normalizeDashboard(await remoteApi('/v0.1/dashboard')));
242
  }, [preview]);
243
244
  useEffect(() => {
245
    document.title = session
246
      ? `${pageMeta[page].label} · OpenClasp`
247
      : session === null
248
        ? 'Sign in · OpenClasp'
249
        : 'OpenClasp';
250
  }, [page, session]);
251
252
  useEffect(() => {
253
    let cancelled = false;
254
    const boot = async () => {
255
      if (import.meta.env.DEV && isPreviewActive()) {
256
        if (cancelled) return;
257
        setPreview(true);
258
        setSession(previewSession);
259
        setData(createPreviewData());
260
        setSettings(defaultPreviewSettings);
261
        setLoading(false);
262
        return;
263
      }
264
      try {
265
        const response = await fetch('/api/session', { credentials: 'same-origin' });
266
        if (!response.ok) {
267
          if (!cancelled && !(import.meta.env.DEV && isPreviewActive())) setSession(null);
268
          return;
269
        }
270
        if (!cancelled && !(import.meta.env.DEV && isPreviewActive())) {
271
          setSession((await response.json()) as AuthSession);
272
        }
273
      } catch {
274
        if (!cancelled && !(import.meta.env.DEV && isPreviewActive())) setSession(null);
275
      }
276
    };
277
    void boot();
278
    return () => {
279
      cancelled = true;
280
    };
281
  }, []);
282
283
  useEffect(() => {
284
    document.documentElement.dataset.theme = theme;
285
    localStorage.setItem(themeKey, theme);
286
    const meta = document.querySelector('meta[name="theme-color"]');
287
    if (meta) meta.setAttribute('content', theme === 'light' ? '#f4f6f1' : '#0b0d0b');
288
  }, [theme]);
289
290
  useEffect(() => {
291
    const onPopState = () => setPage(route());
292
    addEventListener('popstate', onPopState);
293
    return () => removeEventListener('popstate', onPopState);
294
  }, []);
295
296
  useEffect(() => {
297
    if (session === undefined) return;
298
    if (!session && location.pathname !== '/login') history.replaceState({}, '', '/login');
299
    if (!session) return;
300
    if (location.pathname === '/login') history.replaceState({}, '', '/dashboard');
301
    setPage(route());
302
    if (preview) {
303
      setLoading(false);
304
      return;
305
    }
306
    Promise.all([remoteApi('/v0.1/dashboard'), remoteApi('/v0.1/settings')])
307
      .then(([dashboard, accountSettings]) => {
308
        setData(normalizeDashboard(dashboard));
309
        setSettings(accountSettings as Settings);
310
      })
311
      .catch((reason: unknown) =>
312
        setError(reason instanceof Error ? reason.message : 'Load failed'),
313
      )
314
      .finally(() => setLoading(false));
315
  }, [preview, session]);
316
317
  useEffect(() => {
318
    if (!session || preview) return;
319
    const refresh = () => {
320
      if (document.visibilityState === 'visible') void refreshDashboard().catch(() => undefined);
321
    };
322
    const timer = window.setInterval(refresh, 10_000);
323
    document.addEventListener('visibilitychange', refresh);
324
    return () => {
325
      window.clearInterval(timer);
326
      document.removeEventListener('visibilitychange', refresh);
327
    };
328
  }, [preview, refreshDashboard, session]);
329
330
  const navigate = (next: Page) => {
331
    history.pushState({}, '', `/${next}`);
332
    setPage(next);
333
  };
334
  const pendingSetup = data.setupRequests.filter((request) => request.status === 'pending').length;
335
  const pendingInvites = data.federatedInteractions.filter(
336
    (interaction) => interaction.status === 'pending',
337
  ).length;
338
  const unreadThreads = data.hostedThreads.reduce(
339
    (total, thread) => total + Number(thread.unreadCount ?? 0),
340
    0,
341
  );
342
343
  if (session === undefined) return <Loading />;
344
  if (!session)
345
    return import.meta.env.DEV ? (
346
      <Login
347
        onPreview={() => {
348
          enablePreview();
349
          setPreview(true);
350
          setSession(previewSession);
351
          setData(createPreviewData());
352
          setSettings(defaultPreviewSettings);
353
          setLoading(false);
354
          history.replaceState({}, '', '/dashboard');
355
          setPage('dashboard');
356
        }}
357
      />
358
    ) : (
359
      <Login />
360
    );
361
362
  return (
363
    <div className="appShell">
364
      <a className="skipLink" href="#main">
365
        Skip to content
366
      </a>
367
      <aside>
368
        <div className="brand">
369
          <div className="mark">OC</div>
370
          <div>
371
            <strong>OpenClasp</strong>
372
            <small>ASSURANCE NETWORK</small>
373
          </div>
374
        </div>
375
        <nav aria-label="Dashboard">
376
          <Nav
377
            page="dashboard"
378
            active={page}
379
            onClick={navigate}
380
            label="Overview"
381
            icon="home"
382
            badge={pendingInvites || pendingSetup ? pendingInvites + pendingSetup : 0}
383
          />
384
          <Nav page="history" active={page} onClick={navigate} label="History" icon="history" />
385
          <Nav
386
            page="conversations"
387
            active={page}
388
            onClick={navigate}
389
            label="Conversations"
390
            icon="connect"
391
            badge={unreadThreads}
392
          />
393
          <Nav page="agents" active={page} onClick={navigate} label="Agents" icon="agents" />
394
          <Nav page="insights" active={page} onClick={navigate} label="Insights" icon="insights" />
395
          <Nav
396
            page="connect"
397
            active={page}
398
            onClick={navigate}
399
            label="Connect"
400
            icon="connect"
401
            badge={pendingSetup}
402
          />
403
          <Nav page="settings" active={page} onClick={navigate} label="Settings" icon="settings" />
404
        </nav>
405
        <button
406
          className="themeSwitch"
407
          type="button"
408
          onClick={() => setTheme((current) => (current === 'dark' ? 'light' : 'dark'))}
409
          aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} theme`}
410
        >
411
          <Icon name={theme === 'dark' ? 'sun' : 'moon'} />
412
          {theme === 'dark' ? 'Light theme' : 'Dark theme'}
413
        </button>
414
        <div className="privacyStamp">
415
          <span className="liveDot" />
416
          <div>
417
            <strong>Privacy separated</strong>
418
            <small>Direct messages never stored</small>
419
          </div>
420
        </div>
421
        <button
422
          className="account"
423
          type="button"
424
          onClick={() => void signOut(preview)}
425
          title="Sign out"
426
        >
427
          <span>{initials(session.user.name || session.user.email || 'OC')}</span>
428
          <div>
429
            <strong>{session.user.name || 'OpenClasp user'}</strong>
430
            <small>{session.user.email || 'Signed in'}</small>
431
          </div>
432
          <b>Sign out</b>
433
        </button>
434
      </aside>
435
      <div>
436
        <div className="mobileBar">
437
          <div className="brand">
438
            <div className="mark">OC</div>
439
            <div>
440
              <strong>OpenClasp</strong>
441
              <small>ASSURANCE</small>
442
            </div>
443
          </div>
444
          <div className="mobileActions">
445
            <button
446
              className="iconButton"
447
              type="button"
448
              aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} theme`}
449
              onClick={() => setTheme((current) => (current === 'dark' ? 'light' : 'dark'))}
450
            >
451
              <Icon name={theme === 'dark' ? 'sun' : 'moon'} />
452
            </button>
453
            <button
454
              className="iconButton"
455
              type="button"
456
              aria-label="Sign out"
457
              onClick={() => void signOut(preview)}
458
            >
459
              <span>{initials(session.user.name || session.user.email || 'OC')}</span>
460
            </button>
461
          </div>
462
        </div>
463
        <main id="main">
464
          {preview && (
465
            <div className="previewBanner">Local preview with sample data. Changes stay here.</div>
466
          )}
467
          {error && (
468
            <div className="errorBar" role="alert">
469
              {error}
470
            </div>
471
          )}
472
          {loading ? (
473
            <Loading compact />
474
          ) : (
475
            <PageContent
476
              page={page}
477
              data={data}
478
              settings={settings}
479
              setSettings={setSettings}
480
              navigate={navigate}
481
              refreshDashboard={refreshDashboard}
482
              api={api}
483
            />
484
          )}
485
        </main>
486
      </div>
487
    </div>
488
  );
489
}
490
491
function Login({ onPreview }: { onPreview?: () => void }) {
492
  const [error, setError] = useState('');
493
  const continueWith = async (provider: 'google' | 'github') => {
494
    setError('');
495
    try {
496
      await beginAuth(provider);
497
    } catch {
498
      setError(`${provider === 'google' ? 'Google' : 'GitHub'} sign-in is not configured yet.`);
499
    }
500
  };
501
  return (
502
    <div className="loginPage">
503
      <section className="loginPitch">
504
        <div className="brand">
505
          <div className="mark">OC</div>
506
          <strong>OpenClasp</strong>
507
        </div>
508
        <div>
509
          <p className="eyebrow">AGENT-TO-AGENT ASSURANCE</p>
510
          <h1>Know who your agent is dealing with.</h1>
511
          <p>
512
            Verified identities, signed outcomes, private warnings, and contextual
513
            reliability—without using message bodies for scoring.
514
          </p>
515
        </div>
516
        <div className="loginProof">
517
          <span>01</span> User-owned history
518
          <span>02</span> No universal trust score
519
          <span>03</span> Explicit network consent
520
        </div>
521
      </section>
522
      <section className="loginCard">
523
        <div>
524
          <p className="eyebrow">SECURE ACCESS</p>
525
          <h2>Sign in to OpenClasp</h2>
526
          <p>
527
            Manage connected agents, review signed history, and control what contributes to the
528
            network.
529
          </p>
530
        </div>
531
        <div className="socialButtons">
532
          <button type="button" onClick={() => void continueWith('google')}>
533
            <GoogleMark /> Continue with Google
534
          </button>
535
          <button type="button" onClick={() => void continueWith('github')}>
536
            <GitHubMark /> Continue with GitHub
537
          </button>
538
          {onPreview && (
539
            <button type="button" onClick={() => void onPreview()}>
540
              Continue with local preview
541
            </button>
542
          )}
543
        </div>
544
        {error && (
545
          <div className="loginError" role="alert">
546
            {error}
547
          </div>
548
        )}
549
        <small>
550
          Google and GitHub handle authentication. OpenClasp never receives your password.
551
        </small>
552
      </section>
553
    </div>
554
  );
555
}
556
557
let callbackStarted = false;
558
function AuthCallback() {
559
  const [error, setError] = useState('');
560
  useEffect(() => {
561
    if (callbackStarted) return;
562
    callbackStarted = true;
563
    const finish = async () => {
564
      const query = new URLSearchParams(location.search);
565
      const oauthError = query.get('error_description') ?? query.get('error');
566
      if (oauthError) throw new Error(oauthError);
567
      const code = query.get('code');
568
      const returnedState = query.get('state');
569
      const rawTransaction = sessionStorage.getItem(authTransactionKey);
570
      if (!code || !returnedState || !rawTransaction) throw new Error('Missing OAuth transaction');
571
      const transaction = JSON.parse(rawTransaction) as AuthTransaction;
572
      sessionStorage.removeItem(authTransactionKey);
573
      if (returnedState !== transaction.state) throw new Error('OAuth state mismatch');
574
      const response = await fetch('/api/session', {
575
        method: 'POST',
576
        credentials: 'same-origin',
577
        headers: { 'content-type': 'application/json' },
578
        body: JSON.stringify({
579
          code,
580
          codeVerifier: transaction.verifier,
581
        }),
582
      });
583
      if (!response.ok) throw new Error('Auth0 token exchange failed');
584
      location.replace('/dashboard');
585
    };
586
    void finish().catch((reason: unknown) =>
587
      setError(reason instanceof Error ? reason.message : 'Authentication failed'),
588
    );
589
  }, []);
590
  if (error)
591
    return (
592
      <div className="loading">
593
        <span className="mark">OC</span>
594
        <p>{error}</p>
595
        <a href="/login">Return to login</a>
596
      </div>
597
    );
598
  return <Loading />;
599
}
600
601
function PageContent({
602
  page,
603
  data,
604
  settings,
605
  setSettings,
606
  navigate,
607
  refreshDashboard,
608
  api,
609
}: {
610
  page: Page;
611
  data: DashboardData;
612
  settings: Settings;
613
  setSettings: React.Dispatch<React.SetStateAction<Settings>>;
614
  navigate: (page: Page) => void;
615
  refreshDashboard: () => Promise<void>;
616
  api: (path: string, init?: RequestInit) => Promise<unknown>;
617
}) {
618
  if (page === 'history') return <History data={data} />;
619
  if (page === 'conversations')
620
    return <Conversations data={data} refreshDashboard={refreshDashboard} api={api} />;
621
  if (page === 'agents')
622
    return <Agents data={data} navigate={navigate} refreshDashboard={refreshDashboard} api={api} />;
623
  if (page === 'insights') return <Insights data={data} />;
624
  if (page === 'connect')
625
    return (
626
      <Connect data={data} navigate={navigate} refreshDashboard={refreshDashboard} api={api} />
627
    );
628
  if (page === 'settings')
629
    return <SettingsPage settings={settings} setSettings={setSettings} api={api} />;
630
  return <Overview data={data} navigate={navigate} refreshDashboard={refreshDashboard} api={api} />;
631
}
632
633
function Overview({
634
  data,
635
  navigate,
636
  refreshDashboard,
637
  api,
638
}: {
639
  data: DashboardData;
640
  navigate: (page: Page) => void;
641
  refreshDashboard: () => Promise<void>;
642
  api: (path: string, init?: RequestInit) => Promise<unknown>;
643
}) {
644
  const completed = data.receipts.filter((item) => item.outcome === 'success').length;
645
  const warnings = data.events.filter((item) =>
646
    ['policy_warning', 'policy_violation', 'objection'].includes(String(item.eventType)),
647
  ).length;
648
  const publishedIds = new Set(
649
    data.publications
650
      .filter((publication) => publication.published)
651
      .map((publication) => String(publication.agentId)),
652
  );
653
  const runtimeIds = new Set(
654
    data.runtimes
655
      .filter((runtime) => runtime.status === 'verified')
656
      .map((runtime) => String(runtime.agentId)),
657
  );
658
  const modeOf = (agent: Record<string, any>) =>
659
    agent.agentMode ?? (agent.a2aEndpoint ? 'persistent_runtime' : 'temporary_chat');
660
  const readyAgents = data.agents.filter(
661
    (agent) =>
662
      publishedIds.has(String(agent.agentId)) &&
663
      (modeOf(agent) === 'temporary_chat' || runtimeIds.has(String(agent.agentId))),
664
  ).length;
665
  const activeTemporaryChats = data.agents.filter(
666
    (agent) => modeOf(agent) === 'temporary_chat' && agent.presence?.status === 'online',
667
  ).length;
668
  const pendingInvitations = data.federatedInteractions.filter(
669
    (interaction) => interaction.status === 'pending',
670
  ).length;
671
  const pendingFeedback = data.feedbackRequests.filter(
672
    (request) => request.status === 'pending',
673
  ).length;
674
  const pendingSetup = data.setupRequests.filter((request) => request.status === 'pending').length;
675
  const reviewCount = warnings + pendingInvitations + pendingFeedback;
676
  return (
677
    <>
678
      <PageHead page="dashboard" action="Connect agent" onAction={() => navigate('connect')} />
679
      {pendingSetup > 0 && (
680
        <button className="attentionBanner" type="button" onClick={() => navigate('connect')}>
681
          <div>
682
            <span className="statusOrb">{pendingSetup}</span>
683
            <div>
684
              <strong>
685
                {pendingSetup} setup request{pendingSetup === 1 ? '' : 's'} waiting
686
              </strong>
687
              <small>Approve the proposed identity before the agent can be bound.</small>
688
            </div>
689
          </div>
690
        </button>
691
      )}
692
      <section
693
        className={`readiness ${readyAgents === data.agents.length && readyAgents ? 'ready' : ''}`}
694
      >
695
        <div>
696
          <span className="statusOrb">{readyAgents ? '✓' : '!'}</span>
697
          <div>
698
            <strong>
699
              {readyAgents === data.agents.length && readyAgents
700
                ? 'Your agent network is ready'
701
                : `${readyAgents} of ${data.agents.length} agents can receive A2A work`}
702
            </strong>
703
            <small>
704
              Safe matching requests activate automatically. Everything else waits for approval.
705
            </small>
706
          </div>
707
        </div>
708
        <button className="secondary" type="button" onClick={() => navigate('agents')}>
709
          Manage automation
710
        </button>
711
      </section>
712
      <section className="metrics">
713
        <Metric
714
          label="Active temporary chats"
715
          value={activeTemporaryChats}
716
          note={`${runtimeIds.size} persistent runtimes connected`}
717
        />
718
        <Metric
719
          label="Interactions"
720
          value={data.interactions.length + data.federatedInteractions.length}
721
          note="local and shared"
722
        />
723
        <Metric label="Successful outcomes" value={completed} note="receipt-backed" />
724
        <Metric
725
          label="Needs review"
726
          value={reviewCount}
727
          note={`${pendingFeedback} feedback · ${pendingInvitations} invitations`}
728
          warn={reviewCount > 0}
729
        />
730
      </section>
731
      <Invitations data={data} refreshDashboard={refreshDashboard} api={api} />
732
      <section className="contentGrid">
733
        <Panel title="Recent activity" subtitle="Structured events and signed outcomes">
734
          <Timeline events={data.events.slice(-6).reverse()} />
735
          <TextButton onClick={() => navigate('history')}>View complete history</TextButton>
736
        </Panel>
737
        <Panel
738
          title="Reliability context"
739
          subtitle="Task-specific profiles, never a universal score"
740
        >
741
          {data.profiles.length ? (
742
            data.profiles.slice(0, 4).map((profile) => {
743
              const deltas = data.profileDeltas.filter(
744
                (delta) =>
745
                  delta.agentId === profile.agentId &&
746
                  delta.agentVersion === profile.agentVersion &&
747
                  delta.taskCategory === profile.taskCategory,
748
              );
749
              return (
750
                <Profile
751
                  key={`${profile.agentId}-${profile.agentVersion}-${profile.taskCategory}`}
752
                  profile={profile}
753
                  deltas={deltas}
754
                />
755
              );
756
            })
757
          ) : (
758
            <Empty
759
              title="No reliability profile yet"
760
              text="Profiles appear after signed receipts and eligible bilateral feedback."
761
            />
762
          )}
763
        </Panel>
764
      </section>
765
    </>
766
  );
767
}
768
769
function Conversations({
770
  data,
771
  refreshDashboard,
772
  api,
773
}: {
774
  data: DashboardData;
775
  refreshDashboard: () => Promise<void>;
776
  api: (path: string, init?: RequestInit) => Promise<unknown>;
777
}) {
778
  const [selected, setSelected] = useState<Record<string, any> | null>(null);
779
  const [selectedAgentId, setSelectedAgentId] = useState('');
780
  const [draft, setDraft] = useState('');
781
  const [working, setWorking] = useState(false);
782
  const [error, setError] = useState('');
783
  const temporaryAgentFor = (thread: Record<string, any>) =>
784
    data.agents.find(
785
      (agent) =>
786
        thread.participantAgentIds?.includes(agent.agentId) &&
787
        (agent.agentMode ?? (agent.a2aEndpoint ? 'persistent_runtime' : 'temporary_chat')) ===
788
          'temporary_chat',
789
    );
790
  const openThread = async (thread: Record<string, any>) => {
791
    const agent = temporaryAgentFor(thread);
792
    if (!agent) return;
793
    setWorking(true);
794
    setError('');
795
    try {
796
      const [detail] = (await Promise.all([
797
        api(
798
          `/v0.1/agents/${encodeURIComponent(agent.agentId)}/threads/${encodeURIComponent(thread.threadId)}`,
799
        ),
800
        api(
801
          `/v0.1/agents/${encodeURIComponent(agent.agentId)}/threads/${encodeURIComponent(thread.threadId)}/read`,
802
          { method: 'POST' },
803
        ),
804
      ])) as [Record<string, any>, unknown];
805
      setSelected(detail);
806
      setSelectedAgentId(String(agent.agentId));
807
      await refreshDashboard();
808
    } catch (reason) {
809
      setError(reason instanceof Error ? reason.message : 'Could not open conversation');
810
    } finally {
811
      setWorking(false);
812
    }
813
  };
814
  const reply = async () => {
815
    if (!selected || !draft.trim() || !selectedAgentId) return;
816
    setWorking(true);
817
    setError('');
818
    try {
819
      await api(`/v0.1/agents/${encodeURIComponent(selectedAgentId)}/messages`, {
820
        method: 'POST',
821
        body: JSON.stringify({
822
          interactionId: selected.thread.interactionId,
823
          content: draft.trim(),
824
        }),
825
      });
826
      setDraft('');
827
      await openThread(selected.thread);
828
    } catch (reason) {
829
      setError(reason instanceof Error ? reason.message : 'Could not send reply');
830
      setWorking(false);
831
    }
832
  };
833
  return (
834
    <>
835
      <PageHead page="conversations" />
836
      <div className="notice">
837
        <strong>Hosted temporary mode.</strong> OpenClasp processes these messages and encrypts them
838
        at rest for 30 days. Messages between persistent runtimes remain direct and are never shown
839
        here.
840
      </div>
841
      {error ? (
842
        <div className="errorBar" role="alert">
843
          {error}
844
        </div>
845
      ) : null}
846
      <section className="conversationLayout">
847
        <Panel title="Threads" subtitle="Temporary chat identities only">
848
          <div className="threadList">
849
            {data.hostedThreads.length ? (
850
              data.hostedThreads.map((thread) => {
851
                const agent = temporaryAgentFor(thread);
852
                const peer = thread.participantAgentIds?.find(
853
                  (value: string) => value !== agent?.agentId,
854
                );
855
                return (
856
                  <button
857
                    type="button"
858
                    key={thread.threadId}
859
                    className={selected?.thread?.threadId === thread.threadId ? 'active' : ''}
860
                    onClick={() => void openThread(thread)}
861
                    disabled={working}
862
                  >
863
                    <span>
864
                      <strong>{peer ?? 'Counterparty'}</strong>
865
                      <small>{agent?.name ?? agent?.agentId}</small>
866
                    </span>
867
                    <b>{thread.unreadCount ? `${thread.unreadCount} NEW` : thread.status}</b>
868
                  </button>
869
                );
870
              })
871
            ) : (
872
              <Empty
873
                title="No temporary conversations"
874
                text="Messages appear after a temporary chat identity accepts an interaction with a persistent runtime."
875
              />
876
            )}
877
          </div>
878
        </Panel>
879
        <Panel
880
          title={selected ? 'Conversation' : 'Select a thread'}
881
          subtitle="Private insights are attributable and task-specific"
882
        >
883
          {selected ? (
884
            <div className="conversationDetail">
885
              {!!selected.insights?.length && (
886
                <div className="conversationInsights">
887
                  {selected.insights.map((insight: Record<string, any>) => (
888
                    <p key={insight.code}>
889
                      <b>{String(insight.severity).toUpperCase()}</b> {insight.message}
890
                    </p>
891
                  ))}
892
                </div>
893
              )}
894
              <div className="messageList">
895
                {selected.messages.map((message: Record<string, any>) => (
896
                  <article
897
                    key={message.messageId}
898
                    className={message.senderAgentId === selectedAgentId ? 'sent' : 'received'}
899
                  >
900
                    <small>{message.senderAgentId === selectedAgentId ? 'You' : 'Peer'}</small>
901
                    <p>{message.content}</p>
902
                    <span>{new Date(message.createdAt).toLocaleString()}</span>
903
                  </article>
904
                ))}
905
              </div>
906
              <div className="replyBox">
907
                <textarea
908
                  aria-label="Temporary chat reply"
909
                  value={draft}
910
                  onChange={(event) => setDraft(event.target.value)}
911
                  maxLength={20_000}
912
                  placeholder="Reply through the temporary OpenClasp A2A adapter"
913
                />
914
                <button
915
                  className="primary"
916
                  type="button"
917
                  disabled={working || !draft.trim() || selected.thread.status !== 'open'}
918
                  onClick={() => void reply()}
919
                >
920
                  {working ? 'Sending…' : 'Send reply'}
921
                </button>
922
              </div>
923
            </div>
924
          ) : (
925
            <Empty title="Nothing selected" text="Choose a thread to read its hosted history." />
926
          )}
927
        </Panel>
928
      </section>
929
    </>
930
  );
931
}
932
933
function History({ data }: { data: DashboardData }) {
934
  const [query, setQuery] = useState('');
935
  const [status, setStatus] = useState<'all' | 'pending' | 'active' | 'completed'>('all');
936
  const journeys = useMemo(() => interactionJourneys(data), [data]);
937
  const filtered = useMemo(() => {
938
    const needle = query.trim().toLowerCase();
939
    return journeys.filter((journey) => {
940
      if (status !== 'all' && journey.status !== status) return false;
941
      if (!needle) return true;
942
      const hay = [
943
        journey.interactionId,
944
        journey.purpose,
945
        journey.taskCategory,
946
        journey.status,
947
        ...journey.participants,
948
      ]
949
        .filter(Boolean)
950
        .join(' ')
951
        .toLowerCase();
952
      return hay.includes(needle);
953
    });
954
  }, [journeys, query, status]);
955
  const [selectedId, setSelectedId] = useState('');
956
  const selected = filtered.find((journey) => journey.interactionId === selectedId) ?? filtered[0];
957
  return (
958
    <>
959
      <PageHead page="history" />
960
      <div className="historyToolbar">
961
        <input
962
          className="searchField"
963
          value={query}
964
          onChange={(event) => setQuery(event.target.value)}
965
          placeholder="Search purpose, category, or agent"
966
          aria-label="Search interaction history"
967
        />
968
        <div className="filterRow" aria-label="Interaction status filters">
969
          {(['all', 'pending', 'active', 'completed'] as const).map((value) => (
970
            <button
971
              key={value}
972
              className={status === value ? 'filterChip active' : 'filterChip'}
973
              type="button"
974
              aria-pressed={status === value}
975
              onClick={() => setStatus(value)}
976
            >
977
              {value}{' '}
978
              {value === 'all'
979
                ? journeys.length
980
                : journeys.filter((item) => item.status === value).length}
981
            </button>
982
          ))}
983
        </div>
984
      </div>
985
      {filtered.length ? (
986
        <section className="journeyLayout">
987
          <div className="journeyList" aria-label="Interactions">
988
            {filtered.map((journey) => (
989
              <button
990
                key={journey.interactionId}
991
                className={selected?.interactionId === journey.interactionId ? 'active' : ''}
992
                type="button"
993
                onClick={() => {
994
                  setSelectedId(journey.interactionId);
995
                  if (matchMedia('(max-width: 980px)').matches)
996
                    requestAnimationFrame(() =>
997
                      document
998
                        .querySelector('.journeyDetail')
999
                        ?.scrollIntoView({ behavior: 'smooth' }),
1000
                    );
1001
                }}
1002
              >
1003
                <span className="journeyListTop">
1004
                  <b>{journey.taskCategory}</b>
1005
                  <StatusPill value={journey.status} />
1006
                </span>
1007
                <strong>{journey.purpose}</strong>
1008
                <small>{journey.participants.join(' ↔ ') || 'Participants unavailable'}</small>
1009
                <span className="journeyListMeta">
1010
                  {relativeTime(journey.updatedAt)}
1011
                  <i>{journey.stepCount} recorded steps</i>
1012
                </span>
1013
              </button>
1014
            ))}
1015
          </div>
1016
          {selected ? <InteractionJourney journey={selected} /> : null}
1017
        </section>
1018
      ) : (
1019
        <section className="panel">
1020
          <Empty
1021
            title={journeys.length ? 'No matching interactions' : 'No interactions recorded'}
1022
            text={
1023
              journeys.length
1024
                ? 'Clear the search or choose another status.'
1025
                : 'Completed contracts, outcomes, feedback, and learning will appear as one readable journey.'
1026
            }
1027
          />
1028
        </section>
1029
      )}
1030
    </>
1031
  );
1032
}
1033
1034
type InteractionJourneyModel = {
1035
  interactionId: string;
1036
  purpose: string;
1037
  taskCategory: string;
1038
  status: string;
1039
  participants: string[];
1040
  updatedAt: string;
1041
  stepCount: number;
1042
  interaction: Record<string, any>;
1043
  session?: Record<string, any>;
1044
  events: Record<string, any>[];
1045
  briefs: Record<string, any>[];
1046
  reports: Record<string, any>[];
1047
  feedbackRequests: Record<string, any>[];
1048
  feedback: Record<string, any>[];
1049
  conclusion?: Record<string, any>;
1050
  receipt?: Record<string, any>;
1051
  eligibility?: Record<string, any>;
1052
  deltas: Record<string, any>[];
1053
  conflicts: Record<string, any>[];
1054
};
1055
1056
function interactionJourneys(data: DashboardData): InteractionJourneyModel[] {
1057
  const ids = new Set<string>();
1058
  const collections = [
1059
    data.interactions,
1060
    data.federatedInteractions,
1061
    data.liveSessions,
1062
    data.events,
1063
    data.counterpartyBriefs,
1064
    data.completionReports,
1065
    data.feedbackRequests,
1066
    data.interactionFeedback,
1067
    data.interactionConclusions,
1068
    data.receipts,
1069
    data.learningEligibility,
1070
    data.profileDeltas,
1071
    data.conflicts,
1072
  ];
1073
  for (const collection of collections)
1074
    for (const item of collection) if (item.interactionId) ids.add(String(item.interactionId));
1075
  return [...ids]
1076
    .map((interactionId) => {
1077
      const interaction = data.federatedInteractions.find(
1078
        (item) => item.interactionId === interactionId,
1079
      ) ??
1080
        data.interactions.find((item) => item.interactionId === interactionId) ?? { interactionId };
1081
      const session = data.liveSessions.find((item) => item.interactionId === interactionId);
1082
      const events = data.events.filter((item) => item.interactionId === interactionId);
1083
      const briefs = data.counterpartyBriefs.filter((item) => item.interactionId === interactionId);
1084
      const reports = data.completionReports.filter((item) => item.interactionId === interactionId);
1085
      const feedbackRequests = data.feedbackRequests.filter(
1086
        (item) => item.interactionId === interactionId,
1087
      );
1088
      const feedback = data.interactionFeedback.filter(
1089
        (item) => item.interactionId === interactionId,
1090
      );
1091
      const conclusion = data.interactionConclusions.find(
1092
        (item) => item.interactionId === interactionId,
1093
      );
1094
      const receipt = data.receipts.find((item) => item.interactionId === interactionId);
1095
      const eligibility = data.learningEligibility.find(
1096
        (item) => item.interactionId === interactionId,
1097
      );
1098
      const deltas = data.profileDeltas.filter((item) => item.interactionId === interactionId);
1099
      const conflicts = data.conflicts.filter((item) => item.interactionId === interactionId);
1100
      const participants = [
1101
        interaction.initiatorAgentId,
1102
        interaction.responderAgentId,
1103
        interaction.agentId,
1104
        ...reports.flatMap((report) => [report.reportingAgentId, report.counterpartyAgentId]),
1105
      ]
1106
        .filter((value): value is string => typeof value === 'string' && Boolean(value))
1107
        .filter((value, index, values) => values.indexOf(value) === index);
1108
      const dates = [
1109
        interaction.updatedAt,
1110
        interaction.createdAt,
1111
        session?.completedAt,
1112
        session?.activatedAt,
1113
        ...events.map(timestamp),
1114
        ...reports.map(timestamp),
1115
        ...feedback.map(timestamp),
1116
        conclusion?.generatedAt,
1117
        receipt?.completedAt,
1118
        eligibility?.decidedAt,
1119
      ].filter((value): value is string => typeof value === 'string');
1120
      return {
1121
        interactionId,
1122
        purpose: String(
1123
          interaction.contract?.purpose ??
1124
            interaction.contract?.requestedOutcome ??
1125
            conclusion?.summary ??
1126
            'Assured agent interaction',
1127
        ),
1128
        taskCategory: String(interaction.contract?.taskCategory ?? 'general'),
1129
        status: String(
1130
          conclusion || receipt
1131
            ? 'completed'
1132
            : (session?.status ?? interaction.status ?? 'pending'),
1133
        ),
1134
        participants,
1135
        updatedAt:
1136
          dates.sort((left, right) => Date.parse(right) - Date.parse(left))[0] ??
1137
          new Date(0).toISOString(),
1138
        stepCount:
1139
          1 +
1140
          events.length +
1141
          briefs.length +
1142
          reports.length +
1143
          feedbackRequests.length +
1144
          feedback.length +
1145
          conflicts.length +
1146
          Number(Boolean(conclusion)) +
1147
          Number(Boolean(receipt)) +
1148
          Number(Boolean(eligibility)) +
1149
          deltas.length,
1150
        interaction,
1151
        ...(session ? { session } : {}),
1152
        events,
1153
        briefs,
1154
        reports,
1155
        feedbackRequests,
1156
        feedback,
1157
        ...(conclusion ? { conclusion } : {}),
1158
        ...(receipt ? { receipt } : {}),
1159
        ...(eligibility ? { eligibility } : {}),
1160
        deltas,
1161
        conflicts,
1162
      };
1163
    })
1164
    .sort((left, right) => Date.parse(right.updatedAt) - Date.parse(left.updatedAt));
1165
}
1166
1167
function InteractionJourney({ journey }: { journey: InteractionJourneyModel }) {
1168
  const timeline = interactionTimeline(journey);
1169
  const conclusion = journey.conclusion;
1170
  return (
1171
    <article className="journeyDetail">
1172
      <header className="journeyHero">
1173
        <div>
1174
          <span className="eyebrow">{journey.taskCategory}</span>
1175
          <h2>{journey.purpose}</h2>
1176
          <p>{journey.participants.join(' ↔ ')}</p>
1177
        </div>
1178
        <StatusPill value={conclusion?.outcome ?? journey.status} />
1179
      </header>
1180
1181
      <div className="journeySummaryGrid">
1182
        <section>
1183
          <span>Outcome</span>
1184
          <strong>{humanize(conclusion?.outcome ?? 'In progress')}</strong>
1185
          <small>{humanize(conclusion?.consensus ?? 'Awaiting bilateral conclusion')}</small>
1186
        </section>
1187
        <section>
1188
          <span>Feedback</span>
1189
          <strong>{conclusion ? 'Closed' : `${journey.feedback.length} submitted locally`}</strong>
1190
          <small>{feedbackState(journey.feedbackRequests)}</small>
1191
        </section>
1192
        <section>
1193
          <span>Learning</span>
1194
          <strong>
1195
            {journey.eligibility
1196
              ? journey.eligibility.eligible
1197
                ? `${Math.round(Number(journey.eligibility.sampleWeight ?? 0) * 100)}% weight`
1198
                : 'Not eligible'
1199
              : 'Not assessed'}
1200
          </strong>
1201
          <small>{humanize(journey.eligibility?.contributionMode ?? 'Local only')}</small>
1202
        </section>
1203
      </div>
1204
1205
      {conclusion ? (
1206
        <section className="outcomeCard">
1207
          <div>
1208
            <span className="eyebrow">WHAT HAPPENED</span>
1209
            <h3>{conclusion.summary}</h3>
1210
          </div>
1211
          {!!conclusion.criteria?.length && (
1212
            <ul>
1213
              {conclusion.criteria.map((criterion: Record<string, any>) => (
1214
                <li key={criterion.criterion}>
1215
                  <StatusPill value={String(criterion.status)} />
1216
                  <span>{criterion.criterion}</span>
1217
                </li>
1218
              ))}
1219
            </ul>
1220
          )}
1221
          {!!Object.keys(conclusion.averageRatings ?? {}).length && (
1222
            <div className="ratingGrid">
1223
              {Object.entries(conclusion.averageRatings).map(([label, value]) => (
1224
                <Meter key={label} label={humanize(label)} value={Number(value)} />
1225
              ))}
1226
            </div>
1227
          )}
1228
        </section>
1229
      ) : (
1230
        <div className="journeyNotice">
1231
          No conclusion yet. It appears after terminal reports and bilateral feedback or timeout.
1232
        </div>
1233
      )}
1234
1235
      <section className="journeyTimelineSection">
1236
        <div>
1237
          <h3>Interaction timeline</h3>
1238
          <small>Contract → session → outcome → feedback → learning</small>
1239
        </div>
1240
        <ol className="journeyTimeline">
1241
          {timeline.map((step, index) => (
1242
            <li key={`${step.at}-${step.title}-${index}`}>
1243
              <span className={`journeyNode ${step.tone}`} />
1244
              <div>
1245
                <span>{formatDate(step.at)}</span>
1246
                <strong>{step.title}</strong>
1247
                <p>{step.detail}</p>
1248
              </div>
1249
              <StatusPill value={step.status} />
1250
            </li>
1251
          ))}
1252
        </ol>
1253
      </section>
1254
    </article>
1255
  );
1256
}
1257
1258
function interactionTimeline(journey: InteractionJourneyModel) {
1259
  const steps: { at: string; title: string; detail: string; status: string; tone: string }[] = [
1260
    {
1261
      at: String(journey.interaction.createdAt ?? journey.updatedAt),
1262
      title: 'Contract proposed',
1263
      detail: `${journey.participants.length || 2} participants · ${journey.taskCategory}`,
1264
      status: String(journey.interaction.status ?? 'recorded'),
1265
      tone: 'neutral',
1266
    },
1267
    ...journey.briefs.map((brief) => ({
1268
      at: String(brief.generatedAt ?? journey.updatedAt),
1269
      title: `Private counterparty brief for ${brief.recipientAgentId}`,
1270
      detail: `${brief.relevantSampleSize ?? 0} relevant samples · ${Math.round(Number(brief.historyConfidence ?? 0) * 100)}% history confidence`,
1271
      status: String(brief.decision ?? 'ready'),
1272
      tone: brief.decision === 'DENY' ? 'danger' : brief.decision === 'CHALLENGE' ? 'warn' : 'good',
1273
    })),
1274
    ...Object.values(journey.interaction.acceptances ?? {}).map((acceptance: any) => ({
1275
      at: String(acceptance.acceptedAt ?? journey.interaction.updatedAt ?? journey.updatedAt),
1276
      title: `Contract accepted by ${acceptance.agentId}`,
1277
      detail: `Acceptance method: ${humanize(acceptance.method ?? 'recorded')}`,
1278
      status: 'accepted',
1279
      tone: 'good',
1280
    })),
1281
    ...(journey.session?.activatedAt
1282
      ? [
1283
          {
1284
            at: String(journey.session.activatedAt),
1285
            title: 'A2A session activated',
1286
            detail:
1287
              'Scoped credentials were issued. Persistent runtimes exchange content directly; temporary mode uses the hosted adapter.',
1288
            status: 'active',
1289
            tone: 'good',
1290
          },
1291
        ]
1292
      : []),
1293
    ...journey.events.map((event) => ({
1294
      at: timestamp(event),
1295
      title: humanize(event.eventType ?? 'Structured event'),
1296
      detail: String(event.agentId ?? 'OpenClasp') + ' submitted structured metadata.',
1297
      status: String(event.visibility ?? 'recorded'),
1298
      tone: String(event.eventType).includes('violation')
1299
        ? 'danger'
1300
        : String(event.eventType).includes('warning')
1301
          ? 'warn'
1302
          : 'neutral',
1303
    })),
1304
    ...journey.reports.map((report) => ({
1305
      at: timestamp(report),
1306
      title: `Completion reported by ${report.reportingAgentId}`,
1307
      detail: String(report.summary ?? 'Structured completion report submitted.'),
1308
      status: String(report.outcome ?? 'reported'),
1309
      tone:
1310
        report.outcome === 'failure' ? 'danger' : report.outcome === 'partial' ? 'warn' : 'good',
1311
    })),
1312
    ...journey.feedback.map((item) => ({
1313
      at: timestamp(item),
1314
      title: `Feedback submitted by ${item.reviewerAgentId}`,
1315
      detail: `Would work again: ${humanize(item.wouldWorkAgain ?? 'unsure')} · private comment concealed`,
1316
      status: 'sealed',
1317
      tone: 'neutral',
1318
    })),
1319
    ...journey.feedbackRequests
1320
      .filter((request) => request.status !== 'submitted')
1321
      .map((request) => ({
1322
        at: String(request.requestedAt ?? journey.updatedAt),
1323
        title: `Feedback request ${humanize(request.status)}`,
1324
        detail:
1325
          request.status === 'pending'
1326
            ? `Response due ${formatDate(String(request.dueAt))}`
1327
            : 'The bilateral conclusion continued without this response.',
1328
        status: String(request.status),
1329
        tone: request.status === 'pending' ? 'warn' : 'neutral',
1330
      })),
1331
    ...journey.conflicts.map((conflict) => ({
1332
      at: timestamp(conflict),
1333
      title: 'Dispute recorded',
1334
      detail: String(conflict.summary ?? 'The interaction has a structured dispute record.'),
1335
      status: String(conflict.status ?? 'open'),
1336
      tone: conflict.status === 'resolved' ? 'good' : 'danger',
1337
    })),
1338
    ...(journey.conclusion
1339
      ? [
1340
          {
1341
            at: String(journey.conclusion.generatedAt),
1342
            title: 'Bilateral conclusion released',
1343
            detail: String(journey.conclusion.summary),
1344
            status: String(journey.conclusion.consensus),
1345
            tone: journey.conclusion.consensus === 'conflicting' ? 'warn' : 'good',
1346
          },
1347
        ]
1348
      : []),
1349
    ...(journey.receipt
1350
      ? [
1351
          {
1352
            at: timestamp(journey.receipt),
1353
            title: 'Outcome receipt attested',
1354
            detail: 'Contract result, commitment status, and evidence hashes were sealed.',
1355
            status: String(journey.receipt.outcome ?? 'signed'),
1356
            tone: journey.receipt.outcome === 'failure' ? 'danger' : 'good',
1357
          },
1358
        ]
1359
      : []),
1360
    ...(journey.eligibility
1361
      ? [
1362
          {
1363
            at: String(journey.eligibility.decidedAt),
1364
            title: journey.eligibility.eligible
1365
              ? 'Eligible history applied'
1366
              : 'History excluded from profile',
1367
            detail: `${Math.round(Number(journey.eligibility.sampleWeight ?? 0) * 100)}% sample weight · ${humanize(journey.eligibility.contributionMode)}`,
1368
            status: journey.eligibility.eligible ? 'attested' : 'excluded',
1369
            tone: journey.eligibility.eligible ? 'good' : 'warn',
1370
          },
1371
        ]
1372
      : []),
1373
  ];
1374
  return steps.sort((left, right) => Date.parse(left.at) - Date.parse(right.at));
1375
}
1376
1377
function Agents({
1378
  data,
1379
  navigate,
1380
  refreshDashboard,
1381
  api,
1382
}: {
1383
  data: DashboardData;
1384
  navigate: (page: Page) => void;
1385
  refreshDashboard: () => Promise<void>;
1386
  api: (path: string, init?: RequestInit) => Promise<unknown>;
1387
}) {
1388
  const [working, setWorking] = useState('');
1389
  const [error, setError] = useState('');
1390
  const saveAutomation = async (
1391
    agentId: string,
1392
    value: {
1393
      autoPublish: boolean;
1394
      autoAcceptPolicy: 'off' | 'safe_matching';
1395
      autoAcceptTaskCategories: string[];
1396
    },
1397
  ) => {
1398
    setWorking(agentId);
1399
    setError('');
1400
    try {
1401
      await api(`/v0.1/agents/${encodeURIComponent(agentId)}/automation`, {
1402
        method: 'PUT',
1403
        body: JSON.stringify(value),
1404
      });
1405
      await refreshDashboard();
1406
      return true;
1407
    } catch (reason) {
1408
      setError(reason instanceof Error ? reason.message : 'Directory update failed');
1409
      return false;
1410
    } finally {
1411
      setWorking('');
1412
    }
1413
  };
1414
  const saveRuntime = async (agentId: string, endpoint: string) => {
1415
    setWorking(`runtime:${agentId}`);
1416
    setError('');
1417
    try {
1418
      await api(`/v0.1/agents/${encodeURIComponent(agentId)}/runtime`, {
1419
        method: 'PUT',
1420
        body: JSON.stringify({ endpoint }),
1421
      });
1422
      await refreshDashboard();
1423
    } catch (reason) {
1424
      setError(reason instanceof Error ? reason.message : 'Runtime verification failed');
1425
    } finally {
1426
      setWorking('');
1427
    }
1428
  };
1429
  const disableRuntime = async (agentId: string) => {
1430
    setWorking(`runtime:${agentId}`);
1431
    setError('');
1432
    try {
1433
      await api(`/v0.1/agents/${encodeURIComponent(agentId)}/runtime`, { method: 'DELETE' });
1434
      await refreshDashboard();
1435
    } catch (reason) {
1436
      setError(reason instanceof Error ? reason.message : 'Runtime update failed');
1437
    } finally {
1438
      setWorking('');
1439
    }
1440
  };
1441
  const revokeAccessToken = async (agentId: string, tokenId: string) => {
1442
    setWorking(`token:${agentId}`);
1443
    setError('');
1444
    try {
1445
      await api(
1446
        `/v0.1/agents/${encodeURIComponent(agentId)}/access-tokens/${encodeURIComponent(tokenId)}`,
1447
        { method: 'DELETE' },
1448
      );
1449
      await refreshDashboard();
1450
    } catch (reason) {
1451
      setError(reason instanceof Error ? reason.message : 'Could not revoke agent access token');
1452
    } finally {
1453
      setWorking('');
1454
    }
1455
  };
1456
  const deleteAgent = async (agent: Record<string, any>) => {
1457
    const agentId = String(agent.agentId);
1458
    if (
1459
      !window.confirm(
1460
        `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.`,
1461
      )
1462
    )
1463
      return;
1464
    setWorking(`delete:${agentId}`);
1465
    setError('');
1466
    try {
1467
      await api(`/v0.1/agents/${encodeURIComponent(agentId)}`, { method: 'DELETE' });
1468
      await refreshDashboard();
1469
    } catch (reason) {
1470
      setError(reason instanceof Error ? reason.message : 'Agent deletion failed');
1471
    } finally {
1472
      setWorking('');
1473
    }
1474
  };
1475
  return (
1476
    <>
1477
      <PageHead page="agents" action="Connect agent" onAction={() => navigate('connect')} />
1478
      <div className="notice">
1479
        <strong>Two explicit modes.</strong> Persistent runtimes talk directly over A2A. Temporary
1480
        chats use an OpenClasp-hosted endpoint with encrypted-at-rest history. There is no offline
1481
        relay for persistent runtimes.
1482
      </div>
1483
      {error && (
1484
        <div className="errorBar" role="alert">
1485
          {error}
1486
        </div>
1487
      )}
1488
      <section className="agentGrid">
1489
        {data.agents.length ? (
1490
          data.agents.map((agent) => (
1491
            <AgentCard
1492
              key={agent.agentId}
1493
              agent={agent}
1494
              projectName={
1495
                data.projects.find((project) => project.projectId === agent.projectId)?.name
1496
              }
1497
              published={data.publications.some(
1498
                (publication) => publication.agentId === agent.agentId && publication.published,
1499
              )}
1500
              working={working === agent.agentId}
1501
              onSave={(value) => saveAutomation(agent.agentId, value)}
1502
              runtime={data.runtimes.find((runtime) => runtime.agentId === agent.agentId)}
1503
              runtimeWorking={working === `runtime:${agent.agentId}`}
1504
              deleteWorking={working === `delete:${agent.agentId}`}
1505
              onRuntime={(endpoint) => saveRuntime(agent.agentId, endpoint)}
1506
              onDisableRuntime={() => disableRuntime(agent.agentId)}
1507
              accessTokens={data.accessTokens.filter(
1508
                (token) =>
1509
                  token.agentId === agent.agentId &&
1510
                  !token.revokedAt &&
1511
                  Date.parse(String(token.expiresAt)) > Date.now(),
1512
              )}
1513
              accessTokenWorking={working === `token:${agent.agentId}`}
1514
              onRevokeAccessToken={(tokenId) => revokeAccessToken(agent.agentId, tokenId)}
1515
              onDelete={() => deleteAgent(agent)}
1516
            />
1517
          ))
1518
        ) : (
1519
          <Empty
1520
            title="No agents connected"
1521
            text="Install the remote MCP endpoint in an agent, sign in, then create and register its identity."
1522
            action="Open connection guide"
1523
            onAction={() => navigate('connect')}
1524
          />
1525
        )}
1526
      </section>
1527
    </>
1528
  );
1529
}
1530
1531
function Invitations({
1532
  data,
1533
  refreshDashboard,
1534
  api,
1535
}: {
1536
  data: DashboardData;
1537
  refreshDashboard: () => Promise<void>;
1538
  api: (path: string, init?: RequestInit) => Promise<unknown>;
1539
}) {
1540
  const [working, setWorking] = useState('');
1541
  const [error, setError] = useState('');
1542
  const ownedAgentIds = useMemo(
1543
    () => new Set(data.agents.map((agent) => String(agent.agentId))),
1544
    [data.agents],
1545
  );
1546
  const incoming = data.federatedInteractions.filter(
1547
    (interaction) =>
1548
      interaction.status === 'pending' && ownedAgentIds.has(String(interaction.responderAgentId)),
1549
  );
1550
  const respond = async (interactionId: string, agentId: string, decision: 'accept' | 'reject') => {
1551
    setWorking(interactionId);
1552
    setError('');
1553
    try {
1554
      await api(`/v0.1/federated-interactions/${encodeURIComponent(interactionId)}/respond`, {
1555
        method: 'POST',
1556
        body: JSON.stringify({ agentId, decision }),
1557
      });
1558
      await refreshDashboard();
1559
    } catch (reason) {
1560
      setError(reason instanceof Error ? reason.message : 'Could not respond to invitation');
1561
    } finally {
1562
      setWorking('');
1563
    }
1564
  };
1565
  if (!incoming.length && !data.federatedInteractions.length) return null;
1566
  return (
1567
    <Panel
1568
      title={incoming.length ? `Agent invitations (${incoming.length})` : 'Shared interactions'}
1569
      subtitle="Both accounts see the same immutable contract and acceptance state"
1570
    >
1571
      {error ? (
1572
        <div className="errorBar" role="alert">
1573
          {error}
1574
        </div>
1575
      ) : null}
1576
      {(incoming.length ? incoming : data.federatedInteractions.slice(0, 5)).map((interaction) => (
1577
        <article className="setupRequest" key={interaction.interactionId}>
1578
          <div>
1579
            <strong>{interaction.contract?.purpose ?? interaction.interactionId}</strong>
1580
            <small>
1581
              {interaction.initiatorAgentId} → {interaction.responderAgentId} · {interaction.status}
1582
            </small>
1583
            <small>Contract: {String(interaction.termsHash).slice(0, 16)}…</small>
1584
            {data.liveSessions.find(
1585
              (session) => session.interactionId === interaction.interactionId,
1586
            ) ? (
1587
              <small className="autoAccepted">
1588
                Live session:{' '}
1589
                {
1590
                  data.liveSessions.find(
1591
                    (session) => session.interactionId === interaction.interactionId,
1592
                  )?.status
1593
                }
1594
              </small>
1595
            ) : null}
1596
            {interaction.status === 'active' &&
1597
            interaction.acceptances?.[interaction.responderAgentId]?.method ===
1598
              'policy_auto_accept' ? (
1599
              <small className="autoAccepted">✓ Auto-approved by the responder's safe policy</small>
1600
            ) : null}
1601
          </div>
1602
          {interaction.status === 'pending' &&
1603
          ownedAgentIds.has(String(interaction.responderAgentId)) ? (
1604
            <div className="decisionButtons">
1605
              <button
1606
                className="secondary"
1607
                type="button"
1608
                disabled={working === interaction.interactionId}
1609
                onClick={() =>
1610
                  void respond(interaction.interactionId, interaction.responderAgentId, 'reject')
1611
                }
1612
              >
1613
                Reject
1614
              </button>
1615
              <button
1616
                className="primary"
1617
                type="button"
1618
                disabled={working === interaction.interactionId}
1619
                onClick={() =>
1620
                  void respond(interaction.interactionId, interaction.responderAgentId, 'accept')
1621
                }
1622
              >
1623
                {working === interaction.interactionId ? 'Working…' : 'Accept contract'}
1624
              </button>
1625
            </div>
1626
          ) : null}
1627
        </article>
1628
      ))}
1629
    </Panel>
1630
  );
1631
}
1632
1633
function Insights({ data }: { data: DashboardData }) {
1634
  const eligible = data.learningEligibility.filter((item) => item.eligible);
1635
  const pendingFeedback = data.feedbackRequests.filter((item) => item.status === 'pending');
1636
  return (
1637
    <>
1638
      <PageHead page="insights" />
1639
      <section className="insightPrinciple">
1640
        <div>
1641
          <span className="eyebrow">HOW TO READ THIS</span>
1642
          <h2>Reliability is contextual, not a leaderboard.</h2>
1643
          <p>
1644
            Every profile is tied to an agent version and task category. Scores reflect eligible
1645
            structured outcomes and decay as history gets older.
1646
          </p>
1647
        </div>
1648
        <div className="insightStats">
1649
          <span>
1650
            <strong>{data.profiles.length}</strong> contextual profiles
1651
          </span>
1652
          <span>
1653
            <strong>{eligible.length}</strong> eligible interactions
1654
          </span>
1655
          <span>
1656
            <strong>{pendingFeedback.length}</strong> feedback pending
1657
          </span>
1658
        </div>
1659
      </section>
1660
      <section className="insightGrid">
1661
        {data.profiles.length ? (
1662
          data.profiles.map((profile) => {
1663
            const deltas = data.profileDeltas
1664
              .filter(
1665
                (delta) =>
1666
                  delta.agentId === profile.agentId &&
1667
                  delta.agentVersion === profile.agentVersion &&
1668
                  delta.taskCategory === profile.taskCategory,
1669
              )
1670
              .sort((left, right) => Date.parse(timestamp(right)) - Date.parse(timestamp(left)));
1671
            return (
1672
              <Panel
1673
                key={`${profile.agentId}-${profile.agentVersion}-${profile.taskCategory}`}
1674
                title={profile.agentId}
1675
                subtitle={`${humanize(profile.taskCategory)} · version ${profile.agentVersion}`}
1676
              >
1677
                <Profile profile={profile} expanded deltas={deltas} showHeader={false} />
1678
              </Panel>
1679
            );
1680
          })
1681
        ) : (
1682
          <Empty
1683
            title="Not enough verified evidence"
1684
            text="Insights grow only from signed outcomes, eligible feedback, corrections, disputes, and version-aware history."
1685
          />
1686
        )}
1687
      </section>
1688
      {!!data.learningEligibility.length && (
1689
        <Panel
1690
          title="Recent learning decisions"
1691
          subtitle="Why an interaction did or did not affect contextual history"
1692
        >
1693
          <div className="learningDecisionList">
1694
            {data.learningEligibility
1695
              .slice()
1696
              .sort((left, right) => Date.parse(timestamp(right)) - Date.parse(timestamp(left)))
1697
              .slice(0, 8)
1698
              .map((decision) => (
1699
                <article key={decision.decisionId}>
1700
                  <StatusPill value={decision.eligible ? 'eligible' : 'excluded'} />
1701
                  <div>
1702
                    <strong>{decision.interactionId}</strong>
1703
                    <small>
1704
                      {Math.round(Number(decision.sampleWeight ?? 0) * 100)}% weight ·{' '}
1705
                      {humanize(decision.contributionMode)} · {relativeTime(timestamp(decision))}
1706
                    </small>
1707
                    <p>{decision.reasons?.[0] ?? 'No reason supplied.'}</p>
1708
                  </div>
1709
                </article>
1710
              ))}
1711
          </div>
1712
        </Panel>
1713
      )}
1714
    </>
1715
  );
1716
}
1717
1718
function Connect({
1719
  data,
1720
  navigate,
1721
  refreshDashboard,
1722
  api,
1723
}: {
1724
  data: DashboardData;
1725
  navigate: (page: Page) => void;
1726
  refreshDashboard: () => Promise<void>;
1727
  api: (path: string, init?: RequestInit) => Promise<unknown>;
1728
}) {
1729
  const endpoint = 'https://openclasp.vercel.app/mcp';
1730
  const [connectionType, setConnectionType] = useState<'interactive' | 'hosted'>('hosted');
1731
  const [copied, setCopied] = useState(false);
1732
  const [tokenCopied, setTokenCopied] = useState(false);
1733
  const [working, setWorking] = useState('');
1734
  const [decisionError, setDecisionError] = useState('');
1735
  const [providerError, setProviderError] = useState('');
1736
  const [providerForm, setProviderForm] = useState({
1737
    provider: 'botpress' as 'botpress' | 'custom',
1738
    agentName: '',
1739
    projectName: '',
1740
    description: '',
1741
    capabilities: '',
1742
    limitations: '',
1743
    expiresInDays: 365,
1744
  });
1745
  const [providerResult, setProviderResult] = useState<{
1746
    provider: 'botpress' | 'custom';
1747
    agent: { agentId: string; name: string; framework: string };
1748
    accessToken: { token: string; expiresAt: string };
1749
  }>();
1750
  const pending = data.setupRequests.filter((request) => request.status === 'pending');
1751
  useEffect(() => {
1752
    const poll = () => void refreshDashboard().catch(() => undefined);
1753
    poll();
1754
    const timer = window.setInterval(poll, 5000);
1755
    return () => window.clearInterval(timer);
1756
  }, [refreshDashboard]);
1757
  const decide = async (requestId: string, decision: 'approve' | 'reject') => {
1758
    setWorking(requestId);
1759
    setDecisionError('');
1760
    try {
1761
      await api(`/v0.1/onboarding/${encodeURIComponent(requestId)}/${decision}`, {
1762
        method: 'POST',
1763
      });
1764
      await refreshDashboard();
1765
    } catch (reason) {
1766
      setDecisionError(reason instanceof Error ? reason.message : 'Could not update setup request');
1767
    } finally {
1768
      setWorking('');
1769
    }
1770
  };
1771
  const connectHostedProvider = async (event: React.FormEvent) => {
1772
    event.preventDefault();
1773
    setWorking('hosted-provider');
1774
    setProviderError('');
1775
    try {
1776
      const list = (value: string) =>
1777
        value
1778
          .split(/[,\n]/)
1779
          .map((item) => item.trim())
1780
          .filter(Boolean);
1781
      const created = (await api('/v0.1/provider-connections', {
1782
        method: 'POST',
1783
        body: JSON.stringify({
1784
          provider: providerForm.provider,
1785
          agentName: providerForm.agentName,
1786
          projectName: providerForm.projectName,
1787
          description: providerForm.description,
1788
          capabilities: list(providerForm.capabilities),
1789
          limitations: list(providerForm.limitations),
1790
          expiresInDays: providerForm.expiresInDays,
1791
        }),
1792
      })) as {
1793
        provider: 'botpress' | 'custom';
1794
        agent: { agentId: string; name: string; framework: string };
1795
        accessToken: { token: string; expiresAt: string };
1796
      };
1797
      setProviderResult(created);
1798
      await refreshDashboard().catch(() => undefined);
1799
    } catch (reason) {
1800
      setProviderError(reason instanceof Error ? reason.message : 'Could not create connection');
1801
    } finally {
1802
      setWorking('');
1803
    }
1804
  };
1805
  return (
1806
    <>
1807
      <PageHead page="connect" />
1808
      {pending.length > 0 && (
1809
        <section className="setupRequests">
1810
          <div>
1811
            <p className="eyebrow">CONFIRMATION REQUIRED</p>
1812
            <h2>Approve agent setup</h2>
1813
            <p>
1814
              An agent proposed this identity. Confirm it before OpenClasp binds the installation.
1815
            </p>
1816
          </div>
1817
          {pending.map((request) => (
1818
            <article className="setupRequest" key={request.requestId}>
1819
              <div>
1820
                <strong>{request.agentName ?? 'Switch installation'}</strong>
1821
                <small>
1822
                  {request.projectName ?? request.existingAgentId} · {request.framework}
1823
                </small>
1824
                {!!request.capabilities?.length && (
1825
                  <div className="tags">
1826
                    {request.capabilities.slice(0, 5).map((capability: string) => (
1827
                      <span key={capability}>{capability}</span>
1828
                    ))}
1829
                  </div>
1830
                )}
1831
                {!!request.limitations?.length && (
1832
                  <small>Limitations: {request.limitations.join(' · ')}</small>
1833
                )}
1834
                <div className="automationPreview">
1835
                  <span>{request.autoPublish ? 'Public after approval' : 'Private'}</span>
1836
                  <span>
1837
                    {(request.agentMode ?? 'temporary_chat') === 'temporary_chat'
1838
                      ? 'Hosted temporary A2A · encrypted history'
1839
                      : 'Persistent runtime · direct A2A'}
1840
                  </span>
1841
                  <span>
1842
                    {request.autoAcceptPolicy === 'safe_matching'
1843
                      ? 'Auto-accept safe matches'
1844
                      : 'Review every request'}
1845
                  </span>
1846
                </div>
1847
              </div>
1848
              <div className="decisionButtons">
1849
                <button
1850
                  className="secondary"
1851
                  type="button"
1852
                  disabled={working === request.requestId}
1853
                  onClick={() => void decide(request.requestId, 'reject')}
1854
                >
1855
                  Reject
1856
                </button>
1857
                <button
1858
                  className="primary"
1859
                  type="button"
1860
                  disabled={working === request.requestId}
1861
                  onClick={() => void decide(request.requestId, 'approve')}
1862
                >
1863
                  {working === request.requestId ? 'Working…' : 'Approve & automate'}
1864
                </button>
1865
              </div>
1866
            </article>
1867
          ))}
1868
          {decisionError && (
1869
            <div className="loginError" role="alert">
1870
              {decisionError}
1871
            </div>
1872
          )}
1873
        </section>
1874
      )}
1875
      <div className="connectionTypePicker" role="tablist" aria-label="Agent connection type">
1876
        <button
1877
          type="button"
1878
          role="tab"
1879
          aria-selected={connectionType === 'hosted'}
1880
          className={connectionType === 'hosted' ? 'active' : ''}
1881
          onClick={() => setConnectionType('hosted')}
1882
        >
1883
          Hosted provider
1884
          <small>Botpress and similar platforms</small>
1885
        </button>
1886
        <button
1887
          type="button"
1888
          role="tab"
1889
          aria-selected={connectionType === 'interactive'}
1890
          className={connectionType === 'interactive' ? 'active' : ''}
1891
          onClick={() => setConnectionType('interactive')}
1892
        >
1893
          Interactive agent
1894
          <small>Codex, Cursor, and OAuth clients</small>
1895
        </button>
1896
      </div>
1897
      {connectionType === 'hosted' ? (
1898
        <section className="connectLayout providerConnectLayout">
1899
          <Panel title="Add a hosted agent" subtitle="Creates an isolated identity and credential">
1900
            {providerResult ? (
1901
              <div className="providerResult">
1902
                <div className="successBanner">
1903
                  <strong>{providerResult.agent.name} created</strong>
1904
                  <span>{providerResult.agent.agentId}</span>
1905
                </div>
1906
                <label>
1907
                  <span>MCP URL</span>
1908
                  <code>{endpoint}</code>
1909
                </label>
1910
                <label>
1911
                  <span>Agent token — copy it now; it will not be shown again</span>
1912
                  <code>{providerResult.accessToken.token}</code>
1913
                </label>
1914
                <small>
1915
                  This agent-bound token authorizes MCP and automatic runtime registration. It
1916
                  cannot connect or manage another agent.
1917
                </small>
1918
                <div className="agentActions">
1919
                  <button
1920
                    className="secondary"
1921
                    type="button"
1922
                    onClick={() => {
1923
                      void navigator.clipboard.writeText(providerResult.accessToken.token);
1924
                      setTokenCopied(true);
1925
                      window.setTimeout(() => setTokenCopied(false), 2000);
1926
                    }}
1927
                  >
1928
                    {tokenCopied ? 'Copied' : 'Copy token'}
1929
                  </button>
1930
                  <button className="primary" type="button" onClick={() => navigate('agents')}>
1931
                    View agent
1932
                  </button>
1933
                </div>
1934
              </div>
1935
            ) : (
1936
              <form
1937
                className="providerForm"
1938
                onSubmit={(event) => void connectHostedProvider(event)}
1939
              >
1940
                <label>
1941
                  <span>Provider</span>
1942
                  <select
1943
                    value={providerForm.provider}
1944
                    onChange={(event) =>
1945
                      setProviderForm((value) => ({
1946
                        ...value,
1947
                        provider: event.target.value as 'botpress' | 'custom',
1948
                      }))
1949
                    }
1950
                  >
1951
                    <option value="botpress">Botpress</option>
1952
                    <option value="custom">Custom / self-hosted</option>
1953
                  </select>
1954
                </label>
1955
                <label>
1956
                  <span>Agent name</span>
1957
                  <input
1958
                    required
1959
                    maxLength={100}
1960
                    value={providerForm.agentName}
1961
                    onChange={(event) =>
1962
                      setProviderForm((value) => ({ ...value, agentName: event.target.value }))
1963
                    }
1964
                    placeholder="Recruiting agent"
1965
                  />
1966
                </label>
1967
                <label>
1968
                  <span>Project</span>
1969
                  <input
1970
                    required
1971
                    maxLength={100}
1972
                    value={providerForm.projectName}
1973
                    onChange={(event) =>
1974
                      setProviderForm((value) => ({ ...value, projectName: event.target.value }))
1975
                    }
1976
                    placeholder="Recruiting"
1977
                  />
1978
                </label>
1979
                <label className="fullWidth">
1980
                  <span>Purpose</span>
1981
                  <textarea
1982
                    maxLength={500}
1983
                    value={providerForm.description}
1984
                    onChange={(event) =>
1985
                      setProviderForm((value) => ({ ...value, description: event.target.value }))
1986
                    }
1987
                    placeholder="Matches suitable candidates with open roles"
1988
                  />
1989
                </label>
1990
                <label>
1991
                  <span>Capabilities, comma-separated</span>
1992
                  <input
1993
                    value={providerForm.capabilities}
1994
                    onChange={(event) =>
1995
                      setProviderForm((value) => ({ ...value, capabilities: event.target.value }))
1996
                    }
1997
                    placeholder="candidate matching, interview coordination"
1998
                  />
1999
                </label>
2000
                <label>
2001
                  <span>Limitations, comma-separated</span>
2002
                  <input
2003
                    value={providerForm.limitations}
2004
                    onChange={(event) =>
2005
                      setProviderForm((value) => ({ ...value, limitations: event.target.value }))
2006
                    }
2007
                    placeholder="no final hiring decisions"
2008
                  />
2009
                </label>
2010
                <label>
2011
                  <span>Credential expiry</span>
2012
                  <select
2013
                    value={providerForm.expiresInDays}
2014
                    onChange={(event) =>
2015
                      setProviderForm((value) => ({
2016
                        ...value,
2017
                        expiresInDays: Number(event.target.value),
2018
                      }))
2019
                    }
2020
                  >
2021
                    <option value={30}>30 days</option>
2022
                    <option value={90}>90 days</option>
2023
                    <option value={365}>1 year</option>
2024
                  </select>
2025
                </label>
2026
                <div className="providerSubmit fullWidth">
2027
                  <p>This does not reuse or modify your Codex agent.</p>
2028
                  <button
2029
                    className="primary"
2030
                    type="submit"
2031
                    disabled={working === 'hosted-provider'}
2032
                  >
2033
                    {working === 'hosted-provider' ? 'Creating…' : 'Create agent credentials'}
2034
                  </button>
2035
                </div>
2036
                {providerError ? (
2037
                  <div className="loginError fullWidth" role="alert">
2038
                    {providerError}
2039
                  </div>
2040
                ) : null}
2041
              </form>
2042
            )}
2043
          </Panel>
2044
          <Panel
2045
            title={
2046
              providerForm.provider === 'botpress' ? 'Configure Botpress' : 'Deploy the sidecar'
2047
            }
2048
            subtitle={
2049
              providerForm.provider === 'botpress'
2050
                ? 'MCP and inbound runtime are separate connections'
2051
                : 'Runs beside an agent on any cloud'
2052
            }
2053
          >
2054
            {providerForm.provider === 'botpress' ? (
2055
              <>
2056
                <div className="connectSteps">
2057
                  <div className="connectStep">
2058
                    <b>1</b>
2059
                    <span>Add the displayed MCP URL with Bearer authentication.</span>
2060
                  </div>
2061
                  <div className="connectStep">
2062
                    <b>2</b>
2063
                    <span>
2064
                      Paste the generated <code>oc_at_…</code> agent token.
2065
                    </span>
2066
                  </div>
2067
                  <div className="connectStep">
2068
                    <b>3</b>
2069
                    <span>Install the OpenClasp Botpress runtime integration for inbound A2A.</span>
2070
                  </div>
2071
                </div>
2072
                <div className="notice providerNotice">
2073
                  MCP proves outbound tool access only. Autonomous runtime becomes connected only
2074
                  after the Botpress integration registers its webhook successfully.
2075
                </div>
2076
              </>
2077
            ) : (
2078
              <>
2079
                <div className="connectSteps">
2080
                  <div className="connectStep">
2081
                    <b>1</b>
2082
                    <span>
2083
                      Deploy <code>Dockerfile.sidecar</code> beside the agent.
2084
                    </span>
2085
                  </div>
2086
                  <div className="connectStep">
2087
                    <b>2</b>
2088
                    <span>
2089
                      Set <code>OPENCLASP_AGENT_TOKEN</code>, <code>OPENCLASP_RUNTIME_URL</code>,
2090
                      and <code>AGENT_ADAPTER_URL</code>.
2091
                    </span>
2092
                  </div>
2093
                  <div className="connectStep">
2094
                    <b>3</b>
2095
                    <span>
2096
                      The sidecar verifies and registers itself; no endpoint paste is needed.
2097
                    </span>
2098
                  </div>
2099
                </div>
2100
                <div className="notice providerNotice">
2101
                  The application implements three internal POST hooks: session-offer,
2102
                  session-activated, and message. Agent-to-agent content remains off OpenClasp.
2103
                </div>
2104
              </>
2105
            )}
2106
          </Panel>
2107
        </section>
2108
      ) : (
2109
        <section className="connectLayout">
2110
          <Panel title="Connect with OAuth" subtitle="For interactive MCP clients">
2111
            <div className="endpoint">
2112
              <code>{endpoint}</code>
2113
              <button
2114
                type="button"
2115
                onClick={() => {
2116
                  void navigator.clipboard.writeText(endpoint);
2117
                  setCopied(true);
2118
                  window.setTimeout(() => setCopied(false), 2000);
2119
                }}
2120
              >
2121
                {copied ? 'Copied' : 'Copy'}
2122
              </button>
2123
            </div>
2124
            <div className="connectSteps">
2125
              <div className="connectStep">
2126
                <b>1</b>
2127
                <span>Add this MCP URL and complete OAuth.</span>
2128
              </div>
2129
              <div className="connectStep">
2130
                <b>2</b>
2131
                <span>
2132
                  Tell the agent: <code>Set yourself up on OpenClasp</code>.
2133
                </span>
2134
              </div>
2135
              <div className="connectStep">
2136
                <b>3</b>
2137
                <span>Approve the proposed identity and mode here.</span>
2138
              </div>
2139
            </div>
2140
          </Panel>
2141
          <Panel title="Interactive clients" subtitle="Best for temporary user-driven sessions">
2142
            <ul className="checkList">
2143
              <li>OAuth opens in the browser</li>
2144
              <li>Each installation is explicitly approved</li>
2145
              <li>Temporary chats receive hosted A2A history</li>
2146
              <li>Persistent runtimes can switch to direct A2A</li>
2147
            </ul>
2148
          </Panel>
2149
        </section>
2150
      )}
2151
    </>
2152
  );
2153
}
2154
2155
function SettingsPage({
2156
  settings,
2157
  setSettings,
2158
  api,
2159
}: {
2160
  settings: Settings;
2161
  setSettings: React.Dispatch<React.SetStateAction<Settings>>;
2162
  api: (path: string, init?: RequestInit) => Promise<unknown>;
2163
}) {
2164
  const [saving, setSaving] = useState(false);
2165
  const [saved, setSaved] = useState(false);
2166
  const [error, setError] = useState('');
2167
  const save = useCallback(async () => {
2168
    setSaving(true);
2169
    setSaved(false);
2170
    setError('');
2171
    try {
2172
      const updated = (await api('/v0.1/settings', {
2173
        method: 'PUT',
2174
        body: JSON.stringify(settings),
2175
      })) as Settings;
2176
      setSettings(updated);
2177
      setSaved(true);
2178
    } catch (reason) {
2179
      setError(reason instanceof Error ? reason.message : 'Could not save settings');
2180
    } finally {
2181
      setSaving(false);
2182
    }
2183
  }, [api, settings, setSettings]);
2184
  return (
2185
    <>
2186
      <PageHead page="settings" />
2187
      <section className="settingsCard">
2188
        <Setting label="Display name" description="Shown only inside your OpenClasp account.">
2189
          <input
2190
            value={settings.displayName}
2191
            onChange={(event) =>
2192
              setSettings((value) => ({ ...value, displayName: event.target.value }))
2193
            }
2194
            placeholder="Team or operator name"
2195
          />
2196
        </Setting>
2197
        <Setting
2198
          label="Network contribution"
2199
          description="Allow eligible structured aggregates to improve contextual intelligence. Raw conversations are never included."
2200
        >
2201
          <Toggle
2202
            checked={settings.contributionEnabled}
2203
            label="Network contribution"
2204
            onChange={(checked) =>
2205
              setSettings((value) => ({ ...value, contributionEnabled: checked }))
2206
            }
2207
          />
2208
        </Setting>
2209
        <Setting
2210
          label="Evidence sharing"
2211
          description="Controls when permitted evidence references may be shared with interaction participants."
2212
        >
2213
          <select
2214
            value={settings.evidenceSharing}
2215
            onChange={(event) =>
2216
              setSettings((value) => ({
2217
                ...value,
2218
                evidenceSharing: event.target.value as Settings['evidenceSharing'],
2219
              }))
2220
            }
2221
          >
2222
            <option value="never">Never</option>
2223
            <option value="ask">Ask every time</option>
2224
            <option value="contract_only">When contract permits</option>
2225
          </select>
2226
        </Setting>
2227
        <Setting
2228
          label="Structured record retention"
2229
          description="Retention for hosted account records. Signed protocol records may require separate revocation markers."
2230
        >
2231
          <select
2232
            value={settings.retentionDays}
2233
            onChange={(event) =>
2234
              setSettings((value) => ({ ...value, retentionDays: Number(event.target.value) }))
2235
            }
2236
          >
2237
            <option value={7}>7 days</option>
2238
            <option value={30}>30 days</option>
2239
            <option value={90}>90 days</option>
2240
            <option value={365}>1 year</option>
2241
            <option value={0}>No automatic deletion</option>
2242
          </select>
2243
        </Setting>
2244
        <Setting
2245
          label="Raw agent messages"
2246
          description="Persistent runtimes remain direct and are not stored. Temporary chat messages pass through OpenClasp and are encrypted at rest for 30 days."
2247
        >
2248
          <span className="locked">MODE DEPENDENT</span>
2249
        </Setting>
2250
        <div className="saveRow">
2251
          <button className="primary" type="button" onClick={() => void save()} disabled={saving}>
2252
            {saving ? 'Saving…' : 'Save settings'}
2253
          </button>
2254
          {saved && <span role="status">Saved</span>}
2255
          {error && (
2256
            <span className="loginError" role="alert">
2257
              {error}
2258
            </span>
2259
          )}
2260
        </div>
2261
      </section>
2262
    </>
2263
  );
2264
}
2265
2266
function PageHead({
2267
  page,
2268
  action,
2269
  onAction,
2270
}: {
2271
  page: Page;
2272
  action?: string;
2273
  onAction?: () => void;
2274
}) {
2275
  const meta = pageMeta[page];
2276
  return (
2277
    <header className="pageHead">
2278
      <div>
2279
        <p className="eyebrow">{meta.eyebrow}</p>
2280
        <h1>{meta.title}</h1>
2281
        <p className="lede">{meta.lede}</p>
2282
      </div>
2283
      {action && (
2284
        <button className="primary" type="button" onClick={onAction}>
2285
          {action}
2286
        </button>
2287
      )}
2288
    </header>
2289
  );
2290
}
2291
function Nav({
2292
  page,
2293
  active,
2294
  onClick,
2295
  label,
2296
  icon,
2297
  badge = 0,
2298
}: {
2299
  page: Page;
2300
  active: Page;
2301
  onClick: (page: Page) => void;
2302
  label: string;
2303
  icon: IconName;
2304
  badge?: number;
2305
}) {
2306
  return (
2307
    <button
2308
      className={active === page ? 'active' : ''}
2309
      type="button"
2310
      aria-current={active === page ? 'page' : undefined}
2311
      onClick={() => onClick(page)}
2312
    >
2313
      <Icon name={icon} />
2314
      {label}
2315
      {badge > 0 ? <span className="navBadge">{badge}</span> : null}
2316
    </button>
2317
  );
2318
}
2319
function Metric({
2320
  label,
2321
  value,
2322
  note,
2323
  warn,
2324
}: {
2325
  label: string;
2326
  value: number;
2327
  note: string;
2328
  warn?: boolean;
2329
}) {
2330
  return (
2331
    <article className={warn ? 'metric warn' : 'metric'}>
2332
      <span>{label}</span>
2333
      <strong>{value}</strong>
2334
      <small>{note}</small>
2335
    </article>
2336
  );
2337
}
2338
function Panel({
2339
  title,
2340
  subtitle,
2341
  children,
2342
}: React.PropsWithChildren<{ title: string; subtitle: string }>) {
2343
  return (
2344
    <section className="panel">
2345
      <div className="panelHead">
2346
        <div>
2347
          <h2>{title}</h2>
2348
          <p>{subtitle}</p>
2349
        </div>
2350
      </div>
2351
      {children}
2352
    </section>
2353
  );
2354
}
2355
function Timeline({ events }: { events: Record<string, any>[] }) {
2356
  return events.length ? (
2357
    <div className="timeline">
2358
      {events.map((event) => (
2359
        <HistoryRow key={event.eventId} item={{ ...event, _kind: 'event' }} />
2360
      ))}
2361
    </div>
2362
  ) : (
2363
    <Empty
2364
      title="Nothing recorded yet"
2365
      text="Activity appears after your connected agents submit signed structured events."
2366
    />
2367
  );
2368
}
2369
function HistoryRow({ item }: { item: Record<string, any> }) {
2370
  const kind = String(item.eventType ?? item._kind ?? 'record').replaceAll('_', ' ');
2371
  const id = String(item.agentId ?? item.interactionId ?? item.receiptId ?? 'OpenClasp');
2372
  return (
2373
    <article className="historyRow">
2374
      <span className={`eventDot ${item.eventType ?? item._kind}`} />
2375
      <div>
2376
        <strong>{kind}</strong>
2377
        <small>
2378
          {id} · {new Date(timestamp(item)).toLocaleString()}
2379
        </small>
2380
      </div>
2381
      <b>{item.visibility ?? item.outcome ?? item.status ?? 'SIGNED'}</b>
2382
    </article>
2383
  );
2384
}
2385
function AgentCard({
2386
  agent,
2387
  projectName,
2388
  published,
2389
  working,
2390
  onSave,
2391
  runtime,
2392
  runtimeWorking,
2393
  deleteWorking,
2394
  onRuntime,
2395
  onDisableRuntime,
2396
  accessTokens,
2397
  accessTokenWorking,
2398
  onRevokeAccessToken,
2399
  onDelete,
2400
}: {
2401
  agent: Record<string, any>;
2402
  projectName?: string;
2403
  published: boolean;
2404
  working: boolean;
2405
  onSave: (value: {
2406
    autoPublish: boolean;
2407
    autoAcceptPolicy: 'off' | 'safe_matching';
2408
    autoAcceptTaskCategories: string[];
2409
  }) => Promise<boolean>;
2410
  runtime: Record<string, any> | undefined;
2411
  runtimeWorking: boolean;
2412
  deleteWorking: boolean;
2413
  onRuntime: (endpoint: string) => void;
2414
  onDisableRuntime: () => void;
2415
  accessTokens: Record<string, any>[];
2416
  accessTokenWorking: boolean;
2417
  onRevokeAccessToken: (tokenId: string) => Promise<void>;
2418
  onDelete: () => void;
2419
}) {
2420
  const [editing, setEditing] = useState(false);
2421
  const [autoPublish, setAutoPublish] = useState(Boolean(agent.autoPublish ?? published));
2422
  const [autoAcceptPolicy, setAutoAcceptPolicy] = useState<'off' | 'safe_matching'>(
2423
    agent.autoAcceptPolicy === 'safe_matching' ? 'safe_matching' : 'off',
2424
  );
2425
  const [categories, setCategories] = useState<string>(
2426
    (agent.autoAcceptTaskCategories?.length
2427
      ? agent.autoAcceptTaskCategories
2428
      : (agent.capabilities ?? [])
2429
    ).join(', '),
2430
  );
2431
  const [runtimeEndpoint, setRuntimeEndpoint] = useState(String(runtime?.endpoint ?? ''));
2432
  const mode = agent.agentMode ?? (agent.a2aEndpoint ? 'persistent_runtime' : 'temporary_chat');
2433
  const temporary = mode === 'temporary_chat';
2434
  const ready = published && (temporary || runtime?.status === 'verified');
2435
  const online = agent.presence?.status === 'online';
2436
  const providerConnected = accessTokens.length > 0;
2437
  const endpoint = String(
2438
    temporary
2439
      ? `/a2a/temporary/${encodeURIComponent(agent.agentId)}`
2440
      : (runtime?.a2aEndpoint ?? agent.a2aEndpoint ?? 'Connect a runtime first'),
2441
  );
2442
  const identityLabel = agent.revoked
2443
    ? 'REVOKED'
2444
    : agent.identityMode === 'owner_managed'
2445
      ? 'OWNER VERIFIED'
2446
      : agent.identityMode === 'oauth_installation'
2447
        ? 'AUTHENTICATED'
2448
        : 'VERIFIED';
2449
  return (
2450
    <article className="agentCard">
2451
      <div className="agentTop">
2452
        <span className="agentGlyph">
2453
          <Icon name="agents" />
2454
        </span>
2455
        <div className="agentBadges">
2456
          <b className={online && temporary ? 'onlineBadge' : 'offlineBadge'}>
2457
            {temporary
2458
              ? online
2459
                ? 'CHAT ACTIVE'
2460
                : 'CHAT IDLE'
2461
              : runtime?.status === 'verified'
2462
                ? 'ENDPOINT VERIFIED'
2463
                : providerConnected
2464
                  ? 'TOKEN ONLY'
2465
                  : 'ENDPOINT MISSING'}
2466
          </b>
2467
          <b className={agent.revoked ? 'bad' : ''}>{identityLabel}</b>
2468
          <b className={ready ? 'readyBadge' : 'needsBadge'}>{ready ? 'READY' : 'SETUP NEEDED'}</b>
2469
        </div>
2470
      </div>
2471
      <h2>{agent.name ?? agent.agentId}</h2>
2472
      <p>{projectName ?? `Version ${agent.agentVersion ?? '1.0.0'}`}</p>
2473
      <div className="tags">
2474
        {(agent.capabilities ?? []).slice(0, 4).map((capability: string) => (
2475
          <span key={capability}>{capability}</span>
2476
        ))}
2477
      </div>
2478
      <small>
2479
        {agent.framework ? `${agent.framework} · ` : ''}
2480
        {agent.identityMode === 'owner_managed'
2481
          ? 'Owner-created · '
2482
          : agent.identityMode === 'oauth_installation'
2483
            ? 'OAuth-bound · '
2484
            : 'Ed25519 · '}
2485
        Created {new Date(agent.createdAt).toLocaleDateString()}
2486
      </small>
2487
      <small>
2488
        {agent.presence?.lastSeenAt
2489
          ? `Last agent activity ${new Date(agent.presence.lastSeenAt).toLocaleString()}`
2490
          : 'No agent activity recorded yet'}
2491
      </small>
2492
      {published ? (
2493
        <a
2494
          href={`/agents/${encodeURIComponent(agent.agentId)}/card.json`}
2495
          target="_blank"
2496
          rel="noreferrer"
2497
        >
2498
          Public Agent Card
2499
        </a>
2500
      ) : null}
2501
      <div className="automationSummary">
2502
        <span>{published ? 'Public discovery' : 'Private'}</span>
2503
        <span>{temporary ? 'Hosted temporary A2A' : 'Direct agent-owned A2A'}</span>
2504
        <span>
2505
          {agent.autoAcceptPolicy === 'safe_matching' ? 'Safe tasks automatic' : 'Manual approval'}
2506
        </span>
2507
      </div>
2508
      <div className="runtimeBox">
2509
        <div>
2510
          <strong>{temporary ? 'Temporary chat endpoint' : 'Autonomous runtime'}</strong>
2511
          <span
2512
            className={
2513
              temporary || runtime?.status === 'verified' ? 'runtimeLive' : 'runtimeMissing'
2514
            }
2515
          >
2516
            {temporary
2517
              ? 'HOSTED BY OPENCLASP'
2518
              : runtime?.status === 'verified'
2519
                ? 'VERIFIED'
2520
                : 'NOT CONNECTED'}
2521
          </span>
2522
        </div>
2523
        <p>
2524
          {temporary
2525
            ? 'OpenClasp receives A2A for this temporary identity and encrypts its history at rest. Connect a runtime below to switch to direct A2A.'
2526
            : runtime?.status === 'verified'
2527
              ? 'The agent runtime registered itself successfully. Live message bodies travel directly between agents.'
2528
              : providerConnected
2529
                ? 'MCP is connected, but inbound A2A is not. Install the provider connector or deploy the sidecar; it registers this endpoint automatically.'
2530
                : 'Install a provider connector, deploy the sidecar, or register a native A2A endpoint.'}
2531
        </p>
2532
        <input
2533
          type="url"
2534
          value={runtimeEndpoint}
2535
          onChange={(event) => setRuntimeEndpoint(event.target.value)}
2536
          placeholder="https://agent.example.com/openclasp"
2537
        />
2538
        <small>
2539
          Manual registration is only for a runtime that already implements OpenClasp A2A.
2540
        </small>
2541
        <div className="agentActions">
2542
          {runtime?.status === 'verified' ? (
2543
            <button
2544
              className="secondary"
2545
              type="button"
2546
              disabled={runtimeWorking}
2547
              onClick={onDisableRuntime}
2548
            >
2549
              Disable runtime
2550
            </button>
2551
          ) : null}
2552
          <button
2553
            className="primary"
2554
            type="button"
2555
            disabled={runtimeWorking || !runtimeEndpoint.trim()}
2556
            onClick={() => onRuntime(runtimeEndpoint.trim())}
2557
          >
2558
            {runtimeWorking ? 'Verifying…' : runtime ? 'Rotate connection' : 'Verify & connect'}
2559
          </button>
2560
        </div>
2561
        {runtime?.lastError ? <small>Last session error: {runtime.lastError}</small> : null}
2562
      </div>
2563
      {accessTokens.length ? (
2564
        <div className="accessTokenBox">
2565
          <div className="accessTokenHead">
2566
            <div>
2567
              <strong>Provider connections</strong>
2568
              <span>{accessTokens.length} ACTIVE</span>
2569
            </div>
2570
            <p>Created through Connect. Revoke a provider here if its credential is exposed.</p>
2571
          </div>
2572
          <div className="tokenList">
2573
            {accessTokens.map((token) => (
2574
              <div key={token.tokenId}>
2575
                <span>
2576
                  <strong>{token.name}</strong>
2577
                  <small>
2578
                    Created {new Date(token.createdAt).toLocaleDateString()} · expires{' '}
2579
                    {new Date(token.expiresAt).toLocaleDateString()}
2580
                    {token.lastUsedAt
2581
                      ? ` · used ${new Date(token.lastUsedAt).toLocaleString()}`
2582
                      : ' · never used'}
2583
                  </small>
2584
                </span>
2585
                <button
2586
                  className="secondary dangerButton"
2587
                  type="button"
2588
                  disabled={accessTokenWorking}
2589
                  onClick={() => {
2590
                    if (window.confirm(`Revoke “${String(token.name)}”? Botpress will disconnect.`))
2591
                      void onRevokeAccessToken(String(token.tokenId));
2592
                  }}
2593
                >
2594
                  Revoke
2595
                </button>
2596
              </div>
2597
            ))}
2598
          </div>
2599
        </div>
2600
      ) : null}
2601
      {editing ? (
2602
        <div className="automationForm">
2603
          <label>
2604
            <span>
2605
              {temporary ? 'OpenClasp temporary A2A endpoint' : 'Agent-owned A2A endpoint'}
2606
            </span>
2607
            <input type="url" value={endpoint} readOnly />
2608
          </label>
2609
          <label>
2610
            <span>Invitation policy</span>
2611
            <select
2612
              value={autoAcceptPolicy}
2613
              onChange={(event) =>
2614
                setAutoAcceptPolicy(event.target.value as 'off' | 'safe_matching')
2615
              }
2616
            >
2617
              <option value="safe_matching">Auto-accept safe matches</option>
2618
              <option value="off">Review every request</option>
2619
            </select>
2620
          </label>
2621
          <label>
2622
            <span>Safe task categories</span>
2623
            <input
2624
              value={categories}
2625
              onChange={(event) => setCategories(event.target.value)}
2626
              placeholder="research, planning, coding"
2627
            />
2628
          </label>
2629
          <label className="checkControl">
2630
            <input
2631
              type="checkbox"
2632
              checked={autoPublish}
2633
              onChange={(event) => setAutoPublish(event.target.checked)}
2634
            />
2635
            <span>Publish and keep Agent Card updated</span>
2636
          </label>
2637
          <div className="agentActions">
2638
            <button className="secondary" type="button" onClick={() => setEditing(false)}>
2639
              Cancel
2640
            </button>
2641
            <button
2642
              className="primary"
2643
              type="button"
2644
              disabled={working || agent.status === 'revoked'}
2645
              onClick={() =>
2646
                void onSave({
2647
                  autoPublish,
2648
                  autoAcceptPolicy,
2649
                  autoAcceptTaskCategories: categories
2650
                    .split(',')
2651
                    .map((value) => value.trim())
2652
                    .filter(Boolean),
2653
                }).then((ok) => {
2654
                  if (ok) setEditing(false);
2655
                })
2656
              }
2657
            >
2658
              {working ? 'Saving…' : 'Save automation'}
2659
            </button>
2660
          </div>
2661
        </div>
2662
      ) : (
2663
        <div className="agentActions agentManagementActions">
2664
          <button
2665
            className="secondary dangerButton"
2666
            type="button"
2667
            disabled={working || runtimeWorking || deleteWorking}
2668
            onClick={onDelete}
2669
          >
2670
            {deleteWorking ? 'Deleting…' : 'Delete agent'}
2671
          </button>
2672
          <button
2673
            className="secondary"
2674
            type="button"
2675
            disabled={working || runtimeWorking || deleteWorking || agent.status === 'revoked'}
2676
            onClick={() => setEditing(true)}
2677
          >
2678
            Configure automation
2679
          </button>
2680
        </div>
2681
      )}
2682
    </article>
2683
  );
2684
}
2685
function Profile({
2686
  profile,
2687
  expanded,
2688
  deltas = [],
2689
  showHeader = true,
2690
}: {
2691
  profile: Record<string, any>;
2692
  expanded?: boolean;
2693
  deltas?: Record<string, any>[];
2694
  showHeader?: boolean;
2695
}) {
2696
  const dimensions = [
2697
    ['Completion', profile.completion],
2698
    ['Acceptance', profile.acceptance],
2699
    ['Specification', profile.specification],
2700
    ['Scope', profile.scope],
2701
    ['Evidence', profile.evidence],
2702
    ...(expanded
2703
      ? [
2704
          ['Communication', profile.communication],
2705
          ['Deadline', profile.deadline],
2706
          ['Dispute-free', 1 - Number(profile.disputes ?? 0)],
2707
        ]
2708
      : []),
2709
  ] as [string, number][];
2710
  const latestDelta = deltas[0];
2711
  return (
2712
    <article className="profile">
2713
      <div className={showHeader ? 'profileHead' : 'profileHead profileHeadCompact'}>
2714
        {showHeader ? (
2715
          <div>
2716
            <strong>{profile.agentId}</strong>
2717
            <small>
2718
              {humanize(profile.taskCategory)} · version {profile.agentVersion ?? 'unknown'}
2719
            </small>
2720
          </div>
2721
        ) : (
2722
          <small>
2723
            Effective evidence{' '}
2724
            {Number(profile.effectiveSampleSize ?? profile.sampleSize ?? 0).toFixed(1)}
2725
            {profile.updatedAt ? ` · updated ${relativeTime(profile.updatedAt)}` : ''}
2726
          </small>
2727
        )}
2728
        <span className="sampleBadge">
2729
          {profile.sampleSize ?? 0} outcome{Number(profile.sampleSize) === 1 ? '' : 's'}
2730
        </span>
2731
      </div>
2732
      {showHeader ? (
2733
        <div className="profileEvidenceLine">
2734
          <small>
2735
            Effective evidence{' '}
2736
            {Number(profile.effectiveSampleSize ?? profile.sampleSize ?? 0).toFixed(1)}
2737
            {profile.updatedAt ? ` · updated ${relativeTime(profile.updatedAt)}` : ''}
2738
          </small>
2739
        </div>
2740
      ) : null}
2741
      {dimensions.map(([label, value]) => (
2742
        <Meter key={label} label={label} value={Number(value ?? 0.5)} />
2743
      ))}
2744
      {latestDelta ? (
2745
        <div className="profileDelta">
2746
          <span>Latest evidence impact</span>
2747
          <div>
2748
            {Object.entries(latestDelta.dimensionDeltas ?? {})
2749
              .filter(([, value]) => Math.abs(Number(value)) >= 0.001)
2750
              .slice(0, 5)
2751
              .map(([dimension, value]) => (
2752
                <b className={Number(value) >= 0 ? 'positive' : 'negative'} key={dimension}>
2753
                  {humanize(dimension)} {Number(value) >= 0 ? '+' : ''}
2754
                  {(Number(value) * 100).toFixed(1)}
2755
                </b>
2756
              ))}
2757
          </div>
2758
        </div>
2759
      ) : null}
2760
    </article>
2761
  );
2762
}
2763
function Meter({ label, value }: { label: string; value: number }) {
2764
  const bounded = Math.max(0, Math.min(1, value));
2765
  return (
2766
    <div className="meter">
2767
      <span>{label}</span>
2768
      <i>
2769
        <b style={{ width: `${Math.round(bounded * 100)}%` }} />
2770
      </i>
2771
      <strong>{Math.round(bounded * 100)}%</strong>
2772
    </div>
2773
  );
2774
}
2775
function Empty({
2776
  title,
2777
  text,
2778
  action,
2779
  onAction,
2780
}: {
2781
  title: string;
2782
  text: string;
2783
  action?: string;
2784
  onAction?: () => void;
2785
}) {
2786
  return (
2787
    <div className="empty">
2788
      <Icon name="agents" />
2789
      <strong>{title}</strong>
2790
      <p>{text}</p>
2791
      {action && (
2792
        <button className="secondary" type="button" onClick={onAction}>
2793
          {action}
2794
        </button>
2795
      )}
2796
    </div>
2797
  );
2798
}
2799
function TextButton({ children, onClick }: React.PropsWithChildren<{ onClick: () => void }>) {
2800
  return (
2801
    <button className="textButton" type="button" onClick={onClick}>
2802
      {children}
2803
    </button>
2804
  );
2805
}
2806
function Setting({
2807
  label,
2808
  description,
2809
  children,
2810
}: React.PropsWithChildren<{ label: string; description: string }>) {
2811
  return (
2812
    <div className="setting">
2813
      <div>
2814
        <strong>{label}</strong>
2815
        <p>{description}</p>
2816
      </div>
2817
      <div>{children}</div>
2818
    </div>
2819
  );
2820
}
2821
function Toggle({
2822
  checked,
2823
  onChange,
2824
  label,
2825
}: {
2826
  checked: boolean;
2827
  onChange: (checked: boolean) => void;
2828
  label?: string;
2829
}) {
2830
  return (
2831
    <button
2832
      className={checked ? 'toggle on' : 'toggle'}
2833
      type="button"
2834
      role="switch"
2835
      aria-checked={checked}
2836
      aria-label={label}
2837
      onClick={() => onChange(!checked)}
2838
    >
2839
      <span />
2840
    </button>
2841
  );
2842
}
2843
function Loading({ compact }: { compact?: boolean }) {
2844
  return (
2845
    <div className={compact ? 'loading compact' : 'loading'}>
2846
      <span className="spinner" aria-hidden="true" />
2847
      <p>Verifying session…</p>
2848
    </div>
2849
  );
2850
}
2851
2852
type IconName =
2853
  'home' | 'history' | 'agents' | 'insights' | 'connect' | 'settings' | 'sun' | 'moon';
2854
2855
function Icon({ name }: { name: IconName }) {
2856
  return (
2857
    <svg
2858
      viewBox="0 0 24 24"
2859
      fill="none"
2860
      stroke="currentColor"
2861
      strokeWidth="1.8"
2862
      strokeLinecap="round"
2863
      strokeLinejoin="round"
2864
      aria-hidden="true"
2865
    >
2866
      {name === 'home' && <path d="M4 11 12 4l8 7v9a1 1 0 0 1-1 1h-5v-6H10v6H5a1 1 0 0 1-1-1z" />}
2867
      {name === 'history' && (
2868
        <>
2869
          <path d="M4 12a8 8 0 1 0 2.2-5.5" />
2870
          <path d="M4 4v5h5" />
2871
          <path d="M12 8v5l3 2" />
2872
        </>
2873
      )}
2874
      {name === 'agents' && (
2875
        <>
2876
          <path d="M8 11a3 3 0 1 0 0-6 3 3 0 0 0 0 6" />
2877
          <path d="M16 11a3 3 0 1 0 0-6 3 3 0 0 0 0 6" />
2878
          <path d="M4 20v-1a4 4 0 0 1 4-4h1" />
2879
          <path d="M20 20v-1a4 4 0 0 0-4-4h-1" />
2880
        </>
2881
      )}
2882
      {name === 'insights' && (
2883
        <>
2884
          <path d="M4 19h16" />
2885
          <path d="M7 16V9" />
2886
          <path d="M12 16V5" />
2887
          <path d="M17 16v-6" />
2888
        </>
2889
      )}
2890
      {name === 'connect' && (
2891
        <>
2892
          <path d="M12 5v14" />
2893
          <path d="M5 12h14" />
2894
        </>
2895
      )}
2896
      {name === 'settings' && (
2897
        <>
2898
          <circle cx="12" cy="12" r="3" />
2899
          <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" />
2900
        </>
2901
      )}
2902
      {name === 'sun' && (
2903
        <>
2904
          <circle cx="12" cy="12" r="4" />
2905
          <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" />
2906
        </>
2907
      )}
2908
      {name === 'moon' && <path d="M16 13a6 6 0 0 1-7.8-7.8A7 7 0 1 0 16 13" />}
2909
    </svg>
2910
  );
2911
}
2912
2913
function GoogleMark() {
2914
  return (
2915
    <svg viewBox="0 0 24 24" aria-hidden="true">
2916
      <path
2917
        fill="#4285F4"
2918
        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"
2919
      />
2920
      <path
2921
        fill="#34A853"
2922
        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"
2923
      />
2924
      <path
2925
        fill="#FBBC05"
2926
        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"
2927
      />
2928
      <path
2929
        fill="#EA4335"
2930
        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"
2931
      />
2932
    </svg>
2933
  );
2934
}
2935
2936
function GitHubMark() {
2937
  return (
2938
    <svg viewBox="0 0 24 24" aria-hidden="true">
2939
      <path
2940
        fill="currentColor"
2941
        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"
2942
      />
2943
    </svg>
2944
  );
2945
}
2946
2947
function humanize(value: unknown) {
2948
  const text = String(value ?? '')
2949
    .replaceAll('_', ' ')
2950
    .trim();
2951
  return text ? text[0]!.toUpperCase() + text.slice(1) : 'Unknown';
2952
}
2953
2954
function formatDate(value: string) {
2955
  const date = new Date(value);
2956
  return Number.isNaN(date.getTime()) ? 'Time unavailable' : date.toLocaleString();
2957
}
2958
2959
function relativeTime(value: string) {
2960
  const milliseconds = Date.now() - Date.parse(value);
2961
  if (!Number.isFinite(milliseconds)) return 'Time unavailable';
2962
  const minutes = Math.round(Math.abs(milliseconds) / 60_000);
2963
  const future = milliseconds < 0;
2964
  const phrase =
2965
    minutes < 1
2966
      ? 'just now'
2967
      : minutes < 60
2968
        ? `${minutes}m`
2969
        : minutes < 1_440
2970
          ? `${Math.round(minutes / 60)}h`
2971
          : `${Math.round(minutes / 1_440)}d`;
2972
  return minutes < 1 ? phrase : future ? `in ${phrase}` : `${phrase} ago`;
2973
}
2974
2975
function feedbackState(requests: Record<string, any>[]) {
2976
  if (!requests.length) return 'Starts after a completion report';
2977
  const pending = requests.filter((request) => request.status === 'pending').length;
2978
  if (pending) return `${pending} response${pending === 1 ? '' : 's'} still pending`;
2979
  const expired = requests.filter((request) => request.status === 'expired').length;
2980
  return expired
2981
    ? `${expired} request${expired === 1 ? '' : 's'} timed out`
2982
    : 'Bilateral feedback closed';
2983
}
2984
2985
function StatusPill({ value }: { value: string }) {
2986
  const normalized = String(value).toLowerCase();
2987
  const tone = [
2988
    'success',
2989
    'active',
2990
    'ready',
2991
    'met',
2992
    'eligible',
2993
    'attested',
2994
    'allow',
2995
    'completed',
2996
  ].includes(normalized)
2997
    ? 'good'
2998
    : ['failure', 'denied', 'deny', 'missed', 'rejected', 'violation'].some((item) =>
2999
          normalized.includes(item),
3000
        )
3001
      ? 'danger'
3002
      : ['partial', 'pending', 'challenge', 'conflict', 'expired', 'excluded'].some((item) =>
3003
            normalized.includes(item),
3004
          )
3005
        ? 'warn'
3006
        : 'neutral';
3007
  return <span className={`statusPill ${tone}`}>{humanize(value)}</span>;
3008
}
3009
3010
function initials(value: string) {
3011
  return value
3012
    .split(/\s+|@/)
3013
    .slice(0, 2)
3014
    .map((part) => part[0]?.toUpperCase())
3015
    .join('');
3016
}
3017
function timestamp(item: Record<string, any>) {
3018
  return String(
3019
    item.timestamp ??
3020
      item.generatedAt ??
3021
      item.decidedAt ??
3022
      item.appliedAt ??
3023
      item.submittedAt ??
3024
      item.completedAt ??
3025
      item.activatedAt ??
3026
      item.updatedAt ??
3027
      item.requestedAt ??
3028
      item.createdAt ??
3029
      new Date(0).toISOString(),
3030
  );
3031
}
3032
3033
if (!__AUTH0_DOMAIN__ || !__AUTH0_CLIENT_ID__ || !__AUTH0_AUDIENCE__)
3034
  throw new Error('Auth0 public configuration is incomplete');
3035
createRoot(document.getElementById('root')!).render(
3036
  <React.StrictMode>
3037
    {location.pathname === '/sso-callback' ? <AuthCallback /> : <App />}
3038
  </React.StrictMode>,
3039
);