Skip to content

fix(web): retain composer text on send failure (closes #776)#773

Open
heavygee wants to merge 1 commit into
tiann:mainfrom
heavygee:fix/composer-retain-text-on-error
Open

fix(web): retain composer text on send failure (closes #776)#773
heavygee wants to merge 1 commit into
tiann:mainfrom
heavygee:fix/composer-retain-text-on-error

Conversation

@heavygee
Copy link
Copy Markdown
Collaborator

@heavygee heavygee commented Jun 2, 2026

Closes #776.

Problem

Submitting a message while the hub is unreachable (a daily-rebuild restart blip, a 5xx, or any 4xx) clears the composer immediately, destroying the typed text. Operators have to retype before retrying.

@assistant-ui/react's composer clears composer.text synchronously when send is invoked, so by the time useSendMessage's mutation rejects, the text is already gone. SessionChat additionally clears the pending schedule the moment the mutation is accepted, so a failed scheduled send was also silently downgrading to immediate on the next attempt.

Fix

  • useSendMessage exposes a new onError option that hands the original input back to the caller alongside the thrown error AND the resolved scheduledAt (absolute epoch-ms or null).
  • router.tsx owns a sendError state, set from onError and cleared on success or session change.
  • HappyComposer accepts sendError and:
    • Calls api.composer().setText(sendError.text) once per failure id, restoring the typed text.
    • When scheduledAt is non-null, also calls onSchedule({ type: 'absolute', ms: scheduledAt }) so the clock button re-activates and the schedule fires at the originally intended instant.
    • Renders a red ring on the composer wrapper and a role="alert" inline message; both clear the moment the operator starts editing.

Acceptance (issue #776)

  • Submit -> 500/502/503/network error -> composer text not cleared
  • Submit -> 400/401/403 -> composer text not cleared, error surfaces inline
  • Submit -> 2xx -> composer clears as today
  • Operator can edit retained text and retry without re-typing
  • Test covering all three branches
  • Failed scheduled sends restore as scheduled (not silently downgraded to immediate)

Tests

web/src/hooks/mutations/useSendMessage.test.tsx - seven new cases under "issue #776":

  • 5xx (HTTP 503) - onError fires with the original text.
  • Network (TypeError: Failed to fetch) - onError fires with the original text.
  • 4xx (HTTP 400) - onError fires with the original text.
  • 2xx success - onError does not fire; onSuccess does.
  • Non-Error throw (bare string) - onError still fires with the text + null scheduledAt.
  • Scheduled send + 5xx - scheduledAt is carried through unchanged so the composer can restore as scheduled.
  • Immediate send - scheduledAt is null in onError.

bun typecheck and the full bun --filter web run test suite (701 tests) both pass on this branch.

Notes

  • No SCHEMA_VERSION bump (frontend-only).
  • No docs/operator/ or docs/plans/ changes.
  • Two commits: initial fix, then a follow-up addressing the bot review on scheduled-send preservation (Major finding).

Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

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

Findings

  • [Major] Preserve scheduled send state on failed sends — the new error handoff only carries text and error, but SessionChat clears pendingSchedule as soon as the mutation is accepted. If a scheduled send then fails with 4xx/5xx/network, the composer is restored as an immediate send, so editing and pressing send changes the delivery semantics. Evidence: web/src/hooks/mutations/useSendMessage.ts:35.
    Suggested fix:
    export type SendErrorInfo = {
        text: string
        error: unknown
        scheduledAt: number | null
    }
    
    options?.onError?.({
        text: input.text,
        error,
        scheduledAt: input.scheduledAt ?? null,
    })
    Then carry scheduledAt through ComposerSendError as a future { type: 'absolute', ms: scheduledAt } and restore it via the existing onSchedule path when applying the send error.

Summary
Review mode: initial. One issue found in the restored-composer state for scheduled failed sends.

Testing
Not run (automation: bun is not installed in this runner). git diff --check passed.

HAPI Bot

Comment thread web/src/hooks/mutations/useSendMessage.ts
@heavygee heavygee changed the title fix(web): retain composer text on send failure (closes #15) fix(web): retain composer text on send failure (closes #776) Jun 2, 2026
@heavygee
Copy link
Copy Markdown
Collaborator Author

heavygee commented Jun 2, 2026

Addressed the Major finding in 2b48cf4.

SendErrorInfo and ComposerSendError now carry scheduledAt (resolved to absolute epoch-ms before the failed send). HappyComposer's restore effect feeds it back through onSchedule as { type: 'absolute', ms: scheduledAt }, so a failed scheduled send is restored as scheduled - the operator can edit and retry without silently downgrading to immediate. Two new test cases pin the carry-through for both scheduled and immediate sends.

Also corrected the issue ref - this PR closes upstream #776 (the original "closes #15" referenced an unrelated, already-closed upstream issue; my mistake).

Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

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

Findings

  • [Major] Remove the failed optimistic row when restoring the composer — onError now restores the same text into the composer, but the mutation still leaves the optimistic message in the visible thread as failed. Failed messages are intentionally not filtered out of the thread, so the user sees the failed bubble plus the restored composer text; editing and sending from the new affordance creates a duplicate stale failed turn. Evidence: web/src/hooks/mutations/useSendMessage.ts:148.
    Suggested fix:
    import {
        appendOptimisticMessage,
        getMessageWindowState,
        removeOptimisticMessage,
        updateMessageStatus,
    } from '@/lib/message-window-store'
    
    onError: (error, input) => {
        removeOptimisticMessage(input.sessionId, input.localId)
        haptic.notification('error')
        options?.onError?.({
            text: input.text,
            error,
            scheduledAt: input.scheduledAt ?? null
        })
    }

Questions

  • None.

Summary

  • Review mode: follow-up after new commits. The previous scheduled-send finding is addressed, but the new composer-restore path now duplicates failed sends in the visible thread unless the failed optimistic row is removed or otherwise hidden.

Testing

  • Not run (automation: bun is not installed in this runner). The added hook tests do not cover the composer/thread integration where the duplicate failed bubble appears.

HAPI Bot

Comment thread web/src/hooks/mutations/useSendMessage.ts
@heavygee
Copy link
Copy Markdown
Collaborator Author

heavygee commented Jun 2, 2026

Addressed Major #2 in 10dcc32.

mutation.onError now calls removeOptimisticMessage(sessionId, localId) instead of updateMessageStatus(_, _, 'failed'). The optimistic row is dropped from the thread on failure, so the composer-restore + inline alert is the single retry surface - no duplicate failed bubble next to the restored text, no way for the operator to stack a stale failed turn. retryMessage stays exposed for any future code path that still produces failed rows.

Test in useSendMessage.test.tsx ("removes the optimistic row on failure...") pins the new behaviour: removeOptimisticMessage is called, and nothing transitions the row to 'failed' on this path.

Full web suite green: 702/702. bun typecheck clean.

Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

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

Findings

  • [Major] Key restored send errors by the target session — onError now restores the rejected text into whichever SessionPage is mounted, but the error payload does not include the session id actually sent. In the inactive-session resume path, useSendMessage can resolve targetSessionId, start async navigation, and then the POST can fail before navigation completes; the old session composer gets restored and the sessionId change effect clears it, so the typed text is lost again. Evidence: web/src/router.tsx:333.
    Suggested fix:

    export type SendErrorInfo = {
        sessionId: string
        text: string
        error: unknown
        scheduledAt: number | null
    }
    
    options?.onError?.({
        sessionId: input.sessionId,
        text: input.text,
        error,
        scheduledAt: input.scheduledAt ?? null
    })
    
    const [sendErrors, setSendErrors] = useState<Record<string, ComposerSendError>>({})
    const clearSendError = useCallback(() => {
        setSendErrors((prev) => {
            const next = { ...prev }
            delete next[sessionId]
            return next
        })
    }, [sessionId])
    
    onError: (info) => {
        sendErrorIdRef.current += 1
        setSendErrors((prev) => ({
            ...prev,
            [info.sessionId]: {
                id: sendErrorIdRef.current,
                text: info.text,
                message: deriveSendErrorMessage(info.error, t),
                scheduledAt: info.scheduledAt
            }
        }))
    }
  • [Major] Do not delete failed attachment sends unless attachments are also restored — the new failure path always removes the optimistic row, but SendErrorInfo carries only text and scheduledAt. For a rejected attachment or attachment-only send, assistant-ui has already cleared the composer, the failed row is removed at removeOptimisticMessage, and no code re-adds the uploaded attachment metadata; the operator loses both the attachment preview and a retry surface. Evidence: web/src/hooks/mutations/useSendMessage.ts:149.
    Suggested fix:

    function getMessageAttachments(message: DecryptedMessage): AttachmentMetadata[] | undefined {
        const content = message.content
        if (content.role !== 'user' || content.content.type !== 'text') return undefined
        return content.content.attachments
    }
    
    onError: (error, input) => {
        if (input.attachments?.length) {
            updateMessageStatus(input.sessionId, input.localId, 'failed')
            haptic.notification('error')
            return
        }
        removeOptimisticMessage(input.sessionId, input.localId)
        haptic.notification('error')
        options?.onError?.({ text: input.text, error, scheduledAt: input.scheduledAt ?? null })
    }
    
    mutation.mutate({
        sessionId,
        text: message.originalText,
        localId,
        createdAt: message.createdAt,
        attachments: getMessageAttachments(message),
        scheduledAt: message.scheduledAt ?? null,
    })

Questions

  • None.

Summary

  • Review mode: follow-up after new commits. The prior failed-row duplicate finding is addressed for text-only sends, but the full diff still has failure-restore gaps for resumed sessions and attachment sends.

Testing

  • Not run (automation: bun is not installed in this runner). Suggested coverage: inactive-session resume where the resumed-session POST fails before route navigation, plus rejected text+attachment and attachment-only sends.

HAPI Bot

Comment thread web/src/router.tsx
Comment thread web/src/hooks/mutations/useSendMessage.ts
@heavygee
Copy link
Copy Markdown
Collaborator Author

heavygee commented Jun 2, 2026

Addressed Major #3 + Major #4 in 29cc498.

Major #3 (sessionId carry-through): SendErrorInfo now carries the resolved sessionId, and router.tsx stores sendErrors as a Record<sessionId, ComposerSendError>. The clear-on-session-change effect is gone - per-session lookup replaces it, so the inactive-session resume race (resolve target id, navigate async, POST fails before nav completes) restores into the resumed-session composer instead of the old one.

Major #4 (attachment sends): Forked mutation.onError on input.attachments:

  • Has attachments: keep updateMessageStatus(_, _, 'failed') and skip the options.onError callback. The composer-restore path can't reinstate uploaded attachment metadata; the legacy failed-bubble UX with the in-thread retry button is the right surface.
  • Text-only: removeOptimisticMessage + options.onError({ sessionId, text, scheduledAt, error }) as before.

Also fixed a latent bug the bot's snippet flagged: retryMessage was not passing attachments through. Added a getMessageAttachments helper that defensively narrows the (unknown-typed) message content and pulls the attachments off the stored optimistic; failed-bubble retry of an attachment send now re-fires with its files instead of silently downgrading to text-only.

Three new tests cover the cases:

  • carries sessionId through onError so a resumed-session POST that fails restores into the right composer
  • attachment send: keeps the failed row in the thread and skips composer-restore
  • retryMessage: passes attachments through so failed-bubble retry of an attachment send keeps its files

Full web suite: 705/705. bun typecheck clean.

Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

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

Findings

  • No issues found.

Questions

  • None.

Summary

  • Review mode: follow-up after new commits. The latest head addresses the previous bot findings for resumed-session failures and attachment-send failures. Residual risk: this review did not execute the web tests; I validated from the diff and surrounding source only.

Testing

  • Not run (automation/security review; did not execute PR code). Existing added coverage includes text-only 4xx/5xx/network failure retention, scheduled failure retention, resumed-session failure targeting, attachment failure fallback, and attachment retry preservation.

HAPI Bot

When the message composer submits and the hub responds with a 4xx/5xx
or the fetch fails outright, assistant-ui clears the composer
synchronously the moment send is invoked. Without intervention the
operator's typed text is destroyed at exactly the moment they most
need it preserved. SessionChat additionally clears any pending
schedule on accept, so a failed scheduled send was also silently
downgrading to immediate on the next attempt.

Behaviour:

- useSendMessage exposes onError({ sessionId, text, scheduledAt, error })
  so the route can hand the input back to the composer. sessionId is
  the resolved target (post-resolveSessionId), so an inactive-session
  resume that resolves a new id, kicks off async navigation, then
  fails the POST restores into the resumed session's composer rather
  than the old one.

- router.tsx stores sendErrors keyed by sessionId. Per-session lookup
  replaces the clear-on-session-change effect, so errors do not bleed
  between sessions and a session-scoped failure persists across
  navigation.

- HappyComposer accepts ComposerSendError, restores text via
  api.composer().setText() once per failure id, and re-establishes any
  pending schedule via onSchedule({ type: 'absolute', ms: scheduledAt }).
  It renders a red ring on the composer wrapper and a role="alert"
  inline message; both clear the moment the operator types or sends.

- onError forks on input.attachments. Text-only sends use the
  composer-restore path (removeOptimisticMessage drops the row so the
  failed bubble does not duplicate the restored text). Attachment
  sends keep the legacy failed-bubble UX (status='failed' + in-thread
  retry button) because the composer-restore path can't reinstate
  uploaded attachment metadata. retryMessage extracts attachments
  from the stored optimistic message via getMessageAttachments so
  failed-bubble retry of an attachment send re-fires with its files.

Acceptance (issue tiann#776):

- Submit -> 500/502/503/network error -> composer text not cleared
- Submit -> 400/401/403 -> composer text not cleared, error inline
- Submit -> 2xx -> composer clears as today
- Operator can edit retained text and retry without re-typing
- Failed scheduled sends restore as scheduled, not as immediate

Tests in web/src/hooks/mutations/useSendMessage.test.tsx cover
text-only 4xx/5xx/network retention, scheduled-send carry-through,
optimistic-row removal on text-only failure, sessionId carry-through
under resolveSessionId, attachment failure fallback, and attachment
retry preservation. Full web suite passes (705 tests). bun typecheck
clean. No SCHEMA_VERSION bump (frontend-only).
@heavygee heavygee force-pushed the fix/composer-retain-text-on-error branch from 29cc498 to bc6c338 Compare June 4, 2026 17:06
Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

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

Findings

  • No issues found.

Questions

  • None.

Summary

  • Review mode: follow-up after new commits. I reviewed the latest seven-file web diff in full and used the previous bot review plus compare diff only as follow-up context. No correctness, security, regression, data-loss, performance, or maintainability issues above the reporting threshold were found. Residual risk: composer UI behavior itself was validated from source and hook-level tests only; I could not run browser/UI automation in this runner.

Testing

  • git diff --check passed.
  • Not run: bun --filter web run test web/src/hooks/mutations/useSendMessage.test.tsx because bun is not installed on PATH in this runner.

HAPI Bot

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bug: composer eats typed text on 4xx/5xx send failure

1 participant