diff --git a/AGENTS.md b/AGENTS.md index 5dd2df5..e86ea77 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -166,6 +166,13 @@ Port **26071 is pinned** so the URL is always the same one to bookmark. Running the command again while it is already up just reopens that tab rather than failing on a port clash. +Since a second bench means a second tab, the tab title names its project: +` · bench` — the project first, because tab truncation eats the +tail and the tail is the same in every bench tab. The project is the repo +directory's name unless `BOARD_TITLE` in `local/.env` overrides it. The +server renders it into the page, so it is right on first paint; switching +view swaps only the tail (` · sessions`). + All settings live in `manager/core/.env.example` with their defaults documented — the port, the binaries agents launch with, the commands agents may run, the worktrees directory, the watch interval and the in-memory caps. Copy it diff --git a/manager/core/.env.example b/manager/core/.env.example index e7a3012..8765f74 100644 --- a/manager/core/.env.example +++ b/manager/core/.env.example @@ -6,6 +6,12 @@ # everywhere at once. BOARD_PORT=26071 +# What the tab calls this project: the title is " · bench", so two +# benches side by side are told apart at tab-bar width. Empty = the repo +# directory's name, which is the right answer unless every checkout on this +# machine is called "app". +BOARD_TITLE= + # Which agent adapter runs headless jobs (core/adapters/, overridable # in local/adapters/). Ships with: claude, opencode. BOARD_AGENT_ADAPTER=claude diff --git a/manager/core/board.html b/manager/core/board.html index 04c347f..9b53b21 100644 --- a/manager/core/board.html +++ b/manager/core/board.html @@ -3,7 +3,9 @@ -Bench — task board + +bench @@ -746,8 +748,19 @@ function setView(view) { render(); } +/* The tab says which bench this is: the project first (tab truncation eats + the tail, and the tail is the same in every bench tab), then the view. + Without a project in state the server-rendered title stands. */ +const VIEW_TITLES = { board: 'bench', flight: 'sessions', focus: 'focus' }; + +function renderTitle() { + if (!S.state?.project) return; + document.title = S.state.project + ' · ' + (VIEW_TITLES[S.view] || 'bench'); +} + function render() { if (!S.state) return; + renderTitle(); renderChip(); if (S.view === 'board') renderBoard(); else if (S.view === 'flight') renderFlight(); diff --git a/manager/core/config.py b/manager/core/config.py index e23d103..d562f9a 100644 --- a/manager/core/config.py +++ b/manager/core/config.py @@ -86,6 +86,11 @@ PORT = int(setting("BOARD_PORT", "26071")) # One isolated checkout per running work agent, relative to the repo root. WORKTREES = REPO / setting("BOARD_WORKTREES", ".worktrees") +# The project this board serves. Every board looks alike in a tab bar, so +# the title leads with this name — the repo directory's, unless the setting +# says otherwise (checkouts all called "app" need the override). +PROJECT = setting("BOARD_TITLE", "").strip() or REPO.name + # Which agent adapter runs headless jobs. Resolution ladder: local wins. ADAPTER = setting("BOARD_AGENT_ADAPTER", "claude") diff --git a/manager/core/httpd.py b/manager/core/httpd.py index 0eaf9cf..817e8ac 100644 --- a/manager/core/httpd.py +++ b/manager/core/httpd.py @@ -4,8 +4,10 @@ from __future__ import annotations import json import queue +import re import subprocess import time +from html import escape from http.server import BaseHTTPRequestHandler from urllib.parse import parse_qs, unquote, urlparse @@ -27,6 +29,7 @@ def state_payload() -> dict: board_events = list(state.BOARD_EVENTS[-80:]) return { "board": taskfiles.collect(), + "project": config.PROJECT, "sessions": sessions, "agents": agents.list_public(), "prs": github.public_state(), @@ -42,6 +45,17 @@ def state_payload() -> dict: } +_TITLE = re.compile(rb".*?", re.DOTALL) + + +def page_bytes() -> bytes: + """board.html with the project's name rendered into its , so the + tab reads right on first paint rather than after the first state load.""" + html = (config.CORE / "board.html").read_bytes() + title = escape(f"{config.PROJECT} · bench").encode("utf-8") + return _TITLE.sub(lambda _: b"<title>" + title + b"", html, count=1) + + class Handler(BaseHTTPRequestHandler): def log_message(self, fmt, *args): # quieter console pass @@ -61,11 +75,10 @@ class Handler(BaseHTTPRequestHandler): url = urlparse(self.path) path = url.path if path in ("/", "/index.html", "/board.html"): - page = config.CORE / "board.html" - if not page.is_file(): + if not (config.CORE / "board.html").is_file(): self._send(500, b"board.html is missing", "text/plain") return - self._send(200, page.read_bytes(), "text/html; charset=utf-8") + self._send(200, page_bytes(), "text/html; charset=utf-8") elif path == "/api/tasks": self._json(200, taskfiles.collect()) elif path == "/api/state": diff --git a/tests/test_board_title.py b/tests/test_board_title.py new file mode 100644 index 0000000..c274165 --- /dev/null +++ b/tests/test_board_title.py @@ -0,0 +1,168 @@ +"""The tab names its project (task 22): the title is " · bench", +project first, so two benches side by side are told apart at tab-bar width. + +Two halves are tested here. The server half — config resolving the project +name and httpd rendering it into the served page — runs in fresh +interpreters, because config reads its settings at import and BOARD_TITLE +is the thing under test. The browser half lives in board.html's inline JS +with no frontend test runner, so it is checked as source-level invariants: +the ones that, if broken, would let the tab drift back to a generic string +or put the view name before the project. + + python3 -m unittest discover -s tests -v +""" + +from __future__ import annotations + +import json +import os +import re +import subprocess +import sys +import unittest +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] +CORE = REPO / "manager" / "core" +BOARD = CORE / "board.html" + +# BOARD_TITLE unset in the process environment is "nothing configured": +# process env beats local/.env, so this also neutralizes a developer's own +# override leaking into the defaults test. +UNSET = {"BOARD_TITLE": ""} + + +def _probe(expression: str, settings: dict) -> object: + """Evaluate an expression against config/httpd in a fresh interpreter, + with the given settings in the environment config reads at import.""" + env = dict(os.environ) + env.update(UNSET) + env.update(settings) + out = subprocess.check_output( + [sys.executable, "-c", + "import sys, json; sys.path.insert(0, sys.argv[1]); " + "import config, httpd; print(json.dumps(eval(sys.argv[2])))", + str(CORE), expression], + env=env, text=True) + return json.loads(out) + + +def _title_of(settings: dict) -> str: + """The text of the page the board would serve.""" + page = _probe("httpd.page_bytes().decode('utf-8')", settings) + match = re.search(r"<title>(.*?)", page, re.DOTALL) + assert match, "the served page lost its " + return match.group(1) + + +class ProjectName(unittest.TestCase): + def test_defaults_to_the_repo_directory_name(self): + """The name nobody has to configure: what the checkout is called.""" + self.assertEqual(_probe("config.PROJECT", {}), REPO.name) + + def test_board_title_overrides_it(self): + """For people whose checkout directories are all called "app".""" + self.assertEqual(_probe("config.PROJECT", {"BOARD_TITLE": "payments"}), + "payments") + + def test_a_blank_setting_is_not_a_blank_title(self): + """An empty or whitespace-only value means "not configured", not + "call this board nothing".""" + self.assertEqual(_probe("config.PROJECT", {"BOARD_TITLE": " "}), + REPO.name) + + def test_the_state_payload_carries_it(self): + """The browser needs it too — the view switcher rewrites the title + without refetching the page.""" + self.assertEqual(_probe("httpd.state_payload()['project']", + {"BOARD_TITLE": "payments"}), "payments") + + +class ServedTitle(unittest.TestCase): + def test_the_title_is_rendered_into_the_page(self): + """Server-rendered, so the tab is right on first paint rather than + flickering from generic to named on every refresh.""" + self.assertEqual(_title_of({"BOARD_TITLE": "payments"}), + "payments · bench") + + def test_two_projects_get_two_titles(self): + """The whole point: distinguishable in the tab bar, in cmd-tab and + in history — and distinguishable by their *first* word.""" + a = _title_of({"BOARD_TITLE": "projectA"}) + b = _title_of({"BOARD_TITLE": "projectB"}) + self.assertNotEqual(a, b) + self.assertTrue(a.startswith("projectA") and b.startswith("projectB"), + f"the project must lead the title, got {a!r} / {b!r}") + + def test_the_default_title_names_this_checkout(self): + self.assertEqual(_title_of({}), f"{REPO.name} · bench") + + def test_a_project_name_cannot_inject_markup(self): + """The name comes from a directory or a settings file, both of which + can hold anything — it is escaped, not spliced.""" + title = _title_of({"BOARD_TITLE": "<script>x</script>"}) + self.assertNotIn("<script>", title) + self.assertIn("<script>", title) + + def test_nothing_else_in_the_page_is_disturbed(self): + """Only the title element is rewritten; the rest is the file.""" + page = _probe("httpd.page_bytes().decode('utf-8')", + {"BOARD_TITLE": "payments"}) + source = BOARD.read_text(encoding="utf-8") + strip = lambda s: re.sub(r"<title>.*?", "", s, count=1, + flags=re.DOTALL) + self.assertEqual(strip(page), strip(source)) + + +class PageInvariants(unittest.TestCase): + """board.html's own half: the fallback title and the code that keeps it + in step with the view switcher.""" + + @classmethod + def setUpClass(cls): + cls.html = BOARD.read_text(encoding="utf-8") + + def test_the_shipped_title_is_not_the_old_generic_string(self): + """"Bench — task board" in two tabs was the bug.""" + self.assertNotIn("task board", self.html.lower()) + + def test_the_title_is_only_ever_written_from_the_project(self): + """One writer, and it starts with the project — so no code path can + put the view name first or revert to the generic string.""" + writes = re.findall(r"document\.title\s*=\s*([^\n;]+)", self.html) + self.assertEqual(len(writes), 1, + f"expected one document.title assignment, got {writes}") + self.assertTrue(writes[0].startswith("S.state.project"), + f"the project must lead the title, got {writes[0]!r}") + + def test_a_stateless_page_keeps_the_served_title(self): + """Before the first state load there is nothing better to say than + what the server already rendered.""" + body = re.search(r"function renderTitle\(\)\s*\{(.*?)\n\}", + self.html, re.DOTALL) + self.assertIsNotNone(body, "board.html lost renderTitle()") + self.assertIn("if (!S.state?.project) return;", body.group(1), + "renderTitle must bail out rather than write a " + "project-less title") + + def test_every_view_has_a_tail(self): + """The switcher may suffix, but the board view keeps the name people + bookmarked: " · bench".""" + table = re.search(r"const VIEW_TITLES = \{(.*?)\};", self.html, re.DOTALL) + self.assertIsNotNone(table, "board.html lost VIEW_TITLES") + tails = dict(re.findall(r"(\w+): '([^']+)'", table.group(1))) + views = set(re.findall(r'data-view="(\w+)"', self.html)) + self.assertEqual(set(tails), views, + "every view in the switcher needs a title tail") + self.assertEqual(tails["board"], "bench") + + def test_the_title_follows_every_render(self): + """render() runs on state loads and on view switches alike, so + hanging renderTitle off it covers both.""" + body = re.search(r"function render\(\)\s*\{(.*?)\n\}", self.html, re.DOTALL) + self.assertIsNotNone(body, "board.html lost render()") + self.assertIn("renderTitle();", body.group(1)) + + +if __name__ == "__main__": + unittest.main()