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

Viewing files at commit 6b91d3cf5293Commit detailsView latest

apps/dashboard/src/main.tsx 4300 lines · 156.7 KB · tsx
1
import React, { useCallback, useEffect, useMemo, useState } from 'react';
2
import { createRoot } from 'react-dom/client';
3
import {
4
  ArrowRight,
5
  Check,
6
  ChevronDown,
7
  Circle,
8
  Cloud,
9
  Copy,
10
  Download,
11
  ExternalLink,
12
  Moon,
13
  Search,
14
  Share2,
15
  ShieldCheck,
16
  Sun,
17
  X,
18
} from 'lucide-react';
19
import { AgentMark, FrameCorners } from '@/components/agent-mark';
20
import { AppShell } from '@/components/app-shell';
21
import { ClaspMark } from '@/components/clasp-mark';
22
import { FirstRunGuide } from '@/components/first-run-guide';
23
import { LandingBackdrop, LandingDiagram } from '@/components/landing-scene';
24
import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog';
25
import { Input } from '@/components/ui/input';
26
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
27
import { pageMeta, pages, type Page } from '@/lib/navigation';
28
import {
29
  applyPreviewRequest,
30
  createPreviewData,
31
  defaultPreviewSettings,
32
  disablePreview,
33
  enablePreview,
34
  isPreviewActive,
35
  previewSession,
36
} from './preview.js';
37
import './styles.css';
38
39
declare const __AUTH0_DOMAIN__: string;
40
declare const __AUTH0_CLIENT_ID__: string;
41
declare const __AUTH0_AUDIENCE__: string;
42
declare const __OPENCLASP_PUBLIC_URL__: string;
43
44
type Auth0User = { sub: string; name?: string; email?: string; picture?: string };
45
type AuthSession = { user: Auth0User };
46
type AuthTransaction = { state: string; nonce: string; verifier: string };
47
type Theme = 'dark' | 'light';
48
49
const authTransactionKey = 'openclasp.auth0.transaction';
50
const themeKey = 'openclasp.theme.v1';
51
const landingCapabilities = [
52
  'verified agent identity',
53
  'discoverable agent cards',
54
  'structured agreements',
55
  'AI risk intelligence',
56
  'cross-platform coordination',
57
  'outcome history',
58
];
59
60
type DashboardData = {
61
  agents: Record<string, any>[];
62
  projects: Record<string, any>[];
63
  installations: Record<string, any>[];
64
  setupRequests: Record<string, any>[];
65
  publications: Record<string, any>[];
66
  interactions: Record<string, any>[];
67
  federatedInteractions: Record<string, any>[];
68
  liveSessions: Record<string, any>[];
69
  events: Record<string, any>[];
70
  conflicts: Record<string, any>[];
71
  receipts: Record<string, any>[];
72
  profiles: Record<string, any>[];
73
  counterpartyBriefs: Record<string, any>[];
74
  completionReports: Record<string, any>[];
75
  feedbackRequests: Record<string, any>[];
76
  interactionFeedback: Record<string, any>[];
77
  interactionConclusions: Record<string, any>[];
78
  learningEligibility: Record<string, any>[];
79
  profileDeltas: Record<string, any>[];
80
  intelligenceSummaries: Record<string, any>[];
81
  runtimes: Record<string, any>[];
82
  accessTokens: Record<string, any>[];
83
  assuranceAssessments: Record<string, any>[];
84
  assurancePredictions: Record<string, any>[];
85
  assuranceSafeguards: Record<string, any>[];
86
  assuranceEvaluations: Record<string, any>[];
87
  assuranceProbePlans: Record<string, any>[];
88
  assuranceProbeResponses: Record<string, any>[];
89
};
90
91
type Settings = {
92
  displayName: string;
93
  contributionEnabled: boolean;
94
  rawConversationsStored: false;
95
};
96
97
const emptyData: DashboardData = {
98
  agents: [],
99
  projects: [],
100
  installations: [],
101
  setupRequests: [],
102
  publications: [],
103
  interactions: [],
104
  federatedInteractions: [],
105
  liveSessions: [],
106
  events: [],
107
  conflicts: [],
108
  receipts: [],
109
  profiles: [],
110
  counterpartyBriefs: [],
111
  completionReports: [],
112
  feedbackRequests: [],
113
  interactionFeedback: [],
114
  interactionConclusions: [],
115
  learningEligibility: [],
116
  profileDeltas: [],
117
  intelligenceSummaries: [],
118
  runtimes: [],
119
  accessTokens: [],
120
  assuranceAssessments: [],
121
  assurancePredictions: [],
122
  assuranceSafeguards: [],
123
  assuranceEvaluations: [],
124
  assuranceProbePlans: [],
125
  assuranceProbeResponses: [],
126
};
127
const defaultSettings: Settings = {
128
  displayName: '',
129
  contributionEnabled: false,
130
  rawConversationsStored: false,
131
};
132
133
function normalizeDashboard(value: unknown): DashboardData {
134
  return { ...emptyData, ...(value as Partial<DashboardData>) };
135
}
136
137
function base64Url(bytes: Uint8Array) {
138
  let binary = '';
139
  for (const byte of bytes) binary += String.fromCharCode(byte);
140
  return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
141
}
142
143
function randomValue(size = 32) {
144
  return base64Url(crypto.getRandomValues(new Uint8Array(size)));
145
}
146
147
async function beginAuth(provider: 'google' | 'github') {
148
  const verifier = randomValue(48);
149
  const digest = await crypto.subtle.digest(
150
    'SHA-256',
151
    new TextEncoder().encode(verifier) as BufferSource,
152
  );
153
  const transaction: AuthTransaction = { state: randomValue(), nonce: randomValue(), verifier };
154
  sessionStorage.setItem(authTransactionKey, JSON.stringify(transaction));
155
  const parameters = new URLSearchParams({
156
    client_id: __AUTH0_CLIENT_ID__,
157
    redirect_uri: `${location.origin}/sso-callback`,
158
    response_type: 'code',
159
    scope: 'openid profile email',
160
    audience: __AUTH0_AUDIENCE__,
161
    state: transaction.state,
162
    nonce: transaction.nonce,
163
    code_challenge: base64Url(new Uint8Array(digest)),
164
    code_challenge_method: 'S256',
165
    connection: provider === 'google' ? 'google-oauth2' : 'github',
166
  });
167
  location.assign(`https://${__AUTH0_DOMAIN__}/authorize?${parameters}`);
168
}
169
170
async function signOut(preview: boolean) {
171
  if (preview) {
172
    disablePreview();
173
    location.replace('/login');
174
    return;
175
  }
176
  await fetch('/api/session', { method: 'DELETE', credentials: 'same-origin' });
177
  const parameters = new URLSearchParams({
178
    client_id: __AUTH0_CLIENT_ID__,
179
    returnTo: `${location.origin}/login`,
180
  });
181
  location.assign(`https://${__AUTH0_DOMAIN__}/v2/logout?${parameters}`);
182
}
183
184
async function remoteApi(path: string, init?: RequestInit) {
185
  const headers = new Headers(init?.headers);
186
  if (init?.body && !headers.has('content-type')) headers.set('content-type', 'application/json');
187
  return fetch(path, {
188
    ...init,
189
    credentials: 'same-origin',
190
    headers,
191
  }).then(async (response) => {
192
    if (!response.ok) {
193
      const text = await response.text();
194
      let message = text || `Request failed: ${response.status}`;
195
      try {
196
        const value = JSON.parse(text) as { error?: unknown };
197
        if (typeof value.error === 'string') message = value.error;
198
      } catch {
199
        // Preserve non-JSON error text from proxies and platform failures.
200
      }
201
      throw new Error(message);
202
    }
203
    return response.json();
204
  });
205
}
206
207
function route(): Page {
208
  const value = location.pathname.slice(1) || 'dashboard';
209
  if ((pages as readonly string[]).includes(value)) return value as Page;
210
  return 'dashboard';
211
}
212
213
function initialTheme(): Theme {
214
  const saved = localStorage.getItem(themeKey);
215
  if (saved === 'dark' || saved === 'light') return saved;
216
  return matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
217
}
218
219
function App() {
220
  const [session, setSession] = useState<AuthSession | null>();
221
  const [preview, setPreview] = useState(false);
222
  const [theme, setTheme] = useState<Theme>(initialTheme);
223
  const [page, setPage] = useState<Page>(route());
224
  const [data, setData] = useState<DashboardData>(emptyData);
225
  const [settings, setSettings] = useState<Settings>(defaultSettings);
226
  const [loading, setLoading] = useState(true);
227
  const [error, setError] = useState('');
228
  const api = useCallback(
229
    async (path: string, init?: RequestInit) => {
230
      if (preview) {
231
        const next = applyPreviewRequest(data, settings, path, init);
232
        setData(next.data);
233
        setSettings(next.settings);
234
        return next.result;
235
      }
236
      return remoteApi(path, init);
237
    },
238
    [data, preview, settings],
239
  );
240
  const refreshDashboard = useCallback(async () => {
241
    if (preview) return;
242
    setData(normalizeDashboard(await remoteApi('/v0.1/dashboard')));
243
  }, [preview]);
244
245
  useEffect(() => {
246
    document.title = session
247
      ? `${pageMeta[page].title} · OpenClasp`
248
      : session === null
249
        ? 'OpenClasp · AI assurance for agent agreements'
250
        : 'OpenClasp';
251
  }, [page, session]);
252
253
  useEffect(() => {
254
    let cancelled = false;
255
    const boot = async () => {
256
      if (import.meta.env.DEV && isPreviewActive()) {
257
        if (cancelled) return;
258
        setPreview(true);
259
        setSession(previewSession);
260
        setData(createPreviewData());
261
        setSettings(defaultPreviewSettings);
262
        setLoading(false);
263
        return;
264
      }
265
      try {
266
        const response = await fetch('/api/session', { credentials: 'same-origin' });
267
        if (!response.ok) {
268
          if (!cancelled && !(import.meta.env.DEV && isPreviewActive())) setSession(null);
269
          return;
270
        }
271
        if (!cancelled && !(import.meta.env.DEV && isPreviewActive())) {
272
          setSession((await response.json()) as AuthSession);
273
        }
274
      } catch {
275
        if (!cancelled && !(import.meta.env.DEV && isPreviewActive())) setSession(null);
276
      }
277
    };
278
    void boot();
279
    return () => {
280
      cancelled = true;
281
    };
282
  }, []);
283
284
  useEffect(() => {
285
    document.documentElement.dataset.theme = theme;
286
    document.documentElement.classList.toggle('dark', theme === 'dark');
287
    localStorage.setItem(themeKey, theme);
288
    const meta = document.querySelector('meta[name="theme-color"]');
289
    if (meta) meta.setAttribute('content', theme === 'light' ? '#f7f4f2' : '#0c0a0a');
290
  }, [theme]);
291
292
  useEffect(() => {
293
    const onPopState = () => setPage(route());
294
    addEventListener('popstate', onPopState);
295
    return () => removeEventListener('popstate', onPopState);
296
  }, []);
297
298
  useEffect(() => {
299
    if (session === undefined) return;
300
    if (!session) {
301
      const publicPath = '/login';
302
      if (location.pathname !== publicPath) history.replaceState({}, '', publicPath);
303
      return;
304
    }
305
    if (location.pathname === '/' || location.pathname === '/login')
306
      history.replaceState({}, '', '/dashboard');
307
    setPage(route());
308
    if (preview) {
309
      setLoading(false);
310
      return;
311
    }
312
    Promise.all([remoteApi('/v0.1/dashboard'), remoteApi('/v0.1/settings')])
313
      .then(([dashboard, accountSettings]) => {
314
        setData(normalizeDashboard(dashboard));
315
        setSettings(accountSettings as Settings);
316
      })
317
      .catch((reason: unknown) =>
318
        setError(reason instanceof Error ? reason.message : 'Load failed'),
319
      )
320
      .finally(() => setLoading(false));
321
  }, [preview, session]);
322
323
  useEffect(() => {
324
    if (!session || preview) return;
325
    const refresh = () => {
326
      if (document.visibilityState === 'visible') void refreshDashboard().catch(() => undefined);
327
    };
328
    const timer = window.setInterval(refresh, 10_000);
329
    document.addEventListener('visibilitychange', refresh);
330
    return () => {
331
      window.clearInterval(timer);
332
      document.removeEventListener('visibilitychange', refresh);
333
    };
334
  }, [preview, refreshDashboard, session]);
335
336
  const navigate = (next: Page) => {
337
    history.pushState({}, '', `/${next}`);
338
    setPage(next);
339
  };
340
  if (session === undefined) return <Loading />;
341
  if (!session)
342
    return import.meta.env.DEV ? (
343
      <PublicLanding
344
        theme={theme}
345
        onToggleTheme={() => setTheme((current) => (current === 'dark' ? 'light' : 'dark'))}
346
        onPreview={() => {
347
          enablePreview();
348
          setPreview(true);
349
          setSession(previewSession);
350
          setData(createPreviewData());
351
          setSettings(defaultPreviewSettings);
352
          setLoading(false);
353
          history.replaceState({}, '', '/dashboard');
354
          setPage('dashboard');
355
        }}
356
      />
357
    ) : (
358
      <PublicLanding
359
        theme={theme}
360
        onToggleTheme={() => setTheme((current) => (current === 'dark' ? 'light' : 'dark'))}
361
      />
362
    );
363
364
  return (
365
    <AppShell
366
      page={page}
367
      navigate={navigate}
368
      user={session.user}
369
      theme={theme}
370
      onToggleTheme={() => setTheme((current) => (current === 'dark' ? 'light' : 'dark'))}
371
      onSignOut={() => void signOut(preview)}
372
    >
373
      {preview && <div className="previewBanner">local preview · changes stay here</div>}
374
      {error && (
375
        <div className="errorBar" role="alert">
376
          {error}
377
        </div>
378
      )}
379
      {loading ? (
380
        <Loading compact />
381
      ) : (
382
        <PageContent
383
          page={page}
384
          data={data}
385
          settings={settings}
386
          setSettings={setSettings}
387
          navigate={navigate}
388
          refreshDashboard={refreshDashboard}
389
          api={api}
390
        />
391
      )}
392
    </AppShell>
393
  );
394
}
395
396
function PublicLanding({
397
  onPreview,
398
  theme,
399
  onToggleTheme,
400
}: {
401
  onPreview?: () => void;
402
  theme: Theme;
403
  onToggleTheme: () => void;
404
}) {
405
  const [error, setError] = useState('');
406
  const continueWith = async (provider: 'google' | 'github') => {
407
    setError('');
408
    try {
409
      await beginAuth(provider);
410
    } catch {
411
      setError(`${provider === 'google' ? 'Google' : 'GitHub'} sign-in is not configured yet.`);
412
    }
413
  };
414
  const scrollToAnchor = (event: React.MouseEvent<HTMLAnchorElement>) => {
415
    const id = event.currentTarget.hash.slice(1);
416
    const target = document.getElementById(id);
417
    if (!target) return;
418
    event.preventDefault();
419
    const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;
420
    history.pushState({}, '', `#${id}`);
421
    target.classList.remove('is-arriving');
422
    if (!reducedMotion && id !== 'top') {
423
      void target.offsetWidth;
424
      target.classList.add('is-arriving');
425
      window.setTimeout(() => target.classList.remove('is-arriving'), 900);
426
    }
427
    target.scrollIntoView({ behavior: reducedMotion ? 'auto' : 'smooth', block: 'start' });
428
  };
429
  return (
430
    <div className="landingPage">
431
      <LandingBackdrop />
432
      <header className="landingNav">
433
        <a
434
          className="landingBrand"
435
          href="#top"
436
          aria-label="OpenClasp home"
437
          onClick={scrollToAnchor}
438
        >
439
          <ClaspMark className="landingMark" size={28} />
440
          <strong>openclasp</strong>
441
        </a>
442
        <nav aria-label="Main navigation">
443
          <a href="#product" onClick={scrollToAnchor}>
444
            what it knows
445
          </a>
446
          <a href="#intelligence" onClick={scrollToAnchor}>
447
            intelligence
448
          </a>
449
        </nav>
450
        <div className="landingNavActions">
451
          <button
452
            className="themeButton"
453
            type="button"
454
            onClick={onToggleTheme}
455
            aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} theme`}
456
          >
457
            {theme === 'dark' ? <Sun /> : <Moon />}
458
          </button>
459
          <a className="navCta" href="#access" onClick={scrollToAnchor}>
460
            sign in <ArrowRight />
461
          </a>
462
        </div>
463
      </header>
464
465
      <main id="top">
466
        <section className="landingHero">
467
          <div className="heroCopy">
468
            <div className="heroLead">
469
              <p className="landingKicker">
470
                <span>//</span> trust infrastructure for the agent economy
471
              </p>
472
              <h1>
473
                Agents can
474
                <br />
475
                talk.
476
                <br />
477
                <em>
478
                  Now they can
479
                  <br />
480
                  build trust.
481
                </em>
482
              </h1>
483
              <p>
484
                OpenClasp gives AI agents a shared layer for identity, agreements, safeguards, and
485
                outcome intelligence—across organizations and runtimes.
486
              </p>
487
            </div>
488
            <div className="heroActions">
489
              <a className="landingPrimary" href="#access" onClick={scrollToAnchor}>
490
                sign in <ArrowRight />
491
              </a>
492
              <a className="landingSecondary" href="#product" onClick={scrollToAnchor}>
493
                see how it works
494
              </a>
495
            </div>
496
          </div>
497
          <LandingDiagram />
498
        </section>
499
500
        <div className="capabilityRail" aria-label="OpenClasp capabilities">
501
          {landingCapabilities.map((item) => (
502
            <span key={item}>{item}</span>
503
          ))}
504
        </div>
505
506
        <section className="productSection" id="product">
507
          <div className="sectionIntro">
508
            <p className="landingKicker">
509
              <span>01</span> before the agent acts
510
            </p>
511
            <h2>
512
              Trust agents by context.
513
              <br />
514
              <em>Not reputation alone.</em>
515
            </h2>
516
            <p>
517
              Identity is only the start. OpenClasp helps agents understand who they are dealing
518
              with, what was agreed, what could go wrong, and what actually happened.
519
            </p>
520
          </div>
521
          <section className="coreQuestions" aria-labelledby="core-questions-title">
522
            <header>
523
              <span>THE TRUST LAYER</span>
524
              <h3 id="core-questions-title">Before agents work together, OpenClasp asks:</h3>
525
            </header>
526
            <ol>
527
              <li>
528
                <span>01 · IDENTITY</span>
529
                <strong>Who operates this agent, and is its published identity verified?</strong>
530
              </li>
531
              <li>
532
                <span>02 · AGREEMENT</span>
533
                <strong>What exactly will each agent do, under which constraints?</strong>
534
              </li>
535
              <li>
536
                <span>03 · ASSURANCE</span>
537
                <strong>What risks, questions, or safeguards matter for this work?</strong>
538
              </li>
539
              <li>
540
                <span>04 · OUTCOME</span>
541
                <strong>What happened, and what should future agents learn from it?</strong>
542
              </li>
543
            </ol>
544
          </section>
545
        </section>
546
547
        <section className="intelligenceSection" id="intelligence">
548
          <div className="sectionIntro">
549
            <p className="landingKicker">
550
              <span>02</span> the compounding layer
551
            </p>
552
            <h2>
553
              The network gets smarter
554
              <br />
555
              <em>with every interaction.</em>
556
            </h2>
557
            <p>
558
              OpenClasp learns how agents and versions behave, which questions reveal risk, and
559
              which safeguards are associated with better outcomes.
560
            </p>
561
          </div>
562
          <ol className="intelligenceLoop" aria-label="OpenClasp intelligence flywheel">
563
            <li>
564
              <span>01</span>
565
              <strong>Verified agent</strong>
566
            </li>
567
            <li>
568
              <span>02</span>
569
              <strong>Explicit agreement</strong>
570
            </li>
571
            <li>
572
              <span>03</span>
573
              <strong>Assurance decision</strong>
574
            </li>
575
            <li>
576
              <span>04</span>
577
              <strong>Scored outcome</strong>
578
            </li>
579
          </ol>
580
          <p className="intelligenceBoundary">
581
            Conversation bodies stay between agent runtimes. Learning uses explicit structured
582
            records, stays scoped to task and version, and never claims safeguards caused an
583
            outcome.
584
          </p>
585
        </section>
586
587
        <section className="accessSection" id="access">
588
          <div className="accessCopy">
589
            <p className="landingKicker">
590
              <span>03</span> identity required
591
            </p>
592
            <h2>Access your agent network.</h2>
593
            <p>
594
              Sign in to connect agents, control network participation, and inspect structured
595
              history.
596
            </p>
597
          </div>
598
          <div className="accessCard">
599
            <div className="accessChrome">
600
              <span>$ authenticate</span>
601
              <span className="accessBlink">_</span>
602
            </div>
603
            <div className="socialButtons">
604
              <button
605
                className="authProviderButton"
606
                type="button"
607
                onClick={() => void continueWith('google')}
608
              >
609
                <span className="authProviderIcon authProviderIconGoogle" aria-hidden="true">
610
                  <GoogleMark />
611
                </span>
612
                <span>continue with google</span>
613
              </button>
614
              <button
615
                className="authProviderButton"
616
                type="button"
617
                onClick={() => void continueWith('github')}
618
              >
619
                <span className="authProviderIcon" aria-hidden="true">
620
                  <GitHubMark />
621
                </span>
622
                <span>continue with github</span>
623
              </button>
624
              {onPreview && (
625
                <button type="button" onClick={() => void onPreview()}>
626
                  open local preview <ArrowRight />
627
                </button>
628
              )}
629
            </div>
630
            {error && (
631
              <div className="loginError" role="alert">
632
                {error}
633
              </div>
634
            )}
635
            <small>google or github · openclasp never receives your password</small>
636
          </div>
637
        </section>
638
      </main>
639
640
      <footer className="landingFooter">
641
        <a className="landingBrand" href="#top" onClick={scrollToAnchor}>
642
          <ClaspMark className="landingMark" size={28} />
643
          <strong>openclasp</strong>
644
        </a>
645
        <p>Trust infrastructure for AI agents.</p>
646
        <small>Identity. Agreements. Assurance. Outcomes.</small>
647
      </footer>
648
    </div>
649
  );
650
}
651
652
let callbackStarted = false;
653
function AuthCallback() {
654
  const [error, setError] = useState('');
655
  useEffect(() => {
656
    if (callbackStarted) return;
657
    callbackStarted = true;
658
    const finish = async () => {
659
      const query = new URLSearchParams(location.search);
660
      const oauthError = query.get('error_description') ?? query.get('error');
661
      if (oauthError) throw new Error(oauthError);
662
      const code = query.get('code');
663
      const returnedState = query.get('state');
664
      if (code && returnedState?.startsWith('oc_tx_')) {
665
        const response = await fetch('/api/oauth-callback', {
666
          method: 'POST',
667
          credentials: 'same-origin',
668
          headers: { 'content-type': 'application/json' },
669
          body: JSON.stringify({ code, state: returnedState }),
670
        });
671
        if (!response.ok) throw new Error('MCP authorization failed');
672
        const result = (await response.json()) as { redirectTo?: unknown };
673
        if (typeof result.redirectTo !== 'string')
674
          throw new Error('Invalid MCP authorization response');
675
        location.replace(result.redirectTo);
676
        return;
677
      }
678
      const rawTransaction = sessionStorage.getItem(authTransactionKey);
679
      if (!code || !returnedState || !rawTransaction) throw new Error('Missing OAuth transaction');
680
      const transaction = JSON.parse(rawTransaction) as AuthTransaction;
681
      sessionStorage.removeItem(authTransactionKey);
682
      if (returnedState !== transaction.state) throw new Error('OAuth state mismatch');
683
      const response = await fetch('/api/session', {
684
        method: 'POST',
685
        credentials: 'same-origin',
686
        headers: { 'content-type': 'application/json' },
687
        body: JSON.stringify({ code, codeVerifier: transaction.verifier }),
688
      });
689
      if (!response.ok) throw new Error('Auth0 token exchange failed');
690
      location.replace('/dashboard');
691
    };
692
    void finish().catch((reason: unknown) =>
693
      setError(reason instanceof Error ? reason.message : 'Authentication failed'),
694
    );
695
  }, []);
696
  if (error)
697
    return (
698
      <div className="loading">
699
        <span className="mark">OC</span>
700
        <p>{error}</p>
701
        <a href="/">Return home</a>
702
      </div>
703
    );
704
  return <Loading />;
705
}
706
707
function PageContent({
708
  page,
709
  data,
710
  settings,
711
  setSettings,
712
  navigate,
713
  refreshDashboard,
714
  api,
715
}: {
716
  page: Page;
717
  data: DashboardData;
718
  settings: Settings;
719
  setSettings: React.Dispatch<React.SetStateAction<Settings>>;
720
  navigate: (page: Page) => void;
721
  refreshDashboard: () => Promise<void>;
722
  api: (path: string, init?: RequestInit) => Promise<unknown>;
723
}) {
724
  if (page === 'connect')
725
    return (
726
      <Connect data={data} navigate={navigate} refreshDashboard={refreshDashboard} api={api} />
727
    );
728
  if (page === 'marketplace') return <Marketplace data={data} />;
729
  if (page === 'settings')
730
    return <SettingsPage settings={settings} setSettings={setSettings} api={api} />;
731
  if (page === 'history')
732
    return <History data={data} refreshDashboard={refreshDashboard} api={api} />;
733
  if (page === 'agents')
734
    return <Agents data={data} navigate={navigate} refreshDashboard={refreshDashboard} api={api} />;
735
  if (page === 'insights') return <Insights data={data} />;
736
  if (page !== 'dashboard')
737
    return (
738
      <Overview data={data} navigate={navigate} refreshDashboard={refreshDashboard} api={api} />
739
    );
740
  return (
741
    <AgentWorkspace data={data} navigate={navigate} refreshDashboard={refreshDashboard} api={api} />
742
  );
743
}
744
745
type AgentHistoryItem = {
746
  interactionId: string;
747
  title: string;
748
  counterpart: string;
749
  counterpartMode: 'agent';
750
  outcome: 'success' | 'partial' | 'failure' | 'cancelled' | 'provisional' | 'active' | 'pending';
751
  at: string;
752
};
753
754
const SCORECARD_DIMENSIONS = [
755
  { key: 'completion', label: 'Task completion', short: 'Completion' },
756
  { key: 'acceptance', label: 'Outcome satisfaction', short: 'Satisfaction' },
757
  { key: 'specification', label: 'Requirement adherence', short: 'Requirements' },
758
  { key: 'deadline', label: 'Timeliness', short: 'Timeliness' },
759
  { key: 'communication', label: 'Communication', short: 'Communication' },
760
  { key: 'evidence', label: 'Evidence quality', short: 'Evidence' },
761
  { key: 'scope', label: 'Scope adherence', short: 'Scope' },
762
  { key: 'correction', label: 'Correction behaviour', short: 'Corrections' },
763
  { key: 'limitations', label: 'Limitation disclosure', short: 'Limitations' },
764
  { key: 'disputes', label: 'Dispute-free outcomes', short: 'Dispute-free' },
765
] as const;
766
767
type ScorecardMetric = {
768
  key: (typeof SCORECARD_DIMENSIONS)[number]['key'];
769
  label: string;
770
  short: string;
771
  value: number | null;
772
  evidence: number;
773
};
774
775
function AgentWorkspace({
776
  data,
777
  navigate,
778
  refreshDashboard,
779
  api,
780
}: {
781
  data: DashboardData;
782
  navigate: (page: Page) => void;
783
  refreshDashboard: () => Promise<void>;
784
  api: (path: string, init?: RequestInit) => Promise<unknown>;
785
}) {
786
  const [expandedAgent, setExpandedAgent] = useState('');
787
  const [openHistory, setOpenHistory] = useState<Record<string, boolean>>({});
788
  const [expandedHistoryId, setExpandedHistoryId] = useState('');
789
  const [scorecardAgentId, setScorecardAgentId] = useState(
790
    () => new URLSearchParams(window.location.search).get('scorecard') ?? '',
791
  );
792
  const [working, setWorking] = useState('');
793
  const [error, setError] = useState('');
794
  const pendingSetups = data.setupRequests.filter((request) => request.status === 'pending');
795
  const latestAssurancePrediction = data.assurancePredictions.at(-1);
796
  const pendingAssuranceSafeguards = data.assuranceSafeguards.filter(
797
    (item) => item.status === 'recommended',
798
  ).length;
799
  const toggleAgent = (agentId: string, isOpen: boolean) => {
800
    setExpandedAgent(isOpen ? '' : agentId);
801
    setExpandedHistoryId('');
802
  };
803
804
  const decideSetup = async (requestId: string, decision: 'approve' | 'reject') => {
805
    setWorking(`setup:${requestId}`);
806
    setError('');
807
    try {
808
      await api(`/v0.1/onboarding/${encodeURIComponent(requestId)}/${decision}`, {
809
        method: 'POST',
810
      });
811
      await refreshDashboard();
812
    } catch (reason) {
813
      setError(reason instanceof Error ? reason.message : 'Could not update request');
814
    } finally {
815
      setWorking('');
816
    }
817
  };
818
819
  const respondToContract = async (
820
    interaction: Record<string, any>,
821
    agentId: string,
822
    decision: 'accept' | 'reject',
823
  ) => {
824
    const proposal = latestContractProposal(interaction);
825
    if (!proposal) return;
826
    setWorking(`contract:${interaction.interactionId}`);
827
    setError('');
828
    try {
829
      await api(
830
        `/v0.1/federated-interactions/${encodeURIComponent(String(interaction.interactionId))}/contract-proposals/${encodeURIComponent(String(proposal.revisionId))}/respond`,
831
        { method: 'POST', body: JSON.stringify({ agentId, decision }) },
832
      );
833
      await refreshDashboard();
834
    } catch (reason) {
835
      setError(reason instanceof Error ? reason.message : 'Could not respond to request');
836
    } finally {
837
      setWorking('');
838
    }
839
  };
840
841
  return (
842
    <div className="workspacePage">
843
      <header className="workspaceHead">
844
        <h1>
845
          Agents <small>{data.agents.length}</small>
846
        </h1>
847
        <button className="landingPrimary" type="button" onClick={() => navigate('connect')}>
848
          add agent <ArrowRight />
849
        </button>
850
      </header>
851
852
      <FirstRunGuide data={data} api={api} refreshDashboard={refreshDashboard} />
853
854
      <section className="assuranceOverview" aria-label="AI assurance overview">
855
        <div>
856
          <span className="eyebrow">AI ASSURANCE · EXPERIMENTAL</span>
857
          <h2>Predict success before your agents commit.</h2>
858
          <p>
859
            One risk-revealing question at a time. Explicit safeguards. Outcomes that improve the
860
            next decision.
861
          </p>
862
        </div>
863
        <div className="assuranceOverviewStats">
864
          <span>
865
            <strong>{data.assuranceAssessments.length}</strong>
866
            decisions
867
          </span>
868
          <span>
869
            <strong>
870
              {latestAssurancePrediction
871
                ? `${Math.round(Number(latestAssurancePrediction.successProbability) * 100)}%`
872
                : '—'}
873
            </strong>
874
            latest prediction
875
          </span>
876
          <span>
877
            <strong>{data.assuranceProbeResponses.length}</strong>
878
            probes answered
879
          </span>
880
          <span>
881
            <strong>{pendingAssuranceSafeguards}</strong>
882
            safeguards pending
883
          </span>
884
        </div>
885
        <button type="button" onClick={() => navigate('history')}>
886
          view assurance decisions <ArrowRight />
887
        </button>
888
      </section>
889
890
      {error ? (
891
        <div className="errorBar" role="alert">
892
          {error}
893
        </div>
894
      ) : null}
895
896
      {pendingSetups.length ? (
897
        <section className="compactRequests" aria-label="New agent requests">
898
          {pendingSetups.map((request) => (
899
            <article key={request.requestId}>
900
              <AgentMark name={String(request.agentName ?? 'New agent')} size="sm" />
901
              <div>
902
                <strong>{request.agentName ?? 'New agent'}</strong>
903
                <small>{request.framework ?? 'Agent'} wants to connect</small>
904
              </div>
905
              <div className="iconDecisions">
906
                <button
907
                  type="button"
908
                  aria-label={`Reject ${String(request.agentName ?? 'agent')}`}
909
                  disabled={working === `setup:${request.requestId}`}
910
                  onClick={() => void decideSetup(String(request.requestId), 'reject')}
911
                >
912
                  <X />
913
                </button>
914
                <button
915
                  className="approve"
916
                  type="button"
917
                  aria-label={`Approve ${String(request.agentName ?? 'agent')}`}
918
                  disabled={working === `setup:${request.requestId}`}
919
                  onClick={() => void decideSetup(String(request.requestId), 'approve')}
920
                >
921
                  <Check />
922
                </button>
923
              </div>
924
            </article>
925
          ))}
926
        </section>
927
      ) : null}
928
929
      <section className="agentList" aria-label="Connected agents">
930
        {data.agents.length ? (
931
          data.agents.map((agent) => {
932
            const agentId = String(agent.agentId);
933
            const expanded = expandedAgent === agentId;
934
            const invitations = invitationsForAgent(data, agentId);
935
            const history = historyForAgent(data, agent);
936
            const reliability = reliabilityForAgent(data, agentId);
937
            const runtime = data.runtimes.find((item) => item.agentId === agentId);
938
            const verified = !agent.revoked && Boolean(agent.identityMode);
939
            const online = agent.presence?.status === 'online';
940
            const published = data.publications.some(
941
              (item) => item.agentId === agentId && item.published,
942
            );
943
            return (
944
              <article className={`agentRow ${expanded ? 'isExpanded' : ''}`} key={agentId}>
945
                <div className="agentRowSummary">
946
                  <button
947
                    className="agentRowCore"
948
                    type="button"
949
                    aria-expanded={expanded}
950
                    onClick={() => toggleAgent(agentId, expanded)}
951
                  >
952
                    <AgentMark name={String(agent.name ?? agentId)} online={online} />
953
                    <span className="agentIdentity">
954
                      <strong>{agent.name ?? agentId}</strong>
955
                      <small>{agent.framework ?? 'Agent'}</small>
956
                    </span>
957
                    <span className="agentSignals">
958
                      <span className={online ? 'signalOnline' : ''}>
959
                        <Circle /> {online ? 'Online' : 'Offline'}
960
                      </span>
961
                      <span className={verified ? 'signalVerified' : ''}>
962
                        <ShieldCheck /> {verified ? 'Verified' : 'Unverified'}
963
                      </span>
964
                      <span>
965
                        <Cloud />
966
                        {runtime?.status === 'verified' ? 'Cloud' : 'Cloud · disconnected'}
967
                      </span>
968
                    </span>
969
                  </button>
970
                  <button
971
                    className="scorecardTeaser"
972
                    type="button"
973
                    onClick={() => setScorecardAgentId(agentId)}
974
                    aria-label={`Open behavioural scorecard for ${String(agent.name ?? agentId)}`}
975
                  >
976
                    <strong>{reliability.score == null ? '—' : reliability.score}</strong>
977
                    <span>
978
                      <b>behaviour</b>
979
                      <small>
980
                        {reliability.summary
981
                          ? `${humanize(reliability.summary.confidence.level)} confidence · ${reliability.samples}`
982
                          : 'awaiting eligible outcomes'}
983
                      </small>
984
                    </span>
985
                  </button>
986
                  {invitations.length ? (
987
                    <span
988
                      className="notificationCount"
989
                      aria-label={`${invitations.length} requests`}
990
                    >
991
                      {invitations.length}
992
                    </span>
993
                  ) : null}
994
                  <button
995
                    className="agentExpandButton"
996
                    type="button"
997
                    aria-expanded={expanded}
998
                    aria-label={`${expanded ? 'Collapse' : 'Expand'} ${String(agent.name ?? agentId)}`}
999
                    onClick={() => toggleAgent(agentId, expanded)}
1000
                  >
1001
                    <ChevronDown className="expandIcon" />
1002
                  </button>
1003
                </div>
1004
1005
                {expanded ? (
1006
                  <div className="agentExpanded">
1007
                    {invitations.length ? (
1008
                      <section className="agentRequests" aria-label="Contract requests">
1009
                        <p className="sectionLabel">Requests</p>
1010
                        {invitations.map((interaction) => {
1011
                          const proposal = latestContractProposal(interaction);
1012
                          return (
1013
                            <article key={interaction.interactionId}>
1014
                              <span className="requestPulse" />
1015
                              <div>
1016
                                <strong>
1017
                                  {interaction.contract?.purpose ?? 'New interaction request'}
1018
                                </strong>
1019
                                <small>from {counterpartyName(data, interaction, agentId)}</small>
1020
                              </div>
1021
                              <div className="iconDecisions">
1022
                                <button
1023
                                  type="button"
1024
                                  aria-label="Reject request"
1025
                                  disabled={working === `contract:${interaction.interactionId}`}
1026
                                  onClick={() =>
1027
                                    void respondToContract(interaction, agentId, 'reject')
1028
                                  }
1029
                                >
1030
                                  <X />
1031
                                </button>
1032
                                <button
1033
                                  className="approve"
1034
                                  type="button"
1035
                                  aria-label="Accept request"
1036
                                  disabled={
1037
                                    !proposal || working === `contract:${interaction.interactionId}`
1038
                                  }
1039
                                  onClick={() =>
1040
                                    void respondToContract(interaction, agentId, 'accept')
1041
                                  }
1042
                                >
1043
                                  <Check />
1044
                                </button>
1045
                              </div>
1046
                            </article>
1047
                          );
1048
                        })}
1049
                      </section>
1050
                    ) : null}
1051
1052
                    <div className="agentDetailStrip">
1053
                      <span>
1054
                        <b>{reliability.samples || '—'}</b>
1055
                        eligible outcomes
1056
                      </span>
1057
                      <span>
1058
                        <b>{published ? 'Public' : 'Private'}</b>
1059
                        discovery
1060
                      </span>
1061
                      <span>
1062
                        <b>
1063
                          {online
1064
                            ? 'Now'
1065
                            : agent.presence?.lastSeenAt
1066
                              ? relativeTime(agent.presence.lastSeenAt)
1067
                              : 'Never'}
1068
                        </b>
1069
                        last active
1070
                      </span>
1071
                      {published ? (
1072
                        <a
1073
                          href={`/a/${encodeURIComponent(agentId)}`}
1074
                          target="_blank"
1075
                          rel="noreferrer"
1076
                        >
1077
                          Profile <ExternalLink />
1078
                        </a>
1079
                      ) : null}
1080
                    </div>
1081
1082
                    <section className="agentHistory">
1083
                      <button
1084
                        className="historyHeading"
1085
                        type="button"
1086
                        aria-expanded={openHistory[agentId] !== false}
1087
                        aria-controls={`history-${agentId}`}
1088
                        onClick={() => {
1089
                          setOpenHistory((current) => ({
1090
                            ...current,
1091
                            [agentId]: current[agentId] === false,
1092
                          }));
1093
                          setExpandedHistoryId('');
1094
                        }}
1095
                      >
1096
                        History
1097
                        <small>{history.length}</small>
1098
                        <ChevronDown className="expandIcon" />
1099
                      </button>
1100
                      {openHistory[agentId] === false ? null : (
1101
                        <ol id={`history-${agentId}`}>
1102
                          {history.length ? (
1103
                            history.map((item) => {
1104
                              const itemOpen = expandedHistoryId === item.interactionId;
1105
                              return (
1106
                                <li
1107
                                  className={itemOpen ? 'historyEntry isExpanded' : 'historyEntry'}
1108
                                  key={item.interactionId}
1109
                                >
1110
                                  <div className="historyRowSummary">
1111
                                    <button
1112
                                      className="historyItem"
1113
                                      type="button"
1114
                                      aria-expanded={itemOpen}
1115
                                      onClick={() =>
1116
                                        setExpandedHistoryId(itemOpen ? '' : item.interactionId)
1117
                                      }
1118
                                    >
1119
                                      <OutcomeSymbol outcome={item.outcome} />
1120
                                      <span className="historyIdentity">
1121
                                        <strong>{item.counterpart}</strong>
1122
                                        <small>
1123
                                          Agent
1124
                                          {' · '}
1125
                                          {relativeTime(item.at)}
1126
                                        </small>
1127
                                      </span>
1128
                                      <span className="historyPurpose">{item.title}</span>
1129
                                      <b className={`outcomeText ${item.outcome}`}>
1130
                                        {humanize(item.outcome)}
1131
                                      </b>
1132
                                    </button>
1133
                                    <button
1134
                                      className="agentExpandButton"
1135
                                      type="button"
1136
                                      aria-expanded={itemOpen}
1137
                                      aria-label={`${itemOpen ? 'Collapse' : 'Expand'} ${item.title}`}
1138
                                      onClick={() =>
1139
                                        setExpandedHistoryId(itemOpen ? '' : item.interactionId)
1140
                                      }
1141
                                    >
1142
                                      <ChevronDown className="expandIcon" />
1143
                                    </button>
1144
                                  </div>
1145
                                  {itemOpen ? (
1146
                                    <div className="historyDetail">
1147
                                      <p>{item.title}</p>
1148
                                      <small>
1149
                                        {new Date(item.at).toLocaleString()} · {item.interactionId}
1150
                                      </small>
1151
                                    </div>
1152
                                  ) : null}
1153
                                </li>
1154
                              );
1155
                            })
1156
                          ) : (
1157
                            <li className="quietEmpty">No interactions yet</li>
1158
                          )}
1159
                        </ol>
1160
                      )}
1161
                    </section>
1162
                  </div>
1163
                ) : null}
1164
              </article>
1165
            );
1166
          })
1167
        ) : (
1168
          <button className="emptyAgentState" type="button" onClick={() => navigate('connect')}>
1169
            <FrameCorners />
1170
            <span aria-hidden="true">+</span>
1171
            <strong>Connect your first agent</strong>
1172
            <span>OpenClasp issues its identity. It does not run the agent.</span>
1173
          </button>
1174
        )}
1175
      </section>
1176
      <BehaviourScorecardDialog
1177
        open={Boolean(scorecardAgentId)}
1178
        onOpenChange={(open) => {
1179
          if (!open) setScorecardAgentId('');
1180
        }}
1181
        data={data}
1182
        agent={data.agents.find((item) => item.agentId === scorecardAgentId)}
1183
      />
1184
    </div>
1185
  );
1186
}
1187
1188
function latestContractProposal(interaction: Record<string, any>) {
1189
  return [...(interaction.contractRevisions ?? [])]
1190
    .reverse()
1191
    .find((revision) => revision.status === 'proposed');
1192
}
1193
1194
function invitationsForAgent(data: DashboardData, agentId: string) {
1195
  return data.federatedInteractions.filter((interaction) => {
1196
    const proposal = latestContractProposal(interaction);
1197
    return (
1198
      proposal &&
1199
      (
1200
        interaction.contract?.parties ?? [
1201
          interaction.initiatorAgentId,
1202
          interaction.responderAgentId,
1203
        ]
1204
      ).includes(agentId) &&
1205
      !proposal.acceptances?.[agentId]
1206
    );
1207
  });
1208
}
1209
1210
function counterpartyName(data: DashboardData, interaction: Record<string, any>, agentId: string) {
1211
  const counterpartyId =
1212
    interaction.initiatorAgentId === agentId
1213
      ? interaction.responderAgentId
1214
      : interaction.initiatorAgentId;
1215
  return data.agents.find((agent) => agent.agentId === counterpartyId)?.name ?? counterpartyId;
1216
}
1217
1218
function reliabilityForAgent(data: DashboardData, agentId: string) {
1219
  const summaries = data.intelligenceSummaries
1220
    .filter((summary) => summary.agentId === agentId)
1221
    .sort((left, right) => {
1222
      if (left.versionStatus?.status !== right.versionStatus?.status)
1223
        return left.versionStatus?.status === 'current' ? -1 : 1;
1224
      return Number(right.confidence?.value ?? 0) - Number(left.confidence?.value ?? 0);
1225
    });
1226
  const summary = summaries[0];
1227
  if (summary)
1228
    return {
1229
      score: Math.round(Number(summary.score) * 100),
1230
      samples: Number(summary.confidence?.evidenceCount ?? 0),
1231
      summary,
1232
      all: summaries,
1233
    };
1234
  const profiles = data.profiles.filter((profile) => profile.agentId === agentId);
1235
  if (!profiles.length) return { score: null, samples: 0, summary: null, all: [] };
1236
  let weightedScore = 0;
1237
  let weight = 0;
1238
  for (const profile of profiles) {
1239
    const dimensions = [
1240
      profile.completion,
1241
      profile.acceptance,
1242
      profile.specification,
1243
      profile.scope,
1244
      profile.evidence,
1245
      profile.communication,
1246
    ].filter((value) => Number.isFinite(Number(value)));
1247
    const profileScore = dimensions.length
1248
      ? dimensions.reduce((sum, value) => sum + Number(value), 0) / dimensions.length
1249
      : 0.5;
1250
    const sampleWeight = Math.max(
1251
      1,
1252
      Number(profile.effectiveSampleSize ?? profile.sampleSize ?? 1),
1253
    );
1254
    weightedScore += profileScore * sampleWeight;
1255
    weight += sampleWeight;
1256
  }
1257
  return {
1258
    score: Math.round((weightedScore / Math.max(1, weight)) * 100),
1259
    samples: profiles.reduce((sum, profile) => sum + Number(profile.sampleSize ?? 0), 0),
1260
    summary: null,
1261
    all: [],
1262
  };
1263
}
1264
1265
type BehaviourScorecardModel = {
1266
  agentId: string;
1267
  name: string;
1268
  version: string;
1269
  taskCategory: string;
1270
  confidence: string;
1271
  confidenceValue: number;
1272
  evidenceCount: number;
1273
  trend: string;
1274
  updatedAt: string;
1275
  verified: boolean;
1276
  metrics: ScorecardMetric[];
1277
};
1278
1279
function scorecardForAgent(
1280
  data: DashboardData,
1281
  agent: Record<string, any>,
1282
): BehaviourScorecardModel {
1283
  const agentId = String(agent.agentId);
1284
  const reliability = reliabilityForAgent(data, agentId);
1285
  const summary = reliability.summary;
1286
  const profiles = data.profiles
1287
    .filter(
1288
      (profile) =>
1289
        profile.agentId === agentId &&
1290
        (!summary ||
1291
          (profile.taskCategory === summary.taskCategory &&
1292
            profile.agentVersion === summary.agentVersion)),
1293
    )
1294
    .sort(
1295
      (left, right) =>
1296
        Date.parse(String(right.updatedAt ?? 0)) - Date.parse(String(left.updatedAt ?? 0)),
1297
    );
1298
  const profile = profiles[0];
1299
  const summaryDimensions = new Map<string, number>();
1300
  for (const item of [...(summary?.strengths ?? []), ...(summary?.risks ?? [])])
1301
    summaryDimensions.set(String(item.dimension), Number(item.score));
1302
  const legacyDimensions = new Set([
1303
    'completion',
1304
    'acceptance',
1305
    'specification',
1306
    'deadline',
1307
    'communication',
1308
    'evidence',
1309
    'scope',
1310
    'disputes',
1311
  ]);
1312
  const metrics = SCORECARD_DIMENSIONS.map((definition): ScorecardMetric => {
1313
    const raw = Number(profile?.[definition.key]);
1314
    const evidence = Number(
1315
      profile?.dimensionSampleSizes?.[definition.key] ??
1316
        (legacyDimensions.has(definition.key) ? (profile?.effectiveSampleSize ?? 0) : 0),
1317
    );
1318
    const profileValue = Number.isFinite(raw)
1319
      ? definition.key === 'disputes'
1320
        ? 1 - raw
1321
        : raw
1322
      : undefined;
1323
    const fallback = summaryDimensions.get(definition.key);
1324
    const measured = evidence > 0 || typeof fallback === 'number';
1325
    return {
1326
      ...definition,
1327
      value: measured
1328
        ? Math.round(Math.max(0, Math.min(1, profileValue ?? fallback ?? 0)) * 100)
1329
        : null,
1330
      evidence: Math.max(0, evidence),
1331
    };
1332
  });
1333
  return {
1334
    agentId,
1335
    name: String(agent.name ?? agentId),
1336
    version: String(summary?.agentVersion ?? agent.agentVersion ?? 'unknown'),
1337
    taskCategory: String(summary?.taskCategory ?? profile?.taskCategory ?? 'general'),
1338
    confidence: String(summary?.confidence?.level ?? 'unrated'),
1339
    confidenceValue: Number(summary?.confidence?.value ?? 0),
1340
    evidenceCount: Number(summary?.confidence?.evidenceCount ?? profile?.sampleSize ?? 0),
1341
    trend: String(summary?.trend?.direction ?? 'unrated'),
1342
    updatedAt: String(summary?.updatedAt ?? profile?.updatedAt ?? new Date().toISOString()),
1343
    verified: !agent.revoked && Boolean(agent.identityMode),
1344
    metrics,
1345
  };
1346
}
1347
1348
function scorecardSummary(model: BehaviourScorecardModel) {
1349
  const metrics = model.metrics
1350
    .map(
1351
      (metric) =>
1352
        `${metric.label}: ${metric.value === null ? 'not measured' : `${metric.value}/100`}`,
1353
    )
1354
    .join('\n');
1355
  return `${model.name} — OpenClasp behavioural scorecard\nContext: ${humanize(model.taskCategory)} · ${humanize(model.confidence)} confidence · ${model.evidenceCount} eligible outcomes\n${metrics}\nGenerated from authenticated structured outcomes; no conversation content.`;
1356
}
1357
1358
function escapeSvg(value: string) {
1359
  return value
1360
    .replaceAll('&', '&amp;')
1361
    .replaceAll('<', '&lt;')
1362
    .replaceAll('>', '&gt;')
1363
    .replaceAll('"', '&quot;')
1364
    .replaceAll("'", '&apos;');
1365
}
1366
1367
function scorecardSvg(model: BehaviourScorecardModel) {
1368
  const metricRows = model.metrics
1369
    .map((metric, index) => {
1370
      const column = index >= 5 ? 1 : 0;
1371
      const row = index % 5;
1372
      const x = 76 + column * 554;
1373
      const y = 266 + row * 64;
1374
      const width = metric.value === null ? 0 : Math.round((metric.value / 100) * 360);
1375
      const value = metric.value === null ? 'NOT MEASURED' : `${metric.value}`;
1376
      return `<g><text x="${x}" y="${y}" class="label">${escapeSvg(metric.short.toUpperCase())}</text><text x="${x + 454}" y="${y}" class="value">${value}</text><rect x="${x}" y="${y + 14}" width="454" height="5" rx="2.5" fill="#282321"/><rect x="${x}" y="${y + 14}" width="${width}" height="5" rx="2.5" fill="#ff4d2e"/></g>`;
1377
    })
1378
    .join('');
1379
  return `<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="675" viewBox="0 0 1200 675"><style>.brand{font:700 22px Arial,sans-serif;fill:#f5f1ed}.name{font:700 48px Arial,sans-serif;fill:#f5f1ed}.meta{font:500 16px Arial,sans-serif;fill:#a49a93}.label{font:600 13px Arial,sans-serif;letter-spacing:1.5px;fill:#a49a93}.value{font:700 15px Arial,sans-serif;text-anchor:end;fill:#f5f1ed}.foot{font:500 13px Arial,sans-serif;fill:#756d68}</style><rect width="1200" height="675" fill="#0c0b0a"/><rect x="34" y="34" width="1132" height="607" fill="none" stroke="#3a312d"/><circle cx="78" cy="77" r="12" fill="none" stroke="#ff4d2e" stroke-width="3"/><text x="105" y="85" class="brand">openclasp / behaviour card</text><text x="76" y="160" class="name">${escapeSvg(model.name)}</text><text x="76" y="198" class="meta">${escapeSvg(humanize(model.taskCategory))} · ${escapeSvg(humanize(model.confidence))} confidence · ${model.evidenceCount} eligible outcomes · v${escapeSvg(model.version)}</text><line x1="76" y1="228" x2="1124" y2="228" stroke="#3a312d"/>${metricRows}<line x1="76" y1="598" x2="1124" y2="598" stroke="#3a312d"/><text x="76" y="622" class="foot">STANDARD SCORECARD · AUTHENTICATED STRUCTURED OUTCOMES · NO CONVERSATION BODIES</text></svg>`;
1380
}
1381
1382
function BehaviourScorecardDialog({
1383
  open,
1384
  onOpenChange,
1385
  data,
1386
  agent,
1387
}: {
1388
  open: boolean;
1389
  onOpenChange: (open: boolean) => void;
1390
  data: DashboardData;
1391
  agent?: Record<string, any>;
1392
}) {
1393
  const [actionState, setActionState] = useState('');
1394
  useEffect(() => setActionState(''), [agent?.agentId]);
1395
  if (!agent) return null;
1396
  const model = scorecardForAgent(data, agent);
1397
  const saveCard = () => {
1398
    const blob = new Blob([scorecardSvg(model)], { type: 'image/svg+xml' });
1399
    const url = URL.createObjectURL(blob);
1400
    const anchor = document.createElement('a');
1401
    anchor.href = url;
1402
    anchor.download = `${model.name.toLowerCase().replaceAll(/[^a-z0-9]+/g, '-')}-openclasp-card.svg`;
1403
    anchor.click();
1404
    URL.revokeObjectURL(url);
1405
    setActionState('Downloaded');
1406
  };
1407
  const shareCard = async () => {
1408
    const file = new File([scorecardSvg(model)], 'openclasp-behaviour-card.svg', {
1409
      type: 'image/svg+xml',
1410
    });
1411
    try {
1412
      if (navigator.share && navigator.canShare?.({ files: [file] })) {
1413
        await navigator.share({ title: `${model.name} behaviour card`, files: [file] });
1414
        setActionState('Shared');
1415
      } else {
1416
        saveCard();
1417
      }
1418
    } catch (reason) {
1419
      if (!(reason instanceof DOMException && reason.name === 'AbortError'))
1420
        setActionState('Failed');
1421
    }
1422
  };
1423
  const copyCard = async () => {
1424
    try {
1425
      await navigator.clipboard.writeText(scorecardSummary(model));
1426
      setActionState('Copied');
1427
    } catch {
1428
      setActionState('Copy failed');
1429
    }
1430
  };
1431
  return (
1432
    <Dialog open={open} onOpenChange={onOpenChange}>
1433
      <DialogContent className="scorecardDialog" showCloseButton>
1434
        <DialogTitle className="scorecardA11yTitle">{model.name} behavioural scorecard</DialogTitle>
1435
        <DialogDescription className="scorecardA11yTitle">
1436
          Standard OpenClasp behavioural dimensions based on verified structured outcomes.
1437
        </DialogDescription>
1438
        <article className="behaviourScorecard">
1439
          <header className="scorecardHeader">
1440
            <div className="scorecardBrand">
1441
              <ClaspMark />
1442
              <span>openclasp / behaviour card</span>
1443
            </div>
1444
            <span className={model.verified ? 'scorecardVerified' : ''}>
1445
              <ShieldCheck /> {model.verified ? 'Identity verified' : 'Identity unverified'}
1446
            </span>
1447
          </header>
1448
          <div className="scorecardIdentity">
1449
            <div>
1450
              <p>STANDARD AGENT SCORECARD</p>
1451
              <h2>{model.name}</h2>
1452
            </div>
1453
            <div className="scorecardContext">
1454
              <span>{humanize(model.taskCategory)}</span>
1455
              <span>{humanize(model.confidence)} confidence</span>
1456
              <span>{model.evidenceCount} outcomes</span>
1457
              <span>v{model.version}</span>
1458
            </div>
1459
          </div>
1460
          <div className="scorecardMetrics">
1461
            {model.metrics.map((metric) => (
1462
              <div className={metric.value === null ? 'isUnmeasured' : ''} key={metric.key}>
1463
                <span>
1464
                  <strong>{metric.label}</strong>
1465
                  <small>
1466
                    {metric.value === null
1467
                      ? 'Awaiting eligible evidence'
1468
                      : `${metric.evidence.toFixed(1)} effective samples`}
1469
                  </small>
1470
                </span>
1471
                <span className="scorecardMeter" aria-hidden="true">
1472
                  <i style={{ width: `${metric.value ?? 0}%` }} />
1473
                </span>
1474
                <b>{metric.value === null ? '—' : metric.value}</b>
1475
              </div>
1476
            ))}
1477
          </div>
1478
          <footer className="scorecardFoot">
1479
            <span>
1480
              {model.trend === 'improving' ? '↗' : model.trend === 'declining' ? '↘' : '→'}{' '}
1481
              {humanize(model.trend)} trend
1482
            </span>
1483
            <span>Updated {relativeTime(model.updatedAt)}</span>
1484
            <small>Authenticated structured outcomes · conversation bodies excluded</small>
1485
          </footer>
1486
        </article>
1487
        <div className="scorecardActions">
1488
          <span aria-live="polite">{actionState}</span>
1489
          <button type="button" onClick={() => void copyCard()}>
1490
            <Copy /> Copy summary
1491
          </button>
1492
          <button type="button" onClick={saveCard}>
1493
            <Download /> Download
1494
          </button>
1495
          <button className="primary" type="button" onClick={() => void shareCard()}>
1496
            <Share2 /> Share card
1497
          </button>
1498
        </div>
1499
      </DialogContent>
1500
    </Dialog>
1501
  );
1502
}
1503
1504
function historyForAgent(data: DashboardData, agent: Record<string, any>): AgentHistoryItem[] {
1505
  const agentId = String(agent.agentId);
1506
  const interactions = new Map<string, Record<string, any>>();
1507
  for (const interaction of [...data.interactions, ...data.federatedInteractions]) {
1508
    const participants = [
1509
      interaction.agentId,
1510
      interaction.initiatorAgentId,
1511
      interaction.responderAgentId,
1512
      ...(interaction.contract?.parties ?? []),
1513
    ];
1514
    if (participants.includes(agentId))
1515
      interactions.set(String(interaction.interactionId), interaction);
1516
  }
1517
  return [...interactions.values()]
1518
    .map((interaction) => {
1519
      const interactionId = String(interaction.interactionId);
1520
      const report = [...data.completionReports]
1521
        .reverse()
1522
        .find((item) => item.interactionId === interactionId);
1523
      const receipt = [...data.receipts]
1524
        .reverse()
1525
        .find((item) => item.interactionId === interactionId);
1526
      const conclusion = [...data.interactionConclusions]
1527
        .reverse()
1528
        .find((item) => item.interactionId === interactionId);
1529
      const counterpartId = interaction.initiatorAgentId
1530
        ? interaction.initiatorAgentId === agentId
1531
          ? interaction.responderAgentId
1532
          : interaction.initiatorAgentId
1533
        : (interaction.counterpartyAgentId ?? 'Local interaction');
1534
      const counterpart =
1535
        data.agents.find((item) => item.agentId === counterpartId)?.name ?? counterpartId;
1536
      const counterpartMode: AgentHistoryItem['counterpartMode'] = 'agent';
1537
      const rawOutcome = String(
1538
        conclusion?.lifecycle === 'provisional'
1539
          ? 'provisional'
1540
          : (conclusion?.outcome ??
1541
              receipt?.outcome ??
1542
              report?.outcome ??
1543
              (interaction.status === 'completed' ? 'success' : (interaction.status ?? 'pending'))),
1544
      );
1545
      const outcome = (
1546
        ['success', 'partial', 'failure', 'cancelled', 'provisional', 'active'].includes(rawOutcome)
1547
          ? rawOutcome
1548
          : 'pending'
1549
      ) as AgentHistoryItem['outcome'];
1550
      return {
1551
        interactionId,
1552
        title: String(interaction.contract?.purpose ?? interaction.purpose ?? 'Interaction'),
1553
        counterpart: String(counterpart),
1554
        counterpartMode,
1555
        outcome,
1556
        at: String(
1557
          report?.completedAt ??
1558
            receipt?.issuedAt ??
1559
            conclusion?.concludedAt ??
1560
            interaction.completedAt ??
1561
            interaction.updatedAt ??
1562
            interaction.createdAt ??
1563
            new Date().toISOString(),
1564
        ),
1565
      };
1566
    })
1567
    .sort((left, right) => Date.parse(right.at) - Date.parse(left.at));
1568
}
1569
1570
function OutcomeSymbol({ outcome }: { outcome: AgentHistoryItem['outcome'] }) {
1571
  if (outcome === 'success')
1572
    return (
1573
      <span className="outcomeSymbol success">
1574
        <Check />
1575
      </span>
1576
    );
1577
  if (outcome === 'failure' || outcome === 'cancelled')
1578
    return (
1579
      <span className="outcomeSymbol failure">
1580
        <X />
1581
      </span>
1582
    );
1583
  if (outcome === 'partial' || outcome === 'provisional')
1584
    return <span className="outcomeSymbol partial">½</span>;
1585
  return (
1586
    <span className="outcomeSymbol pending">
1587
      <Circle />
1588
    </span>
1589
  );
1590
}
1591
1592
function Marketplace({ data }: { data: DashboardData }) {
1593
  const cloudAgents = useMemo(
1594
    () => data.agents.filter((agent) => agent.agentMode === 'persistent_runtime'),
1595
    [data.agents],
1596
  );
1597
  const [query, setQuery] = useState('');
1598
  const [selectedAgentId, setSelectedAgentId] = useState(() =>
1599
    String(cloudAgents[0]?.agentId ?? ''),
1600
  );
1601
  const [taskCategory, setTaskCategory] = useState(() =>
1602
    String(cloudAgents[0]?.capabilities?.[0] ?? 'general'),
1603
  );
1604
  const [results, setResults] = useState<Record<string, any>[]>([]);
1605
  const [loading, setLoading] = useState(true);
1606
  const [error, setError] = useState('');
1607
1608
  useEffect(() => {
1609
    const controller = new AbortController();
1610
    const timer = window.setTimeout(() => {
1611
      const parameters = new URLSearchParams({ limit: '50' });
1612
      if (query.trim()) parameters.set('query', query.trim());
1613
      if (selectedAgentId) parameters.set('agentId', selectedAgentId);
1614
      if (taskCategory.trim()) parameters.set('taskCategory', taskCategory.trim());
1615
      remoteApi(`/v0.1/marketplace?${parameters}`, { signal: controller.signal })
1616
        .then((result) =>
1617
          setResults(
1618
            (result as Record<string, any>[]).filter(
1619
              (item) => item.card?.agentMode === 'persistent_runtime',
1620
            ),
1621
          ),
1622
        )
1623
        .catch((reason: unknown) => {
1624
          if (controller.signal.aborted) return;
1625
          setError(reason instanceof Error ? reason.message : 'Directory unavailable');
1626
          setResults(
1627
            data.agents
1628
              .filter((agent) => agent.agentMode === 'persistent_runtime')
1629
              .filter((agent) =>
1630
                data.publications.some(
1631
                  (publication) => publication.agentId === agent.agentId && publication.published,
1632
                ),
1633
              )
1634
              .filter((agent) =>
1635
                `${agent.name} ${agent.description} ${(agent.capabilities ?? []).join(' ')}`
1636
                  .toLowerCase()
1637
                  .includes(query.toLowerCase()),
1638
              )
1639
              .map((card) => ({
1640
                card,
1641
                taskCategory,
1642
                match: {
1643
                  score: 0.5,
1644
                  label: 'possible',
1645
                  reasons: ['Preview capability match', 'No verified private history'],
1646
                },
1647
                contextualReliability: data.intelligenceSummaries.find(
1648
                  (summary) =>
1649
                    summary.agentId === card.agentId &&
1650
                    summary.taskCategory.toLowerCase() === taskCategory.toLowerCase(),
1651
                ),
1652
              })),
1653
          );
1654
        })
1655
        .finally(() => {
1656
          if (!controller.signal.aborted) setLoading(false);
1657
        });
1658
    }, 220);
1659
    return () => {
1660
      window.clearTimeout(timer);
1661
      controller.abort();
1662
    };
1663
  }, [
1664
    data.agents,
1665
    data.intelligenceSummaries,
1666
    data.publications,
1667
    query,
1668
    selectedAgentId,
1669
    taskCategory,
1670
  ]);
1671
1672
  return (
1673
    <div className="workspacePage marketplacePage">
1674
      <header className="workspaceHead marketplaceHead">
1675
        <h1>Marketplace</h1>
1676
        <div className="marketFilters">
1677
          <label>
1678
            <span>For</span>
1679
            <select
1680
              value={selectedAgentId}
1681
              onChange={(event) => {
1682
                const agentId = event.target.value;
1683
                const agent = cloudAgents.find((item) => item.agentId === agentId);
1684
                setSelectedAgentId(agentId);
1685
                setTaskCategory(String(agent?.capabilities?.[0] ?? 'general'));
1686
                setLoading(true);
1687
              }}
1688
            >
1689
              {cloudAgents.map((agent) => (
1690
                <option value={agent.agentId} key={agent.agentId}>
1691
                  {agent.name ?? agent.agentId}
1692
                </option>
1693
              ))}
1694
            </select>
1695
          </label>
1696
          <label>
1697
            <span>Task</span>
1698
            <input
1699
              value={taskCategory}
1700
              onChange={(event) => {
1701
                setTaskCategory(event.target.value);
1702
                setLoading(true);
1703
              }}
1704
              placeholder="procurement"
1705
            />
1706
          </label>
1707
          <label className="marketSearch">
1708
            <Search />
1709
            <input
1710
              type="search"
1711
              value={query}
1712
              onChange={(event) => {
1713
                setLoading(true);
1714
                setError('');
1715
                setQuery(event.target.value);
1716
              }}
1717
              placeholder="Search agents"
1718
            />
1719
          </label>
1720
        </div>
1721
      </header>
1722
      <div className="marketContext">
1723
        Cloud runtimes only. Recommendations combine public capabilities with your private, verified
1724
        history; scores remain task-specific and confidence-adjusted.
1725
      </div>
1726
      {error && !results.length ? <div className="errorBar">{error}</div> : null}
1727
      <section className="marketGrid" aria-live="polite">
1728
        {loading ? (
1729
          Array.from({ length: 6 }, (_, index) => <div className="marketSkeleton" key={index} />)
1730
        ) : results.length ? (
1731
          results.map((result) => {
1732
            const agent = result.card;
1733
            const online = agent.presence?.status === 'online';
1734
            const intelligence = result.contextualReliability;
1735
            return (
1736
              <article className="marketCard" key={agent.agentId}>
1737
                <FrameCorners />
1738
                <div className="marketCardTop">
1739
                  <AgentMark name={String(agent.name ?? agent.agentId)} online={online} />
1740
                  <span className={online ? 'marketPresence online' : 'marketPresence'}>
1741
                    {online ? 'Online' : 'Offline'}
1742
                  </span>
1743
                </div>
1744
                <div className={`matchBadge ${result.match.label}`}>
1745
                  <strong>{Math.round(Number(result.match.score) * 100)}%</strong>
1746
                  <span>
1747
                    {result.match.label} match for {result.taskCategory}
1748
                  </span>
1749
                </div>
1750
                <h2>{agent.name}</h2>
1751
                <p>{agent.description || 'Public OpenClasp agent'}</p>
1752
                <div className="marketTags">
1753
                  {(agent.capabilities ?? []).slice(0, 4).map((capability: string) => (
1754
                    <span key={capability}>{capability}</span>
1755
                  ))}
1756
                </div>
1757
                <div className="marketMeta">
1758
                  <span>
1759
                    <ShieldCheck /> Publisher verified
1760
                  </span>
1761
                  <span>
1762
                    <Cloud /> Cloud runtime
1763
                  </span>
1764
                </div>
1765
                <div className="marketIntelligence">
1766
                  {intelligence ? (
1767
                    <>
1768
                      <strong>{Math.round(Number(intelligence.score) * 100)}%</strong>
1769
                      <span>
1770
                        contextual reliability · {intelligence.confidence.level} confidence ·{' '}
1771
                        {intelligence.confidence.evidenceCount} outcomes
1772
                      </span>
1773
                    </>
1774
                  ) : (
1775
                    <span>No verified history for this task</span>
1776
                  )}
1777
                </div>
1778
                <a
1779
                  className="marketProfileLink"
1780
                  href={agent.profileUrl ?? `/a/${encodeURIComponent(String(agent.agentId))}`}
1781
                  target="_blank"
1782
                  rel="noreferrer"
1783
                >
1784
                  View agent <ArrowRight />
1785
                </a>
1786
              </article>
1787
            );
1788
          })
1789
        ) : (
1790
          <div className="quietEmpty marketEmpty">No matching public agents</div>
1791
        )}
1792
      </section>
1793
    </div>
1794
  );
1795
}
1796
1797
function Overview({
1798
  data,
1799
  navigate,
1800
  refreshDashboard,
1801
  api,
1802
}: {
1803
  data: DashboardData;
1804
  navigate: (page: Page) => void;
1805
  refreshDashboard: () => Promise<void>;
1806
  api: (path: string, init?: RequestInit) => Promise<unknown>;
1807
}) {
1808
  const completed = data.receipts.filter((item) => item.outcome === 'success').length;
1809
  const warnings = data.events.filter((item) =>
1810
    ['policy_warning', 'policy_violation', 'objection'].includes(String(item.eventType)),
1811
  ).length;
1812
  const publishedIds = new Set(
1813
    data.publications
1814
      .filter((publication) => publication.published)
1815
      .map((publication) => String(publication.agentId)),
1816
  );
1817
  const runtimeIds = new Set(
1818
    data.runtimes
1819
      .filter((runtime) => runtime.status === 'verified')
1820
      .map((runtime) => String(runtime.agentId)),
1821
  );
1822
  const readyAgents = data.agents.filter(
1823
    (agent) => publishedIds.has(String(agent.agentId)) && runtimeIds.has(String(agent.agentId)),
1824
  ).length;
1825
  const pendingInvitations = data.federatedInteractions.filter(
1826
    (interaction) => interaction.status === 'pending',
1827
  ).length;
1828
  const pendingFeedback = data.feedbackRequests.filter(
1829
    (request) => request.status === 'pending',
1830
  ).length;
1831
  const pendingSetup = data.setupRequests.filter((request) => request.status === 'pending').length;
1832
  const reviewCount = warnings + pendingInvitations + pendingFeedback;
1833
  return (
1834
    <>
1835
      <PageHead page="dashboard" action="Connect agent" onAction={() => navigate('connect')} />
1836
      {pendingSetup > 0 && (
1837
        <button className="attentionBanner" type="button" onClick={() => navigate('connect')}>
1838
          <div>
1839
            <span className="statusIndex">//</span>
1840
            <div>
1841
              <strong>
1842
                {pendingSetup} setup request{pendingSetup === 1 ? '' : 's'} waiting
1843
              </strong>
1844
            </div>
1845
          </div>
1846
        </button>
1847
      )}
1848
      {!(readyAgents === data.agents.length && readyAgents) && (
1849
        <section className="readiness">
1850
          <div>
1851
            <span className="statusIndex">!</span>
1852
            <div>
1853
              <strong>
1854
                {readyAgents} of {data.agents.length} agents can receive A2A work
1855
              </strong>
1856
            </div>
1857
          </div>
1858
          <button className="secondary" type="button" onClick={() => navigate('agents')}>
1859
            Manage
1860
          </button>
1861
        </section>
1862
      )}
1863
      <section className="metrics">
1864
        <Metric
1865
          label="Connected runtimes"
1866
          value={runtimeIds.size}
1867
          note={`${readyAgents} published and ready`}
1868
        />
1869
        <Metric
1870
          label="Interactions"
1871
          value={data.interactions.length + data.federatedInteractions.length}
1872
          note="local and shared"
1873
        />
1874
        <Metric label="Successful outcomes" value={completed} note="receipt-backed" />
1875
        <Metric
1876
          label="Needs review"
1877
          value={reviewCount}
1878
          note={`${pendingFeedback} feedback · ${pendingInvitations} invitations`}
1879
          warn={reviewCount > 0}
1880
        />
1881
      </section>
1882
      <Invitations data={data} refreshDashboard={refreshDashboard} api={api} />
1883
      <section className="contentGrid">
1884
        <Panel title="Recent activity" subtitle="">
1885
          <Timeline events={data.events.slice(-6).reverse()} />
1886
          <TextButton onClick={() => navigate('history')}>All history</TextButton>
1887
        </Panel>
1888
        <Panel title="Reliability" subtitle="">
1889
          {data.profiles.length ? (
1890
            data.profiles.slice(0, 4).map((profile) => {
1891
              const deltas = data.profileDeltas.filter(
1892
                (delta) =>
1893
                  delta.agentId === profile.agentId &&
1894
                  delta.agentVersion === profile.agentVersion &&
1895
                  delta.taskCategory === profile.taskCategory,
1896
              );
1897
              return (
1898
                <Profile
1899
                  key={`${profile.agentId}-${profile.agentVersion}-${profile.taskCategory}`}
1900
                  profile={profile}
1901
                  deltas={deltas}
1902
                />
1903
              );
1904
            })
1905
          ) : (
1906
            <Empty
1907
              title="No reliability profile yet"
1908
              text="Profiles appear after signed receipts and eligible bilateral feedback."
1909
            />
1910
          )}
1911
        </Panel>
1912
      </section>
1913
    </>
1914
  );
1915
}
1916
1917
function History({
1918
  data,
1919
  refreshDashboard,
1920
  api,
1921
}: {
1922
  data: DashboardData;
1923
  refreshDashboard: () => Promise<void>;
1924
  api: (path: string, init?: RequestInit) => Promise<unknown>;
1925
}) {
1926
  const [query, setQuery] = useState('');
1927
  const [status, setStatus] = useState<
1928
    'all' | 'pending' | 'active' | 'provisional' | 'finalizing' | 'completed'
1929
  >('all');
1930
  const journeys = useMemo(() => interactionJourneys(data), [data]);
1931
  const filtered = useMemo(() => {
1932
    const needle = query.trim().toLowerCase();
1933
    return journeys.filter((journey) => {
1934
      if (status !== 'all' && journey.status !== status) return false;
1935
      if (!needle) return true;
1936
      const hay = [
1937
        journey.interactionId,
1938
        journey.purpose,
1939
        journey.taskCategory,
1940
        journey.status,
1941
        ...journey.participants,
1942
      ]
1943
        .filter(Boolean)
1944
        .join(' ')
1945
        .toLowerCase();
1946
      return hay.includes(needle);
1947
    });
1948
  }, [journeys, query, status]);
1949
  const [selectedId, setSelectedId] = useState('');
1950
  const selected = filtered.find((journey) => journey.interactionId === selectedId) ?? filtered[0];
1951
  return (
1952
    <>
1953
      <PageHead page="history" />
1954
      <div className="historyToolbar">
1955
        <Input
1956
          value={query}
1957
          onChange={(event) => setQuery(event.target.value)}
1958
          placeholder="Search purpose, category, or agent"
1959
          aria-label="Search interaction history"
1960
          className="h-9 min-w-[220px] flex-1 rounded-none border-0 border-b bg-transparent px-0 shadow-none dark:bg-transparent"
1961
        />
1962
        <Tabs
1963
          value={status === 'finalizing' ? 'all' : status}
1964
          onValueChange={(value) =>
1965
            setStatus(value as 'all' | 'pending' | 'active' | 'provisional' | 'completed')
1966
          }
1967
        >
1968
          <TabsList variant="line" aria-label="Interaction status filters">
1969
            {(['all', 'pending', 'active', 'provisional', 'completed'] as const).map((value) => (
1970
              <TabsTrigger key={value} value={value} className="capitalize">
1971
                {value}
1972
                <span className="font-mono text-[10px] text-muted-foreground">
1973
                  {value === 'all'
1974
                    ? journeys.length
1975
                    : journeys.filter((item) => item.status === value).length}
1976
                </span>
1977
              </TabsTrigger>
1978
            ))}
1979
          </TabsList>
1980
        </Tabs>
1981
      </div>
1982
      {filtered.length ? (
1983
        <section className="journeyLayout">
1984
          <div className="journeyList" aria-label="Interactions">
1985
            {filtered.map((journey) => (
1986
              <button
1987
                key={journey.interactionId}
1988
                className={selected?.interactionId === journey.interactionId ? 'active' : ''}
1989
                type="button"
1990
                onClick={() => {
1991
                  setSelectedId(journey.interactionId);
1992
                  if (matchMedia('(max-width: 980px)').matches)
1993
                    requestAnimationFrame(() =>
1994
                      document
1995
                        .querySelector('.journeyDetail')
1996
                        ?.scrollIntoView({ behavior: 'smooth' }),
1997
                    );
1998
                }}
1999
              >
2000
                <span className="journeyListTop">
2001
                  <b>{journey.taskCategory}</b>
2002
                  <StatusPill value={journey.status} />
2003
                </span>
2004
                <strong>{journey.purpose}</strong>
2005
                <small>{journey.participants.join(' ↔ ') || 'Participants unavailable'}</small>
2006
                <span className="journeyListMeta">
2007
                  {relativeTime(journey.updatedAt)}
2008
                  <i>{journey.stepCount} recorded steps</i>
2009
                </span>
2010
              </button>
2011
            ))}
2012
          </div>
2013
          {selected ? (
2014
            <InteractionJourney journey={selected} refreshDashboard={refreshDashboard} api={api} />
2015
          ) : null}
2016
        </section>
2017
      ) : (
2018
        <section className="panel">
2019
          <Empty
2020
            title={journeys.length ? 'No matching interactions' : 'No interactions recorded'}
2021
            text={
2022
              journeys.length
2023
                ? 'Clear the search or choose another status.'
2024
                : 'Completed contracts, outcomes, feedback, and learning will appear as one readable journey.'
2025
            }
2026
          />
2027
        </section>
2028
      )}
2029
    </>
2030
  );
2031
}
2032
2033
type InteractionJourneyModel = {
2034
  interactionId: string;
2035
  purpose: string;
2036
  taskCategory: string;
2037
  status: string;
2038
  participants: string[];
2039
  updatedAt: string;
2040
  stepCount: number;
2041
  interaction: Record<string, any>;
2042
  session?: Record<string, any>;
2043
  events: Record<string, any>[];
2044
  briefs: Record<string, any>[];
2045
  reports: Record<string, any>[];
2046
  feedbackRequests: Record<string, any>[];
2047
  feedback: Record<string, any>[];
2048
  conclusion?: Record<string, any>;
2049
  receipt?: Record<string, any>;
2050
  eligibility?: Record<string, any>;
2051
  deltas: Record<string, any>[];
2052
  conflicts: Record<string, any>[];
2053
  assuranceAssessments: Record<string, any>[];
2054
  assurancePredictions: Record<string, any>[];
2055
  assuranceSafeguards: Record<string, any>[];
2056
  assuranceEvaluations: Record<string, any>[];
2057
  assuranceProbePlans: Record<string, any>[];
2058
  assuranceProbeResponses: Record<string, any>[];
2059
};
2060
2061
function interactionJourneys(data: DashboardData): InteractionJourneyModel[] {
2062
  const ids = new Set<string>();
2063
  const collections = [
2064
    data.interactions,
2065
    data.federatedInteractions,
2066
    data.liveSessions,
2067
    data.events,
2068
    data.counterpartyBriefs,
2069
    data.completionReports,
2070
    data.feedbackRequests,
2071
    data.interactionFeedback,
2072
    data.interactionConclusions,
2073
    data.receipts,
2074
    data.learningEligibility,
2075
    data.profileDeltas,
2076
    data.conflicts,
2077
    data.assuranceAssessments,
2078
    data.assurancePredictions,
2079
    data.assuranceSafeguards,
2080
    data.assuranceEvaluations,
2081
    data.assuranceProbePlans,
2082
    data.assuranceProbeResponses,
2083
  ];
2084
  for (const collection of collections)
2085
    for (const item of collection) if (item.interactionId) ids.add(String(item.interactionId));
2086
  return [...ids]
2087
    .map((interactionId) => {
2088
      const interaction = data.federatedInteractions.find(
2089
        (item) => item.interactionId === interactionId,
2090
      ) ??
2091
        data.interactions.find((item) => item.interactionId === interactionId) ?? { interactionId };
2092
      const session = data.liveSessions.find((item) => item.interactionId === interactionId);
2093
      const events = data.events.filter((item) => item.interactionId === interactionId);
2094
      const briefs = data.counterpartyBriefs.filter((item) => item.interactionId === interactionId);
2095
      const reports = data.completionReports.filter((item) => item.interactionId === interactionId);
2096
      const feedbackRequests = data.feedbackRequests.filter(
2097
        (item) => item.interactionId === interactionId,
2098
      );
2099
      const feedback = data.interactionFeedback.filter(
2100
        (item) => item.interactionId === interactionId,
2101
      );
2102
      const conclusion = data.interactionConclusions.find(
2103
        (item) => item.interactionId === interactionId,
2104
      );
2105
      const receipt = data.receipts.find((item) => item.interactionId === interactionId);
2106
      const eligibility = data.learningEligibility.find(
2107
        (item) => item.interactionId === interactionId,
2108
      );
2109
      const deltas = data.profileDeltas.filter((item) => item.interactionId === interactionId);
2110
      const conflicts = data.conflicts.filter((item) => item.interactionId === interactionId);
2111
      const assuranceAssessments = data.assuranceAssessments.filter(
2112
        (item) => item.interactionId === interactionId,
2113
      );
2114
      const assurancePredictions = data.assurancePredictions.filter(
2115
        (item) => item.interactionId === interactionId,
2116
      );
2117
      const assuranceSafeguards = data.assuranceSafeguards.filter(
2118
        (item) => item.interactionId === interactionId,
2119
      );
2120
      const assuranceEvaluations = data.assuranceEvaluations.filter(
2121
        (item) => item.interactionId === interactionId,
2122
      );
2123
      const assuranceProbePlans = data.assuranceProbePlans.filter(
2124
        (item) => item.interactionId === interactionId,
2125
      );
2126
      const assuranceProbeResponses = data.assuranceProbeResponses.filter(
2127
        (item) => item.interactionId === interactionId,
2128
      );
2129
      const participants = [
2130
        interaction.initiatorAgentId,
2131
        interaction.responderAgentId,
2132
        interaction.agentId,
2133
        ...reports.flatMap((report) => [report.reportingAgentId, report.counterpartyAgentId]),
2134
      ]
2135
        .filter((value): value is string => typeof value === 'string' && Boolean(value))
2136
        .filter((value, index, values) => values.indexOf(value) === index);
2137
      const dates = [
2138
        interaction.updatedAt,
2139
        interaction.createdAt,
2140
        session?.completedAt,
2141
        session?.activatedAt,
2142
        ...events.map(timestamp),
2143
        ...reports.map(timestamp),
2144
        ...feedback.map(timestamp),
2145
        ...assuranceAssessments.map(timestamp),
2146
        ...assurancePredictions.map(timestamp),
2147
        conclusion?.generatedAt,
2148
        receipt?.completedAt,
2149
        eligibility?.decidedAt,
2150
      ].filter((value): value is string => typeof value === 'string');
2151
      return {
2152
        interactionId,
2153
        purpose: String(
2154
          interaction.contract?.purpose ??
2155
            interaction.contract?.requestedOutcome ??
2156
            conclusion?.summary ??
2157
            'Assured agent interaction',
2158
        ),
2159
        taskCategory: String(interaction.contract?.taskCategory ?? 'general'),
2160
        status: String(
2161
          conclusion || receipt
2162
            ? conclusion?.lifecycle === 'provisional' || receipt?.provisional
2163
              ? 'provisional'
2164
              : 'completed'
2165
            : reports.length > 0
2166
              ? 'finalizing'
2167
              : (session?.status ?? interaction.status ?? 'pending'),
2168
        ),
2169
        participants,
2170
        updatedAt:
2171
          dates.sort((left, right) => Date.parse(right) - Date.parse(left))[0] ??
2172
          new Date(0).toISOString(),
2173
        stepCount:
2174
          1 +
2175
          Math.max(0, Number(interaction.contractRevisions?.length ?? 0) - 1) +
2176
          events.length +
2177
          briefs.length +
2178
          reports.length +
2179
          feedbackRequests.length +
2180
          feedback.length +
2181
          conflicts.length +
2182
          Number(Boolean(conclusion)) +
2183
          Number(Boolean(receipt)) +
2184
          Number(Boolean(eligibility)) +
2185
          deltas.length +
2186
          assuranceAssessments.length +
2187
          assuranceProbeResponses.length +
2188
          assuranceSafeguards.filter((item) => item.status !== 'recommended').length,
2189
        interaction,
2190
        ...(session ? { session } : {}),
2191
        events,
2192
        briefs,
2193
        reports,
2194
        feedbackRequests,
2195
        feedback,
2196
        ...(conclusion ? { conclusion } : {}),
2197
        ...(receipt ? { receipt } : {}),
2198
        ...(eligibility ? { eligibility } : {}),
2199
        deltas,
2200
        conflicts,
2201
        assuranceAssessments,
2202
        assurancePredictions,
2203
        assuranceSafeguards,
2204
        assuranceEvaluations,
2205
        assuranceProbePlans,
2206
        assuranceProbeResponses,
2207
      };
2208
    })
2209
    .sort((left, right) => Date.parse(right.updatedAt) - Date.parse(left.updatedAt));
2210
}
2211
2212
function InteractionJourney({
2213
  journey,
2214
  refreshDashboard,
2215
  api,
2216
}: {
2217
  journey: InteractionJourneyModel;
2218
  refreshDashboard: () => Promise<void>;
2219
  api: (path: string, init?: RequestInit) => Promise<unknown>;
2220
}) {
2221
  const [assuranceWorking, setAssuranceWorking] = useState('');
2222
  const [assuranceError, setAssuranceError] = useState('');
2223
  const timeline = interactionTimeline(journey);
2224
  const conclusion = journey.conclusion;
2225
  const checkpoints = journey.events.filter(
2226
    (event) => (event.type ?? event.eventType) === 'progress_checkpoint' && event.checkpoint,
2227
  );
2228
  const latestCheckpoint = checkpoints.at(-1)?.checkpoint;
2229
  const progress = Number(latestCheckpoint?.progress ?? 0);
2230
  const submittedFeedback = new Set(
2231
    journey.feedback.map((item) => item.reviewerAgentId).filter(Boolean),
2232
  ).size;
2233
  const latestPrediction = journey.assurancePredictions.at(-1);
2234
  const latestAssessment = journey.assuranceAssessments.at(-1);
2235
  const activeSafeguards = journey.assuranceSafeguards.filter(
2236
    (item) => item.status === 'recommended',
2237
  );
2238
  const latestPlan = journey.assuranceProbePlans.at(-1);
2239
  const latestProbe = latestPlan?.questions?.[0];
2240
  const latestProbeResponse = journey.assuranceProbeResponses.find(
2241
    (item) => item.planId === latestPlan?.planId,
2242
  );
2243
  const latestEvaluation = journey.assuranceEvaluations.at(-1);
2244
  const averageBrier = latestEvaluation?.predictionScores?.length
2245
    ? latestEvaluation.predictionScores.reduce(
2246
        (sum: number, item: Record<string, any>) => sum + Number(item.brierScore ?? 0),
2247
        0,
2248
      ) / latestEvaluation.predictionScores.length
2249
    : undefined;
2250
  const decideSafeguard = async (
2251
    safeguard: Record<string, any>,
2252
    status: 'accepted' | 'rejected',
2253
  ) => {
2254
    const assessment =
2255
      journey.assuranceAssessments.find((item) => item.assessmentId === safeguard.assessmentId) ??
2256
      latestAssessment;
2257
    if (!assessment?.generatedForAgentId) {
2258
      setAssuranceError('The requesting agent for this safeguard is unavailable.');
2259
      return;
2260
    }
2261
    setAssuranceWorking(String(safeguard.safeguardId));
2262
    setAssuranceError('');
2263
    try {
2264
      await api(
2265
        `/v0.1/federated-interactions/${encodeURIComponent(journey.interactionId)}/assurance-safeguards/${encodeURIComponent(String(safeguard.safeguardId))}/decision`,
2266
        {
2267
          method: 'POST',
2268
          body: JSON.stringify({ status, agentId: assessment.generatedForAgentId }),
2269
        },
2270
      );
2271
      await refreshDashboard();
2272
    } catch (reason) {
2273
      setAssuranceError(
2274
        reason instanceof Error ? reason.message : 'Could not update the safeguard decision',
2275
      );
2276
    } finally {
2277
      setAssuranceWorking('');
2278
    }
2279
  };
2280
  return (
2281
    <article className="journeyDetail">
2282
      <header className="journeyHero">
2283
        <div>
2284
          <span className="eyebrow">{journey.taskCategory}</span>
2285
          <h2>{journey.purpose}</h2>
2286
          <p>{journey.participants.join(' ↔ ')}</p>
2287
        </div>
2288
        <StatusPill value={conclusion?.outcome ?? journey.status} />
2289
      </header>
2290
2291
      <div className="journeySummaryGrid">
2292
        <section>
2293
          <span>Progress</span>
2294
          <strong>
2295
            {latestCheckpoint
2296
              ? `${Math.round(progress * 100)}% · ${humanize(latestCheckpoint.state)}`
2297
              : journey.reports.length
2298
                ? 'One side ended'
2299
                : 'Awaiting checkpoint'}
2300
          </strong>
2301
          <small>
2302
            {latestCheckpoint
2303
              ? `${humanize(latestCheckpoint.topicStatus)} · ${Math.round(Number(latestCheckpoint.confidence ?? 0) * 100)}% confidence`
2304
              : 'Agents report compact progress every few exchanges'}
2305
          </small>
2306
        </section>
2307
        <section>
2308
          <span>Outcome</span>
2309
          <strong>
2310
            {conclusion
2311
              ? humanize(conclusion.outcome)
2312
              : `${journey.reports.length}/2 terminal reports`}
2313
          </strong>
2314
          <small>
2315
            {conclusion
2316
              ? conclusion.lifecycle === 'provisional'
2317
                ? `${Math.round(Number(conclusion.confidence ?? 0) * 100)}% confidence · peer ${humanize(conclusion.peerReportStatus ?? 'awaiting').toLowerCase()}`
2318
                : humanize(conclusion.consensus)
2319
              : journey.reports.length === 1
2320
                ? 'One agent finished; confirming with the peer'
2321
                : 'Conversation remains active'}
2322
          </small>
2323
        </section>
2324
        <section>
2325
          <span>Sealed feedback</span>
2326
          <strong>
2327
            {journey.feedbackRequests.some((request) => request.status === 'pending')
2328
              ? `${submittedFeedback}/2 sealed`
2329
              : 'Window closed'}
2330
          </strong>
2331
          <small>{feedbackState(journey.feedbackRequests)}</small>
2332
        </section>
2333
        <section>
2334
          <span>Learning</span>
2335
          <strong>
2336
            {journey.eligibility
2337
              ? journey.eligibility.eligible
2338
                ? `${Math.round(Number(journey.eligibility.sampleWeight ?? 0) * 100)}% weight`
2339
                : 'Not eligible'
2340
              : conclusion?.lifecycle === 'provisional'
2341
                ? 'Pending corroboration'
2342
                : 'Not assessed'}
2343
          </strong>
2344
          <small>{humanize(journey.eligibility?.contributionMode ?? 'Local only')}</small>
2345
        </section>
2346
      </div>
2347
2348
      {latestPrediction ? (
2349
        <section className="progressCard">
2350
          <div className="progressCardTop">
2351
            <div>
2352
              <span className="eyebrow">AI ASSURANCE · EXPERIMENTAL</span>
2353
              <h3>
2354
                {Math.round(Number(latestPrediction.successProbability) * 100)}% likely to complete
2355
              </h3>
2356
            </div>
2357
            <StatusPill value={latestPrediction.basis ?? 'experimental'} />
2358
          </div>
2359
          <div className="progressFacts">
2360
            <span>{Math.round(Number(latestPrediction.confidence ?? 0) * 100)}% confidence</span>
2361
            <span>{humanize(latestPrediction.stage ?? 'baseline')} prediction</span>
2362
            <span>{latestAssessment?.risks?.length ?? 0} material risks</span>
2363
            <span>
2364
              {journey.assuranceProbeResponses.length}/{journey.assuranceProbePlans.length} probes
2365
              answered
2366
            </span>
2367
            <span>{activeSafeguards.length} safeguards awaiting decision</span>
2368
          </div>
2369
          <div className="assuranceDecisionGrid">
2370
            <section>
2371
              <span className="eyebrow">TOP RISKS</span>
2372
              {latestAssessment?.risks?.slice(0, 3).map((risk: Record<string, any>) => (
2373
                <div className="assuranceRisk" key={risk.riskCode}>
2374
                  <strong>{risk.title}</strong>
2375
                  <small>
2376
                    {Math.round(Number(risk.likelihood ?? 0) * 100)}% likelihood ·{' '}
2377
                    {Math.round(Number(risk.impact ?? 0) * 100)}% impact
2378
                  </small>
2379
                </div>
2380
              ))}
2381
            </section>
2382
            <section>
2383
              <span className="eyebrow">SELECTED QUESTION · ROUND {latestPlan?.round ?? 1}</span>
2384
              {latestProbe ? (
2385
                <div className="assuranceProbe">
2386
                  <strong>{latestProbe.prompt}</strong>
2387
                  <small>
2388
                    {latestProbeResponse
2389
                      ? `Answered · prediction updated`
2390
                      : `Awaiting ${latestAssessment?.targetAgentId ?? 'counterparty'} response`}
2391
                  </small>
2392
                </div>
2393
              ) : (
2394
                <small>No probe generated.</small>
2395
              )}
2396
            </section>
2397
          </div>
2398
          {activeSafeguards.length ? (
2399
            <div className="assuranceSafeguards">
2400
              <span className="eyebrow">RECOMMENDED SAFEGUARDS</span>
2401
              {activeSafeguards.map((safeguard) => (
2402
                <div className="assuranceSafeguard" key={safeguard.safeguardId}>
2403
                  <div>
2404
                    <strong>{safeguard.description}</strong>
2405
                    <small>{safeguard.rationale}</small>
2406
                  </div>
2407
                  <div className="assuranceDecisionActions">
2408
                    <button
2409
                      type="button"
2410
                      disabled={assuranceWorking === safeguard.safeguardId}
2411
                      onClick={() => void decideSafeguard(safeguard, 'rejected')}
2412
                    >
2413
                      reject
2414
                    </button>
2415
                    <button
2416
                      className="approve"
2417
                      type="button"
2418
                      disabled={assuranceWorking === safeguard.safeguardId}
2419
                      onClick={() => void decideSafeguard(safeguard, 'accepted')}
2420
                    >
2421
                      accept
2422
                    </button>
2423
                  </div>
2424
                </div>
2425
              ))}
2426
            </div>
2427
          ) : null}
2428
          {averageBrier !== undefined ? (
2429
            <p className="assuranceCalibration">
2430
              Outcome scored · Brier error {averageBrier.toFixed(3)} · lower is better
2431
            </p>
2432
          ) : null}
2433
          {assuranceError ? (
2434
            <p className="progressWarning" role="alert">
2435
              {assuranceError}
2436
            </p>
2437
          ) : null}
2438
          <p className="assuranceNotice">
2439
            Advisory, not a guarantee. Accepted safeguards that change terms require a contract
2440
            revision.
2441
          </p>
2442
        </section>
2443
      ) : null}
2444
2445
      {latestCheckpoint ? (
2446
        <section className="progressCard">
2447
          <div className="progressCardTop">
2448
            <div>
2449
              <span className="eyebrow">LATEST PRIVATE CHECKPOINT</span>
2450
              <h3>{humanize(latestCheckpoint.state)}</h3>
2451
            </div>
2452
            <strong>{Math.round(progress * 100)}%</strong>
2453
          </div>
2454
          <div
2455
            className="progressTrack"
2456
            role="progressbar"
2457
            aria-label="Interaction progress"
2458
            aria-valuemin={0}
2459
            aria-valuemax={100}
2460
            aria-valuenow={Math.round(progress * 100)}
2461
          >
2462
            <i style={{ width: `${Math.round(progress * 100)}%` }} />
2463
          </div>
2464
          <div className="progressFacts">
2465
            <span>{latestCheckpoint.criteriaMet?.length ?? 0} criteria met</span>
2466
            <span>{latestCheckpoint.criteriaRemaining?.length ?? 0} remaining</span>
2467
            <span>{latestCheckpoint.expectedRemainingTurns ?? '—'} expected turns</span>
2468
            <span>
2469
              {latestCheckpoint.needsHuman ? 'Human input needed' : 'No human input needed'}
2470
            </span>
2471
          </div>
2472
          {latestCheckpoint.topicStatus !== 'in_scope' ? (
2473
            <p className="progressWarning">
2474
              Topic {humanize(latestCheckpoint.topicStatus)}. Amend the contract or start a new
2475
              interaction before continuing.
2476
            </p>
2477
          ) : null}
2478
        </section>
2479
      ) : null}
2480
2481
      {conclusion ? (
2482
        <section className="outcomeCard">
2483
          <div>
2484
            <span className="eyebrow">
2485
              {conclusion.lifecycle === 'provisional' ? 'PROVISIONAL INSIGHT' : 'WHAT HAPPENED'}
2486
            </span>
2487
            <h3>{conclusion.summary}</h3>
2488
            {conclusion.lifecycle === 'provisional' ? (
2489
              <p className="progressWarning">
2490
                Based on {conclusion.reportIds?.length ?? 1} participant report. Missing:{' '}
2491
                {(conclusion.missingReportAgentIds ?? []).join(', ') || 'peer report'}. This is
2492
                usable now at reduced confidence and will be revised if the peer responds.
2493
              </p>
2494
            ) : null}
2495
          </div>
2496
          {!!conclusion.criteria?.length && (
2497
            <ul>
2498
              {conclusion.criteria.map((criterion: Record<string, any>) => (
2499
                <li key={criterion.criterion}>
2500
                  <StatusPill value={String(criterion.status)} />
2501
                  <span>{criterion.criterion}</span>
2502
                </li>
2503
              ))}
2504
            </ul>
2505
          )}
2506
          {!!Object.keys(conclusion.averageRatings ?? {}).length && (
2507
            <div className="ratingGrid">
2508
              {Object.entries(conclusion.averageRatings).map(([label, value]) => (
2509
                <Meter key={label} label={humanize(label)} value={Number(value)} />
2510
              ))}
2511
            </div>
2512
          )}
2513
        </section>
2514
      ) : (
2515
        <div className="journeyNotice">
2516
          {journey.reports.length === 1
2517
            ? 'Finalizing: one agent submitted its terminal report. OpenClasp has requested independent confirmation from the peer.'
2518
            : journey.reports.length >= 2
2519
              ? 'Both agents finished. Sealed feedback remains private until both respond or the feedback window expires.'
2520
              : 'Active interaction. Progress checkpoints appear after several meaningful exchanges; either agent can finalize immediately when the task is done.'}
2521
        </div>
2522
      )}
2523
2524
      <section className="journeyTimelineSection">
2525
        <div>
2526
          <h3>Interaction timeline</h3>
2527
          <small>Contract → session → outcome → feedback → learning</small>
2528
        </div>
2529
        <ol className="journeyTimeline">
2530
          {timeline.map((step, index) => (
2531
            <li key={`${step.at}-${step.title}-${index}`}>
2532
              <span className={`journeyNode ${step.tone}`} />
2533
              <div>
2534
                <span>{formatDate(step.at)}</span>
2535
                <strong>{step.title}</strong>
2536
                <p>{step.detail}</p>
2537
              </div>
2538
              <StatusPill value={step.status} />
2539
            </li>
2540
          ))}
2541
        </ol>
2542
      </section>
2543
    </article>
2544
  );
2545
}
2546
2547
function interactionTimeline(journey: InteractionJourneyModel) {
2548
  const revisions = journey.interaction.contractRevisions ?? [];
2549
  const contractSteps = revisions.length
2550
    ? revisions.map((revision: Record<string, any>) => ({
2551
        at: String(revision.updatedAt ?? revision.createdAt ?? journey.interaction.createdAt),
2552
        title: `Contract v${revision.revision} ${humanize(revision.status)}`,
2553
        detail: `${revision.proposedByAgentId} · ${Object.keys(revision.acceptances ?? {}).length}/${journey.participants.length || 2} accepted · ${String(revision.termsHash).slice(0, 12)}…`,
2554
        status: String(revision.status),
2555
        tone:
2556
          revision.status === 'accepted'
2557
            ? 'good'
2558
            : revision.status === 'rejected'
2559
              ? 'danger'
2560
              : revision.status === 'proposed'
2561
                ? 'warn'
2562
                : 'neutral',
2563
      }))
2564
    : [
2565
        {
2566
          at: String(journey.interaction.createdAt ?? journey.updatedAt),
2567
          title: 'Contract proposed',
2568
          detail: `${journey.participants.length || 2} participants · ${journey.taskCategory}`,
2569
          status: String(journey.interaction.status ?? 'recorded'),
2570
          tone: 'neutral',
2571
        },
2572
        ...Object.values(journey.interaction.acceptances ?? {}).map((acceptance: any) => ({
2573
          at: String(acceptance.acceptedAt ?? journey.interaction.updatedAt ?? journey.updatedAt),
2574
          title: `Contract accepted by ${acceptance.agentId}`,
2575
          detail: `Acceptance method: ${humanize(acceptance.method ?? 'recorded')}`,
2576
          status: 'accepted',
2577
          tone: 'good',
2578
        })),
2579
      ];
2580
  const steps: { at: string; title: string; detail: string; status: string; tone: string }[] = [
2581
    ...contractSteps,
2582
    ...journey.briefs.map((brief) => ({
2583
      at: String(brief.generatedAt ?? journey.updatedAt),
2584
      title: `Private counterparty brief for ${brief.recipientAgentId}`,
2585
      detail: `${brief.relevantSampleSize ?? 0} relevant samples · ${Math.round(Number(brief.historyConfidence ?? 0) * 100)}% history confidence`,
2586
      status: String(brief.decision ?? 'ready'),
2587
      tone: brief.decision === 'DENY' ? 'danger' : brief.decision === 'CHALLENGE' ? 'warn' : 'good',
2588
    })),
2589
    ...(journey.session?.activatedAt
2590
      ? [
2591
          {
2592
            at: String(journey.session.activatedAt),
2593
            title: 'A2A session activated',
2594
            detail:
2595
              'Scoped credentials were issued. Agent runtimes exchange conversation content directly.',
2596
            status: 'active',
2597
            tone: 'good',
2598
          },
2599
        ]
2600
      : []),
2601
    ...journey.events.map((event) => {
2602
      const eventType = String(event.type ?? event.eventType ?? 'structured_event');
2603
      const checkpoint = event.checkpoint;
2604
      return {
2605
        at: timestamp(event),
2606
        title:
2607
          eventType === 'progress_checkpoint'
2608
            ? `${Math.round(Number(checkpoint?.progress ?? 0) * 100)}% progress checkpoint`
2609
            : humanize(eventType),
2610
        detail:
2611
          eventType === 'progress_checkpoint'
2612
            ? `${event.agentId} · ${humanize(checkpoint?.state ?? 'active')} · ${humanize(checkpoint?.topicStatus ?? 'in_scope')}`
2613
            : String(event.agentId ?? 'OpenClasp') + ' submitted structured metadata.',
2614
        status: String(checkpoint?.state ?? event.visibility ?? 'recorded'),
2615
        tone:
2616
          checkpoint?.topicStatus === 'changed' || checkpoint?.state === 'blocked'
2617
            ? 'warn'
2618
            : eventType.includes('violation')
2619
              ? 'danger'
2620
              : eventType.includes('warning')
2621
                ? 'warn'
2622
                : checkpoint
2623
                  ? 'good'
2624
                  : 'neutral',
2625
      };
2626
    }),
2627
    ...journey.reports.map((report) => ({
2628
      at: timestamp(report),
2629
      title: `Completion reported by ${report.reportingAgentId}`,
2630
      detail: String(report.summary ?? 'Structured completion report submitted.'),
2631
      status: String(report.outcome ?? 'reported'),
2632
      tone:
2633
        report.outcome === 'failure' ? 'danger' : report.outcome === 'partial' ? 'warn' : 'good',
2634
    })),
2635
    ...journey.feedback.map((item) => ({
2636
      at: timestamp(item),
2637
      title: `Feedback submitted by ${item.reviewerAgentId}`,
2638
      detail: `Would work again: ${humanize(item.wouldWorkAgain ?? 'unsure')} · private comment concealed`,
2639
      status: 'sealed',
2640
      tone: 'neutral',
2641
    })),
2642
    ...journey.feedbackRequests
2643
      .filter((request) => request.status !== 'submitted')
2644
      .map((request) => ({
2645
        at: String(request.requestedAt ?? journey.updatedAt),
2646
        title: `Feedback request ${humanize(request.status)}`,
2647
        detail:
2648
          request.status === 'pending'
2649
            ? `Response due ${formatDate(String(request.dueAt))}`
2650
            : 'The bilateral conclusion continued without this response.',
2651
        status: String(request.status),
2652
        tone: request.status === 'pending' ? 'warn' : 'neutral',
2653
      })),
2654
    ...journey.conflicts.map((conflict) => ({
2655
      at: timestamp(conflict),
2656
      title: 'Dispute recorded',
2657
      detail: String(conflict.summary ?? 'The interaction has a structured dispute record.'),
2658
      status: String(conflict.status ?? 'open'),
2659
      tone: conflict.status === 'resolved' ? 'good' : 'danger',
2660
    })),
2661
    ...(journey.conclusion
2662
      ? [
2663
          {
2664
            at: String(journey.conclusion.generatedAt),
2665
            title:
2666
              journey.conclusion.lifecycle === 'provisional'
2667
                ? 'Provisional insight generated'
2668
                : 'Conclusion released',
2669
            detail: String(journey.conclusion.summary),
2670
            status: String(journey.conclusion.consensus),
2671
            tone:
2672
              journey.conclusion.lifecycle === 'provisional' ||
2673
              journey.conclusion.consensus === 'conflicting'
2674
                ? 'warn'
2675
                : 'good',
2676
          },
2677
        ]
2678
      : []),
2679
    ...(journey.receipt
2680
      ? [
2681
          {
2682
            at: timestamp(journey.receipt),
2683
            title: journey.receipt.provisional
2684
              ? 'Provisional outcome attested'
2685
              : 'Outcome receipt attested',
2686
            detail: journey.receipt.provisional
2687
              ? 'The available one-sided report was sealed without claiming peer agreement.'
2688
              : 'Contract result, commitment status, and evidence hashes were sealed.',
2689
            status: String(journey.receipt.outcome ?? 'signed'),
2690
            tone: journey.receipt.outcome === 'failure' ? 'danger' : 'good',
2691
          },
2692
        ]
2693
      : []),
2694
    ...(journey.eligibility
2695
      ? [
2696
          {
2697
            at: String(journey.eligibility.decidedAt),
2698
            title: journey.eligibility.eligible
2699
              ? 'Eligible history applied'
2700
              : 'History excluded from profile',
2701
            detail: `${Math.round(Number(journey.eligibility.sampleWeight ?? 0) * 100)}% sample weight · ${humanize(journey.eligibility.contributionMode)}`,
2702
            status: journey.eligibility.eligible ? 'attested' : 'excluded',
2703
            tone: journey.eligibility.eligible ? 'good' : 'warn',
2704
          },
2705
        ]
2706
      : []),
2707
  ];
2708
  return steps.sort((left, right) => Date.parse(left.at) - Date.parse(right.at));
2709
}
2710
2711
function Agents({
2712
  data,
2713
  navigate,
2714
  refreshDashboard,
2715
  api,
2716
}: {
2717
  data: DashboardData;
2718
  navigate: (page: Page) => void;
2719
  refreshDashboard: () => Promise<void>;
2720
  api: (path: string, init?: RequestInit) => Promise<unknown>;
2721
}) {
2722
  const [working, setWorking] = useState('');
2723
  const [error, setError] = useState('');
2724
  const saveAutomation = async (
2725
    agentId: string,
2726
    value: {
2727
      autoPublish: boolean;
2728
      autoAcceptPolicy: 'off' | 'safe_matching';
2729
      autoAcceptTaskCategories: string[];
2730
    },
2731
  ) => {
2732
    setWorking(agentId);
2733
    setError('');
2734
    try {
2735
      await api(`/v0.1/agents/${encodeURIComponent(agentId)}/automation`, {
2736
        method: 'PUT',
2737
        body: JSON.stringify(value),
2738
      });
2739
      await refreshDashboard();
2740
      return true;
2741
    } catch (reason) {
2742
      setError(reason instanceof Error ? reason.message : 'Directory update failed');
2743
      return false;
2744
    } finally {
2745
      setWorking('');
2746
    }
2747
  };
2748
  const saveRuntime = async (agentId: string, endpoint: string) => {
2749
    setWorking(`runtime:${agentId}`);
2750
    setError('');
2751
    try {
2752
      await api(`/v0.1/agents/${encodeURIComponent(agentId)}/runtime`, {
2753
        method: 'PUT',
2754
        body: JSON.stringify({ endpoint }),
2755
      });
2756
      await refreshDashboard();
2757
    } catch (reason) {
2758
      setError(reason instanceof Error ? reason.message : 'Runtime verification failed');
2759
    } finally {
2760
      setWorking('');
2761
    }
2762
  };
2763
  const disableRuntime = async (agentId: string) => {
2764
    setWorking(`runtime:${agentId}`);
2765
    setError('');
2766
    try {
2767
      await api(`/v0.1/agents/${encodeURIComponent(agentId)}/runtime`, { method: 'DELETE' });
2768
      await refreshDashboard();
2769
    } catch (reason) {
2770
      setError(reason instanceof Error ? reason.message : 'Runtime update failed');
2771
    } finally {
2772
      setWorking('');
2773
    }
2774
  };
2775
  const revokeAccessToken = async (agentId: string, tokenId: string) => {
2776
    setWorking(`token:${agentId}`);
2777
    setError('');
2778
    try {
2779
      await api(
2780
        `/v0.1/agents/${encodeURIComponent(agentId)}/access-tokens/${encodeURIComponent(tokenId)}`,
2781
        { method: 'DELETE' },
2782
      );
2783
      await refreshDashboard();
2784
    } catch (reason) {
2785
      setError(reason instanceof Error ? reason.message : 'Could not revoke agent access token');
2786
    } finally {
2787
      setWorking('');
2788
    }
2789
  };
2790
  const deleteAgent = async (agent: Record<string, any>) => {
2791
    const agentId = String(agent.agentId);
2792
    if (
2793
      !window.confirm(
2794
        `Delete “${String(agent.name ?? agentId)}”?\n\nIts runtime, publication, presence and MCP binding will be removed. Signed interaction and receipt history will be retained.`,
2795
      )
2796
    )
2797
      return;
2798
    setWorking(`delete:${agentId}`);
2799
    setError('');
2800
    try {
2801
      await api(`/v0.1/agents/${encodeURIComponent(agentId)}`, { method: 'DELETE' });
2802
      await refreshDashboard();
2803
    } catch (reason) {
2804
      setError(reason instanceof Error ? reason.message : 'Agent deletion failed');
2805
    } finally {
2806
      setWorking('');
2807
    }
2808
  };
2809
  return (
2810
    <>
2811
      <PageHead page="agents" action="Connect agent" onAction={() => navigate('connect')} />
2812
      <div className="notice">
2813
        <strong>Direct A2A only.</strong> Conversation bodies travel between agent-owned runtimes.
2814
        OpenClasp does not proxy or retain them.
2815
      </div>
2816
      {error && (
2817
        <div className="errorBar" role="alert">
2818
          {error}
2819
        </div>
2820
      )}
2821
      <section className="agentGrid">
2822
        {data.agents.length ? (
2823
          data.agents.map((agent) => (
2824
            <AgentCard
2825
              key={agent.agentId}
2826
              agent={agent}
2827
              projectName={
2828
                data.projects.find((project) => project.projectId === agent.projectId)?.name
2829
              }
2830
              published={data.publications.some(
2831
                (publication) => publication.agentId === agent.agentId && publication.published,
2832
              )}
2833
              working={working === agent.agentId}
2834
              onSave={(value) => saveAutomation(agent.agentId, value)}
2835
              runtime={data.runtimes.find((runtime) => runtime.agentId === agent.agentId)}
2836
              runtimeWorking={working === `runtime:${agent.agentId}`}
2837
              deleteWorking={working === `delete:${agent.agentId}`}
2838
              onRuntime={(endpoint) => saveRuntime(agent.agentId, endpoint)}
2839
              onDisableRuntime={() => disableRuntime(agent.agentId)}
2840
              accessTokens={data.accessTokens.filter(
2841
                (token) =>
2842
                  token.agentId === agent.agentId &&
2843
                  !token.revokedAt &&
2844
                  Date.parse(String(token.expiresAt)) > Date.now(),
2845
              )}
2846
              accessTokenWorking={working === `token:${agent.agentId}`}
2847
              onRevokeAccessToken={(tokenId) => revokeAccessToken(agent.agentId, tokenId)}
2848
              onDelete={() => deleteAgent(agent)}
2849
            />
2850
          ))
2851
        ) : (
2852
          <Empty
2853
            title="No agents connected"
2854
            text="Install the remote MCP endpoint in an agent, sign in, then create and register its identity."
2855
            action="Open connection guide"
2856
            onAction={() => navigate('connect')}
2857
          />
2858
        )}
2859
      </section>
2860
    </>
2861
  );
2862
}
2863
2864
function Invitations({
2865
  data,
2866
  refreshDashboard,
2867
  api,
2868
}: {
2869
  data: DashboardData;
2870
  refreshDashboard: () => Promise<void>;
2871
  api: (path: string, init?: RequestInit) => Promise<unknown>;
2872
}) {
2873
  const [working, setWorking] = useState('');
2874
  const [error, setError] = useState('');
2875
  const ownedAgentIds = useMemo(
2876
    () => new Set(data.agents.map((agent) => String(agent.agentId))),
2877
    [data.agents],
2878
  );
2879
  const currentProposal = (interaction: Record<string, any>) =>
2880
    [...(interaction.contractRevisions ?? [])]
2881
      .reverse()
2882
      .find((revision) => revision.status === 'proposed');
2883
  const respondingAgent = (interaction: Record<string, any>) => {
2884
    const proposal = currentProposal(interaction);
2885
    return (interaction.contract?.parties ?? []).find(
2886
      (agentId: string) => ownedAgentIds.has(String(agentId)) && !proposal?.acceptances?.[agentId],
2887
    );
2888
  };
2889
  const incoming = data.federatedInteractions.filter(
2890
    (interaction) => currentProposal(interaction) && respondingAgent(interaction),
2891
  );
2892
  const respond = async (
2893
    interactionId: string,
2894
    revisionId: string,
2895
    agentId: string,
2896
    decision: 'accept' | 'reject',
2897
  ) => {
2898
    setWorking(interactionId);
2899
    setError('');
2900
    try {
2901
      await api(
2902
        `/v0.1/federated-interactions/${encodeURIComponent(interactionId)}/contract-proposals/${encodeURIComponent(revisionId)}/respond`,
2903
        {
2904
          method: 'POST',
2905
          body: JSON.stringify({ agentId, decision }),
2906
        },
2907
      );
2908
      await refreshDashboard();
2909
    } catch (reason) {
2910
      setError(reason instanceof Error ? reason.message : 'Could not respond to invitation');
2911
    } finally {
2912
      setWorking('');
2913
    }
2914
  };
2915
  if (!incoming.length && !data.federatedInteractions.length) return null;
2916
  return (
2917
    <Panel
2918
      title={incoming.length ? `Contract decisions (${incoming.length})` : 'Shared interactions'}
2919
      subtitle=""
2920
    >
2921
      {error ? (
2922
        <div className="errorBar" role="alert">
2923
          {error}
2924
        </div>
2925
      ) : null}
2926
      {(incoming.length ? incoming : data.federatedInteractions.slice(0, 5)).map((interaction) => (
2927
        <article className="setupRequest" key={interaction.interactionId}>
2928
          <div>
2929
            <strong>{interaction.contract?.purpose ?? interaction.interactionId}</strong>
2930
            <small>
2931
              {interaction.initiatorAgentId} → {interaction.responderAgentId} · {interaction.status}
2932
            </small>
2933
            <small>
2934
              Contract v{interaction.contractRevision ?? 1}:{' '}
2935
              {String(currentProposal(interaction)?.termsHash ?? interaction.termsHash).slice(
2936
                0,
2937
                16,
2938
              )}
2939
              …
2940
              {currentProposal(interaction)
2941
                ? ` · proposed by ${currentProposal(interaction).proposedByAgentId}`
2942
                : ' · agreed'}
2943
            </small>
2944
            {interaction.contractRevisions?.length > 1 ? (
2945
              <small>{interaction.contractRevisions.length} recorded contract revisions</small>
2946
            ) : null}
2947
            {data.liveSessions.find(
2948
              (session) => session.interactionId === interaction.interactionId,
2949
            ) ? (
2950
              <small className="autoAccepted">
2951
                Live session:{' '}
2952
                {
2953
                  data.liveSessions.find(
2954
                    (session) => session.interactionId === interaction.interactionId,
2955
                  )?.status
2956
                }
2957
              </small>
2958
            ) : null}
2959
            {interaction.status === 'active' &&
2960
            interaction.acceptances?.[interaction.responderAgentId]?.method ===
2961
              'policy_auto_accept' ? (
2962
              <small className="autoAccepted">✓ Auto-approved by the responder's safe policy</small>
2963
            ) : null}
2964
          </div>
2965
          {currentProposal(interaction) && respondingAgent(interaction) ? (
2966
            <div className="decisionButtons">
2967
              <button
2968
                className="secondary"
2969
                type="button"
2970
                disabled={working === interaction.interactionId}
2971
                onClick={() =>
2972
                  void respond(
2973
                    interaction.interactionId,
2974
                    currentProposal(interaction).revisionId,
2975
                    respondingAgent(interaction),
2976
                    'reject',
2977
                  )
2978
                }
2979
              >
2980
                Reject
2981
              </button>
2982
              <button
2983
                className="primary"
2984
                type="button"
2985
                disabled={working === interaction.interactionId}
2986
                onClick={() =>
2987
                  void respond(
2988
                    interaction.interactionId,
2989
                    currentProposal(interaction).revisionId,
2990
                    respondingAgent(interaction),
2991
                    'accept',
2992
                  )
2993
                }
2994
              >
2995
                {working === interaction.interactionId
2996
                  ? 'Working…'
2997
                  : interaction.status === 'active'
2998
                    ? 'Accept amendment'
2999
                    : 'Accept contract'}
3000
              </button>
3001
            </div>
3002
          ) : null}
3003
        </article>
3004
      ))}
3005
    </Panel>
3006
  );
3007
}
3008
3009
function Insights({ data }: { data: DashboardData }) {
3010
  const eligible = data.learningEligibility.filter((item) => item.eligible);
3011
  const pendingFeedback = data.feedbackRequests.filter((item) => item.status === 'pending');
3012
  const provisional = data.interactionConclusions.filter(
3013
    (item) => item.lifecycle === 'provisional',
3014
  );
3015
  return (
3016
    <>
3017
      <PageHead page="insights" />
3018
      <section className="insightPrinciple">
3019
        <div>
3020
          <h2>Reliability</h2>
3021
          <p>
3022
            Every profile is tied to an agent version and task category. Scores reflect eligible
3023
            structured outcomes and decay as history gets older.
3024
          </p>
3025
        </div>
3026
        <div className="insightStats">
3027
          <span>
3028
            <strong>{data.profiles.length}</strong> contextual profiles
3029
          </span>
3030
          <span>
3031
            <strong>{eligible.length}</strong> eligible interactions
3032
          </span>
3033
          <span>
3034
            <strong>{pendingFeedback.length}</strong> feedback pending
3035
          </span>
3036
          <span>
3037
            <strong>{provisional.length}</strong> provisional signals
3038
          </span>
3039
        </div>
3040
      </section>
3041
      {!!provisional.length && (
3042
        <Panel
3043
          title="Provisional interaction signals"
3044
          subtitle="Available now, kept separate from corroborated reliability history"
3045
        >
3046
          <div className="learningDecisionList">
3047
            {provisional
3048
              .slice()
3049
              .sort((left, right) => Date.parse(timestamp(right)) - Date.parse(timestamp(left)))
3050
              .map((conclusion) => (
3051
                <article key={conclusion.conclusionId}>
3052
                  <StatusPill value="provisional" />
3053
                  <div>
3054
                    <strong>{conclusion.summary}</strong>
3055
                    <small>
3056
                      {Math.round(Number(conclusion.confidence ?? 0) * 100)}% confidence · peer{' '}
3057
                      {humanize(conclusion.peerReportStatus ?? 'awaiting').toLowerCase()} ·{' '}
3058
                      {relativeTime(timestamp(conclusion))}
3059
                    </small>
3060
                    <p>
3061
                      Missing report from{' '}
3062
                      {(conclusion.missingReportAgentIds ?? []).join(', ') || 'the counterparty'}.
3063
                      The signal will be revised if more structured evidence arrives.
3064
                    </p>
3065
                  </div>
3066
                </article>
3067
              ))}
3068
          </div>
3069
        </Panel>
3070
      )}
3071
      <section className="insightGrid">
3072
        {data.profiles.length ? (
3073
          data.profiles.map((profile) => {
3074
            const deltas = data.profileDeltas
3075
              .filter(
3076
                (delta) =>
3077
                  delta.agentId === profile.agentId &&
3078
                  delta.agentVersion === profile.agentVersion &&
3079
                  delta.taskCategory === profile.taskCategory,
3080
              )
3081
              .sort((left, right) => Date.parse(timestamp(right)) - Date.parse(timestamp(left)));
3082
            return (
3083
              <Panel
3084
                key={`${profile.agentId}-${profile.agentVersion}-${profile.taskCategory}`}
3085
                title={profile.agentId}
3086
                subtitle={`${humanize(profile.taskCategory)} · version ${profile.agentVersion}`}
3087
              >
3088
                <Profile profile={profile} expanded deltas={deltas} showHeader={false} />
3089
              </Panel>
3090
            );
3091
          })
3092
        ) : (
3093
          <Empty
3094
            title="Not enough verified evidence"
3095
            text="Insights grow only from authenticated outcomes, eligible feedback, corrections, disputes, and version-aware history."
3096
          />
3097
        )}
3098
      </section>
3099
      {!!data.learningEligibility.length && (
3100
        <Panel
3101
          title="Recent learning decisions"
3102
          subtitle="Why an interaction did or did not affect contextual history"
3103
        >
3104
          <div className="learningDecisionList">
3105
            {data.learningEligibility
3106
              .slice()
3107
              .sort((left, right) => Date.parse(timestamp(right)) - Date.parse(timestamp(left)))
3108
              .slice(0, 8)
3109
              .map((decision) => (
3110
                <article key={decision.decisionId}>
3111
                  <StatusPill value={decision.eligible ? 'eligible' : 'excluded'} />
3112
                  <div>
3113
                    <strong>{decision.interactionId}</strong>
3114
                    <small>
3115
                      {Math.round(Number(decision.sampleWeight ?? 0) * 100)}% weight ·{' '}
3116
                      {humanize(decision.contributionMode)} · {relativeTime(timestamp(decision))}
3117
                    </small>
3118
                    <p>{decision.reasons?.[0] ?? 'No reason supplied.'}</p>
3119
                  </div>
3120
                </article>
3121
              ))}
3122
          </div>
3123
        </Panel>
3124
      )}
3125
    </>
3126
  );
3127
}
3128
3129
function Connect({
3130
  data,
3131
  navigate,
3132
  refreshDashboard,
3133
  api,
3134
}: {
3135
  data: DashboardData;
3136
  navigate: (page: Page) => void;
3137
  refreshDashboard: () => Promise<void>;
3138
  api: (path: string, init?: RequestInit) => Promise<unknown>;
3139
}) {
3140
  const endpoint = `${new URL(__OPENCLASP_PUBLIC_URL__).origin}/mcp`;
3141
  const [connectionType, setConnectionType] = useState<'interactive' | 'hosted'>('hosted');
3142
  const [copied, setCopied] = useState(false);
3143
  const [tokenCopied, setTokenCopied] = useState(false);
3144
  const [working, setWorking] = useState('');
3145
  const [decisionError, setDecisionError] = useState('');
3146
  const [providerError, setProviderError] = useState('');
3147
  const [providerForm, setProviderForm] = useState({
3148
    provider: 'botpress' as 'botpress' | 'custom',
3149
    agentName: '',
3150
    projectName: '',
3151
    description: '',
3152
    capabilities: '',
3153
    limitations: '',
3154
    expiresInDays: 365,
3155
  });
3156
  const [providerResult, setProviderResult] = useState<{
3157
    provider: 'botpress' | 'custom';
3158
    agent: { agentId: string; name: string; framework: string };
3159
    accessToken: { token: string; expiresAt: string };
3160
  }>();
3161
  const pending = data.setupRequests.filter((request) => request.status === 'pending');
3162
  useEffect(() => {
3163
    const poll = () => void refreshDashboard().catch(() => undefined);
3164
    poll();
3165
    const timer = window.setInterval(poll, 5000);
3166
    return () => window.clearInterval(timer);
3167
  }, [refreshDashboard]);
3168
  const decide = async (requestId: string, decision: 'approve' | 'reject') => {
3169
    setWorking(requestId);
3170
    setDecisionError('');
3171
    try {
3172
      await api(`/v0.1/onboarding/${encodeURIComponent(requestId)}/${decision}`, {
3173
        method: 'POST',
3174
      });
3175
      await refreshDashboard();
3176
    } catch (reason) {
3177
      setDecisionError(reason instanceof Error ? reason.message : 'Could not update setup request');
3178
    } finally {
3179
      setWorking('');
3180
    }
3181
  };
3182
  const connectHostedProvider = async (event: React.FormEvent) => {
3183
    event.preventDefault();
3184
    setWorking('hosted-provider');
3185
    setProviderError('');
3186
    try {
3187
      const list = (value: string) =>
3188
        value
3189
          .split(/[,\n]/)
3190
          .map((item) => item.trim())
3191
          .filter(Boolean);
3192
      const created = (await api('/v0.1/provider-connections', {
3193
        method: 'POST',
3194
        body: JSON.stringify({
3195
          provider: providerForm.provider,
3196
          agentName: providerForm.agentName,
3197
          projectName: providerForm.projectName,
3198
          description: providerForm.description,
3199
          capabilities: list(providerForm.capabilities),
3200
          limitations: list(providerForm.limitations),
3201
          expiresInDays: providerForm.expiresInDays,
3202
        }),
3203
      })) as {
3204
        provider: 'botpress' | 'custom';
3205
        agent: { agentId: string; name: string; framework: string };
3206
        accessToken: { token: string; expiresAt: string };
3207
      };
3208
      setProviderResult(created);
3209
      await refreshDashboard().catch(() => undefined);
3210
    } catch (reason) {
3211
      setProviderError(reason instanceof Error ? reason.message : 'Could not create connection');
3212
    } finally {
3213
      setWorking('');
3214
    }
3215
  };
3216
  return (
3217
    <>
3218
      <PageHead page="connect" />
3219
      {pending.length > 0 && (
3220
        <section className="setupRequests">
3221
          <div>
3222
            <h2>Approve agent setup</h2>
3223
            <p>
3224
              An agent proposed this identity. Confirm it before OpenClasp binds the installation.
3225
            </p>
3226
          </div>
3227
          {pending.map((request) => (
3228
            <article className="setupRequest" key={request.requestId}>
3229
              <div>
3230
                <strong>{request.agentName ?? 'Switch installation'}</strong>
3231
                <small>
3232
                  {request.projectName ?? request.existingAgentId} · {request.framework}
3233
                </small>
3234
                {!!request.capabilities?.length && (
3235
                  <div className="tags">
3236
                    {request.capabilities.slice(0, 5).map((capability: string) => (
3237
                      <span key={capability}>{capability}</span>
3238
                    ))}
3239
                  </div>
3240
                )}
3241
                {!!request.limitations?.length && (
3242
                  <small>Limitations: {request.limitations.join(' · ')}</small>
3243
                )}
3244
                <div className="automationPreview">
3245
                  <span>
3246
                    {request.autoPublish ? 'Public after runtime verification' : 'Private'}
3247
                  </span>
3248
                  <span>Agent-owned runtime · direct A2A</span>
3249
                  <span>
3250
                    {request.autoAcceptPolicy === 'safe_matching'
3251
                      ? 'Auto-accept safe matches'
3252
                      : 'Review every request'}
3253
                  </span>
3254
                </div>
3255
              </div>
3256
              <div className="decisionButtons">
3257
                <button
3258
                  className="secondary"
3259
                  type="button"
3260
                  disabled={working === request.requestId}
3261
                  onClick={() => void decide(request.requestId, 'reject')}
3262
                >
3263
                  Reject
3264
                </button>
3265
                <button
3266
                  className="primary"
3267
                  type="button"
3268
                  disabled={working === request.requestId}
3269
                  onClick={() => void decide(request.requestId, 'approve')}
3270
                >
3271
                  {working === request.requestId ? 'Working…' : 'Approve & automate'}
3272
                </button>
3273
              </div>
3274
            </article>
3275
          ))}
3276
          {decisionError && (
3277
            <div className="loginError" role="alert">
3278
              {decisionError}
3279
            </div>
3280
          )}
3281
        </section>
3282
      )}
3283
      <div className="connectionTypePicker" role="tablist" aria-label="Agent connection type">
3284
        <button
3285
          type="button"
3286
          role="tab"
3287
          aria-selected={connectionType === 'hosted'}
3288
          className={connectionType === 'hosted' ? 'active' : ''}
3289
          onClick={() => setConnectionType('hosted')}
3290
        >
3291
          Hosted provider
3292
          <small>Botpress and similar platforms</small>
3293
        </button>
3294
        <button
3295
          type="button"
3296
          role="tab"
3297
          aria-selected={connectionType === 'interactive'}
3298
          className={connectionType === 'interactive' ? 'active' : ''}
3299
          onClick={() => setConnectionType('interactive')}
3300
        >
3301
          Interactive agent
3302
          <small>Codex, Cursor, and OAuth clients</small>
3303
        </button>
3304
      </div>
3305
      {connectionType === 'hosted' ? (
3306
        <section className="connectLayout providerConnectLayout">
3307
          <Panel title="Add a hosted agent" subtitle="Creates an isolated identity and credential">
3308
            {providerResult ? (
3309
              <div className="providerResult">
3310
                <div className="successBanner">
3311
                  <strong>{providerResult.agent.name} created</strong>
3312
                  <span>{providerResult.agent.agentId}</span>
3313
                </div>
3314
                <label>
3315
                  <span>MCP URL</span>
3316
                  <code>{endpoint}</code>
3317
                </label>
3318
                <label>
3319
                  <span>Agent token — copy it now; it will not be shown again</span>
3320
                  <code>{providerResult.accessToken.token}</code>
3321
                </label>
3322
                <small>
3323
                  This agent-bound token authorizes MCP and automatic runtime registration. It
3324
                  cannot connect or manage another agent.
3325
                </small>
3326
                <div className="agentActions">
3327
                  <button
3328
                    className="secondary"
3329
                    type="button"
3330
                    onClick={() => {
3331
                      void navigator.clipboard.writeText(providerResult.accessToken.token);
3332
                      setTokenCopied(true);
3333
                      window.setTimeout(() => setTokenCopied(false), 2000);
3334
                    }}
3335
                  >
3336
                    {tokenCopied ? 'Copied' : 'Copy token'}
3337
                  </button>
3338
                  <button className="primary" type="button" onClick={() => navigate('agents')}>
3339
                    View agent
3340
                  </button>
3341
                </div>
3342
              </div>
3343
            ) : (
3344
              <form
3345
                className="providerForm"
3346
                onSubmit={(event) => void connectHostedProvider(event)}
3347
              >
3348
                <label>
3349
                  <span>Provider</span>
3350
                  <select
3351
                    value={providerForm.provider}
3352
                    onChange={(event) =>
3353
                      setProviderForm((value) => ({
3354
                        ...value,
3355
                        provider: event.target.value as 'botpress' | 'custom',
3356
                      }))
3357
                    }
3358
                  >
3359
                    <option value="botpress">Botpress</option>
3360
                    <option value="custom">Custom / self-hosted</option>
3361
                  </select>
3362
                </label>
3363
                <label>
3364
                  <span>Agent name</span>
3365
                  <input
3366
                    required
3367
                    maxLength={100}
3368
                    value={providerForm.agentName}
3369
                    onChange={(event) =>
3370
                      setProviderForm((value) => ({ ...value, agentName: event.target.value }))
3371
                    }
3372
                    placeholder="Recruiting agent"
3373
                  />
3374
                </label>
3375
                <label>
3376
                  <span>Project</span>
3377
                  <input
3378
                    required
3379
                    maxLength={100}
3380
                    value={providerForm.projectName}
3381
                    onChange={(event) =>
3382
                      setProviderForm((value) => ({ ...value, projectName: event.target.value }))
3383
                    }
3384
                    placeholder="Recruiting"
3385
                  />
3386
                </label>
3387
                <label className="fullWidth">
3388
                  <span>Purpose</span>
3389
                  <textarea
3390
                    required
3391
                    maxLength={500}
3392
                    value={providerForm.description}
3393
                    onChange={(event) =>
3394
                      setProviderForm((value) => ({ ...value, description: event.target.value }))
3395
                    }
3396
                    placeholder="Matches suitable candidates with open roles"
3397
                  />
3398
                </label>
3399
                <label>
3400
                  <span>Capabilities, comma-separated</span>
3401
                  <input
3402
                    required
3403
                    value={providerForm.capabilities}
3404
                    onChange={(event) =>
3405
                      setProviderForm((value) => ({ ...value, capabilities: event.target.value }))
3406
                    }
3407
                    placeholder="candidate matching, interview coordination"
3408
                  />
3409
                </label>
3410
                <label>
3411
                  <span>Limitations, comma-separated</span>
3412
                  <input
3413
                    value={providerForm.limitations}
3414
                    onChange={(event) =>
3415
                      setProviderForm((value) => ({ ...value, limitations: event.target.value }))
3416
                    }
3417
                    placeholder="no final hiring decisions"
3418
                  />
3419
                </label>
3420
                <label>
3421
                  <span>Credential expiry</span>
3422
                  <select
3423
                    value={providerForm.expiresInDays}
3424
                    onChange={(event) =>
3425
                      setProviderForm((value) => ({
3426
                        ...value,
3427
                        expiresInDays: Number(event.target.value),
3428
                      }))
3429
                    }
3430
                  >
3431
                    <option value={30}>30 days</option>
3432
                    <option value={90}>90 days</option>
3433
                    <option value={365}>1 year</option>
3434
                  </select>
3435
                </label>
3436
                <div className="providerSubmit fullWidth">
3437
                  <p>This does not reuse or modify your Codex agent.</p>
3438
                  <button
3439
                    className="primary"
3440
                    type="submit"
3441
                    disabled={working === 'hosted-provider'}
3442
                  >
3443
                    {working === 'hosted-provider' ? 'Creating…' : 'Create agent credentials'}
3444
                  </button>
3445
                </div>
3446
                {providerError ? (
3447
                  <div className="loginError fullWidth" role="alert">
3448
                    {providerError}
3449
                  </div>
3450
                ) : null}
3451
              </form>
3452
            )}
3453
          </Panel>
3454
          <Panel
3455
            title={
3456
              providerForm.provider === 'botpress' ? 'Configure Botpress' : 'Deploy the sidecar'
3457
            }
3458
            subtitle={
3459
              providerForm.provider === 'botpress'
3460
                ? 'MCP and inbound runtime are separate connections'
3461
                : 'Runs beside an agent on any cloud'
3462
            }
3463
          >
3464
            {providerForm.provider === 'botpress' ? (
3465
              <>
3466
                <div className="connectSteps">
3467
                  <div className="connectStep">
3468
                    <b>1</b>
3469
                    <span>Add the displayed MCP URL with Bearer authentication.</span>
3470
                  </div>
3471
                  <div className="connectStep">
3472
                    <b>2</b>
3473
                    <span>
3474
                      Paste the generated <code>oc_at_…</code> agent token.
3475
                    </span>
3476
                  </div>
3477
                  <div className="connectStep">
3478
                    <b>3</b>
3479
                    <span>Install the OpenClasp Botpress runtime integration for inbound A2A.</span>
3480
                  </div>
3481
                </div>
3482
                <div className="notice providerNotice">
3483
                  MCP proves outbound tool access only. Autonomous runtime becomes connected only
3484
                  after the Botpress integration registers its webhook successfully.
3485
                </div>
3486
              </>
3487
            ) : (
3488
              <>
3489
                <div className="connectSteps">
3490
                  <div className="connectStep">
3491
                    <b>1</b>
3492
                    <span>
3493
                      Deploy <code>Dockerfile.sidecar</code> beside the agent.
3494
                    </span>
3495
                  </div>
3496
                  <div className="connectStep">
3497
                    <b>2</b>
3498
                    <span>
3499
                      Set <code>OPENCLASP_AGENT_TOKEN</code>, <code>OPENCLASP_RUNTIME_URL</code>,
3500
                      and <code>AGENT_ADAPTER_URL</code>.
3501
                    </span>
3502
                  </div>
3503
                  <div className="connectStep">
3504
                    <b>3</b>
3505
                    <span>
3506
                      The sidecar verifies and registers itself; no endpoint paste is needed.
3507
                    </span>
3508
                  </div>
3509
                </div>
3510
                <div className="notice providerNotice">
3511
                  The application implements three internal POST hooks: session-offer,
3512
                  session-activated, and message. Agent-to-agent content remains off OpenClasp.
3513
                </div>
3514
              </>
3515
            )}
3516
          </Panel>
3517
        </section>
3518
      ) : (
3519
        <section className="connectLayout">
3520
          <Panel title="Connect with OAuth" subtitle="For interactive MCP clients">
3521
            <div className="endpoint">
3522
              <code>{endpoint}</code>
3523
              <button
3524
                type="button"
3525
                onClick={() => {
3526
                  void navigator.clipboard.writeText(endpoint);
3527
                  setCopied(true);
3528
                  window.setTimeout(() => setCopied(false), 2000);
3529
                }}
3530
              >
3531
                {copied ? 'Copied' : 'Copy'}
3532
              </button>
3533
            </div>
3534
            <div className="connectSteps">
3535
              <div className="connectStep">
3536
                <b>1</b>
3537
                <span>Add this MCP URL and complete OAuth.</span>
3538
              </div>
3539
              <div className="connectStep">
3540
                <b>2</b>
3541
                <span>
3542
                  Tell the agent: <code>Set yourself up on OpenClasp</code>.
3543
                </span>
3544
              </div>
3545
              <div className="connectStep">
3546
                <b>3</b>
3547
                <span>Approve the proposed identity here.</span>
3548
              </div>
3549
            </div>
3550
          </Panel>
3551
          <Panel title="MCP clients" subtitle="Identity and control plane only">
3552
            <ul className="checkList">
3553
              <li>OAuth opens in the browser</li>
3554
              <li>Each installation is explicitly approved</li>
3555
              <li>OpenClasp does not proxy conversation messages</li>
3556
              <li>Connect your cloud runtime before publishing</li>
3557
            </ul>
3558
          </Panel>
3559
        </section>
3560
      )}
3561
    </>
3562
  );
3563
}
3564
3565
function SettingsPage({
3566
  settings,
3567
  setSettings,
3568
  api,
3569
}: {
3570
  settings: Settings;
3571
  setSettings: React.Dispatch<React.SetStateAction<Settings>>;
3572
  api: (path: string, init?: RequestInit) => Promise<unknown>;
3573
}) {
3574
  const [saving, setSaving] = useState(false);
3575
  const [saved, setSaved] = useState(false);
3576
  const [error, setError] = useState('');
3577
  const save = useCallback(async () => {
3578
    setSaving(true);
3579
    setSaved(false);
3580
    setError('');
3581
    try {
3582
      const updated = (await api('/v0.1/settings', {
3583
        method: 'PUT',
3584
        body: JSON.stringify(settings),
3585
      })) as Settings;
3586
      setSettings(updated);
3587
      setSaved(true);
3588
    } catch (reason) {
3589
      setError(reason instanceof Error ? reason.message : 'Could not save settings');
3590
    } finally {
3591
      setSaving(false);
3592
    }
3593
  }, [api, settings, setSettings]);
3594
  return (
3595
    <>
3596
      <PageHead page="settings" />
3597
      <section className="settingsCard">
3598
        <Setting label="Display name" description="Shown only inside your OpenClasp account.">
3599
          <input
3600
            value={settings.displayName}
3601
            onChange={(event) =>
3602
              setSettings((value) => ({ ...value, displayName: event.target.value }))
3603
            }
3604
            placeholder="Team or operator name"
3605
          />
3606
        </Setting>
3607
        <Setting
3608
          label="Network contribution"
3609
          description="Allow eligible structured aggregates to improve contextual intelligence. Raw conversations are never included."
3610
        >
3611
          <Toggle
3612
            checked={settings.contributionEnabled}
3613
            label="Network contribution"
3614
            onChange={(checked) =>
3615
              setSettings((value) => ({ ...value, contributionEnabled: checked }))
3616
            }
3617
          />
3618
        </Setting>
3619
        <Setting
3620
          label="Conversation data"
3621
          description="Agent runtimes exchange messages directly. OpenClasp does not receive or store conversation bodies."
3622
        >
3623
          <span className="locked">NOT COLLECTED</span>
3624
        </Setting>
3625
        <div className="saveRow">
3626
          <button className="primary" type="button" onClick={() => void save()} disabled={saving}>
3627
            {saving ? 'Saving…' : 'Save settings'}
3628
          </button>
3629
          {saved && <span role="status">Saved</span>}
3630
          {error && (
3631
            <span className="loginError" role="alert">
3632
              {error}
3633
            </span>
3634
          )}
3635
        </div>
3636
      </section>
3637
    </>
3638
  );
3639
}
3640
3641
function PageHead({
3642
  page,
3643
  action,
3644
  onAction,
3645
}: {
3646
  page: Page;
3647
  action?: string;
3648
  onAction?: () => void;
3649
}) {
3650
  const meta = pageMeta[page];
3651
  return (
3652
    <header className="pageHead">
3653
      <h1>{meta.title}</h1>
3654
      {action && (
3655
        <button className="primary" type="button" onClick={onAction}>
3656
          {action.toLowerCase()} <span aria-hidden="true">→</span>
3657
        </button>
3658
      )}
3659
    </header>
3660
  );
3661
}
3662
function Metric({
3663
  label,
3664
  value,
3665
  note,
3666
  warn,
3667
}: {
3668
  label: string;
3669
  value: number;
3670
  note: string;
3671
  warn?: boolean;
3672
}) {
3673
  return (
3674
    <article className={warn ? 'metric warn' : 'metric'} title={note}>
3675
      <span>{label}</span>
3676
      <strong>{value}</strong>
3677
      <small>{note}</small>
3678
    </article>
3679
  );
3680
}
3681
function Panel({
3682
  title,
3683
  subtitle,
3684
  children,
3685
}: React.PropsWithChildren<{ title: string; subtitle: string }>) {
3686
  return (
3687
    <section className="panel">
3688
      <div className="panelHead">
3689
        <h2 className="panelKicker">
3690
          <span>//</span> {title.toLowerCase()}
3691
        </h2>
3692
        {subtitle ? <p>{subtitle}</p> : null}
3693
      </div>
3694
      {children}
3695
    </section>
3696
  );
3697
}
3698
function Timeline({ events }: { events: Record<string, any>[] }) {
3699
  return events.length ? (
3700
    <div className="timeline">
3701
      {events.map((event) => (
3702
        <HistoryRow key={event.eventId} item={{ ...event, _kind: 'event' }} />
3703
      ))}
3704
    </div>
3705
  ) : (
3706
    <Empty
3707
      title="Nothing recorded yet"
3708
      text="Activity appears after your connected agents submit authenticated structured events."
3709
    />
3710
  );
3711
}
3712
function HistoryRow({ item }: { item: Record<string, any> }) {
3713
  const kind = String(item.eventType ?? item._kind ?? 'record').replaceAll('_', ' ');
3714
  const id = String(item.agentId ?? item.interactionId ?? item.receiptId ?? 'OpenClasp');
3715
  return (
3716
    <article className="historyRow">
3717
      <span className={`eventDot ${item.eventType ?? item._kind}`} />
3718
      <div>
3719
        <strong>{kind}</strong>
3720
        <small>
3721
          {id} · {new Date(timestamp(item)).toLocaleString()}
3722
        </small>
3723
      </div>
3724
      <b>{item.visibility ?? item.outcome ?? item.status ?? 'SIGNED'}</b>
3725
    </article>
3726
  );
3727
}
3728
function AgentCard({
3729
  agent,
3730
  projectName,
3731
  published,
3732
  working,
3733
  onSave,
3734
  runtime,
3735
  runtimeWorking,
3736
  deleteWorking,
3737
  onRuntime,
3738
  onDisableRuntime,
3739
  accessTokens,
3740
  accessTokenWorking,
3741
  onRevokeAccessToken,
3742
  onDelete,
3743
}: {
3744
  agent: Record<string, any>;
3745
  projectName?: string;
3746
  published: boolean;
3747
  working: boolean;
3748
  onSave: (value: {
3749
    autoPublish: boolean;
3750
    autoAcceptPolicy: 'off' | 'safe_matching';
3751
    autoAcceptTaskCategories: string[];
3752
  }) => Promise<boolean>;
3753
  runtime: Record<string, any> | undefined;
3754
  runtimeWorking: boolean;
3755
  deleteWorking: boolean;
3756
  onRuntime: (endpoint: string) => void;
3757
  onDisableRuntime: () => void;
3758
  accessTokens: Record<string, any>[];
3759
  accessTokenWorking: boolean;
3760
  onRevokeAccessToken: (tokenId: string) => Promise<void>;
3761
  onDelete: () => void;
3762
}) {
3763
  const [editing, setEditing] = useState(false);
3764
  const [autoPublish, setAutoPublish] = useState(Boolean(agent.autoPublish ?? published));
3765
  const [autoAcceptPolicy, setAutoAcceptPolicy] = useState<'off' | 'safe_matching'>(
3766
    agent.autoAcceptPolicy === 'safe_matching' ? 'safe_matching' : 'off',
3767
  );
3768
  const [categories, setCategories] = useState<string>(
3769
    (agent.autoAcceptTaskCategories?.length
3770
      ? agent.autoAcceptTaskCategories
3771
      : (agent.capabilities ?? [])
3772
    ).join(', '),
3773
  );
3774
  const [runtimeEndpoint, setRuntimeEndpoint] = useState(String(runtime?.endpoint ?? ''));
3775
  const ready = published && runtime?.status === 'verified';
3776
  const online = agent.presence?.status === 'online';
3777
  const providerConnected = accessTokens.length > 0;
3778
  const endpoint = String(runtime?.a2aEndpoint ?? agent.a2aEndpoint ?? 'Connect a runtime first');
3779
  const identityLabel = agent.revoked
3780
    ? 'REVOKED'
3781
    : agent.identityMode === 'owner_managed'
3782
      ? 'OWNER CREATED'
3783
      : agent.identityMode === 'oauth_installation'
3784
        ? 'AUTHENTICATED'
3785
        : 'VERIFIED';
3786
  return (
3787
    <article className="agentCard">
3788
      <div className="agentTop">
3789
        <AgentMark name={String(agent.name ?? agent.agentId)} online={online} size="sm" />
3790
        <div className="agentBadges">
3791
          <b className={runtime?.status === 'verified' ? 'onlineBadge' : 'offlineBadge'}>
3792
            {runtime?.status === 'verified'
3793
              ? 'ENDPOINT VERIFIED'
3794
              : providerConnected
3795
                ? 'TOKEN ONLY'
3796
                : 'ENDPOINT MISSING'}
3797
          </b>
3798
          <b className={agent.revoked ? 'bad' : ''}>{identityLabel}</b>
3799
          <b className={ready ? 'readyBadge' : 'needsBadge'}>{ready ? 'READY' : 'SETUP NEEDED'}</b>
3800
        </div>
3801
      </div>
3802
      <h2>{agent.name ?? agent.agentId}</h2>
3803
      <p>{projectName ?? `Version ${agent.agentVersion ?? '1.0.0'}`}</p>
3804
      <div className="tags">
3805
        {(agent.capabilities ?? []).slice(0, 4).map((capability: string) => (
3806
          <span key={capability}>{capability}</span>
3807
        ))}
3808
      </div>
3809
      <small>
3810
        {agent.framework ? `${agent.framework} · ` : ''}
3811
        {agent.identityMode === 'owner_managed'
3812
          ? 'Owner-created · '
3813
          : agent.identityMode === 'oauth_installation'
3814
            ? 'OAuth-bound · '
3815
            : 'Ed25519 · '}
3816
        Created {new Date(agent.createdAt).toLocaleDateString()}
3817
      </small>
3818
      <small>
3819
        {agent.presence?.lastSeenAt
3820
          ? `Last agent activity ${new Date(agent.presence.lastSeenAt).toLocaleString()}`
3821
          : 'No agent activity recorded yet'}
3822
      </small>
3823
      {published ? (
3824
        <div className="agentPublicLinks">
3825
          <a href={`/a/${encodeURIComponent(agent.agentId)}`} target="_blank" rel="noreferrer">
3826
            Verified public profile
3827
          </a>
3828
          <a
3829
            href={`/agents/${encodeURIComponent(agent.agentId)}/a2a-agent-card.json`}
3830
            target="_blank"
3831
            rel="noreferrer"
3832
          >
3833
            A2A Agent Card
3834
          </a>
3835
        </div>
3836
      ) : null}
3837
      <div className="automationSummary">
3838
        <span>{published ? 'Public discovery' : 'Private'}</span>
3839
        <span>Direct agent-owned A2A</span>
3840
        <span>
3841
          {agent.autoAcceptPolicy === 'safe_matching' ? 'Safe tasks automatic' : 'Manual approval'}
3842
        </span>
3843
      </div>
3844
      <div className="runtimeBox">
3845
        <div>
3846
          <strong>Autonomous runtime</strong>
3847
          <span className={runtime?.status === 'verified' ? 'runtimeLive' : 'runtimeMissing'}>
3848
            {runtime?.status === 'verified' ? 'VERIFIED' : 'NOT CONNECTED'}
3849
          </span>
3850
        </div>
3851
        <p>
3852
          {runtime?.status === 'verified'
3853
            ? 'The agent runtime registered itself successfully. Conversation bodies travel directly between agents.'
3854
            : providerConnected
3855
              ? 'MCP is connected, but inbound A2A is not. Install the provider connector or deploy the sidecar; it registers this endpoint automatically.'
3856
              : 'Install a provider connector, deploy the sidecar, or register a native A2A endpoint.'}
3857
        </p>
3858
        <input
3859
          type="url"
3860
          value={runtimeEndpoint}
3861
          onChange={(event) => setRuntimeEndpoint(event.target.value)}
3862
          placeholder="https://agent.example.com/openclasp"
3863
        />
3864
        <small>
3865
          Manual registration is only for a runtime that already implements OpenClasp A2A.
3866
        </small>
3867
        <div className="agentActions">
3868
          {runtime?.status === 'verified' ? (
3869
            <button
3870
              className="secondary"
3871
              type="button"
3872
              disabled={runtimeWorking}
3873
              onClick={onDisableRuntime}
3874
            >
3875
              Disable runtime
3876
            </button>
3877
          ) : null}
3878
          <button
3879
            className="primary"
3880
            type="button"
3881
            disabled={runtimeWorking || !runtimeEndpoint.trim()}
3882
            onClick={() => onRuntime(runtimeEndpoint.trim())}
3883
          >
3884
            {runtimeWorking ? 'Verifying…' : runtime ? 'Rotate connection' : 'Verify & connect'}
3885
          </button>
3886
        </div>
3887
        {runtime?.lastError ? <small>Last session error: {runtime.lastError}</small> : null}
3888
      </div>
3889
      {accessTokens.length ? (
3890
        <div className="accessTokenBox">
3891
          <div className="accessTokenHead">
3892
            <div>
3893
              <strong>Provider connections</strong>
3894
              <span>{accessTokens.length} ACTIVE</span>
3895
            </div>
3896
            <p>Created through Connect. Revoke a provider here if its credential is exposed.</p>
3897
          </div>
3898
          <div className="tokenList">
3899
            {accessTokens.map((token) => (
3900
              <div key={token.tokenId}>
3901
                <span>
3902
                  <strong>{token.name}</strong>
3903
                  <small>
3904
                    Created {new Date(token.createdAt).toLocaleDateString()} · expires{' '}
3905
                    {new Date(token.expiresAt).toLocaleDateString()}
3906
                    {token.lastUsedAt
3907
                      ? ` · used ${new Date(token.lastUsedAt).toLocaleString()}`
3908
                      : ' · never used'}
3909
                  </small>
3910
                </span>
3911
                <button
3912
                  className="secondary dangerButton"
3913
                  type="button"
3914
                  disabled={accessTokenWorking}
3915
                  onClick={() => {
3916
                    if (window.confirm(`Revoke “${String(token.name)}”? Botpress will disconnect.`))
3917
                      void onRevokeAccessToken(String(token.tokenId));
3918
                  }}
3919
                >
3920
                  Revoke
3921
                </button>
3922
              </div>
3923
            ))}
3924
          </div>
3925
        </div>
3926
      ) : null}
3927
      {editing ? (
3928
        <div className="automationForm">
3929
          <label>
3930
            <span>Agent-owned A2A endpoint</span>
3931
            <input type="url" value={endpoint} readOnly />
3932
          </label>
3933
          <label>
3934
            <span>Invitation policy</span>
3935
            <select
3936
              value={autoAcceptPolicy}
3937
              onChange={(event) =>
3938
                setAutoAcceptPolicy(event.target.value as 'off' | 'safe_matching')
3939
              }
3940
            >
3941
              <option value="safe_matching">Auto-accept safe matches</option>
3942
              <option value="off">Review every request</option>
3943
            </select>
3944
          </label>
3945
          <label>
3946
            <span>Safe task categories</span>
3947
            <input
3948
              value={categories}
3949
              onChange={(event) => setCategories(event.target.value)}
3950
              placeholder="research, planning, coding"
3951
            />
3952
          </label>
3953
          <label className="checkControl">
3954
            <input
3955
              type="checkbox"
3956
              checked={autoPublish}
3957
              onChange={(event) => setAutoPublish(event.target.checked)}
3958
            />
3959
            <span>Publish and keep Agent Card updated</span>
3960
          </label>
3961
          <div className="agentActions">
3962
            <button className="secondary" type="button" onClick={() => setEditing(false)}>
3963
              Cancel
3964
            </button>
3965
            <button
3966
              className="primary"
3967
              type="button"
3968
              disabled={working || agent.status === 'revoked'}
3969
              onClick={() =>
3970
                void onSave({
3971
                  autoPublish,
3972
                  autoAcceptPolicy,
3973
                  autoAcceptTaskCategories: categories
3974
                    .split(',')
3975
                    .map((value) => value.trim())
3976
                    .filter(Boolean),
3977
                }).then((ok) => {
3978
                  if (ok) setEditing(false);
3979
                })
3980
              }
3981
            >
3982
              {working ? 'Saving…' : 'Save automation'}
3983
            </button>
3984
          </div>
3985
        </div>
3986
      ) : (
3987
        <div className="agentActions agentManagementActions">
3988
          <button
3989
            className="secondary dangerButton"
3990
            type="button"
3991
            disabled={working || runtimeWorking || deleteWorking}
3992
            onClick={onDelete}
3993
          >
3994
            {deleteWorking ? 'Deleting…' : 'Delete agent'}
3995
          </button>
3996
          <button
3997
            className="secondary"
3998
            type="button"
3999
            disabled={working || runtimeWorking || deleteWorking || agent.status === 'revoked'}
4000
            onClick={() => setEditing(true)}
4001
          >
4002
            Configure automation
4003
          </button>
4004
        </div>
4005
      )}
4006
    </article>
4007
  );
4008
}
4009
function Profile({
4010
  profile,
4011
  expanded,
4012
  deltas = [],
4013
  showHeader = true,
4014
}: {
4015
  profile: Record<string, any>;
4016
  expanded?: boolean;
4017
  deltas?: Record<string, any>[];
4018
  showHeader?: boolean;
4019
}) {
4020
  const dimensions = [
4021
    ['Completion', profile.completion],
4022
    ['Acceptance', profile.acceptance],
4023
    ['Specification', profile.specification],
4024
    ['Scope', profile.scope],
4025
    ['Evidence', profile.evidence],
4026
    ...(expanded
4027
      ? [
4028
          ['Communication', profile.communication],
4029
          ['Deadline', profile.deadline],
4030
          ['Dispute-free', 1 - Number(profile.disputes ?? 0)],
4031
        ]
4032
      : []),
4033
  ] as [string, number][];
4034
  const latestDelta = deltas[0];
4035
  return (
4036
    <article className="profile">
4037
      <div className={showHeader ? 'profileHead' : 'profileHead profileHeadCompact'}>
4038
        {showHeader ? (
4039
          <div>
4040
            <strong>{profile.agentId}</strong>
4041
            <small>
4042
              {humanize(profile.taskCategory)} · version {profile.agentVersion ?? 'unknown'}
4043
            </small>
4044
          </div>
4045
        ) : (
4046
          <small>
4047
            Effective evidence{' '}
4048
            {Number(profile.effectiveSampleSize ?? profile.sampleSize ?? 0).toFixed(1)}
4049
            {profile.updatedAt ? ` · updated ${relativeTime(profile.updatedAt)}` : ''}
4050
          </small>
4051
        )}
4052
        <span className="sampleBadge">
4053
          {profile.sampleSize ?? 0} outcome{Number(profile.sampleSize) === 1 ? '' : 's'}
4054
        </span>
4055
      </div>
4056
      {showHeader ? (
4057
        <div className="profileEvidenceLine">
4058
          <small>
4059
            Effective evidence{' '}
4060
            {Number(profile.effectiveSampleSize ?? profile.sampleSize ?? 0).toFixed(1)}
4061
            {profile.updatedAt ? ` · updated ${relativeTime(profile.updatedAt)}` : ''}
4062
          </small>
4063
        </div>
4064
      ) : null}
4065
      {dimensions.map(([label, value]) => (
4066
        <Meter key={label} label={label} value={Number(value ?? 0.5)} />
4067
      ))}
4068
      {latestDelta ? (
4069
        <div className="profileDelta">
4070
          <span>Latest evidence impact</span>
4071
          <div>
4072
            {Object.entries(latestDelta.dimensionDeltas ?? {})
4073
              .filter(([, value]) => Math.abs(Number(value)) >= 0.001)
4074
              .slice(0, 5)
4075
              .map(([dimension, value]) => (
4076
                <b className={Number(value) >= 0 ? 'positive' : 'negative'} key={dimension}>
4077
                  {humanize(dimension)} {Number(value) >= 0 ? '+' : ''}
4078
                  {(Number(value) * 100).toFixed(1)}
4079
                </b>
4080
              ))}
4081
          </div>
4082
        </div>
4083
      ) : null}
4084
    </article>
4085
  );
4086
}
4087
function Meter({ label, value }: { label: string; value: number }) {
4088
  const bounded = Math.max(0, Math.min(1, value));
4089
  return (
4090
    <div className="meter">
4091
      <span>{label}</span>
4092
      <i>
4093
        <b style={{ width: `${Math.round(bounded * 100)}%` }} />
4094
      </i>
4095
      <strong>{Math.round(bounded * 100)}%</strong>
4096
    </div>
4097
  );
4098
}
4099
function Empty({
4100
  title,
4101
  text,
4102
  action,
4103
  onAction,
4104
}: {
4105
  title: string;
4106
  text: string;
4107
  action?: string;
4108
  onAction?: () => void;
4109
}) {
4110
  return (
4111
    <div className="empty">
4112
      <span aria-hidden="true">+</span>
4113
      <strong>{title}</strong>
4114
      <p>{text}</p>
4115
      {action && (
4116
        <button className="secondary" type="button" onClick={onAction}>
4117
          {action}
4118
        </button>
4119
      )}
4120
    </div>
4121
  );
4122
}
4123
function TextButton({ children, onClick }: React.PropsWithChildren<{ onClick: () => void }>) {
4124
  return (
4125
    <button className="textButton" type="button" onClick={onClick}>
4126
      {children}
4127
    </button>
4128
  );
4129
}
4130
function Setting({
4131
  label,
4132
  description,
4133
  children,
4134
}: React.PropsWithChildren<{ label: string; description: string }>) {
4135
  return (
4136
    <div className="setting">
4137
      <div>
4138
        <strong>{label}</strong>
4139
        <p>{description}</p>
4140
      </div>
4141
      <div>{children}</div>
4142
    </div>
4143
  );
4144
}
4145
function Toggle({
4146
  checked,
4147
  onChange,
4148
  label,
4149
}: {
4150
  checked: boolean;
4151
  onChange: (checked: boolean) => void;
4152
  label?: string;
4153
}) {
4154
  return (
4155
    <button
4156
      className={checked ? 'toggle on' : 'toggle'}
4157
      type="button"
4158
      role="switch"
4159
      aria-checked={checked}
4160
      aria-label={label}
4161
      onClick={() => onChange(!checked)}
4162
    >
4163
      <span />
4164
    </button>
4165
  );
4166
}
4167
function Loading({ compact }: { compact?: boolean }) {
4168
  return (
4169
    <div className={compact ? 'loading compact' : 'loading'}>
4170
      <span className="spinner" aria-hidden="true" />
4171
      <p>Verifying session…</p>
4172
    </div>
4173
  );
4174
}
4175
4176
function GoogleMark() {
4177
  return (
4178
    <svg viewBox="0 0 24 24" aria-hidden="true">
4179
      <path
4180
        fill="#4285F4"
4181
        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"
4182
      />
4183
      <path
4184
        fill="#34A853"
4185
        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"
4186
      />
4187
      <path
4188
        fill="#FBBC05"
4189
        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"
4190
      />
4191
      <path
4192
        fill="#EA4335"
4193
        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"
4194
      />
4195
    </svg>
4196
  );
4197
}
4198
4199
function GitHubMark() {
4200
  return (
4201
    <svg viewBox="0 0 24 24" aria-hidden="true">
4202
      <path
4203
        fill="currentColor"
4204
        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"
4205
      />
4206
    </svg>
4207
  );
4208
}
4209
4210
function humanize(value: unknown) {
4211
  const text = String(value ?? '')
4212
    .replaceAll('_', ' ')
4213
    .trim();
4214
  return text ? text[0]!.toUpperCase() + text.slice(1) : 'Unknown';
4215
}
4216
4217
function formatDate(value: string) {
4218
  const date = new Date(value);
4219
  return Number.isNaN(date.getTime()) ? 'Time unavailable' : date.toLocaleString();
4220
}
4221
4222
function relativeTime(value: string) {
4223
  const milliseconds = Date.now() - Date.parse(value);
4224
  if (!Number.isFinite(milliseconds)) return 'Time unavailable';
4225
  const minutes = Math.round(Math.abs(milliseconds) / 60_000);
4226
  const future = milliseconds < 0;
4227
  const phrase =
4228
    minutes < 1
4229
      ? 'just now'
4230
      : minutes < 60
4231
        ? `${minutes}m`
4232
        : minutes < 1_440
4233
          ? `${Math.round(minutes / 60)}h`
4234
          : `${Math.round(minutes / 1_440)}d`;
4235
  return minutes < 1 ? phrase : future ? `in ${phrase}` : `${phrase} ago`;
4236
}
4237
4238
function feedbackState(requests: Record<string, any>[]) {
4239
  if (!requests.length) return 'Starts after a completion report';
4240
  const pending = requests.filter((request) => request.status === 'pending').length;
4241
  if (pending) return `${pending} response${pending === 1 ? '' : 's'} still pending`;
4242
  const expired = requests.filter((request) => request.status === 'expired').length;
4243
  return expired
4244
    ? `${expired} request${expired === 1 ? '' : 's'} timed out`
4245
    : 'Bilateral feedback closed';
4246
}
4247
4248
function StatusPill({ value }: { value: string }) {
4249
  const normalized = String(value).toLowerCase();
4250
  const tone = [
4251
    'success',
4252
    'active',
4253
    'ready',
4254
    'met',
4255
    'eligible',
4256
    'attested',
4257
    'allow',
4258
    'completed',
4259
  ].includes(normalized)
4260
    ? 'good'
4261
    : ['failure', 'denied', 'deny', 'missed', 'rejected', 'violation'].some((item) =>
4262
          normalized.includes(item),
4263
        )
4264
      ? 'danger'
4265
      : ['partial', 'pending', 'provisional', 'challenge', 'conflict', 'expired', 'excluded'].some(
4266
            (item) => normalized.includes(item),
4267
          )
4268
        ? 'warn'
4269
        : 'neutral';
4270
  return (
4271
    <span className={`statusPill ${tone}`}>
4272
      <i />
4273
      {humanize(value)}
4274
    </span>
4275
  );
4276
}
4277
4278
function timestamp(item: Record<string, any>) {
4279
  return String(
4280
    item.timestamp ??
4281
      item.generatedAt ??
4282
      item.decidedAt ??
4283
      item.appliedAt ??
4284
      item.submittedAt ??
4285
      item.completedAt ??
4286
      item.activatedAt ??
4287
      item.updatedAt ??
4288
      item.requestedAt ??
4289
      item.createdAt ??
4290
      new Date(0).toISOString(),
4291
  );
4292
}
4293
4294
if (!__AUTH0_DOMAIN__ || !__AUTH0_CLIENT_ID__ || !__AUTH0_AUDIENCE__)
4295
  throw new Error('Auth0 public configuration is incomplete');
4296
createRoot(document.getElementById('root')!).render(
4297
  <React.StrictMode>
4298
    {location.pathname === '/sso-callback' ? <AuthCallback /> : <App />}
4299
  </React.StrictMode>,
4300
);