diff --git a/docs/web-ui.md b/docs/web-ui.md
index 67b44b71..d2775493 100644
--- a/docs/web-ui.md
+++ b/docs/web-ui.md
@@ -143,6 +143,36 @@ Both bind their listeners with `SO_REUSEADDR` via
exponential backoff capped at 2s) so an nspawn restart that races
the previous process's socket release resolves itself.
+### Per-agent relative paths
+
+The per-agent UI uses **document-relative paths everywhere** for
+assets, API calls, form actions, and the screen WebSocket. Bare
+references like `static/app.js`, `api/state`, `events/stream`,
+`screen/ws` resolve against `document.baseURI` — the page's URL
+without its last path segment.
+
+That makes the page work under any prefix the agent ends up mounted
+at without rebuilding the dist. The cases that matter:
+
+| served at | `api/state` resolves to |
+|---|---|
+| `/` (own port, today's shape) | `/api/state` |
+| `/agent/iris/` (gateway-prefixed) | `/agent/iris/api/state` |
+| `/agent/iris/stats` (subpage, no trailing slash) | `/agent/iris/api/state` |
+
+The gateway upstream config strips the prefix before forwarding to
+the per-agent server, so the agent's Rust routes (`api/state`,
+`events/stream`, `screen/ws`, `login/start`, …) keep their absolute
+paths server-side. Only the browser-facing URLs are gated on the
+mount prefix.
+
+Subpages (`stats`, `screen`) are served without a trailing slash so
+the relative-path resolution stays correct: `static/app.js` from
+`/stats` becomes `/static/app.js` (last segment `stats` gets
+replaced), not `/stats/static/app.js`. Adding a trailing slash to
+those routes would break the resolution; either keep them
+slash-less or use `` injection at serve time.
+
## Dashboard layout
The dashboard (`/`) has a fixed chrome header at the top and a
diff --git a/frontend/packages/agent/src/app.js b/frontend/packages/agent/src/app.js
index f490f649..c2c4805a 100644
--- a/frontend/packages/agent/src/app.js
+++ b/frontend/packages/agent/src/app.js
@@ -330,7 +330,7 @@ window.marked = marked;
}),
);
const start = el('form', {
- action: '/login/start', method: 'POST', 'data-async': '',
+ action: 'login/start', method: 'POST', 'data-async': '',
});
start.append(
el('button', { type: 'submit', class: 'btn btn-login' }, '◆ ST4RT L0G1N'),
@@ -358,7 +358,7 @@ window.marked = marked;
}
if (!s.finished) {
const code = el('form', {
- action: '/login/code', method: 'POST', class: 'loginform', 'data-async': '',
+ action: 'login/code', method: 'POST', class: 'loginform', 'data-async': '',
});
// #568: OAuth code is a sensitive secret — mask the input with
// type="password" so a shoulder-surfer / screenshot doesn't
@@ -397,7 +397,7 @@ window.marked = marked;
root.append(code);
}
const cancel = el('form', {
- action: '/login/cancel', method: 'POST', 'data-async': '',
+ action: 'login/cancel', method: 'POST', 'data-async': '',
style: 'margin-top: 0.4em;',
});
cancel.append(el('button', { type: 'submit', class: 'btn btn-cancel' }, 'cancel + kill'));
@@ -437,7 +437,7 @@ window.marked = marked;
async function postModel(name) {
try {
- const resp = await fetch('/api/model', {
+ const resp = await fetch('api/model', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ model: name }),
@@ -469,10 +469,12 @@ window.marked = marked;
if (termAPI) termAPI.row('turn-end-fail', '✗ ' + label + ' failed: ' + err);
}
}
- const postCancelTurn = () => postSimple('/api/cancel', '/cancel');
- const postCompact = () => postSimple('/api/compact', '/compact');
- const postNewSession = () => postSimple('/api/new-session', '/new-session');
- const postLogout = () => postSimple('/api/logout', '/logout');
+ // First arg is the URL path (relative to document base, #14);
+ // second is the slash-command label rendered in the local note.
+ const postCancelTurn = () => postSimple('api/cancel', '/cancel');
+ const postCompact = () => postSimple('api/compact', '/compact');
+ const postNewSession = () => postSimple('api/new-session', '/new-session');
+ const postLogout = () => postSimple('api/logout', '/logout');
function handleSlashCommand(line) {
if (!termAPI) return false;
@@ -548,7 +550,7 @@ window.marked = marked;
if (!termInputRendered) {
slot.innerHTML = '';
const form = el('form', {
- action: '/send', method: 'POST',
+ action: 'send', method: 'POST',
class: 'sendform-term', 'data-async': '',
});
const ta = el('textarea', {
@@ -668,7 +670,7 @@ window.marked = marked;
// keeps the pill count at zero rather than surfacing a stale chrome.
async function refreshLooseEnds() {
try {
- const resp = await fetch('/api/loose-ends');
+ const resp = await fetch('api/loose-ends');
if (!resp.ok) {
renderLooseEnds([]);
return;
@@ -1084,7 +1086,7 @@ window.marked = marked;
async function refreshState() {
try {
- const resp = await fetch('/api/state');
+ const resp = await fetch('api/state');
if (!resp.ok) throw new Error('http ' + resp.status);
const s = await resp.json();
if (!headerSet) { setHeader(s.label, s.qualified_label, s.dashboard_port); headerSet = true; }
@@ -1516,8 +1518,10 @@ window.marked = marked;
// the composer. Geometry unchanged — `.agent-main` and
// `.terminal-wrap` both `inset: 0` fill the same area.
pillAnchor: $('agent-main'),
- historyUrl: '/events/history',
- streamUrl: '/events/stream',
+ // Path-relative so the page mounted under a nginx prefix
+ // (e.g. /agent//) still hits the right SSE upstream (#14).
+ historyUrl: 'events/history',
+ streamUrl: 'events/stream',
renderers: {
turn_start(ev, api) {
if (api.fromHistory) openTurnsFromHistory += 1;
diff --git a/frontend/packages/agent/src/index.html b/frontend/packages/agent/src/index.html
index f39065cc..a437fab7 100644
--- a/frontend/packages/agent/src/index.html
+++ b/frontend/packages/agent/src/index.html
@@ -3,8 +3,8 @@
hyperhive agent
-
-
+
+
@@ -14,9 +14,12 @@
height on the left as the identity anchor; flyout pills + an
overflow menu trigger sit on the right. Frosted glass over the
terminal — backdrop-filter blur shows the scrolled terminal
- text behind. -->
+ text behind.
+
+ All asset / API hrefs are relative — see docs/web-ui.md::Per-agent
+ relative paths for why (#14). -->