Merge pull request #32 from 12vectors/task/39-the-tab-says-whether-agents-are-working
39 — The browser tab says whether agents are working
This commit is contained in:
@@ -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 (`<project> · sessions`).
|
||||
|
||||
While agents are running the title leads with how many — `2◌ · <project>
|
||||
· 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,
|
||||
|
||||
+30
-3
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user