Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
| --- | --- | --- |
Expand Down
2 changes: 1 addition & 1 deletion docs/user/backups.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<machine>/<workspace>/<epic>`.

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.

Expand Down
14 changes: 9 additions & 5 deletions docs/user/web.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,13 @@ lists its chats, and an epic opens out into its agents and the sessions each ope
| **Search epics** (<kbd>⌘K</kbd>) | 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. <kbd>⇧⌘O</kbd> opens a new epic and <kbd>⌘.</kbd> folds the sidebar away. On a phone
the sidebar is a drawer behind the button in the corner.

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)`
Expand Down
145 changes: 121 additions & 24 deletions src/hmz/web/static/sidebar.js
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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) => {
Expand All @@ -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(
Expand All @@ -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, '')
Expand Down Expand Up @@ -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: [] })
Expand Down Expand Up @@ -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 `@<name>`, 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(() => {
Expand Down
40 changes: 40 additions & 0 deletions src/hmz/web/static/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
114 changes: 114 additions & 0 deletions tests/integration/test_web_daemon_bar.py
Original file line number Diff line number Diff line change
@@ -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"]
)
Loading
Loading