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

Viewing files at commit 20744dd65349Commit detailsView latest

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