Send and Cancel Message Lifecycle
How message sending and cancellation work across the room module.
Core principle
Only the cancel button cancels work. Navigation, dispose, backgrounding, closing the app — none of these cancel a session or spawn. They only stop observing. The backend run completes independently, and the user sees results when they return.
Components
| Component | Role |
|---|---|
RoomState |
Coordinates room-level state. Owns the no-thread send path (sendToNewThread). |
ThreadViewState |
State for a single thread. Owns the existing-thread send path (sendMessage). |
RunRegistry |
Holds sessions across navigation. Keyed by ThreadKey. One per app. |
AgentRuntime |
Spawns sessions. Owns session lifetime and thread history cache. |
ChatInput |
UI widget. Shows send or cancel button based on sessionState signal. |
State machine
Both RoomState and ThreadViewState track _sessionState: Signal<AgentSessionState?>:
_sessionState serves three roles:
- Concurrency guard —
sendMessage/sendToNewThreadreject if non-null. - UI signal —
ChatInputwatches it to show the cancel button. - Error suppression — if null in the catch block, the spawn was cancelled and the error is swallowed.
Two send paths
New thread (RoomState.sendToNewThread)
No thread exists. The spawn creates one server-side.
1. Guard: sessionState != null → reject
2. Set sessionState = spawning
3. runtime.spawn(roomId, prompt) [no threadId]
4. Track _pendingSpawn for cancel detection
5. await spawn
6. Staleness check: _pendingSpawn != spawnFuture → cancelled, return
7. Null _pendingSpawn and _sessionState
8. Register session in RunRegistry
9. Check _isDisposed → return if disposed
10. Create ThreadViewState, attach session, navigate
Existing thread (ThreadViewState.sendMessage)
Thread exists. The spawn continues an existing conversation.
1. Guard: sessionState != null → reject
2. Set sessionState = spawning
3. runtime.spawn(roomId, prompt, threadId)
4. Track _pendingSpawn for cancel detection
5. await spawn
6. Staleness check: _pendingSpawn != spawnFuture → cancelled, return
7. Null _pendingSpawn
8. Register session in RunRegistry [before _isDisposed check]
9. Check _isDisposed → return if disposed
10. Attach session (sets sessionState = session.state, subscribes to runState)
Cancel paths
Cancel during spawn (cancelRun / cancelSpawn)
User clicks the cancel button while the spawn is awaiting.
1. Null _pendingSpawn (triggers staleness check in sendMessage/sendToNewThread)
2. Null _sessionState (unblocks UI, suppresses error in catch)
3. Fire-and-forget: when spawn completes, cancel and dispose the session
The sendMessage/sendToNewThread method resumes from await, sees
_pendingSpawn != spawnFuture, and returns early. The session is never
registered in the registry.
Cancel during active run (cancelRun)
User clicks the cancel button while a session is streaming.
1. _pendingSpawn is null → falls through
2. _activeSession?.cancel()
3. Session transitions to CancelledState
4. _onRunState handles CancelledState → detach, apply conversation
Dispose behavior
ThreadViewState.dispose (navigate between threads)
1. _isDisposed = true
2. Cancel history HTTP fetch (_cancelToken)
3. Detach from session (stop observing runState)
4. Dispose tracker registry
Does NOT cancel spawns or sessions. If a spawn was in progress, it completes naturally:
_registry.registerruns (session enters registry)._isDisposedcheck prevents_attachSession.- Session runs in the background. Registry holds it.
- User navigates back → new
ThreadViewState→_restoreFromRegistryfinds it.
RoomState.dispose (leave room)
1. _isDisposed = true
2. Cancel room metadata HTTP fetch
3. Dispose ThreadListState
4. Dispose active ThreadViewState
Does NOT cancel sendToNewThread spawn. If in progress, the spawn completes:
- Session is registered in RunRegistry.
_isDisposedcheck prevents navigation/UI updates.- Thread is created on the server.
- When user returns, the thread appears in the thread list.
RunRegistry.dispose (app shutdown)
Cancels all active sessions and clears entries. Only called on app termination
from the flavor's onDispose callback.
Scenarios
Send on thread A, navigate to thread B mid-run
- ThreadViewState(A) has active session SA.
- User navigates to B → ThreadViewState(A) disposed (detaches from SA).
- SA continues running in RunRegistry.
- ThreadViewState(B) created, independent.
- User navigates back to A → new ThreadViewState(A) → restores SA from registry.
Send on thread A, navigate away mid-spawn
- ThreadViewState(A) spawning.
- User navigates → ThreadViewState(A) disposed.
_pendingSpawnuntouched. - Spawn completes → session registered in registry →
_isDisposed→ return. - User navigates back → restores from registry.
Multiple concurrent runs
- Send on A → session SA registered and running.
- Navigate to B → ThreadViewState(A) detaches from SA (SA keeps running).
- Send on B → session SB registered and running.
- SA and SB both active in registry, independent.
Send to new thread, navigate to lobby mid-spawn
sendToNewThreadspawning.- User goes to lobby → RoomState disposed. Spawn continues.
- Spawn completes → session registered →
_isDisposed→ return. - Thread created on server. When user returns to room, thread list shows it.
Cancel mid-spawn
- User sends, spawn is awaiting.
- User clicks cancel →
_pendingSpawnnulled,_sessionStatenulled. - Spawn completes → staleness check returns early → session NOT registered.
- Fire-and-forget cleans up the session.
Cancel mid-run
- Session is streaming.
- User clicks cancel →
_activeSession.cancel(). - Session transitions to
CancelledState. _onRunStatehandles it → detach, apply partial conversation.
Spawn error concurrent with cancel
- User clicks cancel →
_sessionStateset to null. - Spawn fails (network error).
- Catch block:
_sessionState.value == null→ return (error suppressed). - User sees clean cancel, no error banner.
App closed during run
- Session is running on the server.
- App process terminates. No cleanup runs.
- Server completes the run independently.
- App reopens → thread list fetched → user sees completed results.
Known limitations
Rapid navigation miss
If the user sends on thread A, navigates away (ThreadViewState disposed), and navigates back before the spawn completes, the new ThreadViewState checks the registry once on construction. At that point the session isn't registered yet. When the spawn completes and registers, the new ThreadViewState doesn't know.
The user sees a static screen until they navigate away and back again (or
refresh). Making RunRegistry observable would fix this but is tracked as a
separate enhancement.