diff --git a/CHANGELOG.md b/CHANGELOG.md index b5b87d6..a962bed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,15 @@ --- +## [v0.49.3] Session title guard + breadcrumb nav + wider panel (PRs #301, #302) + +- **Preserve user-renamed session titles** (PR #301 / closes #300): `title_from()` now only runs when the session title is still `'Untitled'`. Previously it overwrote user-assigned titles on every conversation turn. + - Fixed in both `api/streaming.py` (streaming path) and `api/routes.py` (sync path). +- **Clickable breadcrumb navigation** (PR #302 / closes #292): Workspace file preview now shows a clickable breadcrumb path bar. Each segment navigates directly to that directory level. Paths with spaces and special characters handled correctly. `clearPreview()` restores the directory breadcrumb on close. +- **Wider right panel** (PR #302): `PANEL_MAX` raised from 500 to 1200 — right panel can now be dragged wider on ultrawide screens. +- **Responsive message width** (PR #302): `.messages-inner` now scales up gracefully at 1400px (1100px max) and 1800px (1200px max) viewport widths instead of capping at 800px on all screen sizes. + - 12 new tests in `tests/test_sprint35.py`; 743 tests total (up from 731) + ## [v0.49.2] OAuth provider support in onboarding (issues #303, #304) - **OAuth provider bypass** (closes #303, #304): The first-run onboarding wizard now correctly recognizes OAuth-authenticated providers (GitHub Copilot, OpenAI Codex, Nous Portal, Qwen OAuth) as ready, instead of always demanding an API key. diff --git a/api/routes.py b/api/routes.py index b4d9bd8..b0bfc38 100644 --- a/api/routes.py +++ b/api/routes.py @@ -1543,7 +1543,9 @@ def _handle_chat_sync(handler, body): else: os.environ["HERMES_SESSION_KEY"] = old_session_key s.messages = result.get("messages") or s.messages - s.title = title_from(s.messages, s.title) + # Only auto-generate title when still default; preserves user renames + if s.title == "Untitled": + s.title = title_from(s.messages, s.title) s.save() # Sync to state.db for /insights (opt-in setting) try: diff --git a/api/streaming.py b/api/streaming.py index 8ee5bc8..567c05c 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -334,7 +334,9 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta for _m in s.messages: if isinstance(_m, dict) and not _m.get('timestamp') and not _m.get('_ts'): _m['timestamp'] = int(_now) - s.title = title_from(s.messages, s.title) + # Only auto-generate title when still default; preserves user renames + if s.title == 'Untitled': + 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 output_tokens = getattr(agent, 'session_completion_tokens', 0) or 0 diff --git a/static/boot.js b/static/boot.js index a61b717..c4d72ba 100644 --- a/static/boot.js +++ b/static/boot.js @@ -200,6 +200,8 @@ function clearPreview(){ const pp=$('previewPathText');if(pp)pp.textContent=''; const ft=$('fileTree');if(ft)ft.style.display=''; _previewCurrentPath='';_previewCurrentMode='';_previewDirty=false; + // Restore directory breadcrumb after closing file preview + if(typeof renderBreadcrumb==='function') renderBreadcrumb(); } $('btnClearPreview').onclick=clearPreview; // workspacePath click handler removed -- use topbar workspace chip dropdown instead @@ -302,7 +304,7 @@ document.querySelectorAll('.suggestion').forEach(btn=>{ // ── Resizable panels ────────────────────────────────────────────────────── (function(){ const SIDEBAR_MIN=180, SIDEBAR_MAX=420; - const PANEL_MIN=180, PANEL_MAX=500; + const PANEL_MIN=180, PANEL_MAX=1200; function initResize(handleId, targetEl, edge, minW, maxW, storageKey){ const handle = $(handleId); diff --git a/static/style.css b/static/style.css index 9ec53fe..98f1f6b 100644 --- a/static/style.css +++ b/static/style.css @@ -322,7 +322,9 @@ .chip{font-size:11px;padding:4px 10px;border-radius:999px;background:rgba(255,255,255,0.05);border:1px solid var(--border2);color:var(--muted);font-weight:500;} .chip.model{color:var(--blue);border-color:rgba(124,185,255,0.35);background:rgba(124,185,255,0.1);} .messages{flex:1;overflow-y:auto;display:flex;flex-direction:column;min-height:0;position:relative;z-index:0;} - .messages-inner{max-width:800px;margin:0 auto;width:100%;padding:20px 24px 32px;display:flex;flex-direction:column;} + .messages-inner{margin:0 auto;width:100%;padding:20px 24px 32px;display:flex;flex-direction:column;} + @media(min-width:1400px){.messages-inner{max-width:1100px;}} + @media(min-width:1800px){.messages-inner{max-width:1200px;}} .msg-row{padding:10px 0;} .msg-row+.msg-row{border-top:none;} .msg-role{font-size:12px;font-weight:500;letter-spacing:.01em;margin-bottom:8px;display:flex;align-items:center;gap:8px;} diff --git a/static/workspace.js b/static/workspace.js index 03a259a..6ef59de 100644 --- a/static/workspace.js +++ b/static/workspace.js @@ -200,6 +200,7 @@ async function openFile(path){ $('fileTree').style.display='none'; _previewCurrentPath = path; + renderFileBreadcrumb(path); if(IMAGE_EXTS.has(ext)){ // Image: load via raw endpoint, show as showPreview('image'); @@ -245,3 +246,41 @@ function downloadFile(path){ showToast(t('downloading',filename),2000); } + +// ── Render breadcrumb for file preview mode ────────────────────────────────── +function renderFileBreadcrumb(filePath) { + const bar = $('breadcrumbBar'); + if (!bar) return; + bar.style.display = 'flex'; + const upBtn = $('btnUpDir'); + if (upBtn) upBtn.style.display = ''; + + bar.innerHTML = ''; + // Root + const root = document.createElement('span'); + root.className = 'breadcrumb-seg breadcrumb-link'; + root.textContent = '~'; + root.onclick = () => { clearPreview(); loadDir('.'); }; + bar.appendChild(root); + + const parts = filePath.split('/'); + let accumulated = ''; + for (let i = 0; i < parts.length; i++) { + const sep = document.createElement('span'); + sep.className = 'breadcrumb-sep'; + sep.textContent = '/'; + bar.appendChild(sep); + + accumulated += (accumulated ? '/' : '') + parts[i]; + const seg = document.createElement('span'); + seg.textContent = parts[i]; + if (i < parts.length - 1) { + seg.className = 'breadcrumb-seg breadcrumb-link'; + const target = accumulated; + seg.onclick = () => { clearPreview(); loadDir(target); }; + } else { + seg.className = 'breadcrumb-seg breadcrumb-current'; + } + bar.appendChild(seg); + } +} diff --git a/tests/test_sprint35.py b/tests/test_sprint35.py new file mode 100644 index 0000000..8734f51 --- /dev/null +++ b/tests/test_sprint35.py @@ -0,0 +1,146 @@ +""" +Sprint 35 Tests: Breadcrumb nav + wider panel + responsive message width (PR #302). + +Covers: + 1. PANEL_MAX raised from 500 to 1200 in boot.js + 2. Responsive .messages-inner breakpoints in style.css (no hardcoded 800px) + 3. renderFileBreadcrumb() function exists in workspace.js + 4. renderFileBreadcrumb() is called from openFile() + 5. clearPreview() calls renderBreadcrumb() to restore dir breadcrumb + 6. Breadcrumb segments use correct CSS classes + 7. breadcrumbBar element exists in index.html + 8. Breadcrumb CSS rules exist in style.css +""" + +import pathlib +import re + +REPO = pathlib.Path(__file__).parent.parent + + +def read(path): + return (REPO / path).read_text(encoding="utf-8") + + +# ── 1. PANEL_MAX raised ────────────────────────────────────────────────────── + +def test_panel_max_raised_to_1200(): + """PANEL_MAX must be 1200 (raised from 500) for wider right panel.""" + src = read("static/boot.js") + assert "PANEL_MAX=1200" in src or "PANEL_MAX = 1200" in src, ( + "PANEL_MAX was not raised to 1200 — right panel cannot be widened on ultrawide screens" + ) + + +def test_panel_max_is_not_500(): + """Old PANEL_MAX=500 must no longer be present.""" + src = read("static/boot.js") + assert "PANEL_MAX=500" not in src and "PANEL_MAX = 500" not in src, ( + "Old PANEL_MAX=500 still present — right panel width not updated" + ) + + +# ── 2. Responsive messages-inner ───────────────────────────────────────────── + +def test_messages_inner_has_responsive_breakpoints(): + """style.css must have @media breakpoints for .messages-inner.""" + css = read("static/style.css") + assert "min-width:1400px" in css or "min-width: 1400px" in css, ( + "Missing @media(min-width:1400px) breakpoint for .messages-inner" + ) + assert "min-width:1800px" in css or "min-width: 1800px" in css, ( + "Missing @media(min-width:1800px) breakpoint for .messages-inner" + ) + + +def test_messages_inner_no_hardcoded_800px(): + """The base .messages-inner rule must not hardcode max-width:800px.""" + css = read("static/style.css") + # Find the .messages-inner base rule (not inside a @media block) + # It should not have max-width:800px on the same line + for line in css.splitlines(): + if ".messages-inner{" in line and "max-width:800px" in line: + raise AssertionError( + "Base .messages-inner still has hardcoded max-width:800px — " + "responsive breakpoints not applied" + ) + + +def test_messages_inner_breakpoint_values(): + """The breakpoints should expand max-width at 1400px and 1800px.""" + css = read("static/style.css") + assert "max-width:1100px" in css or "max-width: 1100px" in css, ( + "Expected max-width:1100px at 1400px breakpoint" + ) + assert "max-width:1200px" in css or "max-width: 1200px" in css, ( + "Expected max-width:1200px at 1800px breakpoint" + ) + + +# ── 3–6. Breadcrumb navigation ─────────────────────────────────────────────── + +def test_render_file_breadcrumb_function_exists(): + """workspace.js must expose renderFileBreadcrumb().""" + src = read("static/workspace.js") + assert "function renderFileBreadcrumb" in src, ( + "renderFileBreadcrumb() not defined in workspace.js" + ) + + +def test_render_file_breadcrumb_called_from_open_file(): + """openFile() must call renderFileBreadcrumb(path) to show path segments.""" + src = read("static/workspace.js") + assert "renderFileBreadcrumb(path)" in src, ( + "openFile() does not call renderFileBreadcrumb(path)" + ) + + +def test_breadcrumb_has_root_segment(): + """renderFileBreadcrumb must add a root '~' segment.""" + src = read("static/workspace.js") + idx = src.find("function renderFileBreadcrumb") + block = src[idx:idx + 800] + assert "'~'" in block or '"~"' in block, ( + "renderFileBreadcrumb missing root '~' segment" + ) + + +def test_breadcrumb_segments_use_correct_classes(): + """Breadcrumb segments must use breadcrumb-seg breadcrumb-link/current classes.""" + src = read("static/workspace.js") + assert "breadcrumb-seg" in src, "breadcrumb-seg class not used" + assert "breadcrumb-link" in src, "breadcrumb-link class not used" + assert "breadcrumb-current" in src, "breadcrumb-current class not used" + + +def test_clear_preview_calls_render_breadcrumb(): + """clearPreview() in boot.js must call renderBreadcrumb() to restore dir view.""" + src = read("static/boot.js") + # Find clearPreview and check renderBreadcrumb is called nearby + idx = src.find("function clearPreview") + assert idx != -1, "clearPreview not found in boot.js" + block = src[idx:idx + 600] + assert "renderBreadcrumb" in block, ( + "clearPreview() does not call renderBreadcrumb() — " + "directory breadcrumb won't restore after closing file preview" + ) + + +# ── 7. HTML markup ─────────────────────────────────────────────────────────── + +def test_breadcrumb_bar_in_index_html(): + """index.html must have the breadcrumbBar element.""" + html = read("static/index.html") + assert 'id="breadcrumbBar"' in html, ( + "breadcrumbBar element missing from index.html — " + "renderFileBreadcrumb() has nowhere to render" + ) + + +# ── 8. Breadcrumb CSS ──────────────────────────────────────────────────────── + +def test_breadcrumb_css_rules_exist(): + """style.css must have breadcrumb CSS rules.""" + css = read("static/style.css") + for selector in (".breadcrumb-seg", ".breadcrumb-link", ".breadcrumb-current"): + assert selector in css, f"Missing CSS rule: {selector}"