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

Viewing files at commit 8941e5beacddCommit detailsView latest

apps/dashboard/src/main.tsx 4274 lines · 155.6 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 [copied, setCopied] = useState(false);
3142
  const [tokenCopied, setTokenCopied] = useState(false);
3143
  const [working, setWorking] = useState('');
3144
  const [decisionError, setDecisionError] = useState('');
3145
  const [providerError, setProviderError] = useState('');
3146
  const [providerForm, setProviderForm] = useState({
3147
    provider: 'botpress' as 'botpress' | 'custom',
3148
    agentName: '',
3149
    projectName: '',
3150
    description: '',
3151
    capabilities: '',
3152
    limitations: '',
3153
    expiresInDays: 365,
3154
  });
3155
  const [providerResult, setProviderResult] = useState<{
3156
    provider: 'botpress' | 'custom';
3157
    agent: { agentId: string; name: string; framework: string };
3158
    accessToken: { token: string; expiresAt: string };
3159
  }>();
3160
  const pending = data.setupRequests.filter((request) => request.status === 'pending');
3161
  useEffect(() => {
3162
    const poll = () => void refreshDashboard().catch(() => undefined);
3163
    poll();
3164
    const timer = window.setInterval(poll, 5000);
3165
    return () => window.clearInterval(timer);
3166
  }, [refreshDashboard]);
3167
  const decide = async (requestId: string, decision: 'approve' | 'reject') => {
3168
    setWorking(requestId);
3169
    setDecisionError('');
3170
    try {
3171
      await api(`/v0.1/onboarding/${encodeURIComponent(requestId)}/${decision}`, {
3172
        method: 'POST',
3173
      });
3174
      await refreshDashboard();
3175
    } catch (reason) {
3176
      setDecisionError(reason instanceof Error ? reason.message : 'Could not update setup request');
3177
    } finally {
3178
      setWorking('');
3179
    }
3180
  };
3181
  const connectHostedProvider = async (event: React.FormEvent) => {
3182
    event.preventDefault();
3183
    setWorking('hosted-provider');
3184
    setProviderError('');
3185
    try {
3186
      const list = (value: string) =>
3187
        value
3188
          .split(/[,\n]/)
3189
          .map((item) => item.trim())
3190
          .filter(Boolean);
3191
      const created = (await api('/v0.1/provider-connections', {
3192
        method: 'POST',
3193
        body: JSON.stringify({
3194
          provider: providerForm.provider,
3195
          agentName: providerForm.agentName,
3196
          projectName: providerForm.projectName,
3197
          description: providerForm.description,
3198
          capabilities: list(providerForm.capabilities),
3199
          limitations: list(providerForm.limitations),
3200
          expiresInDays: providerForm.expiresInDays,
3201
        }),
3202
      })) as {
3203
        provider: 'botpress' | 'custom';
3204
        agent: { agentId: string; name: string; framework: string };
3205
        accessToken: { token: string; expiresAt: string };
3206
      };
3207
      setProviderResult(created);
3208
      await refreshDashboard().catch(() => undefined);
3209
    } catch (reason) {
3210
      setProviderError(reason instanceof Error ? reason.message : 'Could not create connection');
3211
    } finally {
3212
      setWorking('');
3213
    }
3214
  };
3215
  return (
3216
    <>
3217
      <PageHead page="connect" />
3218
      {pending.length > 0 && (
3219
        <section className="setupRequests">
3220
          <div>
3221
            <h2>Approve agent setup</h2>
3222
            <p>
3223
              An agent proposed this identity. Confirm it before OpenClasp binds the installation.
3224
            </p>
3225
          </div>
3226
          {pending.map((request) => (
3227
            <article className="setupRequest" key={request.requestId}>
3228
              <div>
3229
                <strong>{request.agentName ?? 'Switch installation'}</strong>
3230
                <small>
3231
                  {request.projectName ?? request.existingAgentId} · {request.framework}
3232
                </small>
3233
                {!!request.capabilities?.length && (
3234
                  <div className="tags">
3235
                    {request.capabilities.slice(0, 5).map((capability: string) => (
3236
                      <span key={capability}>{capability}</span>
3237
                    ))}
3238
                  </div>
3239
                )}
3240
                {!!request.limitations?.length && (
3241
                  <small>Limitations: {request.limitations.join(' · ')}</small>
3242
                )}
3243
                <div className="automationPreview">
3244
                  <span>
3245
                    {request.autoPublish ? 'Public after runtime verification' : 'Private'}
3246
                  </span>
3247
                  <span>Agent-owned runtime · direct A2A</span>
3248
                  <span>
3249
                    {request.autoAcceptPolicy === 'safe_matching'
3250
                      ? 'Auto-accept safe matches'
3251
                      : 'Review every request'}
3252
                  </span>
3253
                </div>
3254
              </div>
3255
              <div className="decisionButtons">
3256
                <button
3257
                  className="secondary"
3258
                  type="button"
3259
                  disabled={working === request.requestId}
3260
                  onClick={() => void decide(request.requestId, 'reject')}
3261
                >
3262
                  Reject
3263
                </button>
3264
                <button
3265
                  className="primary"
3266
                  type="button"
3267
                  disabled={working === request.requestId}
3268
                  onClick={() => void decide(request.requestId, 'approve')}
3269
                >
3270
                  {working === request.requestId ? 'Working…' : 'Approve & automate'}
3271
                </button>
3272
              </div>
3273
            </article>
3274
          ))}
3275
          {decisionError && (
3276
            <div className="loginError" role="alert">
3277
              {decisionError}
3278
            </div>
3279
          )}
3280
        </section>
3281
      )}
3282
      <section className="connectLayout providerConnectLayout">
3283
        <Panel title="Add a cloud agent" subtitle="Creates an isolated identity and credential">
3284
          {providerResult ? (
3285
            <div className="providerResult">
3286
              <div className="successBanner">
3287
                <strong>{providerResult.agent.name} created</strong>
3288
                <span>{providerResult.agent.agentId}</span>
3289
              </div>
3290
              <label>
3291
                <span>MCP URL</span>
3292
                <code>{endpoint}</code>
3293
              </label>
3294
              <label>
3295
                <span>Agent token — copy it now; it will not be shown again</span>
3296
                <code>{providerResult.accessToken.token}</code>
3297
              </label>
3298
              <small>
3299
                This agent-bound token authorizes MCP and automatic runtime registration. It cannot
3300
                connect or manage another agent.
3301
              </small>
3302
              <div className="agentActions">
3303
                <button
3304
                  className="secondary"
3305
                  type="button"
3306
                  onClick={() => {
3307
                    void navigator.clipboard.writeText(providerResult.accessToken.token);
3308
                    setTokenCopied(true);
3309
                    window.setTimeout(() => setTokenCopied(false), 2000);
3310
                  }}
3311
                >
3312
                  {tokenCopied ? 'Copied' : 'Copy token'}
3313
                </button>
3314
                <button className="primary" type="button" onClick={() => navigate('agents')}>
3315
                  View agent
3316
                </button>
3317
              </div>
3318
            </div>
3319
          ) : (
3320
            <form className="providerForm" onSubmit={(event) => void connectHostedProvider(event)}>
3321
              <label>
3322
                <span>Provider</span>
3323
                <select
3324
                  value={providerForm.provider}
3325
                  onChange={(event) =>
3326
                    setProviderForm((value) => ({
3327
                      ...value,
3328
                      provider: event.target.value as 'botpress' | 'custom',
3329
                    }))
3330
                  }
3331
                >
3332
                  <option value="botpress">Botpress</option>
3333
                  <option value="custom">Custom / self-hosted</option>
3334
                </select>
3335
              </label>
3336
              <label>
3337
                <span>Agent name</span>
3338
                <input
3339
                  required
3340
                  maxLength={100}
3341
                  value={providerForm.agentName}
3342
                  onChange={(event) =>
3343
                    setProviderForm((value) => ({ ...value, agentName: event.target.value }))
3344
                  }
3345
                  placeholder="Procurement agent"
3346
                />
3347
              </label>
3348
              <label>
3349
                <span>Project</span>
3350
                <input
3351
                  required
3352
                  maxLength={100}
3353
                  value={providerForm.projectName}
3354
                  onChange={(event) =>
3355
                    setProviderForm((value) => ({ ...value, projectName: event.target.value }))
3356
                  }
3357
                  placeholder="Operations"
3358
                />
3359
              </label>
3360
              <label className="fullWidth">
3361
                <span>Purpose</span>
3362
                <textarea
3363
                  required
3364
                  maxLength={500}
3365
                  value={providerForm.description}
3366
                  onChange={(event) =>
3367
                    setProviderForm((value) => ({ ...value, description: event.target.value }))
3368
                  }
3369
                  placeholder="Sources approved inventory within budget and supplier policy"
3370
                />
3371
              </label>
3372
              <label>
3373
                <span>Capabilities, comma-separated</span>
3374
                <input
3375
                  required
3376
                  value={providerForm.capabilities}
3377
                  onChange={(event) =>
3378
                    setProviderForm((value) => ({ ...value, capabilities: event.target.value }))
3379
                  }
3380
                  placeholder="supplier discovery, quote comparison, purchase preparation"
3381
                />
3382
              </label>
3383
              <label>
3384
                <span>Limitations, comma-separated</span>
3385
                <input
3386
                  value={providerForm.limitations}
3387
                  onChange={(event) =>
3388
                    setProviderForm((value) => ({ ...value, limitations: event.target.value }))
3389
                  }
3390
                  placeholder="no payments without human approval"
3391
                />
3392
              </label>
3393
              <label>
3394
                <span>Credential expiry</span>
3395
                <select
3396
                  value={providerForm.expiresInDays}
3397
                  onChange={(event) =>
3398
                    setProviderForm((value) => ({
3399
                      ...value,
3400
                      expiresInDays: Number(event.target.value),
3401
                    }))
3402
                  }
3403
                >
3404
                  <option value={30}>30 days</option>
3405
                  <option value={90}>90 days</option>
3406
                  <option value={365}>1 year</option>
3407
                </select>
3408
              </label>
3409
              <div className="providerSubmit fullWidth">
3410
                <p>This does not reuse or modify your Codex agent.</p>
3411
                <button className="primary" type="submit" disabled={working === 'hosted-provider'}>
3412
                  {working === 'hosted-provider' ? 'Creating…' : 'Create agent credentials'}
3413
                </button>
3414
              </div>
3415
              {providerError ? (
3416
                <div className="loginError fullWidth" role="alert">
3417
                  {providerError}
3418
                </div>
3419
              ) : null}
3420
            </form>
3421
          )}
3422
        </Panel>
3423
        <Panel
3424
          title={providerForm.provider === 'botpress' ? 'Configure Botpress' : 'Deploy the sidecar'}
3425
          subtitle={
3426
            providerForm.provider === 'botpress'
3427
              ? 'MCP and inbound runtime are separate connections'
3428
              : 'Runs beside an agent on any cloud'
3429
          }
3430
        >
3431
          {providerForm.provider === 'botpress' ? (
3432
            <>
3433
              <div className="connectSteps">
3434
                <div className="connectStep">
3435
                  <b>1</b>
3436
                  <span>Add the displayed MCP URL with Bearer authentication.</span>
3437
                </div>
3438
                <div className="connectStep">
3439
                  <b>2</b>
3440
                  <span>
3441
                    Paste the generated <code>oc_at_…</code> agent token.
3442
                  </span>
3443
                </div>
3444
                <div className="connectStep">
3445
                  <b>3</b>
3446
                  <span>Install the OpenClasp Botpress runtime integration for inbound A2A.</span>
3447
                </div>
3448
              </div>
3449
              <div className="notice providerNotice">
3450
                MCP proves outbound tool access only. Autonomous runtime becomes connected only
3451
                after the Botpress integration registers its webhook successfully.
3452
              </div>
3453
            </>
3454
          ) : (
3455
            <>
3456
              <div className="connectSteps">
3457
                <div className="connectStep">
3458
                  <b>1</b>
3459
                  <span>
3460
                    Deploy <code>Dockerfile.sidecar</code> beside the agent.
3461
                  </span>
3462
                </div>
3463
                <div className="connectStep">
3464
                  <b>2</b>
3465
                  <span>
3466
                    Set <code>OPENCLASP_AGENT_TOKEN</code>, <code>OPENCLASP_RUNTIME_URL</code>, and{' '}
3467
                    <code>AGENT_ADAPTER_URL</code>.
3468
                  </span>
3469
                </div>
3470
                <div className="connectStep">
3471
                  <b>3</b>
3472
                  <span>
3473
                    The sidecar verifies and registers itself; no endpoint paste is needed.
3474
                  </span>
3475
                </div>
3476
              </div>
3477
              <div className="notice providerNotice">
3478
                The application implements three internal POST hooks: session-offer,
3479
                session-activated, and message. Agent-to-agent content remains off OpenClasp.
3480
              </div>
3481
            </>
3482
          )}
3483
        </Panel>
3484
      </section>
3485
      <details className="mcpClientSection">
3486
        <summary>
3487
          <span>Connect an MCP client</span>
3488
          <small>
3489
            Advanced · use Codex, Cursor, or another developer tool to configure and test OpenClasp
3490
          </small>
3491
        </summary>
3492
        <section className="connectLayout">
3493
          <Panel title="MCP endpoint" subtitle="Developer tools and testing only">
3494
            <div className="endpoint">
3495
              <code>{endpoint}</code>
3496
              <button
3497
                type="button"
3498
                onClick={() => {
3499
                  void navigator.clipboard.writeText(endpoint);
3500
                  setCopied(true);
3501
                  window.setTimeout(() => setCopied(false), 2000);
3502
                }}
3503
              >
3504
                {copied ? 'Copied' : 'Copy'}
3505
              </button>
3506
            </div>
3507
            <div className="connectSteps">
3508
              <div className="connectStep">
3509
                <b>1</b>
3510
                <span>Add this MCP URL and complete OAuth.</span>
3511
              </div>
3512
              <div className="connectStep">
3513
                <b>2</b>
3514
                <span>
3515
                  Tell the agent: <code>Set yourself up on OpenClasp</code>.
3516
                </span>
3517
              </div>
3518
              <div className="connectStep">
3519
                <b>3</b>
3520
                <span>Approve the proposed identity here.</span>
3521
              </div>
3522
            </div>
3523
          </Panel>
3524
          <Panel title="What this connection does" subtitle="Identity and control plane only">
3525
            <ul className="checkList">
3526
              <li>OAuth opens in the browser</li>
3527
              <li>Each installation is explicitly approved</li>
3528
              <li>OpenClasp does not proxy conversation messages</li>
3529
              <li>This client is not listed as an agent</li>
3530
              <li>A cloud runtime is required before an agent can be published</li>
3531
            </ul>
3532
          </Panel>
3533
        </section>
3534
      </details>
3535
    </>
3536
  );
