diff --git a/.Jules/palette.md b/.Jules/palette.md new file mode 100644 index 00000000..81ca2dc8 --- /dev/null +++ b/.Jules/palette.md @@ -0,0 +1,9 @@ +# 2026-02-15 - [Accessibility Audit & Fix for Icon Buttons] + +**Learning:** Icon-only buttons without aria-labels are common accessibility gaps in this project. Adding them consistently across Core UI (Header, ChatPanel, MobileIconsBar) significantly improves the experience for screen reader users without visual clutter. +**Action:** Always check icon-only buttons for aria-label or title attributes when modifying UI. + +## 2026-02-15 - [Loading Feedback for Submissions] + +**Learning:** Reusing existing state (like isSubmitting) to provide visual feedback (Spinner) is preferred over creating redundant states. It keeps the architecture clean and prevents synchronization issues. +**Action:** Look for existing submission/loading states in parent components before introducing new ones in child components. diff --git a/components/chat-panel.tsx b/components/chat-panel.tsx index ca2fbc6f..a7d184fc 100644 --- a/components/chat-panel.tsx +++ b/components/chat-panel.tsx @@ -7,6 +7,7 @@ import { cn } from '@/lib/utils' import { UserMessage } from './user-message' import { Button } from './ui/button' import { ArrowRight, Plus, Paperclip, X, Sprout } from 'lucide-react' +import { Spinner } from './ui/spinner' import Textarea from 'react-textarea-autosize' import { nanoid } from '@/lib/utils' import { useSettingsStore } from '@/lib/store/settings' @@ -20,6 +21,8 @@ interface ChatPanelProps { input: string setInput: (value: string) => void onSuggestionsChange?: (suggestions: PartialRelated | null) => void + isPending?: boolean + setIsPending?: (pending: boolean) => void } export interface ChatPanelRef { @@ -27,7 +30,7 @@ export interface ChatPanelRef { submitForm: () => void } -export const ChatPanel = forwardRef(({ messages, input, setInput, onSuggestionsChange }, ref) => { +export const ChatPanel = forwardRef(({ messages, input, setInput, onSuggestionsChange, isPending, setIsPending }, ref) => { const [, setMessages] = useUIState() const { submit, clearChat } = useActions() const { mapProvider } = useSettingsStore() @@ -43,6 +46,15 @@ export const ChatPanel = forwardRef(({ messages, i const inputRef = useRef(null) const formRef = useRef(null) const fileInputRef = useRef(null) + const objectUrls = useRef>(new Set()) + + useEffect(() => { + const urls = objectUrls.current + return () => { + urls.forEach(url => URL.revokeObjectURL(url)) + urls.clear() + } + }, []) useImperativeHandle(ref, () => ({ handleAttachmentClick() { @@ -87,42 +99,72 @@ export const ChatPanel = forwardRef(({ messages, i const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() - if (!input.trim() && !selectedFile) { + if ((!input.trim() && !selectedFile) || isPending) { return } - const content: ({ type: 'text'; text: string } | { type: 'image'; image: string })[] = [] - if (input) { - content.push({ type: 'text', text: input }) - } - if (selectedFile && selectedFile.type.startsWith('image/')) { - content.push({ - type: 'image', - image: URL.createObjectURL(selectedFile) - }) - } + setIsPending?.(true) + + const userMessageId = nanoid() + const currentInput = input + const currentFile = selectedFile + const createdUrls: string[] = [] + + try { + const content: ({ type: 'text'; text: string } | { type: 'image'; image: string; isOptimistic?: boolean })[] = [] + if (input) { + content.push({ type: 'text', text: input }) + } + if (selectedFile && selectedFile.type.startsWith('image/')) { + const url = URL.createObjectURL(selectedFile) + createdUrls.push(url) + objectUrls.current.add(url) + content.push({ + type: 'image', + image: url, + isOptimistic: true + }) + } - setMessages(currentMessages => [ - ...currentMessages, - { - id: nanoid(), - component: + setMessages(currentMessages => [ + ...currentMessages, + { + id: userMessageId, + component: + } + ]) + + const formData = new FormData(e.currentTarget) + if (selectedFile) { + formData.append('file', selectedFile) } - ]) - const formData = new FormData(e.currentTarget) - if (selectedFile) { - formData.append('file', selectedFile) - } + // Include drawn features in the form data + formData.append('drawnFeatures', JSON.stringify(mapData.drawnFeatures || [])) - // Include drawn features in the form data - formData.append('drawnFeatures', JSON.stringify(mapData.drawnFeatures || [])) + setInput('') + clearAttachment() - setInput('') - clearAttachment() + const responseMessage = await submit(formData) + setMessages(currentMessages => [...currentMessages, responseMessage as any]) - const responseMessage = await submit(formData) - setMessages(currentMessages => [...currentMessages, responseMessage as any]) + // Revoke URLs after upload finishes + createdUrls.forEach(url => { + URL.revokeObjectURL(url) + objectUrls.current.delete(url) + }) + } catch (error) { + console.error('Failed to submit message:', error) + setMessages(currentMessages => currentMessages.filter(m => m.id !== userMessageId)) + setInput(currentInput) + setSelectedFile(currentFile) + createdUrls.forEach(url => { + URL.revokeObjectURL(url) + objectUrls.current.delete(url) + }) + } finally { + setIsPending?.(false) + } } const handleClear = async () => { @@ -177,6 +219,7 @@ export const ChatPanel = forwardRef(({ messages, i onClick={() => handleClear()} data-testid="new-chat-button" title="New Chat" + aria-label="Start new chat" > @@ -225,6 +268,7 @@ export const ChatPanel = forwardRef(({ messages, i )} onClick={handleAttachmentClick} data-testid="desktop-attachment-button" + aria-label="Attach file" > @@ -281,11 +325,11 @@ export const ChatPanel = forwardRef(({ messages, i 'absolute top-1/2 transform -translate-y-1/2', isMobile ? 'right-1' : 'right-2' )} - disabled={input.length === 0 && !selectedFile} + disabled={(!input.trim() && !selectedFile) || isPending} aria-label="Send message" data-testid="chat-submit" > - + {isPending ? : } @@ -295,7 +339,7 @@ export const ChatPanel = forwardRef(({ messages, i {selectedFile.name} - diff --git a/components/chat.tsx b/components/chat.tsx index e675f124..2374f5de 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -38,6 +38,7 @@ export function Chat({ id }: ChatProps) { const [input, setInput] = useState('') const [showEmptyScreen, setShowEmptyScreen] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false) + const [triggerSubmit, setTriggerSubmit] = useState(0) const [suggestions, setSuggestions] = useState(null) const chatPanelRef = useRef(null); @@ -86,11 +87,10 @@ export function Chat({ id }: ChatProps) { const { mapData } = useMapData(); useEffect(() => { - if (isSubmitting) { - chatPanelRef.current?.submitForm() - setIsSubmitting(false) + if (triggerSubmit > 0 && chatPanelRef.current) { + chatPanelRef.current.submitForm() } - }, [isSubmitting]) + }, [triggerSubmit]) // useEffect to call the server action when drawnFeatures changes useEffect(() => { @@ -113,7 +113,7 @@ export function Chat({ id }: ChatProps) { setInput(query) setSuggestions(null) // Use a small timeout to ensure state update before submission - setIsSubmitting(true) + setTriggerSubmit(prev => prev + 1) }} onClose={() => setSuggestions(null)} className="relative bottom-auto mb-0 w-full shadow-none border-none bg-transparent" @@ -132,7 +132,11 @@ export function Chat({ id }: ChatProps) { {activeView ? : isUsageOpen ? : }
- +
@@ -153,7 +159,7 @@ export function Chat({ id }: ChatProps) { { setInput(message) - setIsSubmitting(true) + setTriggerSubmit(prev => prev + 1) }} /> ) : ( @@ -185,6 +191,8 @@ export function Chat({ id }: ChatProps) { input={input} setInput={setInput} onSuggestionsChange={setSuggestions} + isPending={isSubmitting} + setIsPending={setIsSubmitting} />
@@ -192,7 +200,7 @@ export function Chat({ id }: ChatProps) { { setInput(message) - setIsSubmitting(true) + setTriggerSubmit(prev => prev + 1) }} /> ) : ( diff --git a/components/header.tsx b/components/header.tsx index fd80bc44..b144830c 100644 --- a/components/header.tsx +++ b/components/header.tsx @@ -52,7 +52,7 @@ export const Header = () => {
-
- @@ -89,7 +89,7 @@ export const Header = () => { {/* Mobile menu buttons */}
- diff --git a/components/history.tsx b/components/history.tsx index 5bae1a39..8977a037 100644 --- a/components/history.tsx +++ b/components/history.tsx @@ -19,6 +19,7 @@ export function History({ location }: HistoryProps) { })} data-testid="history-button" onClick={toggleHistory} + aria-label={location === 'header' ? "Toggle history" : "Close history"} > {location === 'header' ? : } diff --git a/components/mobile-icons-bar.tsx b/components/mobile-icons-bar.tsx index d0db2cfa..cec6054e 100644 --- a/components/mobile-icons-bar.tsx +++ b/components/mobile-icons-bar.tsx @@ -3,7 +3,8 @@ import React from 'react' import { useUIState, useActions } from 'ai/rsc' import { AI } from '@/app/actions' -import { Button } from '@/components/ui/button' +import { Button, buttonVariants } from '@/components/ui/button' +import { cn } from '@/lib/utils' import { Search, CircleUserRound, @@ -19,13 +20,15 @@ import { MapToggle } from './map-toggle' import { ModeToggle } from './mode-toggle' import { ProfileToggle } from './profile-toggle' import { useCalendarToggle } from './calendar-toggle-context' +import { Spinner } from './ui/spinner' interface MobileIconsBarProps { onAttachmentClick: () => void; onSubmitClick: () => void; + isPending?: boolean; } -export const MobileIconsBar: React.FC = ({ onAttachmentClick, onSubmitClick }) => { +export const MobileIconsBar: React.FC = ({ onAttachmentClick, onSubmitClick, isPending }) => { const [, setMessages] = useUIState() const { clearChat } = useActions() const { toggleCalendar } = useCalendarToggle() @@ -37,27 +40,31 @@ export const MobileIconsBar: React.FC = ({ onAttachmentClic return (
- - - - - + + - - diff --git a/components/user-message.tsx b/components/user-message.tsx index 03b8ea8d..0e06c68b 100644 --- a/components/user-message.tsx +++ b/components/user-message.tsx @@ -4,7 +4,7 @@ import { ChatShare } from './chat-share' type UserMessageContentPart = | { type: 'text'; text: string } - | { type: 'image'; image: string } // data URL + | { type: 'image'; image: string; isOptimistic?: boolean } // data URL or blob URL type UserMessageProps = { content: string | UserMessageContentPart[]