Skip to content

fix(web): allow retrying failed attachment uploads - #1722

Open
techotaku39 wants to merge 5 commits into
tiann:mainfrom
techotaku39:fix/attachment-upload-retry
Open

fix(web): allow retrying failed attachment uploads#1722
techotaku39 wants to merge 5 commits into
tiann:mainfrom
techotaku39:fix/attachment-upload-retry

Conversation

@techotaku39

@techotaku39 techotaku39 commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Add an inline refresh-style retry control for failed composer attachments.
  • Reuse the existing attachment control geometry while hiding the failed-state text and preserving the upstream drag/remove layout.
  • Create a fresh File for retries so restored draft attachments receive a new upload ID.
  • Delegate retry eligibility to the active attachment adapter so deterministic size, MIME, and quota failures do not expose a retry action while transient failures remain retryable.
  • Preserve the original attachment position as soon as the replacement appears, including during the upload.
  • Balance the remove-button spacing when a long filename is truncated, including when an attachment transitions into the error layout.
  • Keep non-retryable failures accessible with a localized hidden failure label.
  • Add localized accessible labels and regression coverage for the retry flow, adapter behavior, ordering, and layout.
  • No API, database, dependency, or migration changes.

Validation

  • bun typecheck — passed.
  • pwsh -NoProfile -File .\scripts\Invoke-HapiTaskPlaywright.ps1 -Name attachment-upload-retry -Suite Root -TestArgs 'terminal-wrap-fidelity.spec.ts' — 2 passed.
  • bun --cwd web test src/components/AssistantChat/AttachmentItem.test.tsx src/components/AssistantChat/SortableComposerAttachments.test.tsx src/lib/attachmentAdapter.test.ts src/lib/scratchlistAttachmentAdapter.test.ts src/lib/attachmentOrder.test.ts — 42 passed.
  • bun run build — passed.

Related Issues

None

AI Assistance

OpenAI Codex with GPT-5.6 assisted with implementation, testing, and validation.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Findings

  • [Major] Retrying a draft-restored failure reuses an ID that removal has already cancelled — restored File objects retain their attachment ID (web/src/lib/composer-attachment-drafts.ts:60, web/src/lib/attachmentAdapter.ts:52), while removal permanently adds that ID to cancelledAttachmentIds (web/src/lib/attachmentAdapter.ts:186). Re-adding the same File at web/src/components/AssistantChat/AttachmentItem.tsx:151 therefore reuses the cancelled ID and exits at attachmentAdapter.ts:91 before the upload.
    Suggested fix:
    const retryFile = new File([file], file.name, {
        type: file.type,
        lastModified: file.lastModified,
    })
    await attachmentRuntime.remove()
    await composer.addAttachment(retryFile)
  • [Minor] The truncation observer is not installed when a previewed image transitions into the error layout — the first render returns the preview branch with filenameRef.current === null; because the effect depends only on name, the unchanged filename does not trigger it when isError later mounts the span (web/src/components/AssistantChat/AttachmentItem.tsx:134).
    Suggested fix:
    }, [name, isError])
  • [Minor] Oversized files receive a retry control even though every retry is guaranteed to fail — the adapter maps file.size > MAX_UPLOAD_BYTES to the same incomplete/error state (web/src/lib/attachmentAdapter.ts:95), and the new showRetry condition exposes retry for all such states (web/src/components/AssistantChat/AttachmentItem.tsx:118).
    Suggested fix:
    import { MAX_UPLOAD_BYTES } from '@/lib/attachmentAdapter'
    
    const isRetryableError = isError && file.size <= MAX_UPLOAD_BYTES
    const showRetry = isRetryableError && !isParking

Summary

  • Review mode: initial
  • One major correctness issue blocks retry for restored failed drafts; two minor gaps affect deterministic oversize failures and error-layout spacing.

Testing

  • Not run (automation/security constraint: PR code was not executed). Add an adapter-integrated retry test using a pathless draft-restored File, a render transition test from preview/running to incomplete/error with a long filename, and an oversized-file retry visibility test.

HAPI Bot

