feat: add Spinner and LoadingState components for unified loading UX

Introduces two reusable loading components to standardize loading states:
- Spinner: Base spinner with accessibility support (role, aria-label)
- LoadingState: Semantic loading indicator combining spinner and label

Updates loading patterns across the app:
- Full-screen/block loading now uses LoadingState (App, router, files, file)
- Inline button loading states show spinner + text with aria-busy
- Message list loading replaced with MessageSkeleton component
- Removes duplicate SpinnerIcon definition from PermissionFooter

Improves UX and accessibility:
- Loading screens centered and full-height
- Consistent ellipsis character (…)
- Semantic accessibility (role="status", aria-live, aria-busy)
- Adds CSS variables for banner styling
This commit is contained in:
weishu
2025-12-24 17:23:39 +08:00
parent 9b9f792ff6
commit 7f2a36117c
14 changed files with 317 additions and 31 deletions
@@ -7,6 +7,7 @@ import { HappyAssistantMessage } from '@/components/AssistantChat/messages/Assis
import { HappyUserMessage } from '@/components/AssistantChat/messages/UserMessage'
import { HappySystemMessage } from '@/components/AssistantChat/messages/SystemMessage'
import { Button } from '@/components/ui/button'
import { Spinner } from '@/components/Spinner'
function NewMessagesIndicator(props: { count: number; onClick: () => void }) {
if (props.count === 0) {
@@ -23,6 +24,28 @@ function NewMessagesIndicator(props: { count: number; onClick: () => void }) {
)
}
function MessageSkeleton() {
const rows = [
{ align: 'end', width: 'w-2/3', height: 'h-10' },
{ align: 'start', width: 'w-3/4', height: 'h-12' },
{ align: 'end', width: 'w-1/2', height: 'h-9' },
{ align: 'start', width: 'w-5/6', height: 'h-14' }
]
return (
<div role="status" aria-live="polite">
<span className="sr-only">Loading messages</span>
<div className="space-y-3 animate-pulse">
{rows.map((row, index) => (
<div key={`skeleton-${index}`} className={row.align === 'end' ? 'flex justify-end' : 'flex justify-start'}>
<div className={`${row.height} ${row.width} rounded-xl bg-[var(--app-subtle-bg)]`} />
</div>
))}
</div>
</div>
)
}
const THREAD_MESSAGE_COMPONENTS = {
UserMessage: HappyUserMessage,
AssistantMessage: HappyAssistantMessage,
@@ -234,9 +257,7 @@ export function HappyThread(props: {
<div className="mx-auto w-full max-w-content min-w-0 p-3">
<div ref={topSentinelRef} className="h-px w-full" aria-hidden="true" />
{props.isLoadingMessages ? (
<div className="text-sm text-[var(--app-hint)]">
Loading...
</div>
<MessageSkeleton />
) : (
<>
{props.messagesWarning ? (
@@ -252,8 +273,17 @@ export function HappyThread(props: {
size="sm"
onClick={handleLoadMore}
disabled={props.isLoadingMoreMessages || props.isLoadingMessages}
aria-busy={props.isLoadingMoreMessages}
className="gap-2"
>
{props.isLoadingMoreMessages ? 'Loading...' : 'Load older'}
{props.isLoadingMoreMessages ? (
<>
<Spinner size="sm" label={null} className="text-current" />
Loading
</>
) : (
'Load older'
)}
</Button>
</div>
) : null}
+25
View File
@@ -0,0 +1,25 @@
import { Spinner } from '@/components/Spinner'
import { cn } from '@/lib/utils'
type LoadingStateProps = {
label?: string
className?: string
spinnerSize?: 'sm' | 'md' | 'lg'
}
export function LoadingState({
label = 'Loading…',
className,
spinnerSize = 'md'
}: LoadingStateProps) {
return (
<div
className={cn('inline-flex items-center gap-2 text-[var(--app-hint)]', className)}
role="status"
aria-live="polite"
>
<Spinner size={spinnerSize} label={null} />
<span>{label}</span>
</div>
)
}
+11 -2
View File
@@ -1,5 +1,6 @@
import { useCallback, useState } from 'react'
import { ApiClient } from '@/api/client'
import { Spinner } from '@/components/Spinner'
type LoginPromptProps = {
onLogin: (token: string) => void
@@ -72,9 +73,17 @@ export function LoginPrompt(props: LoginPromptProps) {
<button
type="submit"
disabled={isLoading || !accessToken.trim()}
className="w-full py-2.5 rounded-lg bg-[var(--app-button)] text-[var(--app-button-text)] font-medium disabled:opacity-50 hover:opacity-90 transition-opacity"
aria-busy={isLoading}
className="w-full py-2.5 rounded-lg bg-[var(--app-button)] text-[var(--app-button-text)] font-medium disabled:opacity-50 hover:opacity-90 transition-opacity inline-flex items-center justify-center gap-2"
>
{isLoading ? 'Signing in...' : 'Sign In'}
{isLoading ? (
<>
<Spinner size="sm" label={null} className="text-[var(--app-button-text)]" />
Signing in
</>
) : (
'Sign In'
)}
</button>
</form>
+12 -2
View File
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
import type { ApiClient } from '@/api/client'
import type { Machine } from '@/types/api'
import { Button } from '@/components/ui/button'
import { Spinner } from '@/components/Spinner'
import { usePlatform } from '@/hooks/usePlatform'
import { useSpawnSession } from '@/hooks/mutations/useSpawnSession'
import { useRecentPaths } from '@/hooks/useRecentPaths'
@@ -117,7 +118,7 @@ export function NewSession(props: {
className="w-full rounded-md border border-[var(--app-border)] bg-[var(--app-bg)] p-2 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--app-link)] disabled:opacity-50"
>
{props.isLoading && (
<option value="">Loading machines...</option>
<option value="">Loading machines</option>
)}
{!props.isLoading && props.machines.length === 0 && (
<option value="">No machines available</option>
@@ -212,8 +213,17 @@ export function NewSession(props: {
<Button
onClick={handleCreate}
disabled={!canCreate}
aria-busy={isPending}
className="gap-2"
>
{isPending ? 'Creating...' : 'Create'}
{isPending ? (
<>
<Spinner size="sm" label={null} className="text-[var(--app-button-text)]" />
Creating
</>
) : (
'Create'
)}
</Button>
</div>
</div>
+35
View File
@@ -0,0 +1,35 @@
import { cn } from '@/lib/utils'
type SpinnerProps = {
size?: 'sm' | 'md' | 'lg'
className?: string
label?: string | null
}
export function Spinner({
size = 'md',
className,
label
}: SpinnerProps) {
const sizeClasses = {
sm: 'h-4 w-4',
md: 'h-5 w-5',
lg: 'h-6 w-6'
}
const effectiveLabel = label === undefined ? 'Loading' : label
const accessibilityProps = effectiveLabel === null
? { 'aria-hidden': true }
: { role: 'status', 'aria-label': effectiveLabel }
return (
<svg
className={cn(sizeClasses[size], 'animate-spin text-[var(--app-hint)]', className)}
viewBox="0 0 24 24"
fill="none"
{...accessibilityProps}
>
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="2.5" opacity="0.25" />
<path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" opacity="0.75" />
</svg>
)
}
+4 -3
View File
@@ -1,4 +1,5 @@
import { useOnlineStatus } from '@/hooks/useOnlineStatus'
import { Spinner } from '@/components/Spinner'
export function SyncingBanner({ isSyncing }: { isSyncing: boolean }) {
const isOnline = useOnlineStatus()
@@ -9,9 +10,9 @@ export function SyncingBanner({ isSyncing }: { isSyncing: boolean }) {
}
return (
<div className="fixed top-0 left-0 right-0 bg-[var(--app-button)] text-[var(--app-button-text)] text-center py-2 text-sm font-medium z-50 flex items-center justify-center gap-2">
<span className="inline-block animate-spin">&#8635;</span>
Syncing...
<div className="fixed top-0 left-0 right-0 bg-[var(--app-banner-bg)] text-[var(--app-banner-text)] text-center py-2 text-sm font-medium z-50 flex items-center justify-center gap-2 border-b border-[var(--app-divider)]">
<Spinner size="sm" label={null} className="text-[var(--app-banner-text)]" />
Syncing
</div>
)
}
@@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button'
import { isAskUserQuestionToolName, parseAskUserQuestionInput, type AskUserQuestionQuestion } from '@/components/ToolCard/askUserQuestion'
import { cn } from '@/lib/utils'
import { usePlatform } from '@/hooks/usePlatform'
import { Spinner } from '@/components/Spinner'
function SelectionMark(props: { checked: boolean; mode: 'single' | 'multi' }) {
const mark = props.mode === 'multi'
@@ -381,8 +382,17 @@ export function AskUserQuestionFooter(props: {
size="sm"
disabled={props.disabled || loading}
onClick={submit}
aria-busy={loading}
className="gap-2"
>
{loading ? 'Submitting…' : 'Submit'}
{loading ? (
<>
<Spinner size="sm" label={null} className="text-[var(--app-button-text)]" />
Submitting
</>
) : (
'Submit'
)}
</Button>
)}
</div>
@@ -3,6 +3,7 @@ import type { ApiClient } from '@/api/client'
import type { SessionMetadataSummary } from '@/types/api'
import type { ChatToolCall, ToolPermission } from '@/chat/types'
import { usePlatform } from '@/hooks/usePlatform'
import { Spinner } from '@/components/Spinner'
function isObject(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object'
@@ -63,15 +64,6 @@ function formatPermissionSummary(permission: ToolPermission, toolName: string, t
return 'Permission'
}
function SpinnerIcon(props: { className?: string }) {
return (
<svg className={props.className ?? 'h-4 w-4 animate-spin'} viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="2.5" opacity="0.25" />
<path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" opacity="0.75" />
</svg>
)
}
function PermissionRowButton(props: {
label: string
tone: 'allow' | 'deny' | 'neutral'
@@ -91,12 +83,13 @@ function PermissionRowButton(props: {
type="button"
className={`${base} ${tone}`}
disabled={props.disabled}
aria-busy={props.loading === true}
onClick={props.onClick}
>
<span className="flex-1">{props.label}</span>
{props.loading ? (
<span className="ml-2 shrink-0">
<SpinnerIcon />
<Spinner size="sm" label={null} className="text-current" />
</span>
) : null}
</button>