From 72a0d4195f03147908ca9b8114fed4d6f2512786 Mon Sep 17 00:00:00 2001 From: istos Date: Fri, 31 Jul 2026 16:12:47 +0200 Subject: [PATCH] board: put the running-agent count in the tab title MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A running agent was visible on the board and nowhere else, so switching tabs — the normal thing to do while one works for minutes — left bench saying the same string whether three agents ran or none. The count now leads the title (`2◌ · bench · bench`), ahead of even the project: the reasoning that put the project first (truncation eats the tail) applies harder to the one state you want from a tab you are not looking at. The mark is the in-flight glyph the CI and copilot chips already wear, not an emoji, and a quiet board keeps the plain title byte for byte. Both the tab and the header chip now count through one runningAgents(), so they cannot disagree, and the title is written only when the string actually changed — render() runs on every SSE frame. Behaviour is covered in node (skipped where node is absent); the wiring that keeps one filter and one writer is a source-level invariant, as elsewhere for board.html. --- AGENTS.md | 8 ++ manager/core/board.html | 33 +++++- tests/test_board_title.py | 13 +- tests/test_tab_agents.py | 241 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 288 insertions(+), 7 deletions(-) create mode 100644 tests/test_tab_agents.py diff --git a/AGENTS.md b/AGENTS.md index 0f6f3b5..2f9dd13 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -208,6 +208,14 @@ 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`). +While agents are running the title leads with how many — `2◌ · +· bench` — because a backgrounded tab is the only place that state can +still be read, and the reasoning that put the project first applies +harder to it: a tab narrowed to a few characters still shows the count. +It is the same list the header's live chip counts, so the two can never +disagree, it rides every view, and a board with nothing running has +exactly the plain title above, byte for byte. + 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, whether moves claim and commit themselves, diff --git a/manager/core/board.html b/manager/core/board.html index a97927f..f7b1a54 100644 --- a/manager/core/board.html +++ b/manager/core/board.html @@ -818,8 +818,14 @@ function scheduleRender() { /* ── header ───────────────────────────────────────────────────────────── */ +/* One reading of "an agent is working", shared by the header chip and the + tab title, so the two can never disagree about how many. */ +function runningAgents() { + return (S.state?.agents || []).filter(a => a.status === 'running'); +} + function renderChip() { - const agents = (S.state?.agents || []).filter(a => a.status === 'running'); + const agents = runningAgents(); const liveYou = sessionsOf(m => isLiveSession(m) && !m.agentId); const el = $('#livechip'); if (S.sseDown) { @@ -867,12 +873,33 @@ function setView(view) { /* 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. */ + Without a project in state the server-rendered title stands. + + Ahead of even the project, while and only while agents run, the count — + the one state you want most from a tab you are not looking at, and the + only place a backgrounded window can still say it. The same reasoning + that put the project first applies harder to it, so it leads: a tab + narrowed to a few characters still shows it. It is the in-flight glyph + the CI and copilot chips already use, not an emoji, and a quiet board + is the plain title, byte for byte. */ const VIEW_TITLES = { board: 'bench', flight: 'sessions', focus: 'focus' }; +const WORKING_MARK = '◌'; + +function tabTitle(project, view, working) { + return (working ? working + WORKING_MARK + ' · ' : '') + + project + ' · ' + (VIEW_TITLES[view] || 'bench'); +} + +/* render() runs on every SSE frame, so the title is written only when it + actually changed rather than dozens of times a second. */ +let shownTitle = null; function renderTitle() { if (!S.state?.project) return; - document.title = S.state.project + ' · ' + (VIEW_TITLES[S.view] || 'bench'); + const title = tabTitle(S.state.project, S.view, runningAgents().length); + if (title === shownTitle) return; + shownTitle = title; + document.title = title; } function render() { diff --git a/tests/test_board_title.py b/tests/test_board_title.py index c274165..c56a1cc 100644 --- a/tests/test_board_title.py +++ b/tests/test_board_title.py @@ -127,13 +127,18 @@ class PageInvariants(unittest.TestCase): 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.""" + """One writer, and the string it writes is built by tabTitle() from + the project — so no code path can revert to the generic string. That + the project leads everything but the running count (task 39) is + tested in test_tab_agents.py.""" 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}") + body = re.search(r"function renderTitle\(\)\s*\{(.*?)\n\}", + self.html, re.DOTALL) + self.assertIsNotNone(body, "board.html lost renderTitle()") + self.assertIn("tabTitle(S.state.project", body.group(1), + "the title must be built from the project") def test_a_stateless_page_keeps_the_served_title(self): """Before the first state load there is nothing better to say than diff --git a/tests/test_tab_agents.py b/tests/test_tab_agents.py new file mode 100644 index 0000000..8e965a3 --- /dev/null +++ b/tests/test_tab_agents.py @@ -0,0 +1,241 @@ +"""The tab title carries how many agents are working (task 39). + +A running agent was visible on the board and nowhere else, so the moment +you switched tabs — the normal thing to do while an agent works for +minutes — bench said the same string whether three agents ran or none. +The count now leads the title, ahead of the project, because a truncated +tab must still show it. + +Two halves, as elsewhere for board.html. The behaviour — what the string +is for 0, 1 and N agents, which set is counted, and that document.title +is written only when it changed — is exercised for real: the pieces are +lifted out of the page and run in node, skipped where node is absent. +The wiring (both readers of "running" going through one function) is a +source-level invariant, board.html being a single file with inline JS and +no frontend test runner. + + python3 -m unittest discover -s tests -v +""" + +from __future__ import annotations + +import json +import re +import shutil +import subprocess +import unittest +from pathlib import Path + +BOARD = Path(__file__).resolve().parents[1] / "manager" / "core" / "board.html" + +# The pieces the title is made of, lifted from the page as written. +PARTS = ( + r"function runningAgents\(\) \{.*?\n\}", + r"const VIEW_TITLES = \{[^\n]*\};", + r"const WORKING_MARK = '[^']+';", + r"function tabTitle\(project, view, working\) \{.*?\n\}", + r"let shownTitle = null;", + r"function renderTitle\(\) \{.*?\n\}", +) + + +def _harness() -> str: + html = BOARD.read_text(encoding="utf-8") + out = [] + for pattern in PARTS: + m = re.search(pattern, html, re.S) + if m is None: + raise AssertionError(f"board.html no longer defines {pattern!r}") + out.append(m.group(0)) + return "\n".join(out) + + +def _running(n: int) -> list: + """n agent records the board would call running, plus noise that is not: + a finished run and a failed one.""" + agents = [{"status": "running", "started": 1000 + i} for i in range(n)] + return agents + [{"status": "done"}, {"status": "failed"}] + + +class TitleBehaviour(unittest.TestCase): + """What the tab actually says, run as the browser would run it.""" + + @classmethod + def setUpClass(cls): + cls.node = shutil.which("node") + if not cls.node: + raise unittest.SkipTest("node not available — tab title unrun") + cls.src = _harness() + + def titles(self, frames: list) -> list: + """document.title after each frame — a frame being the state and + view the page would render — with every write recorded, so a title + assigned twice with the same string shows up as two entries.""" + script = (self.src + """ +var writes = []; +var document = { set title(v) { writes.push(v); }, get title() { + return writes.length ? writes[writes.length - 1] : null; } }; +var S = {}; +for (const frame of """ + json.dumps(frames) + """) { + S = { state: frame.state, view: frame.view || 'board' }; + renderTitle(); +} +console.log(JSON.stringify(writes)); +""") + out = subprocess.run([self.node, "-e", script], + capture_output=True, text=True) + self.assertEqual(out.returncode, 0, out.stderr) + return json.loads(out.stdout) + + def title(self, state: dict, view: str = "board") -> str: + """The one title a single frame produces.""" + writes = self.titles([{"state": state, "view": view}]) + self.assertEqual(len(writes), 1, f"expected one write, got {writes}") + return writes[0] + + def test_a_quiet_board_is_the_plain_title(self): + """Byte for byte what it was before this existed — no prefix, no + separator, no space. A board with nothing running looks untouched.""" + self.assertEqual(self.title({"project": "bench", "agents": []}), + "bench · bench") + self.assertEqual( + self.title({"project": "bench", "agents": _running(0)}), + "bench · bench") + self.assertEqual(self.title({"project": "bench"}), "bench · bench") + + def test_the_count_leads_the_title(self): + """Ahead of the project, because a tab truncated to a few characters + must still answer the question this card exists for.""" + title = self.title({"project": "bench", "agents": _running(3)}) + self.assertTrue(title.startswith("3"), + f"the count must lead the title, got {title!r}") + self.assertEqual(title, "3◌ · bench · bench") + + def test_one_agent_reads_as_one(self): + """A count and a mark rather than a word, so "1 agents" is not a + string this code can produce.""" + title = self.title({"project": "bench", "agents": _running(1)}) + self.assertEqual(title, "1◌ · bench · bench") + self.assertNotIn("agents", title) + + def test_only_running_agents_are_counted(self): + """The same filter the header chip applies — see the wiring test + below for why they cannot drift apart.""" + state = {"project": "bench", + "agents": [{"status": "done"}, {"status": "failed"}, + {"status": "running"}]} + self.assertEqual(self.title(state), "1◌ · bench · bench") + + def test_the_project_still_names_which_bench_this_is(self): + """The thing the tab already did well survives the prefix.""" + a = self.title({"project": "projectA", "agents": _running(2)}) + b = self.title({"project": "projectB", "agents": _running(2)}) + self.assertEqual(a, "2◌ · projectA · bench") + self.assertNotEqual(a, b) + + def test_every_view_carries_the_count(self): + """It describes the board, not the view, so switching views changes + only the view word.""" + state = {"project": "bench", "agents": _running(2)} + self.assertEqual( + [self.title(state, v) for v in ("board", "flight", "focus")], + ["2◌ · bench · bench", "2◌ · bench · sessions", + "2◌ · bench · focus"]) + + def test_a_finished_run_returns_the_tab_to_the_plain_title(self): + """No reload: the frame after the last agent stops is a plain + title again.""" + busy = {"project": "bench", "agents": _running(1)} + quiet = {"project": "bench", "agents": _running(0)} + self.assertEqual( + self.titles([{"state": busy}, {"state": quiet}]), + ["1◌ · bench · bench", "bench · bench"]) + + def test_a_stateless_page_keeps_the_served_title(self): + """Before state arrives there is nothing better to say than what the + server rendered, so nothing is written at all.""" + self.assertEqual(self.titles([{"state": None}, {"state": {}}]), []) + + def test_the_title_is_written_only_when_it_changed(self): + """render() runs on every SSE frame; a burst with nothing moving + must not touch document.title once.""" + busy = {"project": "bench", "agents": _running(2)} + frames = [{"state": busy} for _ in range(20)] + self.assertEqual(self.titles(frames), ["2◌ · bench · bench"]) + + def test_a_changed_count_is_written_again(self): + """The cache is a cache, not a latch: every distinct title lands, + including a return to one seen before.""" + def frame(n, view="board"): + return {"state": {"project": "bench", "agents": _running(n)}, + "view": view} + self.assertEqual( + self.titles([frame(1), frame(1), frame(2), frame(2), + frame(2, "focus"), frame(0), frame(1)]), + ["1◌ · bench · bench", "2◌ · bench · bench", + "2◌ · bench · focus", "bench · bench", "1◌ · bench · bench"]) + + +class Wiring(unittest.TestCase): + """board.html's own half: what keeps the tab and the header chip from + counting two different things, and the mark from becoming an emoji.""" + + @classmethod + def setUpClass(cls): + cls.html = BOARD.read_text(encoding="utf-8") + + def test_one_filter_answers_how_many_are_running(self): + """Both counters go through runningAgents(); a second inline filter + is how the tab and the chip would start disagreeing. (Asking which + agent holds one card is a different question and stays where it + is.)""" + filters = re.findall(r"\.filter\(a => a\.status === 'running'\)", + self.html) + self.assertEqual(len(filters), 1, + "only runningAgents() may filter for running agents, " + f"found {len(filters)} such filters") + body = re.search(r"function runningAgents\(\) \{(.*?)\n\}", + self.html, re.DOTALL) + self.assertIsNotNone(body, "board.html lost runningAgents()") + self.assertIn("a.status === 'running'", body.group(1)) + + def test_the_header_chip_counts_that_same_list(self): + chip = re.search(r"function renderChip\(\) \{(.*?)\n\}", + self.html, re.DOTALL) + self.assertIsNotNone(chip, "board.html lost renderChip()") + self.assertIn("runningAgents()", chip.group(1)) + + def test_the_tab_counts_that_same_list(self): + title = re.search(r"function renderTitle\(\) \{(.*?)\n\}", + self.html, re.DOTALL) + self.assertIsNotNone(title, "board.html lost renderTitle()") + self.assertIn("runningAgents()", title.group(1)) + + def test_the_mark_is_the_boards_own_in_flight_glyph(self): + """Not an emoji: the same ◌ the CI and copilot chips wear while + something is in flight.""" + mark = re.search(r"const WORKING_MARK = '([^']+)';", self.html) + self.assertIsNotNone(mark, "board.html lost WORKING_MARK") + self.assertEqual(mark.group(1), "◌") + self.assertGreater(len(re.findall("◌", self.html)), 1, + "the mark should already be in use on the chips") + + def test_the_prefix_is_the_only_thing_ahead_of_the_project(self): + """One writer, and everything it can put before the project name is + the count and the mark — no view name, no 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}") + built = re.search(r"function tabTitle\(project, view, working\) \{" + r"(.*?)\n\}", self.html, re.DOTALL) + self.assertIsNotNone(built, "board.html lost tabTitle()") + before, _, after = built.group(1).partition("project +") + self.assertTrue(after, "tabTitle must put the project in the title") + self.assertNotIn("VIEW_TITLES", before, + "the view name may never precede the project") + self.assertIn("working", before, + "only the running count precedes the project") + + +if __name__ == "__main__": + unittest.main()