agents: shareable per-agent URLs (0.90.0)

opening an agent only lived in component state, so you couldn't link to one,
and a refresh dropped the open agent — inconsistent with the now-shareable
docs. each agent modal now has a URL: amerc.ai/#/agents/<slug>.

- the hash router keys off the first path segment, so #/agents/<slug> still
  routes to Browse while exposing the slug as a sub-path (backward compatible)
- opening a card pushes #/agents/<slug>; loading that URL opens the modal;
  closing returns to #/agents; browser back/forward open & close it
- verified live: click updates URL + opens, Escape closes to #/agents, a fresh
  deep-link opens 'Kebab Webagent', back closes

also recorded a project memory: sandbox->amerc network path is slow (~0.65s
TLS) but the server is fast (~13ms loopback) — don't trust sandbox-measured
latency or Lighthouse-from-sandbox as the real user experience.
This commit is contained in:
artheru 2026-06-10 13:23:35 +08:00
parent cd9c7f7e8c
commit 0fd1ed2b5c
4 changed files with 39 additions and 8 deletions

View File

@ -17,7 +17,7 @@
@media (prefers-reduced-motion: reduce) { .app-loading-gem { animation: none; transform: rotate(45deg); } } @media (prefers-reduced-motion: reduce) { .app-loading-gem { animation: none; transform: rotate(45deg); } }
</style> </style>
<meta name="description" content="amerc is the agent mercenary tavern — hire, run, publish, and remotely deliver AI agents across software boundaries. No infrastructure, just skills." /> <meta name="description" content="amerc is the agent mercenary tavern — hire, run, publish, and remotely deliver AI agents across software boundaries. No infrastructure, just skills." />
<meta name="version" content="0.89.0-changelog" /> <meta name="version" content="0.90.0-agenturls" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" /> <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="icon" href="/app-192.png" type="image/png" sizes="192x192" /> <link rel="icon" href="/app-192.png" type="image/png" sizes="192x192" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" /> <link rel="apple-touch-icon" href="/apple-touch-icon.png" />

View File

@ -1,6 +1,6 @@
{ {
"name": "amerc-site", "name": "amerc-site",
"version": "0.89.0-changelog", "version": "0.90.0-agenturls",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useState } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react';
import { api } from './api.js'; import { api } from './api.js';
import { copyToast } from './toast.js'; import { copyToast } from './toast.js';
@ -27,6 +27,36 @@ export function AgentBrowse({ auth, setRoute }) {
}, [tag, q]); }, [tag, q]);
useEffect(() => { load(); }, [load]); useEffect(() => { load(); }, [load]);
// shareable agent URLs: #/agents/<slug> opens that agent's modal (bookmark/share/refresh-safe)
const didInit = useRef(false);
const openClass = useCallback((c) => {
setOpen(c.id);
if (c.slug) window.history.pushState(null, '', `${window.location.pathname}${window.location.search}#/agents/${c.slug}`);
}, []);
const closeClass = useCallback(() => {
setOpen(null);
if (window.location.hash !== '#/agents') window.history.pushState(null, '', `${window.location.pathname}${window.location.search}#/agents`);
load();
}, [load]);
useEffect(() => {
if (didInit.current || !loaded) return;
didInit.current = true;
const slug = window.location.hash.replace(/^#\/?/, '').split('/')[1];
if (slug) { const hit = classes.find((x) => x.slug === slug); if (hit) setOpen(hit.id); }
}, [loaded, classes]);
useEffect(() => {
const onHash = () => {
const parts = window.location.hash.replace(/^#\/?/, '').split('/');
if (parts[0] !== 'agents') return;
if (!parts[1]) { setOpen(null); return; }
const hit = classes.find((x) => x.slug === parts[1]);
if (hit) setOpen(hit.id);
};
window.addEventListener('hashchange', onHash);
window.addEventListener('popstate', onHash);
return () => { window.removeEventListener('hashchange', onHash); window.removeEventListener('popstate', onHash); };
}, [classes]);
return ( return (
<div className="ap"> <div className="ap">
<div className="ap-store-head"> <div className="ap-store-head">
@ -52,7 +82,7 @@ export function AgentBrowse({ auth, setRoute }) {
</div> </div>
))} ))}
{loaded && classes.map((c) => ( {loaded && classes.map((c) => (
<button key={c.id} className="ap-card" onClick={() => setOpen(c.id)}> <button key={c.id} className="ap-card" onClick={() => openClass(c)}>
<div className="ap-card-head2"> <div className="ap-card-head2">
<span className="ap-avatar" style={avatarStyle(c.name)}>{classEmoji(c)}</span> <span className="ap-avatar" style={avatarStyle(c.name)}>{classEmoji(c)}</span>
<div className="ap-card-titles"><strong>{c.name}</strong><span className="ap-kind">{c.kind}</span></div> <div className="ap-card-titles"><strong>{c.name}</strong><span className="ap-kind">{c.kind}</span></div>
@ -93,7 +123,7 @@ export function AgentBrowse({ auth, setRoute }) {
</div> </div>
)} )}
</div> </div>
{open && <ClassModal id={open} auth={auth} onClose={() => { setOpen(null); load(); }} />} {open && <ClassModal id={open} auth={auth} onClose={closeClass} />}
</div> </div>
); );
} }

View File

@ -10,7 +10,7 @@ import { api } from './api.js';
const Menu = ({ size = 20 }) => (<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true"><line x1="3" y1="6" x2="21" y2="6" /><line x1="3" y1="12" x2="21" y2="12" /><line x1="3" y1="18" x2="21" y2="18" /></svg>); const Menu = ({ size = 20 }) => (<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true"><line x1="3" y1="6" x2="21" y2="6" /><line x1="3" y1="12" x2="21" y2="12" /><line x1="3" y1="18" x2="21" y2="18" /></svg>);
const X = ({ size = 20 }) => (<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /></svg>); const X = ({ size = 20 }) => (<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /></svg>);
const RELEASE = '0.89.0-changelog'; const RELEASE = '0.90.0-agenturls';
const NAV = [ const NAV = [
{ id: 'home', label: 'Tavern' }, { id: 'home', label: 'Tavern' },
@ -27,8 +27,9 @@ const ROUTE_TITLES = {
function useHashRoute() { function useHashRoute() {
const normalize = () => { const normalize = () => {
const raw = window.location.hash.replace(/^#\/?/, ''); // first path segment is the route; the rest (e.g. #/agents/<slug>) is a sub-path
return VALID_ROUTES.includes(raw) ? raw : 'home'; const base = window.location.hash.replace(/^#\/?/, '').split('/')[0];
return VALID_ROUTES.includes(base) ? base : 'home';
}; };
const [route, setRouteState] = useState(normalize); const [route, setRouteState] = useState(normalize);
useEffect(() => { useEffect(() => {