Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/web/src/components/__tests__/chat-interface.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,7 @@ function makeDefaultState() {
chatId: null,
isResuming: false,
resumedContent: '',
resetChatError: vi.fn(),
}
}

Expand Down
4 changes: 3 additions & 1 deletion apps/web/src/components/ai-elements/conversation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ export const Conversation = ({ className, children, showScrollButton = false, ..
ref={scrollRef}
className="absolute inset-0 overflow-y-auto"
role="log"
aria-label="Chat messages"
>
{children}
{/* Anchor element for scroll-to-bottom */}
Expand All @@ -92,10 +93,11 @@ export const Conversation = ({ className, children, showScrollButton = false, ..
{showScrollButton && !isAtBottom && (
<button
type="button"
aria-label="Scroll to latest message"
className="absolute bottom-4 left-1/2 -translate-x-1/2 z-10 rounded-full shadow-lg flex size-11 md:size-9 items-center justify-center bg-background border border-border hover:bg-muted active:scale-95 transition-all"
onClick={scrollToBottom}
>
<ArrowDownIcon className="size-5 md:size-4" />
<ArrowDownIcon className="size-5 md:size-4" aria-hidden />
</button>
)}
</div>
Expand Down
44 changes: 44 additions & 0 deletions apps/web/src/components/app-loading-placeholder.tsx
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" />
Comment on lines +22 to +28

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Redundant aria-live alongside role="status"

role="status" already carries an implicit aria-live="polite" mapping per the ARIA spec, so the explicit aria-live="polite" on lines 24 and 36 is redundant. It doesn't cause harm, but removing it keeps the markup cleaner.

Suggested change
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={cn("flex h-screen w-full bg-sidebar", className)}
role="status"
aria-busy="true"
>

The same applies to the simple variant'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!

<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>
);
}
30 changes: 24 additions & 6 deletions apps/web/src/components/chat/chat-interface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ export function ChatInterface({ chatId }: ChatInterfaceProps) {
error,
stop,
isNewChat,
isLoadingMessages,
resetChatError,
} = usePersistentChat({
chatId,
onChatCreated: (newChatId) => {
Expand Down Expand Up @@ -92,7 +94,9 @@ export function ChatInterface({ chatId }: ChatInterfaceProps) {
messages={messages}
isLoading={isLoading}
isNewChat={isNewChat}
error={error ?? null}
isLoadingHistory={Boolean(chatId) && isLoadingMessages}
streamError={error ?? null}
onDismissStreamError={resetChatError}
stop={stop}
handleSubmit={handleSubmit}
onEditMessage={editMessage}
Expand All @@ -114,7 +118,9 @@ interface ChatInterfaceContentProps {
}>;
isLoading: boolean;
isNewChat: boolean;
error: Error | null;
isLoadingHistory: boolean;
streamError: Error | null;
onDismissStreamError: () => void;
stop: () => void;
handleSubmit: (message: PromptInputMessage) => Promise<void>;
onEditMessage: (messageId: string, newContent: string) => Promise<void>;
Expand All @@ -128,7 +134,9 @@ const ChatInterfaceContent = memo(function ChatInterfaceContent({
messages,
isLoading,
isNewChat,
error: _error,
isLoadingHistory,
streamError,
onDismissStreamError,
stop,
handleSubmit,
onEditMessage,
Expand All @@ -137,6 +145,8 @@ const ChatInterfaceContent = memo(function ChatInterfaceContent({
textareaRef,
}: ChatInterfaceContentProps) {
const controller = usePromptInputController();
const promptValueRef = useRef(controller.textInput.value);
promptValueRef.current = controller.textInput.value;
const [editingMessageId, setEditingMessageId] = useState<string | null>(null);
const [isSavingEdit, setIsSavingEdit] = useState(false);
const savedDraftRef = useRef<string>("");
Expand Down Expand Up @@ -201,14 +211,14 @@ const ChatInterfaceContent = memo(function ChatInterfaceContent({

const startEdit = useCallback(
(messageId: string, content: string) => {
savedDraftRef.current = controller.textInput.value;
savedDraftRef.current = promptValueRef.current;
setEditingMessageId(messageId);
setInput(content);
setTimeout(() => {
textareaRef.current?.focus();
}, 0);
},
[controller.textInput.value, setInput, textareaRef],
[setInput, textareaRef],
);

const cancelEdit = useCallback(() => {
Expand Down Expand Up @@ -260,6 +270,9 @@ const ChatInterfaceContent = memo(function ChatInterfaceContent({
messages={messages}
isLoading={isLoading}
isNewChat={isNewChat}
isLoadingHistory={isLoadingHistory}
streamError={streamError}
onDismissStreamError={onDismissStreamError}
onPromptSelect={onPromptSelect}
onRetryMessage={onRetryMessage}
onForkMessage={onForkMessage}
Expand All @@ -270,11 +283,16 @@ const ChatInterfaceContent = memo(function ChatInterfaceContent({
<div className="px-2 md:px-4 pt-2 md:pt-4 pb-[max(0.5rem,env(safe-area-inset-bottom))] md:pb-4">
<div className="mx-auto max-w-3xl">
{editingMessageId && (
<div className="mb-2 flex items-center justify-between rounded-lg bg-primary/10 border border-primary/20 px-3 py-2">
<div
className="mb-2 flex items-center justify-between rounded-lg bg-primary/10 border border-primary/20 px-3 py-2"
role="status"
aria-live="polite"
>
<span className="text-sm text-primary font-medium">Editing message</span>
<button
type="button"
onClick={cancelEdit}
aria-label="Cancel editing message"
className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<XIcon className="size-3" />
Expand Down
61 changes: 55 additions & 6 deletions apps/web/src/components/chat/chat-message-list.tsx
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";
Expand Down Expand Up @@ -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>
);
}
Expand All @@ -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>;
Expand All @@ -66,6 +77,9 @@ export const ChatMessageList = memo(function ChatMessageList({
messages,
isLoading,
isNewChat,
isLoadingHistory = false,
streamError = null,
onDismissStreamError,
onPromptSelect,
onRetryMessage,
onForkMessage,
Expand Down Expand Up @@ -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" />

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Dismiss XIcon is not aria-hidden

The parent button has aria-label="Dismiss error", which correctly provides the accessible name for the button. However, the <XIcon> inside it is not marked aria-hidden, so some screen readers may attempt to read the icon's implicit description in addition to the button label.

Suggested change
<XIcon className="size-4" />
<XIcon className="size-4" aria-hidden />

Score: 2/5 — in practice most screen readers correctly suppress unlabelled SVGs inside a button that has an explicit aria-label, but being explicit is safer.

</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;
Expand Down
5 changes: 4 additions & 1 deletion apps/web/src/components/chat/inline-error-message.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 aria-expanded without aria-controls

Adding aria-expanded on the "Show details" button is good, but the ARIA disclosure button pattern recommends pairing it with aria-controls pointing to the id of the revealed element. Without it, some screen readers won't automatically navigate to the expanded content when the user activates the button.

Suggested change
<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"}
<button
type="button"
onClick={() => setShowDetails(!showDetails)}
className="text-xs text-destructive/60 hover:text-destructive transition-colors"
aria-expanded={showDetails}
aria-controls="error-details"
>

And give the <pre> block id="error-details". Score: 2/5 — the text "Show details" / "Hide details" still communicates state, so this is a best-practice gap rather than a blocker.

</button>
Expand Down
4 changes: 0 additions & 4 deletions apps/web/src/components/model-selector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -170,10 +170,6 @@ export function ModelSelector({
e.preventDefault();
handleClose();
break;
case "Tab":

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tab removal leaves dropdown open with global key interception

Medium Severity

Removing the entire Tab case instead of just e.preventDefault() means the dropdown no longer calls handleClose() when Tab is pressed. Since there's no focusout/blur handler, the dropdown stays visually open after Tab moves focus away. More critically, the document-level keydown listener remains active, so ArrowDown, ArrowUp, Enter (with preventDefault), and Escape continue to be intercepted globally — swallowing keyboard input meant for other elements on the page.

Fix in Cursor Fix in Web

e.preventDefault();
handleClose();
break;
}
}

Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/components/model-selector/model-item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ export function ModelItem({
: "text-muted-foreground/20 hover:text-amber-400 opacity-0 group-hover:opacity-100",
)}
title={isFavorite ? "Remove from favorites" : "Add to favorites"}
aria-label={isFavorite ? `Remove ${model.name} from favorites` : `Add ${model.name} to favorites`}
>
<StarIcon filled={isFavorite} className="size-4" />
</button>
Expand Down Expand Up @@ -117,6 +118,7 @@ export function ModelItem({
onInfoHover();
}}
className="flex size-6 items-center justify-center rounded-md text-muted-foreground/40 transition-all duration-150 hover:text-foreground hover:bg-accent/80"
aria-label={`Model details for ${model.name}`}
>
<InfoIcon className="size-3.5" />
</button>
Expand Down
18 changes: 18 additions & 0 deletions apps/web/src/hooks/__tests__/use-persistent-chat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,7 @@ describe("initial state shape", () => {
expect(typeof result.current.retryMessage).toBe("function");
expect(typeof result.current.forkMessage).toBe("function");
expect(typeof result.current.stop).toBe("function");
expect(typeof result.current.resetChatError).toBe("function");
});
});

Expand Down Expand Up @@ -411,6 +412,23 @@ describe("error tracking", () => {
});
expect(result.current.error).toBeUndefined();
});

it("resetChatError clears error and returns status from error to ready", async () => {
mockStartBackgroundStream.mockRejectedValueOnce(new Error("boom"));

const { result } = renderChat();
await act(async () => {
await result.current.sendMessage({ text: "Hello" });
});
expect(result.current.status).toBe("error");
expect(result.current.error).toBeTruthy();

await act(async () => {
result.current.resetChatError();
});
expect(result.current.error).toBeUndefined();
expect(result.current.status).toBe("ready");
});
});

// ---------------------------------------------------------------------------
Expand Down
10 changes: 9 additions & 1 deletion apps/web/src/hooks/use-persistent-chat.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useQuery } from "convex/react";
import { api } from "@server/convex/_generated/api";
import type { UIMessage } from "ai";
Expand Down Expand Up @@ -30,6 +30,8 @@ export interface UsePersistentChatReturn {
chatId: string | null;
isResuming: boolean;
resumedContent: string;
/** Clears the last stream/send error and returns status from `error` to `ready` when applicable */
resetChatError: () => void;
}

export function usePersistentChat({
Expand Down Expand Up @@ -84,6 +86,11 @@ export function usePersistentChat({
streamingRef,
});

const resetChatError = useCallback(() => {
setError(undefined);
setStatus((prev) => (prev === "error" ? "ready" : prev));
}, []);

const { sendMessage, editMessage, retryMessage, forkMessage } = useChatActions({
convexUserId,
isUserLoading,
Expand Down Expand Up @@ -117,5 +124,6 @@ export function usePersistentChat({
chatId: currentChatId,
isResuming,
resumedContent,
resetChatError,
};
}
Loading
Loading