Skip to content

Attach files to Research AI chat messages - #1143

Merged
koutst merged 13 commits into
mainfrom
claude/chat-file-uploads
Oct 7, 2026
Merged

koutst merged 13 commits into
mainfrom
claude/chat-file-uploads

Conversation

@koutst

@koutst koutst commented Oct 6, 2026 •

Copy link
Copy Markdown
Member

Summary

Lets users attach uploaded files to Research AI chat messages, in the notebook's assistant panel and in the assistant chat (AI Mode). Both surfaces get it through the code they already share: the composer, the transcript, useAgentChat and the chat transport.

Depends on ResearchHub/researchhub-backend#4229 (merged) and ResearchHub/researchhub-backend#4231 (merged). Reads one optional field, pages_without_text, from ResearchHub/researchhub-backend#4235 (merged) and works without it. Images need ResearchHub/researchhub-backend#4241 (merged on Oct 6) deployed: until it is, the server refuses an image with unsupported_file_type. Uses ResearchHub/researchhub-backend#4242 (merged: the upload limits, the list of unsent files, the code for an empty file) and ResearchHub/researchhub-backend#4243 (merged: a message of files alone), and works without them: see Before the backend deploy under Notes.

  • Client and types: AgentFileService covers the upload flow: create, send the bytes straight to the bucket's presigned form (with progress), complete, read status, delete, and fetch a download URL. Both sendMessage calls take file_ids, and messages carry attachments.
  • Composer: a paperclip button, drag and drop onto the whole chat pane, and paste. Each file is a chip showing its state: uploading with a progress bar, processing, ready, or failed with the server's reason. A chip can be removed at any point, which also deletes the file on the server. Send stays disabled until every chip is ready. With at least one file ready, Send works without text.
  • Checks before upload: type, size, empty files, files per message and files per chat are checked on the client with the server's own wording, against the limits and file types the server publishes at GET files/limits/ (25 MB, 5 files a message and 20 a chat by default). Server refusals are still shown as given: the detail of a refused upload or message, the 503 when uploads are not configured, and a throttled upload with how long to wait.
  • Files without text: a message can be sent with files alone. Send is enabled once there is at least one file and every file is ready. The message shows its files with no text bubble, on the pending echo and once recorded. The server names an untitled chat from the file names.
  • Unsent files elsewhere: when an upload is refused because too many files are waiting to be sent (too_many_unsent_files), the composer reads GET files/ and says how many wait in other chats or tabs, with an action to remove them. Nothing is deleted without that click. See Files the page has no record of under Notes.
  • Transcript: a message shows its files above the bubble with name, kind and page count for a PDF. Clicking one opens it in a new tab through the download endpoint.
  • Paste: pasting files into the message box attaches them through the same path as the picker and drop, with the same checks and refusals. See the rule under Notes.
  • Images: PNG, JPEG, GIF and WebP (.png, .jpg, .jpeg, .gif, .webp) are accepted through the same flow and limits as documents, from the picker, a drop or a paste. The chip and the sent message show an image's kind alone ("PNG image"): it has no page count and no scan note. HEIC, TIFF, BMP and SVG are refused, as on the server. A file that is not really an image, or is too large to decode, fails with the server's reason like any unreadable file. No thumbnail is shown; an image opens in a new tab like any other file.
  • Scanned PDFs: when the file view carries pages_without_text, the chip and the sent message say so: "No readable text found", or how many pages have none. This is a note about the file. It does not depend on the chat's model, and it neither warns nor blocks sending.
  • Activity feed: unchanged. The three attachment rows ("Read an attached file", "Searched attached files", "Looked at pages of an attached file") render from the label and detail the backend sends, with the generic tool icon.
  • Filename links (was Stop linking bare filenames in chat answers #1144): an assistant answer that names a file, such as notes.md, no longer renders the name as a link. The chat renderer (components/AgentChat/MarkdownMessage.tsx) linked any schemeless name.tld whose ending is a known domain, and that list has every two-letter country code, so notes.md, analysis.py, run.sh, main.rs and todo.org all became links. Schemeless text is now linked only when it starts with www.. Still linked: anything with a scheme, www. links, explicit markdown links, <https://…> autolinks and email addresses.

Notes

  • Unsent files belong to a chat. They are kept per user, surface (assistant, or one note) and chat, like the composer's draft, so switching chats neither loses nor moves them. Files attached on the new-chat screen move to the chat created for the first message.
  • Unlike the draft, they survive a reload. The ids of unsent files are kept in sessionStorage and re-read from the server on load. The server's list of unsent files is per user, not per chat or tab, so it cannot say which chat a file was attached in; sessionStorage stays the record of that. sessionStorage rather than localStorage so two tabs do not act on each other's uploads.
  • Files the page has no record of. A closed tab's uploads, and another tab's, hold some of the 20 unsent slots until they expire after 24 hours. The page never removes a listed file by itself: it may be another tab's upload in flight. Only when an upload is refused for want of a slot does the composer read the list and offer to remove what waits elsewhere: every unsent file that is not in the open composer and is not going out with a message, in any status but FAILED, which holds no slot. That includes files waiting in this tab's other chats, which leave their composers. After the click the refused uploads start again by themselves. If the removal failed they are refused again and the offer returns. The list is read only at that moment, never on load.
  • Uploads and polling run in a module-level store, not in a component, so an upload finishes in the chat it was started in whichever chat is on screen, and survives closing the AI Mode overlay.
  • Polling starts at 1s and backs off to 5s, then 15s after two minutes. The client sets no timeout of its own: the server fails a stuck file when its status is read.
  • A refused message returns its files to the composer with the draft. If the refusal was about a file, every chip is re-read from the server, so the one that expired or was sent elsewhere is marked or dropped.
  • Limits come from the API. GET files/limits/ is read once per page load and drives the checks before upload, the picker's accepted types, the numbers and the extension list in the refusal sentences, and the kind shown on a chip, so a deployment override or a new file type needs no client change. The backend's defaults (DEFAULT_AGENT_FILE_LIMITS in types/agentFile.ts) apply until the response arrives and whenever it cannot be read. Two phrases stay fixed in the client, as the first is on the server: the lead-in of the unsupported-type sentence ("a PDF, Word (.docx), text, or image file") and the paperclip's tooltip. A new family of file types would need both reworded.
  • Before the backend deploy. A merge here reaches production at once, whatever the backend is running, so the client works without the new routes. They went live on staging and prod on Oct 7; this is what a server without them gets. A successful limits read is taken as the sign that the server has both List unsent chat files, publish the upload limits and refuse an empty file with a code researchhub-backend#4242 and Accept a chat message of files alone researchhub-backend#4243: the first was merged on top of the second. Without it the default limits apply; Send still needs text, so a message of files alone is never sent to a server that would refuse it; and GET files/ is never called, so a refused upload reads as the server worded it, with no offer. The list is held back on purpose: on the older backend files/ takes POST only and its upload throttle covers the whole view, so a GET there would spend one of the user's 60 uploads an hour before being answered with a 405. A 404 from the limits route is not asked again until a reload. Any other failure is asked again when the composer moves to another chat.
  • The bucket's size refusal (EntityTooLarge) now reads "This file is too large to upload." It quoted the 25 MB constant. The checks before it use the published limit, so it is reached only when a file's bytes exceed the size it declared.
  • The bucket's CORS rule allows POST from the web origins, which the upload needs: it is an XHR with progress events, and browsers preflight those. Preflights sent on Oct 6 pass on prod for researchhub.com and www.researchhub.com, and on staging for staging.researchhub.com and experiment.staging.researchhub.com (each with and without www.) and for localhost:3000.
  • The paste rule. A paste attaches files only when it carries nothing but files. If the clipboard has any text of its own (plain, HTML or RTF), the paste is left to the browser untouched and its files are ignored, so text copied from Word or a web page pastes as text and the image that rides along is never attached. The one text that still counts as a file paste is the copied files' own names, which file managers put on the clipboard beside the files (bare, without the extension, or as a path or file: URL); there the files attach and the name is not typed into the box. A pasted screenshot is an image alone, so it is attached. HTML counts as text only when it holds some: a browser's "Copy image" puts a bare <img> tag on the clipboard beside the image, and that image is attached too.
  • Images before the backend deploy. The client's own check passes an image, since the default file types include those of Attach images to Research AI chat messages researchhub-backend#4241. A server without that change then refuses the upload, and the chip shows the server's sentence, which is the old one that lists no image types.
  • Opening a file opens a blank tab inside the click and points it at the URL once fetched, because a tab opened after the request is blocked as a pop-up.
  • Bare domains no longer link either. example.com or researchhub.com/paper/1 written without a scheme or www. stay plain text, since they cannot be told apart from filenames by shape (todo.org against nature.org).
  • Scope of the link change: MarkdownMessage is the only user of markdown-it in the app. It renders assistant answers and the narration, thinking and tool-draft text in the activity feed, on both chat surfaces. Nothing outside the agent chat uses it.
  • No dependency added.

Test plan

  • npm run type-check
  • npm run lint (0 errors; the 2 warnings are in files this PR does not touch and are on main)
  • prettier --check on the changed files
  • npm run build
  • Exercised in headless Chrome against a local stub of the API and the bucket, on both surfaces: upload progress, processing and ready states; the form sent to the bucket (fields first, file last, no API token); client refusals; send gating and file_ids on the request; attachments on the pending and the recorded message; the three activity rows; opening a file and a refused download; a failed file and removing it by keyboard; removing mid-upload; drag and drop on the pane; files staying with their chat across a switch, a note change and a reload; 503, 429 and bucket refusals; a message refused over an expired file; the scanned-PDF note staying the same on a text-only model; the notebook panel docked and as a phone sheet
  • Paste, on both surfaces, with paste events built in the page: files alone; files with their names as text in the forms file managers use; text alone; Word-style text with HTML, RTF and an image; text with an image and no HTML; a file beside other text; a screenshot. The text cases were checked to reach the browser unprevented with nothing attached and no request made
  • Paste from a real clipboard (a file copied in Finder or Explorer, a selection copied in Word). Not tried: the checks above build the event rather than use the system clipboard
  • Link rendering, in headless Chrome against the stub: notes.md, analysis.py, run.sh, main.rs, lib.cc, todo.org, project.pro and the bare domains example.com, claude.ai, researchhub.com/paper/1 are plain text; https://example.com/notes.md, http://example.org, www.example.com, www.site.de/path, a markdown link, an autolink and an email address are links. The same answer on the unfixed renderer linked all seventeen
  • After combining the two changes: type-check, lint, prettier and build rerun on this branch
  • CI: SonarCloud and Vercel pass. smoke fails on one test, auth.spec.ts:68 (register a new account), which fails the same way on other open PRs
  • An upload against a real backend and private bucket, on experiment.staging: a PDF was uploaded, processed and sent with a message, and the turn rendered its page images
  • Bucket CORS for the web origins: preflights pass for the prod, staging and experiment.staging hosts
  • Screen reader pass
  • Images: type-check, lint (0 errors, the same 2 warnings), prettier and build rerun on this branch
  • Images, in a browser against a local stub of the API and the bucket, in AI Mode: a PNG, a .JPG, a .jpeg, a GIF and a WebP picked together, each sent to the bucket with its image content type and shown as its kind and size; a dropped JPEG; a pasted screenshot (image.png, an image alone); an image file pasted with its own name as text; HEIC, TIFF, BMP and SVG refused with the new sentence and no request made; a text file named fake.png ending as failed with the server's reason and blocking send until removed; a message sent with two images (file_ids on the request, both files on the pending and the recorded message, kind alone); opening a sent image through the download endpoint; an unsent image coming back after a reload; the stub switched to the old table, where the chip shows the server's refusal
  • Paste with HTML, with paste events built in the page: an image beside a bare <img> tag is attached, in the two shapes Chrome writes for "Copy image" on macOS and on Windows (written by hand, not captured from a real copy); an image beside Word-style text, HTML and RTF, beside HTML that holds text, or beside unrelated plain text is not, and the paste reaches the browser unprevented
  • Images not tried: the notebook panel (it shares the composer, the store and the type table; only AI Mode was rerun), a real clipboard (a real screenshot, a real "Copy image", Safari and Firefox), a phone's photo picker, and a backend with Attach images to Research AI chat messages researchhub-backend#4241 deployed
  • Backend sync: type-check, lint (0 errors, the same 2 warnings), prettier and build rerun on this branch
  • Backend sync, in headless Chrome against a local stub of the API and the bucket that answers as backend main. In AI Mode: limits read once and used for the picker's types and size; a stub deployment with 2 MB, 2 files a message, 3 a chat and an extra .pptx type, where the picker, the refusal sentences, the chip's kind and the limits all follow the response; an empty file refused with the server's sentence and no request; files alone sent on a new chat and with Enter on an open one (message: "" and file_ids on the request, no text bubble on the pending or the recorded message, the chat titled from the file name); Send off with nothing to send, with whitespace alone, and with a failed file. The unsent cap, with 18 files seeded as another tab's, one failed file and one file in another chat of the same tab: the refused chip and "19 files are waiting", nothing deleted until the click, the offer withdrawn when the refused file is removed, then exactly those 19 deleted, the refused file uploaded, and the other chat's composer emptied; a failed removal bringing the refusal and the offer back; a failed list leaving the refusal alone; the singular wording. In the notebook panel: files alone on a new chat and on the open one, the picker row of such a chat, and the cap offer in the docked panel
  • The same stub answering as the older backend (limits 404, GET files/ 405, a blank message refused). In AI Mode: the defaults in force, a ready file without text not sendable by button or Enter and no message posted, a message with text sent as before, the cap refusal as the server worded it with no offer, limits asked for once and not again on a chat switch, GET files/ never called. In the notebook panel: the same send gate and the same two request counts. With the limits request failing (500) or malformed, in AI Mode: the defaults in force and no send of files alone; once the request succeeds on the next chat, files alone send
  • Three behaviours broken on purpose (files alone allowed without the limits read, the bubble always drawn, the list read on any backend): the AI Mode checks that cover them failed, and passed again once restored
  • The earlier checks rerun on this branch against the same stub: AI Mode (23), the notebook panel (7) and paste (34) pass, with their expectations brought up to date for the images commits and for the two sentences this change moves
  • Backend sync against the real backend, on experiment.staging. Not tried: it needs a signed-in session. The backend routes went live on staging and prod on Oct 7 between 00:10 and 00:40 UTC: files/limits/ answered 404 without a session and now answers 401, and files/ now allows GET

@vercel

vercel Bot commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
web Ready Ready Preview Oct 7, 2026 12:41am UTC

Request Review

@koutst koutst mentioned this pull request Oct 6, 2026
7 of 8 tasks
@koutst
koutst marked this pull request as ready for review October 6, 2026 19:21
@koutst
koutst requested a review from a team as a code owner October 6, 2026 19:21
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits.
Credits must be used to enable repository wide code reviews.

koutst added 2 commits October 6, 2026 20:00
PNG, JPEG, GIF and WebP join the types the composer takes, mirroring the
backend's table in researchhub-backend#4241. The refusal for any other type
uses the server's new wording.
A browser's "Copy image" puts a bare <img> tag on the clipboard beside the
image. HTML with no text in it no longer makes the paste a text paste, so
the image is attached. HTML that holds text, and any RTF, still leave the
paste to the browser.
koutst added 3 commits October 6, 2026 20:39
GET files/limits/ (researchhub-backend#4242) now drives the checks before
upload, the picker's accepted types, the refusal sentences and the kind
shown on a chip. The backend's defaults stay in force until the response
arrives and on a server without the route. The refusal of an empty file
takes the server's sentence.
With at least one file attached and all of them ready, Send no longer
needs text (researchhub-backend#4243), and a message without text draws no
bubble. Offered only once the server's limits have been read: the two
reached the backend together, and an older server refuses a blank message.
When an upload is refused because too many files are waiting to be sent,
the composer reads GET files/ (researchhub-backend#4242) and offers to
remove those outside the open composer. Nothing is deleted without that
click, and the refused uploads then start by themselves. The list is not
read on a server that has not published its limits: there the route takes
POST only, and a GET would spend the upload throttle.
@sonarqubecloud

sonarqubecloud Bot commented Oct 7, 2026

Copy link
Copy Markdown

@koutst
koutst merged commit 66fae73 into main Oct 7, 2026
4 of 5 checks passed
@koutst
koutst deleted the claude/chat-file-uploads branch October 7, 2026 11:28

This branch was successfully deployed

1 active deployment
Preview — 2a35f719 Deployed Oct 7, 2026 by vercel[bot]
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