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
9 changes: 9 additions & 0 deletions .Jules/palette.md
Original file line number Diff line number Diff line change
@@ -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.
106 changes: 75 additions & 31 deletions components/chat-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -20,14 +21,16 @@ interface ChatPanelProps {
input: string
setInput: (value: string) => void
onSuggestionsChange?: (suggestions: PartialRelated | null) => void
isPending?: boolean
setIsPending?: (pending: boolean) => void
}

export interface ChatPanelRef {
handleAttachmentClick: () => void
submitForm: () => void
}

export const ChatPanel = forwardRef<ChatPanelRef, ChatPanelProps>(({ messages, input, setInput, onSuggestionsChange }, ref) => {
export const ChatPanel = forwardRef<ChatPanelRef, ChatPanelProps>(({ messages, input, setInput, onSuggestionsChange, isPending, setIsPending }, ref) => {
const [, setMessages] = useUIState<typeof AI>()
const { submit, clearChat } = useActions()
const { mapProvider } = useSettingsStore()
Expand All @@ -43,6 +46,15 @@ export const ChatPanel = forwardRef<ChatPanelRef, ChatPanelProps>(({ messages, i
const inputRef = useRef<HTMLTextAreaElement>(null)
const formRef = useRef<HTMLFormElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const objectUrls = useRef<Set<string>>(new Set())

useEffect(() => {
const urls = objectUrls.current
return () => {
urls.forEach(url => URL.revokeObjectURL(url))
urls.clear()
}
}, [])
Comment on lines +49 to +57
Copy link
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Good addition — object URL cleanup prevents memory leaks.

This addresses the previously flagged blob URL leak. The Set-based tracking with cleanup on unmount is a clean pattern.

One minor lint fix: Biome flags the implicit return in the forEach callback (Line 54). Add braces to silence it:

🔧 Proposed fix
-      urls.forEach(url => URL.revokeObjectURL(url))
+      urls.forEach(url => { URL.revokeObjectURL(url) })
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const objectUrls = useRef<Set<string>>(new Set())
useEffect(() => {
const urls = objectUrls.current
return () => {
urls.forEach(url => URL.revokeObjectURL(url))
urls.clear()
}
}, [])
const objectUrls = useRef<Set<string>>(new Set())
useEffect(() => {
const urls = objectUrls.current
return () => {
urls.forEach(url => { URL.revokeObjectURL(url) })
urls.clear()
}
}, [])
🧰 Tools
🪛 Biome (2.3.14)

[error] 54-54: This callback passed to forEach() iterable method should not return a value.

Either remove this return or remove the returned value.

(lint/suspicious/useIterableCallbackReturn)

🤖 Prompt for AI Agents
In `@components/chat-panel.tsx` around lines 49 - 57, The forEach callback in the
cleanup inside the useEffect is using an implicit return which Biome lints;
update the cleanup to use an explicit block for the callback so there is no
implicit return — locate the useEffect that references objectUrls
(useRef<Set<string>> objectUrls and the cleanup using urls.forEach(url =>
URL.revokeObjectURL(url))) and change the forEach callback to use braces (e.g.,
urls.forEach(url => { URL.revokeObjectURL(url); })) so the linter warning is
resolved.


useImperativeHandle(ref, () => ({
handleAttachmentClick() {
Expand Down Expand Up @@ -87,42 +99,72 @@ export const ChatPanel = forwardRef<ChatPanelRef, ChatPanelProps>(({ messages, i

const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
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: <UserMessage content={content} />
setMessages(currentMessages => [
...currentMessages,
{
id: userMessageId,
component: <UserMessage content={content} />
}
])

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)
}
Comment on lines 100 to 167

Choose a reason for hiding this comment

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

handleSubmit sets pending state and pushes a UserMessage that includes an image URL created via URL.createObjectURL(selectedFile), but that object URL is never revoked. Over time (especially with repeated image sends), this can leak memory in the browser. Also, moving the content construction inside the try makes it harder to guarantee cleanup if more logic is added later.

Suggestion

Revoke any created object URL once it's no longer needed. One pragmatic approach is to create the object URL in a local variable and revoke it in the finally block.

Example:

let imageUrl: string | undefined
try {
  const content: ({ type: 'text'; text: string } | { type: 'image'; image: string })[] = []
  if (input) content.push({ type: 'text', text: input })

  if (selectedFile && selectedFile.type.startsWith('image/')) {
    imageUrl = URL.createObjectURL(selectedFile)
    content.push({ type: 'image', image: imageUrl })
  }

  // ...existing setMessages + submit logic...
} finally {
  if (imageUrl) URL.revokeObjectURL(imageUrl)
  setIsPending?.(false)
}

Reply with "@CharlieHelps yes please" if you’d like me to add a commit with this change.

}

const handleClear = async () => {
Expand Down Expand Up @@ -177,6 +219,7 @@ export const ChatPanel = forwardRef<ChatPanelRef, ChatPanelProps>(({ messages, i
onClick={() => handleClear()}
data-testid="new-chat-button"
title="New Chat"
aria-label="Start new chat"
>
<Sprout size={28} className="fill-primary/20" />
</Button>
Expand Down Expand Up @@ -225,6 +268,7 @@ export const ChatPanel = forwardRef<ChatPanelRef, ChatPanelProps>(({ messages, i
)}
onClick={handleAttachmentClick}
data-testid="desktop-attachment-button"
aria-label="Attach file"
>
<Paperclip size={isMobile ? 18 : 20} />
</Button>
Expand Down Expand Up @@ -281,11 +325,11 @@ export const ChatPanel = forwardRef<ChatPanelRef, ChatPanelProps>(({ 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"
>
<ArrowRight size={isMobile ? 18 : 20} />
{isPending ? <Spinner /> : <ArrowRight size={isMobile ? 18 : 20} />}
</Button>
</div>
</form>
Expand All @@ -295,7 +339,7 @@ export const ChatPanel = forwardRef<ChatPanelRef, ChatPanelProps>(({ messages, i
<span className="text-sm text-muted-foreground truncate max-w-xs">
{selectedFile.name}
</span>
<Button variant="ghost" size="icon" onClick={clearAttachment} data-testid="clear-attachment-button">
<Button variant="ghost" size="icon" onClick={clearAttachment} data-testid="clear-attachment-button" aria-label="Clear attachment">
<X size={16} />
</Button>
</div>
Expand Down
24 changes: 16 additions & 8 deletions components/chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<PartialRelated | null>(null)
const chatPanelRef = useRef<ChatPanelRef>(null);

Expand Down Expand Up @@ -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(() => {
Expand All @@ -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"
Expand All @@ -132,7 +132,11 @@ export function Chat({ id }: ChatProps) {
{activeView ? <SettingsView /> : isUsageOpen ? <UsageView /> : <MapProvider />}
</div>
<div className="mobile-icons-bar">
<MobileIconsBar onAttachmentClick={handleAttachment} onSubmitClick={handleMobileSubmit} />
<MobileIconsBar
onAttachmentClick={handleAttachment}
onSubmitClick={handleMobileSubmit}
isPending={isSubmitting}
/>
</div>
<div className="mobile-chat-input-area">
<ChatPanel
Expand All @@ -141,6 +145,8 @@ export function Chat({ id }: ChatProps) {
input={input}
setInput={setInput}
onSuggestionsChange={setSuggestions}
isPending={isSubmitting}
setIsPending={setIsSubmitting}
/>
</div>
<div className="mobile-chat-messages-area relative">
Expand All @@ -153,7 +159,7 @@ export function Chat({ id }: ChatProps) {
<EmptyScreen
submitMessage={message => {
setInput(message)
setIsSubmitting(true)
setTriggerSubmit(prev => prev + 1)
}}
/>
) : (
Expand Down Expand Up @@ -185,14 +191,16 @@ export function Chat({ id }: ChatProps) {
input={input}
setInput={setInput}
onSuggestionsChange={setSuggestions}
isPending={isSubmitting}
setIsPending={setIsSubmitting}
/>
<div className="relative min-h-[100px]">
<div className={cn("transition-all duration-300", suggestions ? "blur-md pointer-events-none" : "")}>
{showEmptyScreen ? (
<EmptyScreen
submitMessage={message => {
setInput(message)
setIsSubmitting(true)
setTriggerSubmit(prev => prev + 1)
}}
/>
) : (
Expand Down
8 changes: 4 additions & 4 deletions components/header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ export const Header = () => {
</div>

<div className="absolute left-1 flex items-center">
<Button variant="ghost" size="icon" onClick={toggleHistory} data-testid="logo-history-toggle">
<Button variant="ghost" size="icon" onClick={toggleHistory} data-testid="logo-history-toggle" aria-label="Toggle history">
<Image
src="/images/logo.svg"
alt="Logo"
Expand All @@ -71,13 +71,13 @@ export const Header = () => {

<MapToggle />

<Button variant="ghost" size="icon" onClick={toggleCalendar} title="Open Calendar" data-testid="calendar-toggle">
<Button variant="ghost" size="icon" onClick={toggleCalendar} title="Open Calendar" aria-label="Open Calendar" data-testid="calendar-toggle">
<CalendarDays className="h-[1.2rem] w-[1.2rem]" />
</Button>

<div id="header-search-portal" className="contents" />

<Button variant="ghost" size="icon" onClick={handleUsageToggle}>
<Button variant="ghost" size="icon" onClick={handleUsageToggle} aria-label="Toggle usage statistics">
<TentTree className="h-[1.2rem] w-[1.2rem]" />
</Button>

Expand All @@ -89,7 +89,7 @@ export const Header = () => {
{/* Mobile menu buttons */}
<div className="flex md:hidden gap-2">

<Button variant="ghost" size="icon" onClick={handleUsageToggle}>
<Button variant="ghost" size="icon" onClick={handleUsageToggle} aria-label="Toggle usage statistics">
<TentTree className="h-[1.2rem] w-[1.2rem]" />
</Button>
<ProfileToggle/>
Expand Down
1 change: 1 addition & 0 deletions components/history.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' ? <Menu /> : <ChevronLeft size={16} />}
</Button>
Expand Down
31 changes: 19 additions & 12 deletions components/mobile-icons-bar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<MobileIconsBarProps> = ({ onAttachmentClick, onSubmitClick }) => {
export const MobileIconsBar: React.FC<MobileIconsBarProps> = ({ onAttachmentClick, onSubmitClick, isPending }) => {
const [, setMessages] = useUIState<typeof AI>()
const { clearChat } = useActions()
const { toggleCalendar } = useCalendarToggle()
Expand All @@ -37,27 +40,31 @@ export const MobileIconsBar: React.FC<MobileIconsBarProps> = ({ onAttachmentClic

return (
<div className="mobile-icons-bar-content">
<Button variant="ghost" size="icon" onClick={handleNewChat} data-testid="mobile-new-chat-button">
<Button variant="ghost" size="icon" onClick={handleNewChat} data-testid="mobile-new-chat-button" aria-label="Start new chat">
<Plus className="h-[1.2rem] w-[1.2rem]" />
</Button>
<ProfileToggle />
<MapToggle />
<Button variant="ghost" size="icon" onClick={toggleCalendar} title="Open Calendar" data-testid="mobile-calendar-button">
<Button variant="ghost" size="icon" onClick={toggleCalendar} title="Open Calendar" aria-label="Open Calendar" data-testid="mobile-calendar-button">
<CalendarDays className="h-[1.2rem] w-[1.2rem] transition-all rotate-0 scale-100" />
</Button>
<Button variant="ghost" size="icon" data-testid="mobile-search-button">
<Button variant="ghost" size="icon" data-testid="mobile-search-button" aria-label="Search">
<Search className="h-[1.2rem] w-[1.2rem] transition-all rotate-0 scale-100" />
</Button>
<a href="https://buy.stripe.com/14A3cv7K72TR3go14Nasg02" target="_blank" rel="noopener noreferrer">
<Button variant="ghost" size="icon">
<TentTree className="h-[1.2rem] w-[1.2rem] transition-all rotate-0 scale-100" />
</Button>
<a
href="https://buy.stripe.com/14A3cv7K72TR3go14Nasg02"
target="_blank"
rel="noopener noreferrer"
aria-label="Purchase credits"
className={cn(buttonVariants({ variant: 'ghost', size: 'icon' }))}
>
<TentTree className="h-[1.2rem] w-[1.2rem] transition-all rotate-0 scale-100" />
</a>
<Button variant="ghost" size="icon" onClick={onAttachmentClick} data-testid="mobile-attachment-button">
<Button variant="ghost" size="icon" onClick={onAttachmentClick} data-testid="mobile-attachment-button" aria-label="Attach file">
<Paperclip className="h-[1.2rem] w-[1.2rem] transition-all rotate-0 scale-100" />
</Button>
<Button variant="ghost" size="icon" data-testid="mobile-submit-button" onClick={onSubmitClick}>
<ArrowRight className="h-[1.2rem] w-[1.2rem] transition-all rotate-0 scale-100" />
<Button variant="ghost" size="icon" data-testid="mobile-submit-button" onClick={onSubmitClick} aria-label="Send message" disabled={isPending}>
{isPending ? <Spinner /> : <ArrowRight className="h-[1.2rem] w-[1.2rem] transition-all rotate-0 scale-100" />}
</Button>
Comment on lines 64 to 68

Choose a reason for hiding this comment

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

The mobile submit button is now disabled based solely on isPending. That’s good for preventing double-send, but it does not mirror the desktop behavior of disabling when there’s no input and no attachment. This can regress UX/accessibility by making a seemingly actionable button that does nothing (since the form submit path may short-circuit).

Suggestion

Pass a second prop (e.g., canSubmit), or pass input/hasAttachment to compute disabled consistently across desktop and mobile.

Example API:

interface MobileIconsBarProps {
  onAttachmentClick: () => void
  onSubmitClick: () => void
  isPending: boolean
  canSubmit: boolean
}
// ...
disabled={isPending || !canSubmit}

Reply with "@CharlieHelps yes please" if you’d like me to add a commit wiring canSubmit from Chat.

<History location="header" />
<ModeToggle />
Expand Down
2 changes: 1 addition & 1 deletion components/user-message.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
Expand Down