feat(platform): vision image attachments, welcome suggestions, and chat fixes#812
Conversation
…at fixes - Pass image attachments as base64 data URLs to beforeGenerateHook so vision models receive actual image data instead of markdown text - Fix cancel generation to handle early stop when latest assistant message is from a previous successful turn - Add role-based suggestion chips to WelcomeView; pass onSuggestionClick to populate the input on click - Disable indented code block parsing in incremental-markdown to prevent false positive code block rendering - Fix chat-interface welcome view alignment (justify-end to justify-center) - Update tools page to use PageSection instead of SectionHeader
There was a problem hiding this comment.
Your free trial has ended. If you'd like to continue receiving code reviews, you can add a payment method here.
📝 WalkthroughWalkthroughThis PR refactors chat and automation UI components with restructured attachment handling, updated message rendering, and feature enhancements. Changes include: reworked attachment rendering with per-item removal buttons across multiple chat input components; simplified layout structures in message editors and skeleton loaders; enhanced code block display with language labels and copy buttons; JSON viewer support in markdown rendering; role-based suggestion system in WelcomeView; mouse interaction improvements in search dialog; and backend support for image attachments in agent prompt construction. Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related PRs
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
📝 Coding Plan
Comment Tip CodeRabbit can use oxc to improve the quality of JavaScript and TypeScript code reviews.Add a configuration file to your project to customize how CodeRabbit runs oxc. |
There was a problem hiding this comment.
Actionable comments posted: 8
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
services/platform/app/features/chat/components/chat-search-dialog.tsx (1)
174-189:⚠️ Potential issue | 🟠 MajorAdd
role="list"to both<ul>elements for accessibility compliance.Lines 174 and 188 have
<ul>elements without the requiredrole="list"attribute. In this repository, with Tailwind v4's Preflight CSS reset, Safari/VoiceOver users lose list semantics without this explicit attribute, affecting WCAG 2.1 Level AA compliance.Suggested fix
- <ul> + <ul role="list"> {(() => { let flatIdx = 0; return groupedChats.map((group) => ( <li key={group.label}> @@ - <ul> + <ul role="list"> {group.chats.map((chat) => {🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@services/platform/app/features/chat/components/chat-search-dialog.tsx` around lines 174 - 189, The two unordered lists in the ChatSearchDialog JSX (the outer list that maps groupedChats and the inner list that renders group.chats inside the map) are missing explicit list semantics; update both <ul> elements in chat-search-dialog.tsx to include role="list" (the outer <ul> that contains the groupedChats iteration and the inner <ul> that iterates over group.chats) so VoiceOver/Safari retain proper list semantics under Tailwind v4 Preflight.services/platform/app/features/custom-agents/components/test-chat-panel/test-chat-input.tsx (1)
72-87:⚠️ Potential issue | 🟠 MajorGate the entire upload surface with one boolean.
Right now the hidden input requires
fileUploadEnabled && fileAccept, the toolbar button only checksfileUploadEnabled, and the drop zone stays active regardless. That leaves two broken states: an attach button with no backing input, and drag-and-drop uploads still working when uploads are supposed to be off.Also applies to: 179-193
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@services/platform/app/features/custom-agents/components/test-chat-panel/test-chat-input.tsx` around lines 72 - 87, The upload UI should be controlled by a single boolean (e.g., isFileUploadActive = fileUploadEnabled && Boolean(fileAccept)) so that the hidden input (fileInputRef/onFileInputChange), the toolbar attach button, and FileUpload.DropZone (onFilesSelected/uploadFiles and clickable) are all gated consistently; update the render logic to use that combined flag everywhere the input, the attach button, and the DropZone are referenced (including the other instance around lines 179-193) and ensure DropZone is disabled/no-op when the combined flag is false so drag-and-drop cannot work when uploads are off.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In
`@services/platform/app/features/automations/components/automation-assistant/chat-input.tsx`:
- Around line 145-167: The textarea is currently inaccessible because
placeholder="" and there is no label or aria attribute; add an accessible name
to the Textarea (e.g., aria-label or aria-labelledby) using the same localized
hint used in the off-screen hint (use t('assistant.messagePlaceholder') and/or
combine with tDialogs('toSend')), or add aria-describedby that points to the
hint element so screen readers announce it; update the Textarea props in the
automation-assistant component (the Textarea JSX where inputValue,
onInputChange, onKeyDown, onPaste, and isLoading are used) to include the
appropriate aria attribute so the multiline control is named for assistive tech.
- Around line 83-142: The send action currently ignores in-flight uploads
(uploadingFiles); update the ChatInput component to block sending while
uploadingFiles.length > 0 by (1) disabling the send button(s) UI and adding
aria-disabled/tooltip, and (2) adding a guard in the submit handler (e.g.,
onSend or handleSubmit / sendMessage) that returns early if
uploadingFiles.length > 0 to prevent programmatic submission; apply the same
change where the secondary send control is implemented (the code around the
other send button at lines ~183-187) so both UI paths and the submit logic check
uploadingFiles before sending.
In `@services/platform/app/features/chat/components/chat-input.tsx`:
- Around line 254-286: The textarea lost an accessible label; add an explicit
accessible name to the Textarea component (referencing the Textarea element that
uses textareaRef and value) by either setting a meaningful aria-label or using
aria-labelledby that points to the visible hint element (which contains
defaultPlaceholder, EnterKeyIcon, and tDialogs('toSend'))—ensure the hint
element has a stable id and update the Textarea props to include that id via
aria-labelledby (or add aria-label directly) so screen readers announce the
multiline input; keep existing visual markup and only add the accessible
attribute.
- Around line 163-251: Pending uploads aren't blocking message send: add the
same guard used when rendering attachments to prevent sending while uploads are
in progress. In practice, use the uploadingFiles array (e.g., const isUploading
= uploadingFiles.length > 0) and (1) early-return from handleSendMessage when
isUploading is true, and (2) disable the send button and prevent Enter/submit
hotkey by using the same isUploading predicate in the key/submit handlers.
Reference uploadingFiles and handleSendMessage (and the send button/Enter submit
handler) and apply the same change in the related code path noted (lines
303-311) so both UI and hotkeys are consistent.
- Around line 196-203: The remove button is hidden via "opacity-0" and only
shown on "group-hover", which makes it inaccessible to keyboard and touch users;
update the button(s) that call removeAttachment(attachment.fileId) (and the
duplicate at lines ~232-239) to include focus and focus-visible (e.g., add
"focus:opacity-100 focus-visible:opacity-100") and/or make the parent use
"group-focus-within:opacity-100" so the button becomes visible on keyboard focus
or when the attachment container is focused, while keeping the hover behavior
(retain "group-hover:opacity-100"); ensure the same changes are applied to both
remove button occurrences referencing removeAttachment.
In `@services/platform/app/features/chat/components/chat-interface.tsx`:
- Around line 69-70: The component uses useCurrentMemberContext to set
role={memberContext?.role}, which can render transient default suggestions while
the query is still resolving; update the ChatInterface render to guard against
unresolved memberContext by checking for the presence of memberContext (or an
isLoading flag from useCurrentMemberContext) before passing role or rendering
the member-suggestion UI (i.e., only pass role when memberContext is defined or
skip rendering the suggestion component), and apply the same guard to the other
occurrence at the second role usage (lines ~294-295) so no incorrect suggestions
flash while loading.
In
`@services/platform/app/features/custom-agents/components/test-chat-panel/test-chat-input.tsx`:
- Around line 153-175: The Textarea is currently unlabeled (placeholder=""), so
add an accessible name: give the Textarea an aria-label or aria-labelledby prop
(e.g., aria-label={t('customAgents.testChat.inputLabel')} or aria-labelledby
pointing to the visual placeholder element) in test-chat-input.tsx to ensure
screen readers can identify the input; if using aria-labelledby, add a stable id
to the visual placeholder Text element and mark the visual-only elements
appropriately (aria-hidden where needed) so only the intended label is
announced.
In `@services/platform/convex/lib/agent_chat/internal_actions.ts`:
- Around line 597-623: The image resolution block (imageAttachments ->
resolvedImageParts) must guard against large payloads and single-image failures:
cap the number of images (e.g., maxImages = 4) and process at most that many
from imageAttachments, check each blob size/arrayBuffer length against a
MAX_BYTES threshold before base64 conversion, and wrap each per-image
read/encode in try/catch so a single failure returns null for that image instead
of rejecting the whole Promise.all; replace the Promise.all approach with a
bounded/concurrent or sequential loop that applies these checks and collects
successful entries into resolvedImageParts, and set/propagate
contextExceedsBudget (or another flag) when images are skipped due to size
limits.
---
Outside diff comments:
In `@services/platform/app/features/chat/components/chat-search-dialog.tsx`:
- Around line 174-189: The two unordered lists in the ChatSearchDialog JSX (the
outer list that maps groupedChats and the inner list that renders group.chats
inside the map) are missing explicit list semantics; update both <ul> elements
in chat-search-dialog.tsx to include role="list" (the outer <ul> that contains
the groupedChats iteration and the inner <ul> that iterates over group.chats) so
VoiceOver/Safari retain proper list semantics under Tailwind v4 Preflight.
In
`@services/platform/app/features/custom-agents/components/test-chat-panel/test-chat-input.tsx`:
- Around line 72-87: The upload UI should be controlled by a single boolean
(e.g., isFileUploadActive = fileUploadEnabled && Boolean(fileAccept)) so that
the hidden input (fileInputRef/onFileInputChange), the toolbar attach button,
and FileUpload.DropZone (onFilesSelected/uploadFiles and clickable) are all
gated consistently; update the render logic to use that combined flag everywhere
the input, the attach button, and the DropZone are referenced (including the
other instance around lines 179-193) and ensure DropZone is disabled/no-op when
the combined flag is false so drag-and-drop cannot work when uploads are off.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro
Run ID: b6698dce-9e35-4496-bfc3-9079a4179673
📒 Files selected for processing (16)
services/platform/app/features/automations/components/automation-assistant/chat-input.tsxservices/platform/app/features/chat/components/chat-input.tsxservices/platform/app/features/chat/components/chat-interface.tsxservices/platform/app/features/chat/components/chat-search-dialog.tsxservices/platform/app/features/chat/components/incremental-markdown.tsxservices/platform/app/features/chat/components/message-bubble/code-block.tsxservices/platform/app/features/chat/components/message-bubble/markdown-renderer.tsxservices/platform/app/features/chat/components/welcome-view.tsxservices/platform/app/features/conversations/components/message-editor.tsxservices/platform/app/features/custom-agents/components/test-chat-panel/test-chat-input.tsxservices/platform/app/routes/dashboard/$id/chat/$threadId.tsxservices/platform/app/routes/dashboard/$id/chat/index.tsxservices/platform/app/routes/dashboard/$id/custom-agents/$agentId/tools.tsxservices/platform/convex/lib/agent_chat/internal_actions.tsservices/platform/convex/threads/cancel_generation.test.tsservices/platform/convex/threads/cancel_generation.ts
| {(attachments.length > 0 || uploadingFiles.length > 0) && ( | ||
| <HStack gap={1} wrap className="mb-2"> | ||
| {imageAttachments.map((attachment) => ( | ||
| <div | ||
| key={attachment.fileId} | ||
| className="group relative size-11 overflow-hidden rounded-lg shadow-sm" | ||
| > | ||
| <div className="bg-secondary/20 size-full"> | ||
| {attachment.previewUrl ? ( | ||
| <img | ||
| src={attachment.previewUrl} | ||
| alt={attachment.fileName} | ||
| className="size-full object-cover" | ||
| /> | ||
| ) : ( | ||
| <div className="flex size-full items-center justify-center bg-linear-to-br from-blue-100 to-blue-200" /> | ||
| )} | ||
| </div> | ||
| ))} | ||
|
|
||
| {fileAttachments.map((attachment) => ( | ||
| <div | ||
| key={attachment.fileId} | ||
| className="bg-secondary/20 group relative flex max-w-[216px] items-center gap-2 rounded-lg px-2 py-1" | ||
| <button | ||
| type="button" | ||
| aria-label={tChat('removeAttachment')} | ||
| onClick={() => removeAttachment(attachment.fileId)} | ||
| className="bg-background absolute top-0.5 right-0.5 flex size-5 items-center justify-center rounded-full opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100" | ||
| > | ||
| <DocumentIcon fileName={attachment.fileName} /> | ||
| <VStack className="min-w-0 flex-1"> | ||
| <Text as="div" variant="label" truncate> | ||
| {attachment.fileName} | ||
| </Text> | ||
| </VStack> | ||
| <button | ||
| type="button" | ||
| aria-label={tChat('removeAttachment')} | ||
| onClick={() => removeAttachment(attachment.fileId)} | ||
| className="bg-background absolute top-0.5 right-0.5 flex size-5 items-center justify-center rounded-full opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100" | ||
| > | ||
| <X className="text-muted-foreground size-3" /> | ||
| </button> | ||
| </div> | ||
| ))} | ||
| <X className="text-muted-foreground size-3" /> | ||
| </button> | ||
| </div> | ||
| ))} | ||
|
|
||
| {uploadingFiles.map((fileId) => ( | ||
| <div | ||
| key={fileId} | ||
| className="bg-secondary/20 grid size-[2.75rem] place-content-center rounded-lg p-2" | ||
| {fileAttachments.map((attachment) => ( | ||
| <div | ||
| key={attachment.fileId} | ||
| className="bg-secondary/20 group relative flex max-w-[216px] items-center gap-2 rounded-lg px-2 py-1" | ||
| > | ||
| <DocumentIcon fileName={attachment.fileName} /> | ||
| <VStack className="min-w-0 flex-1"> | ||
| <Text as="div" variant="label" truncate> | ||
| {attachment.fileName} | ||
| </Text> | ||
| </VStack> | ||
| <button | ||
| type="button" | ||
| aria-label={tChat('removeAttachment')} | ||
| onClick={() => removeAttachment(attachment.fileId)} | ||
| className="bg-background absolute top-0.5 right-0.5 flex size-5 items-center justify-center rounded-full opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100" | ||
| > | ||
| <LoaderCircle className="size-4 animate-spin" /> | ||
| </div> | ||
| ))} | ||
| </HStack> | ||
| )} | ||
| <X className="text-muted-foreground size-3" /> | ||
| </button> | ||
| </div> | ||
| ))} | ||
|
|
||
| <div className="relative"> | ||
| <Textarea | ||
| value={inputValue} | ||
| onChange={(e) => onInputChange(e.target.value)} | ||
| onKeyDown={onKeyDown} | ||
| onPaste={onPaste} | ||
| className="text-foreground placeholder:text-muted-foreground relative min-h-[100px] resize-none border-0 bg-transparent px-0 py-0 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0" | ||
| disabled={isLoading} | ||
| placeholder="" | ||
| /> | ||
| {inputValue.length === 0 && !isLoading && ( | ||
| <Text | ||
| as="div" | ||
| variant="muted" | ||
| className="pointer-events-none absolute top-0 left-0 flex items-center gap-1" | ||
| {uploadingFiles.map((fileId) => ( | ||
| <div | ||
| key={fileId} | ||
| className="bg-secondary/20 grid size-[2.75rem] place-content-center rounded-lg p-2" | ||
| > | ||
| {t('assistant.messagePlaceholder')} | ||
| <div className="border-muted-foreground/30 text-muted-foreground flex size-4 items-center justify-center rounded border"> | ||
| <EnterKeyIcon className="size-3" /> | ||
| </div> | ||
| {tDialogs('toSend')} | ||
| </Text> | ||
| )} | ||
| </div> | ||
| <LoaderCircle className="size-4 animate-spin" /> | ||
| </div> | ||
| ))} | ||
| </HStack> |
There was a problem hiding this comment.
Block send while files are still uploading.
This component shows uploadingFiles, but the send button ignores that state. If the user has typed a message while an attachment is still uploading, the turn can be submitted without that pending file.
Also applies to: 183-187
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@services/platform/app/features/automations/components/automation-assistant/chat-input.tsx`
around lines 83 - 142, The send action currently ignores in-flight uploads
(uploadingFiles); update the ChatInput component to block sending while
uploadingFiles.length > 0 by (1) disabling the send button(s) UI and adding
aria-disabled/tooltip, and (2) adding a guard in the submit handler (e.g.,
onSend or handleSubmit / sendMessage) that returns early if
uploadingFiles.length > 0 to prevent programmatic submission; apply the same
change where the secondary send control is implemented (the code around the
other send button at lines ~183-187) so both UI paths and the submit logic check
uploadingFiles before sending.
| <div className="relative"> | ||
| <Textarea | ||
| value={inputValue} | ||
| onChange={(e) => onInputChange(e.target.value)} | ||
| onKeyDown={onKeyDown} | ||
| onPaste={onPaste} | ||
| className="text-foreground placeholder:text-muted-foreground relative min-h-[100px] resize-none border-0 bg-transparent px-0 py-0 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0" | ||
| disabled={isLoading} | ||
| placeholder="" | ||
| /> | ||
| {inputValue.length === 0 && !isLoading && ( | ||
| <Text | ||
| as="div" | ||
| variant="muted" | ||
| className="pointer-events-none absolute top-0 left-0 flex items-center gap-1" | ||
| > | ||
| {t('assistant.messagePlaceholder')} | ||
| <div className="border-muted-foreground/30 text-muted-foreground flex size-4 items-center justify-center rounded border"> | ||
| <EnterKeyIcon className="size-3" /> | ||
| </div> | ||
| {tDialogs('toSend')} | ||
| </Text> | ||
| )} |
There was a problem hiding this comment.
Restore an accessible name on the composer.
The hint is now rendered outside the native <textarea>, but the control itself has placeholder="" and no label/aria-label. Screen readers will hit an unnamed multiline field here.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@services/platform/app/features/automations/components/automation-assistant/chat-input.tsx`
around lines 145 - 167, The textarea is currently inaccessible because
placeholder="" and there is no label or aria attribute; add an accessible name
to the Textarea (e.g., aria-label or aria-labelledby) using the same localized
hint used in the off-screen hint (use t('assistant.messagePlaceholder') and/or
combine with tDialogs('toSend')), or add aria-describedby that points to the
hint element so screen readers announce it; update the Textarea props in the
automation-assistant component (the Textarea JSX where inputValue,
onInputChange, onKeyDown, onPaste, and isLoading are used) to include the
appropriate aria attribute so the multiline control is named for assistive tech.
| {(attachments.length > 0 || uploadingFiles.length > 0) && ( | ||
| <HStack gap={1} wrap className="mb-2"> | ||
| {imageAttachments.map((attachment) => ( | ||
| <div | ||
| key={attachment.fileId} | ||
| className="ring-border group relative size-9 overflow-hidden rounded-lg ring-1" | ||
| > | ||
| <button | ||
| type="button" | ||
| aria-label={tChat('viewImage')} | ||
| onClick={() => | ||
| attachment.previewUrl && | ||
| setPreviewImage({ | ||
| src: attachment.previewUrl, | ||
| alt: attachment.fileName, | ||
| }) | ||
| } | ||
| className="bg-secondary/20 focus:ring-ring size-full cursor-pointer transition-opacity hover:opacity-90 focus:ring-2 focus:ring-offset-2 focus:outline-none" | ||
| > | ||
| {attachment.previewUrl ? ( | ||
| <img | ||
| src={attachment.previewUrl} | ||
| alt={attachment.fileName} | ||
| className="size-full object-cover" | ||
| /> | ||
| ) : ( | ||
| <div className="flex size-full items-center justify-center bg-gradient-to-br from-blue-100 to-blue-200"> | ||
| <span className="text-xs text-blue-600"> | ||
| {tChat('fileTypes.image')} | ||
| </span> | ||
| </div> | ||
| )} | ||
| </button> | ||
| <button | ||
| type="button" | ||
| aria-label={tChat('removeAttachment')} | ||
| onClick={() => removeAttachment(attachment.fileId)} | ||
| className="bg-background absolute top-0.5 right-0.5 flex size-5 items-center justify-center rounded-full opacity-0 transition-opacity group-hover:opacity-100" | ||
| > | ||
| <button | ||
| type="button" | ||
| aria-label={tChat('viewImage')} | ||
| onClick={() => | ||
| attachment.previewUrl && | ||
| setPreviewImage({ | ||
| src: attachment.previewUrl, | ||
| alt: attachment.fileName, | ||
| }) | ||
| } | ||
| className="bg-secondary/20 focus:ring-ring size-full cursor-pointer transition-opacity hover:opacity-90 focus:ring-2 focus:ring-offset-2 focus:outline-none" | ||
| <X className="text-muted-foreground size-3" /> | ||
| </button> | ||
| </div> | ||
| ))} | ||
|
|
||
| {fileAttachments.map((attachment) => ( | ||
| <div | ||
| key={attachment.fileId} | ||
| className="bg-secondary/20 group relative flex max-w-[216px] items-center gap-2 rounded-lg px-2 py-1" | ||
| > | ||
| <DocumentIcon fileName={attachment.fileName} /> | ||
| <VStack className="min-w-0 flex-1"> | ||
| <Text | ||
| as="div" | ||
| variant="label" | ||
| truncate | ||
| className="ellipsis" | ||
| > | ||
| {attachment.previewUrl ? ( | ||
| <img | ||
| src={attachment.previewUrl} | ||
| alt={attachment.fileName} | ||
| className="size-full object-cover" | ||
| /> | ||
| ) : ( | ||
| <div className="flex size-full items-center justify-center bg-gradient-to-br from-blue-100 to-blue-200"> | ||
| <span className="text-xs text-blue-600"> | ||
| {tChat('fileTypes.image')} | ||
| </span> | ||
| </div> | ||
| )} | ||
| </button> | ||
| <button | ||
| type="button" | ||
| aria-label={tChat('removeAttachment')} | ||
| onClick={() => removeAttachment(attachment.fileId)} | ||
| className="bg-background absolute top-0.5 right-0.5 flex size-5 items-center justify-center rounded-full opacity-0 transition-opacity group-hover:opacity-100" | ||
| {attachment.fileName} | ||
| </Text> | ||
| <Text | ||
| as="div" | ||
| variant="caption" | ||
| className="text-muted-foreground/50" | ||
| > | ||
| <X className="text-muted-foreground size-3" /> | ||
| </button> | ||
| </div> | ||
| ))} | ||
|
|
||
| {fileAttachments.map((attachment) => ( | ||
| <div | ||
| key={attachment.fileId} | ||
| className="bg-secondary/20 group relative flex max-w-[216px] items-center gap-2 rounded-lg px-2 py-1" | ||
| {tChat( | ||
| `fileTypes.${getFileTypeLabelKey(attachment.fileType)}`, | ||
| )} | ||
| </Text> | ||
| </VStack> | ||
| <button | ||
| type="button" | ||
| aria-label={tChat('removeAttachment')} | ||
| onClick={() => removeAttachment(attachment.fileId)} | ||
| className="bg-background absolute top-0.5 right-0.5 flex size-5 items-center justify-center rounded-full opacity-0 transition-opacity group-hover:opacity-100" | ||
| > | ||
| <DocumentIcon fileName={attachment.fileName} /> | ||
| <VStack className="min-w-0 flex-1"> | ||
| <Text | ||
| as="div" | ||
| variant="label" | ||
| truncate | ||
| className="ellipsis" | ||
| > | ||
| {attachment.fileName} | ||
| </Text> | ||
| <Text | ||
| as="div" | ||
| variant="caption" | ||
| className="text-muted-foreground/50" | ||
| > | ||
| {tChat( | ||
| `fileTypes.${getFileTypeLabelKey(attachment.fileType)}`, | ||
| )} | ||
| </Text> | ||
| </VStack> | ||
| <button | ||
| type="button" | ||
| aria-label={tChat('removeAttachment')} | ||
| onClick={() => removeAttachment(attachment.fileId)} | ||
| className="bg-background absolute top-0.5 right-0.5 flex size-5 items-center justify-center rounded-full opacity-0 transition-opacity group-hover:opacity-100" | ||
| > | ||
| <X className="text-muted-foreground size-3" /> | ||
| </button> | ||
| </div> | ||
| ))} | ||
| <X className="text-muted-foreground size-3" /> | ||
| </button> | ||
| </div> | ||
| ))} | ||
|
|
||
| {uploadingFiles.map((fileId) => ( | ||
| <div | ||
| key={fileId} | ||
| className="bg-secondary/20 grid size-9 place-content-center rounded-lg p-2" | ||
| > | ||
| <LoaderCircleIcon className="size-4 animate-spin" /> | ||
| </div> | ||
| ))} | ||
| </HStack> | ||
| {uploadingFiles.map((fileId) => ( | ||
| <div | ||
| key={fileId} | ||
| className="bg-secondary/20 grid size-9 place-content-center rounded-lg p-2" | ||
| > | ||
| <LoaderCircleIcon className="size-4 animate-spin" /> | ||
| </div> | ||
| ))} | ||
| </HStack> |
There was a problem hiding this comment.
Pending uploads should disable send.
uploadingFiles is rendered above, but this predicate doesn't use it. A user can submit typed text while one or more attachments are still uploading, and those pending files won't be part of the turn. Please share the same guard with handleSendMessage() so Enter and the button stay consistent.
Also applies to: 303-311
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@services/platform/app/features/chat/components/chat-input.tsx` around lines
163 - 251, Pending uploads aren't blocking message send: add the same guard used
when rendering attachments to prevent sending while uploads are in progress. In
practice, use the uploadingFiles array (e.g., const isUploading =
uploadingFiles.length > 0) and (1) early-return from handleSendMessage when
isUploading is true, and (2) disable the send button and prevent Enter/submit
hotkey by using the same isUploading predicate in the key/submit handlers.
Reference uploadingFiles and handleSendMessage (and the send button/Enter submit
handler) and apply the same change in the related code path noted (lines
303-311) so both UI and hotkeys are consistent.
| <button | ||
| type="button" | ||
| aria-label={tChat('removeAttachment')} | ||
| onClick={() => removeAttachment(attachment.fileId)} | ||
| className="bg-background absolute top-0.5 right-0.5 flex size-5 items-center justify-center rounded-full opacity-0 transition-opacity group-hover:opacity-100" | ||
| > | ||
| <button | ||
| type="button" | ||
| aria-label={tChat('viewImage')} | ||
| onClick={() => | ||
| attachment.previewUrl && | ||
| setPreviewImage({ | ||
| src: attachment.previewUrl, | ||
| alt: attachment.fileName, | ||
| }) | ||
| } | ||
| className="bg-secondary/20 focus:ring-ring size-full cursor-pointer transition-opacity hover:opacity-90 focus:ring-2 focus:ring-offset-2 focus:outline-none" | ||
| <X className="text-muted-foreground size-3" /> | ||
| </button> |
There was a problem hiding this comment.
Don't make attachment removal hover-only.
Both remove buttons stay at opacity-0 until group-hover, so keyboard users never see them on focus and touch users get no visible remove affordance at all.
Also applies to: 232-239
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@services/platform/app/features/chat/components/chat-input.tsx` around lines
196 - 203, The remove button is hidden via "opacity-0" and only shown on
"group-hover", which makes it inaccessible to keyboard and touch users; update
the button(s) that call removeAttachment(attachment.fileId) (and the duplicate
at lines ~232-239) to include focus and focus-visible (e.g., add
"focus:opacity-100 focus-visible:opacity-100") and/or make the parent use
"group-focus-within:opacity-100" so the button becomes visible on keyboard focus
or when the attachment container is focused, while keeping the hover behavior
(retain "group-hover:opacity-100"); ensure the same changes are applied to both
remove button occurrences referencing removeAttachment.
| <div className="relative"> | ||
| <Textarea | ||
| ref={textareaRef} | ||
| value={value} | ||
| onChange={(e) => handleInputChange(e.target.value)} | ||
| onKeyDown={handleKeyDown} | ||
| onPaste={handlePaste} | ||
| className="text-foreground placeholder:text-muted-foreground relative min-h-[100px] resize-none border-0 bg-transparent px-0 py-0 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0" | ||
| disabled={inputDisabled} | ||
| placeholder="" | ||
| /> | ||
| {value.length === 0 && !inputDisabled && ( | ||
| <Text | ||
| as="div" | ||
| variant="muted" | ||
| className="pointer-events-none absolute top-0 left-0 flex items-center gap-1" | ||
| > | ||
| {defaultPlaceholder} | ||
| <div className="border-muted-foreground/30 text-muted-foreground flex size-4 items-center justify-center rounded border"> | ||
| <EnterKeyIcon className="size-3" /> | ||
| </div> | ||
| {tDialogs('toSend')} | ||
| </Text> | ||
| )} | ||
| {disabled && ( | ||
| <Text | ||
| as="div" | ||
| variant="muted" | ||
| className="pointer-events-none absolute top-0 left-0" | ||
| > | ||
| {tChat('noAgentsAvailable')} | ||
| </Text> | ||
| )} |
There was a problem hiding this comment.
The composer textarea lost its accessible label.
The visible hint is outside the actual <textarea>, while the control itself has placeholder="" and no label/aria-label. That leaves screen readers with an unnamed multiline input.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@services/platform/app/features/chat/components/chat-input.tsx` around lines
254 - 286, The textarea lost an accessible label; add an explicit accessible
name to the Textarea component (referencing the Textarea element that uses
textareaRef and value) by either setting a meaningful aria-label or using
aria-labelledby that points to the visible hint element (which contains
defaultPlaceholder, EnterKeyIcon, and tDialogs('toSend'))—ensure the hint
element has a stable id and update the Textarea props to include that id via
aria-labelledby (or add aria-label directly) so screen readers announce the
multiline input; keep existing visual markup and only add the accessible
attribute.
| const { data: memberContext } = useCurrentMemberContext(organizationId); | ||
|
|
There was a problem hiding this comment.
Prevent default-member suggestion flash while member context is still loading.
role={memberContext?.role} can temporarily render member suggestions before the role query resolves. That creates a brief incorrect suggestion state on first load.
💡 Proposed fix
- const { data: memberContext } = useCurrentMemberContext(organizationId);
+ const { data: memberContext, isLoading: isMemberContextLoading } =
+ useCurrentMemberContext(organizationId);
...
- <WelcomeView
- isPending={isLoading}
+ <WelcomeView
+ isPending={isLoading || isMemberContextLoading}
role={memberContext?.role}
onSuggestionClick={setInputValue}
/>Also applies to: 294-295
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@services/platform/app/features/chat/components/chat-interface.tsx` around
lines 69 - 70, The component uses useCurrentMemberContext to set
role={memberContext?.role}, which can render transient default suggestions while
the query is still resolving; update the ChatInterface render to guard against
unresolved memberContext by checking for the presence of memberContext (or an
isLoading flag from useCurrentMemberContext) before passing role or rendering
the member-suggestion UI (i.e., only pass role when memberContext is defined or
skip rendering the suggestion component), and apply the same guard to the other
occurrence at the second role usage (lines ~294-295) so no incorrect suggestions
flash while loading.
| <div className="relative"> | ||
| <Textarea | ||
| value={inputValue} | ||
| onChange={(e) => onInputChange(e.target.value)} | ||
| onKeyDown={onKeyDown} | ||
| onPaste={onPaste} | ||
| className="text-foreground placeholder:text-muted-foreground relative min-h-[100px] resize-none border-0 bg-transparent px-0 py-0 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0" | ||
| disabled={isBusy} | ||
| placeholder="" | ||
| /> | ||
| {inputValue.length === 0 && !isBusy && ( | ||
| <Text | ||
| as="div" | ||
| variant="muted" | ||
| className="pointer-events-none absolute top-0 left-0 flex items-center gap-1" | ||
| > | ||
| <ArrowUp className="size-4" /> | ||
| </Button> | ||
| </HStack> | ||
| {t('customAgents.testChat.messagePlaceholder')} | ||
| <div className="border-muted-foreground/30 text-muted-foreground flex size-4 items-center justify-center rounded border"> | ||
| <EnterKeyIcon className="size-3" /> | ||
| </div> | ||
| {tDialogs('toSend')} | ||
| </Text> | ||
| )} |
There was a problem hiding this comment.
Give the test composer an accessible name.
The visual placeholder now sits outside the native <textarea>, but the textarea itself has placeholder="" and no label/aria-label. Screen readers will encounter an unnamed input here.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@services/platform/app/features/custom-agents/components/test-chat-panel/test-chat-input.tsx`
around lines 153 - 175, The Textarea is currently unlabeled (placeholder=""), so
add an accessible name: give the Textarea an aria-label or aria-labelledby prop
(e.g., aria-label={t('customAgents.testChat.inputLabel')} or aria-labelledby
pointing to the visual placeholder element) in test-chat-input.tsx to ensure
screen readers can identify the input; if using aria-labelledby, add a stable id
to the visual placeholder Text element and mark the visual-only elements
appropriately (aria-hidden where needed) so only the intended label is
announced.
| const imageAttachments = attachments?.filter((a) => | ||
| a.fileType.startsWith('image/'), | ||
| ); | ||
|
|
||
| if (!imageAttachments?.length) { | ||
| return { promptContent: undefined, contextExceedsBudget }; | ||
| } | ||
|
|
||
| // Use base64 data URLs — storage URLs are internal and inaccessible to | ||
| // external vision APIs (OpenRouter, etc.), and Uint8Array can't cross the | ||
| // Convex action serialization boundary. Data URLs embed directly. | ||
| const resolvedImageParts = ( | ||
| await Promise.all( | ||
| imageAttachments.map(async (a) => { | ||
| const blob = await ctx.storage.get(a.fileId); | ||
| if (!blob) return null; | ||
| const mimeType = a.fileType || 'image/png'; | ||
| const base64 = Buffer.from(await blob.arrayBuffer()).toString( | ||
| 'base64', | ||
| ); | ||
| return { | ||
| type: 'image' as const, | ||
| image: `data:${mimeType};base64,${base64}`, | ||
| }; | ||
| }), | ||
| ) | ||
| ).filter((p): p is NonNullable<typeof p> => p !== null); |
There was a problem hiding this comment.
Bound image payload size and avoid fail-all behavior in image resolution.
Line 609 currently processes all images with Promise.all and no size/count guard. Large uploads can spike memory/serialization limits, and one rejected image read can fail the entire generation.
🔧 Suggested hardening patch
+ const MAX_IMAGE_ATTACHMENTS = 4;
+ const MAX_TOTAL_IMAGE_BYTES = 8 * 1024 * 1024; // tune per model/provider limits
+
- const imageAttachments = attachments?.filter((a) =>
- a.fileType.startsWith('image/'),
- );
+ const imageAttachments = (attachments ?? [])
+ .filter((a) => a.fileType.startsWith('image/'))
+ .slice(0, MAX_IMAGE_ATTACHMENTS);
+
+ const totalImageBytes = imageAttachments.reduce(
+ (sum, a) => sum + a.fileSize,
+ 0,
+ );
+ if (totalImageBytes > MAX_TOTAL_IMAGE_BYTES) {
+ beforeGenerateDebugLog('Image payload too large for inline data URLs', {
+ threadId,
+ totalImageBytes,
+ maxBytes: MAX_TOTAL_IMAGE_BYTES,
+ });
+ return { promptContent: undefined, contextExceedsBudget: true };
+ }
- const resolvedImageParts = (
- await Promise.all(
+ const resolvedImageParts = (
+ await Promise.allSettled(
imageAttachments.map(async (a) => {
const blob = await ctx.storage.get(a.fileId);
if (!blob) return null;
const mimeType = a.fileType || 'image/png';
const base64 = Buffer.from(await blob.arrayBuffer()).toString(
'base64',
);
return {
type: 'image' as const,
image: `data:${mimeType};base64,${base64}`,
};
}),
)
- ).filter((p): p is NonNullable<typeof p> => p !== null);
+ )
+ .flatMap((r) => (r.status === 'fulfilled' && r.value ? [r.value] : []));🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@services/platform/convex/lib/agent_chat/internal_actions.ts` around lines 597
- 623, The image resolution block (imageAttachments -> resolvedImageParts) must
guard against large payloads and single-image failures: cap the number of images
(e.g., maxImages = 4) and process at most that many from imageAttachments, check
each blob size/arrayBuffer length against a MAX_BYTES threshold before base64
conversion, and wrap each per-image read/encode in try/catch so a single failure
returns null for that image instead of rejecting the whole Promise.all; replace
the Promise.all approach with a bounded/concurrent or sequential loop that
applies these checks and collects successful entries into resolvedImageParts,
and set/propagate contextExceedsBudget (or another flag) when images are skipped
due to size limits.
Summary
beforeGenerateHookso vision models (OpenRouter etc.) receive actual image data rather than markdown text links. Storage URLs are internal and inaccessible to external APIs.WelcomeView; clicking a suggestion populates the chat inputincremental-markdownto prevent false positive code block rendering on indented contentjustify-end→justify-center)SectionHeaderwithPageSectioncomponentTest plan
🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Improvements