.agents/skills/ux/references/feedback.md
How the product answers back while and after the user acts — loading visuals and proactive guardrails.
Part of the ux skill — see ../SKILL.md. Each checklist item is
tagged with the design value(s) it serves.
Never use antd Spin — it doesn't match the product's loading visual. Use a project
loader:
| Need | Component |
|---|---|
| Default loading (in-flight) | NeuralNetworkLoading from @/components/NeuralNetworkLoading (size prop) |
| Inline dots | DotsLoading / BubblesLoading from @/components |
| Branded full-page | Loading from @/components/Loading/BrandTextLoading |
| List / card placeholder | a skeleton (e.g. SkeletonList) |
When in doubt, reach for NeuralNetworkLoading — the default in-flight indicator (e.g.
modal "in progress" states). Minimise layout shift (CLS): the strongest loading state
changes as little of the final layout as possible. When a surface already knows its shape
(card, row, list item), keep the layout elements — container, border, radius, padding,
icon — and replace only the text/data with a skeleton sized like the text it stands in
for — matching both its height and its typical width proportion, so a two-line row
keeps a long title line over a shorter subtitle line rather than two equal full-width bars
(which mis-signal the shape and shift as the real, narrower text lands). A generic
full-block / full-card skeleton (or a centred spinner the real content later pushes aside)
is heavier and shifts the layout; an in-place text→skeleton swap is optimal.
✅ The Fleet sidebar's
SidebarTaskSkeletonmirrors the realSidebarTaskItemrow exactly — same 28 px square avatar, two stacked text lines sized like the title/subtitle (70 % / 45 % width), same padding (RunningTaskSidebar.tsx) — so the load→content swap is in-place with no relayout. ❌ A bare full-row block or a centred spinner the real rows later push aside.
An elapsed / progress readout for a long op (a generation clock, an upload %, "running for 2:14") has a subtler honesty trap than the skeleton: if it counts from local component state, it silently resets to 0 on remount — the item scrolls out of a virtual feed and back, the route is revisited, a re-render remounts the node — and the user sees a minute-long job claim it just started. Derive the start from a persisted timestamp keyed by the job id (sessionStorage / store), so the readout recovers the true elapsed on remount rather than lying; and clear that key when the job leaves the active state so a finished / removed job doesn't leak a stale start onto a later one that reuses the slot.
✅ The create-page
ElapsedTimereadsgeneration_start_time_{generationId}from sessionStorage on mount (writing it once if absent), so a generation's clock survives a remount and shows real duration; on!isActiveitremoveItems the key (image/…/GenerationItem/ElapsedTime.tsx:33-49). ❌ AuseState(0)counter started in the mount effect — every remount restarts the timer at zero.
Checklist
Spin; use NeuralNetworkLoading / project loaders. (Natural)A loading state that can only ever resolve to success is a bug. Any async fetch can hang,
time out, or error, so every loading state needs a terminal failure path: after a
bounded wait (or on an error) the spinner / skeleton must give way to an explicit failed
state that says it didn't load and offers a Reload / Retry button. An indefinite spinner
is indistinguishable from a dead one — the user is stuck with no recourse but to reload the
whole app, and can't even tell whether anything is still happening. A failed-with-retry
state hands control back and restores certainty. Retry re-runs the same fetch (SWR
mutate / query refetch), shows loading again while it re-runs, and stays available if it
fails again; keep any already-loaded context rather than blowing the surface away.
But not every failure should offer Retry — gate it on whether a retry could plausibly
succeed. A 401 (auth expired / invalid) or 403 (no permission) wall will just fail
again the same way, so a Reload button there is a false promise: it invites the user to
mash a control that can't work. The failed state still renders (reason + status-specific copy
— "please sign in", "you don't have access"), it just omits Retry; 5xx / timeout /
network stay retryable. Derive this from the normalized error status, don't hand-code it per
surface — normalizeAsyncError marks 401 / 403 (and anything flagged
meta.shouldRetry === false) non-retryable, and AsyncError hides the button accordingly.
Decision (2026-07-02): the component-level failed state does not itself redirect on
401. We assume session-expiry is caught globally (an app-level interceptor routes an expired session to sign-in), so a surface only needs the honest no-retry failure state as a backstop — it should not add a second redirect layer. Revisit only if a future surface turns up where the global redirect doesn't fire and a401genuinely strands the user; then wire per-surface handling. Don't pre-build it.
We under-build this today — most surfaces only draw loading + success and let a slow or failed request spin forever. Treat the failure path as required, not optional: it's a large part of what makes the experience feel trustworthy.
A common shape of this bug: the surface gates its "ready" render on an init flag that is
set only on a successful fetch (if (!isInit) return <Skeleton/>). On error the flag
never flips, so the skeleton is permanent — an infinite spinner wearing a skeleton's
clothes. The error path must drive the flag / a separate error state, not be forgotten.
The flag is often disguised as data-presence — loaded = Array.isArray(map[id]), where
map[id] is written only in the SWR onSuccess: same success-only init flag, just spelled
as "is the data here yet". A failed fetch leaves the entry undefined → loaded stays
false → permanent skeleton. Don't read "present in a success-populated map" as "loaded";
branch the fetch's real error.
A subtler shape: the error state and retry action exist in the store but are wired to
nothing. The slice records an errorMap, exposes isXError / currentXError selectors,
even ships a retryX action — the data layer is exactly right — but no surface consumes
any of it: the component still branches only on the success-only loading flag, so a failed
fetch is a permanent skeleton and the retry the store built is dead code. A built-but-orphaned
error path is indistinguishable from a missing one at the pixel; the store having the
right shape does not discharge the obligation — a surface must actually read the error and
render the failed state. When you find an errorMap / isXError selector, grep its
consumers (rg isXError): zero call sites is the tell.
❌ Agent profile (
/agent/:aid/profile) branches only onisAgentConfigLoading = !activeAgentId || !agentMap[id](store/agent/selectors/selectors.ts), the data-presence disguise, and renders a full-page<Loading/>— so a failed / 404getAgentConfigById(a bad or deleted:aid) spins forever with no retry. The kicker:onErrordoes populateagentConfigErrorMap, andcurrentAgentConfigError/isAgentConfigErrorandretryFetchAgentConfigall exist with a doc comment promising "a retry UI instead of an endless skeleton" — butrgfinds zero consumers anywhere (ProfileAreainroutes/(main)/agent/profile/index.tsxreads none of them). The error+retry machinery is fully built and reaches no pixel. ✅ BranchisAgentConfigErrorbefore the loading gate → a failed state (reason + Reload calling the existingretryFetchAgentConfig).
The mirror-image trap on a compound gate — one that holds the skeleton until a secondary /
dependent fetch resolves (a detail that waits on the assignee's config, a row that waits on its
owner) — is gating on that dependency being present in the map. When the dependency
legitimately resolves to absent (deleted, out-of-scope, null by design) it never lands in
the map, so a presence-gate hangs forever on a perfectly healthy state. Gate on the dependency
fetch's in-flight flag instead: block only while it's genuinely loading, and release on
settled — data or resolved-null or error alike. "Absent" is a resolved state, not a pending
one.
✅ Task detail gets the compound gate right: it blocks the skeleton on
agentConfigLoading(the assignee-config fetch's in-flight flag), not on the assignee being present in the map, so a task whose assignee was deleted / moved out of scope resolves tonulland releases the gate instead of spinning forever (useActiveTaskDetail.ts:66-77); the task skeleton itself is likewise gated on "resolving", not ondata === undefined.
A close cousin, and the most deceptive: the error branch exists in the very same component
and even reads the real fetch error — but it's ordered after an if (isLoading) return <Skeleton/> early-return whose isLoading is the data-presence flag (!map[id]). On a
first-load failure the entry never lands, so isLoading stays true, the skeleton
short-circuits the function, and the error render below it is unreachable — it paints only
on a revalidation failure, when the data is already present. A resolved not-found
(null) hits the same wall: dropped in the success handler, it never populates the map either,
so a deleted / missing record shows a permanent skeleton instead of a 404. Ordering is the
bug: a built error branch a first-load failure can't reach is as good as an absent one. Check
error / not-found before the loading gate, or make the gate go false on settled (data
/ null / error).
❌ Agent document view (
/agent/:aid/docs/:docId) has the error state written but ordering-dead:DocumentIdModerenders{error && <EditorError/>}off the real SWRerror, but only afterif (isLoading) return <EditorSkeleton/>, whereisLoading = editorSelectors.isDocumentLoading(id) = !documents[id](EditorCanvas/DocumentIdMode.tsx,store/document/slices/editor/selectors.ts).useFetchDocumentwritesdocuments[id]only in itsonData, and returnsnull(not a throw) on not-found, dropped by an early return (store/document/slices/document/action.ts). So a first-load 500 and a deleted / baddocIdboth leave the entryundefined→ permanent skeleton, no retry; theEditorErroralert can only appear when a later focus-revalidation fails over already-loaded content. ✅ Brancherrorand resolved-nullbefore the loading gate → a failed state (reason + Reload viamutate) and a real not-found, keeping the skeleton only for!error && no-data-yet.
A third shape, in a transient / auto-dismissing surface (an upload dock, a progress toast, a status snackbar that clears itself after N seconds): auto-dismiss is a success affordance, not a universal one. When the timer also fires on the failed state it clears the error — and often the failed item itself — before the user can react, so the failure is not just un-retryable but invisible. Gate auto-dismiss on success only; a failed item must persist (stay in the dock / keep the toast) and carry a Retry. Dismissing a failure should be the user's choice, never a countdown's.
❌ The Resource upload dock auto-dismisses after 3s whenever the status isn't
uploadingorpending— so theerrorstate falls through too: a failed upload hides the dock andremoveFilesafter 3s (ResourceManager/components/UploadDock/index.tsx), with no error kept and no retry anywhere. ✅ Guard the timer onsuccessonly; keep failed files with a per-item Retry.
Another shape, on the write side: an action that gates forward navigation sets an
isNavigating / isSubmitting flag, awaits a write, then advances — but with no
finally. If the write rejects, the flag never resets, so the advance control (and often
Back with it) stays disabled forever, with no error and no retry — a dead end wearing a
"busy" label, and worse when that write is the one gating the whole flow. Reset the in-progress
flag in finally, and on catch surface the error + a retry; a failed write must never
permanently disable the only way forward.
✅ A panel whose data request errors or exceeds its timeout shows "加载失败" with a Reload button that refetches. ❌ A
NeuralNetworkLoadingthat spins indefinitely when the request hangs. ❌isInitset only in the success handler, so a failed fetch leaves the skeleton up forever. ❌ The onboarding language stepawait setSettings(...)— the write that gatescommonStepsCompleted— with no try/catch/finally, so a failed write leavesisNavigatingtrue and Send + Back staydisabledforever, trapping the user on the step (ResponseLanguageStep.tsx); ✅ the desktopLoginStepidle/loading/success/error state machine with retry + cancel is the shape it should follow. ❌ A builtin-client consent page auto-submits a hidden form on mount and rendersResult status="success"with a spinner + "redirecting…"; if that POST fails the user is stuck on a permanent success-styled spinner with no retry (OAuthConsent/Consent/BuiltinConsent.tsx) — a loading state that both can't fail and mislabels itself "success". (pairs with Read §1.1 error state, §4.1 loading visuals.) ❌ The whole Eval module wires 9 SWR fetches with anonSuccess-only handler and noonError(store/eval/slices/{benchmark,dataset,run,testCase}/action.ts); every list / detail ready-flag (benchmarkListInit,isLoadingDatasets,isLoadingRuns, …) flips only on success, so one root cause hangs a permanent skeleton on the sidebar + bench detail, a false-empty on the overview, and a blank on run / case / dataset detail — five surfaces, zero error paths. ❌ The Memory (记忆) area repeats it: everyuserMemoryfetch registers anonSuccessonly, noonError(store/userMemory/slices/{context,activity,identity,preference,experience}/action.ts,home/action.ts,base/action.tsuseFetchMemoryDetail); each list gateshowLoading = xSearchLoading || !xInitflips only on success, so a failed load hangs a permanent skeleton on all five list tabs, a false-empty on home, and a blank panel on all five detail panels — no*Errorfield even exists to branch on. ❌ Its edit modal is the write-side twin:EditorModal onOkdoessetConfirmLoading(true) → await onConfirm → setConfirmLoading(false)with no try/catch/finally (src/features/EditorModal/index.tsx), so a failed save spins the OK button forever and the edit is lost on close. ✅ The same area'sDateRangeModalsubmit is the model to copy: asubmittingguard against double-submit + try/catch/finally (resets the flag, toasts on failure) + success toast then close (memory/features/MemoryAnalysis/DateRangeModal.tsx). ❌ The Task list / kanban repeats the read-side trap:isTaskListInit/isTaskGroupListInitflip true only in the SWRonSuccess(store/task/slices/list/action.ts:146-155,110-116) — noonError, and the hook'serror/isLoadingare discarded at the call site (AgentTasksPage.tsx:63), so a failedtaskService.list()leaves!isInit→ the skeleton (TaskList.tsx:203) spins forever with no retry. ❌ The create / generation surfaces (视频 / 图像) hit the data-presence-disguised variant:useFetchGenerationBatchesregistersonSuccessonly, noonError(store/{video,image}/slices/generationBatch/action.ts), the "loaded" gate isisCurrentGenerationTopicLoaded = Array.isArray(generationBatchesMap[topicId])(…/selectors.ts:23-27), and the shared shell renders!loaded→<SkeletonList/>/!hasGenerations→<EmptyState/>/ feed with no error branch (routes/(main)/(create)/features/GenerationWorkspace/Content.tsx:39-45). A failed batch fetch never populates the map, so the skeleton is permanent with no retry on both surfaces and any future one built on the shell (the sidebaruseFetchGenerationTopicsis the same success-only shape). ❌ The Discover / Community lists are the trap in its purest form — no init flag even needed. All 8 list slices registeruseSWR(key, fetcher, { revalidateOnFocus: false })with noonError(store/discover/slices/*/action.ts), every call site destructures{ data, isLoading }and discardserror, then gatesif (isLoading || !data) return <Loading/>(community/(list)/agent/index.tsx:18,29+ 7 twins) — so!datais the success-only gate. Worse, the fetcher suppresses even the fallback toast (services/discover.ts:117{ context: { showNotification: false } }). A failed market fetch → permanent skeleton on the primary content of every Discover tab, no error, no retry, no toast (themutateneeded to retry is already returned, unused). ✅ Readerrorat the call site (or a shared list-shell) and render a failed state with Reload before the!databranch. ❌ The chat message stream — the product's highest-traffic surface — has the same trap:useFetchMessagesregistersonDataonly, noonError(store/chat/slices/message/actions/query.ts:216-232,features/Conversation/store/slices/data/action.ts:211-266);messagesInitflips true only in the successonData(or viahasInitMessages = !!dbMessagesMap[key],ConversationArea.tsx:95→StoreUpdater.tsx:73), andChatListdiscards the SWRerror(keepsmessagesSWRbut reads only.isValidating,ChatList/index.tsx:97,157), rendering!messagesInit → <SkeletonList/>with no error branch (ChatList/index.tsx:174). A failedgetMessagesfor a selected topic hangs a permanent skeleton on the core chat — no reason, no Reload. ✅ Branch the fetch'serrorto a failed state with Retry (see the topic audit).
A distinct shape lives on the load-more / infinite-scroll path, not the initial load. The
first page renders fine, so the surface looks healthy — the trap is the next-page fetch:
its catch merely resets isLoadingMore to false with no error state and no retry, so a
failed page-N load makes the "Loading more…" row vanish and the list just stops,
indistinguishable from reaching the end — while hasMore is still true. Worse, when the
trigger is an IntersectionObserver, any scroll nudge re-fires it → the surface silently
retries a failing endpoint with zero user feedback. A pagination failure owes the same
terminal state as an initial one: an inline "couldn't load more — Retry" row at the list
tail, kept distinct from the genuine end-of-list.
❌ Agent topics (
/agent/:aid/topics) hits both shapes. The initial fetch reads only{ isLoading }and nevererror(AgentTopicManager/index.tsx:76); the view map is populated only in the SWR successonData, so a failed load leaves it empty and the page renders the first-run "no topics yet — start a chat" empty (EmptyState.tsx:35) — a failure masquerading as onboarding, no reason, no retry (Read §1.1). AndloadMoreAgentTopicsView'scatchonly flipsisLoadingMoreback to false (store/chat/slices/topic/action.ts:678-689, same inloadMoreTopics:765-776) — a failed page-N load silently drops the "Loading more…" row (index.tsx:251) withhasMorestill true and the observer (index.tsx:185-193) re-firing on scroll. ✅ Branch the initialerrorto a failed state with Reload; render a Retry row when a page fetch rejects.
Checklist
hasMore still true — and doesn't let an IntersectionObserver re-fire into a silent retry loop. (Certainty)errorMap / isXError selector / retryX action with zero call sites (rg the consumers) is a permanent skeleton wearing a built-but-orphaned error path; the store having the right shape doesn't discharge the obligation. (Certainty)null / error), never on the dependency being present in a map — a legitimately absent dependency (deleted / out-of-scope) must not hang the gate. (Certainty){error && …} placed below if (isLoading) return <Skeleton/> where isLoading = !map[id] is unreachable on first-load failure (a failed / not-found fetch never populates the map, so the skeleton short-circuits; it paints only on a revalidation failure over already-loaded data), and a resolved-null not-found hangs the same permanent skeleton. Check error / not-found before the loading gate, or flip the gate on settled. (Certainty)finally and offers retry on catch — a failed write never permanently disables the advance / Back control. (Certainty)401 / 403 (or meta.shouldRetry === false) failure renders the reason but omits Retry (a retry there just fails again); derive it from the normalized error status, not per-surface. 401 session-expiry is assumed handled by a global redirect to sign-in, so a surface does not add its own redirect layer. (Certainty・Meaningful)A feature can be fully built and still produce a broken result when the selected model — or its still-loading config — can't deliver the capability the feature depends on (e.g. an agentic run on a model without tool calling). This is usually the user's configuration choice, not a defect; but if the product stays silent the user reads it as broken. Owe a proactive, non-blocking reminder — a guardrail, not a gate: a soft inline warning at the point of action, never a hard block or a modal that stops the user. Stay reactive — the reminder clears the moment the user switches to a capable model (derive from live state, not a one-shot check). Don't warn while config is still loading (an unresolved capability looks "unsupported" — a false alarm); warn only on a resolved unsupported state. Scope to the mode that needs it — one reminder per root cause — and state both the problem and the remedy.
Soft-inline is right only when the user can fix it in context. The "never a hard block" rule above assumes a model / config capability — the user switches the model dropdown and the feature works, so blocking them would be gratuitous. A different class of gap is structural: the capability is absent because of the platform / deployment, not a choice the user can flip on this screen — a build served without the backend the feature needs (no database, a client-only distribution), a plan that doesn't include the feature. Here there is nothing to switch, so a soft inline warning over a half-working surface is worse than honest: render a full-surface gate that says the feature isn't available in this context and carries the remedy (how to self-host / enable the backend, upgrade the plan), not a bare "not supported". The distinction is user-fixable-here → soft inline; not-fixable-here → full gate with a path out. Both still owe the remedy.
✅ The image-generation surface renders
NotSupportClient— a full-surface explainer with feature cards + links to self-hosting-database docs and the hosted app — when the client build lacks the DB backend generation needs (image/NotSupportClient.tsx), instead of showing a dead composer. The remedy is in the gate. ❌ A model-capability gap hard-blocking the surface, or conversely a platform-absent feature faking a working UI that silently no-ops.
Checklist
A settings / config surface that saves on every change (onValuesChange → setSettings,
toggle → store action) has no explicit "Save" button, so the write is invisible — and an
invisible write that fails silently is a config-loss trap: the user flips a switch,
sees nothing, believes it took, and it didn't. Autosave therefore owes a persistent
save-state, not a fire-and-forget: reflect saving → saved → failed, and on failure
show an inline error with retry (and keep the user's new value, don't snap the control
back without saying why). A one-shot success toast is optional for a silent-save; a
failure signal is mandatory (pairs with §4.2 — the write can fail just like a read).
Just as important, a surface with many such fields must use one save-feedback convention, not a different one per field/tab (consistency is semantic): if changing a setting here confirms one way, changing a setting there must confirm the same way. A shared form wrapper is the natural home for this — bake the save-state affordance into the wrapper so tabs can't each re-invent (or forget) it.
The rule is about surfacing the save-state, not about autosave specifically. Autosave is
one convention; an explicit per-field / per-section Save button is another equally
valid one — and sometimes the better fit (a namespace/handle edit that must validate
uniqueness, a field where a mid-typing autosave would thrash). What §4.4 demands holds either
way: whichever you pick, the write must show saving → saved → failed (a Save button owes
a loading spinner + a success tick + an on-failure inline error that keeps the edited
value), and you must pick one convention for the surface, not mix autosave-here /
explicit-Save-there. Choosing explicit Save doesn't exempt you from the failure signal — it
just moves it onto the button.
✅ An autosave field shows a subtle "saved" tick on success and, on failure, an inline "保存失败" with a Retry that re-runs the write and keeps the edited value. ✅ Community workspace settings takes the explicit-Save route and does it right: each field has its own
Savebutton driving asavingFieldloading state, a success toast, and an on-failuremessage.error(plus an inline field error for the namespace-taken case) that keeps the edited value rather than snapping it back — one convention applied across every field of the surface (CommunityWorkspaceSettings.tsx:310-352). Explicit Save, full save-state, no silent write — the discipline the same area's read side lacks (see Read §1.1). ❌ The Appearance / Advanced / hotkey-essential forms callsetSettingsfromonValuesChangewith no success and no failure feedback — a failed save is indistinguishable from a successful one (settings/appearance,settings/advanced). ❌ The same settings area confirms saves four different ways across tabs (silent /message.success/notification.success/ toast-on-test-only) with no shared convention. ❌ The page editor's content and title/emoji autosave both define a save-state of onlyidle | saving | saved— nofailedvariant — and theircatchblocks resetsaveStatus/metaSaveStatusback toidle(store/document/slices/editor/action.ts,PageEditor/store/action.ts). A network / 500 save failure is then indistinguishable from a success (only a lockCONFLICTis surfaced); the state machine literally can't represent failure, so it can never show it — the silent-write trap baked into the type. ❌ Agent profile shares the identical type-level trap:AutoSaveHint's enum is'idle' | 'saving' | 'saved'(components/Editor/AutoSaveHint.tsx) — nofailed— so the header can't show a failed write, and every writer on the surface swallows it: the prompt save andfinishStreamingcatch → console.error(routes/(main)/agent/profile/features/store/action.ts), while title / avatar / model / tool / advanced-settings saves surface nothing. A prompt edit that failed to persist reads identical to a saved one, with a "saved" tag over it. ❌ Task detail config autosave is the same trap twice over:updateVerifyConfig/updateTaskModelConfig/updatePeriodicInterval/setAutomationMode/updateScheduleall catch-and-console.error(store/task/slices/config/action.ts:121-132,151-167,224-229,279-284) — the optimistic engine reverts the value with no toast — andtaskSaveStatusissaving | saved | idlewith nofailed, reset toidleon error (detail/action.ts:284), while the header rendersAutoSaveHintonly whilesaving(TaskDetailPage.tsx:68). A failed schedule / verify / model edit looks identical to a saved one.
Checklist
Save button (explicit Save still owes the failure signal). (Certainty)failed variant exists and the write's catch drives it, not a reset to idle / neutral. (Certainty)