feat(react): concurrent-thread streaming via concurrentThreads prop#91
Open
Gourav-InfoTech wants to merge 3 commits intoYourGPT:betafrom
Open
feat(react): concurrent-thread streaming via concurrentThreads prop#91Gourav-InfoTech wants to merge 3 commits intoYourGPT:betafrom
concurrentThreads prop#91Gourav-InfoTech wants to merge 3 commits intoYourGPT:betafrom
Conversation
Adds an opt-in mode where multiple threads can stream at the same time. Users can switch away from a generating thread, start a second one, and have both run in parallel. Single-thread mode (default) is byte-for-byte unchanged. Provider-side: - Instance registry keyed by thread id behind `concurrentThreads` flag - `busyThreadIds: ReadonlySet<string>` for per-thread picker spinners - `assignLocalThreadId(id)` so the hook can bind a locally-minted id to the active pending instance mid-stream (for backends that only emit the session id in the final `done` chunk) - `disposeThreadInstance(id)` for clean teardown on thread delete - Provider-level shared registries (tools, skills, system context) so new instances inherit the right state; register/unregister fan out to every live instance - `handleInstanceThreadAssigned` re-keys only when leaving an internal slot in multi-thread mode — preserves the stable UI id when a session creator isn't in play Chat/agent-loop: - `getThreadId?` override on `ChatWithToolsConfig` so the provider can surface its registry key (the stable UI id) to tool handlers instead of `chat.config.threadId` - Public `get threadId()` on `AbstractChat` - Tool context carries `threadId` for per-thread state Hook (`useInternalThreadManager`): - In multi-thread mode, dispatches the first-response transition as soon as streaming starts so the picker row appears immediately - Defers when `sessionStatus === "creating"` so `yourgptConfig` / `onCreateSession` consumers keep their server `session_uid` as the thread id (preserves the pre-multi-thread contract) - Mints a local id and calls `assignLocalThreadId` when no session creator is configured Backward compatibility: - `concurrentThreads` defaults to `false`; existing consumers run the original single-instance path unchanged - `handleInstanceThreadAssigned` skip is gated on `concurrentThreads` so `renewSession()` in single-thread mode still propagates the new server id to `useCopilot().threadId` and the `onThreadChange` prop - `getThreadId` override is opt-in on config; direct `ChatWithTools` consumers without the provider keep the old `() => chat.threadId`
|
@Gourav-InfoTech is attempting to deploy a commit to the Delta4 Infotech Team on Vercel. A member of the Team first needs to authorize it. |
isBusy now respects `concurrentThreads` — when enabled, ThreadPicker and NewChat stay interactive while the active thread streams, which is the whole point of the feature. Single-thread behavior unchanged. Also wire the playground to exercise both `concurrentThreads` and `yourgptConfig`: - Switch playground to `workspace:*` so it consumes the local SDK (matching every other example in examples/*). - Add Concurrent Threads toggle and YourGPT Auth section (apiKey + widgetUid inputs, stored in localStorage) to the Beta panel. - Pass `concurrentThreads` + conditional `yourgptConfig` to the CopilotProvider; include both in the remount key.
…state In concurrent-threads mode, a background instance can enter approval-required while the user is on a different thread. The existing pendingApprovals (line 1373 area) reflects only the active instance's toolExecutions, so consumers had no way to see that a background thread was blocked. Add a reactive ReadonlyMap<threadId, ToolExecution[]> that scans every instance in instancesRef, filtering by approvalStatus === "required", following the existing busyThreadIds pattern. Recomputed from notifyStateChange, from the eager recompute sites in handleInstanceThreadAssigned / disposeThreadInstance / assignLocalThreadId, and — crucially — directly from the per-instance onToolExecutionsChange and onApprovalRequired callbacks. The reactState subscribe channel only fires on message changes, so tool-execution transitions on background instances wouldn't otherwise trigger a recompute. Exposed on CopilotContextValue so consumers can read it via useCopilot(). UI in the downstream extension now lights up an indicator on the thread picker for threads whose pending executions aren't already covered by a session grant.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Concurrent-thread streaming — build notes
Adds a new opt-in capability to
CopilotProvider: users can switch awayfrom a generating thread, start a second thread, send a prompt in it — and
the first thread keeps streaming in the background. Both threads show a
spinner in the picker; switching back to either one shows its live state.
Previously the SDK held exactly one
ReactChatWithToolsinstance perCopilotProvider, and the thread picker disabled itself the momentisBusywent true. The feature request was to remove that limit.This doc captures everything that changed to make that work, the bugs we
hit along the way, and what needs to be verified before shipping.
What was built
CopilotProvider,gated behind a new
concurrentThreadsprop (defaultfalse— so everyexisting consumer keeps running on the old single-instance code path
byte-for-byte).
useSyncExternalStorewrapper so swapping the active instancedoesn't tear React state.
dispose()so deleting a thread aborts its in-flight requestcleanly.
busyThreadIds: ReadonlySet<string>surface for the picker to renderper-thread spinners.
useInternalThreadManagerso persisted threadsare restored with their messages hydrated into the right instance, and
the thread picker lights up immediately even while the server is still
streaming the first reply.
CustomThreadPickerno longer locks duringstreaming,
ToolsSetuppins browser tabs per-thread so Session 1 on Tab Aand Session 2 on Tab B can run concurrently.
Files changed in the SDK
packages/copilot-sdk/src/chat/classes/AbstractChat.tsdispose()/revive()for StrictMode + per-instance cleanup; publicget threadId()getter.packages/copilot-sdk/src/chat/AbstractAgentLoop.tsthreadId(for per-thread state like browser-tab pinning).packages/copilot-sdk/src/chat/types/tool.tsgetThreadId?added toAgentLoopConfig.packages/copilot-sdk/src/chat/ChatWithTools.tsgetThreadIdcallback; added an overrideablegetThreadIdoption onChatWithToolsConfigso the provider can supply a stable registry key.packages/copilot-sdk/src/react/provider/CopilotProvider.tsxconcurrentThreadsprop,busyThreadIds,assignLocalThreadId,disposeThreadInstance,handleInstanceThreadAssigned, provider-level shared tools/skills/context.packages/copilot-sdk/src/ui/hooks/useInternalThreadManager.tspackages/copilot-sdk/src/ui/components/composed/connected-chat.tsxbusyThreadIds, callsdisposeThreadInstanceon delete.packages/copilot-sdk/src/ui/components/composed/chat/chat.tsx+types.tsbusyThreadIdsplumbed into the chat internals.Issues we hit building this, and how each got fixed
1. Tool handlers didn't know which tab they were running for
The extension's tools needed per-thread state (which browser tab a given
conversation is "attached to"). There was no id in the tool handler's
context.
Fix: added
threadIdtoToolContext, fed fromAgentLoopConfig.getThreadId.ChatWithToolsinitially wired this as() => this.chat.threadId.2. Stream UI froze under React StrictMode
ReactChatState.dispose()clears its subscribers.revive()didn't re-addthem, so after StrictMode's mount → unmount → remount cycle the provider's
notifyStateChangewas detached. Streams kept running in the backgroundbut the UI only repainted when something else (like clicking Stop) forced
a render.
Fix: in the provider's revive branch, explicitly call
inst.subscribe(notifyStateChange)for each revived instance.3. Picker spinner sometimes didn't show on send
When the active instance started out as an empty pending slot (typical for
a brand-new send) and auto-restore raced in at the same time, the switch
logic would create a second instance and the pending one never made it
into
busyThreadIds.Fix: in
switchActiveInstance, promote the current empty internal slotto the target key instead of spinning up a parallel instance. Auto-restore
now goes through
setActiveThread(id, {hydrateMessages})so the restoredthread ends up on the same instance the provider already created.
4. Tools said "failed" even though the server-side tool succeeded
Provider callbacks that gated on
key === activeInstanceKeyRef.currentcaptured the
keystring in a closure. WhenhandleInstanceThreadAssignedlater re-keyed the instance from
__pending_1__→ the real id, thecaptured string went stale and the callback thought it wasn't active
anymore — so tool executions got dropped.
Fix: gate callbacks on instance identity
(
inst === chatRef.current) instead of captured strings.5. Tools not used at all — plain text responses on new threads
Each chat instance owned its own tool registry. When the provider minted a
new pending instance for a second thread, the instance started with zero
tools, so the LLM had nothing to call.
Fix: hoisted tool / skill / system-context registries to the provider
level (
sharedToolsRef,sharedSkillsRef,sharedSystemContextRef). Newinstances are seeded from these on creation, and
registerTool/unregisterTool/setInlineSkillsfan out to every live instance.6. Pinning broke when user switched browser tabs
Old pin logic pinned the active browser tab while
isLoadingwas true.If the user switched browser tabs mid-stream, the pin moved — so tools
that started on Tab A finished on Tab B.
Fix: pin per-thread, keyed by thread id. Set when a thread enters
busyThreadIds(captures the current tab at that moment), cleared when itleaves. Tools look up their tab via
context.threadId. A legacyfallback pin covered the gap before the thread id was assigned.
7. Tools stopped executing when user switched sessions
Switching from Session 1 (streaming, running tools on Tab A) to Session 2
would stop Session 1's tools mid-flow.
Root cause:
useAIContextin the extension derives its value frompageContextData(the current browser tab). Every time the user switchedtabs,
pageContextDatachanged →addContext/removeContextran in theprovider → the provider fanned that update out to EVERY chat instance. So
Session 1's system prompt got rewritten to reflect Tab B's page, the AI
lost the thread of what it was doing on Tab A, and abandoned the task.
Fix: scope
addContext/removeContextto the active instance only.Background threads keep the context snapshot that was in place when their
instance was created (seeded via
sharedSystemContextRef). New instancescreated later still get the latest context.
8. New thread didn't appear in the picker until generation finished
The picker is driven by
threadManager.threads, anduseInternalThreadManagerwas waiting for
sdkThreadId(server-assigned session id) before callingcreateThread(). Backends that only emit the id in the finaldonechunk— including the one the extension talks to — meant the picker row didn't
appear at all until after the message was done streaming.
Fix:
assignLocalThreadIdhelper on the provider that re-keys the activepending instance to a caller-supplied local id, without touching
chat.config.threadId(so the server still creates its own session).FIRST_RESPONSE_COMPLETEassoon as streaming starts, mints a local id via
threadManager.createThread(),and hands it to
assignLocalThreadId.AbstractChatupdateschat.config.threadIdinternally for backend communication, but theregistry key and UI thread id stay on the local id (a "non-internal-key
skip" in
handleInstanceThreadAssigned).9. Session 2's tools ran on Session 1's tab, even with per-thread pinning
Once (8) landed, the picker looked right — but tools in Session 2 were
still targeting Tab A. Investigation showed the tool handler's
context.threadIdcame fromchat.config.threadId, which isundefinedfor a fresh send (until the server emits its id). Session 2's tools fell
through to the legacy pin, which was captured when Session 1 started
streaming → Tab A.
Fix:
getThreadId?override toChatWithToolsConfig. The providerwires it to return the instance's registry key (which becomes a
stable UI id as soon as
assignLocalThreadIdruns), notchat.config.threadId.showPageOverlay,hidePageOverlay,hidePagePill) inthe extension now accept a
{ threadId }context, so the page overlayappears on the right session's pinned tab.
bleed) and kept only per-thread pins, plus a
lastActivePinRefso thesettle-all cleanup can hide the overlay on the correct tab.
10. R1 — single-thread
renewSession()silently brokenThe "non-internal-key skip" from (8) was applied in both modes. In
single-thread mode, calling
renewSession()then sending a message meantthe server's new id never propagated to
useCopilot().threadIdor theonThreadChangeprop (the instance's oldKey was the previous server id,which is "non-internal", so the skip kicked in).
Fix: gated the skip on
concurrentThreads === true:Single-thread mode now always re-keys and propagates, matching the
pre-refactor behavior byte-for-byte.
11. R2 —
yourgptConfig/onCreateSessiondecoupled from the thread id in multi-thread modeAlso a consequence of (8). The hook dispatched
FIRST_RESPONSE_COMPLETEthe instant streaming started, even when the
sessionInitPromisewas~50ms from resolving with the real session id.
yourgptConfigconsumersended up with a locally-generated id in
useCopilot().threadIdinstead ofthe
session_uid, breaking anyone storing the id server-side.Fix: added a
sessionStatus === "creating"guard. If a pre-streamsession creator is in flight, defer dispatch until either
sdkThreadIdarrives (use it) or the creator finishes some other way (proceed with
local-id path as a graceful fallback).
Behavior by mode
Single-thread mode (
concurrentThreads={false}, the default)No behavior changes from pre-feature
main. Every existing consumerkeeps running exactly as before. The registry holds one instance under an
internal key; the thread-id assignment path is unchanged.
Multi-thread mode +
yourgptConfig/onCreateSessionsendMessage→sessionStatus = "creating".sessionStatus === "creating"— defers.onThreadChange(sessionId)→sdkThreadId = sessionId.sessionIdas the thread id.useCopilot().threadId === sessionId.Multi-thread mode + server-managed sessions (e.g. the extension)
sendMessage→sessionStatusstays"idle".assignLocalThreadId.context.threadId = localId(stable).thread:created/done→chat.config.threadIdupdates internally, registry + picker +
useCopilot().threadIdkeepthe local id.
What to test
Regression suite — existing single-thread consumers
examples/playground,examples/togetherai-demo,examples/skills-demobehave identically to
main: send, stream, stop, clear, reload.renewSession()flow: after calling it, send a message; verifyuseCopilot().threadIdupdates to the new server id AND theonThreadChangeprop fires with that id.yourgptConfigsingle-thread: first send creates session,useCopilot().threadId === session_uid, subsequent sends reuse it.Multi-thread mode — happy path
picker is enabled and shows Thread A with a spinner.
between them — each shows its own live state. No cross-bleed.
A stops; B keeps going.
Multi-thread mode +
yourgptConfiguseCopilot().threadIdto the YourGPTsession_uid, NOT a local uuid.session_uid.onThreadChangeprop fires once with thesession_uid.session_uid; follow-up sends continue the samebackend session.
Extension-specific (concurrentThreads + no session creator)
immediately (not after generation finishes).
Session 2 there, send a prompt. Session 2's tools run on Tab B;
Session 1 keeps running on Tab A.
tab the user is currently viewing.
shows cancelled), registry entry gone, no console errors.
remain, no error banner, no orphan placeholder messages.
StrictMode (dev)
<React.StrictMode>enabled; verify noduplicate instances in the registry, tools fire once per call,
streaming survives the initial mount→unmount→remount.
Memory
instances).
Known limitations
Reload loses backend session continuity (multi-thread + local-id mode only)
When
concurrentThreads={true}AND no session creator is configured, theUI thread id is locally generated. On reload, the thread manager restores
it, but the server's real session id is NOT persisted. A follow-up send
after reload will send the local id to the server, which will typically
create a new session rather than resume the old one. Persisted messages
still display; only the backend session chain breaks.
Workaround: use
yourgptConfigoronCreateSession— then the backendid IS the thread id and persistence round-trips cleanly.
Proper fix (deferred): add
sessionId?: stringtoThreadData, persistthe server id alongside the UI id, use it to initialize the chat on
restore.
onThreadChangeprop in multi-thread + local-id mode fires with local id onlyIn multi-thread mode with no session creator,
onThreadChangefires oncewith the local id. It does NOT fire again when the server later emits its
real session id. Consumers who need the backend id for their own API
calls have no way to get it today.
Proper fix (deferred): add a separate
onServerSessionAssigned(uiThreadId, sessionId)callback.
Background-streaming threads don't recompact
useMessageHistorycompaction state only updates for the active thread.A thread generating in the background while the user views another thread
won't recompact mid-stream. Accepted as a v1 limitation — documented in
the original plan.
Tool approval UI is global in multi-thread mode
If a background-streaming thread calls a tool that requires approval
while the user is viewing another thread, the approval dialog still
surfaces (routing by
toolExecutionId, not by thread), so it resolvescorrectly regardless of which thread is active. No functional gap, but
worth noting.
Build & typecheck
Both passed clean at commit time: