Skip to content

Commit d962df8

Browse files
committed
feat: share viz link button + viz-default replay share URLs
1 parent 2844f29 commit d962df8

9 files changed

Lines changed: 291 additions & 14 deletions

File tree

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -289,7 +289,11 @@ The CLI looks for `actors.json` via `--actors`, then `./actors.json`, then `./co
289289

290290
### Share a sim with a one-click link
291291

292-
Any saved `.json` run can be turned into a deep link that auto-loads in the dashboard. The link fetches the file, parses it, and lands the viewer on the tab you choose — no upload step, no preview modal.
292+
The dashboard ships two ways to produce a shareable run link, both landing the viewer on the visualization tab with no upload step.
293+
294+
**From the dashboard UI** — once a sim finishes (or while you're watching a stored replay), the TopBar overflow menu (⋯) shows **Share viz link**. Click to copy `paracosm.agentos.sh/sim?replay=<sessionId>&tab=viz` to the clipboard. The Quickstart actor cards have the same "Copy viz share link" button per actor; both routes hit the public `/sessions/:id/replay` SSE endpoint so the viewer streams the stored run directly.
295+
296+
**By URL construction** — any remote `.json` save can be turned into a deep link that auto-fetches, parses, and lands on the tab you choose. No upload step, no preview modal.
293297

294298
```
295299
https://paracosm.agentos.sh/sim?load=<remote-json-url>&tab=viz&autoload=1

