-
Notifications
You must be signed in to change notification settings - Fork 15.2k
Web UI shows permanent Thinking spinner after stream interruption or server restart #17680
Description
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
-
The AI SDK stream can die mid-response (API timeout, rate limit, network error). When this happens, the assistant message never gets
time.completedset in the database. -
Server restarts leave the same orphaned state: messages in-flight at shutdown time never get completed.
-
Even with database-level recovery (setting
time.completedon 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