setIsRetrying(true)
try {
await attachmentRuntime.remove()
await composer.addAttachment(file)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[MAJOR] Retrying a restored draft can be cancelled immediately. Draft-restored File objects retain their old attachment ID in composer-attachment-drafts.ts, and attachmentRuntime.remove() adds that ID to the adapter's permanent cancelledAttachmentIds set. Passing the same File back into addAttachment reuses the old ID, so attachmentAdapter.ts:91 returns before uploading. This makes retry silently remove pathless restored failures after a reload.

Suggested fix:

const retryFile = new File([file], file.name, {
    type: file.type,
    lastModified: file.lastModified,
})
await attachmentRuntime.remove()
await composer.addAttachment(retryFile)

Also cover this with the real adapter: restore a pathless file with an existing ID, fail its upload, retry, and assert a second upload starts under a fresh ID.

const observer = new ResizeObserver(updateTruncation)
observer.observe(element)
return () => observer.disconnect()
}, [name])

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[MINOR] Re-run this effect when the error branch mounts. A previewed image initially returns the image branch, so filenameRef.current is null and this effect exits. When that upload later becomes incomplete, the filename is unchanged; the span mounts but the [name] dependency prevents installing the observer, so the new truncated-name remove margin is never applied for this common transition.

Suggested fix:

}, [name, isError])

Add a rerender test that starts with previewUrl + running status, then changes to incomplete/error with a long filename.

const [isFilenameTruncated, setIsFilenameTruncated] = useState(false)
const isUploading = status.type === 'running'
const isError = status.type === 'incomplete'
const showRetry = isError && !isParking

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[MINOR] Do not offer retry for the adapter's deterministic size failure. attachmentAdapter.ts:95 emits this same incomplete/error state when file.size > MAX_UPLOAD_BYTES; retrying cannot change that input, so the new control loops forever without a possible success.

Suggested fix:

import { MAX_UPLOAD_BYTES } from '@/lib/attachmentAdapter'

const isRetryableError = isError && file.size <= MAX_UPLOAD_BYTES
const showRetry = isRetryableError && !isParking

Keep a non-retry error indicator for oversized files and add a visibility test at the boundary.

@techotaku39

Copy link
Copy Markdown
Contributor Author

Addressed in 48cde1d:

  • Clone the File before removing the failed attachment so restored draft metadata cannot reuse a cancelled attachment ID.
  • Re-run truncation measurement when the error layout mounts.
  • Hide Retry for deterministic oversized-file failures and retain the error indicator.
  • Added component and adapter regression coverage for all three cases.

The updated targeted tests, typecheck, build, and terminal Playwright check pass.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Findings

  • [Minor] Retry eligibility uses the normal chat upload ceiling instead of the active attachment adapter — the same component is used with the scratchlist adapter, whose per-file limit is configurable and defaults to 10 MB. A scratchlist rejection between 10–50 MB still exposes retry and repeats the full base64 upload, while a configured scratchlist limit above 50 MB suppresses retry for otherwise valid transient failures. Evidence: web/src/components/AssistantChat/AttachmentItem.tsx:119, web/src/components/SessionChat.tsx:1659, shared/src/scratchlistAttachments.ts:22, hub/src/config/scratchlistAttachmentLimits.ts:20.
    Suggested fix:

type ComposerAttachmentWithPreview = PendingAttachment & {
previewUrl?: string
retryable?: boolean
}

const showRetry = isError && retryable !== false && !isParking