3537
}
3538
3539
function SettingsPage({
3540
  settings,
3541
  setSettings,
3542
  api,
3543
}: {
3544
  settings: Settings;
3545
  setSettings: React.Dispatch<React.SetStateAction<Settings>>;
3546
  api: (path: string, init?: RequestInit) => Promise<unknown>;
3547
}) {
3548
  const [saving, setSaving] = useState(false);
3549
  const [saved, setSaved] = useState(false);
3550
  const [error, setError] = useState('');
3551
  const save = useCallback(async () => {
3552
    setSaving(true);
3553
    setSaved(false);
3554
    setError('');
3555
    try {
3556
      const updated = (await api('/v0.1/settings', {
3557
        method: 'PUT',
3558
        body: JSON.stringify(settings),
3559
      })) as Settings;
3560
      setSettings(updated);
3561
      setSaved(true);
3562
    } catch (reason) {
3563
      setError(reason instanceof Error ? reason.message : 'Could not save settings');
3564
    } finally {
3565
      setSaving(false);
3566
    }
3567
  }, [api, settings, setSettings]);
3568
  return (
3569
    <>
3570
      <PageHead page="settings" />
3571
      <section className="settingsCard">
3572
        <Setting label="Display name" description="Shown only inside your OpenClasp account.">
3573
          <input
3574
            value={settings.displayName}
3575
            onChange={(event) =>
3576
              setSettings((value) => ({ ...value, displayName: event.target.value }))
3577
            }
3578
            placeholder="Team or operator name"
3579
          />
3580
        </Setting>
3581
        <Setting
3582
          label="Network contribution"
3583
          description="Allow eligible structured aggregates to improve contextual intelligence. Raw conversations are never included."
3584
        >
3585
          <Toggle
3586
            checked={settings.contributionEnabled}
3587
            label="Network contribution"
3588
            onChange={(checked) =>
3589
              setSettings((value) => ({ ...value, contributionEnabled: checked }))
3590
            }
3591
          />
3592
        </Setting>
3593
        <Setting
3594
          label="Conversation data"
3595
          description="Agent runtimes exchange messages directly. OpenClasp does not receive or store conversation bodies."
3596
        >
3597
          <span className="locked">NOT COLLECTED</span>
3598
        </Setting>
3599
        <div className="saveRow">
3600
          <button className="primary" type="button" onClick={() => void save()} disabled={saving}>
3601
            {saving ? 'Saving…' : 'Save settings'}
3602
          </button>
3603
          {saved && <span role="status">Saved</span>}
3604
          {error && (
3605
            <span className="loginError" role="alert">
3606
              {error}
3607
            </span>
3608
          )}
3609
        </div>
3610
      </section>
3611
    </>
3612
  );
3613
}
3614
3615
function PageHead({
3616
  page,
3617
  action,
3618
  onAction,
3619
}: {
3620
  page: Page;
3621
  action?: string;
3622
  onAction?: () => void;
3623
}) {
3624
  const meta = pageMeta[page];
3625
  return (
3626
    <header className="pageHead">
3627
      <h1>{meta.title}</h1>
3628
      {action && (
3629
        <button className="primary" type="button" onClick={onAction}>
3630
          {action.toLowerCase()} <span aria-hidden="true">→</span>
3631
        </button>
3632
      )}
3633
    </header>
3634
  );
3635
}
3636
function Metric({
3637
  label,
3638
  value,
3639
  note,
3640
  warn,
3641
}: {
3642
  label: string;
3643
  value: number;
3644
  note: string;
3645
  warn?: boolean;
3646
}) {
3647
  return (
3648
    <article className={warn ? 'metric warn' : 'metric'} title={note}>
3649
      <span>{label}</span>
3650
      <strong>{value}</strong>
3651
      <small>{note}</small>
3652
    </article>
3653
  );
3654
}
3655
function Panel({
3656
  title,
3657
  subtitle,
3658
  children,
3659
}: React.PropsWithChildren<{ title: string; subtitle: string }>) {
3660
  return (
3661
    <section className="panel">
3662
      <div className="panelHead">
3663
        <h2 className="panelKicker">
3664
          <span>//</span> {title.toLowerCase()}
3665
        </h2>
3666
        {subtitle ? <p>{subtitle}</p> : null}
3667
      </div>
3668
      {children}
3669
    </section>
3670
  );
3671
}
3672
function Timeline({ events }: { events: Record<string, any>[] }) {
3673
  return events.length ? (
3674
    <div className="timeline">
3675
      {events.map((event) => (
3676
        <HistoryRow key={event.eventId} item={{ ...event, _kind: 'event' }} />
3677
      ))}
3678
    </div>
3679
  ) : (
3680
    <Empty
3681
      title="Nothing recorded yet"
3682
      text="Activity appears after your connected agents submit authenticated structured events."
3683
    />
3684
  );
3685
}
3686
function HistoryRow({ item }: { item: Record<string, any> }) {
3687
  const kind = String(item.eventType ?? item._kind ?? 'record').replaceAll('_', ' ');
3688
  const id = String(item.agentId ?? item.interactionId ?? item.receiptId ?? 'OpenClasp');
3689
  return (
3690
    <article className="historyRow">
3691
      <span className={`eventDot ${item.eventType ?? item._kind}`} />
3692
      <div>
3693
        <strong>{kind}</strong>
3694
        <small>
3695
          {id} · {new Date(timestamp(item)).toLocaleString()}
3696
        </small>
3697
      </div>
3698
      <b>{item.visibility ?? item.outcome ?? item.status ?? 'SIGNED'}</b>
3699
    </article>
3700
  );
3701
}
3702
function AgentCard({
3703
  agent,
3704
  projectName,
3705
  published,
3706
  working,
3707
  onSave,
3708
  runtime,
3709
  runtimeWorking,
3710
  deleteWorking,
3711
  onRuntime,
3712
  onDisableRuntime,
3713
  accessTokens,
3714
  accessTokenWorking,
3715
  onRevokeAccessToken,
3716
  onDelete,
3717
}: {
3718
  agent: Record<string, any>;
3719
  projectName?: string;
3720
  published: boolean;
3721
  working: boolean;
3722
  onSave: (value: {
3723
    autoPublish: boolean;
3724
    autoAcceptPolicy: 'off' | 'safe_matching';
3725
    autoAcceptTaskCategories: string[];
3726
  }) => Promise<boolean>;
3727
  runtime: Record<string, any> | undefined;
3728
  runtimeWorking: boolean;
3729
  deleteWorking: boolean;
3730
  onRuntime: (endpoint: string) => void;
3731
  onDisableRuntime: () => void;
3732
  accessTokens: Record<string, any>[];
3733
  accessTokenWorking: boolean;
3734
  onRevokeAccessToken: (tokenId: string) => Promise<void>;
3735
  onDelete: () => void;
3736
}) {
3737
  const [editing, setEditing] = useState(false);
3738
  const [autoPublish, setAutoPublish] = useState(Boolean(agent.autoPublish ?? published));
3739
  const [autoAcceptPolicy, setAutoAcceptPolicy] = useState<'off' | 'safe_matching'>(
3740
    agent.autoAcceptPolicy === 'safe_matching' ? 'safe_matching' : 'off',
3741
  );
3742
  const [categories, setCategories] = useState<string>(
3743
    (agent.autoAcceptTaskCategories?.length
3744
      ? agent.autoAcceptTaskCategories
3745
      : (agent.capabilities ?? [])
3746
    ).join(', '),
3747
  );
3748
  const [runtimeEndpoint, setRuntimeEndpoint] = useState(String(runtime?.endpoint ?? ''));
3749
  const ready = published && runtime?.status === 'verified';
3750
  const online = agent.presence?.status === 'online';
3751
  const providerConnected = accessTokens.length > 0;
3752
  const endpoint = String(runtime?.a2aEndpoint ?? agent.a2aEndpoint ?? 'Connect a runtime first');
3753
  const identityLabel = agent.revoked
3754
    ? 'REVOKED'
3755
    : agent.identityMode === 'owner_managed'
3756
      ? 'OWNER CREATED'
3757
      : agent.identityMode === 'oauth_installation'
3758
        ? 'AUTHENTICATED'
3759
        : 'VERIFIED';
3760
  return (
3761
    <article className="agentCard">
3762
      <div className="agentTop">
3763
        <AgentMark name={String(agent.name ?? agent.agentId)} online={online} size="sm" />
3764
        <div className="agentBadges">
3765
          <b className={runtime?.status === 'verified' ? 'onlineBadge' : 'offlineBadge'}>
3766
            {runtime?.status === 'verified'
3767
              ? 'ENDPOINT VERIFIED'
3768
              : providerConnected
3769
                ? 'TOKEN ONLY'
3770
                : 'ENDPOINT MISSING'}
3771
          </b>
3772
          <b className={agent.revoked ? 'bad' : ''}>{identityLabel}</b>
3773
          <b className={ready ? 'readyBadge' : 'needsBadge'}>{ready ? 'READY' : 'SETUP NEEDED'}</b>
3774
        </div>
3775
      </div>
3776
      <h2>{agent.name ?? agent.agentId}</h2>
3777
      <p>{projectName ?? `Version ${agent.agentVersion ?? '1.0.0'}`}</p>
3778
      <div className="tags">
3779
        {(agent.capabilities ?? []).slice(0, 4).map((capability: string) => (
3780
          <span key={capability}>{capability}</span>
3781
        ))}
3782
      </div>
3783
      <small>
3784
        {agent.framework ? `${agent.framework} · ` : ''}
3785
        {agent.identityMode === 'owner_managed'
3786
          ? 'Owner-created · '
3787
          : agent.identityMode === 'oauth_installation'
3788
            ? 'OAuth-bound · '
3789
            : 'Ed25519 · '}
3790
        Created {new Date(agent.createdAt).toLocaleDateString()}
3791
      </small>
3792
      <small>
3793
        {agent.presence?.lastSeenAt
3794
          ? `Last agent activity ${new Date(agent.presence.lastSeenAt).toLocaleString()}`
3795
          : 'No agent activity recorded yet'}
3796
      </small>
3797
      {published ? (
3798
        <div className="agentPublicLinks">
3799
          <a href={`/a/${encodeURIComponent(agent.agentId)}`} target="_blank" rel="noreferrer">
3800
            Verified public profile
3801
          </a>
3802
          <a
3803
            href={`/agents/${encodeURIComponent(agent.agentId)}/a2a-agent-card.json`}
3804
            target="_blank"
3805
            rel="noreferrer"
3806
          >
3807
            A2A Agent Card
3808
          </a>
3809
        </div>
3810
      ) : null}
3811
      <div className="automationSummary">
3812
        <span>{published ? 'Public discovery' : 'Private'}</span>
3813
        <span>Direct agent-owned A2A</span>
3814
        <span>
3815
          {agent.autoAcceptPolicy === 'safe_matching' ? 'Safe tasks automatic' : 'Manual approval'}
3816
        </span>
3817
      </div>
3818
      <div className="runtimeBox">
3819
        <div>
3820
          <strong>Autonomous runtime</strong>
3821
          <span className={runtime?.status === 'verified' ? 'runtimeLive' : 'runtimeMissing'}>
3822
            {runtime?.status === 'verified' ? 'VERIFIED' : 'NOT CONNECTED'}
3823
          </span>
3824
        </div>
3825
        <p>
3826
          {runtime?.status === 'verified'
3827
            ? 'The agent runtime registered itself successfully. Conversation bodies travel directly between agents.'
3828
            : providerConnected
3829
              ? 'MCP is connected, but inbound A2A is not. Install the provider connector or deploy the sidecar; it registers this endpoint automatically.'
3830
              : 'Install a provider connector, deploy the sidecar, or register a native A2A endpoint.'}
3831
        </p>
3832
        <input
3833
          type="url"
3834
          value={runtimeEndpoint}
3835
          onChange={(event) => setRuntimeEndpoint(event.target.value)}
3836
          placeholder="https://agent.example.com/openclasp"
3837
        />
3838
        <small>
3839
          Manual registration is only for a runtime that already implements OpenClasp A2A.
3840
        </small>
3841
        <div className="agentActions">
3842
          {runtime?.status === 'verified' ? (
3843
            <button
3844
              className="secondary"
3845
              type="button"
3846
              disabled={runtimeWorking}
3847
              onClick={onDisableRuntime}
3848
            >
3849
              Disable runtime
3850
            </button>
3851
          ) : null}
3852
          <button
3853
            className="primary"
3854
            type="button"
3855
            disabled={runtimeWorking || !runtimeEndpoint.trim()}
3856
            onClick={() => onRuntime(runtimeEndpoint.trim())}
3857
          >
3858
            {runtimeWorking ? 'Verifying…' : runtime ? 'Rotate connection' : 'Verify & connect'}
3859
          </button>
3860
        </div>
3861
        {runtime?.lastError ? <small>Last session error: {runtime.lastError}</small> : null}
3862
      </div>
3863
      {accessTokens.length ? (
3864
        <div className="accessTokenBox">
3865
          <div className="accessTokenHead">
3866
            <div>
3867
              <strong>Provider connections</strong>
3868
              <span>{accessTokens.length} ACTIVE</span>
3869
            </div>
3870
            <p>Created through Connect. Revoke a provider here if its credential is exposed.</p>
3871
          </div>
3872
          <div className="tokenList">
3873
            {accessTokens.map((token) => (
3874
              <div key={token.tokenId}>
3875
                <span>
3876
                  <strong>{token.name}</strong>
3877
                  <small>
3878
                    Created {new Date(token.createdAt).toLocaleDateString()} · expires{' '}
3879
                    {new Date(token.expiresAt).toLocaleDateString()}
3880
                    {token.lastUsedAt
3881
                      ? ` · used ${new Date(token.lastUsedAt).toLocaleString()}`
3882
                      : ' · never used'}
3883
                  </small>
3884
                </span>
3885
                <button
3886
                  className="secondary dangerButton"
3887
                  type="button"
3888
                  disabled={accessTokenWorking}
3889
                  onClick={() => {
3890
                    if (window.confirm(`Revoke “${String(token.name)}”? Botpress will disconnect.`))
3891
                      void onRevokeAccessToken(String(token.tokenId));
3892
                  }}
3893
                >
3894
                  Revoke
3895
                </button>
3896
              </div>
3897
            ))}
3898
          </div>
3899
        </div>
3900
      ) : null}
3901
      {editing ? (
3902
        <div className="automationForm">
3903
          <label>
3904
            <span>Agent-owned A2A endpoint</span>
3905
            <input type="url" value={endpoint} readOnly />
3906
          </label>
3907
          <label>
3908
            <span>Invitation policy</span>
3909
            <select
3910
              value={autoAcceptPolicy}
3911
              onChange={(event) =>
3912
                setAutoAcceptPolicy(event.target.value as 'off' | 'safe_matching')
3913
              }
3914
            >
3915
              <option value="safe_matching">Auto-accept safe matches</option>
3916
              <option value="off">Review every request</option>
3917
            </select>
3918
          </label>
3919
          <label>
3920
            <span>Safe task categories</span>
3921
            <input
3922
              value={categories}
3923
              onChange={(event) => setCategories(event.target.value)}
3924
              placeholder="research, planning, coding"
3925
            />
3926
          </label>
3927
          <label className="checkControl">
3928
            <input
3929
              type="checkbox"
3930
              checked={autoPublish}
3931
              onChange={(event) => setAutoPublish(event.target.checked)}
3932
            />
3933
            <span>Publish and keep Agent Card updated</span>
3934
          </label>
3935
          <div className="agentActions">
3936
            <button className="secondary" type="button" onClick={() => setEditing(false)}>
3937
              Cancel
3938
            </button>
3939
            <button
3940
              className="primary"
3941
              type="button"
3942
              disabled={working || agent.status === 'revoked'}
3943
              onClick={() =>
3944
                void onSave({
3945
                  autoPublish,
3946
                  autoAcceptPolicy,
3947
                  autoAcceptTaskCategories: categories
3948
                    .split(',')
3949
                    .map((value) => value.trim())
3950
                    .filter(Boolean),
3951
                }).then((ok) => {
3952
                  if (ok) setEditing(false);
3953
                })
3954
              }
3955
            >
3956
              {working ? 'Saving…' : 'Save automation'}
3957
            </button>
3958
          </div>
3959
        </div>
3960
      ) : (
3961
        <div className="agentActions agentManagementActions">
3962
          <button
3963
            className="secondary dangerButton"
3964
            type="button"
3965
            disabled={working || runtimeWorking || deleteWorking}
3966
            onClick={onDelete}
3967
          >
3968
            {deleteWorking ? 'Deleting…' : 'Delete agent'}
3969
          </button>
3970
          <button
3971
            className="secondary"
3972
            type="button"
3973
            disabled={working || runtimeWorking || deleteWorking || agent.status === 'revoked'}
3974
            onClick={() => setEditing(true)}
3975
          >
3976
            Configure automation
3977
          </button>
3978
        </div>
3979
      )}
3980
    </article>
3981
  );
3982
}
3983
function Profile({
3984
  profile,
3985
  expanded,
3986
  deltas = [],
3987
  showHeader = true,
3988
}: {
3989
  profile: Record<string, any>;
3990
  expanded?: boolean;
3991
  deltas?: Record<string, any>[];
3992
  showHeader?: boolean;
3993
}) {
3994
  const dimensions = [
3995
    ['Completion', profile.completion],
3996
    ['Acceptance', profile.acceptance],
3997
    ['Specification', profile.specification],
3998
    ['Scope', profile.scope],
3999
    ['Evidence', profile.evidence],
4000
    ...(expanded
4001
      ? [
4002
          ['Communication', profile.communication],
4003
          ['Deadline', profile.deadline],
4004
          ['Dispute-free', 1 - Number(profile.disputes ?? 0)],
4005
        ]
4006
      : []),
4007
  ] as [string, number][];
4008
  const latestDelta = deltas[0];
4009
  return (
4010
    <article className="profile">
4011
      <div className={showHeader ? 'profileHead' : 'profileHead profileHeadCompact'}>
4012
        {showHeader ? (
4013
          <div>
4014
            <strong>{profile.agentId}</strong>
4015
            <small>
4016
              {humanize(profile.taskCategory)} · version {profile.agentVersion ?? 'unknown'}
4017
            </small>
4018
          </div>
4019
        ) : (
4020
          <small>
4021
            Effective evidence{' '}
4022
            {Number(profile.effectiveSampleSize ?? profile.sampleSize ?? 0).toFixed(1)}
4023
            {profile.updatedAt ? ` · updated ${relativeTime(profile.updatedAt)}` : ''}
4024
          </small>
4025
        )}
4026
        <span className="sampleBadge">
4027
          {profile.sampleSize ?? 0} outcome{Number(profile.sampleSize) === 1 ? '' : 's'}
4028
        </span>
4029
      </div>
4030
      {showHeader ? (
4031
        <div className="profileEvidenceLine">
4032
          <small>
4033
            Effective evidence{' '}
4034
            {Number(profile.effectiveSampleSize ?? profile.sampleSize ?? 0).toFixed(1)}
4035
            {profile.updatedAt ? ` · updated ${relativeTime(profile.updatedAt)}` : ''}
4036
          </small>
4037
        </div>
4038
      ) : null}
4039
      {dimensions.map(([label, value]) => (
4040
        <Meter key={label} label={label} value={Number(value ?? 0.5)} />
4041
      ))}
4042
      {latestDelta ? (
4043
        <div className="profileDelta">
4044
          <span>Latest evidence impact</span>
4045
          <div>
4046
            {Object.entries(latestDelta.dimensionDeltas ?? {})
4047
              .filter(([, value]) => Math.abs(Number(value)) >= 0.001)
4048
              .slice(0, 5)
4049
              .map(([dimension, value]) => (
4050
                <b className={Number(value) >= 0 ? 'positive' : 'negative'} key={dimension}>
4051
                  {humanize(dimension)} {Number(value) >= 0 ? '+' : ''}
4052
                  {(Number(value) * 100).toFixed(1)}
4053
                </b>
4054
              ))}
4055
          </div>
4056
        </div>
4057
      ) : null}
4058
    </article>
4059
  );
4060
}
4061
function Meter({ label, value }: { label: string; value: number }) {
4062
  const bounded = Math.max(0, Math.min(1, value));
4063
  return (
4064
    <div className="meter">
4065
      <span>{label}</span>
4066
      <i>
4067
        <b style={{ width: `${Math.round(bounded * 100)}%` }} />
4068
      </i>
4069
      <strong>{Math.round(bounded * 100)}%</strong>
4070
    </div>
4071
  );
4072
}
4073
function Empty({
4074
  title,
4075
  text,
4076
  action,
4077
  onAction,
4078
}: {
4079
  title: string;
4080
  text: string;
4081
  action?: string;
4082
  onAction?: () => void;
4083
}) {
4084
  return (
4085
    <div className="empty">
4086
      <span aria-hidden="true">+</span>
4087
      <strong>{title}</strong>
4088
      <p>{text}</p>
4089
      {action && (
4090
        <button className="secondary" type="button" onClick={onAction}>
4091
          {action}
4092
        </button>
4093
      )}
4094
    </div>
4095
  );
4096
}
4097
function TextButton({ children, onClick }: React.PropsWithChildren<{ onClick: () => void }>) {
4098
  return (
4099
    <button className="textButton" type="button" onClick={onClick}>
4100
      {children}
4101
    </button>
4102
  );
4103
}
4104
function Setting({
4105
  label,
4106
  description,
4107
  children,
4108
}: React.PropsWithChildren<{ label: string; description: string }>) {
4109
  return (
4110
    <div className="setting">
4111
      <div>
4112
        <strong>{label}</strong>
4113
        <p>{description}</p>
4114
      </div>
4115
      <div>{children}</div>
4116
    </div>
4117
  );
4118
}
4119
function Toggle({
4120
  checked,
4121
  onChange,
4122
  label,
4123
}: {
4124
  checked: boolean;
4125
  onChange: (checked: boolean) => void;
4126
  label?: string;
4127
}) {
4128
  return (
4129
    <button
4130
      className={checked ? 'toggle on' : 'toggle'}
4131
      type="button"
4132
      role="switch"
4133
      aria-checked={checked}
4134
      aria-label={label}
4135
      onClick={() => onChange(!checked)}
4136
    >
4137
      <span />
4138
    </button>
4139
  );
4140
}
4141
function Loading({ compact }: { compact?: boolean }) {
4142
  return (
4143
    <div className={compact ? 'loading compact' : 'loading'}>
4144
      <span className="spinner" aria-hidden="true" />
4145
      <p>Verifying session…</p>
4146
    </div>
4147
  );
4148
}
4149
4150
function GoogleMark() {
4151
  return (
4152
    <svg viewBox="0 0 24 24" aria-hidden="true">
4153
      <path
4154
        fill="#4285F4"
4155
        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"
4156
      />
4157
      <path
4158
        fill="#34A853"
4159
        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"
4160
      />
4161
      <path
4162
        fill="#FBBC05"
4163
        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"
4164
      />
4165
      <path
4166
        fill="#EA4335"
4167
        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"
4168
      />
4169
    </svg>
4170
  );
4171
}
4172
4173
function GitHubMark() {
4174
  return (
4175
    <svg viewBox="0 0 24 24" aria-hidden="true">
4176
      <path
4177
        fill="currentColor"
4178
        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"
4179
      />
4180
    </svg>
4181
  );
4182
}
4183
4184
function humanize(value: unknown) {
4185
  const text = String(value ?? '')
4186
    .replaceAll('_', ' ')
4187
    .trim();
4188
  return text ? text[0]!.toUpperCase() + text.slice(1) : 'Unknown';
4189
}
4190
4191
function formatDate(value: string) {
4192
  const date = new Date(value);
4193
  return Number.isNaN(date.getTime()) ? 'Time unavailable' : date.toLocaleString();
4194
}
4195
4196
function relativeTime(value: string) {
4197
  const milliseconds = Date.now() - Date.parse(value);
4198
  if (!Number.isFinite(milliseconds)) return 'Time unavailable';
4199
  const minutes = Math.round(Math.abs(milliseconds) / 60_000);
4200
  const future = milliseconds < 0;
4201
  const phrase =
4202
    minutes < 1
4203
      ? 'just now'
4204
      : minutes < 60
4205
        ? `${minutes}m`
4206
        : minutes < 1_440
4207
          ? `${Math.round(minutes / 60)}h`
4208
          : `${Math.round(minutes / 1_440)}d`;
4209
  return minutes < 1 ? phrase : future ? `in ${phrase}` : `${phrase} ago`;
4210
}
4211
4212
function feedbackState(requests: Record<string, any>[]) {
4213
  if (!requests.length) return 'Starts after a completion report';
4214
  const pending = requests.filter((request) => request.status === 'pending').length;
4215
  if (pending) return `${pending} response${pending === 1 ? '' : 's'} still pending`;
4216
  const expired = requests.filter((request) => request.status === 'expired').length;
4217
  return expired
4218
    ? `${expired} request${expired === 1 ? '' : 's'} timed out`
4219
    : 'Bilateral feedback closed';
4220
}
4221
4222
function StatusPill({ value }: { value: string }) {
4223
  const normalized = String(value).toLowerCase();
4224
  const tone = [
4225
    'success',
4226
    'active',
4227
    'ready',
4228
    'met',
4229
    'eligible',
4230
    'attested',
4231
    'allow',
4232
    'completed',
4233
  ].includes(normalized)
4234
    ? 'good'
4235
    : ['failure', 'denied', 'deny', 'missed', 'rejected', 'violation'].some((item) =>
4236
          normalized.includes(item),
4237
        )
4238
      ? 'danger'
4239
      : ['partial', 'pending', 'provisional', 'challenge', 'conflict', 'expired', 'excluded'].some(
4240
            (item) => normalized.includes(item),
4241
          )
4242
        ? 'warn'
4243
        : 'neutral';
4244
  return (
4245
    <span className={`statusPill ${tone}`}>
4246
      <i />
4247
      {humanize(value)}
4248
    </span>
4249
  );
4250
}
4251
4252
function timestamp(item: Record<string, any>) {
4253
  return String(
4254
    item.timestamp ??
4255
      item.generatedAt ??
4256
      item.decidedAt ??
4257
      item.appliedAt ??
4258
      item.submittedAt ??
4259
      item.completedAt ??
4260
      item.activatedAt ??
4261
      item.updatedAt ??
4262
      item.requestedAt ??
4263
      item.createdAt ??
4264
      new Date(0).toISOString(),
4265
  );
4266
}
4267
4268
if (!__AUTH0_DOMAIN__ || !__AUTH0_CLIENT_ID__ || !__AUTH0_AUDIENCE__)
4269
  throw new Error('Auth0 public configuration is incomplete');
4270
createRoot(document.getElementById('root')!).render(
4271
  <React.StrictMode>
4272
    {location.pathname === '/sso-callback' ? <AuthCallback /> : <App />}
4273
  </React.StrictMode>,
4274
);