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:
@@ -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":
|
||||
|
||||
Reference in New Issue
Block a user