Remote Thread List Runtime#
RemoteThreadListRuntime is the core runtime for managing a server-backed list of chat threads in assistant-ui. It is the backbone behind useLangGraphRuntime, useChatRuntime, and other high-level adapter hooks.
Key source files:
| File | Purpose |
|---|---|
RemoteThreadListThreadListRuntimeCore.tsx | Core class — thread switching, state, subscriptions, onThreadIdChange |
useRemoteThreadListRuntime.ts | React hook — instantiates the runtime, wires reactive threadId prop |
types.ts (remote-thread-list) | RemoteThreadListOptions — adapter, threadId, onThreadIdChange, allowNesting |
optimistic-state.ts | OptimisticState — state container with optimistic-update semantics |
Architecture#
RemoteThreadListRuntimeCore (defined inline in useRemoteThreadListRuntime.ts) extends BaseAssistantRuntimeCore and holds a single RemoteThreadListThreadListRuntimeCore as this.threads . The hook creates this runtime exactly once via useState and re-syncs options on each render via __internal_setOptions / __internal_load .
RemoteThreadListThreadListRuntimeCore extends BaseSubscribable and implements ThreadListRuntimeCore . All mutable list state is held in an OptimisticState<RemoteThreadState> instance , which supports three classes of state mutation:
update(state)— hard replace from server dataoptimisticUpdate({ execute, optimistic?, loading?, then? })— runs an async operation with immediate optimistic UI, safe to interleave with concurrent updateswaitForUpdate()— promise-based barrier used byswitchToNewThreadwhen an initialization is in flight
Thread Switching#
switchToThread(id, opts?) and switchToNewThread() are the two public entry points for navigation .
Both use switch generation tracking to prevent out-of-order completions from corrupting _mainThreadId . Each call increments _switchGeneration and passes the generation token to the private _switchToThread / _switchToNewThread helper. After every await, the helper checks if (generation !== this._switchGeneration) return; before committing state .
switchToNewThread assigns a transient local ID with the format __LOCALID_<random> and status: "new" before the thread exists on the server . The thread is fully initialized only once initialize(threadId) is called (e.g., on first message send), at which point the state is updated with the server-assigned remoteId .
If a switch targets an unknown ID, switchToThread calls adapter.fetch(remoteId) to retrieve metadata and inject the thread into local state before navigating to it .
onThreadIdChange and the Echo Problem#
onThreadIdChange is a callback in RemoteThreadListOptions that fires whenever the active thread's settled remote ID changes. The implementation centers on two private members in RemoteThreadListThreadListRuntimeCore:
_mainThreadRemoteId— derives the remote ID from the active thread'sRemoteThreadData.remoteId. Returnsundefinedfor new/optimistic threads so that__LOCALID_*strings are never surfaced to consumers._notifyThreadIdChange()— deduplicates against_lastNotifiedThreadIdbefore invoking the callback .
The notification fires from two sites:
- The
_statesubscriber (catches the optimistic→settled transition afterinitialize()resolves) - At the end of
_switchToThreadafter setting_mainThreadId(catches explicit navigation)
In the React hook, the callback is wrapped with useEffectEvent so it always calls the latest consumer closure without causing the stableOptions memo to invalidate .
The Echo Problem#
The echo problem is the scenario where:
- Parent updates
threadIdprop → hook callsswitchToThread(id)→_notifyThreadIdChangefiresonThreadIdChange(id)→ parent updates URL → React re-renders →threadIdprop changes again → loop.
The deduplication guard in _notifyThreadIdChange (comparing against _lastNotifiedThreadId) breaks this cycle: if the threadId flowing back from onThreadIdChange matches what was already notified, switchToThread completes but the callback does not fire a second time . The hook's useEffect for threadId also skips the switch if options.threadId hasn't changed from prevThreadIdRef.current .
However, a navigation loop can still occur when the threadId prop change triggers a full React remount or RSC refetch (e.g., via router.push in Next.js), causing adapter.fetch and adapter.list to fire twice. The recommended workaround is to write the URL via window.history.pushState inside onThreadIdChange rather than navigating via the router, so no new React render or RSC round-trip is triggered .
State Shape and Subscriptions#
RemoteThreadState holds:
| Field | Description |
|---|---|
isLoading / isLoadingMore | Loading flags for initial load and pagination |
cursor | Pagination cursor; undefined means no more pages |
threadIds / archivedThreadIds | Ordered arrays of remote IDs |
threadIdMap | Map from remote ID (or local __LOCALID_*) → internal mapping ID |
threadData | Map from mapping ID → RemoteThreadData record |
newThreadId | Local ID of the pending new thread, if any |
Subscribers register via BaseSubscribable.subscribe. The state subscriber in the constructor calls both _notifySubscribers() (list UI) and _notifyThreadIdChange() (URL routing) on every state change .
Key Behaviors and Gotchas#
__LOCALID_*is never emitted byonThreadIdChange. While a thread is optimistic, the callback firesundefined.adapter.fetchon unknown IDs: Switching to a remote ID not yet in local state triggersadapter.fetch. The fetched metadata is injected into boththreadDataand the appropriatethreadIds/archivedThreadIdslist .- Nesting guard: If
useRemoteThreadListRuntimeis rendered inside anotherRemoteThreadListRuntime, it throws unlessallowNesting: trueis set, in which case it becomes a no-op that callsruntimeHook()directly . initialThreadIdis deprecated — use the reactivethreadIdoption instead .onThreadIdChangeforwarding: All major adapter hooks (useLangGraphRuntime,useChatRuntime,useStreamRuntime,useAdkRuntime, etc.) forwardonThreadIdChangeto the underlyinguseRemoteThreadListRuntime.