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

Viewing files at commit 296bb55574a2Commit detailsView latest

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