From 07d28a62c65cb629f90cbf03cf15ce290caa8f87 Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Thu, 12 Nov 2026 10:20:31 +0000 Subject: [PATCH] fix(orchestrator): keep polling while text stays selected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hub page skipped every 10 s state poll while any text was selected, so operator rows that operator.py run had closed stayed on screen until the selection ended (2026-11-12: no /api/state from 09:29 to 09:54 while the CLI closed 19 rows). A selection now holds the poll back for at most 20 s, and returning to the window reloads at once. 🎯 Fix: 90 % 💥 Break: 3 % 💸 Cost: 100 % ⏱️ Time: 100 % 🛡️ Security: 100 % 🏆 Score: 87 🚦 Gate: ✅ 90/5, cost and time <= 100, security 100 % Co-Authored-By: Claude Opus 5.5 --- orchestrator/templates/page-boot.js | 1 + orchestrator/templates/page-load.js | 7 +++- tests/test_hub_refresh.py | 64 +++++++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 tests/test_hub_refresh.py diff --git a/orchestrator/templates/page-boot.js b/orchestrator/templates/page-boot.js index 61f2efa..4e9f936 100644 --- a/orchestrator/templates/page-boot.js +++ b/orchestrator/templates/page-boot.js @@ -28,6 +28,7 @@ document.addEventListener("pointerdown", unlock); document.addEventListener("keydown", keyboard); document.addEventListener("visibilitychange", refresh); + window.addEventListener("focus", refresh); window.addEventListener("hashchange", apply); window.addEventListener("resize", measure); window.addEventListener("resize", redrawSky); diff --git a/orchestrator/templates/page-load.js b/orchestrator/templates/page-load.js index 15eccbe..60c3c82 100644 --- a/orchestrator/templates/page-load.js +++ b/orchestrator/templates/page-load.js @@ -30,8 +30,13 @@ } } + const SELECTION_HOLD = 20000; + let selectedSince = 0; + function refresh() { - if (!ui.busy && !ui.playing && !document.hidden && String(window.getSelection()).length === 0) { + const selected = String(window.getSelection()).length > 0; + selectedSince = selected ? selectedSince || Date.now() : 0; + if (!ui.busy && !ui.playing && !document.hidden && (!selected || Date.now() - selectedSince >= SELECTION_HOLD)) { load(); } } diff --git a/tests/test_hub_refresh.py b/tests/test_hub_refresh.py new file mode 100644 index 0000000..97831bc --- /dev/null +++ b/tests/test_hub_refresh.py @@ -0,0 +1,64 @@ +import json +import subprocess +import unittest +from pathlib import Path + +TEMPLATES = Path(__file__).resolve().parents[1] / "orchestrator" / "templates" +LOAD = TEMPLATES / "page-load.js" +BOOT = TEMPLATES / "page-boot.js" +PROBE = """ +const fs = require("fs"); +const vm = require("vm"); +const code = fs.readFileSync(process.argv[1], "utf8"); +const steps = JSON.parse(process.argv[2]); +const loads = []; +const page = {now: 0, selection: ""}; +const context = vm.createContext({ + ui: {busy: false, playing: false}, + document: {hidden: false}, + window: {getSelection: () => page.selection}, + Date: {now: () => page.now * 1000}, +}); +vm.runInContext(code, context); +context.load = () => loads.push(page.now); +for (const step of steps) { + page.now = step.at; + page.selection = step.selection || ""; + context.ui.busy = Boolean(step.busy); + context.document.hidden = Boolean(step.hidden); + vm.runInContext("refresh()", context); +} +console.log(JSON.stringify(loads)); +""" + + +def loads(steps: list[dict]) -> list[int]: + done = subprocess.run(["node", "-e", PROBE, str(LOAD), json.dumps(steps)], capture_output=True, text=True, timeout=60, check=True) + return json.loads(done.stdout) + + +class TestHubRefresh(unittest.TestCase): + def test_a_selection_holds_the_poll_back_for_twenty_seconds_only(self): + steps = [ + {"at": 0}, + {"at": 10, "selection": "git push"}, + {"at": 20, "selection": "git push"}, + {"at": 30, "selection": "git push"}, + {"at": 40, "selection": "git push"}, + {"at": 50}, + {"at": 60, "selection": "other"}, + {"at": 70, "selection": "other"}, + ] + self.assertEqual(loads(steps), [0, 30, 40, 50]) + + def test_busy_and_hidden_pages_still_skip_the_poll(self): + self.assertEqual(loads([{"at": 0, "busy": True}, {"at": 10, "hidden": True}, {"at": 20}]), [20]) + + def test_returning_to_the_window_reloads_at_once(self): + boot = BOOT.read_text(encoding="utf-8") + self.assertIn('window.addEventListener("focus", refresh);', boot) + self.assertIn('document.addEventListener("visibilitychange", refresh);', boot) + + +if __name__ == "__main__": + unittest.main()