fix(ui): 060 keep session-list refresh icon visibly spinning on active session#464
fix(ui): 060 keep session-list refresh icon visibly spinning on active session#464omercnet wants to merge 1 commit into
Conversation
…e session
The little refresh icon in the session list did not visibly animate when
clicked on the currently active session, even though it animated correctly
on every non-active session. Repro: open any session so it is active in the
main view, click its refresh icon in the sidebar — the icon switches to the
spinning variant for less than a frame, then snaps back to the static icon
before the user can perceive any motion.
Root cause was in packages/ui/src/stores/session-api.ts. loadMessages had
three early-return short-circuits at the top of the function:
1. force:true invalidates the messagesLoaded set.
2. alreadyLoaded && !force returns.
3. isLoading returns.
The third check ignored force:true. Whenever the target session was already
in the loadingMessages set, loadMessages resolved in the same microtask
without doing any work. The currently active session is the only session
reliably in that set at click time, because:
- packages/ui/src/components/session/session-view.tsx triggers a
loadMessages call inside a createEffect whenever the active session
mounts or changes.
- packages/ui/src/stores/session-events.ts fires a force:true reload
after every compaction event.
- Any in-flight refetch triggered by SSE hydration leaves the session
flagged as loading until the round-trip returns.
Non-active sessions are not in loadingMessages, so they hit the real fetch
path and the spinner stays visible for the duration of the network
round-trip by accident. The active session got the silent no-op, the
handleReloadSession finally block cleared reloadingSessionIds in the same
microtask, and the <Show> in session-list.tsx never paid out a frame with
the animated icon.
This change addresses both the underlying logic bug and the broader UX
correctness issue:
1. packages/ui/src/stores/session-api.ts: replace the isLoading
short-circuit with an in-flight registry keyed by
${instanceId}:${sessionId}. Non-force callers continue to dedupe
(they await the shared promise and return). force:true callers wait
for the prior load to settle and then run a fresh fetch, so the
refresh button actually refetches even when something else has the
session in flight. The registry entry is always cleared in finally
under a value-identity check so a slow finally cannot erase a newer
entry registered by an immediately-following call. The same fix
incidentally repairs the post-compaction reload in
packages/ui/src/stores/session-events.ts which silently dropped
under the old short-circuit and could leave the UI showing stale
messages until the next user action.
2. packages/ui/src/components/session-list.tsx: wrap the reload await in
a new withMinimumDuration helper (MIN_RELOAD_SPINNER_MS = 450) so the
spinner is guaranteed perceptible even if the underlying work
resolves instantly. This protects the UX against any future fast
path (cached responses, no-op early returns, localhost dev servers)
that could otherwise flash the spinner imperceptibly.
3. packages/ui/src/lib/min-duration.ts (new): pure helper exporting
withMinimumDuration and MIN_RELOAD_SPINNER_MS. now and delay are
injectable so the helper is unit-testable without sleeping real
time. Both success and failure paths honour the floor so a failed
reload still shows the spinner long enough to be perceived before
the error toast replaces it.
Verification:
- packages/ui/src/lib/min-duration.test.ts (new) exercises the helper
across six cases (fast resolve, slow resolve, fast reject, slow
reject, non-positive minMs, default constant bounds) using
node:test with an injected virtual clock. 6/6 pass via
'node --experimental-strip-types --test src/lib/min-duration.test.ts'
from packages/ui/, locking in the regression guarantee.
- npx tsc --noEmit -p packages/ui/tsconfig.json exits 0 under strict
TypeScript.
- npx vite build in packages/ui completes ("built in 19.81s"), exit 0.
Scope: surgical. No copy, no styling, no public API changes. The
loadMessages contract is clarified to match its documented intent
("force:true means actually refetch"), not redefined.
I don't think that is the correct approach. The sessions are reloaded really quickly hence the spinner isn't shown. |
|
Thanks for the quick look! I want to make sure we're talking about the same defect before deciding on the fix shape — I think the comment may be reacting to the secondary part of the change rather than the primary one. What I'm seeing in the code (not a UX hunch — a code path)The reported bug is "refresh icon doesn't animate on the currently active session." Non-active sessions animate fine. That asymmetry is the smoking gun. Walking the code:
if (force) { /* invalidate messagesLoaded set */ }
if (alreadyLoaded && !force) return
const isLoading = loading().loadingMessages.get(instanceId)?.has(sessionId)
if (isLoading) return // ← ignores force:trueThe third check ignores The currently active session is the only session reliably in
Non-active sessions are not in So the user-visible symptom (no spinner on active) is downstream of a logic bug: the reload button doesn't actually reload anything when clicked on the active session. A toast saying "Session reloaded" would announce work that didn't happen. The two parts of this PR are not equivalent
I hear you that 450ms of forced spinner is a perceptible delay. I'm completely fine dropping the wrapper if you'd rather keep this PR minimal — the session-api fix alone closes the reported bug because, with the in-flight defect fixed, a Can you confirm which way you'd like me to take this?
I'd lean (a) — let the real fix stand on its own, and treat any future "spinner too fast" issue as its own UX ticket if it ever materializes. Happy to push that revision as soon as you confirm. |
Summary
force: true-ignoring short-circuit inloadMessagesplus the absence of any minimum-visible-duration floor on the spinner.isLoadingshort-circuit with an in-flight promise registry soforce: truereliably awaits any prior load and then refetches, instead of silently no-opping.withMinimumDuration(default 450 ms) wrapping the reload await so the spinner is guaranteed perceptible regardless of how fast the work resolves.node:test-based regression test that locks in the spinner-duration floor across six behavioral cases.Root Cause
packages/ui/src/stores/session-api.ts#loadMessageshad three early-return checks at the top:force: trueinvalidates themessagesLoadedset.alreadyLoaded && !forcereturns.isLoadingreturns — and this check ignoredforce: true.Whenever the target session was already in the
loadingMessagesset,loadMessagesresolved in the same microtask without doing any work. The currently active session is the only session reliably in that set at click time, because:packages/ui/src/components/session/session-view.tsxtriggers aloadMessagescall inside acreateEffectwhenever the active session mounts or changes.packages/ui/src/stores/session-events.tsfires aforce: truereload after every compaction event.Non-active sessions are not in
loadingMessages, so they hit the real fetch path and the spinner stayed visible for the duration of the network round-trip by accident. The active session got the silent no-op,handleReloadSession'sfinallyblock clearedreloadingSessionIdsin the same microtask, and the<Show>insession-list.tsxnever paid out a frame with the animated icon.CSS was ruled out as a contributor —
.session-item-activeonly changesbackground-color,color,font-weight,box-shadow; nothing overridesanimate-spinor the shared@keyframes spin.What Changed
packages/ui/src/stores/session-api.ts(+61 / -3)inFlightLoadMessagesmap keyed by${instanceId}:${sessionId}.isLoadingshort-circuit with an in-flight-aware branch:forcecallers continue to dedupe by awaiting the shared promise and returning.force: truecallers wait for the prior load to settle (await existing.catch(() => {})), then fall through to run a fresh fetch.try/catch/finallyand clear the entry under a value-identity check so a slowfinallycannot erase a newer registration.packages/ui/src/stores/session-events.tsline 622 (post-compaction reload), which previously dropped under the same defect and could leave the UI showing stale messages until the next user action.packages/ui/src/components/session-list.tsx(+11 / -1)awaitinwithMinimumDuration(loadMessages(...), MIN_RELOAD_SPINNER_MS)so the spinner is held for at least 450 ms regardless of how fast the underlying work resolves. Protects the UX against any future fast path (cached responses, no-op early returns, localhost dev servers) that could otherwise flash the spinner imperceptibly.packages/ui/src/lib/min-duration.ts(new)withMinimumDuration<T>(work, minMs, deps?)andMIN_RELOAD_SPINNER_MS = 450.nowanddelayare injectable so the helper is unit-testable without sleeping real time.node:testrunner.packages/ui/src/lib/min-duration.test.ts(new, regression test)node:testcases exercising fast resolve, slow resolve, fast reject, slow reject, non-positiveminMs, and the default-constant bounds.node --experimental-strip-types --test src/lib/min-duration.test.tsfrompackages/ui/, matching the existing pattern inpackages/ui/src/components/tool-call/question-active.test.ts.Verification
node --experimental-strip-types --test src/lib/min-duration.test.ts(frompackages/ui/)npx tsc --noEmit -p tsconfig.json(frompackages/ui/)npx vite build(frompackages/ui/)✓ built in 19.81s, exit 0No SCR required —
loadMessages' public contract is being clarified to match its documented intent (force: truemeans "actually refetch"), not redefined. No copy, no styling, no public API changes.Scope Notes
packages/ui/src/components/session-list.tsxis now 851 lines andpackages/ui/src/stores/session-api.tsis now 975 lines, both above the 800-line warning threshold fromAGENTS.md.tasks/todo/060-session-list-refresh-icon-animation.md.