Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
31 changes: 31 additions & 0 deletions app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down Expand Up @@ -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()
Expand All @@ -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"]
Expand Down Expand Up @@ -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"):
Expand Down
13 changes: 11 additions & 2 deletions static/chat.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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,
Expand All @@ -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) => {
Expand Down
8 changes: 6 additions & 2 deletions static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>agentchattr</title>
<link rel="stylesheet" href="/static/style.css?v=251">
<link rel="stylesheet" href="/static/style.css?v=252">
<link rel="stylesheet" href="/static/sessions.css?v=223">
<link rel="stylesheet" href="/static/jobs.css?v=223">
<link rel="icon" href="/static/favicon.ico">
Expand Down Expand Up @@ -63,6 +63,10 @@ <h1 id="room-title">agentchattr</h1>
<label for="setting-username">Name</label>
<input type="text" id="setting-username" placeholder="your name" maxlength="20">
</div>
<div class="settings-field">
<label for="setting-subtitle">This server</label>
<input type="text" id="setting-subtitle" placeholder="e.g. urbly" maxlength="40" title="Shown next to agentchattr, to tell this server apart from others">
</div>
<div class="settings-field">
<label for="setting-font">Font</label>
<select id="setting-font">
Expand Down Expand Up @@ -345,6 +349,6 @@ <h1 id="room-title">agentchattr</h1>
<script src="/static/jobs.js?v=224"></script>
<script src="/static/channels.js?v=225"></script>
<script src="/static/rules-panel.js?v=223"></script>
<script src="/static/chat.js?v=268"></script>
<script src="/static/chat.js?v=269"></script>
</body>
</html>
3 changes: 2 additions & 1 deletion static/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,8 @@ header h1 {

.subtitle {
font-size: 12px;
color: var(--text-dim);
font-weight: 500;
color: var(--text);
}

.header-right {
Expand Down
99 changes: 99 additions & 0 deletions tests/test_room_name.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import sys
import unittest
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))

import app


class RoomSubtitleTests(unittest.TestCase):
"""A second name shown beside 'agentchattr' in the header.

Several servers run at once (one per repo) and every one of them says
'agentchattr', so the tab and header are indistinguishable. This adds a
name alongside the product name rather than replacing it -- the existing
`title` setting already covers a full rename.
"""

def test_a_name_is_kept_as_typed(self):
self.assertEqual(app.normalize_room_subtitle("urbly"), "urbly")

def test_surrounding_whitespace_is_trimmed(self):
self.assertEqual(app.normalize_room_subtitle(" urbly "), "urbly")

def test_an_empty_value_clears_the_name(self):
self.assertEqual(app.normalize_room_subtitle(" "), "")

def test_an_overlong_name_is_capped_rather_than_rejected(self):
result = app.normalize_room_subtitle("x" * 200)

self.assertEqual(len(result), 40)

def test_a_non_string_is_refused_rather_than_coerced(self):
self.assertIsNone(app.normalize_room_subtitle(42))
self.assertIsNone(app.normalize_room_subtitle(None))
self.assertIsNone(app.normalize_room_subtitle(["urbly"]))

def test_newlines_cannot_break_the_header_onto_two_lines(self):
self.assertEqual(app.normalize_room_subtitle("ur\nbly"), "ur bly")

def test_the_default_room_settings_carry_an_empty_name(self):
self.assertEqual(app.room_settings.get("subtitle"), "")

def test_a_settings_payload_reaches_the_room_through_the_validator(self):
"""Covers the wiring, not just the validator in isolation."""
saved = app.room_settings.get("subtitle")
self.addCleanup(lambda: app.room_settings.__setitem__("subtitle", saved))

app.apply_room_subtitle({"subtitle": " urbly\nlab "})

self.assertEqual(app.room_settings["subtitle"], "urbly lab")

def test_a_payload_without_a_subtitle_leaves_the_current_one_alone(self):
saved = app.room_settings.get("subtitle")
self.addCleanup(lambda: app.room_settings.__setitem__("subtitle", saved))
app.room_settings["subtitle"] = "urbly"

app.apply_room_subtitle({"font": "mono"})

self.assertEqual(app.room_settings["subtitle"], "urbly")

def test_an_unusable_subtitle_does_not_overwrite_the_current_one(self):
saved = app.room_settings.get("subtitle")
self.addCleanup(lambda: app.room_settings.__setitem__("subtitle", saved))
app.room_settings["subtitle"] = "urbly"

app.apply_room_subtitle({"subtitle": {"evil": 1}})

self.assertEqual(app.room_settings["subtitle"], "urbly")

def test_a_subtitle_read_from_disk_is_normalised_like_a_typed_one(self):
"""settings.json is hand-editable, so the load path must check too."""
import json
import tempfile

saved_settings = dict(app.room_settings)
saved_config = app.config
self.addCleanup(lambda: setattr(app, "config", saved_config))
self.addCleanup(
lambda: (app.room_settings.clear(), app.room_settings.update(saved_settings))
)

tmp = Path(tempfile.mkdtemp())
(tmp / "settings.json").write_text(json.dumps({"subtitle": "x" * 200}))
app.config = {"server": {"data_dir": str(tmp)}}

app._load_settings()

self.assertEqual(len(app.room_settings["subtitle"]), app.ROOM_SUBTITLE_MAX)

def test_the_product_name_is_not_replaced_by_the_new_setting(self):
"""Control: `title` still means a full rename and is untouched."""
self.assertEqual(app.room_settings.get("title"), "agentchattr")


if __name__ == "__main__":
unittest.main()