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

Viewing files at commit ce192002f08aCommit detailsView latest

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