Merge pull request #1799 from nesquena/stage-312

v0.51.18 — 5-PR batch (#1783, #1789, #1790, #1791, #1794)
This commit is contained in:
nesquena-hermes
2026-05-06 23:43:40 -07:00
committed by GitHub
22 changed files with 821 additions and 23 deletions
+37
View File
@@ -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
+1 -1
View File
@@ -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)
+2 -2
View File
@@ -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: <repo>/*
+12 -1
View File
@@ -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)
+27
View File
@@ -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 (
Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

+25
View File
@@ -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."
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

+4 -2
View File
@@ -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;
+1 -1
View File
@@ -122,7 +122,7 @@
<div class="panel-head">
<span data-i18n="tab_chat">Chat</span>
<div class="panel-head-actions">
<button class="panel-head-btn has-tooltip has-tooltip--bottom" id="btnNewChat" data-tooltip="New conversation (Cmd+K)" data-i18n-title="new_conversation" aria-label="New conversation">
<button class="panel-head-btn has-tooltip has-tooltip--bottom-right" id="btnNewChat" data-tooltip="New conversation (Cmd+K)" data-i18n-title="new_conversation" aria-label="New conversation">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
</button>
</div>
+2 -2
View File
@@ -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);
+23 -3
View File
@@ -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) */
+62 -7
View File
@@ -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<NON_MESSAGE_SCROLL_INTENT_SUPPRESS_MS;
}
if(typeof document!=='undefined'){
document.addEventListener('wheel',_recordNonMessageScrollIntent,{capture:true,passive:true});
document.addEventListener('touchmove',_recordNonMessageScrollIntent,{capture:true,passive:true});
}
// Reset hook for session-switch — called from sessions.js loadSession() to
// prevent the new chat's first scroll comparing against the previous chat's
// scrollTop (Opus stage-302 SHOULD-FIX, #1731 follow-up).
@@ -1778,6 +1797,7 @@ document.addEventListener('DOMContentLoaded',function(){
function scrollIfPinned(){
if(!_scrollPinned) return;
if(_recentNonMessageScrollIntent()) return;
const el=$('messages');
if(el){_programmaticScroll=true;el.scrollTop=el.scrollHeight;setTimeout(()=>{_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})});
+4 -4
View File
@@ -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';
}
+168
View File
@@ -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: <nav class="rail" ...> ... </nav>
rail_match = re.search(
r'<nav class="rail"[^>]*>(.*?)</nav>',
html,
re.DOTALL,
)
self.assertIsNotNone(rail_match, "Could not locate <nav class='rail'> in index.html")
rail_block = rail_match.group(1)
rail_btn_count = 0
missing = []
for m in re.finditer(r'<button\b([^>]*?)>', rail_block):
attrs = m.group(1)
if 'rail-btn' not in attrs:
continue
rail_btn_count += 1
if 'has-tooltip' not in attrs:
missing.append(('class missing has-tooltip', attrs[:120]))
continue
tooltip_attr = re.search(r'data-tooltip="([^"]*)"', attrs)
if not tooltip_attr or not tooltip_attr.group(1).strip():
missing.append(('missing or empty data-tooltip', attrs[:120]))
self.assertGreaterEqual(
rail_btn_count, 10,
f"Expected ≥10 rail buttons (found {rail_btn_count}). Test selector wrong?",
)
self.assertEqual(
missing, [],
f"Rail buttons without working tooltip markup:\n " +
"\n ".join(f"{reason}: {attrs}" for reason, attrs in missing),
)
# ---------------------------------------------------------------------------
# `--bottom-right` variant: anchors tooltip's RIGHT edge to a trigger that sits
# flush with its container's right edge, so the label extends inward instead of
# overflowing past the panel edge. Used by `#btnNewChat`.
# ---------------------------------------------------------------------------
class BottomRightTooltipVariantTests(unittest.TestCase):
def setUp(self):
self.css = _read(STYLE_CSS)
self.html = _read(INDEX_HTML)
def test_bottom_right_variant_defined(self):
"""`.has-tooltip--bottom-right::after` must exist and right-anchor the
tooltip (`right: 0` and no `transform: translateX`)."""
rule = re.search(
r"\.has-tooltip--bottom-right:{1,2}after\s*\{([^}]*)\}",
self.css,
re.DOTALL,
)
self.assertIsNotNone(rule, "`.has-tooltip--bottom-right::after` rule missing")
body = rule.group(1)
# Must anchor right edge.
self.assertRegex(body, r"right\s*:\s*0",
"--bottom-right variant must set right:0")
# Must clear the inherited `left:` so it doesn't fight with the base rule.
self.assertRegex(body, r"left\s*:\s*auto",
"--bottom-right variant must clear left:auto")
# Must clear the inherited transform (otherwise translateX(-50%) shifts it).
self.assertRegex(body, r"transform\s*:\s*none",
"--bottom-right variant must reset transform:none")
def test_btn_new_chat_uses_bottom_right_variant(self):
"""`#btnNewChat` sits flush with the chat-panel right edge; its tooltip
previously overflowed (with `--bottom`, half clips past the panel).
Must now use `--bottom-right`, NOT `--bottom`."""
match = re.search(
r'<button[^>]*\bid="btnNewChat"[^>]*>',
self.html,
)
self.assertIsNotNone(match, "Could not find #btnNewChat button")
attrs = match.group(0)
self.assertIn(
"has-tooltip--bottom-right",
attrs,
"#btnNewChat must carry has-tooltip--bottom-right so its tooltip "
"doesn't overflow the chat-panel right edge.",
)
# Must NOT also carry the old --bottom (would conflict).
self.assertNotRegex(
attrs,
r'has-tooltip--bottom(?!-)',
"#btnNewChat carries both --bottom and --bottom-right; pick one. "
"The plain --bottom variant centers on left:50% and overflows.",
)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,59 @@
from pathlib import Path
BOOT_JS = Path("static/boot.js").read_text(encoding="utf-8")
WORKSPACE_JS = Path("static/workspace.js").read_text(encoding="utf-8")
def _function_block(src: str, name: str) -> str:
marker = f"function {name}("
start = src.find(marker)
assert start != -1, f"{name}() not found"
params_end = src.find("){", start)
assert params_end != -1, f"{name}() body not found"
brace = params_end + 1
depth = 0
for idx in range(brace, len(src)):
ch = src[idx]
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
return src[start : idx + 1]
raise AssertionError(f"{name}() body did not close")
def test_clear_preview_can_keep_preview_only_panel_open_for_directory_navigation():
"""#1785: leaving preview via a directory breadcrumb should switch to browse mode, not close."""
block = _function_block(BOOT_JS, "clearPreview")
assert "keepPanelOpen" in block, (
"clearPreview() needs an explicit keep-open option so breadcrumb/directory "
"navigation can leave preview-only mode without closing the workspace panel."
)
assert "_workspacePanelMode==='preview'&&!keepPanelOpen" in block.replace(" ", ""), (
"Preview-only close behavior should remain for the X button, but must be gated "
"off when directory navigation requests keepPanelOpen."
)
assert "openWorkspacePanel('browse')" in block or '_setWorkspacePanelMode("browse")' in block, (
"When keepPanelOpen is requested from preview-only mode, clearPreview() should "
"transition the workspace panel to browse mode so the root listing remains visible."
)
def test_load_dir_keeps_workspace_panel_open_when_clearing_preview():
"""#1785: loadDir('.') from the ~ breadcrumb should reveal the listing, not collapse the panel."""
block = _function_block(WORKSPACE_JS, "loadDir")
assert "clearPreview({keepPanelOpen:true})" in block.replace(" ", ""), (
"Directory navigation clears previews as part of showing the file tree; that clear "
"must keep the workspace panel open for breadcrumb navigation from preview mode."
)
def test_file_preview_breadcrumb_uses_directory_navigation_for_root():
block = _function_block(WORKSPACE_JS, "renderFileBreadcrumb")
assert "loadDir('.')" in block, "The preview root breadcrumb should navigate to the workspace root."
assert "clearPreview(); loadDir('.')" not in block, (
"The preview root breadcrumb should not do a close-style preview clear before "
"directory navigation; loadDir() owns the keep-open preview clear."
)
@@ -113,3 +113,60 @@ def test_known_provider_anthropic():
model, provider, _ = resolve_model_provider(qualified)
assert provider == "anthropic"
assert model == "claude-sonnet-4.6"
# ---------------------------------------------------------------------------
# Issue #1776 — custom provider + :free / :beta / :thinking suffix
#
# The PR #1762 fix for #1744 skipped the rsplit-fallback when the provider
# hint started with "custom:", on the assumption that custom-provider model
# IDs route directly without further heuristics. But "@custom:my-key:model:free"
# trips the same rsplit grammar collision: rsplit yields
# provider="custom:my-key:model", bare="free"
# and the custom-prefix guard skips the fallback → wrong routing.
#
# The fix detects the over-split structurally: custom hints carry exactly
# one segment after "custom:" (see api/config.py:1363 where the slug is
# constructed as "custom:" + entry_name), so any rsplit result of the form
# "custom:<a>:<b>" with bare model "<c>" has eaten one model segment. Peel
# it back so the model becomes "<b>:<c>".
# ---------------------------------------------------------------------------
def test_custom_provider_free_suffix_1776():
"""@custom:my-key:some-model:free → custom:my-key + some-model:free (#1776)."""
qualified = "@custom:my-key:some-model:free"
model, provider, _ = resolve_model_provider(qualified)
assert provider == "custom:my-key", f"expected provider='custom:my-key', got '{provider}'"
assert model == "some-model:free", f"expected model='some-model:free', got '{model}'"
def test_custom_provider_beta_suffix_1776():
"""@custom:my-key:some-model:beta — same bug class as :free."""
qualified = "@custom:my-key:some-model:beta"
model, provider, _ = resolve_model_provider(qualified)
assert provider == "custom:my-key"
assert model == "some-model:beta"
def test_custom_provider_thinking_suffix_1776():
"""@custom:my-key:some-model:thinking — same bug class as :free."""
qualified = "@custom:my-key:some-model:thinking"
model, provider, _ = resolve_model_provider(qualified)
assert provider == "custom:my-key"
assert model == "some-model:thinking"
def test_custom_provider_preview_suffix_1776():
"""@custom:my-key:some-model:preview — same bug class, no allowlist needed."""
qualified = "@custom:my-key:some-model:preview"
model, provider, _ = resolve_model_provider(qualified)
assert provider == "custom:my-key"
assert model == "some-model:preview"
def test_custom_provider_slashed_model_with_free_suffix_1776():
"""@custom:my-key:org/model:free — custom hint + slashed model + suffix."""
qualified = "@custom:my-key:org/model:free"
model, provider, _ = resolve_model_provider(qualified)
assert provider == "custom:my-key"
assert model == "org/model:free"
+27
View File
@@ -130,3 +130,30 @@ def test_eager_checkpointed_user_is_not_duplicated_after_agent_result():
msg_text="repeat me",
)
assert [m["role"] for m in merged] == ["user", "assistant"]
def test_deferred_turn_is_materialized_when_agent_returns_assistant_only_delta():
merged = streaming._merge_display_messages_after_agent_result(
previous_display=[
{"role": "user", "content": "older prompt"},
{"role": "assistant", "content": "older answer"},
],
previous_context=[
{"role": "user", "content": "older prompt"},
{"role": "assistant", "content": "older answer"},
],
result_messages=[
{"role": "user", "content": "older prompt"},
{"role": "assistant", "content": "older answer"},
{"role": "assistant", "content": "current answer"},
],
msg_text="latest prompt",
)
assert [m["role"] for m in merged] == [
"user",
"assistant",
"user",
"assistant",
]
assert [m["content"] for m in merged[-2:]] == ["latest prompt", "current answer"]
+47
View File
@@ -0,0 +1,47 @@
"""Regression tests for #1784: sidebar scroll remains independent while streaming."""
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
UI_JS = (ROOT / "static" / "ui.js").read_text(encoding="utf-8")
STYLE_CSS = (ROOT / "static" / "style.css").read_text(encoding="utf-8")
def _extract_fn(src: str, name: str) -> str:
marker = f"function {name}"
start = src.find(marker)
assert start >= 0, f"{name} not found"
brace = src.find("{", start)
assert brace >= 0, f"{name} body not found"
depth = 0
for i in range(brace, len(src)):
ch = src[i]
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
return src[start : i + 1]
raise AssertionError(f"{name} body did not close")
def test_sidebar_wheel_intent_is_recorded_passively():
"""A sidebar wheel gesture must not be swallowed or ignored during streaming."""
assert "_recordNonMessageScrollIntent" in UI_JS
assert "document.addEventListener('wheel',_recordNonMessageScrollIntent" in UI_JS
assert "{capture:true,passive:true}" in UI_JS
assert "!el.contains(target)" in UI_JS
assert "_lastNonMessageScrollIntentMs=performance.now()" in UI_JS
def test_scroll_if_pinned_skips_during_recent_non_message_scroll():
"""Token rendering must not force-scroll #messages while the sidebar is being scrolled."""
fn = _extract_fn(UI_JS, "scrollIfPinned")
assert "_recentNonMessageScrollIntent()" in fn
guard_index = fn.find("_recentNonMessageScrollIntent()")
write_index = fn.find("scrollTop=el.scrollHeight")
assert guard_index >= 0 and write_index >= 0 and guard_index < write_index
def test_session_list_has_its_own_scroll_boundary():
"""The session list is its own scroll surface, not chained to the chat/body scroller."""
assert ".session-list{flex:1;overflow-y:auto;padding:0 8px 8px;min-height:0;overscroll-behavior-y:contain;touch-action:pan-y;}" in STYLE_CSS
@@ -0,0 +1,263 @@
"""
Workspace context-menu hover and rename-dialog pre-fill regressions.
Two distinct bugs that were both shipped at the same time and only caught when
a user dogfooded the workspace panel:
(a) Workspace + session-list right-click context menu items had no visible
hover state because they wrote `style.background = 'var(--hover)'`. The
custom property `--hover` is undefined anywhere in the codebase. An
undefined `var()` falls back to the property's initial value (transparent
for `background`), so the hover state silently no-op'd. The defined
variable is `--hover-bg` (`rgba(255,255,255,.06)`), used by every other
hover state in the app there's a one-letter typo that ate every
context-menu hover.
(b) Right-click Rename did not pre-fill the input with the current filename.
`_inlineRenameFileItem` passed `defaultValue: item.name` to
`showPromptDialog`, but the dialog's input setter reads `opts.value` only.
The `defaultValue` parameter was silently dropped; only the placeholder
showed (the "ghost" name the user described).
Run: /root/hermes-agent/venv/bin/python -m pytest tests/test_workspace_context_menu_and_rename.py -v
"""
from __future__ import annotations
import os
import re
import unittest
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
UI_JS = os.path.join(BASE_DIR, "static", "ui.js")
SESSIONS_JS = os.path.join(BASE_DIR, "static", "sessions.js")
def _read(path: str) -> str:
with open(path, encoding="utf-8") as fh:
return fh.read()
# ---------------------------------------------------------------------------
# (a) Context-menu hover background — `--hover` was undefined; must use --hover-bg
# ---------------------------------------------------------------------------
class ContextMenuHoverBackgroundTests(unittest.TestCase):
"""Pin: no JS code path may set `style.background = 'var(--hover)'`.
The variable is undefined; the resolved value is `transparent`, which gives
no visible hover feedback. Use `var(--hover-bg)` (the actual variable used
by every other hover state in the codebase).
"""
def test_no_var_hover_in_ui_js(self):
src = _read(UI_JS)
# Match `var(--hover)` but NOT `var(--hover-bg)` / `var(--hover-2)` etc.
# Negative lookahead handles the `-` case; we also bar `_` and word chars.
bad = re.findall(r"var\(--hover\)(?![\w-])", src)
self.assertEqual(
bad, [],
f"Found {len(bad)} `var(--hover)` reference(s) in static/ui.js. "
"The variable `--hover` is undefined; this resolves to `transparent` "
"and breaks visible hover state. Use `var(--hover-bg)` instead.",
)
def test_no_var_hover_in_sessions_js(self):
src = _read(SESSIONS_JS)
bad = re.findall(r"var\(--hover\)(?![\w-])", src)
self.assertEqual(
bad, [],
f"Found {len(bad)} `var(--hover)` reference(s) in static/sessions.js. "
"Use `var(--hover-bg)` (the defined variable).",
)
def test_file_context_menu_uses_var_hover_bg(self):
"""Affirmative pin on the file context menu in ui.js — every menu item
builder (Rename, Reveal, Copy path, Delete) must use `var(--hover-bg)`."""
src = _read(UI_JS)
fn_match = re.search(
r"function\s+_showFileContextMenu\b[^{]*\{",
src,
)
self.assertIsNotNone(fn_match, "Could not find _showFileContextMenu()")
# Slice from start of function until the matching closing brace at
# column 0 (next top-level function). Cheap brace-balance.
start = fn_match.start()
depth = 0
end = start
for i, ch in enumerate(src[start:], start=start):
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
end = i + 1
break
body = src[start:end]
# Expect at least 4 hover assignments (one per menu item).
hits = re.findall(r"\.style\.background\s*=\s*['\"]var\(--hover-bg\)['\"]", body)
self.assertGreaterEqual(
len(hits), 4,
f"Expected ≥4 menu items to set background to var(--hover-bg) "
f"(Rename, Reveal, Copy path, Delete). Found {len(hits)}.",
)
def test_session_context_menu_uses_var_hover_bg(self):
"""Affirmative pin on the project chip context menu in sessions.js."""
src = _read(SESSIONS_JS)
fn_match = re.search(
r"function\s+_showProjectContextMenu\b[^{]*\{",
src,
)
self.assertIsNotNone(fn_match, "Could not find _showProjectContextMenu()")
start = fn_match.start()
depth = 0
end = start
for i, ch in enumerate(src[start:], start=start):
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
end = i + 1
break
body = src[start:end]
hits = re.findall(r"\.style\.background\s*=\s*['\"]var\(--hover-bg\)['\"]", body)
self.assertGreaterEqual(
len(hits), 2,
f"Expected ≥2 menu items to set background to var(--hover-bg) "
f"in _showProjectContextMenu. Found {len(hits)}.",
)
# ---------------------------------------------------------------------------
# (b) showPromptDialog pre-fill: must accept both `value` and `defaultValue`
# ---------------------------------------------------------------------------
class ShowPromptDialogPrefillTests(unittest.TestCase):
"""The rename dialog must pre-fill with the current filename (matches
every native file manager) AND the dialog must accept `defaultValue` as an
alias for `value` the typo that caused the original bug is too easy to
repeat with no API alias.
"""
def setUp(self):
self.src = _read(UI_JS)
def _slice_show_prompt_dialog(self) -> str:
"""Return the body of `showPromptDialog(opts={}){ ... }` as a string."""
# Anchor: the `function showPromptDialog` keyword. Skip past the
# parameter list (which contains `opts={}` — its `{}` would fool a naive
# brace counter), then balance braces from the function-body opener.
kw = re.search(r"function\s+showPromptDialog\b", self.src)
self.assertIsNotNone(kw, "Could not find showPromptDialog()")
# Find the parameter-list parens — skip over them by parens balance.
i = kw.end()
# advance to the opening '('
while i < len(self.src) and self.src[i] != "(":
i += 1
self.assertLess(i, len(self.src), "showPromptDialog: no opening paren")
depth = 0
while i < len(self.src):
ch = self.src[i]
if ch == "(":
depth += 1
elif ch == ")":
depth -= 1
if depth == 0:
i += 1
break
i += 1
# Now skip whitespace to the function-body '{'.
while i < len(self.src) and self.src[i] not in "{":
i += 1
self.assertLess(i, len(self.src), "showPromptDialog: no function-body brace")
start = i
depth = 0
end = start
for j, ch in enumerate(self.src[start:], start=start):
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
end = j + 1
break
return self.src[start:end]
def test_show_prompt_dialog_accepts_default_value_alias(self):
body = self._slice_show_prompt_dialog()
# Must reference `opts.defaultValue` somewhere — the alias was the
# backward-compatibility fix so future typos don't cause silent drops.
self.assertIn(
"opts.defaultValue", body,
"showPromptDialog must accept `defaultValue` as an alias for "
"`value` so callers using the standard HTMLInputElement.defaultValue "
"param name pre-fill correctly (regression protection).",
)
# Must still reference `opts.value` — the canonical param.
self.assertIn("opts.value", body)
def test_show_prompt_dialog_supports_select_stem(self):
"""Stem selection (everything before the last '.') is what makes
rename-with-pre-fill actually fast user can immediately type the new
basename without losing the extension. Without this, pre-fill plus a
full-string select would force the user to type the extension every
time."""
body = self._slice_show_prompt_dialog()
self.assertIn(
"selectStem", body,
"showPromptDialog should support `selectStem:true` to select the "
"filename portion before the last '.' on focus (Finder-style "
"rename UX).",
)
# Pin the actual stem-selection mechanic — must use lastIndexOf('.')
# and setSelectionRange. Anything else is the wrong selection rule.
self.assertRegex(
body, r"lastIndexOf\(\s*['\"]\.['\"]\s*\)",
"selectStem must use lastIndexOf('.') so 'a.b.c.d' selects 'a.b.c'.",
)
self.assertRegex(
body, r"setSelectionRange\s*\(\s*0\s*,",
"selectStem must use setSelectionRange(0, dot) to select the stem.",
)
def test_inline_rename_uses_value_and_select_stem(self):
"""The rename caller must (a) pre-fill the current name via `value:`
and (b) ask for `selectStem:true` on files (so the extension survives)
these are the two legs of the user-visible fix."""
m = re.search(
r"async\s+function\s+_inlineRenameFileItem\b[^{]*\{",
self.src,
)
self.assertIsNotNone(m, "Could not find _inlineRenameFileItem()")
start = m.start()
depth = 0
end = start
for i, ch in enumerate(self.src[start:], start=start):
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
end = i + 1
break
body = self.src[start:end]
# Must pass value:item.name (not defaultValue:item.name — the original bug).
self.assertRegex(
body,
r"value\s*:\s*item\.name",
"_inlineRenameFileItem must pass `value:item.name` to pre-fill "
"the dialog input. (The original `defaultValue:item.name` was "
"silently dropped because the dialog reads `opts.value`.)",
)
# Must opt into selectStem for files (not directories).
self.assertIn(
"selectStem", body,
"_inlineRenameFileItem must pass selectStem:... so renaming "
"'report.txt' selects 'report' and the user can immediately type "
"the new basename while preserving the extension.",
)
if __name__ == "__main__":
unittest.main()