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:
@@ -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.
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;}
|
||||||
|
|||||||
@@ -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
146
tests/test_sprint35.py
Normal 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"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 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}"
|
||||||
Reference in New Issue
Block a user