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

Viewing files at commit 66ff6831b5d2Commit detailsView latest

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