mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-26 03:30:36 +00:00
Merge pull request #1799 from nesquena/stage-312
v0.51.18 — 5-PR batch (#1783, #1789, #1790, #1791, #1794)
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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 |
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user