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

Viewing files at commit db34577898cfCommit detailsView latest

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