-
-
Notifications
You must be signed in to change notification settings - Fork 3
Frontend quality: accessibility, loading UX, and chat error handling #703
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,44 @@ | ||
| import { cn } from "@/lib/utils"; | ||
|
|
||
| type AppLoadingPlaceholderProps = { | ||
| /** Announced to screen readers */ | ||
| message?: string; | ||
| className?: string; | ||
| /** Matches authenticated shell: sidebar stub + main pane */ | ||
| variant?: "app-shell" | "simple"; | ||
| }; | ||
|
|
||
| /** | ||
| * Accessible loading placeholder for route-level and auth-gated suspense states. | ||
| */ | ||
| export function AppLoadingPlaceholder({ | ||
| message = "Loading", | ||
| className, | ||
| variant = "simple", | ||
| }: AppLoadingPlaceholderProps) { | ||
| if (variant === "app-shell") { | ||
| return ( | ||
| <div | ||
| className={cn("flex h-screen w-full bg-sidebar", className)} | ||
| role="status" | ||
| aria-live="polite" | ||
| aria-busy="true" | ||
| > | ||
| <span className="sr-only">{message}</span> | ||
| <div className="w-64 shrink-0 bg-sidebar" aria-hidden="true" /> | ||
| <div className="flex-1 bg-background" aria-hidden="true" /> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| return ( | ||
| <div | ||
| className={cn("flex h-full w-full bg-background", className)} | ||
| role="status" | ||
| aria-live="polite" | ||
| aria-busy="true" | ||
| > | ||
| <span className="sr-only">{message}</span> | ||
| </div> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -1,4 +1,5 @@ | ||||||
| import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; | ||||||
| import { Loader2Icon, XIcon } from "lucide-react"; | ||||||
| import { toast } from "sonner"; | ||||||
| import { cn } from "@/lib/utils"; | ||||||
| import { useConversationScroll, Conversation, ConversationContent } from "@/components/ai-elements/conversation"; | ||||||
|
|
@@ -36,10 +37,16 @@ function AutoScroll({ messageCount }: { messageCount: number }) { | |||||
|
|
||||||
| function LoadingIndicator() { | ||||||
| return ( | ||||||
| <div className="flex items-center gap-1.5 py-2"> | ||||||
| <span className="size-2 animate-bounce rounded-full bg-foreground/40 [animation-delay:0ms]" /> | ||||||
| <span className="size-2 animate-bounce rounded-full bg-foreground/40 [animation-delay:150ms]" /> | ||||||
| <span className="size-2 animate-bounce rounded-full bg-foreground/40 [animation-delay:300ms]" /> | ||||||
| <div | ||||||
| className="flex items-center gap-1.5 py-2" | ||||||
| role="status" | ||||||
| aria-live="polite" | ||||||
| aria-busy="true" | ||||||
| > | ||||||
| <span className="sr-only">Assistant is responding</span> | ||||||
| <span className="size-2 animate-bounce rounded-full bg-foreground/40 [animation-delay:0ms]" aria-hidden /> | ||||||
| <span className="size-2 animate-bounce rounded-full bg-foreground/40 [animation-delay:150ms]" aria-hidden /> | ||||||
| <span className="size-2 animate-bounce rounded-full bg-foreground/40 [animation-delay:300ms]" aria-hidden /> | ||||||
| </div> | ||||||
| ); | ||||||
| } | ||||||
|
|
@@ -54,6 +61,10 @@ export interface ChatMessageListProps { | |||||
| }>; | ||||||
| isLoading: boolean; | ||||||
| isNewChat: boolean; | ||||||
| /** True while Convex history for an existing chat is still loading */ | ||||||
| isLoadingHistory?: boolean; | ||||||
| streamError?: Error | null; | ||||||
| onDismissStreamError?: () => void; | ||||||
| onPromptSelect: (prompt: string) => void; | ||||||
| onRetryMessage: (messageId: string, modelId?: string) => Promise<void>; | ||||||
| onForkMessage: (messageId: string, modelId?: string) => Promise<void>; | ||||||
|
|
@@ -66,6 +77,9 @@ export const ChatMessageList = memo(function ChatMessageList({ | |||||
| messages, | ||||||
| isLoading, | ||||||
| isNewChat, | ||||||
| isLoadingHistory = false, | ||||||
| streamError = null, | ||||||
| onDismissStreamError, | ||||||
| onPromptSelect, | ||||||
| onRetryMessage, | ||||||
| onForkMessage, | ||||||
|
|
@@ -241,9 +255,44 @@ export const ChatMessageList = memo(function ChatMessageList({ | |||||
| <Conversation className="flex-1 px-2 md:px-4" showScrollButton> | ||||||
| <AutoScroll messageCount={messages.length} /> | ||||||
| <ConversationContent className="mx-auto max-w-3xl pt-16 md:pt-6 pb-16 px-2 md:px-4"> | ||||||
| {messages.length === 0 && isNewChat ? ( | ||||||
| {streamError && ( | ||||||
| <div | ||||||
| role="alert" | ||||||
| className="mb-4 flex gap-3 rounded-xl border border-destructive/30 bg-destructive/10 p-4 text-sm" | ||||||
| > | ||||||
| <div className="min-w-0 flex-1"> | ||||||
| <p className="font-medium text-destructive">Something went wrong</p> | ||||||
| <p className="mt-1 text-destructive/80">{streamError.message}</p> | ||||||
| </div> | ||||||
| {onDismissStreamError ? ( | ||||||
| <button | ||||||
| type="button" | ||||||
| onClick={onDismissStreamError} | ||||||
| className="shrink-0 rounded-lg p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" | ||||||
| aria-label="Dismiss error" | ||||||
| > | ||||||
| <XIcon className="size-4" /> | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The parent button has
Suggested change
Score: 2/5 — in practice most screen readers correctly suppress unlabelled SVGs inside a button that has an explicit |
||||||
| </button> | ||||||
| ) : null} | ||||||
| </div> | ||||||
| )} | ||||||
| {isLoadingHistory && messages.length === 0 ? ( | ||||||
| <div | ||||||
| className="flex flex-col items-center justify-center gap-3 py-16 text-muted-foreground" | ||||||
| role="status" | ||||||
| aria-live="polite" | ||||||
| aria-busy="true" | ||||||
| > | ||||||
| <Loader2Icon className="size-8 animate-spin" aria-hidden /> | ||||||
| <span className="sr-only">Loading conversation</span> | ||||||
| </div> | ||||||
| ) : messages.length === 0 && isNewChat ? ( | ||||||
| <StartScreen onPromptSelect={onPromptSelect} /> | ||||||
| ) : messages.length === 0 ? null : ( | ||||||
| ) : messages.length === 0 ? ( | ||||||
| <p className="py-12 text-center text-sm text-muted-foreground"> | ||||||
| No messages in this chat yet. Send a message below to start. | ||||||
| </p> | ||||||
| ) : ( | ||||||
| <> | ||||||
| {processedMessages.map((item, itemIndex) => { | ||||||
| if (item.shouldSkip) return null; | ||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -61,12 +61,13 @@ export function InlineErrorMessage({ error, onRetry }: InlineErrorMessageProps) | |||||||||||||||||||||||||||||
| return ( | ||||||||||||||||||||||||||||||
| <div className="w-full rounded-xl border border-destructive/30 bg-destructive/10 p-4"> | ||||||||||||||||||||||||||||||
| <div className="flex items-start gap-3"> | ||||||||||||||||||||||||||||||
| <div className="flex-shrink-0 mt-0.5"> | ||||||||||||||||||||||||||||||
| <div className="flex-shrink-0 mt-0.5" aria-hidden> | ||||||||||||||||||||||||||||||
| <svg | ||||||||||||||||||||||||||||||
| className="size-5 text-destructive" | ||||||||||||||||||||||||||||||
| fill="none" | ||||||||||||||||||||||||||||||
| viewBox="0 0 24 24" | ||||||||||||||||||||||||||||||
| stroke="currentColor" | ||||||||||||||||||||||||||||||
| aria-hidden | ||||||||||||||||||||||||||||||
| > | ||||||||||||||||||||||||||||||
| <path | ||||||||||||||||||||||||||||||
| strokeLinecap="round" | ||||||||||||||||||||||||||||||
|
|
@@ -85,8 +86,10 @@ export function InlineErrorMessage({ error, onRetry }: InlineErrorMessageProps) | |||||||||||||||||||||||||||||
| {error.details && ( | ||||||||||||||||||||||||||||||
| <div className="mt-2"> | ||||||||||||||||||||||||||||||
| <button | ||||||||||||||||||||||||||||||
| type="button" | ||||||||||||||||||||||||||||||
| onClick={() => setShowDetails(!showDetails)} | ||||||||||||||||||||||||||||||
| className="text-xs text-destructive/60 hover:text-destructive transition-colors" | ||||||||||||||||||||||||||||||
| aria-expanded={showDetails} | ||||||||||||||||||||||||||||||
| > | ||||||||||||||||||||||||||||||
| {showDetails ? "Hide details" : "Show details"} | ||||||||||||||||||||||||||||||
|
Comment on lines
88
to
94
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Adding
Suggested change
And give the |
||||||||||||||||||||||||||||||
| </button> | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -170,10 +170,6 @@ export function ModelSelector({ | |
| e.preventDefault(); | ||
| handleClose(); | ||
| break; | ||
| case "Tab": | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Tab removal leaves dropdown open with global key interceptionMedium Severity Removing the entire |
||
| e.preventDefault(); | ||
| handleClose(); | ||
| break; | ||
| } | ||
| } | ||
|
|
||
|
|
||


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
aria-livealongsiderole="status"role="status"already carries an implicitaria-live="polite"mapping per the ARIA spec, so the explicitaria-live="polite"on lines 24 and 36 is redundant. It doesn't cause harm, but removing it keeps the markup cleaner.The same applies to the
simplevariant's container. Score: 2/5 — purely cosmetic, no functional impact.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!