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

Viewing files at commit 1f74f1f0e3abCommit detailsView latest

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