feat: images upload the moment you attach them - #6276

Closed
t3dotgg wants to merge 3 commits into
mainfrom
t3code/support-arbitrary-attachments
Closed

feat: images upload the moment you attach them#6276
t3dotgg wants to merge 3 commits into
mainfrom
t3code/support-arbitrary-attachments

Conversation

@t3dotgg

@t3dotggt3dotgg commented Aug 12, 2026

Copy link
Copy Markdown
Member

Attached images used to ride inside the send command as base64 data URLs. Sending a message with a 10MB screenshot meant a 14M-character string in one ws frame: send felt slow, the turn could not start until the frame landed, and the stash had to re-encode every image to squeeze it into localStorage.

Now the bytes move while you are still typing. Attaching an image mints a pending-<uuid> id plus a signed upload URL over ws (the same pattern as signed asset GETs, so it works against any environment), and the browser POSTs the compressed bytes to it with per-chip progress and cancel. The send command carries id references only, and the server renames pending files to their thread at turn start — resolving by uuid, so send retries and existing asset URLs survive the rename.

What this buys:

  • Send is instant; upload overlaps with typing.
  • Drafts with images now survive reloads fully (previews via signed asset URLs). The localStorage re-encode/budget machinery is deleted.
  • The send button blocks while a chip is uploading or failed, so an image can never be silently dropped from a sent message.
  • Never-sent uploads are deleted on chip removal, with a 30-day sweep as backstop. Uploads write .part then rename, so a partial transfer is never a valid attachment, and the route enforces the exact minted byte count.

Breaking, deliberately without a compatibility path: the dataUrl upload variant is deleted. Out-of-date clients get a loud schema error. React Native mobile compiles and degrades honestly behind IMAGE_ATTACH_ENABLED = false ("Image attach needs an app update"); the port is a fast-follow. apps/swift-ios is not on main, so the Swift mirror is untouched — a 7-edit patch list for that branch is in the plan doc.

Design doc: https://rztz3kvilrh0.postplan.dev

Verified: contracts 227, server attachment suites 66 (full suite green except 6 launcher-version failures that reproduce on untouched main), web 2201, mobile 654. Lint and format clean. Not yet done: a manual browser pass (paste several images fast, cancel mid-flight, kill the network, reload with a ready draft) — automated browsers cannot see authenticated views here.

Built by Claude Fable 5 running in Claude Code.

🤖 Generated with Claude Code


Note

High Risk
Breaking attachment contract (no dataUrl), new signed upload HTTP surface and turn-start claim logic, plus draft v9 migration that drops unsent inline images on upgrade.

Overview
Composer images no longer ride in thread.turn.start as base64 data URLs. Web uploads bytes as soon as a chip is added: WS mints a pending-<uuid> id and signed URL, HTTP POST stores the file, and send carries id references only. The normalizer claims pending files into thread scope at turn start (uuid-stable resolution so retries and asset URLs survive renames).

