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

Viewing files at commit 8a15fe87b720Commit detailsView latest

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