@volter/world-console 2.0.5 → 2.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -197,3 +197,27 @@ section.pitch { background: none; border: 0; box-shadow: none; padding: 0; margi
197
197
  /* a phone: the top bar keeps the menu, the mark, where you are, the way back as an arrow and Sign out */
198
198
  @media (max-width: 720px) { main.app > header .mode, main.app > header .back-link .back-text { display: none; } main.app > header .back-link { flex: none; } }
199
199
  @media (max-width: 720px) { .head-actions .clock { flex-wrap: wrap; max-width: 100%; } }
200
+
201
+ /* changesets, reviewed as pull requests are: a status word, the changes by vendor, checks, approvals, receipts */
202
+ .badge.cs-done { background: var(--accent-soft); color: var(--ink); }
203
+ .badge.cs-good { color: var(--ok); border-color: var(--ok); }
204
+ .badge.cs-warn { background: var(--warn-soft); color: var(--warn); }
205
+ .badge.cs-bad { background: var(--danger-soft); color: var(--danger-text); }
206
+ .changeset-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; flex-wrap: wrap; margin-bottom: 8px; }
207
+ .changeset-head h3 { margin: 0 0 4px; }
208
+ .changeset-actions { display: flex; gap: 8px; flex-wrap: wrap; }
209
+ .changeset h4 { margin: 22px 0 8px; font-size: 13px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); }
210
+ .changeset .narration { white-space: pre-wrap; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); padding: 10px 12px; font-size: 13px; }
211
+ .changeset .stale { opacity: 0.75; }
212
+ .changeset ul.checks, .changeset ul.approvals, .changeset ul.receipts { list-style: none; padding: 0; margin: 6px 0; display: grid; gap: 6px; }
213
+ .crumbs-line { margin: 0 0 12px; font-size: 13px; }
214
+ /* the branches page: its words, and New branch beside them */
215
+ .branches-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; }
216
+ .branches-head .lede { margin-top: 0; }
217
+ /* a dialog's text box reads in the page's face, not the browser's monospace default */
218
+ dialog textarea { font: inherit; width: 100%; }
219
+ /* a set of choices (a branch's moment): its legend a label, its options one per line */
220
+ fieldset.choice { border: 0; padding: 0; margin: 0; display: grid; gap: 8px; min-width: 0; }
221
+ fieldset.choice > legend { padding: 0; margin-bottom: 4px; color: var(--muted); font-size: 12.5px; }
222
+ fieldset.choice > label.check { display: flex; align-items: flex-start; gap: 8px; color: var(--ink); font-size: 13px; }
223
+ fieldset.choice > label.check input { margin-top: 3px; }
@@ -4,7 +4,7 @@
4
4
  // answer, and offers nothing `volter world` or `curl` could not do. Locators are roles and text.
5
5
  import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
6
6
  import { Background, BackgroundVariant, Controls, MiniMap, NodeToolbar, Panel, Position, ReactFlow, ReactFlowProvider, useNodesState, useReactFlow, type Node, type NodeProps } from '@xyflow/react';
7
- import { applyTheme, ThemeChoice, At, Brand, ConfirmHost, MenuDismiss, confirmAct, CopyField, counted, Dialog, DrawerButton, failure, listed, Mark, Menu, Pending, Toaster, toast, useDoor as useKitDoor, vendorName, when, ago, type Load } from './kit/kit.tsx';
7
+ import { applyTheme, ThemeChoice, At, Brand, ConfirmHost, MenuDismiss, confirmAct, CopyField, counted, Dialog, DrawerButton, Empty, failure, listed, Mark, Menu, Pending, Toaster, toast, useDoor as useKitDoor, vendorName, when, ago, type Load } from './kit/kit.tsx';
8
8
  import { createRoot } from 'react-dom/client';
9
9
  import { agentPrompt } from '../../../packages/cli/src/agent-prompt.ts';
10
10
 
@@ -86,7 +86,7 @@ const actorOf = (e: Entry): string => (e.actor ? `${e.actor.kind}${e.actor.id ?
86
86
  const traceOf = (tp?: string): { trace: string; span: string } | null => { const m = /^[0-9a-f]{2}-([0-9a-f]{32})-([0-9a-f]{16})-[0-9a-f]{2}$/.exec(tp ?? ''); return m ? { trace: m[1]!, span: m[2]! } : null; };
87
87
 
88
88
  /** A past moment of a World, opened by this tab: a branch the host made, held with its tokens for the tab's life. */
89
- type Snapshot = { name: string; parent: string; token: string; readToken: string; at: string; expiresAt: string | null; /** where the parent's pages live, the way back */ parentOrigin?: string; /** this tab's own "View as of" made it here: only such a branch is ever removed */ made?: boolean };
89
+ type Snapshot = { name: string; parent: string; token: string; readToken: string; at: string; expiresAt: string | null; /** where the parent's pages live, the way back */ parentOrigin?: string; /** this tab's own "View as of" made it here: only such a branch is ever removed */ made?: boolean; /** the branch's own origin, where its pages live */ origin?: string };
90
90
  const SNAP_KEY = 'volter.console.snapshots';
91
91
  const snapshots = (): Record<string, Snapshot> => { try { return JSON.parse(sessionStorage.getItem(SNAP_KEY) ?? '{}') as Record<string, Snapshot>; } catch { return {}; } };
92
92
  // on a World origin a snapshot's tokens stay in this module (no frame can reach it), never in storage
@@ -168,6 +168,12 @@ const WORLD_PART = /^[a-z0-9](?:[a-z0-9]|-(?!-))*$/i;
168
168
  * encoded `/` or `..`) ever reaches a door's path. */
169
169
  const worldOf = (o?: string, w?: string): string => (o && w && WORLD_PART.test(o) && WORLD_PART.test(w) ? `${o}/${w}` : '');
170
170
  function go(parts: string[]): void { window.history.pushState(null, '', `${BASE}/${parts.map(encodeURIComponent).join('/')}`); window.dispatchEvent(new PopStateEvent('popstate')); }
171
+ /** A page of a branch this tab holds: at the branch's own origin when it has one (this origin serves its World only), carrying its read token. */
172
+ function goBranch(branch: string, parts: string[]): void {
173
+ const there = worldOriginOf(snapshots()[branch]?.origin);
174
+ if (there && there !== ORIGIN) { moveTo(there, snapshotToken(branch), branch, `${BASE}/${parts.map(encodeURIComponent).join('/')}`); return; }
175
+ go(parts);
176
+ }
171
177
 
172
178
  /** A World this machine serves on loopback opens its session for its own page with no token (served-world.ts
173
179
  * localSession): nothing is ever pasted, and a restart or a rotated token is picked up by asking again. Asked of the
@@ -325,9 +331,9 @@ function App(): React.JSX.Element {
325
331
  const back = worldOriginOf(s.parentOrigin);
326
332
  if (back && back !== ORIGIN) { forgetSnapshot(s.name); window.location.assign(`${back}${BASE}/${s.parent}#close=${encodeURIComponent(s.name)}`); return; }
327
333
  // the branch goes with the way back: a snapshot is a throwaway World, not something to leave running (one this
328
- // tab made; a carried one is only left)
329
- if (s.made) await door(worldToken(s.parent), `/-/${s.parent}/branches/${s.name}`, { method: 'DELETE' }).catch(() => null);
330
- forgetSnapshot(s.name); go(s.parent.split('/'));
334
+ // tab made as a snapshot; a carried one, or a branch made on the Branches page, is only left)
335
+ if (s.made) { await door(worldToken(s.parent), `/-/${s.parent}/branches/${s.name}`, { method: 'DELETE' }).catch(() => null); forgetSnapshot(s.name); }
336
+ go(s.parent.split('/'));
331
337
  };
