Skip to content

feat(platform): vision image attachments, welcome suggestions, and chat fixes#812

Merged
Israeltheminer merged 5 commits into
mainfrom
feat/chat-ux-and-vision-improvements
Mar 18, 2026
Merged

feat(platform): vision image attachments, welcome suggestions, and chat fixes#812
Israeltheminer merged 5 commits into
mainfrom
feat/chat-ux-and-vision-improvements

Conversation

@Israeltheminer
Copy link
Copy Markdown
Collaborator

@Israeltheminer Israeltheminer commented Mar 18, 2026

Summary

  • Vision image attachments: Pass uploaded images as base64 data URLs to beforeGenerateHook so vision models (OpenRouter etc.) receive actual image data rather than markdown text links. Storage URLs are internal and inaccessible to external APIs.
  • Cancel generation fix: Handle early stop when the latest assistant message is from a previous successful turn — previously only handled the case where no assistant message existed yet
  • Welcome view suggestions: Role-based suggestion chips (admin/editor/developer/member) in WelcomeView; clicking a suggestion populates the chat input
  • Markdown fix: Disable indented code block parsing in incremental-markdown to prevent false positive code block rendering on indented content
  • Chat interface alignment: Fix welcome view vertical alignment (justify-endjustify-center)
  • Tools page: Replace SectionHeader with PageSection component

Test plan

  • Upload an image and ask a question — vision model receives the image correctly
  • Stop generation mid-stream on a follow-up message — shows "Generation stopped", not stuck loading
  • New chat welcome screen shows role-appropriate suggestions; clicking one fills the input
  • Indented text in chat no longer renders as a code block
  • Tools page layout renders correctly with PageSection

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • JSON code blocks now render as interactive viewers
    • Image attachments supported in AI prompts
    • Role-based welcome screen with contextual suggestions
  • Improvements

    • Refined chat input layout with cleaner attachment handling
    • Code blocks display language labels with dedicated copy button
    • Enhanced search dialog sizing and item spacing
    • Better visual feedback during file uploads
    • Improved markdown rendering with indented code block handling
    • Cleaner DOM structure across chat components

…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
Copy link
Copy Markdown

@greptile-apps greptile-apps Bot left a comment

Choose a reason for hiding this comment

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

Your free trial has ended. If you'd like to continue receiving code reviews, you can add a payment method here.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Mar 18, 2026

📝 Walkthrough

Walkthrough

This 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)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 7.69% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title accurately captures the three main features: vision image attachments, welcome suggestions, and chat fixes. It directly reflects the primary changes across the changeset.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/chat-ux-and-vision-improvements
📝 Coding Plan
  • Generate coding plan for human review comments

Comment @coderabbitai help to get the list of available commands and usage tips.

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.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

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 | 🟠 Major

Add role="list" to both <ul> elements for accessibility compliance.

Lines 174 and 188 have <ul> elements without the required role="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 | 🟠 Major

Gate the entire upload surface with one boolean.

Right now the hidden input requires fileUploadEnabled && fileAccept, the toolbar button only checks fileUploadEnabled, 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

📥 Commits

Reviewing files that changed from the base of the PR and between b8ce578 and 1004b88.

📒 Files selected for processing (16)
  • services/platform/app/features/automations/components/automation-assistant/chat-input.tsx
  • services/platform/app/features/chat/components/chat-input.tsx
  • services/platform/app/features/chat/components/chat-interface.tsx
  • services/platform/app/features/chat/components/chat-search-dialog.tsx
  • services/platform/app/features/chat/components/incremental-markdown.tsx
  • services/platform/app/features/chat/components/message-bubble/code-block.tsx
  • services/platform/app/features/chat/components/message-bubble/markdown-renderer.tsx
  • services/platform/app/features/chat/components/welcome-view.tsx
  • services/platform/app/features/conversations/components/message-editor.tsx
  • services/platform/app/features/custom-agents/components/test-chat-panel/test-chat-input.tsx
  • services/platform/app/routes/dashboard/$id/chat/$threadId.tsx
  • services/platform/app/routes/dashboard/$id/chat/index.tsx
  • services/platform/app/routes/dashboard/$id/custom-agents/$agentId/tools.tsx
  • services/platform/convex/lib/agent_chat/internal_actions.ts
  • services/platform/convex/threads/cancel_generation.test.ts
  • services/platform/convex/threads/cancel_generation.ts

Comment on lines +83 to +142
{(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>
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

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.

Comment on lines +145 to +167
<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>
)}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

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.

Comment on lines +163 to +251
{(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>
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

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.

Comment on lines +196 to +203
<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>
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

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.

Comment on lines +254 to 286
<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>
)}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

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.

Comment on lines +69 to +70
const { data: memberContext } = useCurrentMemberContext(organizationId);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

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.

Comment on lines +153 to +175
<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>
)}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

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.

Comment on lines +597 to +623
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);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

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.

@Israeltheminer Israeltheminer merged commit 8739d9c into main Mar 18, 2026
8 checks passed
@Israeltheminer Israeltheminer deleted the feat/chat-ux-and-vision-improvements branch March 18, 2026 18:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant