Skip to content

Bug: UI stuck in loading state after undoing and resending message #26654

Closed as not planned

Description

@huohuo-huo

Description:

After undoing (rolling back) a sent message and then resending, the UI fails to properly display the agent's running state. Instead of showing the normal AI execution flow (thinking steps, tool calls, etc.), the page only shows a loading spinner in the bottom-right corner indefinitely.

After restarting the app, the agent works normally and displays the full execution process. This confirms the issue is a UI state management bug related to the undo/rollback operation — not an agent processing failure.

Steps to reproduce:

  1. Send a message to the agent and wait for the response
  2. Undo/rollback the sent message (message shows as "已回滚消息")
  3. Resend a message
  4. Observe: UI only shows a spinning loading indicator, agent's running state (thinking, tool calls) is not displayed
  5. Restart the app → agent works normally with full UI display

Expected behavior:
After resending a rolled-back message, the UI should correctly transition to show the agent's execution flow (thinking steps, tool usage, progress), just like a normal new message.

Actual behavior:
The UI remains stuck on a loading spinner and does not update to reflect the agent's actual running state.

Operating System: Windows

OpenCode version: Desktop v1.14.46

Activity

  1. Small-tailqwq commented on May 17, 2026

    @Small-tailqwq

    We encountered a related but more severe variant of this bug. In our case, after undoing and quickly pressing Enter twice (the first triggers /undo, the second submits the restored text as a new prompt), the conversation and thinking output completely disappear — and the issue persists after restarting opencode.

    Root cause analysis

    The session.undo palette command (routes/session/index.tsx:598) has a fire-and-forget pattern that creates a race:

    void sdk.client.session.revert({...})          // async, fire-and-forget
      .then(() => { toBottom() })
    
    prompt?.set(                                    // synchronously restores input
      parts.reduce((agg, part) => { ... }, { input: "", parts: [] }),
    )

    The race:

    1. First Enter → autocomplete selects /undo → runs session.undo → starts revert() as fire-and-forget → immediately restores the input text via prompt?.set()
    2. Second Enter (fast enough) → submitInner() reads the restored (non-empty) input → sends session.prompt() as a new message
    3. Server-side race: revert() (slower, involves git snapshot) and prompt() (faster, just creates a message) run concurrently. If prompt() completes first:
      • New messages are created in the session (no revert state exists yet)
      • Then revert() completes and sets revert.messageID on the session
      • The session now has both revert state AND new messages beyond the revert point — persisted to SQLite
    4. UI filter kicks in: The <Match when={revert()?.messageID && message.id >= revert()!.messageID}> at routes/session/index.tsx:1194 hides all messages with id >= revert.messageID, including the new prompt + AI thinking + AI response
    5. Survives restart: Because the corrupt state (revert + new messages) is persisted in SQLite, restarting opencode reloads the same inconsistent session — the filter still hides everything

    With a TPS plugin we could confirm the LLM actually processes the request (thinking, tool calls all happen server-side), but the TUI simply doesn't render anything due to the client-side revert filter.

    Suggested fix direction

    The race can be eliminated by moving prompt?.set() into the revert completion callback instead of running it immediately:

    prompt?.set({ input: "", parts: [] })  // clear input immediately to prevent resubmission
    void sdk.client.session.revert({...}).then(() => {
      prompt?.set(restoredText)            // restore only after revert completes
      toBottom()
    })

    This clears the input visually (preventing the second Enter from submitting stale text) and only restores the original message after the revert is fully committed — eliminating the race window entirely.

  2. Small-tailqwq commented on May 17, 2026

    @Small-tailqwq

    Further investigation confirms this is more severe than a UI-only bug — messages are physically deleted from the database, not just hidden.

    The key is revert.cleanup() (called inside SessionPrompt.prompt()) which actually removes messages >= revert.messageID from the session data:

    session/prompt.ts (approximate):
      prompt() → revert.cleanup(session) → DELETE messages >= revert.messageID
                                         → clear revert state
                                       → create new user message
                                       → run AI loop
    

    When the race occurs (revert() and prompt() run concurrently on the same session):

    Scenario A — revert() completes first, then prompt() runs:

    1. revert() sets revert.messageID = B.id (B is the user message being undone)
    2. prompt() (from the second Enter) reads session → sees revert state
    3. cleanup() deletes messages >= B.id — B and its AI response are gone
    4. Creates new message D (containing A's text, restored by undo)
    5. AI loop builds context from remaining messages: only A, A's response, and D
    6. B and its AI response are permanently lost — the AI never sees them

    Scenario B — prompt() completes first, then revert() runs:

    1. prompt() reads session → no revert state → cleanup is no-op
    2. Creates D, AI processes with full context (including B)
    3. revert() completes → sets revert.messageID = B.id in the DB
    4. Session now has: revert state + new messages beyond revert point
    5. After restart, user sends C: "你刚才说了什么吗?"
    6. prompt() loads session → sees revert state → cleanup() deletes messages >= B.id
    7. B, B's AI response, D, and D's AI response are all deleted
    8. AI loops with only [A, AI_response_to_A, C] — knows nothing about B

    This matches the user's test exactly: after restart, the AI only remembers A and has no knowledge of B. The messages are being removed from the SQLite database, not just hidden in the TUI. This is a data-loss bug, not a display bug.

    The root cause remains the same: prompt?.set() restores the input text before revert() completes, allowing the second Enter to submit a new message into the revert window. The fix should ensure the input is not restored until the revert is fully committed.

  3. github-actions commented on Jul 17, 2026

    @github-actions
    Contributor

    To stay organized issues are automatically closed after 60 days of no activity. If the issue is still relevant please open a new one.

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

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions