Skip to content

Web UI shows permanent Thinking spinner after stream interruption or server restart #17680

@dzianisv

Description

@dzianisv

Sessions show permanent "Thinking..." spinner after stream interruption or server restart

Problem

When an assistant message's stream is interrupted (API error, server restart, network timeout), the message is left with incomplete parts (e.g., step-start + reasoning but no step-finish) and no time.completed set.

The web UI's pending memo identifies these as still-active messages:

// message-timeline.tsx:239
const pending = createMemo(() =>
  sessionMessages().findLast(
    (item): item is AssistantMessage => item.role === "assistant" && typeof item.time.completed !== "number",
  ),
)

This causes:

  • Permanent spinner on the session in both the main chat area and sidebar
  • "Thinking..." display that never resolves
  • Users cannot tell if the session is stuck or just slow

Root cause

  1. The AI SDK stream can die mid-response (API timeout, rate limit, network error). When this happens, the assistant message never gets time.completed set in the database.

  2. Server restarts leave the same orphaned state: messages in-flight at shutdown time never get completed.

  3. Even with database-level recovery (setting time.completed on orphaned messages), the frontend's in-memory store may be stale if the page was loaded before recovery ran. No SSE event is emitted when recovery modifies messages.

Observed behavior

After server restart, sessions show:

  • Sidebar: spinner icon on sessions that are fully idle
  • Chat: "Thinking..." with a spinner that never resolves
  • Session status API returns {} (idle) — confirming the session is not actually active

Last message structure from API after recovery:

{
  "info": {
    "time": { "completed": 1773622653848 },
    ...
  },
  "parts": [
    { "type": "step-start" },
    { "type": "reasoning", "text": "..." },
    { "type": "reasoning", "text": "..." }
  ]
}

Note: time.completed is set correctly, but the message has no step-finish part. If the frontend loaded this data before recovery, the store still has time.completed as undefined.

Suggested fix

Two-sided fix:

1. Frontend (message-timeline.tsx): Make the pending memo also consider session status. If the session is idle with no pending permissions, no message should be considered "pending" regardless of time.completed:

const pending = createMemo(() => {
  if (sessionStatus().type === "idle") return undefined
  return sessionMessages().findLast(
    (item): item is AssistantMessage => item.role === "assistant" && typeof item.time.completed !== "number",
  )
})

2. Server (session/index.ts): Emit Bus events after Session.recover() so connected frontends update their stores immediately.

Environment

  • opencode serve (long-running, multiple sessions)
  • Multiple sessions interrupted by server restarts
  • Web UI accessed via browser

Metadata

Metadata

Assignees

Labels

coreAnything pertaining to core functionality of the application (opencode server stuff)

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions