diff --git a/docs/reference/cli.md b/docs/reference/cli.md index f3d0230e..f2f5aafb 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -585,7 +585,7 @@ to it, as `uv tool upgrade` holds it; uv says how to lift the pin. Epics a [daem under the release it started with until it stops; where one is running, `hmz update` says so and leaves it alone. The interfaces say when a newer release is out: the [terminal interface](/reference/tui) in a line as it opens, the [web interface](/reference/web) with -`Update` in the machine menu. +`Update` in the daemon picker. | Condition | Output | Exit | | --- | --- | --- | diff --git a/docs/user/backups.md b/docs/user/backups.md index 48aaf2cf..ed8aa9ef 100644 --- a/docs/user/backups.md +++ b/docs/user/backups.md @@ -122,7 +122,7 @@ same path, on the commit it was on. An epic you already have -- one this machine still keeps, or one downloaded before -- is refused. Without `--to`, every target is looked in; where two machines' epics share a name, name it as `//`. -In [`hmz web`](/user/web), **Download** in the machine's menu lists what a +In [`hmz web`](/user/web), **Download** in the daemon picker lists what a target holds by machine and then by directory, the ones already here marked **Here**; pick the target where there is more than one. **Download** on an epic brings it here and opens it. diff --git a/docs/user/web.md b/docs/user/web.md index 7b3598a5..f7ee8235 100644 --- a/docs/user/web.md +++ b/docs/user/web.md @@ -65,9 +65,13 @@ lists its chats, and an epic opens out into its agents and the sessions each ope | **Search epics** (⌘K) | Every epic, with its directory, agents, what it spent and how long it took | Find epics by how they went, by directory, by flow, or by a word of their task; **Import** one. See [Importing an epic](#importing-an-epic) | | **Settings** | What humanize remembers, and what the epics here spent | Change it, as [`/settings`](/user/settings) does | -The foot of the sidebar names this machine and says how many epics are going on it; -its menu opens **Import**, **Settings**, **Usage** and **Download**, which downloads an epic out of a -[backup target](/user/backups#downloading-an-epic). Where a newer humanize is out, the machine is marked ↑ and its menu offers +The foot of the sidebar is the daemon bar: the daemon the page is of -- **Local**, or an SSH +daemon by its name -- its host, a dot for how it stands (up, connecting, installing, failed or +not connected) and how many epics are `live` on it, or `idle`. Click it for the daemon picker: +every daemon with how it stands, a click switching the page to it; **Connect** under one that +failed; **Manage**, which opens **Settings → Daemons**; then **Import**, +**Settings**, **Usage** and **Download**, which downloads an epic out of a +[backup target](/user/backups#downloading-an-epic). Where a newer humanize is out, the bar is marked ↑ and the picker offers **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. @@ -212,7 +216,7 @@ tokens, and every process -- click one to pick it. ### Importing an epic {#importing-an-epic} -Somebody sent you a `.hmzepic`? Press **Import** in **Search epics**, or in the machine's menu at +Somebody sent you a `.hmzepic`? Press **Import** in **Search epics**, or in the daemon picker at the foot of the sidebar, or right-click between the epics in the sidebar, and pick the file -- or drop it anywhere on the window. It is read back in under the directory it ran in, and opens once it is in. One you already have is refused, with **Open** to go to it. @@ -423,7 +427,7 @@ says. ### The sidebar says why the host let it go The host holding the runs went: it was closed, or it let this window go. The page reaches the -runs again by itself a moment later, starting a host where none is, and the foot of the sidebar +runs again by itself a moment later, starting a host where none is, and the daemon bar says `idle` or how many are `live` again once it has. ### A way in is marked `(at a terminal)` diff --git a/src/hmz/web/static/sidebar.js b/src/hmz/web/static/sidebar.js index ce77d3bf..df8468f0 100644 --- a/src/hmz/web/static/sidebar.js +++ b/src/hmz/web/static/sidebar.js @@ -1,8 +1,10 @@ // The sidebar, as Codex and Claude keep theirs: a new epic, finding one, the flows, and every // workspace of this machine as a folder of its epics, newest first -- each epic opened out into -// 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. +// its agents, and each agent into the sessions it opened. Under them, the daemon the page is of, +// how many epics are going on it, and a picker of the daemons beside the settings and what the +// epics spent. +import * as api from './api.js' import { address, get, poll, post, refresh } from './api.js' import { ask } from './held.js' import { pick } from './importing.js' @@ -26,7 +28,6 @@ const MARKS = { running: '●', stopping: '◐', done: '✔', failed: '✕', sto export function mountSidebar(root, context, where) { const list = h('nav', { class: 'side-runs', 'aria-label': 'Epics', 'data-hmz': 'epics' }, h('p', { class: 'side-quiet', role: 'status' }, 'Reading the epics…')) const more = h('button', { class: 'side-more', type: 'button', hidden: true }, 'Show more') - const state = h('span', { class: 'side-state', 'data-hmz': 'state' }) // How many installed flows have a newer release, as the indexes were fetched as it started. const updates = h('span', { class: 'side-badge', 'data-hmz': 'updates', hidden: true }) poll('/api/flowverses', 60000, (answer) => { @@ -35,25 +36,7 @@ export function mountSidebar(root, context, where) { updates.textContent = String(count) updates.title = count ? `${count} flow${count === 1 ? ' has' : 's have'} a newer release` : '' }) - const account = h( - 'button', - { class: 'side-account', type: 'button', 'data-hmz': 'machine-menu' }, - h('span', { class: 'side-avatar', 'aria-hidden': 'true' }, (context.machine || 'h').slice(0, 1).toUpperCase()), - h('span', { class: 'side-account-text' }, h('b', {}, context.machine || 'This machine'), state), - // A newer release of humanize, as PyPI said when it was last asked. - context.newer ? h('span', { class: 'side-badge', 'data-hmz': 'newer', title: `hmz ${context.newer} is out` }, '↑') : null, - ) - menu( - account, - (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 }), - ], - { up: true, label: 'Machine' }, - ) + const bar = daemonBar(context) fill( root, dragged( @@ -72,7 +55,7 @@ export function mountSidebar(root, context, where) { h('a', { href: '#/flows', 'data-nav': 'flows' }, icon('flows'), h('span', {}, 'Flows'), updates), ), h('div', { class: 'side-scroll' }, list, more), - h('div', { class: 'side-foot' }, account), + h('div', { class: 'side-foot' }, bar.el), ) // The band across its top, above the row. dragged(root, '') @@ -122,7 +105,7 @@ export function mountSidebar(root, context, where) { else link.removeAttribute('aria-current') } const going = data.runs.filter((one) => one.held).length - fill(state, h('span', { class: ['dot', going && 'live'], 'aria-hidden': 'true' }), going ? `${going} live` : 'idle') + bar.going(going) // Every workspace offered, each with its epics: those going first, then the latest. const groups = new Map() for (const one of places ? places.workspaces : []) groups.set(one.path, { path: one.path, runs: [] }) @@ -349,6 +332,120 @@ export function backedSaid(backed) { .join(' · ') } +/** How a daemon stands, as its dot says it, and the word for it where it is not up. */ +const STATES = { up: 'up', connecting: 'connecting', installing: 'installing', failed: 'failed', idle: 'not connected' } + +/** The daemon the page is of: the address's `@`, or Local without one. */ +function current() { + if (api.daemon) return api.daemon() + let first = location.hash.replace(/^#\/?/, '').split(/[/?]/)[0] + try { + first = decodeURIComponent(first) + } catch { + // Not an address's own escaping: read as it is. + } + return first.startsWith('@') && first.length > 1 ? first.slice(1) : 'local' +} + +/** Moves the page to another daemon, a new epic on it. */ +function switchTo(name) { + if (api.switchDaemon) return api.switchDaemon(name) + location.hash = `/@${encodeURIComponent(name)}/new` + // Where the page does not tell of a switch itself, it is told here. + if (!api.onDaemon) window.dispatchEvent(new CustomEvent('hmz:daemon', { detail: { name } })) +} + +/** What a daemon is called on the page. */ +function named(name) { + return name === 'local' ? 'Local' : name +} + +/** + * The foot of the sidebar: the daemon the page is of -- its name, its host, how it stands and + * how many epics are going on it -- opening a picker of every daemon and the machine's choices. + */ +function daemonBar(context) { + const local = { name: 'local', host: context.machine || '', state: 'up' } + let daemons = [local] + // How many epics are going, and on which daemon they were counted. + let live = { of: '', count: 0 } + const avatar = h('span', { class: 'side-avatar', 'aria-hidden': 'true' }) + const title = h('b', { 'data-hmz': 'daemon-name' }) + const host = h('span', { class: 'side-host', 'data-hmz': 'daemon-host' }) + const state = h('span', { class: 'side-state', 'data-hmz': 'state' }) + const el = h( + 'button', + { class: 'side-account', type: 'button', 'data-hmz': 'daemon-bar' }, + avatar, + h('span', { class: 'side-account-text' }, h('span', { class: 'side-daemon' }, title, host), state), + // A newer release of humanize, as PyPI said when it was last asked. + context.newer ? h('span', { class: 'side-badge', 'data-hmz': 'newer', title: `hmz ${context.newer} is out` }, '↑') : null, + ) + const entry = () => daemons.find((one) => one.name === current()) || { name: current(), host: '', state: 'idle' } + const said = (one) => (Object.hasOwn(STATES, one.state) ? one.state : 'idle') + const draw = () => { + const one = entry() + const how = said(one) + const up = how === 'up' + avatar.textContent = named(one.name).slice(0, 1).toUpperCase() + title.textContent = named(one.name) + host.textContent = one.host || '' + el.title = [named(one.name), one.host, one.detail].filter(Boolean).join(' · ') + el.dataset.state = how + const going = live.of === one.name ? live.count : 0 + fill(state, h('span', { class: ['dot', `daemon-${how}`, up && going && 'live'], 'aria-hidden': 'true' }), up ? (going ? `${going} live` : 'idle') : STATES[how]) + } + // Every daemon, as the machine serving the page has them; Local alone where it keeps none. + poll('/api/daemons', 10000, (answer, error) => { + if (Array.isArray(answer)) daemons = answer.some((one) => one.name === 'local') ? answer : [local, ...answer] + else if (error && error.status === 404) daemons = [local] + draw() + }) + const connect = (name) => { + post(`/api/daemons/${encodeURIComponent(name)}/connect`, {}).then(() => { + toast(`Connecting ${named(name)}`) + refresh('/api/daemons') + }, (error) => toast(error.message)) + } + menu( + el, + (close) => [ + section('Daemons'), + daemons.flatMap((one) => [ + choice({ label: named(one.name), about: [one.host, STATES[said(one)]].filter(Boolean).join(' · '), chosen: one.name === current(), act: () => { + settleSidebar() + if (one.name !== current()) switchTo(one.name) + }, close }), + said(one) === 'failed' ? choice({ label: 'Connect', about: named(one.name), glyph: 'resume', act: () => connect(one.name), close }) : null, + ]), + choice({ label: 'Manage', glyph: 'sliders', href: '#/settings/daemons', close }), + section('Machine'), + 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 }), + ], + { up: true, label: 'Daemon' }, + ) + // The picker is what opened after it, once the menu has put it there. + el.addEventListener('click', () => { + const pop = el.nextElementSibling + if (pop && pop.matches('.popover')) pop.dataset.hmz = 'daemon-picker' + }) + addEventListener('hashchange', draw) + addEventListener('hmz:daemon', draw) + draw() + return { + el, + /** Says how many epics are going on the daemon the page is of. */ + going(count) { + live = { of: current(), count } + draw() + }, + } +} + /** Forgets a workspace nothing ran in, which takes it out of the list. */ function forget(path) { post('/api/workspaces/forget', { workspace: path }).then(() => { diff --git a/src/hmz/web/static/style.css b/src/hmz/web/static/style.css index fecebe47..bf28b75f 100644 --- a/src/hmz/web/static/style.css +++ b/src/hmz/web/static/style.css @@ -3845,3 +3845,43 @@ input[aria-invalid='true'] { -webkit-user-select: none; user-select: none; } + +/* daemon bar */ +.side-daemon { + display: flex; + align-items: baseline; + gap: 6px; + min-width: 0; +} + +.side-daemon b { + flex: none; + max-width: 70%; +} + +.side-host { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font: 11.5px var(--mono); + color: var(--ink-3); +} + +.dot.daemon-connecting, +.dot.daemon-installing { + background: var(--budget); +} + +.dot.daemon-failed { + background: var(--fail); +} + +.dot.daemon-idle { + background: transparent; + box-shadow: inset 0 0 0 1.5px var(--ink-3); +} + +.side-account[data-state='failed'] .side-avatar { + background: var(--fail); +} diff --git a/tests/integration/test_web_daemon_bar.py b/tests/integration/test_web_daemon_bar.py new file mode 100644 index 00000000..3c647bb0 --- /dev/null +++ b/tests/integration/test_web_daemon_bar.py @@ -0,0 +1,114 @@ +"""The daemon bar at the foot of `hmz web`'s sidebar, and the picker it opens. + +The bar names the daemon the page is of, its host and how it stands; the picker lists every +daemon the machine serving the page keeps, switches to one, connects one that failed and +opens where they are managed. The daemons are answered here, as the machine would. +""" + +from __future__ import annotations + +import json +import re +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, opened, served + +if TYPE_CHECKING: + from pathlib import Path + +pytestmark = pytest.mark.parametrize("engine", ENGINES) + +_DAEMONS = [ + {"name": "local", "host": "here", "state": "up"}, + {"name": "lab", "host": "lab.example", "state": "up"}, + {"name": "farm", "host": "farm.example", "state": "failed"}, +] + + +@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 _answering(status: int, body: object) -> str: + """A script answering the daemons, and connecting one, as a machine would, before the page asks. + + The page is let in by the address as it opens, so the answer is given in the page itself. + """ + return f""" + window.hmzConnected = [] + const fetched = window.fetch.bind(window) + window.fetch = (url, how) => {{ + const path = new URL(String(url), location.href).pathname + const json = (code, said) => new Response(JSON.stringify(said), {{ + status: code, headers: {{ 'Content-Type': 'application/json' }}, + }}) + if (path === '/api/daemons') return Promise.resolve(json({status}, {json.dumps(body)})) + const connect = /^\\/api\\/daemons\\/([^/]+)\\/connect$/.exec(path) + if (connect) {{ + window.hmzConnected.push(connect[1]) + return Promise.resolve(json(200, {{}})) + }} + return fetched(url, how) + }} + """ + + +def test_the_bar_names_the_daemon_and_its_picker_switches( + project: Path, engine: str +) -> None: + with ( + served() as site, + opened(site, engine=engine, init=_answering(200, _DAEMONS)) as page, + ): + bar = page.locator("[data-hmz=daemon-bar]") + expect(bar.locator("[data-hmz=daemon-name]")).to_have_text("Local") + expect(bar.locator("[data-hmz=daemon-host]")).to_have_text("here") + expect(bar.locator("[data-hmz=state]")).to_have_text("idle") + + bar.click() + picker = page.locator("[data-hmz=daemon-picker]") + expect(picker).to_have_attribute("role", "menu") + expect(picker.locator(".choice-label")).to_have_text( + [ + *["Local", "lab", "farm", "Connect", "Manage"], + *["Import", "Settings", "Usage", "Download"], + ] + ) + expect(picker.locator(".choice.chosen .choice-label")).to_have_text("Local") + + # One that failed is connected again. + picker.get_by_role("menuitem", name="Connect").click() + expect(picker).to_have_count(0) + assert page.evaluate("window.hmzConnected") == ["farm"] + + bar.click() + picker.get_by_role("menuitem", name=re.compile(r"^lab")).click() + page.wait_for_url(re.compile(r".*#/@lab/new$")) + expect(bar.locator("[data-hmz=daemon-name]")).to_have_text("lab") + expect(bar.locator("[data-hmz=daemon-host]")).to_have_text("lab.example") + + bar.click() + picker.get_by_role("menuitem", name="Manage").click() + page.wait_for_url(re.compile(r".*#/settings/daemons$")) + + +def test_without_daemons_the_bar_is_local(project: Path, engine: str) -> None: + missing = {"error": "Nothing is here."} + with ( + served() as site, + opened(site, engine=engine, width=375, init=_answering(404, missing)) as page, + ): + page.locator("[data-hmz=bar] .bar-side").click() + bar = page.locator("[data-hmz=daemon-bar]") + expect(bar.locator("[data-hmz=daemon-name]")).to_have_text("Local") + bar.click() + picker = page.locator("[data-hmz=daemon-picker]") + expect(picker.locator(".choice-label")).to_have_text( + ["Local", "Manage", "Import", "Settings", "Usage", "Download"] + ) diff --git a/tests/integration/test_web_page.py b/tests/integration/test_web_page.py index 2dcdcf76..a51a6acd 100644 --- a/tests/integration/test_web_page.py +++ b/tests/integration/test_web_page.py @@ -406,10 +406,10 @@ def test_the_colours_are_a_pair_under_auto_or_a_theme( project: Path, engine: str ) -> None: with served() as site, opened(site, engine=engine) as page: - # A theme is chosen only in Settings: the machine's menu offers none. - page.locator("[data-hmz=machine-menu]").click() + # A theme is chosen only in Settings: the daemon picker offers none. + page.locator("[data-hmz=daemon-bar]").click() expect(page.locator("[role=menu] .choice-label")).to_have_text( - ["Import", "Settings", "Usage", "Download"] + ["Local", "Manage", "Import", "Settings", "Usage", "Download"] ) page.keyboard.press("Escape") go(page, "/settings/themes") @@ -581,11 +581,13 @@ def test_a_newer_release_is_offered_under_the_machine( releases.fetch() with served() as site, opened(site, engine=engine) as page: expect(page.locator("[data-hmz=newer]")).to_be_visible() - page.locator("[data-hmz=machine-menu]").click() + page.locator("[data-hmz=daemon-bar]").click() expect(page.locator("[role=menu] .choice-label")).to_have_text( - ["Update", "Import", "Settings", "Usage", "Download"] + ["Local", "Manage", "Update", "Import", "Settings", "Usage", "Download"] + ) + expect(page.locator("[role=menu] .choice-about").last).to_have_text( + "hmz 99.0.0" ) - expect(page.locator("[role=menu] .choice-about")).to_have_text(["hmz 99.0.0"]) def test_a_flow_is_opened_and_started_from_the_flows( @@ -645,7 +647,7 @@ def left(page: Page, selector: str) -> float: ) expect(page.locator(f":is({clicked})[data-tauri-drag-region]")).to_have_count(0) # A menu opened in a row is not what the window is dragged by. - page.locator("[data-hmz=machine-menu]").click() + page.locator("[data-hmz=daemon-bar]").click() expect(page.locator("[role=menu]")).to_have_attribute( "data-tauri-drag-region", "false" ) @@ -833,7 +835,7 @@ def test_an_epic_backed_up_elsewhere_is_downloaded_and_opened( ) -> None: name = backed_up_elsewhere(tmp_path, monkeypatch) with served() as site, opened(site, engine=engine) as page: - page.locator("[data-hmz=machine-menu]").click() + page.locator("[data-hmz=daemon-bar]").click() page.get_by_role("menuitem", name="Download").click() stored = page.locator(f"[data-hmz=stored-epic][data-epic='{name}']") @@ -995,9 +997,9 @@ def gone() -> None: page.wait_for_url(opened_on) expect(listed).to_have_count(1) - # Picked again, from the machine's menu: it is here already, and is opened from there. + # Picked again, from the daemon picker: it is here already, and is opened from there. go(page, "/new") - page.locator("[data-hmz=machine-menu]").click() + page.locator("[data-hmz=daemon-bar]").click() with page.expect_file_chooser() as choosing: page.get_by_role("menuitem", name="Import").click() choosing.value.set_files(archive) diff --git a/tests/integration/test_web_pointer.py b/tests/integration/test_web_pointer.py index 2951a3f2..f3d7f7cd 100644 --- a/tests/integration/test_web_pointer.py +++ b/tests/integration/test_web_pointer.py @@ -230,9 +230,9 @@ def test_every_popup_opens_whole_within_the_window(project: Path, engine: str) - page.keyboard.press("Escape") page.keyboard.press("Escape") - # The machine's menu, at the foot of the window: over its button, as wide as it. - page.locator("[data-hmz=machine-menu]").click() - _whole(page, page.get_by_role("menu", name="Machine")) + # The daemon picker, at the foot of the window: over its button, as wide as it. + page.locator("[data-hmz=daemon-bar]").click() + _whole(page, page.get_by_role("menu", name="Daemon")) page.keyboard.press("Escape") # A path browsed for from the folder a new epic opens, in a window too short for it.