docs/COOKBOOK.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -763,6 +763,13 @@ This is also the path the dashboard's Studio tab uses when a user drops a `.json
763763
764764
A saved `.json` run can be turned into a shareable URL that auto-fetches the file and lands the viewer directly on a chosen tab. The exchange runs entirely client-side: the dashboard fetches the URL, parses it through the same `fromJson` path as a manual drop, and switches tabs without a server roundtrip. Designed for one-click posts to subreddits like r/dataisbeautiful or r/internetisbeautiful where the audience won't tolerate an upload step.
765765
766+
Two complementary surfaces produce these links from inside the dashboard so you don't have to construct them by hand:
767+
768+
- **TopBar ⋯ menu → Share viz link** — appears whenever the current run has a server-stored session id (either the sim just finished and `sim_saved` landed, or the dashboard is replaying a previously-shared link). Copies `paracosm.agentos.sh/sim?replay=<sessionId>&tab=viz`.
769+
- **Quickstart actor card → Copy viz share link** — per-actor button on the [Quickstart results](../src/dashboard/src/components/quickstart/QuickstartResults.tsx) panel. Same URL shape, different invocation point.
770+
771+
Both routes go through the public `/sessions/:id/replay` SSE endpoint, so the viewer streams the stored run live; the recipient does not need to download a `.json` first. The `?load=` URL shape below is the fallback for runs that exist as a static remote JSON instead of a server session.
772+
766773
### Input
767774
768775
```

src/dashboard/src/App.tsx

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ import {
1313
parseDestinationTabParam,
1414
parseLoadUrlParam,
1515
} from './hooks/useLoadFromUrl.helpers';
16+
import {
17+
buildReplayShareUrl,
18+
findLatestSavedSessionId,
19+
} from './hooks/shareUrl.helpers';
1620
import { useDashboardDropZone } from './hooks/useDashboardDropZone';
1721
import { LoadPreviewModal } from './components/layout/LoadPreviewModal';
1822
import { DropZoneOverlay } from './components/layout/DropZoneOverlay';
@@ -593,6 +597,28 @@ function AppContent() {
593597
});
594598
}, [gameState, scenario, toast]);
595599

600+
// Sharable session id for the currently visible run. Two sources:
601+
// 1. The replay query param when the user landed via a share link.
602+
// 2. The latest `sim_saved` event with status='saved' from the
603+
// server's autoSaveOnComplete pass.
604+
// Stays null until one of those exists so the TopBar's Share menu
605+
// item stays hidden for fresh / in-flight sims that haven't yet
606+
// produced a sharable id.
607+
const currentSessionId = replaySessionId ?? findLatestSavedSessionId(sse.events);
608+
609+
// Copy a deep link that opens the current run on the viz tab. Used
610+
// for social shares (r/dataisbeautiful etc.) where viewers click
611+
// straight through to the swarm visualization with no upload step.
612+
const handleShareViz = useCallback(() => {
613+
if (!currentSessionId) return;
614+
const url = buildReplayShareUrl(window.location.origin, currentSessionId, 'viz');
615+
navigator.clipboard.writeText(url).then(() => {
616+
toast('success', 'Share link copied', 'Anyone with the link opens this run on the viz tab.');
617+
}).catch(() => {
618+
toast('error', 'Copy Failed', 'Clipboard access denied.');
619+
});
620+
}, [currentSessionId, toast]);
621+
596622
// App-level "launching" state: persists across tab navigation so the
597623
// user can submit /setup, switch to viz/chat/etc., come back to sim,
598624
// and still see the spinner instead of the empty-state Run button.
@@ -867,6 +893,7 @@ function AppContent() {
867893
onRun={handleRun}
868894
onTour={handleTourStart}
869895
onCopy={handleCopySummary}
896+
onShareViz={currentSessionId ? handleShareViz : undefined}
870897
launching={launching}
871898
history={history.entries}
872899
onRestoreHistory={(entry) => history.restore(entry, sse.loadEvents)}

src/dashboard/src/components/layout/TopBar.tsx

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,14 @@ interface TopBarProps {
2626
onRun?: () => void;
2727
onTour?: () => void;
2828
onCopy?: () => void;
29+
/**
30+
* Build + copy a deep link to the current run that opens on the viz
31+
* tab. Wired only when a `sim_saved` server event has landed during
32+
* this session (or the dashboard is currently replaying a stored
33+
* session); the menu item is hidden when no sharable session id
34+
* exists, so the handler doesn't have to defend against null ids.
35+
*/
36+
onShareViz?: () => void;
2937
/** F14 local-history props, forwarded to the RunMenu's history section. */
3038
history?: LocalHistoryEntry[];
3139
onRestoreHistory?: (entry: LocalHistoryEntry) => void;
@@ -58,7 +66,7 @@ function ParacosmLogo({ size = 20 }: { size?: number }) {
5866
);
5967
}
6068

61-
export function TopBar({ scenario, sse, gameState, onSave, onLoad, onClear, onRun, onTour, onCopy, launching = false, history, onRestoreHistory, onClearHistory }: TopBarProps) {
69+
export function TopBar({ scenario, sse, gameState, onSave, onLoad, onClear, onRun, onTour, onCopy, onShareViz, launching = false, history, onRestoreHistory, onClearHistory }: TopBarProps) {
6270
const { resolved, setTheme } = useTheme();
6371
const hasEvents = Object.values(gameState.actors).some((s: ActorSideState) => s.events.length > 0);
6472

@@ -392,15 +400,15 @@ export function TopBar({ scenario, sse, gameState, onSave, onLoad, onClear, onRu
392400
stored runs + sessions + output files even with an empty
393401
local buffer. Save/Copy still require an active run; their
394402
individual buttons inside the menu are gated separately. */}
395-
{((hasEvents && (onSave || onCopy)) || onClear) && (
403+
{((hasEvents && (onSave || onCopy || onShareViz)) || onClear) && (
396404
<div ref={overflowRootRef} className={styles.overflowAnchor}>
397405
<button
398406
type="button"
399407
onClick={() => setOverflowOpen(o => !o)}
400408
aria-haspopup="menu"
401409
aria-expanded={overflowOpen}
402410
aria-label={overflowOpen ? 'Close run actions' : 'Open run actions menu'}
403-
title="Save · Copy · Wipe"
411+
title="Save · Share · Copy · Wipe"
404412
className={`${styles.toolBtn} ${styles.overflowTrigger}`}
405413
>
406414
@@ -423,6 +431,17 @@ export function TopBar({ scenario, sse, gameState, onSave, onLoad, onClear, onRu
423431
Save
424432
</button>
425433
)}
434+
{hasEvents && onShareViz && (
435+
<button
436+
role="menuitem"
437+
type="button"
438+
onClick={() => { setOverflowOpen(false); onShareViz(); }}
439+
className={styles.overflowItem}
440+
title="Copy a deep link that opens this run on the visualization tab"
441+
>
442+
Share viz link
443+
</button>
444+
)}
426445
{hasEvents && onCopy && (
427446
<button
428447
role="menuitem"

src/dashboard/src/components/quickstart/QuickstartResults.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -236,8 +236,13 @@ function ActorResultCard({
236236
)}
237237
<div className={styles.actions}>
238238
<button type="button" onClick={onDownload}>Download JSON</button>
239-
<button type="button" onClick={onShare} disabled={!shareEnabled}>
240-
{copiedHere ? 'Copied!' : 'Copy share link'}
239+
<button
240+
type="button"
241+
onClick={onShare}
242+
disabled={!shareEnabled}
243+
title="Copy a deep link that opens this run on the visualization tab"
244+
>
245+
{copiedHere ? 'Copied!' : 'Copy viz share link'}
241246
</button>
242247
<button
243248
type="button"

src/dashboard/src/components/quickstart/QuickstartView.helpers.test.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,12 @@ test('computeMedianDeltas: identical values omitted', () => {
8989
assert.deepEqual(computeMedianDeltas(a, [b]), []);
9090
});
9191

92-
test('buildQuickstartShareUrl: formats correctly', () => {
92+
test('buildQuickstartShareUrl: defaults to viz tab', () => {
9393
const url = buildQuickstartShareUrl('https://paracosm.agentos.sh', 'abc123');
94-
assert.match(url, /\/sim\?replay=abc123&view=quickstart$/);
94+
assert.equal(url, 'https://paracosm.agentos.sh/sim?replay=abc123&tab=viz');
95+
});
96+
97+
test('buildQuickstartShareUrl: honors explicit quickstart tab', () => {
98+
const url = buildQuickstartShareUrl('https://paracosm.agentos.sh', 'abc123', 'quickstart');
99+
assert.equal(url, 'https://paracosm.agentos.sh/sim?replay=abc123&tab=quickstart');
95100
});

src/dashboard/src/components/quickstart/QuickstartView.helpers.ts

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
*/
66
import type { RunArtifact } from '../../../../engine/schema/index.js';
77
import type { BranchDelta } from '../branches/BranchesTab.helpers.js';
8+
import { buildReplayShareUrl, type ShareTargetTab } from '../../hooks/shareUrl.helpers';
89

910
export interface SeedUrlValidation {
1011
ok: true;
@@ -113,12 +114,19 @@ export function computeMedianDeltas(artifact: RunArtifact, peers: RunArtifact[])
113114
});
114115
}
115116

116-
/** Build the shareable replay URL for a completed Quickstart session. */
117-
export function buildQuickstartShareUrl(origin: string, sessionId: string): string {
118-
const url = new URL('/sim', origin);
119-
url.searchParams.set('replay', sessionId);
120-
url.searchParams.set('view', 'quickstart');
121-
return url.toString();
117+
/**
118+
* Build the shareable replay URL for a completed Quickstart session.
119+
* `tab` defaults to `'viz'` so social-share clicks land on the
120+
* visually compelling swarm visualization (r/dataisbeautiful etc.).
121+
* Pass `'quickstart'` explicitly when the share should reopen on the
122+
* Quickstart actor cards instead.
123+
*/
124+
export function buildQuickstartShareUrl(
125+
origin: string,
126+
sessionId: string,
127+
tab: ShareTargetTab = 'viz',
128+
): string {
129+
return buildReplayShareUrl(origin, sessionId, tab);
122130
}
123131

124132
/**
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
/**
2+
* Pure-logic tests for share-URL helpers. URL construction and
3+
* sim_saved event scanning live here so they run under node:test
4+
* without a browser shim.
5+
*/
6+
import test from 'node:test';
7+
import assert from 'node:assert/strict';
8+
import {
9+
buildReplayShareUrl,
10+
findLatestSavedSessionId,
11+
} from './shareUrl.helpers.js';
12+
import type { SimEvent } from './useSSE';
13+
14+
// -- buildReplayShareUrl --------------------------------------------------
15+
16+
test('buildReplayShareUrl: defaults to viz tab', () => {
17+
const url = buildReplayShareUrl('https://paracosm.agentos.sh', 'abc123');
18+
assert.equal(url, 'https://paracosm.agentos.sh/sim?replay=abc123&tab=viz');
19+
});
20+
21+
test('buildReplayShareUrl: honors explicit sim tab', () => {
22+
const url = buildReplayShareUrl('https://paracosm.agentos.sh', 'abc123', 'sim');
23+
assert.equal(url, 'https://paracosm.agentos.sh/sim?replay=abc123&tab=sim');
24+
});
25+
26+
test('buildReplayShareUrl: honors reports tab', () => {
27+
const url = buildReplayShareUrl('https://paracosm.agentos.sh', 'abc123', 'reports');
28+
assert.equal(url, 'https://paracosm.agentos.sh/sim?replay=abc123&tab=reports');
29+
});
30+
31+
test('buildReplayShareUrl: handles origin with trailing slash', () => {
32+
const url = buildReplayShareUrl('https://paracosm.agentos.sh/', 'abc123');
33+
assert.equal(url, 'https://paracosm.agentos.sh/sim?replay=abc123&tab=viz');
34+
});
35+
36+
test('buildReplayShareUrl: encodes session id with special chars', () => {
37+
const url = buildReplayShareUrl('https://paracosm.agentos.sh', 'id with spaces');
38+
assert.match(url, /replay=id\+with\+spaces|replay=id%20with%20spaces/);
39+
});
40+
41+
test('buildReplayShareUrl: works for localhost dev origin', () => {
42+
const url = buildReplayShareUrl('http://localhost:3456', 'abc123');
43+
assert.equal(url, 'http://localhost:3456/sim?replay=abc123&tab=viz');
44+
});
45+
46+
// -- findLatestSavedSessionId --------------------------------------------
47+
48+
function ev(type: string, data: Record<string, unknown> = {}): SimEvent {
49+
return { type, leader: '', data } as SimEvent;
50+
}
51+
52+
test('findLatestSavedSessionId: returns id from sim_saved with status=saved', () => {
53+
const events = [
54+
ev('turn_start'),
55+
ev('sim_saved', { status: 'saved', id: 'sess_abc' }),
56+
];
57+
assert.equal(findLatestSavedSessionId(events), 'sess_abc');
58+
});
59+
60+
test('findLatestSavedSessionId: returns null when no sim_saved event', () => {
61+
const events = [ev('turn_start'), ev('decision')];
62+
assert.equal(findLatestSavedSessionId(events), null);
63+
});
64+
65+
test('findLatestSavedSessionId: returns null when sim_saved status is failed', () => {
66+
const events = [ev('sim_saved', { status: 'failed', error: 'disk full' })];
67+
assert.equal(findLatestSavedSessionId(events), null);
68+
});
69+
70+
test('findLatestSavedSessionId: returns null when sim_saved status is skipped', () => {
71+
const events = [ev('sim_saved', { status: 'skipped', reason: 'below_min_turns' })];
72+
assert.equal(findLatestSavedSessionId(events), null);
73+
});
74+
75+
test('findLatestSavedSessionId: returns newest id when multiple saves present', () => {
76+
const events = [
77+
ev('sim_saved', { status: 'saved', id: 'sess_old' }),
78+
ev('turn_start'),
79+
ev('sim_saved', { status: 'saved', id: 'sess_new' }),
80+
];
81+
assert.equal(findLatestSavedSessionId(events), 'sess_new');
82+
});
83+
84+
test('findLatestSavedSessionId: ignores sim_saved without id', () => {
85+
const events = [ev('sim_saved', { status: 'saved' })];
86+
assert.equal(findLatestSavedSessionId(events), null);
87+
});
88+
89+
test('findLatestSavedSessionId: ignores sim_saved with non-string id', () => {
90+
const events = [ev('sim_saved', { status: 'saved', id: 12345 })];
91+
assert.equal(findLatestSavedSessionId(events), null);
92+
});
93+
94+
test('findLatestSavedSessionId: ignores sim_saved with empty string id', () => {
95+
const events = [ev('sim_saved', { status: 'saved', id: '' })];
96+
assert.equal(findLatestSavedSessionId(events), null);
97+
});
98+
99+
test('findLatestSavedSessionId: empty event list -> null', () => {
100+
assert.equal(findLatestSavedSessionId([]), null);
101+
});
102+
103+
test('findLatestSavedSessionId: skips failed save and returns prior saved id', () => {
104+
const events = [
105+
ev('sim_saved', { status: 'saved', id: 'sess_first' }),
106+
ev('sim_saved', { status: 'failed', error: 'retry' }),
107+
];
108+
// Newest-wins iteration: failed save short-circuits the continue,
109+
// then the earlier saved entry is returned.
110+
assert.equal(findLatestSavedSessionId(events), 'sess_first');
111+
});

0 commit comments

Comments
 (0)