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:
@@ -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,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
@@ -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();
|
||||
|
||||
@@ -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
@@ -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":
|
||||
|
||||
@@ -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("<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>.*?</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()
|
||||
Reference in New Issue
Block a user