ux(profiles): spinner indicator + parallelized fetches for profile switching#1209
ux(profiles): spinner indicator + parallelized fetches for profile switching#1209nesquena-hermes wants to merge 1 commit intomasterfrom
Conversation
…itching Visual feedback: - Profile chip immediately shows the new name (optimistic update) when clicked - Small CSS spinner appears on the chip icon during the switch - Button is disabled to prevent double-clicks stacking - finally block always cleans up — chip can never get stuck in loading state - On error: chip label reverts to the previous profile name Performance: - populateModelDropdown() and loadWorkspaceList() now run via Promise.all instead of sequential awaits — model dropdown fetch and workspace list fetch are independent, so they run simultaneously (~50% faster switches) - Apply steps (S._pendingProfileModel, workspace update) happen after Promise.all resolves — correctness unchanged 11 new tests in tests/test_profile_switch_ux.py covering: - spinner CSS class present during switch, removed in finally - optimistic name set before API call - chip disabled during switch, re-enabled after - error path reverts chip label - Promise.all pattern verified - no old sequential await pattern present - CSS switching class has correct properties 2798 tests pass, 0 fail.
nesquena
left a comment
There was a problem hiding this comment.
Review — end-to-end ✅ (clean approve)
What this ships
UX polish on top of #1203's profile-switch correctness fix. Two changes:
-
Spinner + optimistic chip label (panels.js:2014-2023 + style.css:661-663) — On click:
.switchingCSS class +_chip.disabled = true+ immediate_chipLabel.textContent = name. The user sees the new name in <1ms; spinner ring rotates on the icon while fetches complete;finallyblock always cleans up; on error the label reverts to the previous name. -
Parallelized fetches (panels.js:2033-2037) —
populateModelDropdown()andloadWorkspaceList()are independent network calls (one hits/api/models, the other/api/workspaces) that were running sequentially. Now they run viaPromise.all. Apply steps stay after the join — the order-sensitive logic (S._pendingProfileModel, workspace update, session update) is unchanged.
Traced against upstream hermes-agent
Pure WebUI presentation. Both endpoints (/api/models and /api/workspaces) are independent in the agent's view too — there's no inter-endpoint state ordering to preserve. ✅
End-to-end trace
Spinner state machine
| Event | Chip class | Chip disabled | Label |
|---|---|---|---|
| Click profile B (was on A) | +switching |
true |
"B" (optimistic) |
await /api/profile/switch succeeds |
switching |
true |
"B" |
await Promise.all([...]) resolves |
switching |
true |
"B" |
| Apply steps run | switching |
true |
"B" |
finally block |
-switching |
false |
"B" |
Error path:
| Event | Chip class | Chip disabled | Label |
|---|---|---|---|
| Click profile B | +switching |
true |
"B" (optimistic) |
| Network/server error | switching |
true |
"B" |
catch (e) |
switching |
true |
"A" (reverted) |
finally block |
-switching |
false |
"A" |
The state machine is sound — the finally block guarantees cleanup, and both disabled=true (HTML attribute) + pointer-events:none (CSS) prevent stacked clicks during the switch.
Promise.all parallelization
populateModelDropdown() is async at ui.js:133; loadWorkspaceList() is async at panels.js:1299. Neither reads the other's output. Switching from sequential awaits to Promise.all([...]) is safe — the slower of the two bounds the wall time. The PR description's "~50% faster" claim matches this analysis when the two fetches have similar latency.
The post-join apply steps (_pendingProfileModel, workspace input update, session update) all run in the same code path as before, just unindented from the previous sequential await chain. Order-sensitive correctness preserved.
@keyframes spin reuse
The new .composer-profile-chip.switching .composer-profile-icon::after rule references animation:spin .6s linear infinite. @keyframes spin is defined at style.css:323 (re-used by other spinner CSS at lines 271, 1643). ✅ No new keyframe needed.
Spinner positioning
.composer-profile-chip.switching .composer-profile-icon::after {
content:''; display:inline-block; width:10px; height:10px;
border:1.5px solid currentColor; border-top-color:transparent;
border-radius:50%;
animation:spin .6s linear infinite;
position:absolute; left:50%; top:50%;
margin-left:-5px; margin-top:-5px;
}
.composer-profile-chip.switching .composer-profile-icon { position:relative; }The icon container becomes position:relative only when .switching is applied, so the absolute-positioned ::after ring is anchored to the icon. Outside switching state, the icon's positioning is unchanged. ✅
Edge-case trace
| Scenario | Behaviour |
|---|---|
| Click + immediate second click | Second click blocked (disabled + pointer-events:none) ✅ |
| POST succeeds, populate fails | label reverts to "A" via catch S.activeProfile is "B" — next syncTopbar re-renders to "B" |
| Both Promise.all branches fail | catch reverts label, toast shown |
| Network slow, user clicks away | await continues regardless; finally runs on completion |
S.busy=true mid-stream |
Early return before the chip-loading block — pre-existing busy-toast UX preserved |
| Chip element doesn't exist (DOM not ready?) | if (_chip) guard skips class + disabled toggle ✅ |
_chipLabel element doesn't exist |
if (_chipLabel) guard skips label updates ✅ |
| User clicks same profile they're on | Switch runs anyway — minor wasted work, but matches existing pre-PR behaviour |
Tests
- PR's 11 new tests (
tests/test_profile_switch_ux.py): all pass.- Spinner CSS class added + removed in
finally - Optimistic name update before API call
- Chip disabled + re-enabled in
finally - Error path reverts label to
_prevProfileName Promise.allpattern present + sequential pattern absent- Apply steps come AFTER
Promise.allresolves - CSS
.switchingclass hascursor:wait+pointer-events:none
- Spinner CSS class added + removed in
- Local full suite: 2753 passed, 47 skipped, 0 failures.
- CI on PR: ✅ test (3.11), ✅ test (3.12), ✅ test (3.13).
Other audit — confirmed correct
- JS syntax:
node --checkpasses onpanels.js. - No agent coupling: pure UI state machine + frontend network parallelization.
- Builds on #1203 correctness: this PR adds UX polish; the underlying bugs (#1200 thread-local workspace, model cache, chip label) were fixed in #1203 and remain unaffected.
finallyis the right primitive: guarantees cleanup on early return, throw, or normal exit._chip.disabled+ CSSpointer-events:noneis belt-and-suspenders against double-clicks. Either alone would suffice on modern browsers; together is defensive.
Minor observations (non-blocking)
- Catch reverts label even when POST succeeded: if
/api/profile/switchreturns 200 (soS.activeProfile = Bis set) but a downstream step (populate/loadWorkspace/applyBotName) throws, the catch reverts the chip label to "A". TheS.activeProfileis still "B" though — so nextsyncTopbar()call re-renders the label to "B". The UX wart is fleeting (chip says "A" briefly then snaps to "B"). Acceptable; the more correct version would be to track which step failed and only revert if the POST itself failed. Out of scope for this UX PR. - The CHANGELOG entry at the very top of the file inserts before
## v0.50.225, but the previous batch'sv0.50.230heading was right above. Looking at the file:### Bug fixesblock at top is for v0.50.231 (probably the macOS quirk), thenv0.50.230, thenv0.50.225. The newv0.50.235entry inserted between v0.50.231 and v0.50.225 — that's the right spot chronologically (newest first). ✅ - The 0.6s spin period is slightly faster than the existing 1s spin used in the workspace dropdown spinner. Cosmetic — both are reasonable.
Recommendation
Approved. Tight UX polish on top of #1203's correctness fixes. State machine is sound (finally guarantees cleanup, optimistic update reverts on error), parallelization is safe (independent fetches, apply steps post-join), and the spinner reuses the existing @keyframes spin. CI green; full suite clean. Parked at approval — ready for the release agent's merge/tag pipeline.
…OLO mode (#1211) fix+feat: batch v0.50.236 — OAuth providers fix, profile switch UX, YOLO mode (#1211) Merges PRs #1208, #1209, #1210 (#1152 rebased): - fix(providers): OAuth provider cards show correct Configured status in Settings. get_providers() was discarding has_key=True from _provider_has_key() for OAuth providers, hiding config.yaml tokens. Also fixed filter excluding all OAuth providers from the Settings panel. Surfaces auth_error string. (closes #1202) - ux(profiles): profile chip shows spinner and new name immediately on switch. Optimistic name update + .switching CSS class + chip disabled + finally cleanup. populateModelDropdown() and loadWorkspaceList() now parallelized via Promise.all. - feat: YOLO mode toggle — skip all approvals per session. /yolo slash command, "Skip all this session" button on approval cards, amber ⚡ pill indicator in composer footer. Session-scoped, in-memory. Full i18n: en, ru, es, de, zh, ko, zh-Hant. (closes #467) Original author: @bergeouss (PR #1152) Tests: 2837 passed (+50 new tests vs previous release) QA harness: 20/20 passed + all browser API checks passed
|
Merged in v0.50.236 via batch PR #1211. |
…OLO mode (nesquena#1211) fix+feat: batch v0.50.236 — OAuth providers fix, profile switch UX, YOLO mode (nesquena#1211) Merges PRs nesquena#1208, nesquena#1209, nesquena#1210 (nesquena#1152 rebased): - fix(providers): OAuth provider cards show correct Configured status in Settings. get_providers() was discarding has_key=True from _provider_has_key() for OAuth providers, hiding config.yaml tokens. Also fixed filter excluding all OAuth providers from the Settings panel. Surfaces auth_error string. (closes nesquena#1202) - ux(profiles): profile chip shows spinner and new name immediately on switch. Optimistic name update + .switching CSS class + chip disabled + finally cleanup. populateModelDropdown() and loadWorkspaceList() now parallelized via Promise.all. - feat: YOLO mode toggle — skip all approvals per session. /yolo slash command, "Skip all this session" button on approval cards, amber ⚡ pill indicator in composer footer. Session-scoped, in-memory. Full i18n: en, ru, es, de, zh, ko, zh-Hant. (closes nesquena#467) Original author: @bergeouss (PR nesquena#1152) Tests: 2837 passed (+50 new tests vs previous release) QA harness: 20/20 passed + all browser API checks passed
Summary
After PR #1203 fixed profile switching correctness (workspace, model, chip label), there was still a noticeable UX gap: between clicking a profile and the UI updating, there was no feedback. The user couldn't tell if their click registered. This PR adds an instant visual response and cuts the switch time roughly in half.
Changes
1. Profile chip loading indicator (
static/panels.js+static/style.css)The moment a profile is selected from the dropdown:
@keyframes spinanimationpointer-events: none+disabledprevents stacked clicks during the switchfinallyblock — always removes the spinner and re-enables the chip, regardless of success or error. The chip can never get stuck in loading state.2. Parallelized model + workspace fetches (
static/panels.js)The two network calls inside
switchToProfile()were sequential:They're completely independent — neither depends on the other's output. They now run simultaneously:
The apply steps (
S._pendingProfileModel, workspace update, session update) happen afterPromise.allresolves — the correctness logic is unchanged.Test results
11 new tests in
tests/test_profile_switch_ux.py:finallyPromise.allpattern verified, old sequential pattern gone.switchingclass hascursor:waitandpointer-events:none2798 passed, 0 failed, 2 skipped (macOS-only)
Browser verification
Tested with 3-profile round-trip (default → camanji → webui → default):
.switchingclass on chip during switchdisabled=trueduring switch.switchingclass removed after switchdisabled=falseafter switch