From 2e039ab7f7acd356686c91aebadb8e3a7c3dbd0b Mon Sep 17 00:00:00 2001 From: Michael Lam Date: Fri, 15 May 2026 14:11:13 -0700 Subject: [PATCH] fix: show readable live activity progress --- CHANGELOG.md | 2 ++ static/ui.js | 24 ++++++++++++++++++++++-- tests/test_ui_tool_call_cleanup.py | 19 +++++++++++++++++++ 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8107e1ac39d..668544b44a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ ### Fixed +- **PR #2331** by @Michaelyklam (closes #2310) — Live compact Activity rows now show transient user-readable progress such as "Searching workspace", "Reading files", "Updating files", or "Running command" while a long tool-only turn is still working. The label is derived from the current tool category, uses the existing non-persistent Activity summary/duration slot, and does not add synthetic assistant content to the transcript. + - **PR #2315** by @Michaelyklam (closes #2305, refs #749) — WebUI profile creation now seeds bundled profile skills for newly-created non-cloned profiles, matching the CLI's `hermes profile create` behaviour. Pre-fix, creating a profile via Settings → New Profile (without checking "Clone from active profile") left the profile's `skills/` directory empty, which was inconsistent with CLI-created profiles that get the full bundled-skills overlay. The fix calls `seed_profile_skills(profile_path, quiet=True)` after `profile_path.mkdir()` when `clone_from is None`. Cloned profiles still inherit skills from their source — they don't get a second bundled-skills overlay. Seed failures (e.g. `hermes_cli` unavailable in Docker fallback) are logged as warnings, not fatal — profile creation still succeeds. - **PR #2317** by @Michaelyklam (refs #2312 follow-up #2) — Appearance boot reconciliation now treats explicit `light`, `dark`, and `system` localStorage theme values as user selections when a prior Settings autosave failed. Pre-fix, the predicate `lsHasExplicitTheme = lsTheme === 'system'` only treated 'system' as explicit, so a user who picked `light` on a server defaulted to `dark` (or vice versa) with a failed autosave still reverted to the server default on refresh. Now broadened to `['system','light','dark'].includes(lsTheme)`. Skin handling was already correct (`lsSkin !== 'default'`). Closes follow-up item #2 from the v0.51.66 review (#2312). diff --git a/static/ui.js b/static/ui.js index 74db6f4ee37..ede774160b9 100644 --- a/static/ui.js +++ b/static/ui.js @@ -5929,10 +5929,11 @@ function _syncToolCallGroupSummary(group){ if(durationEl){ if(group.getAttribute('data-live-tool-call-group')==='1'){ const activeText=_activityElapsedLabel(group); + const progressText=_activityLiveProgressLabel(group); if(activeText) group.setAttribute('data-active-turn-elapsed',activeText); else group.removeAttribute('data-active-turn-elapsed'); - durationEl.textContent=activeText?`Working ${activeText}`:''; - durationEl.style.display=activeText?'':'none'; + durationEl.textContent=[progressText, activeText].filter(Boolean).join(' · '); + durationEl.style.display=durationEl.textContent?'':'none'; }else{ const durationText=_formatTurnDuration(group.dataset.turnDuration); durationEl.textContent=durationText?`Done in ${durationText}`:''; @@ -5941,6 +5942,25 @@ function _syncToolCallGroupSummary(group){ } } +function _activityProgressLabelForToolName(name){ + const key=String(name||'').toLowerCase().replace(/[^a-z0-9]+/g,'_'); + if(!key) return 'Working'; + if(key.includes('search')||key.includes('grep')) return 'Searching workspace'; + if(key.includes('read')||key.includes('view')||key.includes('open')) return 'Reading files'; + if(key.includes('write')||key.includes('patch')||key.includes('edit')) return 'Updating files'; + if(key.includes('terminal')||key.includes('shell')||key.includes('command')||key.includes('process')) return 'Running command'; + if(key.includes('web')||key.includes('fetch')||key.includes('curl')) return 'Checking web data'; + if(key.includes('todo')||key.includes('plan')) return 'Planning next steps'; + return 'Working'; +} + +function _activityLiveProgressLabel(group){ + if(!group||group.getAttribute('data-live-tool-call-group')!=='1') return ''; + const running=group.querySelector('.tool-card.tool-card-running .tool-card-name'); + const latest=running || Array.from(group.querySelectorAll('.tool-card-name')).pop(); + return _activityProgressLabelForToolName(latest?latest.textContent:''); +} + // ── Live tool card helpers (called during SSE streaming) ── // Live cards are inserted INLINE inside #msgInner (tagged with data-live-tid) // so the streaming layout matches the settled layout produced by renderMessages diff --git a/tests/test_ui_tool_call_cleanup.py b/tests/test_ui_tool_call_cleanup.py index 35bdaa1637c..b350379b58a 100644 --- a/tests/test_ui_tool_call_cleanup.py +++ b/tests/test_ui_tool_call_cleanup.py @@ -214,6 +214,25 @@ def test_live_tool_activity_defaults_collapsed_unless_saved_open(self): "A previously-open Activity group should still restore open from persisted state." ) + def test_live_activity_summary_shows_readable_progress_without_persisted_content(self): + sync_fn = _function_body(UI_JS, "_syncToolCallGroupSummary") + progress_fn = _function_body(UI_JS, "_activityProgressLabelForToolName") + live_progress_fn = _function_body(UI_JS, "_activityLiveProgressLabel") + assert "_activityLiveProgressLabel" in sync_fn, ( + "Live compact Activity rows should expose a readable transient progress label." + ) + assert "durationEl.textContent" in sync_fn and "filter(Boolean).join(' · ')" in sync_fn, ( + "Progress should share the existing non-persistent summary/duration slot, not become transcript text." + ) + for label in ("Searching workspace", "Reading files", "Updating files", "Running command"): + assert label in progress_fn + assert "tool-card-running" in live_progress_fn, ( + "The live progress label should prefer the currently running tool over older completed tools." + ) + assert "tool-call-group-list" not in sync_fn, ( + "Readable progress must not reintroduce the noisy secondary tool-name list." + ) + def test_tools_and_thinking_share_one_collapsed_activity_dropdown(self): ui_min = re.sub(r"\s+", "", UI_JS) assert "functionensureActivityGroup(" in ui_min, (