feat(ux): resilience & feedback — error boundaries, no-flash lists, skeletons#617
Conversation
…keletons UX-audit "states, feedback & errors" pass: - Route boundaries: app/not-found.tsx (branded 404 + Dashboard link), app/error.tsx (recoverable render-error card with reset()), and app/global-error.tsx (root boundary) replace Next's blank-white defaults. - No-flash lists: add placeholderData: keepPreviousData to all 11 paginated list hooks (studies, trials, proposals, judgments, clusters, config-repos, conversations, documents, query-sets, query-set queries, query-templates) so sorting/filtering/searching/paginating keeps the prior page visible instead of flashing to a loading state and resetting scroll. By-id hooks intentionally excluded (stale wrong data would be worse). - Skeletons: new ui/skeleton.tsx primitive (reduced-motion aware). DataTable's loading branch and DetailPageShell's loading branch now render shaped skeletons instead of bare "Loading…" text, holding layout stable. - Honest table error state: DataTable's error branch shows a real message (optional errorMessage prop) + an always-present Retry button (optional onRetry, falls back to page reload) instead of a static "Try again" with no affordance. - PR-open success toast: the proposal page now toasts "Pull request opened" on the polled status transition to pr_opened (the money path was previously silent). Fires once, and not when merely viewing an already-opened proposal. Tests: error-boundaries + Skeleton render tests; DataTable error-state test rewritten for the new UI (message + retry callback). Full suite green (1304); tsc + lint clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: SoundMindsAI <eric.starr@soundminds.ai>
There was a problem hiding this comment.
Code Review
This pull request introduces robust error boundaries, skeleton loaders, and enhanced error-handling states across the application, including a new Skeleton component and a global 404 page. It also integrates keepPreviousData across multiple TanStack Query hooks to prevent layout flashes during pagination, sorting, or filtering. Feedback focuses on aligning the button label with its action in the root error boundary and guarding the proposal status transition ref against temporary undefined states to ensure the success toast fires reliably.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| <button | ||
| type="button" | ||
| onClick={reset} | ||
| style={{ | ||
| padding: '0.5rem 1rem', | ||
| borderRadius: '0.375rem', | ||
| border: '1px solid #d1d5db', | ||
| cursor: 'pointer', | ||
| }} | ||
| > | ||
| Reload | ||
| </button> |
There was a problem hiding this comment.
The button is labeled Reload but it calls reset(), which only triggers a React-level re-render of the route segment rather than a full browser reload. To avoid user confusion and align with Next.js conventions (and error.tsx on line 39), consider labeling the button Try again. Alternatively, if a full browser reload is intended (which is often useful for root-level crashes like chunk load errors), you can use window.location.reload().
| <button | |
| type="button" | |
| onClick={reset} | |
| style={{ | |
| padding: '0.5rem 1rem', | |
| borderRadius: '0.375rem', | |
| border: '1px solid #d1d5db', | |
| cursor: 'pointer', | |
| }} | |
| > | |
| Reload | |
| </button> | |
| <button | |
| type="button" | |
| onClick={reset} | |
| style={{ | |
| padding: '0.5rem 1rem', | |
| borderRadius: '0.375rem', | |
| border: '1px solid #d1d5db', | |
| cursor: 'pointer', | |
| }} | |
| > | |
| Try again | |
| </button> |
| useEffect(() => { | ||
| const prev = prevStatusRef.current; | ||
| if (prev && prev !== 'pr_opened' && proposalStatus === 'pr_opened') { | ||
| toast.success('Pull request opened'); | ||
| } | ||
| prevStatusRef.current = proposalStatus; | ||
| }, [proposalStatus]); |
There was a problem hiding this comment.
If proposalStatus temporarily becomes undefined (for example, during a hard query reload or a temporary network error), prevStatusRef.current will be overwritten with undefined. If the status then recovers and flips directly to 'pr_opened', the success toast will not fire because prev is undefined instead of 'pending'. Guarding the ref update to only store defined statuses ensures the transition is tracked reliably across temporary loading states.
| useEffect(() => { | |
| const prev = prevStatusRef.current; | |
| if (prev && prev !== 'pr_opened' && proposalStatus === 'pr_opened') { | |
| toast.success('Pull request opened'); | |
| } | |
| prevStatusRef.current = proposalStatus; | |
| }, [proposalStatus]); | |
| useEffect(() => { | |
| const prev = prevStatusRef.current; | |
| if (prev && prev !== 'pr_opened' && proposalStatus === 'pr_opened') { | |
| toast.success('Pull request opened'); | |
| } | |
| if (proposalStatus !== undefined) { | |
| prevStatusRef.current = proposalStatus; | |
| } | |
| }, [proposalStatus]); |
- global-error: the "Reload" button now does a real window.location.reload() (robust recovery for a root-layout/chunk crash) instead of React reset(), so the action matches the label. - proposals: only store DEFINED proposal statuses in the transition ref, so a transient undefined (hard reload / brief network error) can't erase the prior status and swallow the "Pull request opened" toast on recovery. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: SoundMindsAI <eric.starr@soundminds.ai>
Review adjudicationGemini — 2 findings, both accepted + fixed in
Pre-existing red check (not this PR): the |
Second UX-audit fix PR. Scope: the "states, feedback & errors" findings.
Changes
app/not-found.tsx— branded 404 with a Dashboard link.app/error.tsx— recoverable render-error card withreset()+ Dashboard fallback.app/global-error.tsx— root boundary (renders its own html/body).placeholderData: keepPreviousDatato all 11 paginated list hooks (studies, trials, proposals, judgments, clusters, config-repos, conversations, documents, query-sets, query-set queries, query-templates). Sorting/filtering/searching/paginating now keeps the prior page visible instead of flashing to a loading state and resetting scroll. By-id hooks intentionally excluded (showing stale wrong data during a detail navigation would be worse).ui/skeleton.tsxprimitive (reduced-motion aware). DataTable's loading branch and DetailPageShell's loading branch now render shaped skeletons instead of bare "Loading…" text, holding layout stable.errorMessageprop) + an always-present Retry button (optionalonRetry, falls back to page reload) instead of a static "Try again" with no affordance.pr_opened(the product's key success event was previously silent). Fires once, and not when merely viewing an already-opened proposal.Deferred to later PRs (tracked)
Per-page
<title>s and empty-state CTA parity fit the IA pass and land in the navigation PR; per-listerrorMessage/onRetrywiring is now possible via the new optional props (reload fallback works everywhere today).Testing
New error-boundary + Skeleton render tests; DataTable error-state test rewritten for the new UI (real message + retry callback). Full suite green (1304);
tsc+eslintclean.🤖 Generated with Claude Code