agent UI: relative paths for all assets/api/ws so the page works under any nginx prefix (#14)

Per mara on #14: 'make agent page not assume root path, links / api
calls need to be relative'. atlas's nginx side (#15) will mount the
per-agent UI at a prefix like /agent/<name>/ instead of its own
port; for the page to keep working under that prefix, every
in-page reference needs to resolve document-relative rather than
root-anchored.

Converted in this pass:
- HTML <link>/<script>/<img>/<a> hrefs in index.html, stats.html,
  screen.html: '/icon' → 'icon', '/static/agent.css' →
  'static/agent.css', back links '/' → './'.
- app.js fetch() targets ('/api/state' → 'api/state', /api/cancel,
  /api/loose-ends, etc.), form actions ('/login/start', '/send'),
  EventSource urls ('/events/stream', '/events/history').
- stats.js fetch() targets.
- screen.html WebSocket URL: was hardcoded as
  ws(s)://host/screen/ws; now derived from document.baseURI via
  new URL('screen/ws', document.baseURI) so the gateway prefix
  flows through.

Slash-command labels (/cancel, /compact, …) and the dashboard-port
link (different port, intentionally absolute) intentionally
untouched.

Added a new 'Per-agent relative paths' section to docs/web-ui.md
covering the rationale + the trailing-slash gotcha (sub-pages like
/stats must NOT have a trailing slash, or 'static/app.js' resolves
under /stats/ instead of replacing the segment).

Functional code unchanged; build clean. Damocles + atlas can
proceed with the backend / nginx side without depending on this
landing first, but once both ship the agent page works under the
gateway-prefixed URL without further changes.

refs #14
This commit is contained in:
iris 2026-05-31 12:10:56 +02:00
commit 37d99ed118
6 changed files with 70 additions and 27 deletions

View file

@ -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/<name>/) still hits the right SSE upstream (#14).
historyUrl: 'events/history',
streamUrl: 'events/stream',
renderers: {
turn_start(ev, api) {
if (api.fromHistory) openTurnsFromHistory += 1;