Skip to content

fix(chat): keep pictures when editing a queued message - #398

Merged
omegent-app[bot] merged 3 commits into
fork/devfrom
fix/queued-edit-keeps-attachments
Aug 12, 2026
Merged

fix(chat): keep pictures when editing a queued message#398
omegent-app[bot] merged 3 commits into
fork/devfrom
fix/queued-edit-keeps-attachments

Conversation

@omegent-app

@omegent-app omegent-app Bot commented Aug 12, 2026

Copy link
Copy Markdown

Queue a message with a picture, then edit it: the picture was gone.

Editing removes the queued message and puts it back in the composer — but the recall carried only text, and the composer handle had no way to take anything else. Since a user removal also prunes the message's attachment files server-side (ProjectionPipelinethread.queued-message-removed), the picture was gone from disk, not just from the screen.

The fork already did this correctly in one of three places, which is what made the intent unambiguous:

path text attachments
mobile, local outbox
mobile, server-queued
web

The local outbox works because its attachments already hold their bytes. A server-queued attachment carries only {id, name, mimeType, sizeBytes}, so the two broken paths now read the bytes back through the attachment's signed asset URL before the removal, and rebuild what each composer's drafts actually hold — a File + object URL on web, a data URL on mobile.

Nothing is removed that cannot be restored

Two adversarial review rounds (grok-4.5, gpt-5.6-sol) found the first pass still lost pictures three ways. Everything that could fail now fails before the removal:

  • Signed URLs resolve asynchronously. Editing before they landed, or through a network blip, restored nothing and removed the message anyway — the reported bug under a race. The edit now backs out and leaves the message queued.
  • The 8-image limit was ignored, so an edit could leave a draft that no longer sends. An edit that would not fit is refused up front rather than restoring some pictures and dropping the rest.
  • Recalled content followed the live composer, so switching threads mid-fetch moved it to the wrong draft — and after the second round, split text from pictures. Both now go to the draft target captured at click time; the composer handle is called only while it still belongs to that thread, since all it adds is input history and cursor placement.

Known, deliberate

  • An attachment the server genuinely no longer has makes that queued message uneditable — it can still be sent as-is. Refusing the edit is the safe direction, and the message says so instead of promising a retry will help.
  • Capacity is checked at click time; images added during the fetch can still put the draft over the limit. No picture is lost, and the user can remove one.
  • addImages dedupes on name + mimeType + sizeBytes, so recalling a picture the draft already holds is a no-op.

Verification

pnpm typecheck (18 packages) · pnpm test (2763 passed; the pre-existing CodexTextGeneration launch-args failure is unrelated) · vp build in apps/web · vp check --fix. 29 new tests cover the rebuild, the per-attachment URL fetch, partial failure, and the capacity guard on both platforms.

Co-authored-by: Patrick Roza 42661+patroza@users.noreply.github.com

🤖 Generated with Claude Code via T3 Chat on Discord

omegent-app Bot and others added 3 commits August 12, 2026 20:08
Editing a queued message removed it and then recalled only its text, so
any attached picture was lost — the queued entry was already gone, and the
composer had no way to carry the image back.

A server-queued attachment carries only `{id, name, mimeType, sizeBytes}`;
the bytes live on the server. So the attachments are now read back through
their signed asset URLs before the removal — before, because the removal is
what makes the edit destructive. Web rebuilds a `File` + object URL, mobile
a data URL, matching what each composer's drafts already hold. Mobile's
local outbox path already did this and is unchanged.

An attachment that cannot be read back is reported rather than dropped
silently, and the message's text still comes back for editing.

Co-authored-by: Patrick Roza <42661+patroza@users.noreply.github.com>
… back

Adversarial review found the first pass still lost pictures in three ways.
Removing a queued message prunes its attachment files server-side
(ProjectionPipeline "thread.queued-message-removed"), so anything that goes
wrong before the composer has the bytes is unrecoverable.

- Signed asset URLs resolve asynchronously. Editing before they land — or
  through a network blip — restored nothing but removed the message anyway,
  which is the reported bug under a race. The edit now backs out and leaves
  the message queued, so it can be retried.