332
338
  return (
333
339
  <main className={name ? 'app' : undefined}>
@@ -421,13 +427,16 @@ type Placed = { twin: string; position: number; entry: Entry };
421
427
  type Timeline = { entries: Placed[]; next: string | null };
422
428
  type Diff = { base: { id: string; at: string | null }; changes: Array<{ twin: string; entry: Entry }> };
423
429
  type Branch = { name: string; at?: { instant?: string }; createdAt: string; expiresAt: string | null };
430
+ /** The branches door: the host's snapshots of this World, and the World's own named branches (`volter world branch`),
431
+ * one of which, `checkedOut`, is the one this serve holds. */
432
+ type BranchesDoor = { branches: Branch[]; checkedOut?: string; named?: Array<{ name: string; running: boolean }> };
424
433
  /** What the World's session answered: its scope; `unknown` when the host has no session door (a write then says). */
425
434
  type Scope = 'pending' | 'read' | 'write' | 'unknown';
426
435
 
427
436
  // A World's page is laid out as Neon's console is (Volter is Neon for every SaaS, docs/concepts/the-model.md): the
428
437
  // World's own pages, the branch picker, then the branch's pages and its services, which here are its vendors, each a
429
438
  // sibling. All screens is a canvas app's page, the whole window. The older page names still open their page.
430
- const TABS = [['overview', 'Dashboard'], ['branches', 'Branches'], ['settings', 'Settings'], ['branch', 'Overview'], ['changes', 'Changes'], ['activity', 'Activity'], ['vendors', 'Vendors'], ['canvas', 'All screens']] as const;
439
+ const TABS = [['overview', 'Dashboard'], ['branches', 'Branches'], ['settings', 'Settings'], ['branch', 'Overview'], ['changes', 'Changes'], ['changesets', 'Changesets'], ['activity', 'Activity'], ['vendors', 'Vendors'], ['canvas', 'All screens']] as const;
431
440
  type Tab = (typeof TABS)[number][0];
432
441
  const TAB_ALIASES: Record<string, Tab> = { dashboard: 'overview', mirrors: 'vendors', screens: 'canvas', timeline: 'activity', monitoring: 'activity', trace: 'activity', operations: 'settings' };
433
442
  /** A vendor's own screens, at the twin's place in the World: the vendor's site (served-world.ts, the wire's page fallback). */
@@ -462,14 +471,17 @@ function WorldView({ name, token, twin, tab, arg, admin, snapshot, onReturn, ope
462
471
  const [scope, setScope] = useState<Scope>('pending');
463
472
  // why the session was not opened (the wall says it, with a way to try again), and that try
464
473
  const [sessionError, setSessionError] = useState<string | null>(null); const [attempt, setAttempt] = useState(0);
474
+ // whom an approval from this session is recorded as; null: the page names them (a session the World's token opened)
475
+ const [signsAs, setSignsAs] = useState<string | null>(null);
465
476
  // biome-ignore lint/correctness/useExhaustiveDependencies: `attempt` is the wall's "Try again"
466
477
  useEffect(() => {
467
478
  let live = true; setSessionError(null);
468
479
  void door(token, `/-/${name}/session`, { method: 'POST' }).then(async (r) => {
469
- const b = (await r.json().catch(() => ({}))) as { scope?: string; origin?: string; error?: string; who?: string; issuer?: string };
480
+ const b = (await r.json().catch(() => ({}))) as { scope?: string; origin?: string; error?: string; who?: string; issuer?: string; signsAs?: string | null };
470
481
  // the person a platform's pass opened this session for: the page says who is signed in, and the way back to it
471
482
  keepPortal(b.issuer);
472
483
  if (b.who) onWho?.(b.who);
484
+ setSignsAs(typeof b.signsAs === 'string' ? b.signsAs : null);
473
485
  // the World's session lives at its own origin: its pages go there
474
486
  // the host's own answer names the World's origin (a console apart is on another host and port): an http(s) origin only
475
487
  const own = httpOrigin(b.origin)?.origin;
@@ -493,7 +505,7 @@ function WorldView({ name, token, twin, tab, arg, admin, snapshot, onReturn, ope
493
505
  const [clockVersion, setClockVersion] = useState(0);
494
506
  const clock = useDoor<Clock>(token, `/-/${name}/clock`, tick + clockVersion);
495
507
  const twins = useDoor<{ twins: Twin[] }>(token, `/-/${name}/twins`);
496
- const branches = useDoor<{ branches: Branch[] }>(token, `/-/${name}/branches`, clockVersion);
508
+ const branches = useDoor<BranchesDoor>(token, `/-/${name}/branches`, clockVersion);
497
509
  const map = useDoor<WorldMap>(token, `/-/${name}/map`, Math.floor(tick / 2));
498
510
  // branches are the host's: a host that does not answer the door has no "as of", and the console shows none
499
511
  const hasBranches = !(branches.state === 'error' && (branches.status === 404 || branches.status === 405));
@@ -527,6 +539,7 @@ function WorldView({ name, token, twin, tab, arg, admin, snapshot, onReturn, ope
527
539
  <p className="side-group">Branch</p>
528
540
  {item('branch')}
529
541
  {item('changes')}
542
+ {item('changesets')}
530
543
  {item('activity')}
531
544
  <p className="side-group">Vendors</p>
532
545
  <a href={`${BASE}/${name}/~/canvas`} className="all-screens" onClick={open(['~', 'canvas'])}><span className="mark" aria-hidden="true">▦</span><span>All screens</span></a>
@@ -559,6 +572,7 @@ function WorldView({ name, token, twin, tab, arg, admin, snapshot, onReturn, ope
559
572
  {current === 'activity' && tab === 'trace' ? <Trace name={name} token={token} id={arg ?? ''} /> : null}
560
573
  {current === 'activity' && tab !== 'trace' ? <TimelineTab name={name} token={token} vendors={vendors} /> : null}
561
574
  {current === 'changes' ? <Changes name={name} token={token} /> : null}
575
+ {current === 'changesets' ? <Changesets name={name} token={token} signsAs={signsAs} canWrite={canWrite && !snapshot} rooted={map.state === 'ok' ? map.data.twins.filter((t) => t.root).map((t) => t.twin) : []} {...(arg ? { open: arg } : {})} refused={refused} /> : null}
562
576
  {current === 'branches' ? <Overview name={name} token={token} canWrite={canWrite} branches={hasBranches ? branches : null} opens={opens} onBranchesChanged={() => setClockVersion((v) => v + 1)} refused={refused} /> : null}
563
577
  {current === 'settings' ? <Operations name={name} token={token} admin={admin} canWrite={canWrite} refused={refused} onRemoved={onRemoved} /> : null}
564
578
  </div>
@@ -602,13 +616,13 @@ function Connect({ name }: { name: string }): React.JSX.Element {
602
616
 
603
617
  /** The branch picker, between the World's pages and the branch's (where Neon's sits): this World live, or one of the
604
618
  * host's branches of it; a snapshot names its parent as the way back. */
605
- function BranchPicker({ name, snapshot, branches, opens }: { name: string; snapshot: Snapshot | null; branches: Load<{ branches: Branch[] }> | null; opens: (name: string) => boolean }): React.JSX.Element {
619
+ function BranchPicker({ name, snapshot, branches, opens }: { name: string; snapshot: Snapshot | null; branches: Load<BranchesDoor> | null; opens: (name: string) => boolean }): React.JSX.Element {
606
620
  const short = (n: string): string => n.split('/').pop() ?? n;
607
621
  const live = snapshot?.parent ?? name;
608
622
  const others = branches?.state === 'ok' ? branches.data.branches.filter((b) => b.name !== name) : [];
609
623
  return (
610
624
  <details className="menu branch-picker">
611
- <summary><svg className="branch-glyph" viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><path fill="currentColor" d="M5 3.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Zm0 2.122a2.25 2.25 0 1 0-1.5 0v5.256a2.25 2.25 0 1 0 1.5 0V9.5h4.25A2.25 2.25 0 0 0 11.5 7.25v-.878a2.25 2.25 0 1 0-1.5 0v.878a.75.75 0 0 1-.75.75H5V5.372ZM4.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm6.5-9.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z" /></svg><span className="branch-name">{snapshot ? short(name) : short(live)}</span><span className="badge">{snapshot ? 'snapshot' : 'live'}</span></summary>
625
+ <summary><svg className="branch-glyph" viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><path fill="currentColor" d="M5 3.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Zm0 2.122a2.25 2.25 0 1 0-1.5 0v5.256a2.25 2.25 0 1 0 1.5 0V9.5h4.25A2.25 2.25 0 0 0 11.5 7.25v-.878a2.25 2.25 0 1 0-1.5 0v.878a.75.75 0 0 1-.75.75H5V5.372ZM4.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm6.5-9.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z" /></svg><span className="branch-name">{snapshot ? short(name) : (branches?.state === 'ok' && branches.data.checkedOut) || short(live)}</span><span className="badge">{snapshot ? 'snapshot' : 'live'}</span></summary>
612
626
  <div className="popover">
613
627
  <p className="mode">Branch</p>
614
628
  <ul className="branch-list">
@@ -616,11 +630,21 @@ function BranchPicker({ name, snapshot, branches, opens }: { name: string; snaps
616
630
  {snapshot ? <li><a href={`${BASE}/${name}`} aria-current="page" onClick={(e) => e.preventDefault()}>{short(name)} <span className="mode">as of <At iso={snapshot.at} /></span></a></li> : null}
617
631
  {others.map((b) => (
618
632
  <li key={b.name}>{opens(b.name)
619
- ? <a href={`${BASE}/${b.name}`} onClick={(e) => { e.preventDefault(); go(b.name.split('/')); }}>{short(b.name)} <span className="mode">{b.at?.instant ? <>as of <At iso={b.at.instant} /></> : 'copy'}</span></a>
633
+ ? <a href={`${BASE}/${b.name}`} onClick={(e) => { e.preventDefault(); goBranch(b.name, b.name.split('/')); }}>{short(b.name)} <span className="mode">{b.at?.instant ? <>as of <At iso={b.at.instant} /></> : 'copy'}</span></a>
620
634
  : <span className="mode" title="Its token was handed to whoever made it">{short(b.name)}</span>}</li>
621
635
  ))}
622
636
  </ul>
623
- {branches?.state === 'ok' && others.length === 0 && !snapshot ? <p className="mode">No branches yet. “View an earlier moment…” opens one.</p> : null}
637
+ {branches?.state === 'ok' && (branches.data.named?.length ?? 0) > 1 ? (
638
+ <>
639
+ <p className="mode">This World's branches</p>
640
+ <ul className="branch-list">
641
+ {branches.data.named!.map((b) => (
642
+ <li key={b.name}><span aria-current={b.name === branches.data.checkedOut ? 'true' : undefined}>{b.name} <span className="mode">{b.name === branches.data.checkedOut ? 'served here' : <code>volter world checkout {b.name}</code>}</span></span></li>
643
+ ))}
644
+ </ul>
645
+ </>
646
+ ) : null}
647
+ {branches?.state === 'ok' && others.length === 0 && !snapshot && (branches.data.named?.length ?? 0) <= 1 ? <p className="mode">No branches yet. “View an earlier moment…” opens one.</p> : null}
624
648
  </div>
625
649
  </details>
626
650
  );
@@ -689,7 +713,7 @@ function BranchOverview({ name, token, map, vendors, tick }: { name: string; tok
689
713
  * Neon with how to connect and the branches. A World's live preview is its vendors' own screens; beside them, how an
690
714
  * app connects and the branches; under them, what just happened. Counts sit with what they count, not in a row. */
691
715
  const THUMB_SCALE = 0.2;
692
- function WorldOverview({ name, token, map, vendors, twins, session, branches, canWrite, tick, snapshotOf }: { name: string; token: string; map: Load<WorldMap>; vendors: string[]; twins: Load<{ twins: Twin[] }>; session: MirrorSession; branches: Load<{ branches: Branch[] }> | null; canWrite: boolean; tick: number; /** the World this one is a snapshot of, when it is one */ snapshotOf?: string }): React.JSX.Element {
716
+ function WorldOverview({ name, token, map, vendors, twins, session, branches, canWrite, tick, snapshotOf }: { name: string; token: string; map: Load<WorldMap>; vendors: string[]; twins: Load<{ twins: Twin[] }>; session: MirrorSession; branches: Load<BranchesDoor> | null; canWrite: boolean; tick: number; /** the World this one is a snapshot of, when it is one */ snapshotOf?: string }): React.JSX.Element {
693
717
  const diff = useDoor<Diff>(token, `/-/${name}/diff`, Math.floor(tick / 2));
694
718
  const recent = useDoor<Timeline>(token, `/-/${name}/timeline?limit=8`, tick);
695
719
  const rows = map.state === 'ok' ? map.data.twins : [];
@@ -856,7 +880,7 @@ function AsOf({ name, token, clock, refused }: { name: string; token: string; cl
856
880
  }
857
881
 
858
882
  /** Overview: what the World holds, one card per twin (a resource explorer), and the branches cut from it. */
859
- function Overview({ name, token, canWrite, branches, opens, onBranchesChanged, refused }: { name: string; token: string; canWrite: boolean; branches: Load<{ branches: Branch[] }> | null; opens: (name: string) => boolean; onBranchesChanged: () => void; refused: (r: Response) => void }): React.JSX.Element {
883
+ function Overview({ name, token, canWrite, branches, opens, onBranchesChanged, refused }: { name: string; token: string; canWrite: boolean; branches: Load<BranchesDoor> | null; opens: (name: string) => boolean; onBranchesChanged: () => void; refused: (r: Response) => void }): React.JSX.Element {
860
884
  const map = useDoor<WorldMap>(token, `/-/${name}/map`);
861
885
  const [note, setNote] = useState<string | null>(null);
862
886
  const remove = async (b: string): Promise<void> => {
@@ -869,7 +893,7 @@ function Overview({ name, token, canWrite, branches, opens, onBranchesChanged, r
869
893
  const total = map.state === 'ok' ? map.data.twins.reduce((n, t) => n + t.resources.reduce((m, x) => m + x.count, 0), 0) : 0;
870
894
  // the Branches tab: this World's copies; what it holds per vendor is the Vendors tab's list (kept here for a host
871
895
  // without branches, where this tab is not shown)
872
- if (branches) return <BranchList name={name} token={token} canWrite={canWrite} branches={branches} opens={opens} note={note} remove={remove} />;
896
+ if (branches) return <BranchList name={name} token={token} canWrite={canWrite} branches={branches} opens={opens} note={note} remove={remove} onChanged={onBranchesChanged} refused={refused} />;
873
897
  return (
874
898
  <>
875
899
  <h3>Map</h3>
@@ -904,14 +928,57 @@ function Overview({ name, token, canWrite, branches, opens, onBranchesChanged, r
904
928
  );
905
929
  }
906
930
 
907
- /** A World's branches: copies of it, each as of a moment (a snapshot "as of" is one, short-lived), openable and removable. */
908
- function BranchList({ name, canWrite, branches, opens, note, remove }: { name: string; token: string; canWrite: boolean; branches: Load<{ branches: Branch[] }>; opens: (name: string) => boolean; note: string | null; remove: (b: string) => Promise<void> }): React.JSX.Element {
909
- // as Neon's Branches page: what a branch is for, then every branch with its parent, the moment it copies and its life
931
+ /** A World's branches, as a database's are (Neon's Branches page): each a copy of it as of a moment, made here (as of now
932
+ * or a moment you pick, for as long as you say), opened read-only in this tab, compared with its parent (what it changed
933
+ * since it branched), reset to its parent as it is now, or removed. Each is the World's own branches door. */
934
+ function BranchList({ name, token, canWrite, branches, opens, note, remove, onChanged, refused }: { name: string; token: string; canWrite: boolean; branches: Load<BranchesDoor>; opens: (name: string) => boolean; note: string | null; remove: (b: string) => Promise<void>; onChanged: () => void; refused: (r: Response) => void }): React.JSX.Element {
910
935
  const short = (n: string): string => n.split('/').pop() ?? n;
911
936
  const list = branches.state === 'ok' ? branches.data.branches : [];
937
+ const [making, setMaking] = useState(false); const [busy, setBusy] = useState(false); const [error, setError] = useState<string | null>(null);
938
+ const [label, setLabel] = useState(''); const [when, setWhen] = useState<'now' | 'at'>('now'); const [at, setAt] = useState(''); const [hours, setHours] = useState('24');
939
+ // made here: kept with its tokens so it opens in this tab; not `made` (a snapshot's "Return to live" never removes it: it lives as long as it was given)
940
+ const make = async (body: Record<string, unknown>, opened: string): Promise<{ name: string } | string> => {
941
+ const r = await door(token, `/-/${name}/branches`, { method: 'POST', body: JSON.stringify(body) }).catch(() => null);
942
+ if (!r) return `${ORIGIN} did not answer`;
943
+ if (!r.ok) { refused(r); return await failure(r); }
944
+ const b = (await r.json()) as { name: string; token?: string; readToken: string; expiresAt: string | null; origin?: string };
945
+ keepSnapshot({ name: b.name, parent: name, token: b.token ?? '', readToken: b.readToken, at: opened, expiresAt: b.expiresAt ?? null, parentOrigin: ORIGIN, ...(b.origin ? { origin: b.origin } : {}) });
946
+ return b;
947
+ };
948
+ const create = async (e: React.FormEvent): Promise<void> => {
949
+ e.preventDefault(); setError(null);
950
+ const instant = when === 'at' ? new Date(at) : null;
951
+ if (instant && Number.isNaN(instant.getTime())) { setError('Pick a date and time, or make it as of now.'); return; }
952
+ if (instant && instant.getTime() > Date.now()) { setError('A branch is made as of now or a moment before it.'); return; }
953
+ const ttl = Math.round(Number(hours) * 3600);
954
+ setBusy(true);
955
+ const b = await make({ ttl, ...(instant ? { at: { instant: instant.toISOString() } } : { live: true }), ...(label.trim() ? { label: label.trim() } : {}) }, (instant ?? new Date()).toISOString());
956
+ setBusy(false);
957
+ if (typeof b === 'string') { setError(b); return; }
958
+ setMaking(false); setLabel(''); toast(`Made ${short(b.name)}`); onChanged();
959
+ };
960
+ const reset = async (b: Branch): Promise<void> => {
961
+ if (!(await confirmAct({ title: `Reset ${short(b.name)} to ${short(name)}?`, body: `A fresh copy of ${short(name)} as it is now replaces it, with the same label and the time it had left; what was written to it goes. The copy has a new address and new keys: an app, a CI job or a pull request's preview that holds this branch's must be given the new one.`, action: 'Reset branch', danger: true }))) return;
962
+ const left = b.expiresAt ? Math.max(3600, Math.round((Date.parse(b.expiresAt) - Date.now()) / 1000)) : 86_400;
963
+ // a branch is named `<parent>-<label>-<4 hex>`: its label is what lies between
964
+ const own = short(b.name).startsWith(`${short(name)}-`) ? short(b.name).slice(short(name).length + 1) : '';
965
+ const labelOf = /^([a-z0-9][a-z0-9-]*)-[0-9a-f]{4}$/.exec(own)?.[1];
966
+ const fresh = await make({ ttl: left, live: true, ...(labelOf && !labelOf.startsWith('at-') ? { label: labelOf } : {}) }, new Date().toISOString());
967
+ if (typeof fresh === 'string') { toast(`Reset ${short(b.name)}: ${fresh}`, 'error'); return; }
968
+ const r = await door(token, `/-/${name}/branches/${b.name}`, { method: 'DELETE' }).catch(() => null);
969
+ if (r?.ok) forgetSnapshot(b.name);
970
+ toast(r?.ok ? `Reset: ${short(fresh.name)} is ${short(name)} as it is now` : `Made ${short(fresh.name)}; ${short(b.name)} was not removed`); onChanged();
971
+ };
972
+ const menu = (b: Branch): Array<{ label: string; onSelect: () => void; danger?: boolean }> => [
973
+ ...(opens(b.name) ? [{ label: 'Compare with parent', onSelect: () => goBranch(b.name, [...b.name.split('/'), '~', 'changes']) }] : []),
974
+ ...(canWrite ? [{ label: 'Reset to parent…', onSelect: () => void reset(b) }, { label: 'Delete…', danger: true, onSelect: () => void remove(b.name) }] : []),
975
+ ];
912
976
  return (
913
977
  <div aria-labelledby="branches">
914
- <p className="lede">Branch this World to look at it as it was, or to change it without touching <b>{short(name)}</b>. {canWrite ? '“View an earlier moment…” above makes one as the World was then.' : ''}</p>
978
+ <div className="branches-head">
979
+ <p className="lede">Branch this World to look at it as it was, or to change it without touching <b>{short(name)}</b>. A branch made here opens read-only in this tab; its parent removes it.</p>
980
+ {canWrite ? <button type="button" className="primary" onClick={() => { setError(null); setAt(localInput(new Date().toISOString())); setMaking(true); }}>New branch</button> : null}
981
+ </div>
915
982
  {note ? <p role="status">{note}</p> : null}
916
983
  <Pending load={branches} what="the branches" />
917
984
  {branches.state === 'ok' ? (
@@ -919,25 +986,58 @@ function BranchList({ name, canWrite, branches, opens, note, remove }: { name: s
919
986
  <table className="dense" aria-label="Branches">
920
987
  <thead><tr><th id="branches">Branch</th><th>Parent</th><th>As of</th><th>Made</th><th>Goes away</th><th /></tr></thead>
921
988
  <tbody>
922
- <tr><td><b>{short(name)}</b> <span className="badge">default</span></td><td className="mode">—</td><td className="mode">live</td><td className="mode">—</td><td className="mode">kept</td><td /></tr>
923
- {list.map((b) => (
989
+ <tr><td><b>{branches.data.checkedOut ?? short(name)}</b> <span className="badge">{branches.data.checkedOut ? 'served here' : 'default'}</span></td><td className="mode">—</td><td className="mode">live</td><td className="mode">—</td><td className="mode">kept</td><td /></tr>
990
+ {list.map((b) => {
991
+ const items = menu(b);
992
+ return (
993
+ <tr key={b.name}>
994
+ <td><b>{short(b.name)}</b></td>
995
+ <td>{short(name)}</td>
996
+ <td>{b.at?.instant ? <At iso={b.at.instant} /> : 'when it was made'}</td>
997
+ <td><At iso={b.createdAt} /></td>
998
+ <td>{b.expiresAt ? <At iso={b.expiresAt} /> : 'kept'}</td>
999
+ <td className="num row-actions">
1000
+ {opens(b.name) ? <button type="button" aria-label={`Open ${b.name}`} onClick={() => goBranch(b.name, b.name.split('/'))}>Open</button> : <span className="mode" title="Its token was handed to whoever made it">opened by whoever made it</span>}
1001
+ {items.length ? <Menu label={`More for ${short(b.name)}`} items={items} /> : null}
1002
+ </td>
1003
+ </tr>
1004
+ );
1005
+ })}
1006
+ </tbody>
1007
+ </table>
1008
+ {list.length === 0 ? <p className="mode pad">No snapshots yet.</p> : null}
1009
+ </div>
1010
+ ) : null}
1011
+ {branches.state === 'ok' && (branches.data.named?.length ?? 0) > 0 ? (
1012
+ <>
1013
+ <p className="lede">This World's own branches (<code>volter world branch</code>). One runs at a time; this page shows the one served here. Another is served by stopping this serve, <code>volter world checkout &lt;branch&gt;</code>, and serving again.</p>
1014
+ <div className="block">
1015
+ <table className="dense" aria-label="World branches">
1016
+ <thead><tr><th>Branch</th><th>State</th></tr></thead>
1017
+ <tbody>
1018
+ {branches.data.named!.map((b) => (
924
1019
  <tr key={b.name}>
925
- <td><b>{short(b.name)}</b></td>
926
- <td>{short(name)}</td>
927
- <td>{b.at?.instant ? <At iso={b.at.instant} /> : 'when it was made'}</td>
928
- <td><At iso={b.createdAt} /></td>
929
- <td>{b.expiresAt ? <At iso={b.expiresAt} /> : 'kept'}</td>
930
- <td className="num">
931
- {opens(b.name) ? <button type="button" aria-label={`Open ${b.name}`} onClick={() => go(b.name.split('/'))}>Open</button> : <span className="mode" title="Its token was handed to whoever made it">opened by whoever made it</span>}{' '}
932
- {canWrite ? <button type="button" aria-label={`Delete ${b.name}`} onClick={() => void remove(b.name)}>Delete</button> : null}
933
- </td>
1020
+ <td><b>{b.name}</b>{b.name === branches.data.checkedOut ? <> <span className="badge">served here</span></> : null}</td>
1021
+ <td className="mode">{b.running ? 'running' : 'stopped'}</td>
934
1022
  </tr>
935
1023
  ))}
936
1024
  </tbody>
937
1025
  </table>
938
- {list.length === 0 ? <p className="mode pad">No branches yet.</p> : null}
939
1026
  </div>
1027
+ </>
940
1028
  ) : null}
1029
+ <Dialog open={making} onClose={() => { if (!busy) setMaking(false); }} title={`New branch of ${short(name)}`} footer={<><button type="button" onClick={() => setMaking(false)} disabled={busy}>Cancel</button><button type="submit" form="new-branch" className="primary" disabled={busy}>{busy ? 'Making…' : 'Make branch'}</button></>}>
1030
+ <form id="new-branch" onSubmit={(e) => void create(e)} className="stack">
1031
+ <label><span>Name <span className="mode">(optional)</span></span><input name="branchLabel" value={label} onChange={(e) => setLabel(e.target.value.toLowerCase())} placeholder="checkout-flow" pattern="[a-z0-9][a-z0-9-]{0,23}" maxLength={24} /></label>
1032
+ <fieldset className="choice"><legend>As of</legend>
1033
+ <label className="check"><input type="radio" name="branchWhen" checked={when === 'now'} onChange={() => setWhen('now')} /> <span>Now: the World as it is, its clock running on</span></label>
1034
+ <label className="check"><input type="radio" name="branchWhen" checked={when === 'at'} onChange={() => setWhen('at')} /> <span>A moment before now: the World as it was then, its clock stopped there</span></label>
1035
+ {when === 'at' ? <input type="datetime-local" aria-label="As of" value={at} max={localInput(new Date().toISOString())} onChange={(e) => setAt(e.target.value)} /> : null}
1036
+ </fieldset>
1037
+ <label><span>Goes away after</span><select name="branchLife" value={hours} onChange={(e) => setHours(e.target.value)}><option value="1">1 hour</option><option value="24">1 day</option><option value="72">3 days</option><option value="168">7 days</option></select></label>
1038
+ {error ? <p role="alert">{error}</p> : null}
1039
+ </form>
1040
+ </Dialog>
941
1041
  </div>
942
1042
  );
943
1043
  }
@@ -1278,22 +1378,171 @@ function CanvasSurface({ name, twins, session, retry, snapshot, back }: { name:
1278
1378
  }
1279
1379
 
1280
1380
  /** What this World changed since its base — what a changeset would cut — in a compare view, by twin. */
1281
- function Changes({ name, token }: { name: string; token: string }): React.JSX.Element {
1282
- const diff = useDoor<Diff>(token, `/-/${name}/diff`);
1283
- const byTwin = useMemo(() => {
1284
- const m = new Map<string, Entry[]>();
1285
- if (diff.state === 'ok') for (const c of diff.data.changes) m.set(c.twin, [...(m.get(c.twin) ?? []), c.entry]);
1286
- return [...m.entries()];
1287
- }, [diff]);
1381
+ /** A changeset as the World's changesets door answers it: what it does at each vendor, the checks it ran, who signed
1382
+ * which bytes, and whether it went to the real vendors (with a receipt for each change). */
1383
+ type ChangesetView = {
1384
+ name: string; message?: string; narration?: string; createdAt: string; contentHash: string; /** the body's hash as it stands (GET …/changesets) */ currentHash?: string;
1385
+ actions: Array<{ service: string; action: Omit<Entry, 'service' | 'op'> & { op?: string; operation?: string } }>;
1386
+ verification: { at: string; contentHash: string; passed: boolean; results: Array<{ id: string; service: string; passed: boolean; actual?: unknown }>; refusals?: Array<{ service: string; check: string; reason: string }> } | null;
1387
+ approvals: Array<{ principal: string; at: string; contentHash: string; note?: string }>;
1388
+ applied: { at: string; outcome: 'applied' | 'partial' | 'refused'; refusal?: string[]; receipts: Array<{ actionId: string; service: string; status: string; externalId?: string; url?: string; error?: string }> } | null;
1389
+ };
1390
+ /** Its actions as the Changes view shows entries: by vendor. */
1391
+ const changesetGroups = (c: ChangesetView): Array<[string, Entry[]]> => {
1392
+ const m = new Map<string, Entry[]>();
1393
+ for (const a of c.actions) m.set(a.service, [...(m.get(a.service) ?? []), { ...a.action, service: a.service, op: a.action.op ?? a.action.operation ?? '' } as Entry]);
1394
+ return [...m.entries()];
1395
+ };
1396
+ /** Where it stands, in a word, and the state behind the word: a verification or an approval of other bytes is stale. */
1397
+ function changesetStatus(c: ChangesetView): { label: string; tone: 'done' | 'good' | 'warn' | 'bad' | 'plain' } {
1398
+ if (c.applied) return c.applied.outcome === 'applied' ? { label: 'Deployed', tone: 'done' } : c.applied.outcome === 'partial' ? { label: 'Partly deployed', tone: 'warn' } : { label: 'Deploy refused', tone: 'bad' };
1399
+ const now = c.currentHash ?? c.contentHash;
1400
+ const signed = c.approvals.filter((a) => a.contentHash === now).length;
1401
+ const checked = c.verification && c.verification.contentHash === now ? c.verification : null;
1402
+ if (checked && !checked.passed) return { label: 'Checks failed', tone: 'bad' };
1403
+ if (signed > 0) return { label: `Approved by ${signed}`, tone: 'good' };
1404
+ if (checked) return { label: 'Verified', tone: 'good' };
1405
+ return { label: 'Not verified', tone: 'plain' };
1406
+ }
1407
+
1408
+ /** THE CHANGESETS: what this World would do, cut into reviewable pieces, listed as a repository lists pull requests;
1409
+ * each one's page reviews it (its changes by vendor, its checks, its approvals) and, where the World has a real root,
1410
+ * deploys it. Every button is the World's own door; a refusal is the door's words. */
1411
+ function Changesets({ name, token, signsAs, canWrite, rooted, open: opened, refused }: { name: string; token: string; signsAs: string | null; canWrite: boolean; rooted: string[]; open?: string; refused: (r: Response) => void }): React.JSX.Element {
1412
+ const [version, setVersion] = useState(0);
1413
+ const list = useDoor<{ changesets: ChangesetView[] }>(token, `/-/${name}/changesets`, version);
1414
+ const at = (c: string) => (e: React.MouseEvent): void => { e.preventDefault(); go([...name.split('/'), '~', 'changesets', c]); };
1415
+ if (opened) {
1416
+ const c = list.state === 'ok' ? list.data.changesets.find((x) => x.name === opened) : undefined;
1417
+ return (
1418
+ <>
1419
+ <p className="crumbs-line"><a href={`${BASE}/${name}/~/changesets`} onClick={(e) => { e.preventDefault(); go([...name.split('/'), '~', 'changesets']); }}>← Changesets</a></p>
1420
+ <Pending load={list} what="the changesets" />
1421
+ {list.state === 'ok' && !c ? <p className="mode">This World has no changeset named {opened}.</p> : null}
1422
+ {c ? <ChangesetPage name={name} token={token} signsAs={signsAs} c={c} canWrite={canWrite} rooted={rooted} refused={refused} onChanged={() => setVersion((v) => v + 1)} /> : null}
1423
+ </>
1424
+ );
1425
+ }
1426
+ const rows = list.state === 'ok' ? [...list.data.changesets].sort((a, b) => b.createdAt.localeCompare(a.createdAt)) : [];
1288
1427
  return (
1289
1428
  <>
1290
- <Pending load={diff} what="the changes" />
1291
- {diff.state === 'ok' ? (
1429
+ <p className="lede">A changeset is what this World changed, cut into a piece a person reviews: what it does at each vendor, the World's checks over it, and who approved it. Where the World has a real account behind a vendor, an approved changeset deploys there, with a receipt for each change.</p>
1430
+ <Pending load={list} what="the changesets" />
1431
+ {list.state === 'ok' && rows.length === 0 ? (
1432
+ <Empty title="No changesets yet">
1433
+ <p>Cut one from what your app did in this World: <code>volter world changeset -m "What it does and why"</code>. Pushed to a shared World, it shows up here for review.</p>
1434
+ </Empty>
1435
+ ) : null}
1436
+ {rows.length > 0 ? (
1437
+ <div className="block">
1438
+ <table className="dense" aria-label="Changesets">
1439
+ <thead><tr><th>Changeset</th><th>Changes</th><th>Made</th><th>Status</th></tr></thead>
1440
+ <tbody>{rows.map((c) => {
1441
+ const s = changesetStatus(c);
1442
+ const counts = changesetGroups(c).map(([v, es]) => `${es.length} at ${vendorName(v)}`).join(' · ');
1443
+ return (
1444
+ <tr key={c.name}>
1445
+ <td><a href={`${BASE}/${name}/~/changesets/${c.name}`} onClick={at(c.name)}><b>{c.message ?? c.name}</b></a>{c.message ? <span className="mode mono"> {c.name}</span> : null}</td>
1446
+ <td className="mode">{counts || 'nothing'}</td>
1447
+ <td className="mode"><At iso={c.createdAt} /></td>
1448
+ <td><span className={`badge cs-${s.tone}`}>{s.label}</span></td>
1449
+ </tr>
1450
+ );
1451
+ })}</tbody>
1452
+ </table>
1453
+ </div>
1454
+ ) : null}
1455
+ </>
1456
+ );
1457
+ }
1458
+
1459
+ /** One changeset, reviewed: its words, its changes by vendor, its checks, its approvals and its receipts; Verify,
1460
+ * Approve (signed as the person this session is for) and Deploy where they apply, each saying why when it does not. */
1461
+ function ChangesetPage({ name, token, signsAs, c, canWrite, rooted, refused, onChanged }: { name: string; token: string; signsAs: string | null; c: ChangesetView; canWrite: boolean; rooted: string[]; refused: (r: Response) => void; onChanged: () => void }): React.JSX.Element {
1462
+ const [busy, setBusy] = useState<string | null>(null);
1463
+ const [approving, setApproving] = useState(false); const [note, setNote] = useState(''); const [as, setAs] = useState('');
1464
+ const s = changesetStatus(c);
1465
+ const groups = changesetGroups(c);
1466
+ const touches = groups.map(([v]) => v);
1467
+ const deployable = touches.some((v) => rooted.includes(v));
1468
+ const current = (h: string): boolean => h === (c.currentHash ?? c.contentHash);
1469
+ const act = async (what: string, path: string, body?: unknown, done?: string): Promise<boolean> => {
1470
+ setBusy(what);
1471
+ try {
1472
+ const r = await door(token, path, { method: 'POST', ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
1473
+ if (!r.ok) { refused(r); toast(`${what}: ${await failure(r)}`, 'error'); return false; }
1474
+ if (done) toast(done);
1475
+ onChanged(); return true;
1476
+ } catch (e) { toast(`${what}: the World did not answer (${e instanceof Error ? e.message : String(e)})`, 'error'); return false; } finally { setBusy(null); }
1477
+ };
1478
+ const verify = (): void => { void act('Verify', `/-/${name}/changesets/${c.name}/verify`, undefined, 'Verified: the checks ran over it'); };
1479
+ const approve = async (e: React.FormEvent): Promise<void> => { e.preventDefault(); if (await act('Approve', `/-/${name}/changesets/${c.name}/approve`, { ...(note.trim() ? { note: note.trim() } : {}), ...(signsAs === null && as.trim() ? { as: as.trim() } : {}) }, 'Approved')) { setApproving(false); setNote(''); } };
1480
+ const deploy = async (): Promise<void> => {
1481
+ if (!(await confirmAct({ title: `Deploy ${c.name} to the real vendors?`, body: <>It performs its {c.actions.length} change{c.actions.length === 1 ? '' : 's'} against the real account{touches.filter((v) => rooted.includes(v)).length === 1 ? '' : 's'} behind {listed(touches.filter((v) => rooted.includes(v)).map(vendorName))}. This cannot be taken back here.</>, action: 'Deploy', danger: true, typed: c.name }))) return;
1482
+ void act('Deploy', `/-/${name}/deploy/${c.name}`, { confirm: c.name }, 'Deployed: the receipts are below');
1483
+ };
1484
+ // why a button is off, in words: the reader's scope, what already happened, or the World's own rule
1485
+ const offWhy = !canWrite ? 'This session reads the World; it changes nothing' : c.applied ? 'Already deployed' : null;
1486
+ return (
1487
+ <div className="changeset" aria-labelledby="changeset-title">
1488
+ <div className="changeset-head">
1489
+ <div>
1490
+ <h3 id="changeset-title">{c.message ?? c.name} <span className={`badge cs-${s.tone}`}>{s.label}</span></h3>
1491
+ <p className="mode"><code>{c.name}</code> · made <At iso={c.createdAt} /> · {c.actions.length} change{c.actions.length === 1 ? '' : 's'} at {listed(touches.map(vendorName)) || 'no vendor'}</p>
1492
+ </div>
1493
+ <div className="changeset-actions">
1494
+ <button type="button" onClick={verify} disabled={Boolean(offWhy) || busy !== null} title={offWhy ?? 'Replay it in a throwaway World and run the World\'s checks'}>{busy === 'Verify' ? 'Verifying…' : 'Verify'}</button>
1495
+ <button type="button" onClick={() => setApproving(true)} disabled={Boolean(offWhy) || busy !== null} title={offWhy ?? 'Sign its current contents as yourself'}>Approve…</button>
1496
+ {deployable ? <button type="button" className="primary" onClick={() => void deploy()} disabled={Boolean(offWhy) || busy !== null} title={offWhy ?? 'Perform it against the real accounts behind this World'}>{busy === 'Deploy' ? 'Deploying…' : 'Deploy…'}</button> : null}
1497
+ </div>
1498
+ </div>
1499
+ {offWhy && !c.applied ? <p className="mode">{offWhy}.</p> : null}
1500
+ {!deployable && !c.applied ? <p className="mode">It stays in this World: no vendor it changes has a real account behind it here (a shared World with a root deploys; see Settings › Real services).</p> : null}
1501
+ {c.narration ? <p className="narration">{c.narration}</p> : null}
1502
+
1503
+ <h4>Changes</h4>
1504
+ {groups.length === 0 ? <p className="mode">It changes nothing.</p> : <ByVendor groups={groups} idPrefix={`cs-${c.name}`} />}
1505
+
1506
+ <h4>Checks</h4>
1507
+ {!c.verification ? <p className="mode">Not verified yet. Verify replays it in a throwaway World and runs this World's checks over the result.</p> : (
1508
+ <div className={current(c.verification.contentHash) ? '' : 'stale'}>
1509
+ <p>{c.verification.passed ? '✓ Passed' : '✗ Failed'} · <At iso={c.verification.at} />{current(c.verification.contentHash) ? null : <span className="mode"> · on an earlier version of it: verify again</span>}</p>
1510
+ {c.verification.results.length ? <ul className="checks">{c.verification.results.map((r) => <li key={r.id}>{r.passed ? '✓' : '✗'} <code>{r.id}</code> <span className="mode">{vendorName(r.service)}</span></li>)}</ul> : null}
1511
+ {c.verification.refusals?.length ? <ul className="checks">{c.verification.refusals.map((r, i) => <li key={i}>✗ <code>{r.check}</code> <span className="mode">{vendorName(r.service)}: {r.reason}</span></li>)}</ul> : null}
1512
+ </div>
1513
+ )}
1514
+
1515
+ <h4>Approvals</h4>
1516
+ {c.approvals.length === 0 ? <p className="mode">No one has approved it.</p> : (
1517
+ <ul className="approvals">{c.approvals.map((a, i) => <li key={i}><b>{a.principal}</b> <span className="mode"><At iso={a.at} />{current(a.contentHash) ? '' : ' · signed an earlier version'}</span>{a.note ? <div className="mode">“{a.note}”</div> : null}</li>)}</ul>
1518
+ )}
1519
+
1520
+ {c.applied ? (
1292
1521
  <>
1293
- <p className="mode">What this World changed since it started{diff.data.base.at ? <> (<At iso={diff.data.base.at} />, <code>{diff.data.base.id}</code>)</> : <> (<code>{diff.data.base.id}</code>)</>}: {diff.data.changes.length} change{diff.data.changes.length === 1 ? '' : 's'} at {byTwin.length} vendor{byTwin.length === 1 ? '' : 's'}. A changeset takes these to the real services.</p>
1294
- {byTwin.length === 0 ? <p className="empty">No changes since {diff.data.base.id}{diff.data.base.at ? <> (<At iso={diff.data.base.at} />)</> : null}.</p> : byTwin.map(([t, entries]) => (
1295
- <div key={t} className="compare" role="group" aria-labelledby={`changes-${t}`}>
1296
- <h3 id={`changes-${t}`} className="vendor-cell"><Mark vendor={t} />{vendorName(t)} <span className="mode">· {entries.length} change{entries.length === 1 ? '' : 's'}</span></h3>
1522
+ <h4>Deployed</h4>
1523
+ <p>{c.applied.outcome === 'applied' ? 'Every change reached the real vendors' : c.applied.outcome === 'partial' ? 'Some changes reached the real vendors' : 'The deploy was refused'} · <At iso={c.applied.at} /></p>
1524
+ {c.applied.refusal?.length ? <ul className="checks">{c.applied.refusal.map((r, i) => <li key={i} className="mode">{r}</li>)}</ul> : null}
1525
+ {c.applied.receipts.length ? <ul className="receipts">{c.applied.receipts.map((r) => <li key={r.actionId}><span className={r.status === 'failed' ? 'badge cs-bad' : 'badge cs-done'}>{r.status}</span> {vendorName(r.service)} {r.url ? <a href={r.url} target="_blank" rel="noopener noreferrer">{r.externalId ?? r.url}</a> : r.externalId ? <code>{r.externalId}</code> : null}{r.error ? <span className="mode"> · {r.error}</span> : null}</li>)}</ul> : null}
1526
+ </>
1527
+ ) : null}
1528
+
1529
+ <Dialog open={approving} onClose={() => { if (!busy) setApproving(false); }} title={`Approve ${c.name}`} footer={<><button type="button" onClick={() => setApproving(false)} disabled={busy !== null}>Cancel</button><button type="submit" form="approve-changeset" className="primary" disabled={busy !== null}>{busy === 'Approve' ? 'Approving…' : 'Approve'}</button></>}>
1530
+ <form id="approve-changeset" onSubmit={(e) => void approve(e)} className="stack">
1531
+ <p className="mode">You sign its contents as they stand now{signsAs ? <>, recorded as <b>{signsAs}</b></> : null}. A later change to it shows your approval as for an earlier version.</p>
1532
+ {signsAs === null ? <label><span>Approving as</span><input name="approveAs" value={as} onChange={(e) => setAs(e.target.value)} maxLength={80} required placeholder="your name or email" autoComplete="off" /><span className="mode">This World was opened with its token, which names no person: the approval records the name you give.</span></label> : null}
1533
+ <label><span>Note <span className="mode">(optional)</span></span><textarea name="approveNote" value={note} onChange={(e) => setNote(e.target.value)} rows={3} maxLength={500} /></label>
1534
+ </form>
1535
+ </Dialog>
1536
+ </div>
1537
+ );
1538
+ }
1539
+
1540
+ /** What a set of entries does at each vendor, grouped by vendor, runs of three alike folded: the Changes view's and a
1541
+ * changeset's page's one rendering. */
1542
+ function ByVendor({ groups, idPrefix }: { groups: Array<[string, Entry[]]>; idPrefix: string }): React.JSX.Element {
1543
+ return (<>{groups.map(([t, entries]) => (
1544
+ <div key={t} className="compare" role="group" aria-labelledby={`${idPrefix}-${t}`}>
1545
+ <h3 id={`${idPrefix}-${t}`} className="vendor-cell"><Mark vendor={t} />{vendorName(t)} <span className="mode">· {entries.length} change{entries.length === 1 ? '' : 's'}</span></h3>
1297
1546
  <ul>{runsOf(entries).map((run) => run.length >= 3 ? (
1298
1547
  <li key={run[0]!.id}>
1299
1548
  <details>
@@ -1310,7 +1559,23 @@ function Changes({ name, token }: { name: string; token: string }): React.JSX.El
1310
1559
  </li>
1311
1560
  )))}</ul>
1312
1561
  </div>
1313
- ))}
1562
+ ))}</>);
1563
+ }
1564
+
1565
+ function Changes({ name, token }: { name: string; token: string }): React.JSX.Element {
1566
+ const diff = useDoor<Diff>(token, `/-/${name}/diff`);
1567
+ const byTwin = useMemo(() => {
1568
+ const m = new Map<string, Entry[]>();
1569
+ if (diff.state === 'ok') for (const c of diff.data.changes) m.set(c.twin, [...(m.get(c.twin) ?? []), c.entry]);
1570
+ return [...m.entries()];
1571
+ }, [diff]);
1572
+ return (
1573
+ <>
1574
+ <Pending load={diff} what="the changes" />
1575
+ {diff.state === 'ok' ? (
1576
+ <>
1577
+ <p className="mode">What this World changed since it started{diff.data.base.at ? <> (<At iso={diff.data.base.at} />, <code>{diff.data.base.id}</code>)</> : <> (<code>{diff.data.base.id}</code>)</>}: {diff.data.changes.length} change{diff.data.changes.length === 1 ? '' : 's'} at {byTwin.length} vendor{byTwin.length === 1 ? '' : 's'}. A changeset takes these to the real services.</p>
1578
+ {byTwin.length === 0 ? <p className="empty">No changes since {diff.data.base.id}{diff.data.base.at ? <> (<At iso={diff.data.base.at} />)</> : null}.</p> : <ByVendor groups={byTwin} idPrefix="changes" />}
1314
1579
  </>
1315
1580
  ) : null}
1316
1581
  </>