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

Viewing files at commit 66a0492edc4aCommit detailsView latest

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