- The composer's 8-image limit was ignored, so an edit could leave a draft
  that no longer sends. The edit is now refused up front when the pictures
  would not fit, rather than restoring some and dropping the rest.
- On web the recalled images went to whichever composer was current after
  the awaits, so switching threads mid-fetch moved them to the wrong draft.
  They are written to the draft target captured before the awaits instead,
  matching the retry path; this also drops the ChatComposer handle change.

Co-authored-by: Patrick Roza <42661+patroza@users.noreply.github.com>
Second review round: pinning the recalled pictures to the thread captured at
click time left the text going through the live composer handle, so switching
threads mid-edit split a message in two — pictures on the original thread,
text on whichever thread was open by then, with the queued copy already gone.
An unmounted composer dropped the text outright.

Text and pictures now both go to the captured draft target. The composer
handle is still called, but only while it belongs to that thread, since all
it adds is input history and cursor placement.

The failure message no longer promises that retrying will help: an attachment
the server no longer has will never come back, and the message says so.

Co-authored-by: Patrick Roza <42661+patroza@users.noreply.github.com>
@omegent-app
omegent-app Bot merged commit 24cb14f into fork/dev Aug 12, 2026
8 of 9 checks passed
@omegent-app
omegent-app Bot deleted the fix/queued-edit-keeps-attachments branch August 12, 2026 20:48
omegent-app Bot added a commit that referenced this pull request Aug 15, 2026
#401 was squash-merged. That kept the code and threw the lineage away:
the 23 upstream commits stopped being ancestors, so `fork/dev` read as
**29 commits behind upstream when only 6 were genuinely outstanding**,
and the next sync would have re-merged and re-resolved all 23 — on the
same mobile files that took sixteen conflicts to land the first time.

Nobody noticed for two merges. It surfaced in a deploy alert that said
**"Commits (2)"** for a range that had carried 23.

## What this adds

A `push`-triggered check on `fork/dev` that fails when a commit which
carried a sync has fewer than two parents, and prints the `-s ours`
repair in the log.

Sync commits are identified by **the head branch of the PR they came
from**, not by their subject, because subjects vary by merge method:

```
Merge pull request #400 from patroza/sync/upstream-2026-08-12b
Merge upstream/main into fork/dev (23 commits) (#401)
merge: sync upstream through b73232b
```

A merge-button commit names the branch inline, so no API call is needed;
squash and rebase commits are resolved through the API, with the subject
line as a fallback when that is unavailable. **Ordinary fork PRs are
untouched** — they are expected to squash, and are never checked.

## Verified against the real commits

| commit | what it is | result |
|---|---|---|
| `5e63531b1` | #401, squash-merged sync | **fails**, exit 1 |
| `0bf7835cc` | #400, sync merged properly | recognised as a sync,
passes (2 parents) |
| `a76069bd9` | #402, an ordinary squashed PR | not flagged |

The third row is the one that matters most: the guard has to stay silent
on your normal workflow.

## This detects, it does not prevent

Worth being explicit, since it was the first question asked: **clicking
merge does not fail.** The check runs after the merge lands, because
GitHub has no per-PR merge-method control, and a repository-wide setting
cannot allow squash for ordinary fork PRs while requiring a merge commit
for syncs. Squash merges do fire `push` — `5e63531b1` triggered Fork CI
at 08:08 — so this turns a silent, weeks-later discovery into a red
check within a minute.

Actual prevention is `gh pr merge <n> --merge`, which is how #398, #399
and #400 all landed correctly. That is now written into the sync runbook
in `AGENTS.md`.

If you would rather it be enforced at the button, the next step is a
`sync:upstream` label workflow that merges the PR through the API once
checks pass — say the word and I will add it.

Co-authored-by: Patrick Roza <42661+patroza@users.noreply.github.com>

🤖 Generated with [Claude Code](https://claude.com/claude-code) via [T3
Chat](https://t3.chat) on Discord

---------

Co-authored-by: omegent-app[bot] <306514130+omegent-app[bot]@users.noreply.github.com>
Co-authored-by: Patrick Roza <42661+patroza@users.noreply.github.com>
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.

0 participants