From 501bf19335c38821699a76a5312b5009219fabb3 Mon Sep 17 00:00:00 2001 From: Zijian Zhang <35801754+futrime@users.noreply.github.com> Date: Mon, 12 Oct 2026 22:57:21 +0800 Subject: [PATCH] feat(web): scope the page to the daemon its address names The address names the daemon first, #/@/, and Local where it names none. api.js asks everything of that daemon under /d//api/, save /api/daemons and /api/notifications, keeps @ in every link through hash(), and on a switch clears what polls read, closes the held streams and fires hmz:daemon, on which the view and the sidebar's lists are drawn again. The server answers /d/local/api/ as /api/. Co-Authored-By: Claude Opus 5.5 --- docs/reference/web.md | 9 ++ docs/user/web.md | 6 +- src/hmz/web/site.py | 3 + src/hmz/web/static/api.js | 76 ++++++++- src/hmz/web/static/app.js | 43 +++-- src/hmz/web/static/graph.js | 3 +- src/hmz/web/static/held.js | 15 +- src/hmz/web/static/importing.js | 9 +- src/hmz/web/static/pickers.js | 20 ++- src/hmz/web/static/places.js | 4 +- src/hmz/web/static/sidebar.js | 61 ++++--- src/hmz/web/static/views/download.js | 6 +- src/hmz/web/static/views/epic.js | 24 +-- src/hmz/web/static/views/exomyth/view.js | 4 +- src/hmz/web/static/views/flows.js | 6 +- src/hmz/web/static/views/import.js | 6 +- src/hmz/web/static/views/new.js | 4 +- src/hmz/web/static/views/search.js | 4 +- src/hmz/web/static/views/settings.js | 3 +- src/hmz/web/static/views/settings/runtimes.js | 3 +- src/hmz/web/static/views/settings/themes.js | 19 +-- src/hmz/web/static/views/settings/usage.js | 4 +- tests/integration/test_web_scoping.py | 150 ++++++++++++++++++ 23 files changed, 391 insertions(+), 91 deletions(-) create mode 100644 tests/integration/test_web_scoping.py diff --git a/docs/reference/web.md b/docs/reference/web.md index e4311393c..c8122b086 100644 --- a/docs/reference/web.md +++ b/docs/reference/web.md @@ -145,6 +145,15 @@ Each view is an address after `#`, so it can be kept and opened again: | `#/download[?target=]` | A dialog downloading an epic out of a backup target: the first in settings without `target`. | | `#/settings/[?days=]` | A dialog: `general`, `themes`, `accounts`, `fallback`, `runtimes`, `backup`, `workspace` or `usage`; `days` is how far `usage` looks back (7, 30, 90 or 365; default 30), by UTC day. | +Every address above is of one daemon. `#/@/` -- `#/@local/new`, `#/@gpu/epics/` +-- is that view of the daemon named; an address naming none is Local's, as every address +written before daemons were is. The page shows only the daemon its address names: every link +it draws keeps the `@` the address carries, and going to another daemon's address draws +the view and the sidebar again, of that daemon, closing what the page streamed of the last. +Everything the page asks of a daemon is under `/d//api/…`, Local's `/d/local/api/…` +being the same as `/api/…`; what is the same whichever daemon the page is on -- +`/api/daemons…` and `/api/notifications…` -- is asked of Local, unscoped. + The page keeps a few conveniences in the browser's `localStorage`, each forgotten without harm: the colours chosen as `hmz-theme` (`light`, `dark`, or absent for the system's), a sidebar folded away as `hmz-sidebar`, a side panel closed as `hmz-panel`, which epics are opened out diff --git a/docs/user/web.md b/docs/user/web.md index 7b3598a57..fff5912c1 100644 --- a/docs/user/web.md +++ b/docs/user/web.md @@ -71,7 +71,11 @@ its menu opens **Import**, **Settings**, **Usage** and **Download**, which downl **Update**, which copies `hmz update` to run in a terminal. ⇧⌘O opens a new epic and ⌘. folds the sidebar away. On a phone the sidebar is a drawer behind the button in the corner. -Every view is an address: copy it, keep it, or open it in another tab. +Every view is an address: copy it, keep it, or open it in another tab. An address names the +daemon it is of first, `#/@gpu/new`, and the page shows only that daemon's epics, workspaces +and settings; every link keeps it, and going to another daemon's address draws everything again +of that one. An address naming no daemon is Local's. See +[The page](/reference/web#page). **Right-click** anything for what can be done to it: a folder in the sidebar (new epic here, its flows, search it, copy its path, settings), an epic (open, its log, resume, copy its link or task, diff --git a/src/hmz/web/site.py b/src/hmz/web/site.py index e6204457b..aaec30871 100644 --- a/src/hmz/web/site.py +++ b/src/hmz/web/site.py @@ -282,6 +282,9 @@ def _routes(self, method: str, path: str, query: dict[str, str]) -> None: raise Refusal( "Open the address hmz web printed, on the machine it runs on.", 403 ) + # This machine's own daemon, named as a page names every daemon it asks. + if path.startswith("/d/local/api/"): + path = path.removeprefix("/d/local") if path.startswith("/api/"): self._api(method, path, query) elif method != "GET": diff --git a/src/hmz/web/static/api.js b/src/hmz/web/static/api.js index 1bdd983b1..7df0421d7 100644 --- a/src/hmz/web/static/api.js +++ b/src/hmz/web/static/api.js @@ -1,6 +1,68 @@ // How the page asks the server anything: one wrapper for reading and writing, which turns a // refusal into the sentence the server gave, and one store for what is read again and again, -// shared by every view of it. +// shared by every view of it. Everything is asked of the daemon the address names -- `#/@/…`, +// Local where it names none -- under `/d//api/…`, save what is the same for every daemon. + +/** What is asked of Local whichever daemon the page is on: the daemons, and the notifications. */ +const GLOBAL = /^\/api\/(daemons|notifications)(\/|\?|$)/ + +/** The daemon the address names, or '' where it names none. */ +function named(address) { + const match = /^#?\/?@([^/?]*)/.exec(address) + try { + return match ? decodeURIComponent(match[1]) : '' + } catch { + return match[1] + } +} + +let naming = named(location.hash) +/** How many times the page has switched daemons: what was asked before the last is not told. */ +let era = 0 +const switched = new Set() + +/** The daemon the page is on. */ +export function daemon() { + return naming || 'local' +} + +/** Where an address of the API is asked: of the daemon the page is on, unless it is global. */ +export function scoped(path) { + return path.startsWith('/api/') && !GLOBAL.test(path) ? `/d/${encodeURIComponent(daemon())}${path}` : path +} + +/** The address of a view of the page, on the daemon the page is on, as the address names it. */ +export function hash(path) { + if (path.startsWith('#')) path = path.slice(1) + if (path.startsWith('/@')) return `#${path}` + return naming ? `#/@${encodeURIComponent(naming)}${path}` : `#${path}` +} + +/** Switches the page to another daemon, on the view given, a new epic where none is. */ +export function toDaemon(name, path = '/new') { + location.hash = name === 'local' ? path : `/@${encodeURIComponent(name)}${path}` +} + +/** Tells `fn` the daemon the page switched to, each time it does, until what it returns is called. */ +export function onDaemon(fn) { + switched.add(fn) + return () => switched.delete(fn) +} + +// Ahead of the page's own routing, which is told by the event to draw everything again. +window.addEventListener('hashchange', () => { + const was = daemon() + naming = named(location.hash) + if (daemon() === was) return + era += 1 + // What was read of the last daemon is not this one's: read again, once whoever leaves has. + for (const read of reads.values()) Object.assign(read, { text: undefined, data: undefined, error: null, failures: 0, stale: true }) + setTimeout(() => { + for (const read of reads.values()) if (read.stale) fetchNow(read) + }) + for (const fn of [...switched]) fn(daemon()) + window.dispatchEvent(new CustomEvent('hmz:daemon', { detail: { daemon: daemon() } })) +}) /** A refusal: the server's own sentence, the status it came with, and what else it said. */ export class Refused extends Error { @@ -13,13 +75,13 @@ export class Refused extends Error { /** Reads one answer, or throws the server's sentence. */ export async function get(url) { - return answered(await fetch(url, { headers: { Accept: 'application/json' } })) + return answered(await fetch(scoped(url), { headers: { Accept: 'application/json' } })) } /** Writes, sending JSON, and reads the answer, or throws the server's sentence. */ export async function post(url, body = {}) { return answered( - await fetch(url, { + await fetch(scoped(url), { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify(body), @@ -30,7 +92,7 @@ export async function post(url, body = {}) { /** Sends a file as it is, under its name, and reads the answer, or throws the server's sentence. */ export async function send(url, file, kind) { return answered( - await fetch(url, { + await fetch(scoped(url), { method: 'POST', headers: { 'Content-Type': kind, 'X-Hmz-Filename': encodeURIComponent(file.name), Accept: 'application/json' }, body: file, @@ -97,12 +159,15 @@ export function refresh(url) { async function fetchNow(read) { clearTimeout(read.timer) + read.stale = false // A page nobody looks at reads nothing again until looked at -- but what it has never read // it reads, so a page opened in a tab behind is drawn once it is looked at. if (document.hidden && read.text !== undefined) return + const mine = era try { - const response = await fetch(read.url, { headers: { Accept: 'application/json' } }) + const response = await fetch(scoped(read.url), { headers: { Accept: 'application/json' } }) const text = await response.text() + if (mine !== era) return if (response.status === 401) window.dispatchEvent(new CustomEvent('hmz:out')) if (!response.ok) { let said = null @@ -122,6 +187,7 @@ async function fetchNow(read) { for (const watcher of read.watchers) watcher.told(read.data, null) } } catch (error) { + if (mine !== era) return read.failures += 1 read.error = error for (const watcher of read.watchers) watcher.told(read.data, error) diff --git a/src/hmz/web/static/app.js b/src/hmz/web/static/app.js index 12da1ee40..6d9762810 100644 --- a/src/hmz/web/static/app.js +++ b/src/hmz/web/static/app.js @@ -2,9 +2,10 @@ // epics on the left, and whichever view the address names beside it -- a new epic, its graph, one session's // thread, the flows -- with finding a run and the settings opened over it as dialogs. Routed // by the address's fragment, so that every view of the page is an address that can be kept, -// shared or opened again, and the server serves one page for all of them. +// shared or opened again, and the server serves one page for all of them. An address names the +// daemon it is of first, `#/@/…`, or names none and is of Local. -import { get } from './api.js' +import { daemon, get, hash } from './api.js' import { fill, h, problem } from './dom.js' import { mountSidebar } from './sidebar.js' import { kept, toggleSidebar } from './shell.js' @@ -44,16 +45,16 @@ let behind = '' /** What the view drawn was drawn for, so a dialog opened over it leaves it standing. */ let shown = '' -/** Where the page is: the parts of the address's path, and its query. */ +/** Where the page is: the parts of the address's path, after the daemon it names, and its query. */ export function where() { - const raw = location.hash.replace(/^#/, '') || '/new' + const raw = location.hash.replace(/^#/, '').replace(/^\/?@[^/?]*/, '') || '/new' const url = new URL(raw.startsWith('/') ? raw : `/${raw}`, 'http://page') return { parts: url.pathname.split('/').filter(Boolean).map(decodeURIComponent), query: url.searchParams } } -/** Goes to another view of the page. */ +/** Goes to another view of the page, on the daemon it is on. */ export function go(path) { - location.hash = path + location.hash = hash(path) } /** Puts the query of the view the page is on in the address, without a step in its history. */ @@ -68,7 +69,7 @@ export function stays(query) { const said = new URLSearchParams() for (const [name, value] of Object.entries(query)) if (value !== '' && value !== undefined && value !== null) said.set(name, value) const text = said.toString() - history.replaceState(null, '', `#/${parts.map(encodeURIComponent).join('/')}${text ? `?${text}` : ''}`) + history.replaceState(null, '', hash(`/${parts.map(encodeURIComponent).join('/')}${text ? `?${text}` : ''}`)) routedTo = location.hash } @@ -222,14 +223,36 @@ window.addEventListener('hmz:out', () => { ) }) +// Another daemon: everything drawn of the last is drawn again of this one, as the address +// routed next says -- told before that routing, which api.js is told of first. +window.addEventListener('hmz:daemon', () => { + if (mounted) mounted.leave() + mounted = null + shown = '' + opened = '' + behind = '' + named().catch(() => {}) +}) + +/** Names the page by the machine the daemon it is on is. */ +function named() { + const asked = daemon() + return get('/api/held').then((context) => { + if (asked === daemon()) document.title = `humanize · ${context.machine}` + return context + }) +} + window.addEventListener('hashchange', route) -get('/api/held') +named() .then((context) => { - document.title = `humanize · ${context.machine}` mountSidebar(document.getElementById('sidebar'), context, where) route() }) .catch((error) => { - if (!out) fill(page, h('div', { class: 'view' }, problem(error))) + if (out) return + // A daemon out of reach: said where the view would be, the sidebar there to go elsewhere. + mountSidebar(document.getElementById('sidebar'), {}, where) + fill(page, h('div', { class: 'view' }, problem(error))) }) diff --git a/src/hmz/web/static/graph.js b/src/hmz/web/static/graph.js index bebabf553..03924578d 100644 --- a/src/hmz/web/static/graph.js +++ b/src/hmz/web/static/graph.js @@ -4,6 +4,7 @@ // role to another, as many times as it did. While the run goes, the node working is lit, the // last handover is the red one, and a line on every lane says where now is. +import { hash } from './api.js' import { flowName, h, iconButton, lasting, svg, when } from './dom.js' import { copy, menuOn } from './ui.js' @@ -285,7 +286,7 @@ export function graph({ open }) { } function agentNode(agent, box, focused, epic, going) { - const base = `#/epics/${encodeURIComponent(epic)}` + const base = hash(`/epics/${encodeURIComponent(epic)}`) const head = h( 'button', { class: 'node-head', type: 'button', 'aria-pressed': String(focused) }, diff --git a/src/hmz/web/static/held.js b/src/hmz/web/static/held.js index 73ea02bf3..391748a60 100644 --- a/src/hmz/web/static/held.js +++ b/src/hmz/web/static/held.js @@ -3,7 +3,7 @@ // out -- the same the terminal interface draws. A workspace is followed only while a view of it // is open. What a view asks of a workspace's runs goes through `ask`. -import { post } from './api.js' +import { onDaemon, post, scoped } from './api.js' /** The most records kept per workspace, and about how many bytes of them, as the server keeps. */ const KEPT = 20000 @@ -38,7 +38,7 @@ export function follow(workspace, told) { setTimeout(() => { if (one.listeners.size) return one.close() - followed.delete(workspace) + if (followed.get(workspace) === one) followed.delete(workspace) }, 5000) } } @@ -77,7 +77,7 @@ function following(workspace) { function connect() { const query = new URLSearchParams({ workspace }) if (lastId && state.epoch) query.set('last', lastId) - const mine = new EventSource(`/api/held/stream?${query}`) + const mine = new EventSource(scoped(`/api/held/stream?${query}`)) one.source = mine // A stream let go of, or replaced, says nothing more to the page. const on = (name, handle) => mine.addEventListener(name, (event) => one.source === mine && handle(event)) @@ -145,6 +145,15 @@ function following(workspace) { return one } +// Another daemon's workspaces are others: every stream of the last goes, and nobody is told it. +onDaemon(() => { + for (const one of followed.values()) { + one.listeners.clear() + one.close() + } + followed.clear() +}) + // A browser holds only so many connections to one server: a tab nobody looks at lets its // streams go after a while, and picks each up from the last record it heard once looked at. let resting = 0 diff --git a/src/hmz/web/static/importing.js b/src/hmz/web/static/importing.js index b7ca6ea7e..d62e77826 100644 --- a/src/hmz/web/static/importing.js +++ b/src/hmz/web/static/importing.js @@ -2,7 +2,7 @@ // the disk, or dropped anywhere on the window, and opened once it is in. One already on this // machine, named by its path -- as the desktop app opens a file -- is `#/epics/import?path=`. -import { refresh, send } from './api.js' +import { daemon, hash, refresh, send } from './api.js' import { h, iconButton, problem } from './dom.js' /** What an epic's archive is named with, and sent as. */ @@ -29,18 +29,21 @@ function failed(...why) { /** How an import refused is said: the server's sentence, and the epic it names where that is here already. */ export function refusal(error) { const name = error.said && error.said.name - return [problem(error), name ? h('a', { href: `#/epics/${encodeURIComponent(name)}`, 'data-hmz': 'imported-already', onclick: () => say() }, 'Open') : null] + return [problem(error), name ? h('a', { href: hash(`/epics/${encodeURIComponent(name)}`), 'data-hmz': 'imported-already', onclick: () => say() }, 'Open') : null] } /** Sends one archive, saying so as it goes, and opens the epic it was once it is in. */ export async function importFile(file) { if (file.size > LARGEST) return failed(problem('That is more than a request here may send.')) say(h('span', {}, `Importing ${file.name}…`)) + const into = daemon() try { const answer = await send('/api/runs/import', file, KIND) say() + // Opened on the daemon it went into, unless the page has since gone to another. + if (into !== daemon()) return refresh('/api/runs') - location.hash = `/epics/${encodeURIComponent(answer.name)}` + location.hash = hash(`/epics/${encodeURIComponent(answer.name)}`) } catch (error) { failed(refusal(error)) } diff --git a/src/hmz/web/static/pickers.js b/src/hmz/web/static/pickers.js index c875a906f..7572bfd7b 100644 --- a/src/hmz/web/static/pickers.js +++ b/src/hmz/web/static/pickers.js @@ -3,7 +3,7 @@ // runs as, a model that account runs and an effort that model takes; an environment is a // backend, the runtime of it, and a directory there. Each comes to what `-a` or `-e` spells. -import { get, post } from './api.js' +import { daemon, get, hash, onDaemon, post } from './api.js' import { fill, h, iconButton } from './dom.js' import { pathPicker } from './paths.js' @@ -20,11 +20,23 @@ let reading = '' export async function readBackends(workspace = reading) { reading = workspace - backends = await get(`/api/backends?workspace=${encodeURIComponent(workspace)}`) + const of = daemon() + const read = await get(`/api/backends?workspace=${encodeURIComponent(workspace)}`) + // Read of a daemon the page has since left: another's CLIs. + if (of !== daemon()) return read + backends = read for (const one of told) one() return backends } +// Another daemon's CLIs and accounts are others. +onDaemon(() => { + backends = null + reading = '' + asking.clear() + failed.clear() +}) + /** Asks a CLI what it runs as an account, once however many pickers ask. */ function ask(cli, account) { const key = `${cli}@${account}` @@ -151,7 +163,7 @@ export function agentPicker(role, kept, changed, { effort: withEffort = true } = account.addEventListener('change', () => { if (account.value === '+') { account.value = chosen.account - location.hash = '/settings/accounts' + location.hash = hash('/settings/accounts') return } Object.assign(chosen, { account: account.value, model: '', effort: '' }) @@ -249,7 +261,7 @@ export function envPicker(role, kept, offered, runtimes) { }) machine.addEventListener('change', () => { if (machine.value === '+') { - location.hash = '/settings/runtimes' + location.hash = hash('/settings/runtimes') draw() return } diff --git a/src/hmz/web/static/places.js b/src/hmz/web/static/places.js index fefe8ac5e..875943127 100644 --- a/src/hmz/web/static/places.js +++ b/src/hmz/web/static/places.js @@ -2,12 +2,14 @@ // opened here, the one `hmz web` was started in first. Picking one is picking where an epic // runs, whose flows are on offer, and whose settings are read. -import { get, post } from './api.js' +import { get, onDaemon, post } from './api.js' import { fill, h, icon } from './dom.js' import { pathPicker } from './paths.js' import { choice, dragged, follow, menu, section } from './shell.js' let known = null +// Another daemon's workspaces are others. +onDaemon(() => (known = null)) /** Every workspace there is to offer, read again where asked to. */ export async function workspaces(again = false) { diff --git a/src/hmz/web/static/sidebar.js b/src/hmz/web/static/sidebar.js index ce77d3bf2..771672b77 100644 --- a/src/hmz/web/static/sidebar.js +++ b/src/hmz/web/static/sidebar.js @@ -3,7 +3,7 @@ // its agents, and each agent into the sessions it opened. Under them, the machine, how many // epics are going on it, and a menu of the settings and what the epics spent. -import { address, get, poll, post, refresh } from './api.js' +import { address, daemon, get, hash, poll, post, refresh, scoped } from './api.js' import { ask } from './held.js' import { pick } from './importing.js' import { copy, menuOn, toast } from './ui.js' @@ -48,9 +48,9 @@ export function mountSidebar(root, context, where) { (close) => [ context.newer ? choice({ label: 'Update', about: `hmz ${context.newer}`, glyph: 'up', act: () => copy('hmz update', 'Copied hmz update'), close }) : null, choice({ label: 'Import', glyph: 'file', act: pick, close }), - choice({ label: 'Settings', glyph: 'gear', href: '#/settings/general', close }), - choice({ label: 'Usage', glyph: 'chart', href: '#/settings/usage', close }), - choice({ label: 'Download', glyph: 'download', href: '#/download', close }), + choice({ label: 'Settings', glyph: 'gear', href: hash('/settings/general'), close }), + choice({ label: 'Usage', glyph: 'chart', href: hash('/settings/usage'), close }), + choice({ label: 'Download', glyph: 'download', href: hash('/download'), close }), ], { up: true, label: 'Machine' }, ) @@ -60,16 +60,16 @@ export function mountSidebar(root, context, where) { h( 'div', { class: 'side-top' }, - h('a', { class: 'brand', href: '#/new', 'aria-label': 'humanize, a new epic' }, h('img', { src: '/mark.svg', alt: '', width: '24', height: '24' }), h('strong', {}, 'humanize')), + h('a', { class: 'brand', href: hash('/new'), 'aria-label': 'humanize, a new epic' }, h('img', { src: '/mark.svg', alt: '', width: '24', height: '24' }), h('strong', {}, 'humanize')), iconButton('sidebar', 'Hide sidebar', { class: 'side-fold', onclick: () => toggleSidebar(false), 'aria-controls': 'sidebar' }), ), ), h( 'div', { class: 'side-nav' }, - h('a', { href: '#/new', 'data-nav': 'new' }, icon('new'), h('span', {}, 'New epic'), h('kbd', {}, '⇧⌘O')), - h('a', { href: '#/search', 'data-nav': 'search' }, icon('search'), h('span', {}, 'Search epics'), h('kbd', {}, '⌘K')), - h('a', { href: '#/flows', 'data-nav': 'flows' }, icon('flows'), h('span', {}, 'Flows'), updates), + h('a', { href: hash('/new'), 'data-nav': 'new' }, icon('new'), h('span', {}, 'New epic'), h('kbd', {}, '⇧⌘O')), + h('a', { href: hash('/search'), 'data-nav': 'search' }, icon('search'), h('span', {}, 'Search epics'), h('kbd', {}, '⌘K')), + h('a', { href: hash('/flows'), 'data-nav': 'flows' }, icon('flows'), h('span', {}, 'Flows'), updates), ), h('div', { class: 'side-scroll' }, list, more), h('div', { class: 'side-foot' }, account), @@ -149,20 +149,20 @@ export function mountSidebar(root, context, where) { { class: ['side-folder', chosen === group.path && 'current'] }, toggle, h('span', { class: 'side-folder-name', title: group.path }, icon('folder'), h('span', { class: 'side-label' }, nameOf(group.path))), - iconButton('plus', `New epic in ${nameOf(group.path)}`, { class: 'side-add', onclick: () => (location.hash = `/new?workspace=${encodeURIComponent(group.path)}`) }), + iconButton('plus', `New epic in ${nameOf(group.path)}`, { class: 'side-add', onclick: () => (location.hash = hash(`/new?workspace=${encodeURIComponent(group.path)}`)) }), ) const where = encodeURIComponent(group.path) menuOn(folder, () => [ { head: group.path }, - { label: 'New epic here', glyph: 'new', href: `#/new?workspace=${where}` }, - { label: 'Flows here', glyph: 'flows', href: `#/flows?workspace=${where}` }, - { label: 'Search here', glyph: 'search', href: `#/search?workspace=${where}` }, + { label: 'New epic here', glyph: 'new', href: hash(`/new?workspace=${where}`) }, + { label: 'Flows here', glyph: 'flows', href: hash(`/flows?workspace=${where}`) }, + { label: 'Search here', glyph: 'search', href: hash(`/search?workspace=${where}`) }, '-', { label: 'Copy path', glyph: 'copy', act: () => copy(group.path, 'Path copied') }, { label: open ? 'Collapse' : 'Expand', glyph: 'chevron', act: () => toggle.click() }, '-', - { label: 'Settings', glyph: 'gear', href: `#/settings/workspace?workspace=${where}` }, - { label: 'Usage', glyph: 'chart', href: `#/settings/usage?workspace=${where}` }, + { label: 'Settings', glyph: 'gear', href: hash(`/settings/workspace?workspace=${where}`) }, + { label: 'Usage', glyph: 'chart', href: hash(`/settings/usage?workspace=${where}`) }, group.runs.length ? null : { label: 'Forget', danger: true, act: () => forget(group.path) }, ]) return h( @@ -185,7 +185,7 @@ export function mountSidebar(root, context, where) { function item(run, here, parts) { const shown = (opened.has(run.name) || run.name === here) && !folded.has(run.name) - const link = `#/epics/${encodeURIComponent(run.name)}` + const link = hash(`/epics/${encodeURIComponent(run.name)}`) const toggle = h('button', { class: 'side-toggle', type: 'button', 'aria-expanded': String(shown), 'aria-label': shown ? 'Collapse' : 'Expand' }, icon('chevron')) toggle.addEventListener('click', () => { if (shown) { @@ -224,19 +224,21 @@ export function mountSidebar(root, context, where) { const stale = !read || read.how !== run.how || (run.held && Date.now() - read.at > 3000) if (stale && !(read && read.asking)) { details.set(run.name, { ...(read || {}), asking: true }) + const of = daemon() get(`/api/runs/${encodeURIComponent(run.name)}`).then( (detail) => { + if (of !== daemon()) return details.set(run.name, { how: run.how, at: Date.now(), detail }) - draw() + if (data) draw() }, - () => details.set(run.name, { how: run.how, at: Date.now(), detail: null }), + () => of === daemon() && details.set(run.name, { how: run.how, at: Date.now(), detail: null }), ) } const detail = read && read.detail const sessions = detail ? sessionsOf(detail) : [] const agents = run.agents.map((one) => ({ role: one.role, runs: one.runs, sessions: sessions.filter((session) => session.role === one.role) })) if (!agents.length) return h('p', { class: 'side-quiet nested' }, 'No agents') - const base = `#/epics/${encodeURIComponent(run.name)}` + const base = hash(`/epics/${encodeURIComponent(run.name)}`) const logged = run.name === here && parts[2] === 'logs' && !parts[3] const mythed = run.name === here && parts[2] === 'exomyth' return h( @@ -312,25 +314,38 @@ export function mountSidebar(root, context, where) { } watch() - window.addEventListener('hashchange', () => data && draw()) + window.addEventListener('hashchange', () => { + // The links that stay go on to the daemon the address names, as it names it. + for (const link of root.querySelectorAll('.side-top .brand, .side-nav a')) link.setAttribute('href', hash(`/${link.dataset.nav || 'new'}`)) + if (data) draw() + }) + // Another daemon's epics and workspaces: nothing of the last one's is drawn until they are read. + window.addEventListener('hmz:daemon', () => { + data = null + places = null + limit = PAGE + details.clear() + fill(list, h('p', { class: 'side-quiet', role: 'status' }, 'Reading the epics…')) + watch() + }) } /** What can be done to one epic, from wherever it is listed. */ export function epicMenu(run) { - const base = `#/epics/${encodeURIComponent(run.name)}` + const base = hash(`/epics/${encodeURIComponent(run.name)}`) const resumes = run.resumable && !run.held && run.how !== 'done' return [ { head: run.task.split('\n')[0] }, { label: 'Open', glyph: 'open', href: base }, { label: 'Open log', glyph: 'thread', href: `${base}/logs` }, { label: 'Exomyth', glyph: 'chart', href: `${base}/exomyth` }, - resumes ? { label: 'Resume', glyph: 'resume', href: `#/new?flow=${encodeURIComponent(run.flow)}&resume=${encodeURIComponent(run.name)}&workspace=${encodeURIComponent(run.workspace)}` } : null, - { label: 'New epic like it', glyph: 'new', href: `#/new?flow=${encodeURIComponent(run.flow)}&workspace=${encodeURIComponent(run.workspace)}` }, + resumes ? { label: 'Resume', glyph: 'resume', href: hash(`/new?flow=${encodeURIComponent(run.flow)}&resume=${encodeURIComponent(run.name)}&workspace=${encodeURIComponent(run.workspace)}`) } : null, + { label: 'New epic like it', glyph: 'new', href: hash(`/new?flow=${encodeURIComponent(run.flow)}&workspace=${encodeURIComponent(run.workspace)}`) }, '-', { label: 'Copy link', glyph: 'link', act: () => copy(`${location.origin}/${base}`, 'Link copied') }, { label: 'Copy task', glyph: 'copy', act: () => copy(run.task, 'Task copied') }, { label: 'Copy name', glyph: 'copy', act: () => copy(run.name, 'Name copied') }, - run.held ? null : { label: 'Export', glyph: 'download', act: () => (location.href = `/api/runs/${encodeURIComponent(run.name)}/bundle`) }, + run.held ? null : { label: 'Export', glyph: 'download', act: () => (location.href = scoped(`/api/runs/${encodeURIComponent(run.name)}/bundle`)) }, { label: 'Upload', glyph: 'up', title: backedSaid(run.backed), act: () => upload(run.name) }, run.held ? '-' : null, run.held ? { label: 'Stop', glyph: 'stop', danger: true, act: () => ask(run.workspace, 'stop').then(() => toast('Stopping'), (error) => toast(error.message)) } : null, diff --git a/src/hmz/web/static/views/download.js b/src/hmz/web/static/views/download.js index c6c61b6dc..1bcef6893 100644 --- a/src/hmz/web/static/views/download.js +++ b/src/hmz/web/static/views/download.js @@ -4,7 +4,7 @@ // epics, as an import would, opening it. What the target holds is fetched afresh as it opens, // which can take a while. -import { get, post, refresh } from '../api.js' +import { get, hash, post, refresh } from '../api.js' import { go, shut, stays } from '../app.js' import { ago, badge, empty, fill, flowName, h, headline, icon, iconButton, problem, when } from '../dom.js' import { nameOf } from '../places.js' @@ -22,7 +22,7 @@ export function mount(root, { query }) { get('/api/settings').then((settings) => { const names = settings.backup.targets.map((one) => one.name) if (!names.length) { - fill(body, empty('No backup target.', ' Add one, and the epics it holds are downloaded from here.', h('a', { class: 'btn primary', href: '#/settings/backup' }, 'Backup'))) + fill(body, empty('No backup target.', ' Add one, and the epics it holds are downloaded from here.', h('a', { class: 'btn primary', href: hash('/settings/backup') }, 'Backup'))) return } const chosen = names.includes(query.get('target')) ? query.get('target') : names[0] @@ -79,7 +79,7 @@ export function mount(root, { query }) { function row(target, one) { const action = one.here - ? h('a', { class: 'btn small quiet', href: `#/epics/${encodeURIComponent(one.epic)}` }, 'Open') + ? h('a', { class: 'btn small quiet', href: hash(`/epics/${encodeURIComponent(one.epic)}`) }, 'Open') : h('button', { class: 'btn small', type: 'button', onclick: (event) => pull(target, one, event.currentTarget) }, 'Download') return h( 'div', diff --git a/src/hmz/web/static/views/epic.js b/src/hmz/web/static/views/epic.js index dc80f711b..b544c6936 100644 --- a/src/hmz/web/static/views/epic.js +++ b/src/hmz/web/static/views/epic.js @@ -9,7 +9,7 @@ // and the side questions asked beside it. import { asides, board, outworlders, question, saying } from '../acting.js' -import { get } from '../api.js' +import { get, hash, scoped } from '../api.js' import { go } from '../app.js' import { acting, ago, badge, flowName, pathText, confirming, copier, empty, fill, h, headline, icon, lasting, problem, secondsOf, when } from '../dom.js' import { ask } from '../held.js' @@ -80,7 +80,7 @@ export function mount(root, { parts, query }) { { class: 'view-pad' }, starting || !none ? h('p', { class: 'loading', role: 'status' }, 'Waiting for the epic to be written down…') - : empty('No epic is going here.', ' Start one, or open an epic from the sidebar.', h('a', { class: 'btn primary', href: '#/new' }, 'New epic')), + : empty('No epic is going here.', ' Start one, or open an epic from the sidebar.', h('a', { class: 'btn primary', href: hash('/new') }, 'New epic')), ), ), ) @@ -91,7 +91,7 @@ export function mount(root, { parts, query }) { if (!name) { // The run going is named now: the address says which, and it is followed by name. name = detail.name - history.replaceState(null, '', `#/epics/${encodeURIComponent(name)}`) + history.replaceState(null, '', hash(`/epics/${encodeURIComponent(name)}`)) landing = chatting(detail) stop() stop = watch(name, detail.workspace, told) @@ -106,7 +106,7 @@ export function mount(root, { parts, query }) { if (opened) { // A chat is a conversation, and is opened on it, as a chat app opens on one. landing = false - history.replaceState(null, '', `#/epics/${encodeURIComponent(name)}/sessions/${encodeURIComponent(opened.key)}`) + history.replaceState(null, '', hash(`/epics/${encodeURIComponent(name)}/sessions/${encodeURIComponent(opened.key)}`)) mode = modeOf(['epics', name, 'sessions', opened.key]) } const model = run ? live(held, run, detail) : written(detail, traced) @@ -129,14 +129,14 @@ export function mount(root, { parts, query }) { const actions = [] if (going && run.state === 'running') actions.push(confirming({ label: 'Stop', ask: 'Stop this epic?', yes: 'Stop', no: 'Cancel', act: () => ask(workspace(), 'stop') })) else if (going && run.state === 'stopping') actions.push(confirming({ label: 'Force stop', ask: 'Close every session now?', yes: 'Force stop', no: 'Cancel', act: () => ask(workspace(), 'force') })) - if (!going && detail.picks_up) actions.push(h('a', { class: 'btn', href: `#/new?flow=${encodeURIComponent(detail.flow)}&resume=${encodeURIComponent(detail.name)}` }, icon('resume'), 'Resume')) - actions.push(h('a', { class: 'icon-btn', href: `#/epics/${encodeURIComponent(detail.name)}/exomyth`, 'aria-label': 'Exomyth', title: 'Exomyth', 'data-hmz': 'exomyth-open' }, icon('chart'))) - if (!going) actions.push(h('a', { class: 'icon-btn', href: `/api/runs/${encodeURIComponent(detail.name)}/bundle`, download: '', 'aria-label': 'Export', title: 'Export' }, icon('download'))) + if (!going && detail.picks_up) actions.push(h('a', { class: 'btn', href: hash(`/new?flow=${encodeURIComponent(detail.flow)}&resume=${encodeURIComponent(detail.name)}`) }, icon('resume'), 'Resume')) + actions.push(h('a', { class: 'icon-btn', href: hash(`/epics/${encodeURIComponent(detail.name)}/exomyth`), 'aria-label': 'Exomyth', title: 'Exomyth', 'data-hmz': 'exomyth-open' }, icon('chart'))) + if (!going) actions.push(h('a', { class: 'icon-btn', href: scoped(`/api/runs/${encodeURIComponent(detail.name)}/bundle`), download: '', 'aria-label': 'Export', title: 'Export' }, icon('download'))) // How each target last took it, said on hover; read again as the epic is. actions.push(h('button', { class: 'icon-btn', type: 'button', 'aria-label': 'Upload', title: backedSaid(detail.backed) || 'Upload', 'data-hmz': 'upload', onclick: () => upload(detail.name) }, icon('up'))) actions.push(copier('Copy link', () => location.href)) actions.push(panel.toggle) - const base = `#/epics/${encodeURIComponent(detail.name)}` + const base = hash(`/epics/${encodeURIComponent(detail.name)}`) const sub = [ badge(state), h('span', { class: 'mono', title: detail.flow }, flowName(detail.flow)), @@ -348,7 +348,7 @@ export function mount(root, { parts, query }) { dock.querySelector('.composer, .dock-note')?.remove() if (composer) dock.append(composer) else if (detail.picks_up) - dock.append(h('p', { class: 'dock-note' }, 'Ended · ', h('a', { href: `#/new?flow=${encodeURIComponent(detail.flow)}&resume=${encodeURIComponent(detail.name)}` }, 'Resume'))) + dock.append(h('p', { class: 'dock-note' }, 'Ended · ', h('a', { href: hash(`/new?flow=${encodeURIComponent(detail.flow)}&resume=${encodeURIComponent(detail.name)}`) }, 'Resume'))) // Imported from a workspace this machine has not got as it was: why it is not picked up here. else if (detail.foreign) dock.append(h('p', { class: 'dock-note', 'data-hmz': 'foreign' }, `Imported · not resumable here: ${detail.foreign}`)) }) @@ -403,7 +403,7 @@ export function mount(root, { parts, query }) { function overview(detail, run, model, focus, going, figures, standing) { const agent = model.agents.find((one) => one.role === focus) - const base = `#/epics/${encodeURIComponent(detail.name)}` + const base = hash(`/epics/${encodeURIComponent(detail.name)}`) const parts = [] if (agent) { parts.push( @@ -609,7 +609,7 @@ function placed(env) { /** The agents as a list rather than a graph: the working first, then in the flow's order. */ function listOf(model, epic, focus) { - const base = `#/epics/${encodeURIComponent(epic)}` + const base = hash(`/epics/${encodeURIComponent(epic)}`) const order = [...model.agents].sort((a, b) => Number(b.working) - Number(a.working)) return order.map((agent) => h( @@ -697,7 +697,7 @@ function facts(detail) { h('dd', {}, budgeted(detail.budget)), h('dt', {}, 'sessions'), h('dd', {}, detail.sessions.length ? sessionsOf(detail).map((one) => h('div', {}, h('span', { class: 'mono' }, one.key), ` · ${one.backend}`, one.provider ? `@${one.provider}` : '', h('div', { class: 'dim mono' }, one.ident))) : 'none'), - detail.picked_up ? [h('dt', {}, 'resumed from'), h('dd', {}, h('a', { href: `#/epics/${encodeURIComponent(detail.picked_up)}` }, detail.picked_up))] : null, + detail.picked_up ? [h('dt', {}, 'resumed from'), h('dd', {}, h('a', { href: hash(`/epics/${encodeURIComponent(detail.picked_up)}`) }, detail.picked_up))] : null, detail.profile ? [h('dt', {}, 'profiled'), h('dd', {}, 'yes')] : null, ) } diff --git a/src/hmz/web/static/views/exomyth/view.js b/src/hmz/web/static/views/exomyth/view.js index 6f60f2359..7914f02db 100644 --- a/src/hmz/web/static/views/exomyth/view.js +++ b/src/hmz/web/static/views/exomyth/view.js @@ -4,7 +4,7 @@ // what it carried, or a stretch of time. An epic still going is read again every few seconds, // for as long as it is looked at. -import { get, poll, refresh } from '../../api.js' +import { get, hash, poll, refresh } from '../../api.js' import { stays } from '../../app.js' import { badge, copier, fill, h, headline, icon, iconButton, problem } from '../../dom.js' import { bar, sidePanel, tabbed } from '../../shell.js' @@ -53,7 +53,7 @@ export function mount(root, name, query) { // ---------------------------------------------------------------- the bar and the tools function drawHead() { - const link = `#/epics/${encodeURIComponent(name)}` + const link = hash(`/epics/${encodeURIComponent(name)}`) const title = h('span', { class: 'crumbs' }, h('a', { href: link }, detail ? headline(detail.task) : name), h('span', { 'aria-hidden': 'true' }, ' / '), h('span', {}, 'Exomyth')) const sub = model ? [badge(model.live ? 'running' : (detail && detail.how) || 'done'), h('span', {}, `${counted(model.summary.processes)} processes`), h('span', {}, `${counted(model.summary.spans)} spans`), h('span', { class: 'mono' }, lasted(model.end))] diff --git a/src/hmz/web/static/views/flows.js b/src/hmz/web/static/views/flows.js index f661338fb..83530ee4a 100644 --- a/src/hmz/web/static/views/flows.js +++ b/src/hmz/web/static/views/flows.js @@ -5,7 +5,7 @@ // from, fetched or written into, and -- one opened -- every flow its index lists, every release // of each, installing, switching and uninstalling one; with adding, fetching and removing one. -import { get, poll, post, refresh } from '../api.js' +import { get, hash, poll, post, refresh } from '../api.js' import { stays } from '../app.js' import { acting, confirming, empty, fill, h, icon, permitted, problem } from '../dom.js' import { workspacePicker } from '../places.js' @@ -135,7 +135,7 @@ export function mount(root, { query }) { one.about ? h('span', { class: 'card-about' }, one.about) : null, ) card.addEventListener('click', () => show(one.name)) - const newEpic = `#/new?flow=${encodeURIComponent(one.name)}&workspace=${encodeURIComponent(workspace)}` + const newEpic = hash(`/new?flow=${encodeURIComponent(one.name)}&workspace=${encodeURIComponent(workspace)}`) return menuOn(card, () => [ { head: one.name }, { label: 'New epic', glyph: 'new', href: newEpic }, @@ -172,7 +172,7 @@ export function mount(root, { query }) { if (chosen !== name) return const installed = ((verses && verses.installed) || []).find((one) => one.flow === name) const update = ((verses && verses.updates) || []).find((one) => one.flow === name) - const actions = [h('a', { class: 'btn primary', href: `#/new?flow=${encodeURIComponent(flow.name)}&workspace=${encodeURIComponent(workspace)}` }, icon('new'), 'New epic')] + const actions = [h('a', { class: 'btn primary', href: hash(`/new?flow=${encodeURIComponent(flow.name)}&workspace=${encodeURIComponent(workspace)}`) }, icon('new'), 'New epic')] if (update) actions.push(acting(`Update to ${update.to}`, () => change('/api/flowverses/install', { flow: name, version: update.to }), { busy: 'Updating…' })) if (!name.startsWith('@local/')) actions.push(acting('Copy here', () => change('/api/flows/fork', { flow: name, workspace }), { kind: 'btn quiet', busy: 'Copying…' })) if (installed) actions.push(confirming({ label: 'Uninstall', ask: `Uninstall ${name}?`, yes: 'Uninstall', no: 'Cancel', act: () => change('/api/flowverses/uninstall', { flow: name }) })) diff --git a/src/hmz/web/static/views/import.js b/src/hmz/web/static/views/import.js index 622aafa93..bc71ca29f 100644 --- a/src/hmz/web/static/views/import.js +++ b/src/hmz/web/static/views/import.js @@ -4,7 +4,7 @@ // In a browser it is asked first: any page elsewhere can link here, and the page would carry // its key. -import { post, refresh } from '../api.js' +import { hash, post, refresh } from '../api.js' import { fill, h } from '../dom.js' import { refusal } from '../importing.js' import { bar, inApp } from '../shell.js' @@ -21,7 +21,7 @@ export function mount(root, { query }) { if (left) return refresh('/api/runs') // In place of this address, so that going back never imports it again. - location.replace(`#/epics/${encodeURIComponent(answer.name)}/exomyth`) + location.replace(hash(`/epics/${encodeURIComponent(answer.name)}/exomyth`)) }, (error) => { if (!left) fill(body, refusal(error)) @@ -33,7 +33,7 @@ export function mount(root, { query }) { fill( body, h('p', { class: 'path selectable', title: path }, path), - h('p', { class: 'row' }, h('button', { class: 'btn primary', type: 'button', autofocus: true, onclick: go }, 'Import'), h('a', { class: 'btn quiet', href: '#/new' }, 'Cancel')), + h('p', { class: 'row' }, h('button', { class: 'btn primary', type: 'button', autofocus: true, onclick: go }, 'Import'), h('a', { class: 'btn quiet', href: hash('/new') }, 'Cancel')), ) return () => { left = true diff --git a/src/hmz/web/static/views/new.js b/src/hmz/web/static/views/new.js index 0365c0994..f69316138 100644 --- a/src/hmz/web/static/views/new.js +++ b/src/hmz/web/static/views/new.js @@ -4,7 +4,7 @@ // Options on the box, set up as this directory last ran that flow, the way `/flow` in the // terminal interface sets one up. Picking a run up again is the same box, on that run. -import { get, post } from '../api.js' +import { get, hash, post } from '../api.js' import { covered, go, stays } from '../app.js' import { composer } from '../composer.js' import { acting, fill, h, icon, permitted, problem, secondsOf } from '../dom.js' @@ -235,7 +235,7 @@ export function mount(root, { query }) { // Back from making an account or a runtime, the pickers offer it. const back = () => { - if (!location.hash.startsWith('#/settings')) readBackends(workspace).catch(() => {}) + if (!location.hash.startsWith(hash('/settings'))) readBackends(workspace).catch(() => {}) } window.addEventListener('hashchange', back) return () => { diff --git a/src/hmz/web/static/views/search.js b/src/hmz/web/static/views/search.js index 90084c607..54d4b43b8 100644 --- a/src/hmz/web/static/views/search.js +++ b/src/hmz/web/static/views/search.js @@ -3,7 +3,7 @@ // what it spent and how long it took -- found by how it went, by flow and by its task, a page // at a time. The list `/epics` is in the terminal interface. -import { address, poll } from '../api.js' +import { address, hash, poll } from '../api.js' import { shut, stays } from '../app.js' import { nameOf, workspaces } from '../places.js' import { epicMenu } from '../sidebar.js' @@ -108,7 +108,7 @@ export function mount(root, { query }) { {}, menuOn(h( 'a', - { class: ['result', run.held && 'held'], href: `#/epics/${encodeURIComponent(run.name)}`, title: run.task, 'data-hmz': 'epic' }, + { class: ['result', run.held && 'held'], href: hash(`/epics/${encodeURIComponent(run.name)}`), title: run.task, 'data-hmz': 'epic' }, h('span', { class: 'result-top' }, badge(run.how), h('span', { class: 'result-task' }, headline(run.task)), h('span', { class: 'result-when', title: when(run.began) }, ago(run.began))), h( 'span', diff --git a/src/hmz/web/static/views/settings.js b/src/hmz/web/static/views/settings.js index b84805854..00b7ab866 100644 --- a/src/hmz/web/static/views/settings.js +++ b/src/hmz/web/static/views/settings.js @@ -4,6 +4,7 @@ // work goes, where epics are backed up, this one directory -- and last what the runs here spent; and, after what is true // wherever humanize runs, how this browser draws the page. +import { hash } from '../api.js' import { shut } from '../app.js' import { fill, h, iconButton, problem } from '../dom.js' import { dragged } from '../shell.js' @@ -34,7 +35,7 @@ export function mount(root, { parts, query }) { 'nav', { class: 'settings-nav', 'aria-label': 'Settings pages' }, h('h2', {}, 'Settings'), - Object.entries(PAGES).map(([key, one]) => h('a', { href: `#/settings/${key}`, 'aria-current': key === name ? 'page' : null }, one.label)), + Object.entries(PAGES).map(([key, one]) => h('a', { href: hash(`/settings/${key}`), 'aria-current': key === name ? 'page' : null }, one.label)), ), ), h( diff --git a/src/hmz/web/static/views/settings/runtimes.js b/src/hmz/web/static/views/settings/runtimes.js index a27ad2f46..a84d5e969 100644 --- a/src/hmz/web/static/views/settings/runtimes.js +++ b/src/hmz/web/static/views/settings/runtimes.js @@ -3,7 +3,7 @@ // reaching it asks; a new one is written from its backend's own fields, or brought in from // the hosts an ssh config names. -import { get, post } from '../../api.js' +import { get, onDaemon, post } from '../../api.js' import { acting, confirming, fill, h, icon, iconButton, problem } from '../../dom.js' import { pathPicker } from '../../paths.js' @@ -196,6 +196,7 @@ function labelOf(key) { /** The SSH hosts your config names, read once, for the fields that name one. */ let hosts = null +onDaemon(() => (hosts = null)) function sshHosts() { hosts ||= get('/api/runtimes/hosts').then((answer) => answer.hosts.map((one) => one.alias), () => []) return hosts diff --git a/src/hmz/web/static/views/settings/themes.js b/src/hmz/web/static/views/settings/themes.js index 30cb807b4..87dba5c16 100644 --- a/src/hmz/web/static/views/settings/themes.js +++ b/src/hmz/web/static/views/settings/themes.js @@ -4,6 +4,7 @@ // it is made on, each of its colours and a picture behind any of the large planes, shown on the // page as it is changed and kept at once. +import { hash } from '../../api.js' import { confirming, fill, h, iconButton, problem } from '../../dom.js' import { choice, menu } from '../../shell.js' import { menuOn, toast } from '../../ui.js' @@ -61,11 +62,11 @@ function list(holder) { }) const fresh = async (from, name) => { const one = await look.create(from, name) - location.hash = `/settings/themes/${one.id}` + location.hash = hash(`/settings/themes/${one.id}`) } const freshPair = async ([light, dark], name) => { const [one] = await look.createPair(light, dark, name) - location.hash = `/settings/themes/${one.id}` + location.hash = hash(`/settings/themes/${one.id}`) } const use = (one) => { look.choose(one.id) @@ -75,7 +76,7 @@ function list(holder) { const offered = (one) => [ { head: look.label(one) }, { label: 'Use', glyph: 'check', act: () => use(one) }, - look.OWN.includes(one) ? null : { label: 'Edit', glyph: 'new', href: `#/settings/themes/${one.id}` }, + look.OWN.includes(one) ? null : { label: 'Edit', glyph: 'new', href: hash(`/settings/themes/${one.id}`) }, { label: 'Duplicate', glyph: 'copy', act: () => fresh(one, `${one.name} copy`) }, { label: 'Export', glyph: 'download', act: () => look.exported(one) }, ] @@ -107,7 +108,7 @@ function list(holder) { 'div', { class: 'row' }, h('button', { class: 'btn small', type: 'button', disabled: on, onclick: () => (look.usePair(pair.key), redraw()) }, 'Use pair'), - pair.mine ? either('Edit', two, (one) => (location.hash = `/settings/themes/${one.id}`)) : null, + pair.mine ? either('Edit', two, (one) => (location.hash = hash(`/settings/themes/${one.id}`))) : null, either('Duplicate', two, (one) => fresh(one, `${pair.name} ${one.base}`), () => freshPair(two, `${pair.name} copy`)), either('Export', two, (one) => look.exported(one), () => look.exported(pair.key)), pair.mine ? confirming({ label: 'Delete', ask: `Delete ${pair.name}?`, yes: 'Delete', small: true, act: () => look.removePair(pair.key).then(redraw) }) : null, @@ -126,7 +127,7 @@ function list(holder) { 'div', { class: 'row' }, h('button', { class: 'btn small', type: 'button', disabled: on, onclick: () => use(one) }, 'Use'), - h('a', { class: 'btn quiet small', href: `#/settings/themes/${one.id}` }, 'Edit'), + h('a', { class: 'btn quiet small', href: hash(`/settings/themes/${one.id}`) }, 'Edit'), h('button', { class: 'btn quiet small', type: 'button', onclick: () => fresh(one, `${one.name} copy`) }, 'Duplicate'), h('button', { class: 'btn quiet small', type: 'button', onclick: () => look.exported(one) }, 'Export'), confirming({ label: 'Delete', ask: `Delete ${one.name}?`, yes: 'Delete', small: true, act: () => look.remove(one.id).then(redraw) }), @@ -209,7 +210,7 @@ function hex(colour) { function editor(holder, id) { const one = look.theme(id) if (!one || look.OWN.includes(one)) { - fill(holder, problem('No such theme'), h('p', { class: 'row' }, h('a', { class: 'btn', href: '#/settings/themes' }, 'Themes'))) + fill(holder, problem('No such theme'), h('p', { class: 'row' }, h('a', { class: 'btn', href: hash('/settings/themes') }, 'Themes'))) return } // What the theme's colours are where it sets none: its base's, as a theme on it works them out. @@ -233,7 +234,7 @@ function editor(holder, id) { 'div', { class: 'segmented', role: 'group', 'aria-label': 'Side', 'data-hmz': 'theme-side' }, look.members(one.pair).map((other) => - h('button', { type: 'button', 'aria-pressed': String(other.id === one.id), onclick: () => (location.hash = `/settings/themes/${other.id}`) }, other.base === 'dark' ? 'Dark' : 'Light'), + h('button', { type: 'button', 'aria-pressed': String(other.id === one.id), onclick: () => (location.hash = hash(`/settings/themes/${other.id}`)) }, other.base === 'dark' ? 'Dark' : 'Light'), ), ) : null @@ -350,12 +351,12 @@ function editor(holder, id) { h( 'div', { class: 'row' }, - h('a', { class: 'btn', href: '#/settings/themes' }, 'Done'), + h('a', { class: 'btn', href: hash('/settings/themes') }, 'Done'), one.pair ? h('button', { class: 'btn quiet', type: 'button', onclick: () => (look.usePair(one.pair), toast(`Using ${one.name}`)) }, 'Use pair') : h('button', { class: 'btn quiet', type: 'button', onclick: () => (look.choose(one.id), toast(`Using ${one.name}`)) }, 'Use'), h('button', { class: 'btn quiet', type: 'button', onclick: () => look.exported(one.pair || one) }, 'Export'), - confirming({ label: 'Delete', ask: `Delete ${one.name}?`, yes: 'Delete', act: () => (one.pair ? look.removePair(one.pair) : look.remove(one.id)).then(() => (location.hash = '/settings/themes')) }), + confirming({ label: 'Delete', ask: `Delete ${one.name}?`, yes: 'Delete', act: () => (one.pair ? look.removePair(one.pair) : look.remove(one.id)).then(() => (location.hash = hash('/settings/themes'))) }), ), ), ), diff --git a/src/hmz/web/static/views/settings/usage.js b/src/hmz/web/static/views/settings/usage.js index dfa5001ae..dfd9519dc 100644 --- a/src/hmz/web/static/views/settings/usage.js +++ b/src/hmz/web/static/views/settings/usage.js @@ -2,7 +2,7 @@ // chart is drawn in plain HTML with a table beside it, so no figure rests on a colour or on a // pointer hovering over it. -import { address, poll } from '../../api.js' +import { address, hash, poll } from '../../api.js' import { stays } from '../../app.js' import { badge, fill, flowName, h, problem } from '../../dom.js' import { workspaces } from '../../places.js' @@ -121,7 +121,7 @@ export function draw(holder, query) { h( 'tr', {}, - h('td', {}, h('a', { href: `#/search?flow=${encodeURIComponent(one.flow)}`, title: one.flow }, flowName(one.flow))), + h('td', {}, h('a', { href: hash(`/search?flow=${encodeURIComponent(one.flow)}`), title: one.flow }, flowName(one.flow))), h('td', { class: 'num' }, one.runs), h('td', { class: 'num' }, one.money), h('td', {}, h('div', { class: 'share', title: share(one.cost, whole.cost) }, h('span', { style: { width: share(one.cost, whole.cost) } }))), diff --git a/tests/integration/test_web_scoping.py b/tests/integration/test_web_scoping.py new file mode 100644 index 000000000..d681a345a --- /dev/null +++ b/tests/integration/test_web_scoping.py @@ -0,0 +1,150 @@ +"""`hmz web`'s page on one daemon at a time: the daemon is the first part of every address. + +`#/@/` is a view of that daemon, and an address naming none is of Local. Whatever +the page asks of the daemon it is on goes under `/d//api/`, every link it draws keeps the +daemon its address named, and going to another daemon's address tells the page it switched. +Each in Chromium and in WebKit. +""" + +from __future__ import annotations + +import re +import urllib.parse +from typing import TYPE_CHECKING + +import pytest +from playwright.sync_api import expect + +from tests.integration.doubles_core import install +from tests.integration.doubles_web import ENGINES, go, opened, served + +if TYPE_CHECKING: + from pathlib import Path + + from playwright.sync_api import Page, Request, Route + +pytestmark = pytest.mark.parametrize("engine", ENGINES) + + +@pytest.fixture +def project(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, asking: None) -> Path: + """The stand-in `claude` alone on PATH, asked what it runs, and a project to serve.""" + return install(tmp_path, monkeypatch) + + +def _asked(page: Page) -> list[str]: + """Every path of the API the page asks from here on, as it asks it.""" + paths: list[str] = [] + + def heard(request: Request) -> None: + path = urllib.parse.urlsplit(request.url).path + if "/api/" in path: + paths.append(path) + + page.on("request", heard) + return paths + + +def _other(page: Page) -> None: + """A daemon named `other`, which is Local answering under another name.""" + + def answered(route: Route) -> None: + route.continue_(url=route.request.url.replace("/d/other/api/", "/d/local/api/")) + + page.route(re.compile(r".*/d/other/api/.*"), answered) + + +def test_an_address_naming_local_draws_every_view_and_keeps_naming_it( + project: Path, engine: str +) -> None: + with served() as site, opened(site, engine=engine) as page: + asked = _asked(page) + + go(page, "/@local/new") + expect(page.locator("[data-hmz=flow-picker] b")).to_have_text("chat") + nav = page.locator(".side-nav a") + expect(nav).to_have_count(3) + for one in nav.all(): + expect(one).to_have_attribute("href", re.compile(r"^#/@local/")) + expect(page.locator(".side-top .brand")).to_have_attribute( + "href", "#/@local/new" + ) + + page.locator(".side-nav a[data-nav=flows]").click() + expect(page.locator("[data-hmz=flows] .card").first).to_be_visible() + assert page.evaluate("location.hash").startswith("#/@local/flows") + + go(page, "/@local/settings/general") + dialog = page.locator("[data-hmz=dialog-settings]") + expect(dialog).to_be_visible() + expect(dialog.locator("nav a").first).to_have_attribute( + "href", re.compile(r"^#/@local/settings/") + ) + page.keyboard.press("Escape") + expect(dialog).to_have_count(0) + assert page.evaluate("location.hash").startswith("#/@local/flows") + + assert asked + assert all(path.startswith("/d/local/api/") for path in asked), asked + + +def test_an_epic_started_on_a_daemon_stays_on_it(project: Path, engine: str) -> None: + with served() as site, opened(site, engine=engine) as page: + asked = _asked(page) + go(page, "/@local/new?flow=chat") + page.get_by_role("button", name="Options").click() + expect( + page.locator("[data-role=assistant] [data-hmz=pick-model]") + ).to_have_value("claude-haiku-4-5") + page.locator("[data-hmz=composer-text]").fill("hello there") + page.locator("[data-hmz=composer-text]").press("Enter") + page.wait_for_url(re.compile(r".*#/@local/epics/[^/]+/sessions/assistant%2F1$")) + expect(page.locator("[data-hmz=thread]")).to_contain_text("did: hello there") + + # The epic's stream, and its links, are the daemon's. + assert any(path == "/d/local/api/held/stream" for path in asked), asked + expect(page.locator("[data-hmz=epic]").first).to_have_attribute( + "href", re.compile(r"^#/@local/epics/") + ) + page.get_by_role("button", name="Stop").click() + page.get_by_role("button", name="Stop").click() + expect(page.locator("[data-hmz=bar] .badge")).to_have_text( + re.compile("stopped") + ) + + +def test_another_daemon_is_switched_to_and_back(project: Path, engine: str) -> None: + with served() as site, opened(site, engine=engine) as page: + _other(page) + page.evaluate( + """() => { + window.switched = [] + addEventListener('hmz:daemon', (event) => switched.push(event.detail.daemon)) + }""" + ) + asked = _asked(page) + + # The same daemon, named, is no switch. + go(page, "/@local/new") + assert page.evaluate("switched") == [] + + go(page, "/@other/flows") + assert page.evaluate("switched") == ["other"] + expect(page.locator("[data-hmz=flows] .card").first).to_be_visible() + expect(page.locator(".side-nav a[data-nav=new]")).to_have_attribute( + "href", "#/@other/new" + ) + expect(page.locator("[data-hmz=workspace]").first).to_be_visible() + since = len(asked) + page.locator(".side-nav a[data-nav=new]").click() + expect(page.locator("[data-hmz=flow-picker] b")).to_have_text("chat") + assert page.evaluate("location.hash").startswith("#/@other/new?") + later = asked[since:] + assert later + assert all(path.startswith("/d/other/api/") for path in later), later + + go(page, "/new") + assert page.evaluate("switched") == ["other", "local"] + expect(page.locator(".side-nav a[data-nav=flows]")).to_have_attribute( + "href", "#/flows" + )