Repository navigation
Conversation
kalvinnchau
reviewed
Nov 4, 2026
kalvinnchau
left a comment
Contributor
There was a problem hiding this comment.
🤖 Two edit-restoration lifecycle defects need correction; details inline.
kazemek21
reviewed
Nov 4, 2026
kazemek21
left a comment
Contributor
There was a problem hiding this comment.
Three non-blocking composer concerns, detailed inline.
Posted by Codex (gpt-6.1-sol) on behalf of kylez.
matt2e
force-pushed
the
paste-text-and-image
branch
from
November 4, 2026 23:48
2bf7938 to
4ad9ab8
Compare
kalvinnchau
reviewed
Nov 5, 2026
kalvinnchau
left a comment
Contributor
There was a problem hiding this comment.
🤖 One reproduced paste/edit lifecycle defect and one non-blocking maintainability finding. The five earlier findings are addressed at this head; required CI remains red after a cancelled JavaScript job.
matt2e
force-pushed
the
paste-text-and-image
branch
2 times, most recently
from
November 5, 2026 05:09
84b381a to
4bd49e7
Compare
Excel, Numbers, Word and Keynote put text, HTML and a PNG picture of the selection on the clipboard. Pasting that into any composer attached the picture and dropped the text without saying so. In a message edit, on a connection without uploads, or in New message, it showed an attach error toast and still inserted no text. Paste and Match Style was swallowed the same way. Two handlers dropped the text: the composer's paste capture attached any file item, and the editor's own paste handler refused any paste with a file item. Both now ask one rule, pastedFiles() in composer-clipboard.ts. A paste attaches its files only when its plain text is empty or each line is one of the files' names or an HTTP(S) URL. That keeps a Finder file copy, which carries its name as text, and a browser image copy, which can carry its URL, attaching as before. Any other text pastes as text and nothing is attached. Tests: - MessageComposer: an Office-shaped paste inserts the text with no chip, then a file pasted alone starts the first and only upload. It fails before this change, and also with only the composer capture fixed. - EditableInput: Paste and Match Style inserts the text from the same shape (fails before). The file-plus-own-name case now supplies a real file and still inserts nothing. - composer-clipboard: a table of clipboard shapes for pastedFiles(). - Paste fixtures in MessageComposer and attachment-panes tests gain getData, as a real DataTransfer has. Docs: channels.md and buzz-help.md describe text copied with a picture pasting as text, and a picture or file on its own attaching. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
Choosing Edit message, rewriting without saving, then switching
channel, closing the thread or reloading discarded the rewrite with no
warning. A plain unsent draft survives all three. The edit lived only
in component state, and saveDraft never wrote edit text.
The composer now keeps an unsaved edit under its own view key,
edit:<channel> or edit:<channel>:thread:<root>, as { id, base, draft }.
base is the message text the edit opened from. The draft: key is never
written, so the new-message draft keeps its documented invariant and
Inbox Drafts does not list the edit. Cancel, Escape, x and delivery
clear it, and so does submitting Save: the outbox then owns the edit,
a failed one included.
Choosing Edit on that message resumes the kept text. A composer that
mounts with a kept edit reopens it, without moving focus, once the
message is among its rows. It stops waiting when the rows load without
it, when the new-message draft changes first, or when the composer
reports an error for that draft, as for a recovered send. A quiet
resume therefore never takes over a draft in use. A deleted or
uneditable message opens nothing and reports nothing. The resume
starts in a commit after the mount: StrictMode replays mount effects,
which rebuild the editor from the draft prop and dropped its reset.
useMessageEdit.start takes that base, and save compares the current
message with it. A message changed elsewhere in the meantime gets the
existing "This message changed while you were editing" error instead
of being overwritten. save now returns true once the outbox owns the
edit.
Tests (MessageComposer):
- A channel switch reopens the rewrite without taking focus, outside
the draft key and Drafts; Cancel ends it. Fails before.
- Rows that load after the composer mounts reopen it (the reload path).
- Rows loaded without the message, or a new draft first, end the wait;
choosing Edit still resumes the kept text.
- A message changed elsewhere reopens with the rewrite, and Save
refuses it without publishing.
- Save clears the record; after a failed save, returning opens no
editor.
- A thread edit is kept under the thread key and reopens when its
replies load.
- The archived Copy text edit case now expects Unarchive to reopen the
edit, and Cancel to return the saved draft.
All of these fail without the production change. The existing
recovered-send remount test covers the error rule and fails without
it. Mutations of the base comparison and of the quiet focus rule are
each caught.
Docs: relay-queries.md, buzz-help.md and channels.md describe the kept
edit, and Unarchive reopening it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
pastedFiles() let a paste attach its picture when every line of its plain text was a URL, so a browser's Copy Image kept attaching. An Excel cell or Word paragraph that holds only a link has the same plain text, so pasting one attached the PNG picture and dropped the link. The URL lines now count as an image's address only when the clipboard's text/html has no text of its own: empty, or just an <img> as a browser copies it. Office HTML carries the link as text, so the paste inserts the link and attaches nothing. A file's own name still attaches whatever the HTML says. Tests (composer-clipboard pastedFiles table): - A link-only Excel cell (URL, table HTML and a PNG) attaches nothing. Fails before this change. - A browser Copy Image (<img> HTML, URL and a PNG) attaches. - A picture with its URL and no HTML attaches (the existing row, renamed). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
Two composers on one edit key both reopened the kept edit. An image
review in a thread mounts its own composer for that thread while the
thread panel stays open underneath. Opening an image after rewriting a
reply opened a second editor the user never chose. Saving in one then
left the other on a stale base, so its Save failed with "This message
changed while you were editing".
A composer now records the edit it has open in a window-wide map. It
adds the entry when an edit starts and removes it on close or unmount.
A quiet resume skips an edit that another composer in this window has
open. Choosing Edit still opens it.
The media review composer no longer reopens a kept edit unasked
(resumeEdit={false}), so an image review never opens an editor you did
not choose there. Choosing Edit there still resumes the kept text.
That composer also had no load signal, so it waited, subscribed, as
long as the review stayed open. That wait is gone too.
A thread composer gets its rows and load signal as props, so while it
waits it no longer subscribes to the channel window, which kept the
window from being trimmed.
Tests:
- MessageEdit integration: a reply rewritten in the real ThreadPanel
is not reopened by the real MediaReviewViewer, and Up there resumes
it.
- MessageComposer: a second composer on the same thread leaves an edit
open in the first closed. After Cancel in one composer and unmount
of another, the next composer reopens it.
- MessageComposer: a thread composer waiting for its replies reopens
the edit without calling subscribeWindow.
All three fail before this change. Each fails alone when its piece is
removed: the review opt-out, the open-edit check, the thread
subscription rule, the close cleanup or the unmount cleanup.
Docs: buzz-help.md and relay-queries.md say a media review does not
reopen a kept edit, and that a composer leaves one open elsewhere.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
pastedFiles() read text/plain and text/html on every paste that had
text, with or without files. The composer's paste capture and
EditableInput each call it, and EditableInput then reads text/html
again itself. In WKWebView each pasteboard read is a synchronous IPC,
and HTML copied from another app can be sanitized or converted from
RTF on each read, so a large Word or web-page paste repeated that work.
pastedFiles() now returns at once when the paste has no files, and
reads text/html only after every remaining line has passed the URL
test. What a paste attaches is unchanged.
Tests (composer-clipboard):
- A link pasted alone and Excel text with a picture both paste as text
without calling getData("text/html"). Both fail before this change.
The link row fails alone without the early return, and the Excel row
fails alone when the HTML is read eagerly again.
- The pastedFiles table shares the clipboard fixture with that test.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
…hosen Choosing Edit in one composer could open an edit that another composer in this window already had open. Rewrite a reply in the thread panel, open an image in that thread and press Up in the review's comment box: the reply resumed in a second editor. Text added there, then the review closed, left the panel's editor on the older text. Its next keystroke wrote that text back over the kept record, and its Save published it, so the review's text was lost without a warning. Saving in the review instead left the panel on a stale base. Each openEdits entry now carries the message id and a handOver callback. Choosing Edit on a message another composer has open closes that composer's editor, returning it to its pre-edit draft without taking focus, and opens the edit here from the kept record, which holds its latest text. A composer clears the record on close only while its openEdits entry is still listed, so a handed-over edit keeps it. When the other composer has a different message's edit open on the same key, taking it over would close that editor and let the next keystroke here replace its text in the record. Choosing Edit there reports "Finish or cancel your current edit first." instead, as within one composer. Tests: - MessageEdit integration: a reply rewritten in the real ThreadPanel, then Edit chosen in the real MediaReviewViewer, resumes there and returns the panel to its draft without focusing it. After text is added in the review and the review closes, the panel shows no editor, and Edit there resumes the review's text. - MessageComposer: Edit of another message on a key whose edit is open in another composer is refused, and that edit stays open. Both fail before this change. The integration test also fails alone without the focus reset, or when handOver clears the record. The unit test fails alone without the message id check. Docs: buzz-help.md and relay-queries.md describe the hand-over and the refusal. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
Copying rich text that holds a picture from TextEdit, Notes, Pages or Mail puts RTFD on the pasteboard, with no image type and no file URL. WebKit, which the macOS app runs in, hands the page no file item for it. The picture is an <img> in text/html whose src is a blob: URL of the page, and a fetch of that URL returns the image. The plain text has a U+FFFC OBJECT REPLACEMENT CHARACTER line where the picture was. So the paste inserted the text with a literal "" and attached nothing. Chromium leaves an empty paragraph in the HTML instead, so there the picture cannot be recovered and only the text can paste. When pastedFiles() finds nothing to attach, the composer's paste capture now asks pastedInlineImages() for the blob: and data:image/ pictures in the paste's HTML. It reads text/html once, and parses it only when one of those schemes appears. Each picture is named from its alt when that is a file name, and duplicates are dropped. http(s) images are a copied page's own and are never fetched. The capture does not prevent the paste, so the editor still pastes the text, and fetchPastedImages() then reads the pictures in order. Unnamed ones become "Pasted image.<ext>", numbered from the second. One that fails to read, or is not an image, is left out. The files go through attachFiles() as the composer is when they arrive (a useEffectEvent), and are dropped if it has unmounted. While a message edit is open, attachFiles() reports "Finish editing before attaching new files." and the text still pastes. pastedFiles() and its Office rule are unchanged. EditableInput drops U+FFFC from pasted plain text, both on the normal path and on Paste and Match Style. A line holding only the placeholder goes with its line break; elsewhere only the character goes. A rich paste with a link and a picture now also keeps the link, because its plain text matches the HTML's text again. Tests: - composer-clipboard: blob: and data: pictures are collected with their alt names, once each, with one text/html read. http(s), data: non-images, HTML without pictures, no HTML and no clipboard collect none. fetchPastedImages keeps order, names from alt, numbers fallbacks, and skips a failed fetch and a non-image blob. - MessageComposer: the WebKit TextEdit shape pastes its text without "" and, once the held fetch resolves, uploads Attachment.png. The same paste during an edit pastes the text and shows the "Finish editing" notice without uploading. A picture that arrives after unmount uploads nothing. All three fail before this change, and the unmount test also fails without the mounted check alone. - EditableInput: placeholder lines and inline placeholders are dropped on both paste paths (fail before). Docs: channels.md and buzz-help.md say rich text with pictures pastes its text and, in the macOS app, attaches the pictures. In browsers such as Chrome only the text pastes, and the placeholder is dropped. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
…L once, and allow the fetch in the app f383516 attached the pictures a rich paste carries in its HTML. A review found three ways it fell short. The release app refused the fetch. The bundled CSP's connect-src was 'self' plus ipc:, GitHub's API, wss: and buzz-media:. 'self' does not match blob:, and WebKit applies connect-src to fetch() of a URL in another scheme: under this policy a fetch of a data:image/png URL is refused with "Refused to connect ... connect-src", and blob: is held to the same directive. Tauri dev builds load the Vite server without this header, so nothing showed it. In a release build, a TextEdit paste would drop its U+FFFC placeholder, then fetchPastedImages() would reject and leave the picture out without a notice. connect-src now lists blob: after 'self'. A blob: URL belongs to the origin that made it, so this admits only this page's own blobs, here the ones WebKit mints for a pasted RTFD's or web archive's pictures. data: stays out. tauri.conf.json is JSON, so this rationale lives here. data:image/ pictures attached from ordinary web copies. Pages put inline data: images in their HTML: 1x1 GIF lazy-load placeholders, favicons, search result thumbnails. Chrome keeps them in copied HTML and WebKit's paste sanitizer leaves them alone. Copying an article paragraph could attach "Pasted image.gif", or, in a composer without uploads, show "Uploads are unavailable on this connection.", the text-paste error 50ae9cf removed. pastedInlineImages() now collects only blob: pictures, the measured RTFD and web archive case. Every paste with HTML read text/html twice, once in the composer's paste capture and again in the editor's paste handler. That brought back the duplicate pasteboard round trip 9249eeb removed, and in WKWebView each read of an RTFD or web archive flavour can mint a new set of blob: URLs. pastedHtml() reads it once per DataTransfer, through a WeakMap, for pastedFiles(), pastedInlineImages() and EditableInput. pastedFiles() still reads it last, and only for a URL-only paste. An unnamed picture of a type outside png, jpeg, gif, webp and tiff was named .png whatever its type. Its extension now comes from the subtype, without a leading x-: image/svg+xml is .svg and image/x-icon is .icon. jpeg stays .jpg, and an empty subtype falls back to .png. Tests: - composer-clipboard: blob: pictures are collected once each, beside a data: image that is not. A Chrome page copy with a lazy-load placeholder, and an inline favicon, collect none. A TextEdit copy and a link-only Excel cell with a picture read text/html once across the composer's and the editor's calls. fetchPastedImages() names x-icon and svg+xml pictures from their subtypes. - MessageComposer: the WebKit TextEdit paste reads text/html once through the real capture and editor. Each fails with its piece reverted: the data: rule, the WeakMap, the editor's own read, or the .png fallback. The CSP change is reasoned about and unit-tested only. It has not been confirmed in a release app from tauri build. Docs: buzz-help.md and channels.md already say rich text with pictures attaches them in the macOS app and pastes only its text in browsers such as Chrome. Neither mentions data:, and both now hold. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
…der attach RTF and RTFD attachments leave U+FFFC OBJECT REPLACEMENT CHARACTER in a paste's plain text beside a copied picture. pastedFiles() counted a line of only that character as text, because trim() keeps it and it is neither a file name nor a URL, while EditableInput deletes the same line as a placeholder. A paste with a file item (image.png), text/plain "" and HTML missing or without a blob: picture therefore did nothing: pastedFiles() and pastedInlineImages() both returned nothing, and the editor inserted "". origin/main attached image.png. pastedFiles() now drops placeholder-only lines as it drops file-name lines, so that paste attaches its file. Text beside a placeholder still pastes as text. An unnamed pasted picture's extension also came from its subtype alone, so image/x-icon was .icon and image/vnd.microsoft.icon (what macOS reports for .ico) and image/vnd.adobe.photoshop were .vnd. Known types now come from a small map (jpeg is jpg, x-icon and vnd.microsoft.icon are ico); otherwise a plain subtype, without x- or +xml, is used, and anything else is png. Tests (composer-clipboard): - A picture with "", and with "\n\n", attaches it; text with a placeholder line and a picture attaches nothing. - fetchPastedImages() names x-icon and vnd.microsoft.icon pictures .ico and a vnd.adobe.photoshop picture .png. The placeholder rows and the naming test fail before this change. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
…ed after a new draft Two review findings on the kept-edit resume wait. A channel composer ended the wait as soon as its window was ready. A head restored after a reload is ready with freshness "cached", so when the saved head predated the message (one received live, then edited and left unsaved), the composer decided the message was missing before the revalidated head arrived. The edit stayed closed until Edit was chosen. Absence now counts only once the window is ready and not cached; a cached head that holds the message still reopens it at once. A thread composer has no such gap: a thread view becomes ready only after a relay read. Typing a new message while the edit waited ended only that mount's wait. The kept record stayed as it was, so leaving and returning reopened the old edit over the new draft. A new-message draft written while a kept edit is closed now stores resume: false in its record, spread from the raw stored value, and a mount only waits for a record without it. Choosing Edit resumes the kept text as before and sets resume: true again, so an edit chosen and left open reopens on return. A draft written while another composer in this window has the edit open, as the thread under a review, leaves the record alone: an edit open somewhere when the user leaves still reopens by itself. Tests (MessageComposer): - A cached head without the message, then a verified head with it, reopens the edit. - A new draft typed while the edit waits keeps the record apart from resume: false, and a later return keeps the new draft. Edit then resumes the rewrite, and the next return reopens it. - A new message started in a second composer while the first has the edit open leaves it reopening by itself after both unmount. All fail before this change. Each also fails alone without its piece: the freshness check, the saveDraft write, the mount gate, the write on Edit or the open-elsewhere check. The harness window gains a freshness field. Docs: buzz-help.md says a new message started before the edit reopens keeps it closed. relay-queries.md also says a restored channel head without the message waits for the revalidated head, and that the new-message draft keeps it closed only while no composer has it open. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
…hen another message is edited Two review findings on the kept-edit record. saveDraft ignored writeView()'s result for the edit record. With storage full or unavailable, a rewrite looked kept while the record still held older text. A channel switch or reload then reopened that older text as the rewrite, and choosing Edit in another composer took the edit over from the record: the newer text was dropped when the first editor closed. keepEdit() now writes the record and, when that fails, clears it, so no remount or other composer takes older text for the latest. The composer shows "Could not save this edit on this device. Your changes are kept here." with Retry edit save, as a draft that cannot be saved does. Cancel, Save and a successful retry end the notice. While it shows, handOver() refuses, and choosing Edit elsewhere reports "Finish or cancel your current edit first." Choosing Edit on message B replaced a kept rewrite of message A on the same key once no composer had A open, as in a media review, which does not reopen A by itself, or a channel where a new message kept A closed. B's first keystroke overwrote A's record and Cancel cleared it. A plain refusal is not enough, because a deleted message leaves the rows, so its rewrite would block every edit on that key. Now: - If A is among the composer's editable rows, Edit on B opens A's rewrite instead, with "Finish or cancel your current edit first." - If not, it reports "Your unsaved edit of another message can't be opened here. Choose Edit again to discard it." Choosing Edit again while that shows clears A's record and opens B. Explicit Edit on A still resumes it. A quiet resume now opens only a kept edit of its own message. A composer waiting for A's row no longer opens a fresh, unasked edit of A after the record is cleared or replaced while it waits. That can happen after a failed write, a discard, or a Cancel in another composer. Tests (MessageComposer): - A failed edit write shows the notice, keeps the text, clears the record, and a return reopens nothing. Retry still failing keeps the notice. Cancel and Save each end it. - A thread edit whose write failed is not taken over by a review composer. After a successful retry, Edit there resumes the latest text. - A kept reply rewrite opens in place of a newer reply chosen in a review composer. After Cancel, Edit opens the newer reply. - A kept rewrite whose message is not in the review's rows is refused once, and discarded on the next Edit. - A composer waiting for a reply does not open it after another composer cancels its kept edit. All five fail before this change. Each also fails alone without its piece: clearing the record, the hand-over refusal, the redirect, the discard confirmation, the quiet guard, or the notice reset on Cancel or on Save. Docs: buzz-help.md and relay-queries.md describe Edit on another message and an edit that cannot be saved on the device. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
A rich paste from TextEdit, Notes, Pages or Mail inserts its text at once, and its pictures attach only after fetchPastedImages() has read their blob: URLs. Send was available in between. Enter right after the paste sent the text alone, then the picture landed in the empty follow-up draft, so one paste became two messages. The composer now counts its picture reads in flight and treats any as it treats a failed file: attachments.blocked and the count together disable Send and stop send(). Each read releases its count after its pictures reach attachFiles(), whether it found pictures, found none or failed to read, so Send returns with the pictures already attached. The count is the composer's own state, so an unmount ends it. An edit's Save is not held, as it is not for a failed file: the pictures cannot attach to an edit, and attachFiles() still reports "Finish editing before attaching new files." Tests (MessageComposer): - Enter during the read sends nothing and Send is disabled. Once the picture is read, one message goes out with the text and the picture. - A read that finds no image, and one that fails, release Send without uploading. - During an edit, Save stays enabled before and after the read. The first two fail before this change. Each also fails alone without its piece: the release, the send() guard or the disabled Send. The edit test fails when the hold also covers Save. Docs: buzz-help.md and channels.md say Send waits for the pictures. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
…oser waits A composer that mounts with a kept edit reads its resume mark once, at mount, then waits for the message's row. The quiet start that follows checked only that the kept record was still this message's. A resume: false written while it waited was ignored. A thread panel with a kept reply edit, waiting for its replies on a slow or offline load, shows this. Opening an image in that thread and typing a comment in the review composer, which shares the thread's edit key, marks the record resume: false, because no composer has the edit open. When the replies loaded, the panel opened the edit over the comment it shares and set the mark back to true. relay-queries.md says such a draft keeps the edit closed until Edit is chosen. A quiet start now also returns when the kept record's resume mark is false. Choosing Edit is not quiet and still resumes the kept text. Tests (MessageComposer): - A thread composer waiting for its replies, after a review composer on the same thread starts a comment, keeps the comment and opens no editor when the replies load. Up from an empty composer then resumes the rewrite. Fails before this change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
A rich paste from TextEdit, Notes, Pages or Mail reads its blob: pictures after the text is inserted. A paste made in a message edit started that read too. If Save or Cancel closed the edit before the read finished, the pictures were checked against the composer as it was then. They attached to the separate new-message draft and started uploading. Until the read finished, that draft's Send was also held by the picture count. An edit can never take attachments, so the composer's paste capture no longer reads pictures pasted into one. It shows "Finish editing before attaching new files." at once, as attachFiles() would after the read, and the editor still pastes the text. With no read started, nothing can arrive once the edit closes, and Send is not held. Save never waited for these pictures and still does not. The reverse order is unchanged. A paste in the new-message draft whose read finishes while an edit is open is refused with the same notice. If the edit closes first, the pictures attach to that draft, which holds their text. Tests (MessageComposer): - Save and Cancel each close an edit holding a gated TextEdit paste. The new-message draft keeps its text and Send stays enabled. Once the gate is released, nothing uploads and no chip appears. Both fail before this change: Send is held, and, with that check skipped, the release starts one upload and shows an Attachment.png chip. - The existing edit paste test now expects the notice without a fetch. Docs: channels.md and buzz-help.md say a message edit pastes only the text of rich text with pictures. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
…card target A review of PR 2414 found the kept-edit state machine spread across MessageComposer: the edit:<view> record, the window-wide open-edit registry, the resume wait and its resume: false mark, the failed-storage hand-over refusal, and the two-step discard. The discard confirmation was armed by comparing the displayed error with the notice's copy. useKeptEdit() in use-kept-edit.ts now owns all of it. The composer supplies its rows, how to reopen one quietly and how to close its editor, and calls: - open(row, editableRow, quiet, discard): takes an edit over from another composer, opens a kept rewrite of another message in place of the one chosen, or names the kept rewrite that cannot open here - keep() while editing, closed() when the editor closes, saved() once the outbox owns the edit, drafted() when a new-message draft is written - storageFailed, which shows the notice with Retry edit save The hook is called where the resume wait was, so its effects and storage writes run in the same order as before. Copy is unchanged. The composer's error state is now a report that can name a discard target, a message id: setError() still takes a plain message, which replaces the report and any target it named. Choosing Edit again discards the kept rewrite only when the report on screen names it. A send or any other report clears the confirmation as it did before. One edge now differs. If the kept record changes to another message's rewrite while the notice shows, as from another window, choosing Edit again shows the notice for that rewrite and keeps it. Before, it discarded whatever the record held at the time. Tests: - use-kept-edit: with no target named, open() keeps asking. A target naming a replaced record asks about the new rewrite and keeps it. Naming the current one discards it and opens the chosen message. Fails when open() discards on any named target. - MessageComposer: after a send clears the notice, Edit asks again and keeps the rewrite. Passes before this change too. It fails when the discard target outlives its notice. No existing test changed. MessageComposer, MessageEdit integration, ThreadPanel and MediaReviewViewer comments pass, 688 tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
matt2e
force-pushed
the
paste-text-and-image
branch
from
November 5, 2026 06:55
4bd49e7 to
33299c2
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two composer text-loss bugs, plus the rich-text paste case that fell out of reviewing the first:
blob:<img>inside the pasted HTML. Both leave a U+FFFC placeholder line in the plain text, which pasted as an invisible "". Now the text pastes clean and, in the macOS app, the picture attaches. Browsers still get only the text — they never receive the picture.Before / after
Left:
main. Right: this branch. Same scripted steps on both, aligned per step. Cases in order: spreadsheet range with picture → link-only cell → screenshot alone → TextEdit rich text with a picture → edit kept across a channel switch, a thread close/reopen and a reload.all-cases-before-after.mp4
What changed
Paste (
composer-clipboard.ts,MessageComposer.tsx,EditableInput.tsx)pastedFiles()attaches a paste's files only when its text is empty, the files' own names, placeholder characters, or bare URLs beside HTML with no text, such as an<img>alone (so a link-only Office cell is text). Safari's and Chrome's Copy Image carry no text at all and attach by the empty-text rule; no measured source uses the URL rule. The HTML is read last and only when needed.pastedInlineImages()/fetchPastedImages(): when there are no file items,blob:images in the pasted HTML are fetched after the text has pasted and attached as files, named from theiralt.data:/https:images are never attached (copied web pages are full of favicons and lazy-load placeholders). Pictures arriving after the composer unmounted are dropped.pastedHtml()memoizestext/htmlperDataTransfer, so a paste reads it once across the composer capture and the editor.src-tauri/tauri.conf.json:blob:added toconnect-src. Without it the release app's CSP refusesfetch(blob:)(confirmed in WebKit and Chromium under the exact policy) and the picture would be dropped silently. Blob URLs are origin-bound, so this admits only the page's own blobs; no other directive changed. Reviewer with Tauri/security context: please look at this line, and ideally paste a TextEdit note with a picture into atauri buildapp — dev builds don't send the header, so I could not exercise it end-to-end.Edit (
MessageComposer.tsx,useMessageEdit.ts,MediaReviewViewer.tsx){ id, base, draft }underedit:<channel>[:thread:<root>]. On remount it waits for the message to load (one render after mount, to survive StrictMode's effect replay), then reopens it with the kept text and base. The wait ends if the message isn't in the loaded rows, a new draft starts, or an error occurs.useMessageEditkeeps the base text that Save compares against.resumeEdit={false}).Docs:
docs/channels.md,docs/buzz-help.md,docs/relay-queries.md.What the pasteboard carries
Measured on macOS 27.2 with 13 real copies. Each was pasted with the native Paste action into the system WKWebView (WebKit 22625.2.8.11.9, the macOS app's engine) and into Playwright Chromium 153. This branch's
composer-clipboard.ts(2bf793864) ran on each engine's realDataTransfer: the composer capture, then the editor path, with realblob:fetches. That covers the paste decision only, not the full app (no ProseMirror, no upload).Types on the pasteboard (item 0), in the source's order
public.file-url, plain textchart.png,com.apple.icnspublic.file-url; item 0 also has the names joined by\ras plain textpublic.png(Preview adds a private info type)public.tiff,com.apple.flat-rtfd,com.apple.webarchive,public.html(<img>)public.png,public.html(<img>), Chromium-private typesflat-rtfd,public.rtf, plain text with alineiWork.*,flat-rtfd,webarchive,rtf, plain text,html,public.png(0 bytes),com.adobe.pdfiWork.*,rtf, plain textiWork.*, a canvas-object type,public.heifiWork.*,flat-rtfd,rtf, plain text; the picture stays iniWork.*datawebarchive,flat-rtfd,rtf,html, plain textNeither browser's Copy Image carries its URL or any plain text. Keynote never copies text together with a picture: its text copies have no picture, and its object copies have no text.
The order never reaches the page. The same PNG and text, written image-first and then text-first, gave identical
types,items,filesandgetDataresults in both engines. WebKit always listsFilesfirst; only its string types follow pasteboard order. Chromium's order is fixed.navigator.clipboard.read()is fixed in Chromium too, and WKWebView refuses it inside a paste handler. SopastedFiles()judges from content, not rank. It can misjudge only an image-first item whose text is neither the file names nor a URL, and none of the 13 copies has that shape.What each source does on this branch
and attaches theblob:pictureimage.pngisn't attachedtypesis["Files"], but no file arrives: WebKit can't read HEIFWherever the page receives something, the branch picks what the source's first usable type points to. The Keynote row is an engine limit that knowing the type order wouldn't fix.
Open
text/plainwhen the pasteboard holds files, doesn't fire for an image plus text: Numbers and synthetic image-plus-text pastes kept their text in WKWebView. If Excel or Word also writes a file URL or a file promise, the text would be blanked and the picture would attach.maintoo. Fixing that needs a native HEIF read, which is a separate change.Method, byte sizes, WebKit source references, and a possible native read of the order (a custom Paste menu item, not recommended yet) are in the author's pasteboard research note (Staged
note:37cd2236).Verification
tauri.conf.jsonpass.Attachment.pngattached and uploaded.fetch(blob:)refused under the old policy in both engines, allowed withblob:inconnect-src.data:over-trigger, placeholder-only file paste).Known gaps
cid:images (e.g. a signature logo in a quoted email) will attach in the app.Still to check by hand (native app)
Excel/Numbers/Word paste; ⌃⇧⌘4 screenshot; Safari Copy Image; TextEdit/Notes/Pages/Mail rich text with a picture in a release build; rewrite a message then switch channel / close the thread / reload.
🤖 Generated with Claude Code