diff --git a/CHANGELOG.md b/CHANGELOG.md index f46f6ba7..a884aeed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,42 @@ # Hermes Web UI -- Changelog +## [v0.51.18] — 2026-05-07 — 5-PR batch (4 contributor + 1 self-built UX polish) + +### Fixed + +- **PR #1783** by @Sanjays2402 — Custom provider + `:free`/`:beta`/`:thinking` suffix mis-resolution. **Closes #1776** (the follow-up I filed during the v0.51.15 sweep against PR #1762). `api/config.py +13` extends `resolve_model_provider()`'s rsplit-fallback so `@custom:my-key:some-model:free` correctly resolves to `provider=custom:my-key, model=some-model:free` (was previously dropping the suffix). 57 LOC test coverage in `tests/test_resolve_model_provider_free_suffix.py`. Opus verified: non-custom path (`@openrouter:tencent/hy3-preview:free`) preserved unchanged; `@custom:my-key:some-model` (no suffix) backward-compatible; no recursion risk. + +- **PR #1791** by @Michaelyklam — Keep assistant-only stream deltas on the current turn (closes #1787). When an SSE stream produces only assistant content (no user-turn material), `api/streaming.py +27` no longer promotes it to a new turn — appends to current. Tool-call responses (`role in ('assistant','tool')`) correctly trigger user-turn materialization. Pure display-merge logic with no INFLIGHT mutation. 27 LOC test coverage. Includes screenshot of correct transcript order. + +- **PR #1790** by @Michaelyklam — Keep workspace open from preview breadcrumb (closes #1785). `static/boot.js +6/-1` (panel-state preservation via new `clearPreview({keepPanelOpen:true})`) + `static/workspace.js +8/-7` (breadcrumb-click handler delegates instead of duplicating mode logic). Compact-viewport routing through existing `openWorkspacePanel('browse')` path preserved. No conflict with PR #1758's composer chip lightbox (different code path). 59 LOC test coverage with 2 screenshots. + +- **PR #1789** by @Michaelyklam — Preserve sidebar scrolling while streaming (closes #1784). `static/style.css +2/-1` + `static/ui.js +20`. Adds `{capture:true, passive:true}` scroll listeners (non-blocking) that detect non-message scroll intent within a 350ms window using `performance.now()` (monotonic), then suppresses `scrollIfPinned()` auto-scroll-to-bottom during that window. Auto-scroll still works at-bottom + new message when no recent sidebar gesture. 47 LOC test coverage + screenshot + QA JSON. + +### Added (UX polish) + +- **PR #1794** by @nesquena-hermes — Self-built UX bundle following up on the v0.51.17 tooltip system. **APPROVED by @nesquena** at exact head SHA `f2d5e9bd`. Four fixes: + - **Rail tooltip cascade fix**: removed `.rail .nav-tab:hover::after { content:none }` (specificity 0,3,1) which was preventing `.has-tooltip:hover::after` from firing on rail buttons. Legacy `data-label` rule correctly scoped to `.sidebar-nav .nav-tab` so rail buttons (no `data-label`) don't get an empty styled box. + - **+New-conversation button clipping**: introduces new `.has-tooltip--bottom-right` variant (`left:auto; right:0; transform:none`) for the `#btnNewChat` button which sits at the right edge of the sidebar header. Tooltip flips to align with the right edge of the trigger instead of extending past the viewport. + - **Context-menu hover affordance**: adds visible `var(--hover-bg)` background on `.workspace-context-menu li:hover` (typo fix from `var(--hover)` which was undefined → no visual feedback). + - **Rename pre-fill**: rename modal now calls `setSelectionRange(0, dot)` to pre-select the basename portion of a filename (everything before the last `.`), so users can immediately type the new name without manually clearing the extension. + + `static/index.html +1` (single attribute swap on `#btnNewChat` from `has-tooltip--bottom` to `has-tooltip--bottom-right`), `static/sessions.js +4`, `static/style.css +26`, `static/ui.js +69`. 168 LOC of `tests/test_css_tooltips.py` extensions (regex-vs-source, consistent with existing pattern) + 263 LOC of new `tests/test_workspace_context_menu_and_rename.py`. + +### Tests + +4723 → **4747 collected** (+24). 4733 passed, 11 skipped (2 dev-only spawn from v0.51.15 + 9 prong-2/QA gating), 3 xpassed, 0 failed in 149s. + +### Pre-release verification + +- All 5 PRs CI-green individually +- File overlaps: `static/style.css` and `static/ui.js` (#1789 + #1794) — different rules/functions, auto-merged cleanly +- All JS/Python files syntax-clean +- Browser API sanity (11/11 endpoints): all pass +- Pre-stamp re-fetch: all 5 PR heads still match local rebases +- Opus advisor: SHIP all 5, 0 MUST-FIX, 1 informational SHOULD-NOTE (test pattern divergence — acceptable, matches existing style) + +Closes #1776, #1784, #1785, #1787. + ## [v0.51.17] — 2026-05-07 — 2-PR contributor batch (kanban early-out + tooltip system overhaul) ### Fixed diff --git a/ROADMAP.md b/ROADMAP.md index 8716c905..7897ae4d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ > Web companion to the Hermes Agent CLI. Same workflows, browser-native. > -> Last updated: v0.51.17 (May 7, 2026) — 4723 tests collected — 2-PR contributor batch (#1780, #1782) +> Last updated: v0.51.18 (May 7, 2026) — 4747 tests collected — 5-PR batch (#1783, #1789, #1790, #1791, #1794) > Test source: `pytest tests/ --collect-only -q` > Per-version detail: see [CHANGELOG.md](./CHANGELOG.md) diff --git a/TESTING.md b/TESTING.md index be8b94fd..e14280df 100644 --- a/TESTING.md +++ b/TESTING.md @@ -1835,8 +1835,8 @@ Bridged CLI sessions: --- -*Last updated: v0.51.17, May 7, 2026* -*Total automated tests collected: 4723* +*Last updated: v0.51.18, May 7, 2026* +*Total automated tests collected: 4747* *Regression gate: tests/test_regressions.py* *Run: pytest tests/ -v --timeout=60* *Source: /* diff --git a/api/config.py b/api/config.py index c01f20e1..6e67800f 100644 --- a/api/config.py +++ b/api/config.py @@ -1373,10 +1373,21 @@ def resolve_model_provider(model_id: str) -> tuple: # into provider="openrouter:tencent/hy3-preview", model="free"). Guard # against that by falling back to split(":") when the rsplit result is not # a recognised provider (#1744). + # + # Edge case (#1776): for custom providers with the same suffix + # ("@custom:my-key:some-model:free"), rsplit yields + # provider_hint="custom:my-key:some-model", bare_model="free", and the + # custom-prefix guard below skips the split-fallback. Detect the + # over-split structurally — custom hints carry exactly one segment after + # "custom:", so any provider_hint with 2+ colons that starts with + # "custom:" has eaten part of the model name. Peel one segment back. if model_id.startswith("@") and ":" in model_id: inner = model_id[1:] provider_hint, bare_model = inner.rsplit(":", 1) - if (provider_hint not in _PROVIDER_MODELS + if provider_hint.startswith("custom:") and provider_hint.count(":") >= 2: + provider_hint, extra = provider_hint.rsplit(":", 1) + bare_model = f"{extra}:{bare_model}" + elif (provider_hint not in _PROVIDER_MODELS and provider_hint not in _PROVIDER_DISPLAY and not provider_hint.startswith("custom:")): provider_hint, bare_model = inner.split(":", 1) diff --git a/api/streaming.py b/api/streaming.py index ae8ebe9f..90c2a68c 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -1516,6 +1516,33 @@ def _merge_display_messages_after_agent_result(previous_display, previous_contex merged = previous_display[:] seen = {_message_identity(m) for m in merged} current_user_key = _message_identity({'role': 'user', 'content': msg_text}) + current_user_in_candidates = any( + _message_identity(m) == current_user_key for m in candidates + ) + current_user_already_checkpointed = bool( + merged and _message_identity(merged[-1]) == current_user_key + ) + if ( + current_user_key is not None + and not current_user_in_candidates + and not current_user_already_checkpointed + and any( + isinstance(m, dict) and m.get('role') in ('assistant', 'tool') + for m in candidates + ) + ): + # Some provider retry/fallback paths can return an assistant/tool delta + # without echoing the current user turn. In deferred session-save mode + # the prompt exists only in pending_user_message, so appending that delta + # directly would make the assistant bubble appear attached to the prior + # exchange and then clear the pending prompt. Materialize the current + # turn at the transcript boundary before the assistant/tool response. + current_user_msg = {'role': 'user', 'content': msg_text} + insert_at = 0 + while insert_at < len(candidates) and _is_context_compression_marker(candidates[insert_at]): + insert_at += 1 + candidates = candidates[:insert_at] + [current_user_msg] + candidates[insert_at:] + for msg in candidates: key = _message_identity(msg) if ( diff --git a/docs/pr-media/1784/sidebar-scroll-fixture.png b/docs/pr-media/1784/sidebar-scroll-fixture.png new file mode 100644 index 00000000..3f90334d Binary files /dev/null and b/docs/pr-media/1784/sidebar-scroll-fixture.png differ diff --git a/docs/pr-media/1784/sidebar-scroll-qa.json b/docs/pr-media/1784/sidebar-scroll-qa.json new file mode 100644 index 00000000..75d2cc2a --- /dev/null +++ b/docs/pr-media/1784/sidebar-scroll-qa.json @@ -0,0 +1,25 @@ +{ + "issue": 1784, + "commit_under_test": "9875967", + "fixture": "Synthetic 180-row session sidebar with active sid_0 streaming and long chat pane content.", + "pre_fix_observation": { + "steps": [ + "Set _scrollPinned=true with #messages at scrollTop 0 in a long chat fixture.", + "Dispatch a wheel gesture on the active sidebar session row.", + "Call scrollIfPinned() to mimic the next streaming token render." + ], + "result": "#messages jumped from scrollTop 0 to 3073 immediately after the sidebar wheel gesture, showing the chat auto-scroll path fought non-chat scroll intent." + }, + "post_fix_observation": { + "steps": [ + "Repeat the same fixture and sidebar wheel gesture after the fix.", + "Call scrollIfPinned() immediately, then again after the 350ms non-chat intent guard expires." + ], + "result": { + "afterSidebarWheel": 0, + "afterIntentExpires": 2992, + "sessionListCss": "overscroll-behavior-y: contain; touch-action: pan-y" + }, + "meaning": "A sidebar wheel/touch scroll intent now suppresses only the immediate chat-pane auto-scroll write, leaving the sidebar gesture free while streaming continues." + } +} diff --git a/docs/pr-media/1785/workspace-preview-breadcrumb-before.png b/docs/pr-media/1785/workspace-preview-breadcrumb-before.png new file mode 100644 index 00000000..12d18944 Binary files /dev/null and b/docs/pr-media/1785/workspace-preview-breadcrumb-before.png differ diff --git a/docs/pr-media/1785/workspace-root-breadcrumb-fixed.png b/docs/pr-media/1785/workspace-root-breadcrumb-fixed.png new file mode 100644 index 00000000..6167e6ee Binary files /dev/null and b/docs/pr-media/1785/workspace-root-breadcrumb-fixed.png differ diff --git a/docs/pr-media/1787/issue-1787-transcript-order.png b/docs/pr-media/1787/issue-1787-transcript-order.png new file mode 100644 index 00000000..b08a2a3e Binary files /dev/null and b/docs/pr-media/1787/issue-1787-transcript-order.png differ diff --git a/static/boot.js b/static/boot.js index 7c4a66f9..3fa54c94 100644 --- a/static/boot.js +++ b/static/boot.js @@ -820,10 +820,11 @@ $('importFileInput').onchange=async(e)=>{ } }; // btnRefreshFiles is now panel-icon-btn in header (see HTML) -function clearPreview(){ +function clearPreview(opts={}){ + const keepPanelOpen=!!(opts&&opts.keepPanelOpen); // Restore directory breadcrumb after closing file preview if(typeof renderBreadcrumb==='function') renderBreadcrumb(); - const closePanelAfter=_workspacePanelMode==='preview'; + const closePanelAfter=_workspacePanelMode==='preview'&&!keepPanelOpen; const pa=$('previewArea');if(pa)pa.classList.remove('visible'); const pi=$('previewImg');if(pi){pi.onerror=null;pi.src='';} const pdf=$('previewPdfFrame');if(pdf)pdf.src=''; @@ -834,6 +835,7 @@ function clearPreview(){ const ft=$('fileTree');if(ft)ft.style.display=''; _previewCurrentPath='';_previewCurrentMode='';_previewDirty=false; if(closePanelAfter)closeWorkspacePanel(); + else if(keepPanelOpen&&_workspacePanelMode==='preview')openWorkspacePanel('browse'); else syncWorkspacePanelUI(); } $('btnClearPreview').onclick=handleWorkspaceClose; diff --git a/static/index.html b/static/index.html index dd97d70d..2eb061ad 100644 --- a/static/index.html +++ b/static/index.html @@ -122,7 +122,7 @@
Chat
-
diff --git a/static/sessions.js b/static/sessions.js index e7892a0f..a925b1c8 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -2884,7 +2884,7 @@ function _showProjectContextMenu(e, proj, chip){ const renameItem=document.createElement('div'); renameItem.textContent='Rename'; renameItem.style.cssText='padding:7px 14px;cursor:pointer;font-size:13px;color:var(--text);'; - renameItem.onmouseenter=()=>renameItem.style.background='var(--hover)'; + renameItem.onmouseenter=()=>renameItem.style.background='var(--hover-bg)'; renameItem.onmouseleave=()=>renameItem.style.background=''; renameItem.onclick=()=>{menu.remove();_startProjectRename(proj,chip);}; menu.appendChild(renameItem); @@ -2913,7 +2913,7 @@ function _showProjectContextMenu(e, proj, chip){ const delItem=document.createElement('div'); delItem.textContent='Delete'; delItem.style.cssText='padding:7px 14px;cursor:pointer;font-size:13px;color:var(--error,#e94560);'; - delItem.onmouseenter=()=>delItem.style.background='var(--hover)'; + delItem.onmouseenter=()=>delItem.style.background='var(--hover-bg)'; delItem.onmouseleave=()=>delItem.style.background=''; delItem.onclick=()=>{menu.remove();_confirmDeleteProject(proj);}; menu.appendChild(delItem); diff --git a/static/style.css b/static/style.css index 21be7ff6..d8d28922 100644 --- a/static/style.css +++ b/static/style.css @@ -321,7 +321,7 @@ .sidebar-section{padding:14px 14px 8px;} .new-chat-btn{width:100%;padding:9px 12px;border-radius:9px;background:var(--accent-bg);border:1px solid var(--accent-bg-strong);color:var(--accent-text);font-size:13px;cursor:pointer;display:flex;align-items:center;gap:8px;transition:all .15s;margin-bottom:8px;font-weight:500;} .new-chat-btn:hover{background:var(--accent-bg-strong);border-color:var(--accent);} - .session-list{flex:1;overflow-y:auto;padding:0 8px 8px;min-height:0;} + .session-list{flex:1;overflow-y:auto;padding:0 8px 8px;min-height:0;overscroll-behavior-y:contain;touch-action:pan-y;} .sidebar-search{position:relative;padding:8px 12px;flex-shrink:0;} .sidebar-search input{width:100%;background:var(--bg);border:1px solid var(--border);border-radius:8px;color:var(--text);padding:7px 10px 7px 32px;font-size:13px;outline:none;transition:border-color .15s,box-shadow .15s,background .15s;box-sizing:border-box;} .sidebar-search input:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-bg);} @@ -660,13 +660,27 @@ .has-tooltip:hover::after,.has-tooltip:focus-visible::after{opacity:1;transition-delay:.15s;} /* For bottom-positioned tooltips (panel header buttons, non-rail elements) */ .has-tooltip--bottom::after{left:50%;top:auto;bottom:auto;transform:translateX(-50%);top:calc(100% + 8px);} + /* For bottom-positioned tooltips on a trigger that sits flush with its + container's right edge — anchors the tooltip's RIGHT edge to the trigger + so the label extends inward (to the left) instead of overflowing past the + panel edge. Used for the `+` New conversation button at the right of the + chat panel header. Pairs with `--bottom`; do not apply both. */ + .has-tooltip--bottom-right::after{left:auto;right:0;top:calc(100% + 8px);bottom:auto;transform:none;} /* For right-edge elements (e.g. send button) — tooltip flips to the LEFT of the trigger so it doesn't extend past the viewport edge. */ .has-tooltip--left::after{left:auto;right:calc(100% + 8px);top:50%;transform:translateY(-50%);} @media(prefers-reduced-motion:reduce){.has-tooltip::after{transition:none;transition-delay:0s;}} .rail-spacer{flex:1;min-height:8px;} .rail .nav-tab{flex:0 0 auto;padding:0;font-size:inherit;border-bottom:none;overflow:visible;} -.rail .nav-tab:hover::after{content:none;} +/* Note: previously this block had `.rail .nav-tab:hover::after { content: none }` + to suppress the legacy `.nav-tab:hover::after { content: attr(data-label) }` + tooltip (line ~681 below) on the desktop rail. After v0.51.17 migrated the + rail to the custom `.has-tooltip` system, that suppression rule survived and + blocked the new tooltips because `.rail .nav-tab:hover::after` (specificity + 0,3,1) outweighs `.has-tooltip:hover::after` (0,2,1) and `content:none` + removes the pseudo-element entirely. Solution: scope the legacy + `.nav-tab:hover::after` data-label tooltip to `.sidebar-nav` (mobile) only + (see line ~681). The rail rule is no longer needed. */ .rail .nav-tab.active::before{content:'';position:absolute;left:-6px;top:50%;bottom:auto;transform:translateY(-50%);width:3px;height:16px;background:var(--accent);border-radius:0 2px 2px 0;} .dashboard-link{position:relative;} .dashboard-link-visible{display:flex!important;} @@ -678,7 +692,13 @@ .sidebar-nav{display:flex;border-bottom:1px solid var(--border);flex-shrink:0;padding:6px 8px 0;gap:2px;} .nav-tab{flex:1;padding:10px 4px 8px;font-size:20px;text-align:center;cursor:pointer;color:var(--muted);border:none;background:none;transition:color .15s;border-bottom:2px solid transparent;white-space:nowrap;overflow:hidden;position:relative;display:flex;align-items:center;justify-content:center;} .nav-tab:hover{color:var(--text);} - .nav-tab:hover::after{content:attr(data-label);position:absolute;bottom:calc(100% + 8px);left:50%;transform:translateX(-50%);background:var(--surface);border:1px solid var(--accent-bg-strong);color:var(--accent-text);font-size:12px;font-weight:700;letter-spacing:.02em;padding:6px 12px;border-radius:8px;white-space:nowrap;pointer-events:none;z-index:50;box-shadow:0 4px 12px rgba(0,0,0,.3);} + /* Legacy hover-tooltip — kept for the mobile `.sidebar-nav` only, where it + positions ABOVE the trigger (the bar is at the top of the sidebar so a + bottom-positioned tooltip would sink into the panel content). The desktop + `.rail` buttons opt out of this rule so the `.has-tooltip` system can run + unobstructed; rail buttons carry no `data-label`, so an unscoped rule + would render an empty styled box on hover. */ + .sidebar-nav .nav-tab:hover::after{content:attr(data-label);position:absolute;bottom:calc(100% + 8px);left:50%;transform:translateX(-50%);background:var(--surface);border:1px solid var(--accent-bg-strong);color:var(--accent-text);font-size:12px;font-weight:700;letter-spacing:.02em;padding:6px 12px;border-radius:8px;white-space:nowrap;pointer-events:none;z-index:50;box-shadow:0 4px 12px rgba(0,0,0,.3);} .nav-tab.active{color:var(--accent-text);} .nav-tab.active::before{content:'';position:absolute;bottom:0;left:50%;transform:translateX(-50%);width:20px;height:2px;background:var(--accent);border-radius:2px 2px 0 0;} /* Panel content areas (swapped by tab) */ diff --git a/static/ui.js b/static/ui.js index 7cf75ed0..ff666883 100644 --- a/static/ui.js +++ b/static/ui.js @@ -1472,6 +1472,25 @@ let _scrollPinned=true; let _programmaticScroll=false; let _nearBottomCount=0; let _lastScrollTop=null; +let _lastNonMessageScrollIntentMs=0; +const NON_MESSAGE_SCROLL_INTENT_SUPPRESS_MS=350; +function _recordNonMessageScrollIntent(e){ + const el=document.getElementById('messages'); + const target=e&&e.target; + if(!el||!target) return; + // Streaming token renders should keep pinning the chat only while the user is + // actually interacting with the chat pane. A wheel/touch gesture over the + // session sidebar (or another independent pane) must not be immediately fought + // by scrollIfPinned() writing #messages.scrollTop on the next token (#1784). + if(!el.contains(target)) _lastNonMessageScrollIntentMs=performance.now(); +} +function _recentNonMessageScrollIntent(){ + return performance.now()-_lastNonMessageScrollIntentMs{_programmaticScroll=false;},0);} } @@ -3025,7 +3045,11 @@ function showPromptDialog(opts={}){ if(desc) desc.textContent=opts.message||''; if(input){ input.type=opts.inputType||'text';input.style.display=''; - input.value=opts.value||'';input.placeholder=opts.placeholder||''; + // Pre-fill: prefer `value`, accept `defaultValue` as alias for callers that + // mirror the standard HTMLInputElement.defaultValue naming. Both empty → + // blank field (the default rename-from-scratch flow stays unchanged). + const prefill=(opts.value!=null?opts.value:(opts.defaultValue!=null?opts.defaultValue:'')); + input.value=prefill;input.placeholder=opts.placeholder||''; input.autocomplete='off';input.spellcheck=false; } if(cancelBtn) cancelBtn.textContent=opts.cancelLabel||t('cancel'); @@ -3034,7 +3058,27 @@ function showPromptDialog(opts={}){ if(overlay){overlay.style.display='flex';overlay.setAttribute('aria-hidden','false');} return new Promise(resolve=>{ APP_DIALOG.resolve=resolve; - setTimeout(()=>{if(input&&input.style.display!=='none')input.focus();else if(confirmBtn)confirmBtn.focus();},0); + setTimeout(()=>{ + if(input&&input.style.display!=='none'){ + input.focus(); + // Selection behavior on focus: + // selectStem:true → select everything before the LAST '.' (e.g. for + // 'report.txt' selects 'report' so a user can retype the basename + // without losing the extension; matches macOS Finder rename UX). + // Falls back to selecting the full value when there's no '.' or + // the dot is at index 0 ('.gitignore' → full select). + // selectAll:true → select the entire prefilled value. + // default → caret at end (current behavior). + const v=input.value||''; + if(opts.selectStem && v){ + const dot=v.lastIndexOf('.'); + if(dot>0) input.setSelectionRange(0,dot); + else input.select(); + } else if(opts.selectAll && v){ + input.select(); + } + } else if(confirmBtn) confirmBtn.focus(); + },0); }); } @@ -6205,7 +6249,7 @@ function _showFileContextMenu(e, item){ const renameItem=document.createElement('div'); renameItem.textContent=t('rename_title'); renameItem.style.cssText='padding:7px 14px;cursor:pointer;font-size:13px;color:var(--text);'; - renameItem.onmouseenter=()=>renameItem.style.background='var(--hover)'; + renameItem.onmouseenter=()=>renameItem.style.background='var(--hover-bg)'; renameItem.onmouseleave=()=>renameItem.style.background=''; renameItem.onclick=()=>{menu.remove();_inlineRenameFileItem(item);}; menu.appendChild(renameItem); @@ -6214,7 +6258,7 @@ function _showFileContextMenu(e, item){ const revealItem=document.createElement('div'); revealItem.textContent=t('reveal_in_finder'); revealItem.style.cssText='padding:7px 14px;cursor:pointer;font-size:13px;color:var(--text);'; - revealItem.onmouseenter=()=>revealItem.style.background='var(--hover)'; + revealItem.onmouseenter=()=>revealItem.style.background='var(--hover-bg)'; revealItem.onmouseleave=()=>revealItem.style.background=''; revealItem.onclick=async()=>{menu.remove();try{await api('/api/file/reveal',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,path:item.path})});}catch(err){showToast(t('reveal_failed')+(err.message||err));}}; menu.appendChild(revealItem); @@ -6227,7 +6271,7 @@ function _showFileContextMenu(e, item){ const copyPathItem=document.createElement('div'); copyPathItem.textContent=t('copy_file_path'); copyPathItem.style.cssText='padding:7px 14px;cursor:pointer;font-size:13px;color:var(--text);'; - copyPathItem.onmouseenter=()=>copyPathItem.style.background='var(--hover)'; + copyPathItem.onmouseenter=()=>copyPathItem.style.background='var(--hover-bg)'; copyPathItem.onmouseleave=()=>copyPathItem.style.background=''; copyPathItem.onclick=async()=>{ menu.remove(); @@ -6266,7 +6310,7 @@ function _showFileContextMenu(e, item){ const delItem=document.createElement('div'); delItem.textContent=t('delete_title'); delItem.style.cssText='padding:7px 14px;cursor:pointer;font-size:13px;color:var(--error,#e94560);'; - delItem.onmouseenter=()=>delItem.style.background='var(--hover)'; + delItem.onmouseenter=()=>delItem.style.background='var(--hover-bg)'; delItem.onmouseleave=()=>delItem.style.background=''; delItem.onclick=()=>{menu.remove();if(item.type==='dir')deleteWorkspaceDir(item.path,item.name);else deleteWorkspaceFile(item.path,item.name);}; menu.appendChild(delItem); @@ -6278,7 +6322,18 @@ function _showFileContextMenu(e, item){ async function _inlineRenameFileItem(item){ if(!S.session)return; - const newName=await showPromptDialog({message:t('rename_prompt'),defaultValue:item.name,placeholder:item.name,confirmLabel:t('rename_title')}); + // Pre-fill the input with the current name and select just the stem + // (everything before the last '.') so the user can immediately retype the + // basename while preserving the extension — matches macOS Finder. For + // directories or names with no '.', the helper selects the full value. + // `selectStem` also handles dotfiles ('.gitignore') by full-selecting. + const newName=await showPromptDialog({ + message:t('rename_prompt'), + value:item.name, + confirmLabel:t('rename_title'), + selectStem:item.type!=='dir', + selectAll:item.type==='dir' + }); if(!newName||newName===item.name)return; try{ await api('/api/file/rename',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,path:item.path,new_name:newName})}); diff --git a/static/workspace.js b/static/workspace.js index bf26f1e4..1511a70a 100644 --- a/static/workspace.js +++ b/static/workspace.js @@ -85,9 +85,9 @@ async function loadDir(path){ } if(typeof clearPreview==='function'){ if(typeof _previewDirty!=='undefined'&&_previewDirty){ - showConfirmDialog({title:t('unsaved_confirm'),message:'',confirmLabel:'Discard',danger:true,focusCancel:true}).then(ok=>{if(ok)clearPreview();}); + showConfirmDialog({title:t('unsaved_confirm'),message:'',confirmLabel:'Discard',danger:true,focusCancel:true}).then(ok=>{if(ok)clearPreview({keepPanelOpen:true});}); }else{ - clearPreview(); + clearPreview({keepPanelOpen:true}); } } // Fetch git info for workspace root (non-blocking) @@ -337,7 +337,7 @@ function renderFileBreadcrumb(filePath) { const root = document.createElement('span'); root.className = 'breadcrumb-seg breadcrumb-link'; root.textContent = '~'; - root.onclick = () => { clearPreview(); loadDir('.'); }; + root.onclick = () => { loadDir('.'); }; bar.appendChild(root); const parts = filePath.split('/'); @@ -354,7 +354,7 @@ function renderFileBreadcrumb(filePath) { if (i < parts.length - 1) { seg.className = 'breadcrumb-seg breadcrumb-link'; const target = accumulated; - seg.onclick = () => { clearPreview(); loadDir(target); }; + seg.onclick = () => { loadDir(target); }; } else { seg.className = 'breadcrumb-seg breadcrumb-current'; } diff --git a/tests/test_css_tooltips.py b/tests/test_css_tooltips.py index 125faed0..2e31cc25 100644 --- a/tests/test_css_tooltips.py +++ b/tests/test_css_tooltips.py @@ -350,5 +350,173 @@ class TestI18NTooltipSync(unittest.TestCase): ) +# --------------------------------------------------------------------------- +# Rail tooltip cascade regression (post-v0.51.17 follow-up) +# --------------------------------------------------------------------------- +class RailTooltipCascadeTests(unittest.TestCase): + """Pin the cascade fix that lets `.has-tooltip` work on `.rail .nav-tab`. + + Background: the legacy `.nav-tab:hover::after { content: attr(data-label) }` + rule was paired with a `.rail .nav-tab:hover::after { content: none }` rule + that suppressed it on the desktop rail. After v0.51.17 migrated rail icons + to `.has-tooltip`, the suppression rule's specificity (0,3,1) outweighed + `.has-tooltip:hover::after` (0,2,1), and `content: none` removes the + pseudo-element entirely — so rail tooltips never appeared. Fix: scope the + legacy `data-label` tooltip to `.sidebar-nav .nav-tab` only and drop the + rail suppression rule. + """ + + def setUp(self): + self.css = _read(STYLE_CSS) + + def test_rail_nav_tab_hover_after_killer_is_gone(self): + """The `.rail .nav-tab:hover::after { content: none }` rule MUST NOT + exist — it kills the `.has-tooltip` pseudo-element on rail buttons.""" + # Strip CSS comments first so the test doesn't false-positive on the + # explanatory note left in place after the rule's removal. + css_no_comments = re.sub(r"/\*.*?\*/", "", self.css, flags=re.DOTALL) + pattern = re.compile( + r"\.rail\s+\.nav-tab:hover:{1,2}after\s*\{[^}]*content\s*:\s*none\s*[;}]", + re.DOTALL, + ) + match = pattern.search(css_no_comments) + self.assertIsNone( + match, + f"Found re-added killer rule that nukes rail tooltips: {match.group(0)[:120] if match else ''}", + ) + + def test_legacy_data_label_hover_is_scoped_to_sidebar_nav(self): + """The legacy `data-label` hover tooltip must be scoped to + `.sidebar-nav .nav-tab` — otherwise it fires on rail buttons (which + carry no data-label) and renders an empty styled box on hover.""" + css_no_comments = re.sub(r"/\*.*?\*/", "", self.css, flags=re.DOTALL) + # The unscoped bug form: `.nav-tab:hover::after { content: attr(data-label) }` + # at the START of a selector (i.e. after `}` or whitespace+nothing-else). + # Walk every rule whose selector ends with `.nav-tab:hover::after` and + # check the prefix that comes before `.nav-tab`. If the prefix is empty + # or pure whitespace, the rule is unscoped. + for m in re.finditer( + r"([^{}]*?)\.nav-tab:hover:{1,2}after\s*\{([^}]*content\s*:\s*attr\(data-label\)[^}]*)\}", + css_no_comments, + re.DOTALL, + ): + prefix = m.group(1) + # If the prefix (back to the previous `}` or `;`) is empty or pure + # whitespace, this is the unscoped bug form. + # Trim to the part after the last selector-list separator. + last_sep = max(prefix.rfind("}"), prefix.rfind("\n"), prefix.rfind(",")) + scope_text = prefix[last_sep + 1:].strip() if last_sep >= 0 else prefix.strip() + self.assertTrue( + scope_text, + "Found unscoped `.nav-tab:hover::after { content: attr(data-label) }` " + "rule. Must be `.sidebar-nav .nav-tab:hover::after` so it does not " + "fire on rail buttons that carry no data-label.", + ) + + # Affirmative: the scoped form must exist. + good_pattern = re.compile( + r"\.sidebar-nav\s+\.nav-tab:hover:{1,2}after\s*\{[^}]*content\s*:\s*attr\(data-label\)", + re.DOTALL, + ) + self.assertIsNotNone( + good_pattern.search(css_no_comments), + "Expected `.sidebar-nav .nav-tab:hover::after { content: attr(data-label); ... }` " + "rule (mobile sidebar fallback tooltip). It went missing.", + ) + + def test_all_rail_buttons_carry_has_tooltip(self): + """Every `.rail-btn.nav-tab` button must carry `class="has-tooltip"` and + a non-empty `data-tooltip` attribute. Otherwise the rail tooltip is + invisible regardless of the cascade fix above.""" + html = _read(INDEX_HTML) + # Find the rail block: + rail_match = re.search( + r'', + html, + re.DOTALL, + ) + self.assertIsNotNone(rail_match, "Could not locate