From f7917bdde37fe089485bb2b65c5504315dcc9c56 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Wed, 2 Sep 2026 00:38:27 +0200 Subject: fix(hub): fall back to the group's first app when the landing tab is absent A group could open on a tab that rendered nothing: no panel, no tab shown active, and nothing on screen to explain it. The landing tab is chosen at mount from a preference -- default_tab for the group, else the account-wide one, else 'chat'. Which applications the group runs comes from the node, in the handshake ack, several awaits later. A preference is a preference, not a promise that the app exists here, so the two disagree in two ordinary cases: the group has Chat disabled while 'chat' is everyone's default, or the reader prefers an app this group does not run. `apps.map(a => tab === a.key && ...)` then matches nothing. The first app the group does offer answers both. Two more cases come free: a preference naming an app that no longer exists, and an operator disabling the app someone is currently looking at -- enabledApps changes live over apps_enabled, and being moved to a working tab beats staring at an empty panel. Settings is exempt: it is not an application, and the create-group wizard lands on it deliberately. `const apps` moves above the effect that reads it; a const further down would be in its temporal dead zone, which is the hook-ordering trap already recorded in CLAUDE.md. tests/harness/group_tab_probe.py renders the real GroupPage against a stub node answering a chosen enabled_apps and reads the tab bar back, over five cases. With the fix reverted the three fallback cases report no active tab at all and four of the six tests fail; the two that pass either way are the ones that must not change -- a group running everything, and a preference the group does honour (Videos stays selected, so the fallback has not become "always the first app"). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01W8oRqEHhnKUr1NfmTVdcyL --- .../meshbay-hub/tests/harness/group_tab_probe.py | 185 +++++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 packages/meshbay-hub/tests/harness/group_tab_probe.py (limited to 'packages/meshbay-hub/tests/harness/group_tab_probe.py') diff --git a/packages/meshbay-hub/tests/harness/group_tab_probe.py b/packages/meshbay-hub/tests/harness/group_tab_probe.py new file mode 100644 index 0000000..5e4e452 --- /dev/null +++ b/packages/meshbay-hub/tests/harness/group_tab_probe.py @@ -0,0 +1,185 @@ +#!/usr/bin/env python3 +""" +Which tab does a group actually open on? + +The landing tab is chosen from a preference before the node has said which +applications the group runs, so the two can disagree — the group has Chat +disabled while 'chat' is the default, or the reader's preferred app is one this +group does not offer. Both used to land on a tab that rendered *nothing*: no +panel, no tab shown active, and nothing on screen to say why. + +That is not visible in the source, where the tab is a plain string and the app +list arrives from a handshake several `await`s later. So this renders the real +`GroupPage` — the shipped module — against a stub node that answers with a +chosen `enabled_apps`, and reports the tab that ended up active. + + group_tab_probe.py + +Prints JSON: one entry per case, with the tab bar as rendered and whether a +panel was drawn under it. +""" +import http.server +import json +import socketserver +import subprocess +import sys +import tempfile +import threading +import time +from pathlib import Path + +STATIC = Path(__file__).resolve().parents[2] / "src" / "meshbay_hub" / "static" +PORT = 8748 +RECORDS = [] +socketserver.TCPServer.allow_reuse_address = True + +# name -> (the reader's default_tab preference, what the group runs) +CASES = [ + ("every app, default chat", "chat", None), + ("chat disabled", "chat", ["files", "video"]), + ("preferred app absent", "music", ["chat", "files"]), + ("preferred app present", "video", ["chat", "files", "video"]), + ("preference names nothing real", "nonsense", ["files", "music"]), +] + +FRAME = r""" + +
+ +""" + +PAGE = r""" +
""" + + +class H(http.server.BaseHTTPRequestHandler): + def log_message(self, *a): + pass + + def do_POST(self): + length = int(self.headers.get("Content-Length") or 0) + if self.path == "/log": + RECORDS.append(json.loads(self.rfile.read(length).decode())) + else: + self.rfile.read(length) + self.send_response(204) + self.end_headers() + + def _send(self, body: bytes, ctype: str) -> None: + self.send_response(200) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_GET(self): + path = self.path.split("?")[0] + if path == "/": + self._send((PAGE % {"count": len(CASES)}).encode(), "text/html; charset=utf-8") + elif path == "/case": + index = int(self.path.split("n=")[1]) + _, pref, enabled = CASES[index] + self._send((FRAME % {"index": index, + "pref": json.dumps(pref), + "enabled": json.dumps(enabled)}).encode(), + "text/html; charset=utf-8") + elif path == "/v1/groups/g1/nodes": + self._send(b'{"nodes": [{"node_id": "n1"}]}', "application/json") + else: + asset = (STATIC / path.lstrip("/")).resolve() + if not str(asset).startswith(str(STATIC)) or not asset.is_file(): + self.send_response(404) + self.end_headers() + return + self._send(asset.read_bytes(), + "text/css" if asset.suffix == ".css" + else "text/javascript" if asset.suffix == ".js" + else "application/octet-stream") + + +def main() -> int: + with socketserver.TCPServer(("127.0.0.1", PORT), H) as srv: + threading.Thread(target=srv.serve_forever, daemon=True).start() + with tempfile.TemporaryDirectory() as profile: + proc = subprocess.Popen( + ["google-chrome", "--headless=new", "--disable-gpu", "--no-sandbox", + f"--user-data-dir={profile}", "--window-size=1100,900", + f"http://127.0.0.1:{PORT}/"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + for _ in range(300): + if RECORDS: + break + time.sleep(0.1) + proc.terminate() + try: + proc.wait(timeout=10) + except subprocess.TimeoutExpired: + proc.kill() + if not RECORDS: + print(json.dumps({"error": "no measurement"}), file=sys.stderr) + return 1 + by_case = {r["case"]: r for r in RECORDS[0]} + print(json.dumps( + [dict(name=CASES[i][0], preference=CASES[i][1], enabled=CASES[i][2], + **by_case[i]) for i in sorted(by_case)], indent=1)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) -- cgit v1.2.3