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

Viewing files at commit 874239cc591fCommit detailsView latest

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