diff --git a/CHANGELOG.md b/CHANGELOG.md
index c58107f..a0801e6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,12 @@
---
+## [v0.50.10] Title auto-generation fix + mobile close button (PR #333)
+
+- **Session title now auto-generates for all default title values** (`'Untitled'`, `'New Chat'`, empty string): The condition in `api/streaming.py` that triggers `title_from()` previously only matched `'Untitled'`. It now also covers `'New Chat'` (used by some external clients/forks) and any empty/falsy title, so sessions started from those states get a proper auto-generated title after the first message.
+- **Redundant workspace panel close button hidden on mobile** (`static/style.css`): On viewports ≤900px wide, both the desktop collapse button (`#btnCollapseWorkspacePanel`) and the mobile-specific X button (`.mobile-close-btn`) were rendered simultaneously. The desktop button is now hidden on mobile and `.mobile-close-btn` is hidden by default (desktop) and shown only on mobile — eliminating the duplicate control.
+ - 11 new tests in `tests/test_sprint41.py`; 802 tests total (up from 791)
+
## [v0.50.9] Onboarding works from Docker bridge networks (PR #335, fixes #334)
- **Docker users can now complete onboarding without enabling auth first** (closes #334): The onboarding setup endpoint previously only accepted requests from `127.0.0.1`. Docker containers connect via bridge network IPs (`172.17.x.x`, etc.), so the endpoint returned a 403 mid-wizard with no clear explanation. The check now accepts any loopback or RFC-1918 private address (`127.0.0.0/8`, `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`) using Python's `ipaddress.is_loopback` and `is_private`. Public IPs are still blocked unless auth is enabled.
diff --git a/api/streaming.py b/api/streaming.py
index 567c05c..c080794 100644
--- a/api/streaming.py
+++ b/api/streaming.py
@@ -335,7 +335,7 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
if isinstance(_m, dict) and not _m.get('timestamp') and not _m.get('_ts'):
_m['timestamp'] = int(_now)
# Only auto-generate title when still default; preserves user renames
- if s.title == 'Untitled':
+ if s.title == 'Untitled' or s.title == 'New Chat' or not s.title:
s.title = title_from(s.messages, s.title)
# Read token/cost usage from the agent object (if available)
input_tokens = getattr(agent, 'session_prompt_tokens', 0) or 0
diff --git a/static/index.html b/static/index.html
index a786255..9eec0a4 100644
--- a/static/index.html
+++ b/static/index.html
@@ -526,7 +526,7 @@
System
Instance version and access controls.
- v0.50.9
+ v0.50.10
diff --git a/static/style.css b/static/style.css
index ec5b769..991b577 100644
--- a/static/style.css
+++ b/static/style.css
@@ -466,6 +466,7 @@
.git-badge{font-size:9px;font-weight:600;color:var(--muted);background:var(--hover-bg);padding:2px 7px;border-radius:4px;letter-spacing:.02em;margin-left:auto;margin-right:4px;white-space:nowrap;font-family:'SF Mono',ui-monospace,monospace;}
.git-badge.dirty{color:var(--gold);background:rgba(201,168,76,.1);}
.panel-actions{display:flex;gap:4px;}
+ .mobile-close-btn{display:none;}
.panel-icon-btn{width:24px;height:24px;background:none;border:none;color:var(--muted);cursor:pointer;border-radius:5px;font-size:13px;display:flex;align-items:center;justify-content:center;transition:all .15s;}
.panel-icon-btn:hover{background:rgba(255,255,255,.08);color:var(--text);}
.panel-icon-btn:disabled{opacity:.35;cursor:not-allowed;}
@@ -533,7 +534,12 @@
.layout.workspace-panel-collapsed .rightpanel{width:0 !important;opacity:0;transform:translateX(14px);border-left-color:transparent;pointer-events:none;}
}
- @media(max-width:900px){.rightpanel{display:none}.workspace-toggle-btn,.mobile-files-btn{display:inline-flex!important;}}
+ @media(max-width:900px){
+ .rightpanel{display:none}
+ .workspace-toggle-btn,.mobile-files-btn{display:inline-flex!important;}
+ .mobile-close-btn{display:flex;}
+ #btnCollapseWorkspacePanel{display:none;}
+ }
@media(max-width:640px){
/* ── Sidebar: slide-in overlay instead of hidden ── */
diff --git a/tests/test_sprint41.py b/tests/test_sprint41.py
new file mode 100644
index 0000000..5e0e5f5
--- /dev/null
+++ b/tests/test_sprint41.py
@@ -0,0 +1,129 @@
+"""
+Sprint 41 Tests: Title auto-generation fix + mobile close button CSS (PR #333).
+
+Covers:
+- streaming.py: sessions titled 'New Chat' trigger auto-title generation
+- streaming.py: sessions with empty/falsy title trigger auto-title generation
+- streaming.py: sessions titled 'Untitled' (original guard) still trigger
+- streaming.py: sessions with a user-set title do NOT trigger auto-title
+- style.css: .mobile-close-btn is hidden by default (desktop rule present)
+- style.css: .mobile-close-btn shown in <=900px media query
+- style.css: #btnCollapseWorkspacePanel hidden in <=900px media query
+- index.html: both .mobile-close-btn and #btnCollapseWorkspacePanel buttons exist
+"""
+import pathlib
+import re
+import unittest
+
+REPO_ROOT = pathlib.Path(__file__).parent.parent
+CSS = (REPO_ROOT / "static" / "style.css").read_text()
+HTML = (REPO_ROOT / "static" / "index.html").read_text()
+STREAMING_PY = (REPO_ROOT / "api" / "streaming.py").read_text()
+
+
+# ── streaming.py: title auto-generation condition ─────────────────────────
+
+class TestTitleAutoGenerationCondition(unittest.TestCase):
+ """Verify the guarded condition in streaming.py covers all default title cases."""
+
+ def _titles_that_trigger(self):
+ """Extract the condition from the source so tests stay in sync with code."""
+ # Find the if-condition that calls title_from
+ m = re.search(
+ r'if\s+(s\.title\s*==.*?):\s*\n\s*s\.title\s*=\s*title_from',
+ STREAMING_PY,
+ re.DOTALL,
+ )
+ self.assertIsNotNone(m, "Could not find title auto-generation condition in streaming.py")
+ return m.group(1)
+
+ def test_untitled_in_condition(self):
+ cond = self._titles_that_trigger()
+ self.assertIn("'Untitled'", cond, "Original 'Untitled' guard must be present")
+
+ def test_new_chat_in_condition(self):
+ cond = self._titles_that_trigger()
+ self.assertIn("'New Chat'", cond, "'New Chat' guard must be present (PR #333)")
+
+ def test_empty_title_guard_in_condition(self):
+ cond = self._titles_that_trigger()
+ self.assertIn("not s.title", cond, "Empty/falsy title guard must be present (PR #333)")
+
+ def test_condition_logic_covers_all_defaults(self):
+ """The condition uses OR so any one default title triggers generation."""
+ cond = self._titles_that_trigger()
+ # All three guards must be joined by 'or'
+ parts = re.split(r'\bor\b', cond)
+ self.assertGreaterEqual(len(parts), 3,
+ "Expected at least 3 OR-joined sub-conditions (Untitled, New Chat, not s.title)")
+
+
+# ── style.css: mobile close button visibility ─────────────────────────────
+
+class TestMobileCloseButtonCSS(unittest.TestCase):
+ """Verify CSS rules that control the duplicate close button on mobile."""
+
+ def test_mobile_close_btn_hidden_by_default(self):
+ """Desktop default: .mobile-close-btn must be display:none outside any media query."""
+ # Find the rule before the first @media block that contains mobile-close-btn
+ # We look for the pattern in the desktop (non-media-query) section
+ self.assertIn(
+ ".mobile-close-btn{display:none;}",
+ CSS.replace(" ", ""),
+ ".mobile-close-btn should be hidden by default (desktop) — rule missing or wrong"
+ )
+
+ def test_mobile_close_btn_shown_in_900px_query(self):
+ """Inside max-width:900px media query, .mobile-close-btn must be display:flex."""
+ # Extract the 900px media block
+ m = re.search(r'@media\s*\(max-width\s*:\s*900px\)\s*\{([^{}]*(?:\{[^{}]*\}[^{}]*)*)\}',
+ CSS)
+ self.assertIsNotNone(m, "@media(max-width:900px) block not found in style.css")
+ block = m.group(1).replace(" ", "")
+ self.assertIn(".mobile-close-btn{display:flex;}",
+ block,
+ ".mobile-close-btn must be display:flex inside the 900px media query")
+
+ def test_desktop_collapse_btn_hidden_in_900px_query(self):
+ """Inside max-width:900px media query, #btnCollapseWorkspacePanel must be display:none."""
+ m = re.search(r'@media\s*\(max-width\s*:\s*900px\)\s*\{([^{}]*(?:\{[^{}]*\}[^{}]*)*)\}',
+ CSS)
+ self.assertIsNotNone(m, "@media(max-width:900px) block not found in style.css")
+ block = m.group(1).replace(" ", "")
+ self.assertIn("#btnCollapseWorkspacePanel{display:none;}",
+ block,
+ "#btnCollapseWorkspacePanel must be display:none in 900px media query")
+
+ def test_900px_query_retains_existing_rules(self):
+ """Ensure the PR didn't accidentally drop existing rules from the 900px block."""
+ m = re.search(r'@media\s*\(max-width\s*:\s*900px\)\s*\{([^{}]*(?:\{[^{}]*\}[^{}]*)*)\}',
+ CSS)
+ self.assertIsNotNone(m)
+ block = m.group(1)
+ self.assertIn("rightpanel", block, ".rightpanel rule missing from 900px block")
+ self.assertIn("mobile-files-btn", block, ".mobile-files-btn rule missing from 900px block")
+
+
+# ── index.html: button presence ───────────────────────────────────────────
+
+class TestWorkspacePanelButtons(unittest.TestCase):
+ """Verify both panel buttons are present in the HTML so CSS rules have targets."""
+
+ def test_desktop_collapse_button_exists(self):
+ self.assertIn("btnCollapseWorkspacePanel", HTML,
+ "#btnCollapseWorkspacePanel button must exist in index.html")
+
+ def test_mobile_close_button_exists(self):
+ self.assertIn("mobile-close-btn", HTML,
+ ".mobile-close-btn button must exist in index.html")
+
+ def test_mobile_close_button_has_aria_label(self):
+ """Accessibility: mobile close button must have an aria-label."""
+ m = re.search(r'class="[^"]*mobile-close-btn[^"]*"[^>]*>', HTML)
+ self.assertIsNotNone(m, "Could not find mobile-close-btn element")
+ self.assertIn("aria-label", m.group(0),
+ "mobile-close-btn must have aria-label for accessibility")
+
+
+if __name__ == "__main__":
+ unittest.main()