Skip to content

Composer remounts whenever the message list transitions empty↔non-empty, dropping in-progress input #3022

Description

@dokterbob

Found while diagnosing #3019. That issue is a test flake; this is the application behaviour underneath it, which outlives the test fix in #3021.

The mechanism

Two components render a MessageComposer, and which one owns it depends on whether the message list is empty:

  • frontend/src/components/chat/Footer.tsx:20if (!hasMessage(messages) && !showIfEmptyThread) return null;
  • frontend/src/components/chat/WelcomeScreen.tsx:80,91if (hasMessage(messages)) return null; … otherwise renders its own <MessageComposer {...props} />

hasMessage() (frontend/src/lib/utils.ts:10) is true when any step is of type user_message, assistant_message, or tool.

So every transition of hasMessage() unmounts one composer instance and mounts a different one. Because MessageComposer holds its draft in local state, the remount discards whatever the user had typed, and the send button then reads as disabled because the new instance's value is empty (MessageComposer/index.tsx:339-341).

Why this is user-facing, not just a test artifact

The transition fires on ordinary app events, not only in tests:

  1. First turn of a chat. @cl.on_chat_start sends its message asynchronously. A user who starts typing before that message lands can have their input silently dropped.
  2. Thread resume. connection_successful (backend/chainlit/socket.py:208-240) emits the @cl.on_chat_resume message and then resume_thread; the client handler (libs/react-client/src/useChatSession.ts:258+) rebuilds the list from thread.steps and replaces it wholesale. If the persisted history is empty, the list goes [] → [resume message] → [], remounting the composer twice in quick succession.

The faster the backend, the narrower the window — which is exactly why this reads as intermittent.

Suggested direction

Render the composer once, above the WelcomeScreen/Footer branch, so its identity is stable across the transition — or lift the draft value into shared state so a remount preserves it. Either removes a class of "my message vanished" reports that would be near-impossible to reproduce from a bug report.

Notes

  • fix(e2e): de-flake thread_resume by waiting on the resume replay #3021 works around this for cypress/e2e/thread_resume/spec.cy.ts by waiting for the transitions to settle before interacting. That makes the test honest, but it does not address the behaviour.
  • Related, likely the same root cause: the on_chat_resume message is always erased by the resume_thread replay that follows it, so @cl.on_chat_resume output is only visible for a few frames unless it happens to be in the persisted steps. That may be worth its own issue if it is not intended.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingfrontendPertains to the frontend.needs-triage

    Type

    No type

    Projects

    Status
    Todo

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions