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()