Have each adapter set `retryable: false` for its own deterministic failures, including normal oversize and scratchlist size/MIME/quota validation:
```ts
yield {
  id,
  type: 'file',
  name: file.name,
  contentType,
  file,
  status: { type: 'incomplete', reason: 'error' },
  retryable: false,
}

Summary

  • Review mode: follow-up after new commits
  • The three prior bot findings are addressed. One minor cross-adapter retry eligibility issue remains.

Testing

  • Not run (automation/security constraint: PR code was not executed). Add coverage for a deterministic scratchlist rejection below 50 MB and an adapter-provided retryability flag.

HAPI Bot

const [isFilenameTruncated, setIsFilenameTruncated] = useState(false)
const isUploading = status.type === 'running'
const isError = status.type === 'incomplete'
const isRetryableError = isError && file.size <= MAX_UPLOAD_BYTES

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[MINOR] Use adapter-specific retryability here. This component is also rendered with createScratchlistAttachmentAdapter when scratchlist mode is active (SessionChat.tsx:1659). Scratchlist uploads default to 10 MB per file and can be configured independently (shared/src/scratchlistAttachments.ts:22, hub/src/config/scratchlistAttachmentLimits.ts:20), and size/MIME/quota rejections all become the same incomplete/error state in scratchlistAttachmentAdapter.ts:138. Therefore a deterministic 10–50 MB scratchlist failure still shows this retry button and repeats the full upload; conversely, an operator-configured limit above 50 MB hides retry after a transient failure.

Suggested fix:

type ComposerAttachmentWithPreview = PendingAttachment & {
    previewUrl?: string
    retryable?: boolean
}

const showRetry = isError && retryable !== false && !isParking

Then have each adapter emit retryable: false from its own deterministic size/MIME/quota failure branches instead of applying the chat adapter constant globally.

@techotaku39

Copy link
Copy Markdown
Contributor Author

Addressed in 478adfc:

  • Retry eligibility now comes from the active attachment adapter instead of a hard-coded chat upload limit.
  • Normal and scratchlist adapters mark deterministic size/MIME/quota failures as non-retryable while leaving transient failures retryable.
  • Added adapter and component regression coverage for deterministic and transient failures.

Validation on the new HEAD: 32 targeted tests passed, bun typecheck passed, and bun run build passed.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Findings

  • [Minor] Retrying a failed attachment moves it to the end — the retry removes the old ID and adds a fresh ID, while the order reconciler appends new IDs. This changes user-visible and submitted attachment order. Evidence: web/src/components/AssistantChat/AttachmentItem.tsx:155, web/src/lib/attachmentOrder.ts:15, web/src/lib/assistant-runtime.ts:930.

    Suggested fix:

    // Preserve the old order index and replace it with the new retry id.
    const originalIndex = attachmentOrderRef.current.indexOf(oldId)
    await retryAttachment()
    replaceAttachmentIdAt(originalIndex, newId)

Summary

  • Review mode: follow-up after new commits
  • One minor ordering regression remains on the latest head.

Testing

  • Not run (automation/security constraint: PR code was not executed).
  • Add a regression test with three attachments: retry the first and middle items, then assert both rendered order and the order passed to send/scratchlist park.
  • Add an adapter test using a thrown ApiError with a deterministic scratchlist code; the current new test covers only a resolved success: false result, while the real client throws on HTTP 400/413.

HAPI Bot

lastModified: file.lastModified,
})
await attachmentRuntime.remove()
await composer.addAttachment(retryFile)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[MINOR] Preserve the attachment's position when retrying. Removing the failed attachment and then adding a fresh-ID attachment makes the runtime append the retry as a new item. The order reconciler appends unknown IDs (web/src/lib/attachmentOrder.ts:15), and that reconciled order is used for outgoing attachments (web/src/lib/assistant-runtime.ts:930) and scratchlist parks (web/src/components/AssistantChat/HappyComposer.tsx:1163). Retrying the first or middle item therefore silently moves it to the end.

Suggested fix:

// Route retry through HappyComposer, which owns attachmentOrderRef.
const originalIndex = attachmentOrderRef.current.indexOf(oldId)
await remove()
await addAttachment(retryFile)
const newId = composer.getState().attachments.find(
    (attachment) => !idsBeforeRetry.has(attachment.id),
)?.id
if (newId) {
    const next = attachmentOrderRef.current.filter((id) => id !== newId)
    next.splice(Math.max(0, originalIndex), 0, newId)
    attachmentOrderRef.current = next
}

@techotaku39

Copy link
Copy Markdown
Contributor Author

Addressed in 8ee0e98:

  • Preserve the failed attachment's original index when replacing it with the fresh retry attachment ID.
  • Thread the shared attachment-order ref through the sortable attachment components.
  • Added regression coverage for the replacement helper and retry callback.

Validation on the new HEAD: 42 targeted tests passed, bun typecheck passed, and bun run build passed.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Findings

  • [Minor] Restore the retry slot when the replacement first appears — composer.addAttachment() resolves only after the adapter's async generator finishes, while both upload adapters await the network request. The replacement therefore sits at the reconciler-appended tail for the whole retry, and the late callback can overwrite a user reorder made during that upload. Evidence: web/src/components/AssistantChat/AttachmentItem.tsx:173, web/src/lib/attachmentAdapter.ts:144, web/src/lib/scratchlistAttachmentAdapter.ts:145.

    Suggested fix:

    const unsubscribe = composer.subscribe(() => {
        const retry = composer.getState().attachments.find((item) => item.file === retryFile)
        if (!retry) return
        unsubscribe()
        props.onRetry?.(id, retry.id, originalIndex)
    })
    try {
        await attachmentRuntime.remove()
        await composer.addAttachment(retryFile)
    } finally {
        unsubscribe()
    }
  • [Minor] Keep an accessible label for non-retryable upload failures — the visible "Upload failed" text was removed, but the fallback error icon has no role or accessible text. A screen reader now announces only the struck-through filename and remove control, not that the upload failed. Evidence: web/src/components/AssistantChat/AttachmentItem.tsx:250.

    Suggested fix:

    <>
        <span aria-hidden="true" className="text-red-500"><ErrorIcon /></span>
        <span className="sr-only">{t('attachment.uploadFailed')}</span>
    </>

Questions

  • None.

Summary
Review mode: follow-up after new commits

The previous ordering finding is only partially resolved: final order is restored, but not during the retry upload. One accessibility regression also remains for non-retryable failures.

Testing

  • Not run (automation/security constraint: PR code was not executed).
  • Add a deferred-upload component test asserting the replacement occupies the original slot immediately and a user reorder during upload is not overwritten on completion.
  • Add an accessibility assertion that a non-retryable failure exposes localized failure text.

HAPI Bot

lastModified: file.lastModified,
})
await attachmentRuntime.remove()
await composer.addAttachment(retryFile)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[MINOR] Restore the retry slot before the upload finishes.

In @assistant-ui/react@0.14.29, addAttachment() drains the adapter's async generator before resolving. Both HAPI adapters yield the new attachment first, then await the upload (web/src/lib/attachmentAdapter.ts:144, web/src/lib/scratchlistAttachmentAdapter.ts:145). Waiting here means the new ID is rendered at the reconciler-appended tail for the whole network request. Because running attachments remain draggable, the later onRetry also overwrites any reorder the user makes while that request is pending.

Suggested fix:

const unsubscribe = composer.subscribe(() => {
    const retry = composer.getState().attachments.find((item) => item.file === retryFile)
    if (!retry) return
    unsubscribe()
    props.onRetry?.(id, retry.id, originalIndex)
})
try {
    await attachmentRuntime.remove()
    await composer.addAttachment(retryFile)
} finally {
    unsubscribe()
}

{isUploading ? <Spinner size="sm" label={null} className="text-[var(--app-hint)]" /> : null}
{isError ? (
<span className="text-red-500">
{isError && (!showRetry || isParking) ? (

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[MINOR] Preserve an accessible upload-failure label.

This branch replaces the previous "Upload failed" text with an unlabeled SVG. For a non-retryable failure there is no retry button either, so assistive technology receives the filename and remove control but no indication that the upload failed.

Suggested fix:

<>
    <span aria-hidden="true" className="text-red-500"><ErrorIcon /></span>
    <span className="sr-only">{t('attachment.uploadFailed')}</span>
</>

Add attachment.uploadFailed to both locale files.

@techotaku39

Copy link
Copy Markdown
Contributor Author

Addressed in 694c7e0:

  • Subscribe to composer changes so the replacement ID is moved into the original order slot as soon as its first pending state appears.
  • Avoid late order callbacks overwriting a reorder made while the upload is in flight.
  • Add a localized screen-reader-only “Upload failed” label for non-retryable failures.

Validation on the new HEAD: 42 targeted tests passed, bun typecheck passed, and bun run build passed.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Findings

  • None.

Questions

  • None.

Summary
Review mode: follow-up after new commits

No issues found in the latest full diff. The prior ordering and accessible failure-label findings are addressed. Residual testing risk: retry ordering is verified with a mocked composer subscription, not a real assistant-ui runtime integration test.

Testing

  • Not run (automation/security constraint: PR code was not executed).
  • GitHub checks at review time: integration passed; test was still in progress.
  • Added unit coverage exercises retry file identity, immediate slot replacement, adapter retryability, and the non-retryable accessible label.

HAPI Bot

@heavygee heavygee added area:web Web PWA / React client bug Something isn't working community-pr PR from non-collaborator contributor labels Sep 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:web Web PWA / React client bug Something isn't working community-pr PR from non-collaborator contributor

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants