From 8ade3cd777716ef13a9e98117ed02afccb3da02a Mon Sep 17 00:00:00 2001 From: maxlamagna Date: Thu, 20 Aug 2026 07:08:23 +0100 Subject: [PATCH] feat(header): optional per-server subtitle so several instances can be told apart Running one agentchattr install across several projects, as the per-project isolation section describes, leaves you with identical tabs and identical headers. There is no way to tell which server a tab belongs to. This fills in the #room-subtitle span that already exists in the header but has never had anything put into it. A new "This server" field in Settings sets it, it renders beside the title, and it is appended to the browser tab title as "agentchattr - name". Empty by default, which is today's appearance exactly, so nothing changes for anyone who ignores it. The value is normalised on the way in: internal whitespace is collapsed so a pasted newline cannot break the header across two lines, and it is capped at 40 characters so a long name cannot crowd out the rest of the header. Normalisation also runs on the settings-file read path, because data/settings.json is hand-editable and would otherwise bypass every check the live update path applies. It is rendered with textContent, not innerHTML. This commit also changes an existing rule of yours, and it is easy to drop if you would rather it stayed as it is. The .subtitle rule is 12px, normal weight, in --text-dim, which is #6a6a80 against a dark header. Because nothing ever populated the span, that styling had never actually been rendered, and it is hard to read once there is text in it. This raises it to weight 500 and --text. It stays clearly secondary to the title, which is 16px at weight 600. static/index.html:22 is the only element carrying the class, so nothing else in the UI moves. Tests: 12 new in tests/test_room_name.py, covering normalisation, the cap, the settings-file read path, and the default-empty behaviour. One declared gap: the single line wiring the subtitle into the websocket update_settings handler is not covered. Testing it needs either a live websocket or extracting that inline settings block, and neither belongs in this change. The normalisation it delegates to is covered directly. --- README.md | 2 + app.py | 31 +++++++++++++ static/chat.js | 13 +++++- static/index.html | 8 +++- static/style.css | 3 +- tests/test_room_name.py | 99 +++++++++++++++++++++++++++++++++++++++++ 6 files changed, 151 insertions(+), 5 deletions(-) create mode 100644 tests/test_room_name.py diff --git a/README.md b/README.md index 3848361b..f3da8a38 100644 --- a/README.md +++ b/README.md @@ -477,6 +477,8 @@ Relative paths resolve against the shell's current directory (not agentchattr's Server and wrappers share the same `AGENTCHATTR_*` env vars and the same flag names, so a launcher/profile can run multiple isolated instances by passing matching values to each process. If no flags or env vars are set, `config.toml` is used exactly as before — zero change for existing setups. +With several instances open at once, set **Settings → This server** to give each one a short name. It appears beside the title in the header and in the browser tab title, so you can tell which project a tab belongs to. It is empty by default, which looks exactly as it does now. + ### API agents (local models) Connect any local model with an OpenAI-compatible API (Ollama, llama-server, LM Studio, vLLM, etc.) to the chat room. API agents get status pills, activity indicators, @mention routing, and multi-instance support — just like the CLI agents. diff --git a/app.py b/app.py index 60a6434e..2455384d 100644 --- a/app.py +++ b/app.py @@ -49,6 +49,9 @@ # Room settings (persisted to data/settings.json) room_settings: dict = { "title": "agentchattr", + # Shown beside the title, so several servers can be told apart without + # renaming the product itself. "" hides it. + "subtitle": "", "username": "user", "font": "sans", "channels": ["general"], @@ -124,6 +127,30 @@ def _settings_path() -> Path: return Path(data_dir) / "settings.json" +ROOM_SUBTITLE_MAX = 40 + + +def normalize_room_subtitle(value): + """Clean a room subtitle, or return None if it is not usable. + + Collapses any internal whitespace so a pasted newline cannot break the + header across two lines, and caps the length so a long name cannot crowd + out the rest of the header. + """ + if not isinstance(value, str): + return None + return " ".join(value.split())[:ROOM_SUBTITLE_MAX] + + +def apply_room_subtitle(new: dict): + """Apply a subtitle from a settings payload, if present and usable.""" + if "subtitle" not in new: + return + cleaned = normalize_room_subtitle(new["subtitle"]) + if cleaned is not None: + room_settings["subtitle"] = cleaned + + def _load_settings(): global room_settings p = _settings_path() @@ -133,6 +160,9 @@ def _load_settings(): room_settings.update(saved) except Exception: pass + # settings.json is hand-editable, so the file bypasses every check the + # live update path applies. Re-normalise on load rather than trust it. + room_settings["subtitle"] = normalize_room_subtitle(room_settings.get("subtitle")) or "" # Ensure "general" always exists and is first if "channels" not in room_settings or not room_settings["channels"]: room_settings["channels"] = ["general"] @@ -1265,6 +1295,7 @@ async def websocket_endpoint(websocket: WebSocket): new = event.get("data", {}) if "title" in new and isinstance(new["title"], str): room_settings["title"] = new["title"].strip() or "agentchattr" + apply_room_subtitle(new) if "username" in new and isinstance(new["username"], str): room_settings["username"] = new["username"].strip() or "user" if "font" in new and new["font"] in ("mono", "serif", "sans"): diff --git a/static/chat.js b/static/chat.js index 338653c4..9b048868 100644 --- a/static/chat.js +++ b/static/chat.js @@ -1804,7 +1804,14 @@ let pendingChannelSwitch = null; function applySettings(data) { if (data.title) { document.getElementById('room-title').textContent = data.title; - document.title = data.title; + // Tab title carries both names, so several servers are distinguishable + document.title = data.subtitle ? data.title + ' - ' + data.subtitle : data.title; + } + if (data.subtitle !== undefined) { + const subEl = document.getElementById('room-subtitle'); + if (subEl) subEl.textContent = data.subtitle; + const subInput = document.getElementById('setting-subtitle'); + if (subInput) subInput.value = data.subtitle; } if (data.username) { username = data.username; @@ -1929,6 +1936,7 @@ function clearChat() { function saveSettings() { const newUsername = document.getElementById('setting-username').value.trim(); + const newSubtitle = document.getElementById('setting-subtitle').value.trim(); const newFont = document.getElementById('setting-font').value; const newHops = document.getElementById('setting-hops').value; const histVal = document.getElementById('setting-history').value; @@ -1941,6 +1949,7 @@ function saveSettings() { type: 'update_settings', data: { username: newUsername || 'user', + subtitle: newSubtitle, font: newFont, max_agent_hops: parseInt(newHops) || 4, history_limit: newHistory, @@ -1953,7 +1962,7 @@ function saveSettings() { function setupSettingsKeys() { // Auto-save on blur/Enter for text/number fields - for (const id of ['setting-username', 'setting-hops']) { + for (const id of ['setting-username', 'setting-subtitle', 'setting-hops']) { const el = document.getElementById(id); el.addEventListener('blur', () => saveSettings()); el.addEventListener('keydown', (e) => { diff --git a/static/index.html b/static/index.html index f015bf49..de5b69aa 100644 --- a/static/index.html +++ b/static/index.html @@ -4,7 +4,7 @@ agentchattr - + @@ -63,6 +63,10 @@

agentchattr

+
+ + +