Card 22: the tab names its project

Every board tab read "Bench — task board", so the moment a second bench
existed the tab bar stopped saying which was which. The title now leads
with the project — "<project> · bench" — because tab truncation eats the
tail and the tail is the same in every bench tab.

The project is config.PROJECT: the repo directory's name, or BOARD_TITLE
from local/.env for people whose checkouts are all called "app". The
server renders it into the served page's <title>, so the tab is right on
first paint with no flicker from generic to named; /api/state carries it
too, and renderTitle() keeps it in step when the view switcher swaps the
tail (sessions, focus). The project stays the first word regardless, and
nothing else writes document.title.

Tests: tests/test_board_title.py covers the server half in fresh
interpreters (BOARD_TITLE resolution, the rendered title, escaping, the
rest of the page untouched) and the browser half as source invariants,
the same way the other board.html tests work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
istos
2026-07-30 09:11:58 +02:00
co-authored by Claude Opus 5
parent a647b849e8
commit de5cedb31f
6 changed files with 216 additions and 4 deletions
+7
View File
@@ -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:
`<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 (`<project> · 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
+6
View File
@@ -6,6 +6,12 @@
# everywhere at once.
BOARD_PORT=26071
# What the tab calls this project: the title is "<project> · 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/<name>, overridable
# in local/adapters/<name>). Ships with: claude, opencode.
BOARD_AGENT_ADAPTER=claude
+14 -1
View File
@@ -3,7 +3,9 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Bench — task board</title>
<!-- The server rewrites this to "<project> · bench"; the view switcher keeps
it in step. Project first — tab truncation eats the tail. -->
<title>bench</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><rect width='16' height='16' rx='4' fill='%230d6e8c'/><circle cx='8' cy='8' r='3' fill='%23e9f3f3'/></svg>">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
@@ -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();
+5
View File
@@ -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")
+16 -3
View File
@@ -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"<title>.*?</title>", re.DOTALL)
def page_bytes() -> bytes:
"""board.html with the project's name rendered into its <title>, 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"</title>", 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":
+168
View File
@@ -0,0 +1,168 @@
"""The tab names its project (task 22): the title is "<project> · 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 <title> text of the page the board would serve."""
page = _probe("httpd.page_bytes().decode('utf-8')", settings)
match = re.search(r"<title>(.*?)</title>", page, re.DOTALL)
assert match, "the served page lost its <title>"
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("&lt;script&gt;", 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>.*?</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: "<project> · 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()