fix+feat: session title guard + breadcrumb nav + wider panel + responsive msgs (closes #300, #292)

PR #301 changes:
- api/streaming.py: guard title_from() with s.title == 'Untitled' check
- api/routes.py: same guard in sync/non-streaming path

PR #302 changes (cleaned — restores accidentally-removed features):
- static/boot.js: PANEL_MAX 500 -> 1200
- static/boot.js: clearPreview() calls renderBreadcrumb() to restore dir view
- static/style.css: responsive .messages-inner breakpoints (1400px/1800px)
- static/workspace.js: renderFileBreadcrumb() function with clickable segments
- static/workspace.js: openFile() calls renderFileBreadcrumb(path)

12 new tests in tests/test_sprint35.py

Note: PR #302 branch contained several accidental regressions (removed app-dialog
system, onboarding CSS, _checkProviderMismatch, closeMobileFiles, etc.) that were
not part of its stated scope. This clean branch applies only the three intended
features on top of current master.

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
This commit is contained in:
nesquena-hermes
2026-04-12 10:51:48 -07:00
committed by GitHub
parent b12a682121
commit 28a0f0bef9
7 changed files with 206 additions and 4 deletions

View File

@@ -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) ## [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. - **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.

View File

@@ -1543,7 +1543,9 @@ def _handle_chat_sync(handler, body):
else: else:
os.environ["HERMES_SESSION_KEY"] = old_session_key os.environ["HERMES_SESSION_KEY"] = old_session_key
s.messages = result.get("messages") or s.messages 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() s.save()
# Sync to state.db for /insights (opt-in setting) # Sync to state.db for /insights (opt-in setting)
try: try:

View File

@@ -334,7 +334,9 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
for _m in s.messages: for _m in s.messages:
if isinstance(_m, dict) and not _m.get('timestamp') and not _m.get('_ts'): if isinstance(_m, dict) and not _m.get('timestamp') and not _m.get('_ts'):
_m['timestamp'] = int(_now) _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) # Read token/cost usage from the agent object (if available)
input_tokens = getattr(agent, 'session_prompt_tokens', 0) or 0 input_tokens = getattr(agent, 'session_prompt_tokens', 0) or 0
output_tokens = getattr(agent, 'session_completion_tokens', 0) or 0 output_tokens = getattr(agent, 'session_completion_tokens', 0) or 0

View File

@@ -200,6 +200,8 @@ function clearPreview(){
const pp=$('previewPathText');if(pp)pp.textContent=''; const pp=$('previewPathText');if(pp)pp.textContent='';
const ft=$('fileTree');if(ft)ft.style.display=''; const ft=$('fileTree');if(ft)ft.style.display='';
_previewCurrentPath='';_previewCurrentMode='';_previewDirty=false; _previewCurrentPath='';_previewCurrentMode='';_previewDirty=false;
// Restore directory breadcrumb after closing file preview
if(typeof renderBreadcrumb==='function') renderBreadcrumb();
} }
$('btnClearPreview').onclick=clearPreview; $('btnClearPreview').onclick=clearPreview;
// workspacePath click handler removed -- use topbar workspace chip dropdown instead // workspacePath click handler removed -- use topbar workspace chip dropdown instead
@@ -302,7 +304,7 @@ document.querySelectorAll('.suggestion').forEach(btn=>{
// ── Resizable panels ────────────────────────────────────────────────────── // ── Resizable panels ──────────────────────────────────────────────────────
(function(){ (function(){
const SIDEBAR_MIN=180, SIDEBAR_MAX=420; 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){ function initResize(handleId, targetEl, edge, minW, maxW, storageKey){
const handle = $(handleId); const handle = $(handleId);

View File

@@ -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{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);} .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{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{padding:10px 0;}
.msg-row+.msg-row{border-top:none;} .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;} .msg-role{font-size:12px;font-weight:500;letter-spacing:.01em;margin-bottom:8px;display:flex;align-items:center;gap:8px;}

View File

@@ -200,6 +200,7 @@ async function openFile(path){
$('fileTree').style.display='none'; $('fileTree').style.display='none';
_previewCurrentPath = path; _previewCurrentPath = path;
renderFileBreadcrumb(path);
if(IMAGE_EXTS.has(ext)){ if(IMAGE_EXTS.has(ext)){
// Image: load via raw endpoint, show as <img> // Image: load via raw endpoint, show as <img>
showPreview('image'); showPreview('image');
@@ -245,3 +246,41 @@ function downloadFile(path){
showToast(t('downloading',filename),2000); 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);
}
}

146
tests/test_sprint35.py Normal file
View File

@@ -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"
)
# ── 36. 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}"