Server additions:AttachmentUpload (token mint/validate, .part then rename, size enforcement), POST /api/attachments/upload/*, RPC attachments.createUploadUrl / attachments.delete, pending attachment sweep on startup, and planAttachmentClaim in the attachment store.

Web composer/drafts: Upload queue with progress, cancel, retry, and environment mismatch handling; send is blocked while uploads are unsettled; draft persistence bumps to v9 (server id + environmentId only—legacy inline dataUrl attachments are dropped on rehydrate). Stash and sidebar discard release pending server bytes.

Mobile: Image attach is disabled behind IMAGE_ATTACH_ENABLED = false; picks/pastes surface “needs an app update,” legacy draft/outbox images are not sent (warnings via droppedAttachmentsWarning), and buildProjectThreadStartTurnInput always sends attachments: [].

Reviewed by Cursor Bugbot for commit 36f6d76. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Upload composer image attachments immediately on attach instead of at send time

  • Images are now uploaded to the server as soon as they are added to the composer, using a background queue (attachmentUploadQueue.ts) with progress reporting, abort capability, and XHR-based upload to signed URLs.
  • Two new WebSocket RPCs (attachments.createUploadUrl and attachments.delete) mint signed upload URLs and delete pending attachments; the server validates tokens, enforces exact size, and persists bytes via a new POST endpoint.
  • ClientThreadTurnStartCommand payloads no longer inline base64 image data URLs; they carry ChatAttachment id references to pre-uploaded blobs. The composer send button is blocked while uploads are pending, failed, or belong to a different environment.
  • Switching environments triggers automatic re-upload of any images that still have a local File; discarding or removing an image aborts in-flight uploads and releases server-side bytes.
  • Composer draft persistence is bumped to v9: only ready (fully uploaded) attachments are persisted as environment-scoped id references; in-flight and failed uploads are not saved. Legacy v8 inline dataUrl entries are dropped on rehydration.
  • Mobile image attach is explicitly disabled via IMAGE_ATTACH_ENABLED = false in composerImages.ts; callers receive a structured error and a warning banner when legacy attachments are dropped.
  • Risk: any clients with persisted v8 composer drafts containing inline image data will silently lose those attachments on rehydration.

Macroscope summarized 36f6d76.

@coderabbitai

coderabbitaiBot commented Aug 12, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: bf950256-a9a4-410e-98ba-2154f6f01183

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@github-actionsgithub-actionsBot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:XXL 1,000+ changed lines (additions + deletions). labels Aug 12, 2026

@macroscopeappmacroscopeappBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Effect service conventions review of the changed server/contracts code. Two findings on error modeling; the new AttachmentUpload module itself follows the repo's service/DI conventions (namespace subpath imports, dependencies acquired via yield* Foo.Foo, no hidden runtimes).

Posted via Macroscope — Effect Service Conventions

Comment threadpackages/contracts/src/assets.ts Outdated
Comment threadapps/server/src/orchestration/Normalizer.ts
Comment threadapps/web/src/components/chat/ChatComposer.tsx
Comment threadapps/server/src/attachmentStore.ts
Comment threadapps/web/src/components/ChatView.tsx
Comment threadapps/web/src/lib/attachmentUploadQueue.ts
Comment threadapps/web/src/components/ChatView.tsx

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Medium

removePreviewAnnotation: (threadRef,annotationId)=>{

removePreviewAnnotation filters the annotation's image out of images but never calls releaseComposerAttachment or cancelAttachmentUpload. Removing a preview annotation while its image is still uploading leaves the upload job running, and removing one after upload completes leaves the server-side pending attachment allocated until the 30-day backstop sweep. The image disappears from the draft's images array, so the upload queue never sees it again to release or cancel it.

Also found in 1 other location(s)

apps/web/src/components/chat/ChatComposer.tsx:1510

The environment-retargeting path calls retryAttachmentUpload for an already-ready image but never releases its existing server attachment. retryAttachmentUpload only cancels an active job (there is none after readiness) and starts a new upload, whereas releaseComposerAttachment is the function that deletes a ready attachment. Switching a draft between environments therefore leaves one orphaned pending upload behind on each retarget until the 30-day sweep.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/web/src/composerDraftStore.ts around line 3253:
`removePreviewAnnotation` filters the annotation's image out of `images` but never calls `releaseComposerAttachment` or `cancelAttachmentUpload`. Removing a preview annotation while its image is still uploading leaves the upload job running, and removing one after upload completes leaves the server-side pending attachment allocated until the 30-day backstop sweep. The image disappears from the draft's `images` array, so the upload queue never sees it again to release or cancel it.
Also found in 1 other location(s):
- apps/web/src/components/chat/ChatComposer.tsx:1510 -- The environment-retargeting path calls `retryAttachmentUpload` for an already-`ready` image but never releases its existing server attachment. `retryAttachmentUpload` only cancels an active job (there is none after readiness) and starts a new upload, whereas `releaseComposerAttachment` is the function that deletes a ready attachment. Switching a draft between environments therefore leaves one orphaned pending upload behind on each retarget until the 30-day sweep.

Comment threadapps/web/src/components/chat/ChatComposer.tsx
Comment threadapps/web/src/components/chat/ChatComposer.tsx
Comment threadapps/web/src/components/ChatView.tsx
Comment threadapps/web/src/composerDraftStore.ts
Comment threadapps/web/src/lib/attachmentUploadQueue.ts
Comment threadapps/mobile/src/features/threads/NewTaskDraftScreen.tsx
@macroscopeapp

macroscopeappBot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Needs human review

2 blocking correctness issues found. This PR introduces substantial new capability (upload-on-attach for images) with new server endpoints, client upload queue, contract changes, and storage schema migrations. The scope exceeds what can be auto-approved, and there is an unresolved High-severity finding about stalled uploads potentially blocking sends indefinitely.

You can customize Macroscope's approvability policy. Learn more.

@github-actions

github-actionsBot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Thread transfer impact

✅ Thread transfer remains within every enforced ceiling.

ProviderMetricMain baselineThis PRImpactPR ceiling
CodexTotal thread wire11.3 KiB11.3 KiB−1 B (−0.0%)15.1 KiB
CodexThread snapshot wire5.5 KiB5.5 KiB+1 B (+0.0%)7.3 KiB
CodexLive turn WebSocket wire5.9 KiB5.9 KiB−2 B (−0.0%)7.8 KiB
CodexLive turn WebSocket decoded49.7 KiB49.7 KiB0 B (0.0%)66.4 KiB
CodexLive turn messages16160 (0.0%)21
ClaudeTotal thread wire11.3 KiB11.3 KiB0 B (0.0%)15.1 KiB
ClaudeThread snapshot wire5.5 KiB5.5 KiB+5 B (+0.1%)7.3 KiB
ClaudeLive turn WebSocket wire5.9 KiB5.9 KiB−5 B (−0.1%)7.8 KiB
ClaudeLive turn WebSocket decoded50.6 KiB50.6 KiB0 B (0.0%)66.4 KiB
ClaudeLive turn messages16160 (0.0%)21

Baseline: 5304f3e · PR result: 36f6d76 · Source CI: success

Scenario and decoded snapshot size

10 historical turns, 5 command tools per turn, 878.9 KiB retained MCP result per historical turn, and a 1.05 MiB retained result in the measured turn.

  • Codex decoded thread snapshot: 94.6 KiB
  • Claude decoded thread snapshot: 95.4 KiB

Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed.

t3dotggand others added 2 commits August 13, 2026 17:51
Attachments used to ride the turn-start command as base64 data URLs:
send carried the bytes, the stash re-encoded them into localStorage,
and a 10MB image meant a 14M-char string in a single ws frame.
Images now upload the moment they are attached. A ws RPC mints a
pending-<uuid> id plus a signed, expiring upload URL (mirroring signed
asset GETs, so it works against any environment); the browser POSTs the
compressed bytes to it with progress and abort; the turn-start command
carries id references only. The Normalizer renames pending files to
their thread segment at send, resolving by uuid so retries after a
partial send are idempotent. Never-sent uploads are deleted on chip
removal and swept after 30 days.
Breaking: the dataUrl upload variant is deleted with no compatibility
path. RN mobile compiles with image attach gated off behind
IMAGE_ATTACH_ENABLED and tells the user an update is needed; drafts and
stash persist id references (v9/v3 storage, old payloads purged).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Real bugs from Macroscope and Bugbot, all verified against source:
- awaitAttachmentUploads consulted only live jobs, so an upload that
settled before the await lost its result and a ready image could drop
from a pick-and-send message. Terminal states are now kept in a
settled map that cancel/release clean up.
- Stashing cancelled in-flight uploads before the localStorage write was
confirmed; a quota failure stranded chips in uploading forever.
Cancellation now happens only after the write lands.
- Retargeting a draft to another environment overwrote a reload-restored
attachment's ready state with a dead failed state. The mismatch is now
derived (never written), so switching back recovers the attachment,
and re-uploads release the old environment's bytes first.
- The turn-start failure path restored chips from the pre-await
snapshot, resurrecting uploading states no job would ever advance.
- The "images were not attached" toast could fire when no message was
sent; it now waits for the turn to actually start.
- Removing a preview annotation or discarding a draft leaked the upload
and its server-side bytes; both now release like chip removal.
- Mobile paste drops now surface the needs-an-update banner instead of
a console.warn.
- Normalizer error mappings keep their PlatformError causes; the dead
unstructured AttachmentUploadRequestError is deleted; the sweep test
no longer reads the real clock (effect-diagnostics CI failure).
Also folds the rebase onto main: the new moveComposerPromptAndImages
draft-carry now merges the unified image list, and in-flight uploads are
retargeted to the destination draft so moved chips keep their progress.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@t3dotgg
t3dotggforce-pushed the t3code/support-arbitrary-attachments branch from 1331956 to 03196efCompareAugust 14, 2026 01:03
Comment threadapps/web/src/components/chat/ChatComposer.tsx Outdated
Comment threadapps/server/src/assets/AttachmentUpload.ts
// moment they were attached. The composer blocks sending while an upload
// is in flight, but the preview "pick and send" gesture attaches and sends
// in one step, so wait for anything still running here.
const settledUploads = await awaitAttachmentUploads(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 Highcomponents/ChatView.tsx:5207

A stalled preview upload leaves the send operation pending indefinitely, so sendInFlightRef stays true and the message is neither sent nor restored. awaitAttachmentUploads waits on an XMLHttpRequest with the default timeout of 0, which never expires when the connection remains open; add an upload timeout or cancellation path before awaiting it.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/web/src/components/ChatView.tsx around line 5207:
A stalled preview upload leaves the send operation pending indefinitely, so `sendInFlightRef` stays `true` and the message is neither sent nor restored. `awaitAttachmentUploads` waits on an `XMLHttpRequest` with the default `timeout` of `0`, which never expires when the connection remains open; add an upload timeout or cancellation path before awaiting it.

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 3 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 03196ef. Configure here.

Comment threadapps/web/src/lib/attachmentUploadQueue.ts
Comment threadapps/web/src/components/ChatView.tsx
Comment threadapps/mobile/src/lib/composerImages.ts Outdated
- Unique per-request .part suffix so concurrent POSTs of one token
cannot interleave into a shared temp file.
- xhr.timeout on uploads: a stalled POST now fails into the retryable
state instead of blocking send (and pick-and-send) forever.
- Cross-environment re-upload keeps the old environment's copy until
the new upload succeeds (supersedes), so a failed re-upload never
destroys the only server copy.
- The plan follow-up branch releases attached images before clearing
the composer instead of orphaning their pending files.
- Mobile: a mixed text+image clipboard still pastes its text while
attach is disabled, and the image drop is surfaced.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@ElliotDrelChatGPT Codex Connector

Copy link
Copy Markdown

Non-blocking architecture follow-up for Prompt Stash: this PR solves the hard attachment half by moving image bytes onto the target T3 environment, but apps/web/src/promptStashStore.ts still makes browser localStorage the authoritative stash store. That means stash metadata still cannot follow the same environment across desktop/web/mobile or another controlling machine, even though the attachment bytes now can.

I think the clean follow-up is to make the target ExecutionEnvironment/server authoritative for stash state, rather than putting canonical stash state in T3 Connect or a thread snapshot:

  • add server-side durable stash metadata keyed to the environment;
  • expose idempotent mutations such as prompt-stash.create, atomic prompt-stash.take, and prompt-stash.delete (plus attachment finalization only if still needed);
  • expose a separate environment-level stash subscription/snapshot, e.g. orchestration.subscribePromptStash, rather than piggybacking on subscribeThread because the stash is intentionally thread/provider agnostic;
  • put the replicated client state in packages/client-runtime so web and mobile consume the same source;
  • reuse the server attachment IDs introduced by this PR, so stash synchronization moves references rather than base64/blob payloads;
  • keep client storage only as an optional pending outbox for offline writes. A normal stash should clear the composer only after the target server acknowledges the durable write.

T3 Connect then remains transport to the execution environment instead of becoming a second state authority.

I would keep this out of this PR because #6276 is already a large/high-risk attachment-contract change. The server-owned stash is a cleaner follow-up PR once this attachment model is stable.

@ElliotDrelChatGPT Codex Connector

Copy link
Copy Markdown

Follow-up tracked in #7626. That issue is intentionally sequenced after this PR and should build on this PR's server-side attachment ID model rather than expand #6276 further.

@t3dotgg

Copy link
Copy Markdown
MemberAuthor

Note

🤖 GPT-5.6 Sol responding on behalf of Theo

Closing in favor of #8048. Image attachments now upload before sending, with compatibility preserved for existing mobile clients and older servers.

@t3dotggt3dotgg closed this Aug 24, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL1,000+ changed lines (additions + deletions).vouch:trustedPR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@t3dotgg@ElliotDrel
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

feat: images upload the moment you attach them - #6276

Closed
t3dotgg wants to merge 3 commits into
mainfrom
t3code/support-arbitrary-attachments
Closed

feat: images upload the moment you attach them#6276
t3dotgg wants to merge 3 commits into
mainfrom
t3code/support-arbitrary-attachments

Conversation

@t3dotgg

@t3dotggt3dotgg commented Aug 12, 2026

Copy link
Copy Markdown
Member

Attached images used to ride inside the send command as base64 data URLs. Sending a message with a 10MB screenshot meant a 14M-character string in one ws frame: send felt slow, the turn could not start until the frame landed, and the stash had to re-encode every image to squeeze it into localStorage.

Now the bytes move while you are still typing. Attaching an image mints a pending-<uuid> id plus a signed upload URL over ws (the same pattern as signed asset GETs, so it works against any environment), and the browser POSTs the compressed bytes to it with per-chip progress and cancel. The send command carries id references only, and the server renames pending files to their thread at turn start — resolving by uuid, so send retries and existing asset URLs survive the rename.

What this buys:

  • Send is instant; upload overlaps with typing.
  • Drafts with images now survive reloads fully (previews via signed asset URLs). The localStorage re-encode/budget machinery is deleted.
  • The send button blocks while a chip is uploading or failed, so an image can never be silently dropped from a sent message.
  • Never-sent uploads are deleted on chip removal, with a 30-day sweep as backstop. Uploads write .part then rename, so a partial transfer is never a valid attachment, and the route enforces the exact minted byte count.

Breaking, deliberately without a compatibility path: the dataUrl upload variant is deleted. Out-of-date clients get a loud schema error. React Native mobile compiles and degrades honestly behind IMAGE_ATTACH_ENABLED = false ("Image attach needs an app update"); the port is a fast-follow. apps/swift-ios is not on main, so the Swift mirror is untouched — a 7-edit patch list for that branch is in the plan doc.

Design doc: https://rztz3kvilrh0.postplan.dev

Verified: contracts 227, server attachment suites 66 (full suite green except 6 launcher-version failures that reproduce on untouched main), web 2201, mobile 654. Lint and format clean. Not yet done: a manual browser pass (paste several images fast, cancel mid-flight, kill the network, reload with a ready draft) — automated browsers cannot see authenticated views here.

Built by Claude Fable 5 running in Claude Code.

🤖 Generated with Claude Code


Note

High Risk
Breaking attachment contract (no dataUrl), new signed upload HTTP surface and turn-start claim logic, plus draft v9 migration that drops unsent inline images on upgrade.

Overview
Composer images no longer ride in thread.turn.start as base64 data URLs. Web uploads bytes as soon as a chip is added: WS mints a pending-<uuid> id and signed URL, HTTP POST stores the file, and send carries id references only. The normalizer claims pending files into thread scope at turn start (uuid-stable resolution so retries and asset URLs survive renames).

Server additions:AttachmentUpload (token mint/validate, .part then rename, size enforcement), POST /api/attachments/upload/*, RPC attachments.createUploadUrl / attachments.delete, pending attachment sweep on startup, and planAttachmentClaim in the attachment store.

Web composer/drafts: Upload queue with progress, cancel, retry, and environment mismatch handling; send is blocked while uploads are unsettled; draft persistence bumps to v9 (server id + environmentId only—legacy inline dataUrl attachments are dropped on rehydrate). Stash and sidebar discard release pending server bytes.

Mobile: Image attach is disabled behind IMAGE_ATTACH_ENABLED = false; picks/pastes surface “needs an app update,” legacy draft/outbox images are not sent (warnings via droppedAttachmentsWarning), and buildProjectThreadStartTurnInput always sends attachments: [].

Reviewed by Cursor Bugbot for commit 36f6d76. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Upload composer image attachments immediately on attach instead of at send time

  • Images are now uploaded to the server as soon as they are added to the composer, using a background queue (attachmentUploadQueue.ts) with progress reporting, abort capability, and XHR-based upload to signed URLs.
  • Two new WebSocket RPCs (attachments.createUploadUrl and attachments.delete) mint signed upload URLs and delete pending attachments; the server validates tokens, enforces exact size, and persists bytes via a new POST endpoint.
  • ClientThreadTurnStartCommand payloads no longer inline base64 image data URLs; they carry ChatAttachment id references to pre-uploaded blobs. The composer send button is blocked while uploads are pending, failed, or belong to a different environment.
  • Switching environments triggers automatic re-upload of any images that still have a local File; discarding or removing an image aborts in-flight uploads and releases server-side bytes.
  • Composer draft persistence is bumped to v9: only ready (fully uploaded) attachments are persisted as environment-scoped id references; in-flight and failed uploads are not saved. Legacy v8 inline dataUrl entries are dropped on rehydration.
  • Mobile image attach is explicitly disabled via IMAGE_ATTACH_ENABLED = false in composerImages.ts; callers receive a structured error and a warning banner when legacy attachments are dropped.
  • Risk: any clients with persisted v8 composer drafts containing inline image data will silently lose those attachments on rehydration.

Macroscope summarized 36f6d76.

@coderabbitai

coderabbitaiBot commented Aug 12, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: bf950256-a9a4-410e-98ba-2154f6f01183

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@github-actionsgithub-actionsBot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:XXL 1,000+ changed lines (additions + deletions). labels Aug 12, 2026

@macroscopeappmacroscopeappBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Effect service conventions review of the changed server/contracts code. Two findings on error modeling; the new AttachmentUpload module itself follows the repo's service/DI conventions (namespace subpath imports, dependencies acquired via yield* Foo.Foo, no hidden runtimes).

Posted via Macroscope — Effect Service Conventions

Comment threadpackages/contracts/src/assets.ts Outdated
Comment threadapps/server/src/orchestration/Normalizer.ts
Comment threadapps/web/src/components/chat/ChatComposer.tsx
Comment threadapps/server/src/attachmentStore.ts
Comment threadapps/web/src/components/ChatView.tsx
Comment threadapps/web/src/lib/attachmentUploadQueue.ts
Comment threadapps/web/src/components/ChatView.tsx

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Medium

removePreviewAnnotation: (threadRef,annotationId)=>{

removePreviewAnnotation filters the annotation's image out of images but never calls releaseComposerAttachment or cancelAttachmentUpload. Removing a preview annotation while its image is still uploading leaves the upload job running, and removing one after upload completes leaves the server-side pending attachment allocated until the 30-day backstop sweep. The image disappears from the draft's images array, so the upload queue never sees it again to release or cancel it.

Also found in 1 other location(s)

apps/web/src/components/chat/ChatComposer.tsx:1510

The environment-retargeting path calls retryAttachmentUpload for an already-ready image but never releases its existing server attachment. retryAttachmentUpload only cancels an active job (there is none after readiness) and starts a new upload, whereas releaseComposerAttachment is the function that deletes a ready attachment. Switching a draft between environments therefore leaves one orphaned pending upload behind on each retarget until the 30-day sweep.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/web/src/composerDraftStore.ts around line 3253:
`removePreviewAnnotation` filters the annotation's image out of `images` but never calls `releaseComposerAttachment` or `cancelAttachmentUpload`. Removing a preview annotation while its image is still uploading leaves the upload job running, and removing one after upload completes leaves the server-side pending attachment allocated until the 30-day backstop sweep. The image disappears from the draft's `images` array, so the upload queue never sees it again to release or cancel it.
Also found in 1 other location(s):
- apps/web/src/components/chat/ChatComposer.tsx:1510 -- The environment-retargeting path calls `retryAttachmentUpload` for an already-`ready` image but never releases its existing server attachment. `retryAttachmentUpload` only cancels an active job (there is none after readiness) and starts a new upload, whereas `releaseComposerAttachment` is the function that deletes a ready attachment. Switching a draft between environments therefore leaves one orphaned pending upload behind on each retarget until the 30-day sweep.

Comment threadapps/web/src/components/chat/ChatComposer.tsx
Comment threadapps/web/src/components/chat/ChatComposer.tsx
Comment threadapps/web/src/components/ChatView.tsx
Comment threadapps/web/src/composerDraftStore.ts
Comment threadapps/web/src/lib/attachmentUploadQueue.ts
Comment threadapps/mobile/src/features/threads/NewTaskDraftScreen.tsx
@macroscopeapp

macroscopeappBot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Needs human review

2 blocking correctness issues found. This PR introduces substantial new capability (upload-on-attach for images) with new server endpoints, client upload queue, contract changes, and storage schema migrations. The scope exceeds what can be auto-approved, and there is an unresolved High-severity finding about stalled uploads potentially blocking sends indefinitely.

You can customize Macroscope's approvability policy. Learn more.

@github-actions

github-actionsBot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Thread transfer impact

✅ Thread transfer remains within every enforced ceiling.

ProviderMetricMain baselineThis PRImpactPR ceiling
CodexTotal thread wire11.3 KiB11.3 KiB−1 B (−0.0%)15.1 KiB
CodexThread snapshot wire5.5 KiB5.5 KiB+1 B (+0.0%)7.3 KiB
CodexLive turn WebSocket wire5.9 KiB5.9 KiB−2 B (−0.0%)7.8 KiB
CodexLive turn WebSocket decoded49.7 KiB49.7 KiB0 B (0.0%)66.4 KiB
CodexLive turn messages16160 (0.0%)21
ClaudeTotal thread wire11.3 KiB11.3 KiB0 B (0.0%)15.1 KiB
ClaudeThread snapshot wire5.5 KiB5.5 KiB+5 B (+0.1%)7.3 KiB
ClaudeLive turn WebSocket wire5.9 KiB5.9 KiB−5 B (−0.1%)7.8 KiB
ClaudeLive turn WebSocket decoded50.6 KiB50.6 KiB0 B (0.0%)66.4 KiB
ClaudeLive turn messages16160 (0.0%)21

Baseline: 5304f3e · PR result: 36f6d76 · Source CI: success

Scenario and decoded snapshot size

10 historical turns, 5 command tools per turn, 878.9 KiB retained MCP result per historical turn, and a 1.05 MiB retained result in the measured turn.

  • Codex decoded thread snapshot: 94.6 KiB
  • Claude decoded thread snapshot: 95.4 KiB

Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed.

t3dotggand others added 2 commits August 13, 2026 17:51
Attachments used to ride the turn-start command as base64 data URLs:
send carried the bytes, the stash re-encoded them into localStorage,
and a 10MB image meant a 14M-char string in a single ws frame.
Images now upload the moment they are attached. A ws RPC mints a
pending-<uuid> id plus a signed, expiring upload URL (mirroring signed
asset GETs, so it works against any environment); the browser POSTs the
compressed bytes to it with progress and abort; the turn-start command
carries id references only. The Normalizer renames pending files to
their thread segment at send, resolving by uuid so retries after a
partial send are idempotent. Never-sent uploads are deleted on chip
removal and swept after 30 days.
Breaking: the dataUrl upload variant is deleted with no compatibility
path. RN mobile compiles with image attach gated off behind
IMAGE_ATTACH_ENABLED and tells the user an update is needed; drafts and
stash persist id references (v9/v3 storage, old payloads purged).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Real bugs from Macroscope and Bugbot, all verified against source:
- awaitAttachmentUploads consulted only live jobs, so an upload that
settled before the await lost its result and a ready image could drop
from a pick-and-send message. Terminal states are now kept in a
settled map that cancel/release clean up.
- Stashing cancelled in-flight uploads before the localStorage write was
confirmed; a quota failure stranded chips in uploading forever.
Cancellation now happens only after the write lands.
- Retargeting a draft to another environment overwrote a reload-restored
attachment's ready state with a dead failed state. The mismatch is now
derived (never written), so switching back recovers the attachment,
and re-uploads release the old environment's bytes first.
- The turn-start failure path restored chips from the pre-await
snapshot, resurrecting uploading states no job would ever advance.
- The "images were not attached" toast could fire when no message was
sent; it now waits for the turn to actually start.
- Removing a preview annotation or discarding a draft leaked the upload
and its server-side bytes; both now release like chip removal.
- Mobile paste drops now surface the needs-an-update banner instead of
a console.warn.
- Normalizer error mappings keep their PlatformError causes; the dead
unstructured AttachmentUploadRequestError is deleted; the sweep test
no longer reads the real clock (effect-diagnostics CI failure).
Also folds the rebase onto main: the new moveComposerPromptAndImages
draft-carry now merges the unified image list, and in-flight uploads are
retargeted to the destination draft so moved chips keep their progress.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@t3dotgg
t3dotggforce-pushed the t3code/support-arbitrary-attachments branch from 1331956 to 03196efCompareAugust 14, 2026 01:03
Comment threadapps/web/src/components/chat/ChatComposer.tsx Outdated
Comment threadapps/server/src/assets/AttachmentUpload.ts
// moment they were attached. The composer blocks sending while an upload
// is in flight, but the preview "pick and send" gesture attaches and sends
// in one step, so wait for anything still running here.
const settledUploads = await awaitAttachmentUploads(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 Highcomponents/ChatView.tsx:5207

A stalled preview upload leaves the send operation pending indefinitely, so sendInFlightRef stays true and the message is neither sent nor restored. awaitAttachmentUploads waits on an XMLHttpRequest with the default timeout of 0, which never expires when the connection remains open; add an upload timeout or cancellation path before awaiting it.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/web/src/components/ChatView.tsx around line 5207:
A stalled preview upload leaves the send operation pending indefinitely, so `sendInFlightRef` stays `true` and the message is neither sent nor restored. `awaitAttachmentUploads` waits on an `XMLHttpRequest` with the default `timeout` of `0`, which never expires when the connection remains open; add an upload timeout or cancellation path before awaiting it.

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 3 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 03196ef. Configure here.

Comment threadapps/web/src/lib/attachmentUploadQueue.ts
Comment threadapps/web/src/components/ChatView.tsx
Comment threadapps/mobile/src/lib/composerImages.ts Outdated
- Unique per-request .part suffix so concurrent POSTs of one token
cannot interleave into a shared temp file.
- xhr.timeout on uploads: a stalled POST now fails into the retryable
state instead of blocking send (and pick-and-send) forever.
- Cross-environment re-upload keeps the old environment's copy until
the new upload succeeds (supersedes), so a failed re-upload never
destroys the only server copy.
- The plan follow-up branch releases attached images before clearing
the composer instead of orphaning their pending files.
- Mobile: a mixed text+image clipboard still pastes its text while
attach is disabled, and the image drop is surfaced.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@ElliotDrelChatGPT Codex Connector

Copy link
Copy Markdown

Non-blocking architecture follow-up for Prompt Stash: this PR solves the hard attachment half by moving image bytes onto the target T3 environment, but apps/web/src/promptStashStore.ts still makes browser localStorage the authoritative stash store. That means stash metadata still cannot follow the same environment across desktop/web/mobile or another controlling machine, even though the attachment bytes now can.

I think the clean follow-up is to make the target ExecutionEnvironment/server authoritative for stash state, rather than putting canonical stash state in T3 Connect or a thread snapshot:

  • add server-side durable stash metadata keyed to the environment;
  • expose idempotent mutations such as prompt-stash.create, atomic prompt-stash.take, and prompt-stash.delete (plus attachment finalization only if still needed);
  • expose a separate environment-level stash subscription/snapshot, e.g. orchestration.subscribePromptStash, rather than piggybacking on subscribeThread because the stash is intentionally thread/provider agnostic;
  • put the replicated client state in packages/client-runtime so web and mobile consume the same source;
  • reuse the server attachment IDs introduced by this PR, so stash synchronization moves references rather than base64/blob payloads;
  • keep client storage only as an optional pending outbox for offline writes. A normal stash should clear the composer only after the target server acknowledges the durable write.

T3 Connect then remains transport to the execution environment instead of becoming a second state authority.

I would keep this out of this PR because #6276 is already a large/high-risk attachment-contract change. The server-owned stash is a cleaner follow-up PR once this attachment model is stable.

@ElliotDrelChatGPT Codex Connector

Copy link
Copy Markdown

Follow-up tracked in #7626. That issue is intentionally sequenced after this PR and should build on this PR's server-side attachment ID model rather than expand #6276 further.

@t3dotgg

Copy link
Copy Markdown
MemberAuthor

Note

🤖 GPT-5.6 Sol responding on behalf of Theo

Closing in favor of #8048. Image attachments now upload before sending, with compatibility preserved for existing mobile clients and older servers.

@t3dotggt3dotgg closed this Aug 24, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL1,000+ changed lines (additions + deletions).vouch:trustedPR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@t3dotgg@ElliotDrel
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat: images upload the moment you attach them - #6276

Closed
t3dotgg wants to merge 3 commits into
mainfrom
t3code/support-arbitrary-attachments
Closed

feat: images upload the moment you attach them#6276
t3dotgg wants to merge 3 commits into
mainfrom
t3code/support-arbitrary-attachments

Conversation

@t3dotgg

@t3dotggt3dotgg commented Aug 12, 2026

Copy link
Copy Markdown
Member

Attached images used to ride inside the send command as base64 data URLs. Sending a message with a 10MB screenshot meant a 14M-character string in one ws frame: send felt slow, the turn could not start until the frame landed, and the stash had to re-encode every image to squeeze it into localStorage.

Now the bytes move while you are still typing. Attaching an image mints a pending-<uuid> id plus a signed upload URL over ws (the same pattern as signed asset GETs, so it works against any environment), and the browser POSTs the compressed bytes to it with per-chip progress and cancel. The send command carries id references only, and the server renames pending files to their thread at turn start — resolving by uuid, so send retries and existing asset URLs survive the rename.

What this buys:

  • Send is instant; upload overlaps with typing.
  • Drafts with images now survive reloads fully (previews via signed asset URLs). The localStorage re-encode/budget machinery is deleted.
  • The send button blocks while a chip is uploading or failed, so an image can never be silently dropped from a sent message.
  • Never-sent uploads are deleted on chip removal, with a 30-day sweep as backstop. Uploads write .part then rename, so a partial transfer is never a valid attachment, and the route enforces the exact minted byte count.

Breaking, deliberately without a compatibility path: the dataUrl upload variant is deleted. Out-of-date clients get a loud schema error. React Native mobile compiles and degrades honestly behind IMAGE_ATTACH_ENABLED = false ("Image attach needs an app update"); the port is a fast-follow. apps/swift-ios is not on main, so the Swift mirror is untouched — a 7-edit patch list for that branch is in the plan doc.

Design doc: https://rztz3kvilrh0.postplan.dev

Verified: contracts 227, server attachment suites 66 (full suite green except 6 launcher-version failures that reproduce on untouched main), web 2201, mobile 654. Lint and format clean. Not yet done: a manual browser pass (paste several images fast, cancel mid-flight, kill the network, reload with a ready draft) — automated browsers cannot see authenticated views here.

Built by Claude Fable 5 running in Claude Code.

🤖 Generated with Claude Code


Note

High Risk
Breaking attachment contract (no dataUrl), new signed upload HTTP surface and turn-start claim logic, plus draft v9 migration that drops unsent inline images on upgrade.

Overview
Composer images no longer ride in thread.turn.start as base64 data URLs. Web uploads bytes as soon as a chip is added: WS mints a pending-<uuid> id and signed URL, HTTP POST stores the file, and send carries id references only. The normalizer claims pending files into thread scope at turn start (uuid-stable resolution so retries and asset URLs survive renames).

Server additions:AttachmentUpload (token mint/validate, .part then rename, size enforcement), POST /api/attachments/upload/*, RPC attachments.createUploadUrl / attachments.delete, pending attachment sweep on startup, and planAttachmentClaim in the attachment store.

Web composer/drafts: Upload queue with progress, cancel, retry, and environment mismatch handling; send is blocked while uploads are unsettled; draft persistence bumps to v9 (server id + environmentId only—legacy inline dataUrl attachments are dropped on rehydrate). Stash and sidebar discard release pending server bytes.

Mobile: Image attach is disabled behind IMAGE_ATTACH_ENABLED = false; picks/pastes surface “needs an app update,” legacy draft/outbox images are not sent (warnings via droppedAttachmentsWarning), and buildProjectThreadStartTurnInput always sends attachments: [].

Reviewed by Cursor Bugbot for commit 36f6d76. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Upload composer image attachments immediately on attach instead of at send time

  • Images are now uploaded to the server as soon as they are added to the composer, using a background queue (attachmentUploadQueue.ts) with progress reporting, abort capability, and XHR-based upload to signed URLs.
  • Two new WebSocket RPCs (attachments.createUploadUrl and attachments.delete) mint signed upload URLs and delete pending attachments; the server validates tokens, enforces exact size, and persists bytes via a new POST endpoint.
  • ClientThreadTurnStartCommand payloads no longer inline base64 image data URLs; they carry ChatAttachment id references to pre-uploaded blobs. The composer send button is blocked while uploads are pending, failed, or belong to a different environment.
  • Switching environments triggers automatic re-upload of any images that still have a local File; discarding or removing an image aborts in-flight uploads and releases server-side bytes.
  • Composer draft persistence is bumped to v9: only ready (fully uploaded) attachments are persisted as environment-scoped id references; in-flight and failed uploads are not saved. Legacy v8 inline dataUrl entries are dropped on rehydration.
  • Mobile image attach is explicitly disabled via IMAGE_ATTACH_ENABLED = false in composerImages.ts; callers receive a structured error and a warning banner when legacy attachments are dropped.
  • Risk: any clients with persisted v8 composer drafts containing inline image data will silently lose those attachments on rehydration.

Macroscope summarized 36f6d76.

@coderabbitai

coderabbitaiBot commented Aug 12, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: bf950256-a9a4-410e-98ba-2154f6f01183

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@github-actionsgithub-actionsBot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:XXL 1,000+ changed lines (additions + deletions). labels Aug 12, 2026

@macroscopeappmacroscopeappBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Effect service conventions review of the changed server/contracts code. Two findings on error modeling; the new AttachmentUpload module itself follows the repo's service/DI conventions (namespace subpath imports, dependencies acquired via yield* Foo.Foo, no hidden runtimes).

Posted via Macroscope — Effect Service Conventions

Comment threadpackages/contracts/src/assets.ts Outdated
Comment threadapps/server/src/orchestration/Normalizer.ts
Comment threadapps/web/src/components/chat/ChatComposer.tsx
Comment threadapps/server/src/attachmentStore.ts
Comment threadapps/web/src/components/ChatView.tsx
Comment threadapps/web/src/lib/attachmentUploadQueue.ts
Comment threadapps/web/src/components/ChatView.tsx

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Medium

removePreviewAnnotation: (threadRef,annotationId)=>{

removePreviewAnnotation filters the annotation's image out of images but never calls releaseComposerAttachment or cancelAttachmentUpload. Removing a preview annotation while its image is still uploading leaves the upload job running, and removing one after upload completes leaves the server-side pending attachment allocated until the 30-day backstop sweep. The image disappears from the draft's images array, so the upload queue never sees it again to release or cancel it.

Also found in 1 other location(s)

apps/web/src/components/chat/ChatComposer.tsx:1510

The environment-retargeting path calls retryAttachmentUpload for an already-ready image but never releases its existing server attachment. retryAttachmentUpload only cancels an active job (there is none after readiness) and starts a new upload, whereas releaseComposerAttachment is the function that deletes a ready attachment. Switching a draft between environments therefore leaves one orphaned pending upload behind on each retarget until the 30-day sweep.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/web/src/composerDraftStore.ts around line 3253:
`removePreviewAnnotation` filters the annotation's image out of `images` but never calls `releaseComposerAttachment` or `cancelAttachmentUpload`. Removing a preview annotation while its image is still uploading leaves the upload job running, and removing one after upload completes leaves the server-side pending attachment allocated until the 30-day backstop sweep. The image disappears from the draft's `images` array, so the upload queue never sees it again to release or cancel it.
Also found in 1 other location(s):
- apps/web/src/components/chat/ChatComposer.tsx:1510 -- The environment-retargeting path calls `retryAttachmentUpload` for an already-`ready` image but never releases its existing server attachment. `retryAttachmentUpload` only cancels an active job (there is none after readiness) and starts a new upload, whereas `releaseComposerAttachment` is the function that deletes a ready attachment. Switching a draft between environments therefore leaves one orphaned pending upload behind on each retarget until the 30-day sweep.

Comment threadapps/web/src/components/chat/ChatComposer.tsx
Comment threadapps/web/src/components/chat/ChatComposer.tsx
Comment threadapps/web/src/components/ChatView.tsx
Comment threadapps/web/src/composerDraftStore.ts
Comment threadapps/web/src/lib/attachmentUploadQueue.ts
Comment threadapps/mobile/src/features/threads/NewTaskDraftScreen.tsx
@macroscopeapp

macroscopeappBot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Needs human review

2 blocking correctness issues found. This PR introduces substantial new capability (upload-on-attach for images) with new server endpoints, client upload queue, contract changes, and storage schema migrations. The scope exceeds what can be auto-approved, and there is an unresolved High-severity finding about stalled uploads potentially blocking sends indefinitely.

You can customize Macroscope's approvability policy. Learn more.

@github-actions

github-actionsBot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Thread transfer impact

✅ Thread transfer remains within every enforced ceiling.

ProviderMetricMain baselineThis PRImpactPR ceiling
CodexTotal thread wire11.3 KiB11.3 KiB−1 B (−0.0%)15.1 KiB
CodexThread snapshot wire5.5 KiB5.5 KiB+1 B (+0.0%)7.3 KiB
CodexLive turn WebSocket wire5.9 KiB5.9 KiB−2 B (−0.0%)7.8 KiB
CodexLive turn WebSocket decoded49.7 KiB49.7 KiB0 B (0.0%)66.4 KiB
CodexLive turn messages16160 (0.0%)21
ClaudeTotal thread wire11.3 KiB11.3 KiB0 B (0.0%)15.1 KiB
ClaudeThread snapshot wire5.5 KiB5.5 KiB+5 B (+0.1%)7.3 KiB
ClaudeLive turn WebSocket wire5.9 KiB5.9 KiB−5 B (−0.1%)7.8 KiB
ClaudeLive turn WebSocket decoded50.6 KiB50.6 KiB0 B (0.0%)66.4 KiB
ClaudeLive turn messages16160 (0.0%)21

Baseline: 5304f3e · PR result: 36f6d76 · Source CI: success

Scenario and decoded snapshot size

10 historical turns, 5 command tools per turn, 878.9 KiB retained MCP result per historical turn, and a 1.05 MiB retained result in the measured turn.

  • Codex decoded thread snapshot: 94.6 KiB
  • Claude decoded thread snapshot: 95.4 KiB

Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed.

t3dotggand others added 2 commits August 13, 2026 17:51
Attachments used to ride the turn-start command as base64 data URLs:
send carried the bytes, the stash re-encoded them into localStorage,
and a 10MB image meant a 14M-char string in a single ws frame.
Images now upload the moment they are attached. A ws RPC mints a
pending-<uuid> id plus a signed, expiring upload URL (mirroring signed
asset GETs, so it works against any environment); the browser POSTs the
compressed bytes to it with progress and abort; the turn-start command
carries id references only. The Normalizer renames pending files to
their thread segment at send, resolving by uuid so retries after a
partial send are idempotent. Never-sent uploads are deleted on chip
removal and swept after 30 days.
Breaking: the dataUrl upload variant is deleted with no compatibility
path. RN mobile compiles with image attach gated off behind
IMAGE_ATTACH_ENABLED and tells the user an update is needed; drafts and
stash persist id references (v9/v3 storage, old payloads purged).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Real bugs from Macroscope and Bugbot, all verified against source:
- awaitAttachmentUploads consulted only live jobs, so an upload that
settled before the await lost its result and a ready image could drop
from a pick-and-send message. Terminal states are now kept in a
settled map that cancel/release clean up.
- Stashing cancelled in-flight uploads before the localStorage write was
confirmed; a quota failure stranded chips in uploading forever.
Cancellation now happens only after the write lands.
- Retargeting a draft to another environment overwrote a reload-restored
attachment's ready state with a dead failed state. The mismatch is now
derived (never written), so switching back recovers the attachment,
and re-uploads release the old environment's bytes first.
- The turn-start failure path restored chips from the pre-await
snapshot, resurrecting uploading states no job would ever advance.
- The "images were not attached" toast could fire when no message was
sent; it now waits for the turn to actually start.
- Removing a preview annotation or discarding a draft leaked the upload
and its server-side bytes; both now release like chip removal.
- Mobile paste drops now surface the needs-an-update banner instead of
a console.warn.
- Normalizer error mappings keep their PlatformError causes; the dead
unstructured AttachmentUploadRequestError is deleted; the sweep test
no longer reads the real clock (effect-diagnostics CI failure).
Also folds the rebase onto main: the new moveComposerPromptAndImages
draft-carry now merges the unified image list, and in-flight uploads are
retargeted to the destination draft so moved chips keep their progress.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@t3dotgg
t3dotggforce-pushed the t3code/support-arbitrary-attachments branch from 1331956 to 03196efCompareAugust 14, 2026 01:03
Comment threadapps/web/src/components/chat/ChatComposer.tsx Outdated
Comment threadapps/server/src/assets/AttachmentUpload.ts
// moment they were attached. The composer blocks sending while an upload
// is in flight, but the preview "pick and send" gesture attaches and sends
// in one step, so wait for anything still running here.
const settledUploads = await awaitAttachmentUploads(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 Highcomponents/ChatView.tsx:5207

A stalled preview upload leaves the send operation pending indefinitely, so sendInFlightRef stays true and the message is neither sent nor restored. awaitAttachmentUploads waits on an XMLHttpRequest with the default timeout of 0, which never expires when the connection remains open; add an upload timeout or cancellation path before awaiting it.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/web/src/components/ChatView.tsx around line 5207:
A stalled preview upload leaves the send operation pending indefinitely, so `sendInFlightRef` stays `true` and the message is neither sent nor restored. `awaitAttachmentUploads` waits on an `XMLHttpRequest` with the default `timeout` of `0`, which never expires when the connection remains open; add an upload timeout or cancellation path before awaiting it.

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 3 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 03196ef. Configure here.

Comment threadapps/web/src/lib/attachmentUploadQueue.ts
Comment threadapps/web/src/components/ChatView.tsx
Comment threadapps/mobile/src/lib/composerImages.ts Outdated
- Unique per-request .part suffix so concurrent POSTs of one token
cannot interleave into a shared temp file.
- xhr.timeout on uploads: a stalled POST now fails into the retryable
state instead of blocking send (and pick-and-send) forever.
- Cross-environment re-upload keeps the old environment's copy until
the new upload succeeds (supersedes), so a failed re-upload never
destroys the only server copy.
- The plan follow-up branch releases attached images before clearing
the composer instead of orphaning their pending files.
- Mobile: a mixed text+image clipboard still pastes its text while
attach is disabled, and the image drop is surfaced.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@ElliotDrelChatGPT Codex Connector

Copy link
Copy Markdown

Non-blocking architecture follow-up for Prompt Stash: this PR solves the hard attachment half by moving image bytes onto the target T3 environment, but apps/web/src/promptStashStore.ts still makes browser localStorage the authoritative stash store. That means stash metadata still cannot follow the same environment across desktop/web/mobile or another controlling machine, even though the attachment bytes now can.

I think the clean follow-up is to make the target ExecutionEnvironment/server authoritative for stash state, rather than putting canonical stash state in T3 Connect or a thread snapshot:

  • add server-side durable stash metadata keyed to the environment;
  • expose idempotent mutations such as prompt-stash.create, atomic prompt-stash.take, and prompt-stash.delete (plus attachment finalization only if still needed);
  • expose a separate environment-level stash subscription/snapshot, e.g. orchestration.subscribePromptStash, rather than piggybacking on subscribeThread because the stash is intentionally thread/provider agnostic;
  • put the replicated client state in packages/client-runtime so web and mobile consume the same source;
  • reuse the server attachment IDs introduced by this PR, so stash synchronization moves references rather than base64/blob payloads;
  • keep client storage only as an optional pending outbox for offline writes. A normal stash should clear the composer only after the target server acknowledges the durable write.

T3 Connect then remains transport to the execution environment instead of becoming a second state authority.

I would keep this out of this PR because #6276 is already a large/high-risk attachment-contract change. The server-owned stash is a cleaner follow-up PR once this attachment model is stable.

@ElliotDrelChatGPT Codex Connector

Copy link
Copy Markdown

Follow-up tracked in #7626. That issue is intentionally sequenced after this PR and should build on this PR's server-side attachment ID model rather than expand #6276 further.

@t3dotgg

Copy link
Copy Markdown
MemberAuthor

Note

🤖 GPT-5.6 Sol responding on behalf of Theo

Closing in favor of #8048. Image attachments now upload before sending, with compatibility preserved for existing mobile clients and older servers.

@t3dotggt3dotgg closed this Aug 24, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL1,000+ changed lines (additions + deletions).vouch:trustedPR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@t3dotgg@ElliotDrel
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat: images upload the moment you attach them - #6276

Closed
t3dotgg wants to merge 3 commits into
mainfrom
t3code/support-arbitrary-attachments
Closed

feat: images upload the moment you attach them#6276
t3dotgg wants to merge 3 commits into
mainfrom
t3code/support-arbitrary-attachments

Conversation

@t3dotgg

@t3dotggt3dotgg commented Aug 12, 2026

Copy link
Copy Markdown
Member

Attached images used to ride inside the send command as base64 data URLs. Sending a message with a 10MB screenshot meant a 14M-character string in one ws frame: send felt slow, the turn could not start until the frame landed, and the stash had to re-encode every image to squeeze it into localStorage.

Now the bytes move while you are still typing. Attaching an image mints a pending-<uuid> id plus a signed upload URL over ws (the same pattern as signed asset GETs, so it works against any environment), and the browser POSTs the compressed bytes to it with per-chip progress and cancel. The send command carries id references only, and the server renames pending files to their thread at turn start — resolving by uuid, so send retries and existing asset URLs survive the rename.

What this buys:

  • Send is instant; upload overlaps with typing.
  • Drafts with images now survive reloads fully (previews via signed asset URLs). The localStorage re-encode/budget machinery is deleted.
  • The send button blocks while a chip is uploading or failed, so an image can never be silently dropped from a sent message.
  • Never-sent uploads are deleted on chip removal, with a 30-day sweep as backstop. Uploads write .part then rename, so a partial transfer is never a valid attachment, and the route enforces the exact minted byte count.

Breaking, deliberately without a compatibility path: the dataUrl upload variant is deleted. Out-of-date clients get a loud schema error. React Native mobile compiles and degrades honestly behind IMAGE_ATTACH_ENABLED = false ("Image attach needs an app update"); the port is a fast-follow. apps/swift-ios is not on main, so the Swift mirror is untouched — a 7-edit patch list for that branch is in the plan doc.

Design doc: https://rztz3kvilrh0.postplan.dev

Verified: contracts 227, server attachment suites 66 (full suite green except 6 launcher-version failures that reproduce on untouched main), web 2201, mobile 654. Lint and format clean. Not yet done: a manual browser pass (paste several images fast, cancel mid-flight, kill the network, reload with a ready draft) — automated browsers cannot see authenticated views here.

Built by Claude Fable 5 running in Claude Code.

🤖 Generated with Claude Code


Note

High Risk
Breaking attachment contract (no dataUrl), new signed upload HTTP surface and turn-start claim logic, plus draft v9 migration that drops unsent inline images on upgrade.

Overview
Composer images no longer ride in thread.turn.start as base64 data URLs. Web uploads bytes as soon as a chip is added: WS mints a pending-<uuid> id and signed URL, HTTP POST stores the file, and send carries id references only. The normalizer claims pending files into thread scope at turn start (uuid-stable resolution so retries and asset URLs survive renames).

Server additions:AttachmentUpload (token mint/validate, .part then rename, size enforcement), POST /api/attachments/upload/*, RPC attachments.createUploadUrl / attachments.delete, pending attachment sweep on startup, and planAttachmentClaim in the attachment store.

Web composer/drafts: Upload queue with progress, cancel, retry, and environment mismatch handling; send is blocked while uploads are unsettled; draft persistence bumps to v9 (server id + environmentId only—legacy inline dataUrl attachments are dropped on rehydrate). Stash and sidebar discard release pending server bytes.

Mobile: Image attach is disabled behind IMAGE_ATTACH_ENABLED = false; picks/pastes surface “needs an app update,” legacy draft/outbox images are not sent (warnings via droppedAttachmentsWarning), and buildProjectThreadStartTurnInput always sends attachments: [].

Reviewed by Cursor Bugbot for commit 36f6d76. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Upload composer image attachments immediately on attach instead of at send time

  • Images are now uploaded to the server as soon as they are added to the composer, using a background queue (attachmentUploadQueue.ts) with progress reporting, abort capability, and XHR-based upload to signed URLs.
  • Two new WebSocket RPCs (attachments.createUploadUrl and attachments.delete) mint signed upload URLs and delete pending attachments; the server validates tokens, enforces exact size, and persists bytes via a new POST endpoint.
  • ClientThreadTurnStartCommand payloads no longer inline base64 image data URLs; they carry ChatAttachment id references to pre-uploaded blobs. The composer send button is blocked while uploads are pending, failed, or belong to a different environment.
  • Switching environments triggers automatic re-upload of any images that still have a local File; discarding or removing an image aborts in-flight uploads and releases server-side bytes.
  • Composer draft persistence is bumped to v9: only ready (fully uploaded) attachments are persisted as environment-scoped id references; in-flight and failed uploads are not saved. Legacy v8 inline dataUrl entries are dropped on rehydration.
  • Mobile image attach is explicitly disabled via IMAGE_ATTACH_ENABLED = false in composerImages.ts; callers receive a structured error and a warning banner when legacy attachments are dropped.
  • Risk: any clients with persisted v8 composer drafts containing inline image data will silently lose those attachments on rehydration.

Macroscope summarized 36f6d76.

@coderabbitai

coderabbitaiBot commented Aug 12, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: bf950256-a9a4-410e-98ba-2154f6f01183

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@github-actionsgithub-actionsBot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:XXL 1,000+ changed lines (additions + deletions). labels Aug 12, 2026

@macroscopeappmacroscopeappBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Effect service conventions review of the changed server/contracts code. Two findings on error modeling; the new AttachmentUpload module itself follows the repo's service/DI conventions (namespace subpath imports, dependencies acquired via yield* Foo.Foo, no hidden runtimes).

Posted via Macroscope — Effect Service Conventions

Comment threadpackages/contracts/src/assets.ts Outdated
Comment threadapps/server/src/orchestration/Normalizer.ts
Comment threadapps/web/src/components/chat/ChatComposer.tsx
Comment threadapps/server/src/attachmentStore.ts
Comment threadapps/web/src/components/ChatView.tsx
Comment threadapps/web/src/lib/attachmentUploadQueue.ts
Comment threadapps/web/src/components/ChatView.tsx

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Medium

removePreviewAnnotation: (threadRef,annotationId)=>{

removePreviewAnnotation filters the annotation's image out of images but never calls releaseComposerAttachment or cancelAttachmentUpload. Removing a preview annotation while its image is still uploading leaves the upload job running, and removing one after upload completes leaves the server-side pending attachment allocated until the 30-day backstop sweep. The image disappears from the draft's images array, so the upload queue never sees it again to release or cancel it.

Also found in 1 other location(s)

apps/web/src/components/chat/ChatComposer.tsx:1510

The environment-retargeting path calls retryAttachmentUpload for an already-ready image but never releases its existing server attachment. retryAttachmentUpload only cancels an active job (there is none after readiness) and starts a new upload, whereas releaseComposerAttachment is the function that deletes a ready attachment. Switching a draft between environments therefore leaves one orphaned pending upload behind on each retarget until the 30-day sweep.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/web/src/composerDraftStore.ts around line 3253:
`removePreviewAnnotation` filters the annotation's image out of `images` but never calls `releaseComposerAttachment` or `cancelAttachmentUpload`. Removing a preview annotation while its image is still uploading leaves the upload job running, and removing one after upload completes leaves the server-side pending attachment allocated until the 30-day backstop sweep. The image disappears from the draft's `images` array, so the upload queue never sees it again to release or cancel it.
Also found in 1 other location(s):
- apps/web/src/components/chat/ChatComposer.tsx:1510 -- The environment-retargeting path calls `retryAttachmentUpload` for an already-`ready` image but never releases its existing server attachment. `retryAttachmentUpload` only cancels an active job (there is none after readiness) and starts a new upload, whereas `releaseComposerAttachment` is the function that deletes a ready attachment. Switching a draft between environments therefore leaves one orphaned pending upload behind on each retarget until the 30-day sweep.

Comment threadapps/web/src/components/chat/ChatComposer.tsx
Comment threadapps/web/src/components/chat/ChatComposer.tsx
Comment threadapps/web/src/components/ChatView.tsx
Comment threadapps/web/src/composerDraftStore.ts
Comment threadapps/web/src/lib/attachmentUploadQueue.ts
Comment threadapps/mobile/src/features/threads/NewTaskDraftScreen.tsx
@macroscopeapp

macroscopeappBot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Needs human review

2 blocking correctness issues found. This PR introduces substantial new capability (upload-on-attach for images) with new server endpoints, client upload queue, contract changes, and storage schema migrations. The scope exceeds what can be auto-approved, and there is an unresolved High-severity finding about stalled uploads potentially blocking sends indefinitely.

You can customize Macroscope's approvability policy. Learn more.

@github-actions

github-actionsBot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Thread transfer impact

✅ Thread transfer remains within every enforced ceiling.

ProviderMetricMain baselineThis PRImpactPR ceiling
CodexTotal thread wire11.3 KiB11.3 KiB−1 B (−0.0%)15.1 KiB
CodexThread snapshot wire5.5 KiB5.5 KiB+1 B (+0.0%)7.3 KiB
CodexLive turn WebSocket wire5.9 KiB5.9 KiB−2 B (−0.0%)7.8 KiB
CodexLive turn WebSocket decoded49.7 KiB49.7 KiB0 B (0.0%)66.4 KiB
CodexLive turn messages16160 (0.0%)21
ClaudeTotal thread wire11.3 KiB11.3 KiB0 B (0.0%)15.1 KiB
ClaudeThread snapshot wire5.5 KiB5.5 KiB+5 B (+0.1%)7.3 KiB
ClaudeLive turn WebSocket wire5.9 KiB5.9 KiB−5 B (−0.1%)7.8 KiB
ClaudeLive turn WebSocket decoded50.6 KiB50.6 KiB0 B (0.0%)66.4 KiB
ClaudeLive turn messages16160 (0.0%)21

Baseline: 5304f3e · PR result: 36f6d76 · Source CI: success

Scenario and decoded snapshot size

10 historical turns, 5 command tools per turn, 878.9 KiB retained MCP result per historical turn, and a 1.05 MiB retained result in the measured turn.

  • Codex decoded thread snapshot: 94.6 KiB
  • Claude decoded thread snapshot: 95.4 KiB

Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed.

t3dotggand others added 2 commits August 13, 2026 17:51
Attachments used to ride the turn-start command as base64 data URLs:
send carried the bytes, the stash re-encoded them into localStorage,
and a 10MB image meant a 14M-char string in a single ws frame.
Images now upload the moment they are attached. A ws RPC mints a
pending-<uuid> id plus a signed, expiring upload URL (mirroring signed
asset GETs, so it works against any environment); the browser POSTs the
compressed bytes to it with progress and abort; the turn-start command
carries id references only. The Normalizer renames pending files to
their thread segment at send, resolving by uuid so retries after a
partial send are idempotent. Never-sent uploads are deleted on chip
removal and swept after 30 days.
Breaking: the dataUrl upload variant is deleted with no compatibility
path. RN mobile compiles with image attach gated off behind
IMAGE_ATTACH_ENABLED and tells the user an update is needed; drafts and
stash persist id references (v9/v3 storage, old payloads purged).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Real bugs from Macroscope and Bugbot, all verified against source:
- awaitAttachmentUploads consulted only live jobs, so an upload that
settled before the await lost its result and a ready image could drop
from a pick-and-send message. Terminal states are now kept in a
settled map that cancel/release clean up.
- Stashing cancelled in-flight uploads before the localStorage write was
confirmed; a quota failure stranded chips in uploading forever.
Cancellation now happens only after the write lands.
- Retargeting a draft to another environment overwrote a reload-restored
attachment's ready state with a dead failed state. The mismatch is now
derived (never written), so switching back recovers the attachment,
and re-uploads release the old environment's bytes first.
- The turn-start failure path restored chips from the pre-await
snapshot, resurrecting uploading states no job would ever advance.
- The "images were not attached" toast could fire when no message was
sent; it now waits for the turn to actually start.
- Removing a preview annotation or discarding a draft leaked the upload
and its server-side bytes; both now release like chip removal.
- Mobile paste drops now surface the needs-an-update banner instead of
a console.warn.
- Normalizer error mappings keep their PlatformError causes; the dead
unstructured AttachmentUploadRequestError is deleted; the sweep test
no longer reads the real clock (effect-diagnostics CI failure).
Also folds the rebase onto main: the new moveComposerPromptAndImages
draft-carry now merges the unified image list, and in-flight uploads are
retargeted to the destination draft so moved chips keep their progress.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@t3dotgg
t3dotggforce-pushed the t3code/support-arbitrary-attachments branch from 1331956 to 03196efCompareAugust 14, 2026 01:03
Comment threadapps/web/src/components/chat/ChatComposer.tsx Outdated
Comment threadapps/server/src/assets/AttachmentUpload.ts
// moment they were attached. The composer blocks sending while an upload
// is in flight, but the preview "pick and send" gesture attaches and sends
// in one step, so wait for anything still running here.
const settledUploads = await awaitAttachmentUploads(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 Highcomponents/ChatView.tsx:5207

A stalled preview upload leaves the send operation pending indefinitely, so sendInFlightRef stays true and the message is neither sent nor restored. awaitAttachmentUploads waits on an XMLHttpRequest with the default timeout of 0, which never expires when the connection remains open; add an upload timeout or cancellation path before awaiting it.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/web/src/components/ChatView.tsx around line 5207:
A stalled preview upload leaves the send operation pending indefinitely, so `sendInFlightRef` stays `true` and the message is neither sent nor restored. `awaitAttachmentUploads` waits on an `XMLHttpRequest` with the default `timeout` of `0`, which never expires when the connection remains open; add an upload timeout or cancellation path before awaiting it.

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 3 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 03196ef. Configure here.

Comment threadapps/web/src/lib/attachmentUploadQueue.ts
Comment threadapps/web/src/components/ChatView.tsx
Comment threadapps/mobile/src/lib/composerImages.ts Outdated
- Unique per-request .part suffix so concurrent POSTs of one token
cannot interleave into a shared temp file.
- xhr.timeout on uploads: a stalled POST now fails into the retryable
state instead of blocking send (and pick-and-send) forever.
- Cross-environment re-upload keeps the old environment's copy until
the new upload succeeds (supersedes), so a failed re-upload never
destroys the only server copy.
- The plan follow-up branch releases attached images before clearing
the composer instead of orphaning their pending files.
- Mobile: a mixed text+image clipboard still pastes its text while
attach is disabled, and the image drop is surfaced.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@ElliotDrelChatGPT Codex Connector

Copy link
Copy Markdown

Non-blocking architecture follow-up for Prompt Stash: this PR solves the hard attachment half by moving image bytes onto the target T3 environment, but apps/web/src/promptStashStore.ts still makes browser localStorage the authoritative stash store. That means stash metadata still cannot follow the same environment across desktop/web/mobile or another controlling machine, even though the attachment bytes now can.

I think the clean follow-up is to make the target ExecutionEnvironment/server authoritative for stash state, rather than putting canonical stash state in T3 Connect or a thread snapshot:

  • add server-side durable stash metadata keyed to the environment;
  • expose idempotent mutations such as prompt-stash.create, atomic prompt-stash.take, and prompt-stash.delete (plus attachment finalization only if still needed);
  • expose a separate environment-level stash subscription/snapshot, e.g. orchestration.subscribePromptStash, rather than piggybacking on subscribeThread because the stash is intentionally thread/provider agnostic;
  • put the replicated client state in packages/client-runtime so web and mobile consume the same source;
  • reuse the server attachment IDs introduced by this PR, so stash synchronization moves references rather than base64/blob payloads;
  • keep client storage only as an optional pending outbox for offline writes. A normal stash should clear the composer only after the target server acknowledges the durable write.

T3 Connect then remains transport to the execution environment instead of becoming a second state authority.

I would keep this out of this PR because #6276 is already a large/high-risk attachment-contract change. The server-owned stash is a cleaner follow-up PR once this attachment model is stable.

@ElliotDrelChatGPT Codex Connector

Copy link
Copy Markdown

Follow-up tracked in #7626. That issue is intentionally sequenced after this PR and should build on this PR's server-side attachment ID model rather than expand #6276 further.

@t3dotgg

Copy link
Copy Markdown
MemberAuthor

Note

🤖 GPT-5.6 Sol responding on behalf of Theo

Closing in favor of #8048. Image attachments now upload before sending, with compatibility preserved for existing mobile clients and older servers.

@t3dotggt3dotgg closed this Aug 24, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL1,000+ changed lines (additions + deletions).vouch:trustedPR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@t3dotgg@ElliotDrel
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

feat: images upload the moment you attach them - #6276

Closed
t3dotgg wants to merge 3 commits into
mainfrom
t3code/support-arbitrary-attachments
Closed

feat: images upload the moment you attach them#6276
t3dotgg wants to merge 3 commits into
mainfrom
t3code/support-arbitrary-attachments

Conversation

@t3dotgg

@t3dotggt3dotgg commented Aug 12, 2026

Copy link
Copy Markdown
Member

Attached images used to ride inside the send command as base64 data URLs. Sending a message with a 10MB screenshot meant a 14M-character string in one ws frame: send felt slow, the turn could not start until the frame landed, and the stash had to re-encode every image to squeeze it into localStorage.

Now the bytes move while you are still typing. Attaching an image mints a pending-<uuid> id plus a signed upload URL over ws (the same pattern as signed asset GETs, so it works against any environment), and the browser POSTs the compressed bytes to it with per-chip progress and cancel. The send command carries id references only, and the server renames pending files to their thread at turn start — resolving by uuid, so send retries and existing asset URLs survive the rename.

What this buys:

  • Send is instant; upload overlaps with typing.
  • Drafts with images now survive reloads fully (previews via signed asset URLs). The localStorage re-encode/budget machinery is deleted.
  • The send button blocks while a chip is uploading or failed, so an image can never be silently dropped from a sent message.
  • Never-sent uploads are deleted on chip removal, with a 30-day sweep as backstop. Uploads write .part then rename, so a partial transfer is never a valid attachment, and the route enforces the exact minted byte count.

Breaking, deliberately without a compatibility path: the dataUrl upload variant is deleted. Out-of-date clients get a loud schema error. React Native mobile compiles and degrades honestly behind IMAGE_ATTACH_ENABLED = false ("Image attach needs an app update"); the port is a fast-follow. apps/swift-ios is not on main, so the Swift mirror is untouched — a 7-edit patch list for that branch is in the plan doc.

Design doc: https://rztz3kvilrh0.postplan.dev

Verified: contracts 227, server attachment suites 66 (full suite green except 6 launcher-version failures that reproduce on untouched main), web 2201, mobile 654. Lint and format clean. Not yet done: a manual browser pass (paste several images fast, cancel mid-flight, kill the network, reload with a ready draft) — automated browsers cannot see authenticated views here.

Built by Claude Fable 5 running in Claude Code.

🤖 Generated with Claude Code


Note

High Risk
Breaking attachment contract (no dataUrl), new signed upload HTTP surface and turn-start claim logic, plus draft v9 migration that drops unsent inline images on upgrade.

Overview
Composer images no longer ride in thread.turn.start as base64 data URLs. Web uploads bytes as soon as a chip is added: WS mints a pending-<uuid> id and signed URL, HTTP POST stores the file, and send carries id references only. The normalizer claims pending files into thread scope at turn start (uuid-stable resolution so retries and asset URLs survive renames).

Server additions:AttachmentUpload (token mint/validate, .part then rename, size enforcement), POST /api/attachments/upload/*, RPC attachments.createUploadUrl / attachments.delete, pending attachment sweep on startup, and planAttachmentClaim in the attachment store.

Web composer/drafts: Upload queue with progress, cancel, retry, and environment mismatch handling; send is blocked while uploads are unsettled; draft persistence bumps to v9 (server id + environmentId only—legacy inline dataUrl attachments are dropped on rehydrate). Stash and sidebar discard release pending server bytes.

Mobile: Image attach is disabled behind IMAGE_ATTACH_ENABLED = false; picks/pastes surface “needs an app update,” legacy draft/outbox images are not sent (warnings via droppedAttachmentsWarning), and buildProjectThreadStartTurnInput always sends attachments: [].

Reviewed by Cursor Bugbot for commit 36f6d76. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Upload composer image attachments immediately on attach instead of at send time

  • Images are now uploaded to the server as soon as they are added to the composer, using a background queue (attachmentUploadQueue.ts) with progress reporting, abort capability, and XHR-based upload to signed URLs.
  • Two new WebSocket RPCs (attachments.createUploadUrl and attachments.delete) mint signed upload URLs and delete pending attachments; the server validates tokens, enforces exact size, and persists bytes via a new POST endpoint.
  • ClientThreadTurnStartCommand payloads no longer inline base64 image data URLs; they carry ChatAttachment id references to pre-uploaded blobs. The composer send button is blocked while uploads are pending, failed, or belong to a different environment.
  • Switching environments triggers automatic re-upload of any images that still have a local File; discarding or removing an image aborts in-flight uploads and releases server-side bytes.
  • Composer draft persistence is bumped to v9: only ready (fully uploaded) attachments are persisted as environment-scoped id references; in-flight and failed uploads are not saved. Legacy v8 inline dataUrl entries are dropped on rehydration.
  • Mobile image attach is explicitly disabled via IMAGE_ATTACH_ENABLED = false in composerImages.ts; callers receive a structured error and a warning banner when legacy attachments are dropped.
  • Risk: any clients with persisted v8 composer drafts containing inline image data will silently lose those attachments on rehydration.

Macroscope summarized 36f6d76.

@coderabbitai

coderabbitaiBot commented Aug 12, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: bf950256-a9a4-410e-98ba-2154f6f01183

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@github-actionsgithub-actionsBot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:XXL 1,000+ changed lines (additions + deletions). labels Aug 12, 2026

@macroscopeappmacroscopeappBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Effect service conventions review of the changed server/contracts code. Two findings on error modeling; the new AttachmentUpload module itself follows the repo's service/DI conventions (namespace subpath imports, dependencies acquired via yield* Foo.Foo, no hidden runtimes).

Posted via Macroscope — Effect Service Conventions

Comment threadpackages/contracts/src/assets.ts Outdated
Comment threadapps/server/src/orchestration/Normalizer.ts
Comment threadapps/web/src/components/chat/ChatComposer.tsx
Comment threadapps/server/src/attachmentStore.ts
Comment threadapps/web/src/components/ChatView.tsx
Comment threadapps/web/src/lib/attachmentUploadQueue.ts
Comment threadapps/web/src/components/ChatView.tsx

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Medium

removePreviewAnnotation: (threadRef,annotationId)=>{

removePreviewAnnotation filters the annotation's image out of images but never calls releaseComposerAttachment or cancelAttachmentUpload. Removing a preview annotation while its image is still uploading leaves the upload job running, and removing one after upload completes leaves the server-side pending attachment allocated until the 30-day backstop sweep. The image disappears from the draft's images array, so the upload queue never sees it again to release or cancel it.

Also found in 1 other location(s)

apps/web/src/components/chat/ChatComposer.tsx:1510

The environment-retargeting path calls retryAttachmentUpload for an already-ready image but never releases its existing server attachment. retryAttachmentUpload only cancels an active job (there is none after readiness) and starts a new upload, whereas releaseComposerAttachment is the function that deletes a ready attachment. Switching a draft between environments therefore leaves one orphaned pending upload behind on each retarget until the 30-day sweep.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/web/src/composerDraftStore.ts around line 3253:
`removePreviewAnnotation` filters the annotation's image out of `images` but never calls `releaseComposerAttachment` or `cancelAttachmentUpload`. Removing a preview annotation while its image is still uploading leaves the upload job running, and removing one after upload completes leaves the server-side pending attachment allocated until the 30-day backstop sweep. The image disappears from the draft's `images` array, so the upload queue never sees it again to release or cancel it.
Also found in 1 other location(s):
- apps/web/src/components/chat/ChatComposer.tsx:1510 -- The environment-retargeting path calls `retryAttachmentUpload` for an already-`ready` image but never releases its existing server attachment. `retryAttachmentUpload` only cancels an active job (there is none after readiness) and starts a new upload, whereas `releaseComposerAttachment` is the function that deletes a ready attachment. Switching a draft between environments therefore leaves one orphaned pending upload behind on each retarget until the 30-day sweep.

Comment threadapps/web/src/components/chat/ChatComposer.tsx
Comment threadapps/web/src/components/chat/ChatComposer.tsx
Comment threadapps/web/src/components/ChatView.tsx
Comment threadapps/web/src/composerDraftStore.ts
Comment threadapps/web/src/lib/attachmentUploadQueue.ts
Comment threadapps/mobile/src/features/threads/NewTaskDraftScreen.tsx
@macroscopeapp

macroscopeappBot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Needs human review

2 blocking correctness issues found. This PR introduces substantial new capability (upload-on-attach for images) with new server endpoints, client upload queue, contract changes, and storage schema migrations. The scope exceeds what can be auto-approved, and there is an unresolved High-severity finding about stalled uploads potentially blocking sends indefinitely.

You can customize Macroscope's approvability policy. Learn more.

@github-actions

github-actionsBot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Thread transfer impact

✅ Thread transfer remains within every enforced ceiling.

ProviderMetricMain baselineThis PRImpactPR ceiling
CodexTotal thread wire11.3 KiB11.3 KiB−1 B (−0.0%)15.1 KiB
CodexThread snapshot wire5.5 KiB5.5 KiB+1 B (+0.0%)7.3 KiB
CodexLive turn WebSocket wire5.9 KiB5.9 KiB−2 B (−0.0%)7.8 KiB
CodexLive turn WebSocket decoded49.7 KiB49.7 KiB0 B (0.0%)66.4 KiB
CodexLive turn messages16160 (0.0%)21
ClaudeTotal thread wire11.3 KiB11.3 KiB0 B (0.0%)15.1 KiB
ClaudeThread snapshot wire5.5 KiB5.5 KiB+5 B (+0.1%)7.3 KiB
ClaudeLive turn WebSocket wire5.9 KiB5.9 KiB−5 B (−0.1%)7.8 KiB
ClaudeLive turn WebSocket decoded50.6 KiB50.6 KiB0 B (0.0%)66.4 KiB
ClaudeLive turn messages16160 (0.0%)21

Baseline: 5304f3e · PR result: 36f6d76 · Source CI: success

Scenario and decoded snapshot size

10 historical turns, 5 command tools per turn, 878.9 KiB retained MCP result per historical turn, and a 1.05 MiB retained result in the measured turn.

  • Codex decoded thread snapshot: 94.6 KiB
  • Claude decoded thread snapshot: 95.4 KiB

Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed.

t3dotggand others added 2 commits August 13, 2026 17:51
Attachments used to ride the turn-start command as base64 data URLs:
send carried the bytes, the stash re-encoded them into localStorage,
and a 10MB image meant a 14M-char string in a single ws frame.
Images now upload the moment they are attached. A ws RPC mints a
pending-<uuid> id plus a signed, expiring upload URL (mirroring signed
asset GETs, so it works against any environment); the browser POSTs the
compressed bytes to it with progress and abort; the turn-start command
carries id references only. The Normalizer renames pending files to
their thread segment at send, resolving by uuid so retries after a
partial send are idempotent. Never-sent uploads are deleted on chip
removal and swept after 30 days.
Breaking: the dataUrl upload variant is deleted with no compatibility
path. RN mobile compiles with image attach gated off behind
IMAGE_ATTACH_ENABLED and tells the user an update is needed; drafts and
stash persist id references (v9/v3 storage, old payloads purged).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Real bugs from Macroscope and Bugbot, all verified against source:
- awaitAttachmentUploads consulted only live jobs, so an upload that
settled before the await lost its result and a ready image could drop
from a pick-and-send message. Terminal states are now kept in a
settled map that cancel/release clean up.
- Stashing cancelled in-flight uploads before the localStorage write was
confirmed; a quota failure stranded chips in uploading forever.
Cancellation now happens only after the write lands.
- Retargeting a draft to another environment overwrote a reload-restored
attachment's ready state with a dead failed state. The mismatch is now
derived (never written), so switching back recovers the attachment,
and re-uploads release the old environment's bytes first.
- The turn-start failure path restored chips from the pre-await
snapshot, resurrecting uploading states no job would ever advance.
- The "images were not attached" toast could fire when no message was
sent; it now waits for the turn to actually start.
- Removing a preview annotation or discarding a draft leaked the upload
and its server-side bytes; both now release like chip removal.
- Mobile paste drops now surface the needs-an-update banner instead of
a console.warn.
- Normalizer error mappings keep their PlatformError causes; the dead
unstructured AttachmentUploadRequestError is deleted; the sweep test
no longer reads the real clock (effect-diagnostics CI failure).
Also folds the rebase onto main: the new moveComposerPromptAndImages
draft-carry now merges the unified image list, and in-flight uploads are
retargeted to the destination draft so moved chips keep their progress.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@t3dotgg
t3dotggforce-pushed the t3code/support-arbitrary-attachments branch from 1331956 to 03196efCompareAugust 14, 2026 01:03
Comment threadapps/web/src/components/chat/ChatComposer.tsx Outdated
Comment threadapps/server/src/assets/AttachmentUpload.ts
// moment they were attached. The composer blocks sending while an upload
// is in flight, but the preview "pick and send" gesture attaches and sends
// in one step, so wait for anything still running here.
const settledUploads = await awaitAttachmentUploads(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 Highcomponents/ChatView.tsx:5207

A stalled preview upload leaves the send operation pending indefinitely, so sendInFlightRef stays true and the message is neither sent nor restored. awaitAttachmentUploads waits on an XMLHttpRequest with the default timeout of 0, which never expires when the connection remains open; add an upload timeout or cancellation path before awaiting it.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/web/src/components/ChatView.tsx around line 5207:
A stalled preview upload leaves the send operation pending indefinitely, so `sendInFlightRef` stays `true` and the message is neither sent nor restored. `awaitAttachmentUploads` waits on an `XMLHttpRequest` with the default `timeout` of `0`, which never expires when the connection remains open; add an upload timeout or cancellation path before awaiting it.

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 3 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 03196ef. Configure here.

Comment threadapps/web/src/lib/attachmentUploadQueue.ts
Comment threadapps/web/src/components/ChatView.tsx
Comment threadapps/mobile/src/lib/composerImages.ts Outdated
- Unique per-request .part suffix so concurrent POSTs of one token
cannot interleave into a shared temp file.
- xhr.timeout on uploads: a stalled POST now fails into the retryable
state instead of blocking send (and pick-and-send) forever.
- Cross-environment re-upload keeps the old environment's copy until
the new upload succeeds (supersedes), so a failed re-upload never
destroys the only server copy.
- The plan follow-up branch releases attached images before clearing
the composer instead of orphaning their pending files.
- Mobile: a mixed text+image clipboard still pastes its text while
attach is disabled, and the image drop is surfaced.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@ElliotDrelChatGPT Codex Connector

Copy link
Copy Markdown

Non-blocking architecture follow-up for Prompt Stash: this PR solves the hard attachment half by moving image bytes onto the target T3 environment, but apps/web/src/promptStashStore.ts still makes browser localStorage the authoritative stash store. That means stash metadata still cannot follow the same environment across desktop/web/mobile or another controlling machine, even though the attachment bytes now can.

I think the clean follow-up is to make the target ExecutionEnvironment/server authoritative for stash state, rather than putting canonical stash state in T3 Connect or a thread snapshot:

  • add server-side durable stash metadata keyed to the environment;
  • expose idempotent mutations such as prompt-stash.create, atomic prompt-stash.take, and prompt-stash.delete (plus attachment finalization only if still needed);
  • expose a separate environment-level stash subscription/snapshot, e.g. orchestration.subscribePromptStash, rather than piggybacking on subscribeThread because the stash is intentionally thread/provider agnostic;
  • put the replicated client state in packages/client-runtime so web and mobile consume the same source;
  • reuse the server attachment IDs introduced by this PR, so stash synchronization moves references rather than base64/blob payloads;
  • keep client storage only as an optional pending outbox for offline writes. A normal stash should clear the composer only after the target server acknowledges the durable write.

T3 Connect then remains transport to the execution environment instead of becoming a second state authority.

I would keep this out of this PR because #6276 is already a large/high-risk attachment-contract change. The server-owned stash is a cleaner follow-up PR once this attachment model is stable.

@ElliotDrelChatGPT Codex Connector

Copy link
Copy Markdown

Follow-up tracked in #7626. That issue is intentionally sequenced after this PR and should build on this PR's server-side attachment ID model rather than expand #6276 further.

@t3dotgg

Copy link
Copy Markdown
MemberAuthor

Note

🤖 GPT-5.6 Sol responding on behalf of Theo

Closing in favor of #8048. Image attachments now upload before sending, with compatibility preserved for existing mobile clients and older servers.

@t3dotggt3dotgg closed this Aug 24, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL1,000+ changed lines (additions + deletions).vouch:trustedPR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@t3dotgg@ElliotDrel
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat: images upload the moment you attach them - #6276

Closed
t3dotgg wants to merge 3 commits into
mainfrom
t3code/support-arbitrary-attachments
Closed

feat: images upload the moment you attach them#6276
t3dotgg wants to merge 3 commits into
mainfrom
t3code/support-arbitrary-attachments

Conversation

@t3dotgg

@t3dotggt3dotgg commented Aug 12, 2026

Copy link
Copy Markdown
Member

Attached images used to ride inside the send command as base64 data URLs. Sending a message with a 10MB screenshot meant a 14M-character string in one ws frame: send felt slow, the turn could not start until the frame landed, and the stash had to re-encode every image to squeeze it into localStorage.

Now the bytes move while you are still typing. Attaching an image mints a pending-<uuid> id plus a signed upload URL over ws (the same pattern as signed asset GETs, so it works against any environment), and the browser POSTs the compressed bytes to it with per-chip progress and cancel. The send command carries id references only, and the server renames pending files to their thread at turn start — resolving by uuid, so send retries and existing asset URLs survive the rename.

What this buys:

  • Send is instant; upload overlaps with typing.
  • Drafts with images now survive reloads fully (previews via signed asset URLs). The localStorage re-encode/budget machinery is deleted.
  • The send button blocks while a chip is uploading or failed, so an image can never be silently dropped from a sent message.
  • Never-sent uploads are deleted on chip removal, with a 30-day sweep as backstop. Uploads write .part then rename, so a partial transfer is never a valid attachment, and the route enforces the exact minted byte count.

Breaking, deliberately without a compatibility path: the dataUrl upload variant is deleted. Out-of-date clients get a loud schema error. React Native mobile compiles and degrades honestly behind IMAGE_ATTACH_ENABLED = false ("Image attach needs an app update"); the port is a fast-follow. apps/swift-ios is not on main, so the Swift mirror is untouched — a 7-edit patch list for that branch is in the plan doc.

Design doc: https://rztz3kvilrh0.postplan.dev

Verified: contracts 227, server attachment suites 66 (full suite green except 6 launcher-version failures that reproduce on untouched main), web 2201, mobile 654. Lint and format clean. Not yet done: a manual browser pass (paste several images fast, cancel mid-flight, kill the network, reload with a ready draft) — automated browsers cannot see authenticated views here.

Built by Claude Fable 5 running in Claude Code.

🤖 Generated with Claude Code


Note

High Risk
Breaking attachment contract (no dataUrl), new signed upload HTTP surface and turn-start claim logic, plus draft v9 migration that drops unsent inline images on upgrade.

Overview
Composer images no longer ride in thread.turn.start as base64 data URLs. Web uploads bytes as soon as a chip is added: WS mints a pending-<uuid> id and signed URL, HTTP POST stores the file, and send carries id references only. The normalizer claims pending files into thread scope at turn start (uuid-stable resolution so retries and asset URLs survive renames).

Server additions:AttachmentUpload (token mint/validate, .part then rename, size enforcement), POST /api/attachments/upload/*, RPC attachments.createUploadUrl / attachments.delete, pending attachment sweep on startup, and planAttachmentClaim in the attachment store.

Web composer/drafts: Upload queue with progress, cancel, retry, and environment mismatch handling; send is blocked while uploads are unsettled; draft persistence bumps to v9 (server id + environmentId only—legacy inline dataUrl attachments are dropped on rehydrate). Stash and sidebar discard release pending server bytes.

Mobile: Image attach is disabled behind IMAGE_ATTACH_ENABLED = false; picks/pastes surface “needs an app update,” legacy draft/outbox images are not sent (warnings via droppedAttachmentsWarning), and buildProjectThreadStartTurnInput always sends attachments: [].

Reviewed by Cursor Bugbot for commit 36f6d76. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Upload composer image attachments immediately on attach instead of at send time

  • Images are now uploaded to the server as soon as they are added to the composer, using a background queue (attachmentUploadQueue.ts) with progress reporting, abort capability, and XHR-based upload to signed URLs.
  • Two new WebSocket RPCs (attachments.createUploadUrl and attachments.delete) mint signed upload URLs and delete pending attachments; the server validates tokens, enforces exact size, and persists bytes via a new POST endpoint.
  • ClientThreadTurnStartCommand payloads no longer inline base64 image data URLs; they carry ChatAttachment id references to pre-uploaded blobs. The composer send button is blocked while uploads are pending, failed, or belong to a different environment.
  • Switching environments triggers automatic re-upload of any images that still have a local File; discarding or removing an image aborts in-flight uploads and releases server-side bytes.
  • Composer draft persistence is bumped to v9: only ready (fully uploaded) attachments are persisted as environment-scoped id references; in-flight and failed uploads are not saved. Legacy v8 inline dataUrl entries are dropped on rehydration.
  • Mobile image attach is explicitly disabled via IMAGE_ATTACH_ENABLED = false in composerImages.ts; callers receive a structured error and a warning banner when legacy attachments are dropped.
  • Risk: any clients with persisted v8 composer drafts containing inline image data will silently lose those attachments on rehydration.

Macroscope summarized 36f6d76.

@coderabbitai

coderabbitaiBot commented Aug 12, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: bf950256-a9a4-410e-98ba-2154f6f01183

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@github-actionsgithub-actionsBot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:XXL 1,000+ changed lines (additions + deletions). labels Aug 12, 2026

@macroscopeappmacroscopeappBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Effect service conventions review of the changed server/contracts code. Two findings on error modeling; the new AttachmentUpload module itself follows the repo's service/DI conventions (namespace subpath imports, dependencies acquired via yield* Foo.Foo, no hidden runtimes).

Posted via Macroscope — Effect Service Conventions

Comment threadpackages/contracts/src/assets.ts Outdated
Comment threadapps/server/src/orchestration/Normalizer.ts
Comment threadapps/web/src/components/chat/ChatComposer.tsx
Comment threadapps/server/src/attachmentStore.ts
Comment threadapps/web/src/components/ChatView.tsx
Comment threadapps/web/src/lib/attachmentUploadQueue.ts
Comment threadapps/web/src/components/ChatView.tsx

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Medium

removePreviewAnnotation: (threadRef,annotationId)=>{

removePreviewAnnotation filters the annotation's image out of images but never calls releaseComposerAttachment or cancelAttachmentUpload. Removing a preview annotation while its image is still uploading leaves the upload job running, and removing one after upload completes leaves the server-side pending attachment allocated until the 30-day backstop sweep. The image disappears from the draft's images array, so the upload queue never sees it again to release or cancel it.

Also found in 1 other location(s)

apps/web/src/components/chat/ChatComposer.tsx:1510

The environment-retargeting path calls retryAttachmentUpload for an already-ready image but never releases its existing server attachment. retryAttachmentUpload only cancels an active job (there is none after readiness) and starts a new upload, whereas releaseComposerAttachment is the function that deletes a ready attachment. Switching a draft between environments therefore leaves one orphaned pending upload behind on each retarget until the 30-day sweep.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/web/src/composerDraftStore.ts around line 3253:
`removePreviewAnnotation` filters the annotation's image out of `images` but never calls `releaseComposerAttachment` or `cancelAttachmentUpload`. Removing a preview annotation while its image is still uploading leaves the upload job running, and removing one after upload completes leaves the server-side pending attachment allocated until the 30-day backstop sweep. The image disappears from the draft's `images` array, so the upload queue never sees it again to release or cancel it.
Also found in 1 other location(s):
- apps/web/src/components/chat/ChatComposer.tsx:1510 -- The environment-retargeting path calls `retryAttachmentUpload` for an already-`ready` image but never releases its existing server attachment. `retryAttachmentUpload` only cancels an active job (there is none after readiness) and starts a new upload, whereas `releaseComposerAttachment` is the function that deletes a ready attachment. Switching a draft between environments therefore leaves one orphaned pending upload behind on each retarget until the 30-day sweep.

Comment threadapps/web/src/components/chat/ChatComposer.tsx
Comment threadapps/web/src/components/chat/ChatComposer.tsx
Comment threadapps/web/src/components/ChatView.tsx
Comment threadapps/web/src/composerDraftStore.ts
Comment threadapps/web/src/lib/attachmentUploadQueue.ts
Comment threadapps/mobile/src/features/threads/NewTaskDraftScreen.tsx
@macroscopeapp

macroscopeappBot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Needs human review

2 blocking correctness issues found. This PR introduces substantial new capability (upload-on-attach for images) with new server endpoints, client upload queue, contract changes, and storage schema migrations. The scope exceeds what can be auto-approved, and there is an unresolved High-severity finding about stalled uploads potentially blocking sends indefinitely.

You can customize Macroscope's approvability policy. Learn more.

@github-actions

github-actionsBot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Thread transfer impact

✅ Thread transfer remains within every enforced ceiling.

ProviderMetricMain baselineThis PRImpactPR ceiling
CodexTotal thread wire11.3 KiB11.3 KiB−1 B (−0.0%)15.1 KiB
CodexThread snapshot wire5.5 KiB5.5 KiB+1 B (+0.0%)7.3 KiB
CodexLive turn WebSocket wire5.9 KiB5.9 KiB−2 B (−0.0%)7.8 KiB
CodexLive turn WebSocket decoded49.7 KiB49.7 KiB0 B (0.0%)66.4 KiB
CodexLive turn messages16160 (0.0%)21
ClaudeTotal thread wire11.3 KiB11.3 KiB0 B (0.0%)15.1 KiB
ClaudeThread snapshot wire5.5 KiB5.5 KiB+5 B (+0.1%)7.3 KiB
ClaudeLive turn WebSocket wire5.9 KiB5.9 KiB−5 B (−0.1%)7.8 KiB
ClaudeLive turn WebSocket decoded50.6 KiB50.6 KiB0 B (0.0%)66.4 KiB
ClaudeLive turn messages16160 (0.0%)21

Baseline: 5304f3e · PR result: 36f6d76 · Source CI: success

Scenario and decoded snapshot size

10 historical turns, 5 command tools per turn, 878.9 KiB retained MCP result per historical turn, and a 1.05 MiB retained result in the measured turn.

  • Codex decoded thread snapshot: 94.6 KiB
  • Claude decoded thread snapshot: 95.4 KiB

Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed.

t3dotggand others added 2 commits August 13, 2026 17:51
Attachments used to ride the turn-start command as base64 data URLs:
send carried the bytes, the stash re-encoded them into localStorage,
and a 10MB image meant a 14M-char string in a single ws frame.
Images now upload the moment they are attached. A ws RPC mints a
pending-<uuid> id plus a signed, expiring upload URL (mirroring signed
asset GETs, so it works against any environment); the browser POSTs the
compressed bytes to it with progress and abort; the turn-start command
carries id references only. The Normalizer renames pending files to
their thread segment at send, resolving by uuid so retries after a
partial send are idempotent. Never-sent uploads are deleted on chip
removal and swept after 30 days.
Breaking: the dataUrl upload variant is deleted with no compatibility
path. RN mobile compiles with image attach gated off behind
IMAGE_ATTACH_ENABLED and tells the user an update is needed; drafts and
stash persist id references (v9/v3 storage, old payloads purged).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Real bugs from Macroscope and Bugbot, all verified against source:
- awaitAttachmentUploads consulted only live jobs, so an upload that
settled before the await lost its result and a ready image could drop
from a pick-and-send message. Terminal states are now kept in a
settled map that cancel/release clean up.
- Stashing cancelled in-flight uploads before the localStorage write was
confirmed; a quota failure stranded chips in uploading forever.
Cancellation now happens only after the write lands.
- Retargeting a draft to another environment overwrote a reload-restored
attachment's ready state with a dead failed state. The mismatch is now
derived (never written), so switching back recovers the attachment,
and re-uploads release the old environment's bytes first.
- The turn-start failure path restored chips from the pre-await
snapshot, resurrecting uploading states no job would ever advance.
- The "images were not attached" toast could fire when no message was
sent; it now waits for the turn to actually start.
- Removing a preview annotation or discarding a draft leaked the upload
and its server-side bytes; both now release like chip removal.
- Mobile paste drops now surface the needs-an-update banner instead of
a console.warn.
- Normalizer error mappings keep their PlatformError causes; the dead
unstructured AttachmentUploadRequestError is deleted; the sweep test
no longer reads the real clock (effect-diagnostics CI failure).
Also folds the rebase onto main: the new moveComposerPromptAndImages
draft-carry now merges the unified image list, and in-flight uploads are
retargeted to the destination draft so moved chips keep their progress.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@t3dotgg
t3dotggforce-pushed the t3code/support-arbitrary-attachments branch from 1331956 to 03196efCompareAugust 14, 2026 01:03
Comment threadapps/web/src/components/chat/ChatComposer.tsx Outdated
Comment threadapps/server/src/assets/AttachmentUpload.ts
// moment they were attached. The composer blocks sending while an upload
// is in flight, but the preview "pick and send" gesture attaches and sends
// in one step, so wait for anything still running here.
const settledUploads = await awaitAttachmentUploads(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 Highcomponents/ChatView.tsx:5207

A stalled preview upload leaves the send operation pending indefinitely, so sendInFlightRef stays true and the message is neither sent nor restored. awaitAttachmentUploads waits on an XMLHttpRequest with the default timeout of 0, which never expires when the connection remains open; add an upload timeout or cancellation path before awaiting it.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/web/src/components/ChatView.tsx around line 5207:
A stalled preview upload leaves the send operation pending indefinitely, so `sendInFlightRef` stays `true` and the message is neither sent nor restored. `awaitAttachmentUploads` waits on an `XMLHttpRequest` with the default `timeout` of `0`, which never expires when the connection remains open; add an upload timeout or cancellation path before awaiting it.

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 3 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 03196ef. Configure here.

Comment threadapps/web/src/lib/attachmentUploadQueue.ts
Comment threadapps/web/src/components/ChatView.tsx
Comment threadapps/mobile/src/lib/composerImages.ts Outdated
- Unique per-request .part suffix so concurrent POSTs of one token
cannot interleave into a shared temp file.
- xhr.timeout on uploads: a stalled POST now fails into the retryable
state instead of blocking send (and pick-and-send) forever.
- Cross-environment re-upload keeps the old environment's copy until
the new upload succeeds (supersedes), so a failed re-upload never
destroys the only server copy.
- The plan follow-up branch releases attached images before clearing
the composer instead of orphaning their pending files.
- Mobile: a mixed text+image clipboard still pastes its text while
attach is disabled, and the image drop is surfaced.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@ElliotDrelChatGPT Codex Connector

Copy link
Copy Markdown

Non-blocking architecture follow-up for Prompt Stash: this PR solves the hard attachment half by moving image bytes onto the target T3 environment, but apps/web/src/promptStashStore.ts still makes browser localStorage the authoritative stash store. That means stash metadata still cannot follow the same environment across desktop/web/mobile or another controlling machine, even though the attachment bytes now can.

I think the clean follow-up is to make the target ExecutionEnvironment/server authoritative for stash state, rather than putting canonical stash state in T3 Connect or a thread snapshot:

  • add server-side durable stash metadata keyed to the environment;
  • expose idempotent mutations such as prompt-stash.create, atomic prompt-stash.take, and prompt-stash.delete (plus attachment finalization only if still needed);
  • expose a separate environment-level stash subscription/snapshot, e.g. orchestration.subscribePromptStash, rather than piggybacking on subscribeThread because the stash is intentionally thread/provider agnostic;
  • put the replicated client state in packages/client-runtime so web and mobile consume the same source;
  • reuse the server attachment IDs introduced by this PR, so stash synchronization moves references rather than base64/blob payloads;
  • keep client storage only as an optional pending outbox for offline writes. A normal stash should clear the composer only after the target server acknowledges the durable write.

T3 Connect then remains transport to the execution environment instead of becoming a second state authority.

I would keep this out of this PR because #6276 is already a large/high-risk attachment-contract change. The server-owned stash is a cleaner follow-up PR once this attachment model is stable.

@ElliotDrelChatGPT Codex Connector

Copy link
Copy Markdown

Follow-up tracked in #7626. That issue is intentionally sequenced after this PR and should build on this PR's server-side attachment ID model rather than expand #6276 further.

@t3dotgg

Copy link
Copy Markdown
MemberAuthor

Note

🤖 GPT-5.6 Sol responding on behalf of Theo

Closing in favor of #8048. Image attachments now upload before sending, with compatibility preserved for existing mobile clients and older servers.

@t3dotggt3dotgg closed this Aug 24, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL1,000+ changed lines (additions + deletions).vouch:trustedPR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@t3dotgg@ElliotDrel
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat: images upload the moment you attach them - #6276

Closed
t3dotgg wants to merge 3 commits into
mainfrom
t3code/support-arbitrary-attachments
Closed

feat: images upload the moment you attach them#6276
t3dotgg wants to merge 3 commits into
mainfrom
t3code/support-arbitrary-attachments

Conversation

@t3dotgg

@t3dotggt3dotgg commented Aug 12, 2026

Copy link
Copy Markdown
Member

Attached images used to ride inside the send command as base64 data URLs. Sending a message with a 10MB screenshot meant a 14M-character string in one ws frame: send felt slow, the turn could not start until the frame landed, and the stash had to re-encode every image to squeeze it into localStorage.

Now the bytes move while you are still typing. Attaching an image mints a pending-<uuid> id plus a signed upload URL over ws (the same pattern as signed asset GETs, so it works against any environment), and the browser POSTs the compressed bytes to it with per-chip progress and cancel. The send command carries id references only, and the server renames pending files to their thread at turn start — resolving by uuid, so send retries and existing asset URLs survive the rename.

What this buys:

  • Send is instant; upload overlaps with typing.
  • Drafts with images now survive reloads fully (previews via signed asset URLs). The localStorage re-encode/budget machinery is deleted.
  • The send button blocks while a chip is uploading or failed, so an image can never be silently dropped from a sent message.
  • Never-sent uploads are deleted on chip removal, with a 30-day sweep as backstop. Uploads write .part then rename, so a partial transfer is never a valid attachment, and the route enforces the exact minted byte count.

Breaking, deliberately without a compatibility path: the dataUrl upload variant is deleted. Out-of-date clients get a loud schema error. React Native mobile compiles and degrades honestly behind IMAGE_ATTACH_ENABLED = false ("Image attach needs an app update"); the port is a fast-follow. apps/swift-ios is not on main, so the Swift mirror is untouched — a 7-edit patch list for that branch is in the plan doc.

Design doc: https://rztz3kvilrh0.postplan.dev

Verified: contracts 227, server attachment suites 66 (full suite green except 6 launcher-version failures that reproduce on untouched main), web 2201, mobile 654. Lint and format clean. Not yet done: a manual browser pass (paste several images fast, cancel mid-flight, kill the network, reload with a ready draft) — automated browsers cannot see authenticated views here.

Built by Claude Fable 5 running in Claude Code.

🤖 Generated with Claude Code


Note

High Risk
Breaking attachment contract (no dataUrl), new signed upload HTTP surface and turn-start claim logic, plus draft v9 migration that drops unsent inline images on upgrade.

Overview
Composer images no longer ride in thread.turn.start as base64 data URLs. Web uploads bytes as soon as a chip is added: WS mints a pending-<uuid> id and signed URL, HTTP POST stores the file, and send carries id references only. The normalizer claims pending files into thread scope at turn start (uuid-stable resolution so retries and asset URLs survive renames).

Server additions:AttachmentUpload (token mint/validate, .part then rename, size enforcement), POST /api/attachments/upload/*, RPC attachments.createUploadUrl / attachments.delete, pending attachment sweep on startup, and planAttachmentClaim in the attachment store.

Web composer/drafts: Upload queue with progress, cancel, retry, and environment mismatch handling; send is blocked while uploads are unsettled; draft persistence bumps to v9 (server id + environmentId only—legacy inline dataUrl attachments are dropped on rehydrate). Stash and sidebar discard release pending server bytes.

Mobile: Image attach is disabled behind IMAGE_ATTACH_ENABLED = false; picks/pastes surface “needs an app update,” legacy draft/outbox images are not sent (warnings via droppedAttachmentsWarning), and buildProjectThreadStartTurnInput always sends attachments: [].

Reviewed by Cursor Bugbot for commit 36f6d76. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Upload composer image attachments immediately on attach instead of at send time

  • Images are now uploaded to the server as soon as they are added to the composer, using a background queue (attachmentUploadQueue.ts) with progress reporting, abort capability, and XHR-based upload to signed URLs.
  • Two new WebSocket RPCs (attachments.createUploadUrl and attachments.delete) mint signed upload URLs and delete pending attachments; the server validates tokens, enforces exact size, and persists bytes via a new POST endpoint.
  • ClientThreadTurnStartCommand payloads no longer inline base64 image data URLs; they carry ChatAttachment id references to pre-uploaded blobs. The composer send button is blocked while uploads are pending, failed, or belong to a different environment.
  • Switching environments triggers automatic re-upload of any images that still have a local File; discarding or removing an image aborts in-flight uploads and releases server-side bytes.
  • Composer draft persistence is bumped to v9: only ready (fully uploaded) attachments are persisted as environment-scoped id references; in-flight and failed uploads are not saved. Legacy v8 inline dataUrl entries are dropped on rehydration.
  • Mobile image attach is explicitly disabled via IMAGE_ATTACH_ENABLED = false in composerImages.ts; callers receive a structured error and a warning banner when legacy attachments are dropped.
  • Risk: any clients with persisted v8 composer drafts containing inline image data will silently lose those attachments on rehydration.

Macroscope summarized 36f6d76.

@coderabbitai

coderabbitaiBot commented Aug 12, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: bf950256-a9a4-410e-98ba-2154f6f01183

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@github-actionsgithub-actionsBot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:XXL 1,000+ changed lines (additions + deletions). labels Aug 12, 2026

@macroscopeappmacroscopeappBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Effect service conventions review of the changed server/contracts code. Two findings on error modeling; the new AttachmentUpload module itself follows the repo's service/DI conventions (namespace subpath imports, dependencies acquired via yield* Foo.Foo, no hidden runtimes).

Posted via Macroscope — Effect Service Conventions

Comment threadpackages/contracts/src/assets.ts Outdated
Comment threadapps/server/src/orchestration/Normalizer.ts
Comment threadapps/web/src/components/chat/ChatComposer.tsx
Comment threadapps/server/src/attachmentStore.ts
Comment threadapps/web/src/components/ChatView.tsx
Comment threadapps/web/src/lib/attachmentUploadQueue.ts
Comment threadapps/web/src/components/ChatView.tsx

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Medium

removePreviewAnnotation: (threadRef,annotationId)=>{

removePreviewAnnotation filters the annotation's image out of images but never calls releaseComposerAttachment or cancelAttachmentUpload. Removing a preview annotation while its image is still uploading leaves the upload job running, and removing one after upload completes leaves the server-side pending attachment allocated until the 30-day backstop sweep. The image disappears from the draft's images array, so the upload queue never sees it again to release or cancel it.

Also found in 1 other location(s)

apps/web/src/components/chat/ChatComposer.tsx:1510

The environment-retargeting path calls retryAttachmentUpload for an already-ready image but never releases its existing server attachment. retryAttachmentUpload only cancels an active job (there is none after readiness) and starts a new upload, whereas releaseComposerAttachment is the function that deletes a ready attachment. Switching a draft between environments therefore leaves one orphaned pending upload behind on each retarget until the 30-day sweep.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/web/src/composerDraftStore.ts around line 3253:
`removePreviewAnnotation` filters the annotation's image out of `images` but never calls `releaseComposerAttachment` or `cancelAttachmentUpload`. Removing a preview annotation while its image is still uploading leaves the upload job running, and removing one after upload completes leaves the server-side pending attachment allocated until the 30-day backstop sweep. The image disappears from the draft's `images` array, so the upload queue never sees it again to release or cancel it.
Also found in 1 other location(s):
- apps/web/src/components/chat/ChatComposer.tsx:1510 -- The environment-retargeting path calls `retryAttachmentUpload` for an already-`ready` image but never releases its existing server attachment. `retryAttachmentUpload` only cancels an active job (there is none after readiness) and starts a new upload, whereas `releaseComposerAttachment` is the function that deletes a ready attachment. Switching a draft between environments therefore leaves one orphaned pending upload behind on each retarget until the 30-day sweep.

Comment threadapps/web/src/components/chat/ChatComposer.tsx
Comment threadapps/web/src/components/chat/ChatComposer.tsx
Comment threadapps/web/src/components/ChatView.tsx
Comment threadapps/web/src/composerDraftStore.ts
Comment threadapps/web/src/lib/attachmentUploadQueue.ts
Comment threadapps/mobile/src/features/threads/NewTaskDraftScreen.tsx
@macroscopeapp

macroscopeappBot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Needs human review

2 blocking correctness issues found. This PR introduces substantial new capability (upload-on-attach for images) with new server endpoints, client upload queue, contract changes, and storage schema migrations. The scope exceeds what can be auto-approved, and there is an unresolved High-severity finding about stalled uploads potentially blocking sends indefinitely.

You can customize Macroscope's approvability policy. Learn more.

@github-actions

github-actionsBot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Thread transfer impact

✅ Thread transfer remains within every enforced ceiling.

ProviderMetricMain baselineThis PRImpactPR ceiling
CodexTotal thread wire11.3 KiB11.3 KiB−1 B (−0.0%)15.1 KiB
CodexThread snapshot wire5.5 KiB5.5 KiB+1 B (+0.0%)7.3 KiB
CodexLive turn WebSocket wire5.9 KiB5.9 KiB−2 B (−0.0%)7.8 KiB
CodexLive turn WebSocket decoded49.7 KiB49.7 KiB0 B (0.0%)66.4 KiB
CodexLive turn messages16160 (0.0%)21
ClaudeTotal thread wire11.3 KiB11.3 KiB0 B (0.0%)15.1 KiB
ClaudeThread snapshot wire5.5 KiB5.5 KiB+5 B (+0.1%)7.3 KiB
ClaudeLive turn WebSocket wire5.9 KiB5.9 KiB−5 B (−0.1%)7.8 KiB
ClaudeLive turn WebSocket decoded50.6 KiB50.6 KiB0 B (0.0%)66.4 KiB
ClaudeLive turn messages16160 (0.0%)21

Baseline: 5304f3e · PR result: 36f6d76 · Source CI: success

Scenario and decoded snapshot size

10 historical turns, 5 command tools per turn, 878.9 KiB retained MCP result per historical turn, and a 1.05 MiB retained result in the measured turn.

  • Codex decoded thread snapshot: 94.6 KiB
  • Claude decoded thread snapshot: 95.4 KiB

Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed.

t3dotggand others added 2 commits August 13, 2026 17:51
Attachments used to ride the turn-start command as base64 data URLs:
send carried the bytes, the stash re-encoded them into localStorage,
and a 10MB image meant a 14M-char string in a single ws frame.
Images now upload the moment they are attached. A ws RPC mints a
pending-<uuid> id plus a signed, expiring upload URL (mirroring signed
asset GETs, so it works against any environment); the browser POSTs the
compressed bytes to it with progress and abort; the turn-start command
carries id references only. The Normalizer renames pending files to
their thread segment at send, resolving by uuid so retries after a
partial send are idempotent. Never-sent uploads are deleted on chip
removal and swept after 30 days.
Breaking: the dataUrl upload variant is deleted with no compatibility
path. RN mobile compiles with image attach gated off behind
IMAGE_ATTACH_ENABLED and tells the user an update is needed; drafts and
stash persist id references (v9/v3 storage, old payloads purged).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Real bugs from Macroscope and Bugbot, all verified against source:
- awaitAttachmentUploads consulted only live jobs, so an upload that
settled before the await lost its result and a ready image could drop
from a pick-and-send message. Terminal states are now kept in a
settled map that cancel/release clean up.
- Stashing cancelled in-flight uploads before the localStorage write was
confirmed; a quota failure stranded chips in uploading forever.
Cancellation now happens only after the write lands.
- Retargeting a draft to another environment overwrote a reload-restored
attachment's ready state with a dead failed state. The mismatch is now
derived (never written), so switching back recovers the attachment,
and re-uploads release the old environment's bytes first.
- The turn-start failure path restored chips from the pre-await
snapshot, resurrecting uploading states no job would ever advance.
- The "images were not attached" toast could fire when no message was
sent; it now waits for the turn to actually start.
- Removing a preview annotation or discarding a draft leaked the upload
and its server-side bytes; both now release like chip removal.
- Mobile paste drops now surface the needs-an-update banner instead of
a console.warn.
- Normalizer error mappings keep their PlatformError causes; the dead
unstructured AttachmentUploadRequestError is deleted; the sweep test
no longer reads the real clock (effect-diagnostics CI failure).
Also folds the rebase onto main: the new moveComposerPromptAndImages
draft-carry now merges the unified image list, and in-flight uploads are
retargeted to the destination draft so moved chips keep their progress.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@t3dotgg
t3dotggforce-pushed the t3code/support-arbitrary-attachments branch from 1331956 to 03196efCompareAugust 14, 2026 01:03
Comment threadapps/web/src/components/chat/ChatComposer.tsx Outdated
Comment threadapps/server/src/assets/AttachmentUpload.ts
// moment they were attached. The composer blocks sending while an upload
// is in flight, but the preview "pick and send" gesture attaches and sends
// in one step, so wait for anything still running here.
const settledUploads = await awaitAttachmentUploads(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 Highcomponents/ChatView.tsx:5207

A stalled preview upload leaves the send operation pending indefinitely, so sendInFlightRef stays true and the message is neither sent nor restored. awaitAttachmentUploads waits on an XMLHttpRequest with the default timeout of 0, which never expires when the connection remains open; add an upload timeout or cancellation path before awaiting it.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/web/src/components/ChatView.tsx around line 5207:
A stalled preview upload leaves the send operation pending indefinitely, so `sendInFlightRef` stays `true` and the message is neither sent nor restored. `awaitAttachmentUploads` waits on an `XMLHttpRequest` with the default `timeout` of `0`, which never expires when the connection remains open; add an upload timeout or cancellation path before awaiting it.

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 3 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 03196ef. Configure here.

Comment threadapps/web/src/lib/attachmentUploadQueue.ts
Comment threadapps/web/src/components/ChatView.tsx
Comment threadapps/mobile/src/lib/composerImages.ts Outdated
- Unique per-request .part suffix so concurrent POSTs of one token
cannot interleave into a shared temp file.
- xhr.timeout on uploads: a stalled POST now fails into the retryable
state instead of blocking send (and pick-and-send) forever.
- Cross-environment re-upload keeps the old environment's copy until
the new upload succeeds (supersedes), so a failed re-upload never
destroys the only server copy.
- The plan follow-up branch releases attached images before clearing
the composer instead of orphaning their pending files.
- Mobile: a mixed text+image clipboard still pastes its text while
attach is disabled, and the image drop is surfaced.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@ElliotDrelChatGPT Codex Connector

Copy link
Copy Markdown

Non-blocking architecture follow-up for Prompt Stash: this PR solves the hard attachment half by moving image bytes onto the target T3 environment, but apps/web/src/promptStashStore.ts still makes browser localStorage the authoritative stash store. That means stash metadata still cannot follow the same environment across desktop/web/mobile or another controlling machine, even though the attachment bytes now can.

I think the clean follow-up is to make the target ExecutionEnvironment/server authoritative for stash state, rather than putting canonical stash state in T3 Connect or a thread snapshot:

  • add server-side durable stash metadata keyed to the environment;
  • expose idempotent mutations such as prompt-stash.create, atomic prompt-stash.take, and prompt-stash.delete (plus attachment finalization only if still needed);
  • expose a separate environment-level stash subscription/snapshot, e.g. orchestration.subscribePromptStash, rather than piggybacking on subscribeThread because the stash is intentionally thread/provider agnostic;
  • put the replicated client state in packages/client-runtime so web and mobile consume the same source;
  • reuse the server attachment IDs introduced by this PR, so stash synchronization moves references rather than base64/blob payloads;
  • keep client storage only as an optional pending outbox for offline writes. A normal stash should clear the composer only after the target server acknowledges the durable write.

T3 Connect then remains transport to the execution environment instead of becoming a second state authority.

I would keep this out of this PR because #6276 is already a large/high-risk attachment-contract change. The server-owned stash is a cleaner follow-up PR once this attachment model is stable.

@ElliotDrelChatGPT Codex Connector

Copy link
Copy Markdown

Follow-up tracked in #7626. That issue is intentionally sequenced after this PR and should build on this PR's server-side attachment ID model rather than expand #6276 further.

@t3dotgg

Copy link
Copy Markdown
MemberAuthor

Note

🤖 GPT-5.6 Sol responding on behalf of Theo

Closing in favor of #8048. Image attachments now upload before sending, with compatibility preserved for existing mobile clients and older servers.

@t3dotggt3dotgg closed this Aug 24, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL1,000+ changed lines (additions + deletions).vouch:trustedPR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@t3dotgg@ElliotDrel
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

feat: images upload the moment you attach them - #6276

Closed
t3dotgg wants to merge 3 commits into
mainfrom
t3code/support-arbitrary-attachments
Closed

feat: images upload the moment you attach them#6276
t3dotgg wants to merge 3 commits into
mainfrom
t3code/support-arbitrary-attachments

Conversation

@t3dotgg

@t3dotggt3dotgg commented Aug 12, 2026

Copy link
Copy Markdown
Member

Attached images used to ride inside the send command as base64 data URLs. Sending a message with a 10MB screenshot meant a 14M-character string in one ws frame: send felt slow, the turn could not start until the frame landed, and the stash had to re-encode every image to squeeze it into localStorage.

Now the bytes move while you are still typing. Attaching an image mints a pending-<uuid> id plus a signed upload URL over ws (the same pattern as signed asset GETs, so it works against any environment), and the browser POSTs the compressed bytes to it with per-chip progress and cancel. The send command carries id references only, and the server renames pending files to their thread at turn start — resolving by uuid, so send retries and existing asset URLs survive the rename.

What this buys:

  • Send is instant; upload overlaps with typing.
  • Drafts with images now survive reloads fully (previews via signed asset URLs). The localStorage re-encode/budget machinery is deleted.
  • The send button blocks while a chip is uploading or failed, so an image can never be silently dropped from a sent message.
  • Never-sent uploads are deleted on chip removal, with a 30-day sweep as backstop. Uploads write .part then rename, so a partial transfer is never a valid attachment, and the route enforces the exact minted byte count.

Breaking, deliberately without a compatibility path: the dataUrl upload variant is deleted. Out-of-date clients get a loud schema error. React Native mobile compiles and degrades honestly behind IMAGE_ATTACH_ENABLED = false ("Image attach needs an app update"); the port is a fast-follow. apps/swift-ios is not on main, so the Swift mirror is untouched — a 7-edit patch list for that branch is in the plan doc.

Design doc: https://rztz3kvilrh0.postplan.dev

Verified: contracts 227, server attachment suites 66 (full suite green except 6 launcher-version failures that reproduce on untouched main), web 2201, mobile 654. Lint and format clean. Not yet done: a manual browser pass (paste several images fast, cancel mid-flight, kill the network, reload with a ready draft) — automated browsers cannot see authenticated views here.

Built by Claude Fable 5 running in Claude Code.

🤖 Generated with Claude Code


Note

High Risk
Breaking attachment contract (no dataUrl), new signed upload HTTP surface and turn-start claim logic, plus draft v9 migration that drops unsent inline images on upgrade.

Overview
Composer images no longer ride in thread.turn.start as base64 data URLs. Web uploads bytes as soon as a chip is added: WS mints a pending-<uuid> id and signed URL, HTTP POST stores the file, and send carries id references only. The normalizer claims pending files into thread scope at turn start (uuid-stable resolution so retries and asset URLs survive renames).

Server additions:AttachmentUpload (token mint/validate, .part then rename, size enforcement), POST /api/attachments/upload/*, RPC attachments.createUploadUrl / attachments.delete, pending attachment sweep on startup, and planAttachmentClaim in the attachment store.

Web composer/drafts: Upload queue with progress, cancel, retry, and environment mismatch handling; send is blocked while uploads are unsettled; draft persistence bumps to v9 (server id + environmentId only—legacy inline dataUrl attachments are dropped on rehydrate). Stash and sidebar discard release pending server bytes.

Mobile: Image attach is disabled behind IMAGE_ATTACH_ENABLED = false; picks/pastes surface “needs an app update,” legacy draft/outbox images are not sent (warnings via droppedAttachmentsWarning), and buildProjectThreadStartTurnInput always sends attachments: [].

Reviewed by Cursor Bugbot for commit 36f6d76. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Upload composer image attachments immediately on attach instead of at send time

  • Images are now uploaded to the server as soon as they are added to the composer, using a background queue (attachmentUploadQueue.ts) with progress reporting, abort capability, and XHR-based upload to signed URLs.
  • Two new WebSocket RPCs (attachments.createUploadUrl and attachments.delete) mint signed upload URLs and delete pending attachments; the server validates tokens, enforces exact size, and persists bytes via a new POST endpoint.
  • ClientThreadTurnStartCommand payloads no longer inline base64 image data URLs; they carry ChatAttachment id references to pre-uploaded blobs. The composer send button is blocked while uploads are pending, failed, or belong to a different environment.
  • Switching environments triggers automatic re-upload of any images that still have a local File; discarding or removing an image aborts in-flight uploads and releases server-side bytes.
  • Composer draft persistence is bumped to v9: only ready (fully uploaded) attachments are persisted as environment-scoped id references; in-flight and failed uploads are not saved. Legacy v8 inline dataUrl entries are dropped on rehydration.
  • Mobile image attach is explicitly disabled via IMAGE_ATTACH_ENABLED = false in composerImages.ts; callers receive a structured error and a warning banner when legacy attachments are dropped.
  • Risk: any clients with persisted v8 composer drafts containing inline image data will silently lose those attachments on rehydration.

Macroscope summarized 36f6d76.

@coderabbitai

coderabbitaiBot commented Aug 12, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: bf950256-a9a4-410e-98ba-2154f6f01183

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@github-actionsgithub-actionsBot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:XXL 1,000+ changed lines (additions + deletions). labels Aug 12, 2026

@macroscopeappmacroscopeappBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Effect service conventions review of the changed server/contracts code. Two findings on error modeling; the new AttachmentUpload module itself follows the repo's service/DI conventions (namespace subpath imports, dependencies acquired via yield* Foo.Foo, no hidden runtimes).

Posted via Macroscope — Effect Service Conventions

Comment threadpackages/contracts/src/assets.ts Outdated
Comment threadapps/server/src/orchestration/Normalizer.ts
Comment threadapps/web/src/components/chat/ChatComposer.tsx
Comment threadapps/server/src/attachmentStore.ts
Comment threadapps/web/src/components/ChatView.tsx
Comment threadapps/web/src/lib/attachmentUploadQueue.ts
Comment threadapps/web/src/components/ChatView.tsx

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Medium

removePreviewAnnotation: (threadRef,annotationId)=>{

removePreviewAnnotation filters the annotation's image out of images but never calls releaseComposerAttachment or cancelAttachmentUpload. Removing a preview annotation while its image is still uploading leaves the upload job running, and removing one after upload completes leaves the server-side pending attachment allocated until the 30-day backstop sweep. The image disappears from the draft's images array, so the upload queue never sees it again to release or cancel it.

Also found in 1 other location(s)

apps/web/src/components/chat/ChatComposer.tsx:1510

The environment-retargeting path calls retryAttachmentUpload for an already-ready image but never releases its existing server attachment. retryAttachmentUpload only cancels an active job (there is none after readiness) and starts a new upload, whereas releaseComposerAttachment is the function that deletes a ready attachment. Switching a draft between environments therefore leaves one orphaned pending upload behind on each retarget until the 30-day sweep.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/web/src/composerDraftStore.ts around line 3253:
`removePreviewAnnotation` filters the annotation's image out of `images` but never calls `releaseComposerAttachment` or `cancelAttachmentUpload`. Removing a preview annotation while its image is still uploading leaves the upload job running, and removing one after upload completes leaves the server-side pending attachment allocated until the 30-day backstop sweep. The image disappears from the draft's `images` array, so the upload queue never sees it again to release or cancel it.
Also found in 1 other location(s):
- apps/web/src/components/chat/ChatComposer.tsx:1510 -- The environment-retargeting path calls `retryAttachmentUpload` for an already-`ready` image but never releases its existing server attachment. `retryAttachmentUpload` only cancels an active job (there is none after readiness) and starts a new upload, whereas `releaseComposerAttachment` is the function that deletes a ready attachment. Switching a draft between environments therefore leaves one orphaned pending upload behind on each retarget until the 30-day sweep.

Comment threadapps/web/src/components/chat/ChatComposer.tsx
Comment threadapps/web/src/components/chat/ChatComposer.tsx
Comment threadapps/web/src/components/ChatView.tsx
Comment threadapps/web/src/composerDraftStore.ts
Comment threadapps/web/src/lib/attachmentUploadQueue.ts
Comment threadapps/mobile/src/features/threads/NewTaskDraftScreen.tsx
@macroscopeapp

macroscopeappBot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Needs human review

2 blocking correctness issues found. This PR introduces substantial new capability (upload-on-attach for images) with new server endpoints, client upload queue, contract changes, and storage schema migrations. The scope exceeds what can be auto-approved, and there is an unresolved High-severity finding about stalled uploads potentially blocking sends indefinitely.

You can customize Macroscope's approvability policy. Learn more.

@github-actions

github-actionsBot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Thread transfer impact

✅ Thread transfer remains within every enforced ceiling.

ProviderMetricMain baselineThis PRImpactPR ceiling
CodexTotal thread wire11.3 KiB11.3 KiB−1 B (−0.0%)15.1 KiB
CodexThread snapshot wire5.5 KiB5.5 KiB+1 B (+0.0%)7.3 KiB
CodexLive turn WebSocket wire5.9 KiB5.9 KiB−2 B (−0.0%)7.8 KiB
CodexLive turn WebSocket decoded49.7 KiB49.7 KiB0 B (0.0%)66.4 KiB
CodexLive turn messages16160 (0.0%)21
ClaudeTotal thread wire11.3 KiB11.3 KiB0 B (0.0%)15.1 KiB
ClaudeThread snapshot wire5.5 KiB5.5 KiB+5 B (+0.1%)7.3 KiB
ClaudeLive turn WebSocket wire5.9 KiB5.9 KiB−5 B (−0.1%)7.8 KiB
ClaudeLive turn WebSocket decoded50.6 KiB50.6 KiB0 B (0.0%)66.4 KiB
ClaudeLive turn messages16160 (0.0%)21

Baseline: 5304f3e · PR result: 36f6d76 · Source CI: success

Scenario and decoded snapshot size

10 historical turns, 5 command tools per turn, 878.9 KiB retained MCP result per historical turn, and a 1.05 MiB retained result in the measured turn.

  • Codex decoded thread snapshot: 94.6 KiB
  • Claude decoded thread snapshot: 95.4 KiB

Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed.

t3dotggand others added 2 commits August 13, 2026 17:51
Attachments used to ride the turn-start command as base64 data URLs:
send carried the bytes, the stash re-encoded them into localStorage,
and a 10MB image meant a 14M-char string in a single ws frame.
Images now upload the moment they are attached. A ws RPC mints a
pending-<uuid> id plus a signed, expiring upload URL (mirroring signed
asset GETs, so it works against any environment); the browser POSTs the
compressed bytes to it with progress and abort; the turn-start command
carries id references only. The Normalizer renames pending files to
their thread segment at send, resolving by uuid so retries after a
partial send are idempotent. Never-sent uploads are deleted on chip
removal and swept after 30 days.
Breaking: the dataUrl upload variant is deleted with no compatibility
path. RN mobile compiles with image attach gated off behind
IMAGE_ATTACH_ENABLED and tells the user an update is needed; drafts and
stash persist id references (v9/v3 storage, old payloads purged).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Real bugs from Macroscope and Bugbot, all verified against source:
- awaitAttachmentUploads consulted only live jobs, so an upload that
settled before the await lost its result and a ready image could drop
from a pick-and-send message. Terminal states are now kept in a
settled map that cancel/release clean up.
- Stashing cancelled in-flight uploads before the localStorage write was
confirmed; a quota failure stranded chips in uploading forever.
Cancellation now happens only after the write lands.
- Retargeting a draft to another environment overwrote a reload-restored
attachment's ready state with a dead failed state. The mismatch is now
derived (never written), so switching back recovers the attachment,
and re-uploads release the old environment's bytes first.
- The turn-start failure path restored chips from the pre-await
snapshot, resurrecting uploading states no job would ever advance.
- The "images were not attached" toast could fire when no message was
sent; it now waits for the turn to actually start.
- Removing a preview annotation or discarding a draft leaked the upload
and its server-side bytes; both now release like chip removal.
- Mobile paste drops now surface the needs-an-update banner instead of
a console.warn.
- Normalizer error mappings keep their PlatformError causes; the dead
unstructured AttachmentUploadRequestError is deleted; the sweep test
no longer reads the real clock (effect-diagnostics CI failure).
Also folds the rebase onto main: the new moveComposerPromptAndImages
draft-carry now merges the unified image list, and in-flight uploads are
retargeted to the destination draft so moved chips keep their progress.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@t3dotgg
t3dotggforce-pushed the t3code/support-arbitrary-attachments branch from 1331956 to 03196efCompareAugust 14, 2026 01:03
Comment threadapps/web/src/components/chat/ChatComposer.tsx Outdated
Comment threadapps/server/src/assets/AttachmentUpload.ts
// moment they were attached. The composer blocks sending while an upload
// is in flight, but the preview "pick and send" gesture attaches and sends
// in one step, so wait for anything still running here.
const settledUploads = await awaitAttachmentUploads(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 Highcomponents/ChatView.tsx:5207

A stalled preview upload leaves the send operation pending indefinitely, so sendInFlightRef stays true and the message is neither sent nor restored. awaitAttachmentUploads waits on an XMLHttpRequest with the default timeout of 0, which never expires when the connection remains open; add an upload timeout or cancellation path before awaiting it.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/web/src/components/ChatView.tsx around line 5207:
A stalled preview upload leaves the send operation pending indefinitely, so `sendInFlightRef` stays `true` and the message is neither sent nor restored. `awaitAttachmentUploads` waits on an `XMLHttpRequest` with the default `timeout` of `0`, which never expires when the connection remains open; add an upload timeout or cancellation path before awaiting it.

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 3 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 03196ef. Configure here.

Comment threadapps/web/src/lib/attachmentUploadQueue.ts
Comment threadapps/web/src/components/ChatView.tsx
Comment threadapps/mobile/src/lib/composerImages.ts Outdated
- Unique per-request .part suffix so concurrent POSTs of one token
cannot interleave into a shared temp file.
- xhr.timeout on uploads: a stalled POST now fails into the retryable
state instead of blocking send (and pick-and-send) forever.
- Cross-environment re-upload keeps the old environment's copy until
the new upload succeeds (supersedes), so a failed re-upload never
destroys the only server copy.
- The plan follow-up branch releases attached images before clearing
the composer instead of orphaning their pending files.
- Mobile: a mixed text+image clipboard still pastes its text while
attach is disabled, and the image drop is surfaced.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@ElliotDrelChatGPT Codex Connector

Copy link
Copy Markdown

Non-blocking architecture follow-up for Prompt Stash: this PR solves the hard attachment half by moving image bytes onto the target T3 environment, but apps/web/src/promptStashStore.ts still makes browser localStorage the authoritative stash store. That means stash metadata still cannot follow the same environment across desktop/web/mobile or another controlling machine, even though the attachment bytes now can.

I think the clean follow-up is to make the target ExecutionEnvironment/server authoritative for stash state, rather than putting canonical stash state in T3 Connect or a thread snapshot:

  • add server-side durable stash metadata keyed to the environment;
  • expose idempotent mutations such as prompt-stash.create, atomic prompt-stash.take, and prompt-stash.delete (plus attachment finalization only if still needed);
  • expose a separate environment-level stash subscription/snapshot, e.g. orchestration.subscribePromptStash, rather than piggybacking on subscribeThread because the stash is intentionally thread/provider agnostic;
  • put the replicated client state in packages/client-runtime so web and mobile consume the same source;
  • reuse the server attachment IDs introduced by this PR, so stash synchronization moves references rather than base64/blob payloads;
  • keep client storage only as an optional pending outbox for offline writes. A normal stash should clear the composer only after the target server acknowledges the durable write.

T3 Connect then remains transport to the execution environment instead of becoming a second state authority.

I would keep this out of this PR because #6276 is already a large/high-risk attachment-contract change. The server-owned stash is a cleaner follow-up PR once this attachment model is stable.

@ElliotDrelChatGPT Codex Connector

Copy link
Copy Markdown

Follow-up tracked in #7626. That issue is intentionally sequenced after this PR and should build on this PR's server-side attachment ID model rather than expand #6276 further.

@t3dotgg

Copy link
Copy Markdown
MemberAuthor

Note

🤖 GPT-5.6 Sol responding on behalf of Theo

Closing in favor of #8048. Image attachments now upload before sending, with compatibility preserved for existing mobile clients and older servers.

@t3dotggt3dotgg closed this Aug 24, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL1,000+ changed lines (additions + deletions).vouch:trustedPR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@t3dotgg@ElliotDrel