From 70a4f33d947e8705ade75f2e21c0323690ad07ec Mon Sep 17 00:00:00 2001 From: Gourav Date: Wed, 22 Apr 2026 18:59:23 +0530 Subject: [PATCH 1/3] feat(react): concurrent-thread streaming via `concurrentThreads` prop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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` 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` --- .../copilot-sdk/src/chat/AbstractAgentLoop.ts | 5 +- .../copilot-sdk/src/chat/ChatWithTools.ts | 16 + .../src/chat/classes/AbstractChat.ts | 5 + packages/copilot-sdk/src/chat/types/tool.ts | 7 + .../src/react/provider/CopilotProvider.tsx | 751 +++++++++++++++--- .../src/ui/components/composed/chat/chat.tsx | 8 + .../src/ui/components/composed/chat/types.ts | 6 + .../ui/components/composed/connected-chat.tsx | 16 +- .../src/ui/hooks/useInternalThreadManager.ts | 138 +++- 9 files changed, 829 insertions(+), 123 deletions(-) diff --git a/packages/copilot-sdk/src/chat/AbstractAgentLoop.ts b/packages/copilot-sdk/src/chat/AbstractAgentLoop.ts index c621214..8f2e3d2 100644 --- a/packages/copilot-sdk/src/chat/AbstractAgentLoop.ts +++ b/packages/copilot-sdk/src/chat/AbstractAgentLoop.ts @@ -399,9 +399,12 @@ export class AbstractAgentLoop implements AgentLoopActions { throw new Error("Tool execution cancelled"); } - // Pass signal and approvalData to handler via context + // Pass signal, threadId, and approvalData to handler via context. + // threadId lets handlers scope per-run state (e.g. per-thread tab + // pinning when multiple threads stream concurrently). const result = await tool.handler(toolCall.args, { signal: this.abortController?.signal, + threadId: this.config.getThreadId?.(), data: { toolCallId: toolCall.id }, approvalData, }); diff --git a/packages/copilot-sdk/src/chat/ChatWithTools.ts b/packages/copilot-sdk/src/chat/ChatWithTools.ts index 66a7126..1f45ffe 100644 --- a/packages/copilot-sdk/src/chat/ChatWithTools.ts +++ b/packages/copilot-sdk/src/chat/ChatWithTools.ts @@ -73,6 +73,14 @@ export interface ChatWithToolsConfig { transport?: ChatTransport; /** Custom error message extractor for non-2xx API responses */ parseError?: (status: number, body: unknown) => string | null | undefined; + /** + * Override the thread id exposed to tool handlers (`context.threadId`). + * Called once per tool invocation. Defaults to `chat.threadId` (the backend + * session id). Useful for framework adapters that maintain a stable + * UI-level thread id separate from the backend session id — e.g. when a + * local id is assigned client-side before the server issues its own. + */ + getThreadId?: () => string | undefined; } /** @@ -128,6 +136,14 @@ export class ChatWithTools { { maxIterations: config.maxIterations ?? 20, tools: config.tools, + // Expose this chat's current threadId to tool handlers via + // ToolContext.threadId. Read lazily per invocation so it reflects + // the id assigned by the server mid-stream (thread:created). + // If the caller provided a getThreadId override (e.g. the React + // provider exposing its stable registry key), prefer that. + getThreadId: config.getThreadId + ? () => config.getThreadId!() + : () => this.chat?.threadId, }, { onExecutionsChange: (executions) => { diff --git a/packages/copilot-sdk/src/chat/classes/AbstractChat.ts b/packages/copilot-sdk/src/chat/classes/AbstractChat.ts index da38799..05e7383 100644 --- a/packages/copilot-sdk/src/chat/classes/AbstractChat.ts +++ b/packages/copilot-sdk/src/chat/classes/AbstractChat.ts @@ -168,6 +168,11 @@ export class AbstractChat { return this.transport.isStreaming(); } + /** The thread id currently associated with this chat, if any. */ + get threadId(): string | undefined { + return this.config.threadId; + } + // ============================================ // Public Actions // ============================================ diff --git a/packages/copilot-sdk/src/chat/types/tool.ts b/packages/copilot-sdk/src/chat/types/tool.ts index 458e1cd..4000311 100644 --- a/packages/copilot-sdk/src/chat/types/tool.ts +++ b/packages/copilot-sdk/src/chat/types/tool.ts @@ -96,6 +96,13 @@ export interface AgentLoopConfig { tools?: ToolDefinition[]; /** Max tool executions to keep in memory (default: 100). Oldest are pruned. */ maxExecutionHistory?: number; + /** + * Returns the current thread id for tool handler context. Called once per + * tool invocation. Needed by consumers that need to scope per-tool state + * (e.g. per-thread browser-tab pinning in the Chrome extension). When + * omitted, `context.threadId` is undefined in handlers. + */ + getThreadId?: () => string | undefined; } /** diff --git a/packages/copilot-sdk/src/react/provider/CopilotProvider.tsx b/packages/copilot-sdk/src/react/provider/CopilotProvider.tsx index 30c737c..88b8d45 100644 --- a/packages/copilot-sdk/src/react/provider/CopilotProvider.tsx +++ b/packages/copilot-sdk/src/react/provider/CopilotProvider.tsx @@ -352,6 +352,23 @@ export interface CopilotProviderProps { * Only inline skills (source.type === "inline") are supported client-side. */ skills?: SkillDefinition[]; + /** + * Allow multiple threads to stream concurrently. When enabled, switching away + * from a thread that is still generating does NOT cancel its request — it + * continues in the background. You can also start a new send on a different + * thread while another is still streaming. + * + * When enabled: + * - `useCopilot().messages/status/error` always reflect the ACTIVE thread. + * - A new `busyThreadIds` set reflects every thread currently streaming. + * - The provider maintains one `ReactChatWithTools` instance per thread. + * + * When `onCreateSession` is provided, it may be called concurrently for + * different threads — ensure each call produces a distinct session id. + * + * @default false + */ + concurrentThreads?: boolean; } // ============================================ @@ -490,8 +507,16 @@ export interface CopilotContextValue { * Switch to a different thread (or start a new one). * Pass the session/thread ID from persistence to reuse it (no new session call), * or null to start a fresh thread (new session created on first sendMessage). + * + * When `concurrentThreads` is enabled on the provider, `opts.hydrateMessages` + * is applied when the target instance is being created fresh (not yet + * streaming). It is ignored if the instance already exists (which would + * mean a stream is in-flight or completed) to avoid clobbering state. */ - setActiveThread: (id: string | null) => void; + setActiveThread: ( + id: string | null, + opts?: { hydrateMessages?: UIMessage[]; hydrateActiveLeafId?: string }, + ) => void; /** * Force a new session to be created on the next sendMessage. * Call when the current session has expired or credits are exhausted. @@ -527,6 +552,39 @@ export interface CopilotContextValue { * Use useMessageMeta(messageId) for the hook API. */ messageMeta: MessageMetaStore; + + /** + * Whether concurrent-thread streaming is enabled (via the `concurrentThreads` + * prop on CopilotProvider). When false, the provider uses a single chat + * instance and inactive threads cannot stream. When true, each thread has + * its own instance and streams in the background on switch. + */ + concurrentThreads: boolean; + + /** + * The set of thread IDs that currently have an in-flight request + * (status "submitted" or "streaming"). Always empty when `concurrentThreads` + * is false. UI can use this to render per-thread busy indicators in a + * thread picker. + */ + busyThreadIds: ReadonlySet; + + /** + * Dispose the chat instance backing a given thread ID and remove it from + * the registry. Aborts its in-flight stream if any. Call this when a + * thread is deleted so its background stream doesn't keep running. + * No-op when `concurrentThreads` is false. + */ + disposeThreadInstance: (threadId: string) => void; + + /** + * Commit a locally-generated thread id to the currently-active pending + * chat instance. Used by persistence hooks (e.g. useInternalThreadManager) + * to make a just-started thread visible in the picker immediately, without + * waiting for the server to assign its session id. No-op when + * `concurrentThreads` is false or the active instance is not a pending slot. + */ + assignLocalThreadId: (localId: string) => void; } // ============================================ @@ -571,6 +629,7 @@ export function CopilotProvider(props: CopilotProviderProps) { optimization, messageHistory, skills, + concurrentThreads = false, } = props; const isThreadIdControlled = Object.prototype.hasOwnProperty.call( props, @@ -636,114 +695,466 @@ export function CopilotProvider(props: CopilotProviderProps) { }); // ============================================ - // ChatWithTools Instance + // ChatWithTools Instance Registry // ============================================ - + // + // In single-thread mode (`concurrentThreads === false`, default), there is + // exactly one ReactChatWithTools instance stored in the registry under the + // key SINGLE_INSTANCE_KEY. Behavior matches pre-registry semantics. + // + // In multi-thread mode (`concurrentThreads === true`), one instance per + // thread id is stored in the registry. A "__pending___" slot is used for + // new-thread sends whose server id hasn't been assigned yet; that slot is + // re-keyed to the real id when onThreadChange fires on that instance. + + const SINGLE_INSTANCE_KEY = "__single__"; + + // chatRef.current always points to the active instance; other instances (if + // any) are stored in instancesRef. Using `chatRef.current` everywhere below + // keeps the existing code path unchanged for the single-thread case. const chatRef = useRef(null); + const instancesRef = useRef>(new Map()); + const activeInstanceKeyRef = useRef(SINGLE_INSTANCE_KEY); + const pendingCounterRef = useRef(0); + + // Provider-level shared state that every instance inherits at creation. + // useTool / setInlineSkills / setContext fan out to every live instance so + // the registry is consistent across threads. New instances created later + // (e.g. when a user starts a new thread) are seeded from these refs. + const sharedToolsRef = useRef< + Map + >(new Map()); + const sharedSkillsRef = useRef< + Array<{ + name: string; + description: string; + content: string; + strategy?: string; + }> + >([]); + const sharedSystemContextRef = useRef(""); + + // React subscribers (from useSyncExternalStore) registered on a stable + // wrapper. Each created instance pipes its `subscribe` callbacks through + // this set so swapping the active instance doesn't tear React state. + const subscribersRef = useRef void>>(new Set()); + const stableSubscribe = useMemo( + () => (cb: () => void) => { + subscribersRef.current.add(cb); + return () => { + subscribersRef.current.delete(cb); + }; + }, + [], + ); - // Initialize chat on first render - // If disposed (React StrictMode), revive instead of recreate to preserve tools - if (chatRef.current !== null && chatRef.current.disposed) { - chatRef.current.revive(); - debugLog("Revived disposed instance (React StrictMode)"); - } + // Reactive set of thread ids with an in-flight request. Empty in + // single-thread mode. + const [busyThreadIds, setBusyThreadIds] = useState>( + () => new Set(), + ); - if (chatRef.current === null) { - const uiInitialMessages = initialMessages; - - chatRef.current = new ReactChatWithTools( - { - runtimeUrl, - systemPrompt, - threadId, - onCreateSession, - yourgptConfig, - initialMessages: uiInitialMessages, - streaming, - headers, - body, - parseError, - debug, - maxIterations, - maxIterationsMessage, - optimization, - }, - { - onToolExecutionsChange: (executions) => { - debugLog("Tool executions changed:", executions.length); - setToolExecutions(executions); - // Sync the agent loop iteration count at the same time — it increments - // once per executeToolCalls() call, which is what triggers this callback. - setAgentIteration(chatRef.current?.iteration ?? 0); - }, - onApprovalRequired: (execution) => { - debugLog("Tool approval required:", execution.name); - }, - onContextUsageChange: (usage) => { - setContextUsage(usage); - }, - onError: (error) => { - if (error) onError?.(error); - }, - onThreadChange: (id) => { - debugLog("Thread/session ID assigned:", id); - setActualThreadId(id); - onThreadChange?.(id); - }, - onSessionStatusChange: (status) => { - debugLog("Session status:", status); - setSessionStatus(status); + const recomputeBusyThreadIds = useCallback(() => { + if (!concurrentThreads) return; + const next = new Set(); + for (const [key, inst] of instancesRef.current) { + // Skip internal / pre-session keys — they aren't real thread ids. + if (key === SINGLE_INSTANCE_KEY) continue; + if (key.startsWith("__pending_")) continue; + const s = inst.status; + if (s === "streaming" || s === "submitted") next.add(key); + } + setBusyThreadIds((prev) => { + if (prev.size === next.size) { + let same = true; + for (const id of next) { + if (!prev.has(id)) { + same = false; + break; + } + } + if (same) return prev; + } + return next; + }); + }, [concurrentThreads]); + + const notifyStateChange = useCallback(() => { + for (const cb of subscribersRef.current) cb(); + recomputeBusyThreadIds(); + }, [recomputeBusyThreadIds]); + + // Keep latest prop/callback values in refs so the imperative factory doesn't + // need a useCallback dep list the size of the universe. + const configRef = useRef({ + runtimeUrl, + systemPrompt, + threadId, + onCreateSession, + yourgptConfig, + initialMessages, + streaming, + headers, + body, + parseError, + debug, + maxIterations, + maxIterationsMessage, + optimization, + }); + configRef.current = { + runtimeUrl, + systemPrompt, + threadId, + onCreateSession, + yourgptConfig, + initialMessages, + streaming, + headers, + body, + parseError, + debug, + maxIterations, + maxIterationsMessage, + optimization, + }; + const callbacksRef = useRef({ onError, onThreadChange }); + callbacksRef.current = { onError, onThreadChange }; + + // Handle a thread-id assignment from a chat instance (fires after + // onCreateSession resolves or when the server emits thread:created). + // Resolves the current key by instance identity (captured string keys in + // closures would go stale after a re-key), re-keys pending / internal + // slots to the real thread id, and only propagates to top-level state if + // the firing instance is the active one. + // + // Multi-thread-only guard: when `concurrentThreads` is enabled AND the + // current key is already a non-internal (committed) id, SKIP the re-key. + // This preserves the stable UI thread id that was assigned locally via + // assignLocalThreadId before streaming started — the server's id is kept + // internally on chat.config.threadId for backend communication, but the + // registry / picker keeps the local id so the row stays stable without + // flicker and so deletion / switching keeps working. + // + // In single-thread mode this guard does NOT apply: every server id change + // (including the one after renewSession()) re-keys the registry and + // updates actualThreadId / the user's onThreadChange prop, matching + // pre-multi-thread behavior. + const handleInstanceThreadAssigned = useCallback( + (inst: ReactChatWithTools, newId: string) => { + let oldKey: string | undefined; + for (const [k, v] of instancesRef.current) { + if (v === inst) { + oldKey = k; + break; + } + } + if (oldKey === undefined) return; + + const isInternalKey = + oldKey === SINGLE_INSTANCE_KEY || oldKey.startsWith("__pending_"); + // In single-thread mode, always re-key and propagate. In multi-thread + // mode, only re-key when we're leaving an internal slot; a committed + // id is preserved for UI stability. + const shouldRekey = !concurrentThreads || isInternalKey; + + if (shouldRekey && oldKey !== newId && !instancesRef.current.has(newId)) { + instancesRef.current.delete(oldKey); + instancesRef.current.set(newId, inst); + if (activeInstanceKeyRef.current === oldKey) { + activeInstanceKeyRef.current = newId; + } + if (inst === chatRef.current) { + debugLog("Thread/session ID assigned:", newId); + setActualThreadId(newId); + callbacksRef.current.onThreadChange?.(newId); + } + } + // Multi-thread + non-internal oldKey: do nothing UI-facing. + // chat.config.threadId was already updated by AbstractChat to `newId` + // so future requests hit the server's session; we just don't propagate + // to React state or the registry key. + recomputeBusyThreadIds(); + }, + [concurrentThreads, debugLog, recomputeBusyThreadIds], + ); + + // Create a new chat instance and register it under `key`. Wires all + // callbacks so per-instance events are gated on the active-instance check. + const createInstance = useCallback( + (key: string, opts?: { initialMessages?: UIMessage[] }) => { + const cfg = configRef.current; + // For keys that look like real thread ids (not pending, not single), + // pass the key as the initial threadId so no new session is created. + // For pending / single keys, fall through to the controlled threadId prop. + const initialThreadId = + key === SINGLE_INSTANCE_KEY || key.startsWith("__pending_") + ? cfg.threadId + : key; + // Surrogate holder so the getThreadId closure can read back the + // instance's registry key without searching the whole map every tool + // call. It's updated in-place whenever the key changes (pending → + // local id, or during handleInstanceThreadAssigned). + const inst = new ReactChatWithTools( + { + runtimeUrl: cfg.runtimeUrl, + systemPrompt: cfg.systemPrompt, + threadId: initialThreadId, + onCreateSession: cfg.onCreateSession, + yourgptConfig: cfg.yourgptConfig, + initialMessages: opts?.initialMessages ?? cfg.initialMessages, + streaming: cfg.streaming, + headers: cfg.headers, + body: cfg.body, + parseError: cfg.parseError, + debug: cfg.debug, + maxIterations: cfg.maxIterations, + maxIterationsMessage: cfg.maxIterationsMessage, + optimization: cfg.optimization, + // Expose the registry key (usually the UI thread id after + // assignLocalThreadId runs) to tool handlers, NOT chat.config.threadId. + // For extensions that key per-thread state on context.threadId (e.g. + // browser-tab pinning), this guarantees a stable id — even while the + // backend session id is still being resolved. Internal keys + // (__single__, __pending_*) return undefined so handlers can treat + // them as "not yet committed". + getThreadId: () => { + for (const [k, v] of instancesRef.current) { + if (v === inst) { + if (k === SINGLE_INSTANCE_KEY || k.startsWith("__pending_")) { + return undefined; + } + return k; + } + } + return undefined; + }, }, - onStreamChunk: (chunk) => { - // Broadcast to all useCopilotEvent() subscribers - if (streamListenersRef.current.size > 0) { - for (const handler of streamListenersRef.current) { - handler(chunk); + { + onToolExecutionsChange: (executions) => { + debugLog("Tool executions changed:", executions.length); + // Gate by instance identity — the captured `key` string would go + // stale after handleInstanceThreadAssigned re-keys the instance. + if (inst === chatRef.current) { + setToolExecutions(executions); + setAgentIteration(inst.iteration ?? 0); } - } + }, + onApprovalRequired: (execution) => { + debugLog("Tool approval required:", execution.name); + }, + onContextUsageChange: (usage) => { + if (inst === chatRef.current) { + setContextUsage(usage); + } + }, + onError: (error) => { + if (error && inst === chatRef.current) { + callbacksRef.current.onError?.(error); + } + }, + onThreadChange: (id) => { + handleInstanceThreadAssigned(inst, id); + }, + onSessionStatusChange: (status) => { + debugLog("Session status:", status); + if (inst === chatRef.current) { + setSessionStatus(status); + } + }, + onStreamChunk: (chunk) => { + if (streamListenersRef.current.size > 0) { + for (const handler of streamListenersRef.current) { + handler(chunk); + } + } + }, }, - }, - ); + ); + // Seed the new instance with provider-level shared state (tools, + // skills, system-level context). Without this, instances created later + // — e.g. when the user starts a new thread — start out with no tools, + // so the LLM can't use them even though useTool hooks were set up. + for (const { tool } of sharedToolsRef.current.values()) { + inst.registerTool(tool); + } + if (sharedSkillsRef.current.length > 0) { + inst.setInlineSkills(sharedSkillsRef.current); + } + if (sharedSystemContextRef.current) { + inst.setContext(sharedSystemContextRef.current); + } + + // Wire the instance's state changes into our unified subscriber set so + // every useSyncExternalStore call on stableSubscribe reacts. + inst.subscribe(notifyStateChange); + instancesRef.current.set(key, inst); + return inst; + }, + [debugLog, handleInstanceThreadAssigned, notifyStateChange], + ); + + // Initialize the first instance on first render. If disposed (React + // StrictMode), revive every instance and re-wire our notification + // subscriber: ReactChatState.dispose() clears subscribers, and revive() is + // a no-op for them. Without re-subscribing here, state changes from a + // streaming instance never reach React, so the UI stays frozen until + // something else (like clicking Stop) forces a render. + if (chatRef.current !== null && chatRef.current.disposed) { + for (const inst of instancesRef.current.values()) { + inst.revive(); + inst.subscribe(notifyStateChange); + } + debugLog("Revived disposed instance(s) (React StrictMode)"); } + if (chatRef.current === null) { + const initialKey = + concurrentThreads && threadId ? threadId : SINGLE_INSTANCE_KEY; + activeInstanceKeyRef.current = initialKey; + chatRef.current = createInstance(initialKey); + } + + // Swap the active instance to a different thread. In single-thread mode, + // falls back to legacy setActiveThread behavior on the single instance. + // In multi-thread mode, finds or creates the instance for `key` (or + // optionally hydrates it with the given messages if fresh) and swaps the + // active pointer; the in-flight stream of the previously-active instance + // keeps running in the background. + const switchActiveInstance = useCallback( + ( + key: string | null, + opts?: { hydrateMessages?: UIMessage[]; hydrateActiveLeafId?: string }, + ) => { + if (!concurrentThreads) { + chatRef.current?.setActiveThread(key); + return; + } + // Resolve the target key. Null means "start a new thread" — reuse the + // current fresh-empty slot if we're already sitting on one, otherwise + // mint a new pending slot. A specific string is used as-is. + let targetKey: string; + if (key == null) { + const currentKey = activeInstanceKeyRef.current; + const currentInst = instancesRef.current.get(currentKey); + const isCurrentFreshEmpty = + currentInst !== undefined && + currentInst.messages.length === 0 && + (currentKey === SINGLE_INSTANCE_KEY || + currentKey.startsWith("__pending_")); + if (isCurrentFreshEmpty) { + return; + } + targetKey = `__pending_${++pendingCounterRef.current}__`; + } else { + targetKey = key; + } + + let inst = instancesRef.current.get(targetKey); + const wasFresh = !inst; + if (!inst) { + // If the currently-active instance is an empty internal-keyed slot + // (__single__ or __pending___), promote it to the target id + // instead of creating a new instance. This happens during auto-restore + // when the app loads with a persisted thread: the initial active + // instance at SINGLE_INSTANCE_KEY gets promoted to the restored id + // so sends use the correct session and busyThreadIds tracks it. + const currentKey = activeInstanceKeyRef.current; + const currentInst = instancesRef.current.get(currentKey); + const currentIsPromotableSlot = + currentInst !== undefined && + currentInst.messages.length === 0 && + (currentKey === SINGLE_INSTANCE_KEY || + currentKey.startsWith("__pending_")); + if (currentIsPromotableSlot) { + instancesRef.current.delete(currentKey); + instancesRef.current.set(targetKey, currentInst!); + currentInst!.setActiveThread(targetKey); + inst = currentInst; + if (opts?.hydrateMessages) { + inst!.setMessages(opts.hydrateMessages); + } + if (opts?.hydrateActiveLeafId) { + inst!.switchBranch(opts.hydrateActiveLeafId); + } + } else { + inst = createInstance(targetKey, { + initialMessages: opts?.hydrateMessages, + }); + if (opts?.hydrateActiveLeafId) { + inst.switchBranch(opts.hydrateActiveLeafId); + } + } + } else if ( + wasFresh && + opts?.hydrateMessages && + inst.messages.length === 0 + ) { + inst.setMessages(opts.hydrateMessages); + if (opts.hydrateActiveLeafId) + inst.switchBranch(opts.hydrateActiveLeafId); + } + if (activeInstanceKeyRef.current === targetKey) return; + activeInstanceKeyRef.current = targetKey; + chatRef.current = inst!; + if ( + targetKey === SINGLE_INSTANCE_KEY || + targetKey.startsWith("__pending_") + ) { + setActualThreadId(undefined); + } else { + setActualThreadId(targetKey); + } + setSessionStatus(inst!.getSessionStatus()); + setToolExecutions(inst!.toolExecutions); + setAgentIteration(inst!.iteration); + notifyStateChange(); + debugLog("Active instance switched", { key: targetKey }); + }, + [concurrentThreads, createInstance, debugLog, notifyStateChange], + ); + // ============================================ // System Prompt Reactivity // ============================================ - // Watch for systemPrompt prop changes and update chat + // Watch for systemPrompt prop changes and update every instance. In + // single-thread mode this is just the one instance; in multi-thread mode + // we fan out so background and newly-created instances stay consistent. useEffect(() => { - if (chatRef.current && systemPrompt !== undefined) { - chatRef.current.setSystemPrompt(systemPrompt); - debugLog("System prompt updated from prop"); + if (systemPrompt === undefined) return; + for (const inst of instancesRef.current.values()) { + inst.setSystemPrompt(systemPrompt); } + debugLog("System prompt updated from prop"); }, [systemPrompt, debugLog]); // ============================================ // Headers & Body Reactivity // ============================================ - // Watch for headers prop changes and update chat useEffect(() => { - if (chatRef.current && headers !== undefined) { - chatRef.current.setHeaders(headers); - debugLog("Headers config updated from prop"); + if (headers === undefined) return; + for (const inst of instancesRef.current.values()) { + inst.setHeaders(headers); } + debugLog("Headers config updated from prop"); }, [headers, debugLog]); - // Watch for body prop changes useEffect(() => { - if (chatRef.current && body !== undefined) { - chatRef.current.setBody(body); - debugLog("Body config updated from prop"); + if (body === undefined) return; + for (const inst of instancesRef.current.values()) { + inst.setBody(body); } + debugLog("Body config updated from prop"); }, [body, debugLog]); - // Watch for runtimeUrl prop changes useEffect(() => { - if (chatRef.current && runtimeUrl !== undefined) { - chatRef.current.setUrl(runtimeUrl); - debugLog("URL config updated from prop"); + if (runtimeUrl === undefined) return; + for (const inst of instancesRef.current.values()) { + inst.setUrl(runtimeUrl); } + debugLog("URL config updated from prop"); }, [runtimeUrl, debugLog]); // Keep the chat instance aligned with controlled threadId prop changes. @@ -765,11 +1176,21 @@ export function CopilotProvider(props: CopilotProviderProps) { return; } - chatRef.current?.setActiveThread(threadId ?? null); - setActualThreadId(threadId); - setSessionStatus(threadId ? "ready" : "idle"); + if (concurrentThreads) { + switchActiveInstance(threadId ?? null); + } else { + chatRef.current?.setActiveThread(threadId ?? null); + setActualThreadId(threadId); + setSessionStatus(threadId ? "ready" : "idle"); + } debugLog("Thread/session synced from prop", { threadId }); - }, [debugLog, isThreadIdControlled, threadId]); + }, [ + debugLog, + isThreadIdControlled, + threadId, + concurrentThreads, + switchActiveInstance, + ]); // Stable snapshot callbacks for useSyncExternalStore // getServerSnapshot must return a cached/stable value to avoid infinite loops @@ -782,21 +1203,23 @@ export function CopilotProvider(props: CopilotProviderProps) { const getStatusSnapshot = useCallback(() => chatRef.current!.status, []); const getErrorSnapshot = useCallback(() => chatRef.current!.error, []); - // Subscribe to chat state with useSyncExternalStore + // Subscribe to chat state with useSyncExternalStore via the stable wrapper + // so that swapping the active instance in multi-thread mode doesn't tear + // subscriptions. const messages = useSyncExternalStore( - chatRef.current.subscribe, + stableSubscribe, getMessagesSnapshot, getServerMessagesSnapshot, ); const status = useSyncExternalStore( - chatRef.current.subscribe, + stableSubscribe, getStatusSnapshot, () => "ready" as const, ); const errorFromChat = useSyncExternalStore( - chatRef.current.subscribe, + stableSubscribe, getErrorSnapshot, () => undefined, ); @@ -808,11 +1231,85 @@ export function CopilotProvider(props: CopilotProviderProps) { // Actions // ============================================ - const setActiveThread = useCallback((id: string | null) => { - chatRef.current?.setActiveThread(id); - // Sync React state: known ID → expose it; null (new thread) → clear until onThreadChange fires - setActualThreadId(id ?? undefined); - }, []); + const setActiveThread = useCallback( + ( + id: string | null, + opts?: { hydrateMessages?: UIMessage[]; hydrateActiveLeafId?: string }, + ) => { + if (concurrentThreads) { + switchActiveInstance(id, opts); + } else { + chatRef.current?.setActiveThread(id); + // Sync React state: known ID → expose it; null (new thread) → clear until onThreadChange fires + setActualThreadId(id ?? undefined); + } + }, + [concurrentThreads, switchActiveInstance], + ); + + const disposeThreadInstance = useCallback( + (id: string) => { + if (!concurrentThreads) return; + const inst = instancesRef.current.get(id); + if (!inst) return; + inst.dispose(); + instancesRef.current.delete(id); + if (activeInstanceKeyRef.current === id) { + // Deleted the active instance — switchActiveInstance(null) will mint + // a fresh pending slot and swap to it. + switchActiveInstance(null); + } else { + recomputeBusyThreadIds(); + } + }, + [concurrentThreads, switchActiveInstance, recomputeBusyThreadIds], + ); + + // Re-key the active pending instance to a caller-supplied local thread id + // so the thread becomes visible in the picker WHILE it's still streaming, + // without waiting for the server to emit `thread:created`. + // + // Why this exists: some backends only include the real session id in the + // final `done` chunk. Without this, a newly-started thread would not show + // up in the picker until the stream ended. With this, useInternalThreadManager + // mints a local id as soon as the first send starts, creates the thread + // in the manager, and calls assignLocalThreadId to bind the pending instance + // to that id. busyThreadIds then includes the local id and the picker shows + // a live row with a spinner. + // + // Side note on the backend session id: we do NOT call chat.setActiveThread + // here, so chat.config.threadId stays undefined and the server creates its + // own session as usual. When the server emits its session id (via + // thread:created or done), AbstractChat updates chat.config.threadId + // internally so subsequent sends on this thread reuse that server session. + // The registry key and the picker thread id stay the local id for stability. + const assignLocalThreadId = useCallback( + (localId: string) => { + if (!concurrentThreads) return; + if (!localId) return; + const currentKey = activeInstanceKeyRef.current; + if (currentKey === localId) return; + const currentInst = instancesRef.current.get(currentKey); + if (!currentInst) return; + const isInternalSlot = + currentKey === SINGLE_INSTANCE_KEY || + currentKey.startsWith("__pending_"); + if (!isInternalSlot) return; + // Someone else already owns this key — bail rather than clobber it. + if (instancesRef.current.has(localId)) return; + + instancesRef.current.delete(currentKey); + instancesRef.current.set(localId, currentInst); + activeInstanceKeyRef.current = localId; + if (currentInst === chatRef.current) { + setActualThreadId(localId); + callbacksRef.current.onThreadChange?.(localId); + } + recomputeBusyThreadIds(); + debugLog("Assigned local thread id", { localId }); + }, + [concurrentThreads, debugLog, recomputeBusyThreadIds], + ); const renewSession = useCallback(() => { chatRef.current?.renewSession(); @@ -821,11 +1318,33 @@ export function CopilotProvider(props: CopilotProviderProps) { }, []); const registerTool = useCallback((tool: ToolDefinition) => { - chatRef.current?.registerTool(tool); + // Track at the provider level so new instances created later (e.g. on + // thread switch / new thread) can inherit the same tool set. Ref-count + // so StrictMode's register → unregister → register cycle is a no-op. + const existing = sharedToolsRef.current.get(tool.name); + if (existing) { + existing.tool = tool; + existing.refCount++; + } else { + sharedToolsRef.current.set(tool.name, { tool, refCount: 1 }); + } + // Fan out to every live instance — the active one AND any background + // instances — so every thread can use the tool. + for (const inst of instancesRef.current.values()) { + inst.registerTool(tool); + } }, []); const unregisterTool = useCallback((name: string) => { - chatRef.current?.unregisterTool(name); + const entry = sharedToolsRef.current.get(name); + if (!entry) return; + entry.refCount = Math.max(0, entry.refCount - 1); + for (const inst of instancesRef.current.values()) { + inst.unregisterTool(name); + } + if (entry.refCount === 0) { + sharedToolsRef.current.delete(name); + } }, []); const approveToolExecution = useCallback( @@ -887,6 +1406,20 @@ export function CopilotProvider(props: CopilotProviderProps) { const [contextChars, setContextChars] = useState(0); const [contextUsage, setContextUsage] = useState(null); + // Note: addContext / removeContext update ONLY the active instance. + // + // AI context (useAIContext) is often derived from the user's current state + // — e.g. "current_page" reflects whichever browser tab the user is on. If + // we fanned out to every instance, switching browser tabs would overwrite + // the context of a thread that was mid-task, which derails the AI (it may + // abandon the work it was doing because the "current page" suddenly + // changed out from under it). + // + // Background threads keep the context snapshot that was in place when + // their instance was created (via createInstance's seeding from + // sharedSystemContextRef). New instances created later still get the + // latest context at creation time. sharedSystemContextRef tracks the + // latest so new instances are seeded correctly. const addContext = useCallback( (context: string, parentId?: string): string => { const id = `ctx-${++contextIdCounter.current}`; @@ -895,8 +1428,8 @@ export function CopilotProvider(props: CopilotProviderProps) { { id, value: context, parentId }, parentId, ); - // Update chat's context const contextString = printTree(contextTreeRef.current); + sharedSystemContextRef.current = contextString; chatRef.current?.setContext(contextString); setContextChars(contextString.length); debugLog("Context added:", id); @@ -908,8 +1441,8 @@ export function CopilotProvider(props: CopilotProviderProps) { const removeContext = useCallback( (id: string): void => { contextTreeRef.current = removeNode(contextTreeRef.current, id); - // Update chat's context const contextString = printTree(contextTreeRef.current); + sharedSystemContextRef.current = contextString; chatRef.current?.setContext(contextString); setContextChars(contextString.length); debugLog("Context removed:", id); @@ -923,7 +1456,9 @@ export function CopilotProvider(props: CopilotProviderProps) { const setSystemPrompt = useCallback( (prompt: string): void => { - chatRef.current?.setSystemPrompt(prompt); + for (const inst of instancesRef.current.values()) { + inst.setSystemPrompt(prompt); + } debugLog("System prompt updated via function"); }, [debugLog], @@ -938,7 +1473,10 @@ export function CopilotProvider(props: CopilotProviderProps) { strategy?: string; }>, ): void => { - chatRef.current?.setInlineSkills(skills); + sharedSkillsRef.current = skills; + for (const inst of instancesRef.current.values()) { + inst.setInlineSkills(skills); + } debugLog("Inline skills updated", { count: skills.length }); }, [debugLog], @@ -1000,7 +1538,7 @@ export function CopilotProvider(props: CopilotProviderProps) { [], ); const hasBranches = useSyncExternalStore( - chatRef.current.subscribe, + stableSubscribe, getHasBranchesSnapshot, () => false, ); @@ -1043,10 +1581,13 @@ export function CopilotProvider(props: CopilotProviderProps) { } }, [error, onError]); - // Cleanup + // Cleanup — dispose every registered instance so all in-flight streams are + // aborted on unmount (including background ones in multi-thread mode). useEffect(() => { return () => { - chatRef.current?.dispose(); + for (const inst of instancesRef.current.values()) { + inst.dispose(); + } }; }, []); @@ -1115,6 +1656,12 @@ export function CopilotProvider(props: CopilotProviderProps) { // Headless primitives subscribeToStreamEvents, messageMeta: messageMetaStoreRef.current, + + // Multi-thread streaming + concurrentThreads, + busyThreadIds, + disposeThreadInstance, + assignLocalThreadId, }), [ messages, @@ -1155,6 +1702,10 @@ export function CopilotProvider(props: CopilotProviderProps) { sessionStatus, runtimeUrl, toolsConfig, + concurrentThreads, + busyThreadIds, + disposeThreadInstance, + assignLocalThreadId, ], ); diff --git a/packages/copilot-sdk/src/ui/components/composed/chat/chat.tsx b/packages/copilot-sdk/src/ui/components/composed/chat/chat.tsx index 5f8fd20..b3f9f4f 100644 --- a/packages/copilot-sdk/src/ui/components/composed/chat/chat.tsx +++ b/packages/copilot-sdk/src/ui/components/composed/chat/chat.tsx @@ -68,6 +68,11 @@ interface CopilotChatInternalContext { onSwitchThread?: (id: string) => void; onDeleteThread?: (id: string) => void; isThreadBusy?: boolean; + /** + * Set of thread IDs that currently have an in-flight request. Populated + * only when `concurrentThreads` is enabled on the CopilotProvider. + */ + busyThreadIds?: ReadonlySet; } const CopilotChatContext = createContext( @@ -564,6 +569,7 @@ function ChatComponent({ currentThreadId, onSwitchThread, isThreadBusy, + busyThreadIds, // Branching getBranchInfo, onSwitchBranch, @@ -897,6 +903,7 @@ function ChatComponent({ onSwitchThread, onDeleteThread, isThreadBusy, + busyThreadIds, }), [ view, @@ -913,6 +920,7 @@ function ChatComponent({ onSwitchThread, onDeleteThread, isThreadBusy, + busyThreadIds, ], ); diff --git a/packages/copilot-sdk/src/ui/components/composed/chat/types.ts b/packages/copilot-sdk/src/ui/components/composed/chat/types.ts index 4cc5f66..4b940ac 100644 --- a/packages/copilot-sdk/src/ui/components/composed/chat/types.ts +++ b/packages/copilot-sdk/src/ui/components/composed/chat/types.ts @@ -574,6 +574,12 @@ export type ChatProps = { onSwitchThread?: (threadId: string) => void; /** Whether a thread operation is in progress (disables controls) */ isThreadBusy?: boolean; + /** + * Set of thread IDs with an in-flight request. Populated only when + * `concurrentThreads` is enabled on the CopilotProvider. Use in a custom + * thread picker to show a per-thread streaming indicator. + */ + busyThreadIds?: ReadonlySet; // === Branching (conversation variants) === /** diff --git a/packages/copilot-sdk/src/ui/components/composed/connected-chat.tsx b/packages/copilot-sdk/src/ui/components/composed/connected-chat.tsx index 6598f30..be24e9a 100644 --- a/packages/copilot-sdk/src/ui/components/composed/connected-chat.tsx +++ b/packages/copilot-sdk/src/ui/components/composed/connected-chat.tsx @@ -364,6 +364,7 @@ function CopilotChatBase( getBranchInfo, editMessage, error: chatError, + disposeThreadInstance, } = useCopilot(); // Convert tool executions to the expected format @@ -604,13 +605,21 @@ function CopilotChatBase( : undefined; // Build thread picker element (if enabled) - const { threadManager, handleSwitchThread, handleNewThread, isBusy } = - threadManagerResult; + const { + threadManager, + handleSwitchThread, + handleNewThread, + isBusy, + busyThreadIds, + } = threadManagerResult; // Handle delete thread const handleDeleteThread = React.useCallback( (threadId: string) => { const isCurrentThread = threadManager.currentThreadId === threadId; + // Abort any in-flight stream for this thread and evict the backing chat + // instance. No-op when concurrentThreads is disabled. + disposeThreadInstance(threadId); threadManager.deleteThread(threadId); // If deleting the current thread, clear messages and show welcome screen @@ -618,7 +627,7 @@ function CopilotChatBase( handleNewThread(); } }, - [threadManager, handleNewThread], + [threadManager, handleNewThread, disposeThreadInstance], ); const threadPickerElement = @@ -676,6 +685,7 @@ function CopilotChatBase( currentThreadId={threadManager.currentThreadId} onSwitchThread={isPersistenceEnabled ? handleSwitchThread : undefined} isThreadBusy={isBusy} + busyThreadIds={busyThreadIds} // Branching (auto-wired from context) getBranchInfo={getBranchInfo} onSwitchBranch={switchBranch} diff --git a/packages/copilot-sdk/src/ui/hooks/useInternalThreadManager.ts b/packages/copilot-sdk/src/ui/hooks/useInternalThreadManager.ts index ece89e7..c89a59d 100644 --- a/packages/copilot-sdk/src/ui/hooks/useInternalThreadManager.ts +++ b/packages/copilot-sdk/src/ui/hooks/useInternalThreadManager.ts @@ -37,7 +37,14 @@ export interface UseInternalThreadManagerReturn { threadManager: ReturnType; handleSwitchThread: (threadId: string) => Promise; handleNewThread: () => Promise; + /** Whether the currently-active thread is busy (submitted or streaming). */ isBusy: boolean; + /** + * Set of thread IDs that currently have an in-flight request. Empty unless + * `concurrentThreads` is enabled on the CopilotProvider. Use for per-thread + * busy indicators in a thread picker. + */ + busyThreadIds: ReadonlySet; } // ── State machine ──────────────────────────────────────────────────────────── @@ -236,6 +243,10 @@ export function useInternalThreadManager( switchBranch, threadId: sdkThreadId, setActiveThread, + concurrentThreads, + busyThreadIds, + assignLocalThreadId, + sessionStatus, } = useCopilot(); // ── Auto-restore on mount ────────────────────────────────────────────── @@ -249,8 +260,20 @@ export function useInternalThreadManager( if (currentThread.messages && currentThread.messages.length > 0) { const uiMessages = currentThread.messages.map(coreToUI); const snapshot = getMessageSnapshot(uiMessages); - setMessages(uiMessages); - if (currentThread.activeLeafId) switchBranch(currentThread.activeLeafId); + if (concurrentThreads) { + // Re-key the active instance to the restored thread id AND hydrate + // messages. Without this, subsequent sends would create a brand new + // session (since the instance's threadId wasn't set), and the picker + // row for the restored thread would never appear in busyThreadIds. + setActiveThread(currentThread.id, { + hydrateMessages: uiMessages, + hydrateActiveLeafId: currentThread.activeLeafId, + }); + } else { + setMessages(uiMessages); + if (currentThread.activeLeafId) + switchBranch(currentThread.activeLeafId); + } onThreadChange?.(currentThread.id); dispatch({ type: "RESTORE_COMPLETE", @@ -258,6 +281,9 @@ export function useInternalThreadManager( snapshot, }); } else { + if (concurrentThreads) { + setActiveThread(currentThread.id); + } onThreadChange?.(currentThread.id); dispatch({ type: "RESTORE_COMPLETE", @@ -276,6 +302,8 @@ export function useInternalThreadManager( setMessages, switchBranch, onThreadChange, + concurrentThreads, + setActiveThread, ]); // Mark initialized if no thread to restore @@ -291,18 +319,52 @@ export function useInternalThreadManager( }, [enabled, autoRestoreLastThread, state.initialized]); // ── Phase: idle → awaiting_server_id ─────────────────────────────────── - + // + // Single-thread mode: wait for the response to finish (status ready) before + // creating the thread record, so the record captures the final messages. + // + // Multi-thread mode: fire as soon as the first send starts so the thread + // becomes visible in the picker (with a spinner) mid-generation. If the + // backend supplies its session id early — either because yourgptConfig / + // onCreateSession resolved pre-stream, or because the server emits + // `thread:created` at the top of the stream — we wait for that id and use + // it as the thread id, so UI and backend stay in lockstep (preserving the + // pre-multi-thread contract where `useCopilot().threadId` === backend + // session id). Only when NO early id is available do we mint a local one. + // + // `sessionStatus === "creating"` means a session creator is in flight; + // holding back for that keeps yourgptConfig / onCreateSession consumers on + // their server-assigned id instead of a local uuid. useEffect(() => { if (!enabled) return; if (state.phase !== "idle") return; if (isLoadingRef.current) return; - if (status === "streaming" || status === "submitted") return; if (messages.length === 0) return; if (currentThreadId) return; // Already have a thread - // First message response just completed + const streaming = status === "streaming" || status === "submitted"; + if (streaming && !concurrentThreads) return; // legacy: wait for ready + + // Multi-thread: if a pre-stream session creator is still running, defer + // so we can use its id. If it already resolved (sessionStatus moved to + // "ready") and sdkThreadId is set, proceed immediately and use the id. + // If there's no session creator at all, sessionStatus stays "idle" and + // we proceed without waiting. + if (concurrentThreads && sessionStatus === "creating" && !sdkThreadId) { + return; + } + dispatch({ type: "FIRST_RESPONSE_COMPLETE" }); - }, [enabled, state.phase, status, messages.length, currentThreadId]); + }, [ + enabled, + state.phase, + status, + messages.length, + currentThreadId, + concurrentThreads, + sdkThreadId, + sessionStatus, + ]); // ── Phase: awaiting_server_id → creating ─────────────────────────────── @@ -330,11 +392,19 @@ export function useInternalThreadManager( const activeLeafId = messages[messages.length - 1]?.id; const snapshot = getMessageSnapshot(messages); + const usingLocalId = !sdkThreadId; + createThread({ id: sdkThreadId ?? undefined, messages: coreMessages, activeLeafId, }).then((thread) => { + // Multi-thread mode without a server-assigned id yet: bind the pending + // chat instance to this manager-generated local id so the thread shows + // up in busyThreadIds / the picker while it streams. + if (concurrentThreads && usingLocalId) { + assignLocalThreadId(thread.id); + } dispatch({ type: "THREAD_CREATED", threadId: thread.id, snapshot }); onThreadChange?.(thread.id); }); @@ -383,24 +453,42 @@ export function useInternalThreadManager( isLoadingRef.current = true; const thread = await switchThread(threadId); - if (thread?.messages) { - const uiMessages = thread.messages.map(coreToUI); - const snapshot = getMessageSnapshot(uiMessages); - setMessages(uiMessages); - if (thread.activeLeafId) switchBranch(thread.activeLeafId); - onThreadChange?.(threadId); - dispatch({ type: "SWITCH_COMPLETE", threadId, snapshot }); + const uiMessages = thread?.messages ? thread.messages.map(coreToUI) : []; + const snapshot = thread?.messages ? getMessageSnapshot(uiMessages) : ""; + + if (concurrentThreads) { + // Multi-thread mode: delegate to the provider, which swaps the active + // chat instance. The provider hydrates messages only if the target + // instance is fresh; an existing (possibly streaming) instance is + // left alone so its in-flight stream is preserved. + setActiveThread(threadId, { + hydrateMessages: uiMessages, + hydrateActiveLeafId: thread?.activeLeafId, + }); } else { - setMessages([]); - onThreadChange?.(threadId); - dispatch({ type: "SWITCH_COMPLETE", threadId, snapshot: "" }); + // Single-thread mode: write the loaded messages into the shared chat + if (thread?.messages) { + setMessages(uiMessages); + if (thread.activeLeafId) switchBranch(thread.activeLeafId); + } else { + setMessages([]); + } } + onThreadChange?.(threadId); + dispatch({ type: "SWITCH_COMPLETE", threadId, snapshot }); requestAnimationFrame(() => { isLoadingRef.current = false; }); }, - [switchThread, setMessages, switchBranch, onThreadChange], + [ + switchThread, + setMessages, + switchBranch, + onThreadChange, + concurrentThreads, + setActiveThread, + ], ); // ── New thread ───────────────────────────────────────────────────────── @@ -409,7 +497,12 @@ export function useInternalThreadManager( isLoadingRef.current = true; clearCurrentThread(); - setMessages([]); + // In multi-thread mode, skip setMessages([]) — it would clobber the + // currently-active instance's messages (possibly mid-stream). The + // provider's switchActiveInstance mints a fresh, empty pending slot. + if (!concurrentThreads) { + setMessages([]); + } setActiveThread(null); // Clear SDK session so next message creates a new one onThreadChange?.(null); dispatch({ type: "NEW_THREAD" }); @@ -417,7 +510,13 @@ export function useInternalThreadManager( requestAnimationFrame(() => { isLoadingRef.current = false; }); - }, [clearCurrentThread, setMessages, setActiveThread, onThreadChange]); + }, [ + clearCurrentThread, + setMessages, + setActiveThread, + onThreadChange, + concurrentThreads, + ]); // ── Return ───────────────────────────────────────────────────────────── @@ -428,5 +527,6 @@ export function useInternalThreadManager( handleSwitchThread, handleNewThread, isBusy, + busyThreadIds, }; } From e74de6c64f0a761ea81b923c324e542896db3e4f Mon Sep 17 00:00:00 2001 From: Gourav Date: Thu, 23 Apr 2026 13:55:35 +0530 Subject: [PATCH 2/3] fix(react): unblock thread switching during stream in concurrent mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../playground/AlphaFeaturesSection.tsx | 55 ++++++ .../components/playground/CopilotSidebar.tsx | 13 +- examples/playground/lib/constants.ts | 4 + examples/playground/lib/types.ts | 6 + examples/playground/package.json | 4 +- .../src/ui/hooks/useInternalThreadManager.ts | 7 +- pnpm-lock.yaml | 168 +----------------- 7 files changed, 89 insertions(+), 168 deletions(-) diff --git a/examples/playground/components/playground/AlphaFeaturesSection.tsx b/examples/playground/components/playground/AlphaFeaturesSection.tsx index b27deec..b9ef4df 100644 --- a/examples/playground/components/playground/AlphaFeaturesSection.tsx +++ b/examples/playground/components/playground/AlphaFeaturesSection.tsx @@ -11,9 +11,12 @@ import { LayoutList, BarChart2, ChevronRight, + Layers, + KeyRound, } from "lucide-react"; import { Switch } from "@/components/ui/switch"; import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; import { Select, @@ -225,6 +228,58 @@ export function AlphaFeaturesSection({
+ {/* Threads */} +

+ Threads +

+ onUpdate("concurrentThreads", v)} + /> + +
+ + {/* YourGPT Auth */} +

+ YourGPT Auth +

+ onUpdate("yourgptAuthEnabled", v)} + /> + {alphaConfig.yourgptAuthEnabled && ( +
+ + onUpdate("yourgptApiKey", e.target.value)} + placeholder="ygpt_..." + className="h-7 text-xs" + /> + + onUpdate("yourgptWidgetUid", e.target.value)} + placeholder="wgt_..." + className="h-7 text-xs" + /> +

+ Stored in localStorage. Reload chat after saving. +

+
+ )} + +
+ {/* Tools */}

Advanced Tools diff --git a/examples/playground/components/playground/CopilotSidebar.tsx b/examples/playground/components/playground/CopilotSidebar.tsx index 102c3a4..75e7a4e 100644 --- a/examples/playground/components/playground/CopilotSidebar.tsx +++ b/examples/playground/components/playground/CopilotSidebar.tsx @@ -96,12 +96,23 @@ export function CopilotSidebar({

=21.1.0} @@ -4679,33 +4642,6 @@ packages: babel-plugin-react-compiler: optional: true - '@yourgpt/copilot-sdk@2.1.8': - resolution: {integrity: sha512-c3cSm92Liz7Jr0rbzJ5dPvf+N/fFpsLHdO6Ww1bzImpxNwXIq4zTbdDviCD/1ybMpAxrnaU5wUAv4F/rGYjyjg==} - engines: {node: '>=18'} - peerDependencies: - react: ^18.0.0 || ^19.0.0 - react-dom: ^18.0.0 || ^19.0.0 - peerDependenciesMeta: - react: - optional: true - react-dom: - optional: true - - '@yourgpt/llm-sdk@2.1.8': - resolution: {integrity: sha512-dMLyvaEySmJC+6PnodZVE9N9l+A1aPmzOP9U1hk+on+mS1T0JCdXXh95wjHGL8/KNOy97clh9b4R78NnRFc0XQ==} - engines: {node: '>=18'} - peerDependencies: - '@anthropic-ai/sdk': '>=0.20.0' - '@google/generative-ai': '>=0.21.0' - openai: '>=4.0.0' - peerDependenciesMeta: - '@anthropic-ai/sdk': - optional: true - '@google/generative-ai': - optional: true - openai: - optional: true - abort-controller@3.0.0: resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} engines: {node: '>=6.5'} @@ -9011,20 +8947,6 @@ snapshots: optionalDependencies: '@types/react': 18.3.27 - '@base-ui/react@1.0.0(@types/react@18.3.27)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': - dependencies: - '@babel/runtime': 7.28.4 - '@base-ui/utils': 0.2.3(@types/react@18.3.27)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@floating-ui/react-dom': 2.1.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@floating-ui/utils': 0.2.10 - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) - reselect: 5.1.1 - tabbable: 6.3.0 - use-sync-external-store: 1.6.0(react@19.2.3) - optionalDependencies: - '@types/react': 18.3.27 - '@base-ui/utils@0.2.3(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@babel/runtime': 7.28.4 @@ -9036,17 +8958,6 @@ snapshots: optionalDependencies: '@types/react': 18.3.27 - '@base-ui/utils@0.2.3(@types/react@18.3.27)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': - dependencies: - '@babel/runtime': 7.28.4 - '@floating-ui/utils': 0.2.10 - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) - reselect: 5.1.1 - use-sync-external-store: 1.6.0(react@19.2.3) - optionalDependencies: - '@types/react': 18.3.27 - '@changesets/apply-release-plan@7.0.14': dependencies: '@changesets/config': 3.1.2 @@ -11681,11 +11592,6 @@ snapshots: react: 18.3.1 shiki: 3.20.0 - '@streamdown/code@1.0.1(react@19.2.3)': - dependencies: - react: 19.2.3 - shiki: 3.20.0 - '@swc/helpers@0.5.15': dependencies: tslib: 2.8.1 @@ -12006,10 +11912,6 @@ snapshots: '@types/validate-npm-package-name@4.0.2': {} - '@types/ws@8.18.1': - dependencies: - '@types/node': 20.19.27 - '@typescript-eslint/eslint-plugin@8.50.0(@typescript-eslint/parser@8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -12191,40 +12093,6 @@ snapshots: '@rolldown/pluginutils': 1.0.0-rc.7 vite: 8.0.3(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@20.19.27)(esbuild@0.27.1)(jiti@2.6.1)(sass@1.97.0)(tsx@4.21.0)(yaml@2.8.2) - '@yourgpt/copilot-sdk@2.1.8(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': - dependencies: - '@base-ui/react': 1.0.0(@types/react@18.3.27)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-avatar': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-hover-card': 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-slot': 1.2.4(@types/react@18.3.27)(react@19.2.3) - '@radix-ui/react-tooltip': 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@streamdown/code': 1.0.1(react@19.2.3) - class-variance-authority: 0.7.1 - clsx: 2.1.1 - html-to-image: 1.11.13 - html2canvas: 1.4.1 - lucide-react: 0.561.0(react@19.2.3) - streamdown: 2.1.0(react@19.2.3) - tailwind-merge: 3.4.0 - use-stick-to-bottom: 1.1.1(react@19.2.3) - zod: 3.25.76 - optionalDependencies: - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) - transitivePeerDependencies: - - '@types/react' - - '@types/react-dom' - - supports-color - - '@yourgpt/llm-sdk@2.1.8(@anthropic-ai/sdk@0.71.2(zod@3.25.76))(@google/generative-ai@0.24.1)(openai@6.16.0(ws@8.18.0)(zod@3.25.76))': - dependencies: - hono: 4.11.0 - zod: 3.25.76 - optionalDependencies: - '@anthropic-ai/sdk': 0.71.2(zod@3.25.76) - '@google/generative-ai': 0.24.1 - openai: 6.16.0(ws@8.18.0)(zod@3.25.76) - abort-controller@3.0.0: dependencies: event-target-shim: 5.0.1 @@ -14588,10 +14456,6 @@ snapshots: dependencies: react: 18.3.1 - lucide-react@0.561.0(react@19.2.3): - dependencies: - react: 19.2.3 - lucide-react@0.562.0(react@19.2.1): dependencies: react: 19.2.1 @@ -16468,26 +16332,6 @@ snapshots: transitivePeerDependencies: - supports-color - streamdown@2.1.0(react@19.2.3): - dependencies: - clsx: 2.1.1 - hast-util-to-jsx-runtime: 2.3.6 - html-url-attributes: 3.0.1 - marked: 17.0.1 - react: 19.2.3 - rehype-harden: 1.1.7 - rehype-raw: 7.0.0 - rehype-sanitize: 6.0.0 - remark-gfm: 4.0.1 - remark-parse: 11.0.0 - remark-rehype: 11.1.2 - remend: 1.1.0 - tailwind-merge: 3.4.0 - unified: 11.0.5 - unist-util-visit: 5.0.0 - transitivePeerDependencies: - - supports-color - strict-event-emitter@0.5.1: {} string-argv@0.3.2: {} @@ -16991,10 +16835,6 @@ snapshots: dependencies: react: 18.3.1 - use-stick-to-bottom@1.1.1(react@19.2.3): - dependencies: - react: 19.2.3 - use-sync-external-store@1.6.0(react@18.3.1): dependencies: react: 18.3.1 From 8bb0184bf86d877aa889e16e2195cdb951014d3b Mon Sep 17 00:00:00 2001 From: Gourav Date: Thu, 23 Apr 2026 18:58:31 +0530 Subject: [PATCH 3/3] feat(react): expose pendingApprovalsByThread for background approval state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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. --- .../src/react/provider/CopilotProvider.tsx | 82 +++++++++++++++++-- 1 file changed, 77 insertions(+), 5 deletions(-) diff --git a/packages/copilot-sdk/src/react/provider/CopilotProvider.tsx b/packages/copilot-sdk/src/react/provider/CopilotProvider.tsx index 88b8d45..501e863 100644 --- a/packages/copilot-sdk/src/react/provider/CopilotProvider.tsx +++ b/packages/copilot-sdk/src/react/provider/CopilotProvider.tsx @@ -569,6 +569,16 @@ export interface CopilotContextValue { */ busyThreadIds: ReadonlySet; + /** + * Reactive map of per-thread pending tool approvals. Key = thread id, + * value = ToolExecutions whose approvalStatus is "required". Empty when + * `concurrentThreads` is false. UI can use this to light up indicators + * on background threads that are blocked waiting for the user to approve + * or reject a tool — the approval card only renders on the active thread, + * so without this, a background thread's block is invisible. + */ + pendingApprovalsByThread: ReadonlyMap; + /** * Dispose the chat instance backing a given thread ID and remove it from * the registry. Aborts its in-flight stream if any. Call this when a @@ -779,10 +789,41 @@ export function CopilotProvider(props: CopilotProviderProps) { }); }, [concurrentThreads]); + // Reactive map of per-thread pending approvals. Built by scanning every + // instance in instancesRef — the only place background threads are visible. + const [pendingApprovalsByThread, setPendingApprovalsByThread] = useState< + ReadonlyMap + >(() => new Map()); + + const recomputePendingApprovalsByThread = useCallback(() => { + if (!concurrentThreads) return; + const next = new Map(); + for (const [key, inst] of instancesRef.current) { + if (key === SINGLE_INSTANCE_KEY) continue; + if (key.startsWith("__pending_")) continue; + const pending = inst.toolExecutions.filter( + (e) => e.approvalStatus === "required", + ); + if (pending.length > 0) next.set(key, pending); + } + setPendingApprovalsByThread((prev) => { + if (prev.size !== next.size) return next; + for (const [id, execs] of next) { + const prevExecs = prev.get(id); + if (!prevExecs || prevExecs.length !== execs.length) return next; + for (let i = 0; i < execs.length; i++) { + if (prevExecs[i] !== execs[i]) return next; + } + } + return prev; + }); + }, [concurrentThreads]); + const notifyStateChange = useCallback(() => { for (const cb of subscribersRef.current) cb(); recomputeBusyThreadIds(); - }, [recomputeBusyThreadIds]); + recomputePendingApprovalsByThread(); + }, [recomputeBusyThreadIds, recomputePendingApprovalsByThread]); // Keep latest prop/callback values in refs so the imperative factory doesn't // need a useCallback dep list the size of the universe. @@ -875,8 +916,14 @@ export function CopilotProvider(props: CopilotProviderProps) { // so future requests hit the server's session; we just don't propagate // to React state or the registry key. recomputeBusyThreadIds(); + recomputePendingApprovalsByThread(); }, - [concurrentThreads, debugLog, recomputeBusyThreadIds], + [ + concurrentThreads, + debugLog, + recomputeBusyThreadIds, + recomputePendingApprovalsByThread, + ], ); // Create a new chat instance and register it under `key`. Wires all @@ -939,9 +986,15 @@ export function CopilotProvider(props: CopilotProviderProps) { setToolExecutions(executions); setAgentIteration(inst.iteration ?? 0); } + // Per-thread pending-approvals must refresh for ANY instance — + // background threads entering approval-required won't be seen + // otherwise, since the reactState `subscribe` channel only fires + // on message changes. + recomputePendingApprovalsByThread(); }, onApprovalRequired: (execution) => { debugLog("Tool approval required:", execution.name); + recomputePendingApprovalsByThread(); }, onContextUsageChange: (usage) => { if (inst === chatRef.current) { @@ -991,7 +1044,12 @@ export function CopilotProvider(props: CopilotProviderProps) { instancesRef.current.set(key, inst); return inst; }, - [debugLog, handleInstanceThreadAssigned, notifyStateChange], + [ + debugLog, + handleInstanceThreadAssigned, + notifyStateChange, + recomputePendingApprovalsByThread, + ], ); // Initialize the first instance on first render. If disposed (React @@ -1260,9 +1318,15 @@ export function CopilotProvider(props: CopilotProviderProps) { switchActiveInstance(null); } else { recomputeBusyThreadIds(); + recomputePendingApprovalsByThread(); } }, - [concurrentThreads, switchActiveInstance, recomputeBusyThreadIds], + [ + concurrentThreads, + switchActiveInstance, + recomputeBusyThreadIds, + recomputePendingApprovalsByThread, + ], ); // Re-key the active pending instance to a caller-supplied local thread id @@ -1306,9 +1370,15 @@ export function CopilotProvider(props: CopilotProviderProps) { callbacksRef.current.onThreadChange?.(localId); } recomputeBusyThreadIds(); + recomputePendingApprovalsByThread(); debugLog("Assigned local thread id", { localId }); }, - [concurrentThreads, debugLog, recomputeBusyThreadIds], + [ + concurrentThreads, + debugLog, + recomputeBusyThreadIds, + recomputePendingApprovalsByThread, + ], ); const renewSession = useCallback(() => { @@ -1660,6 +1730,7 @@ export function CopilotProvider(props: CopilotProviderProps) { // Multi-thread streaming concurrentThreads, busyThreadIds, + pendingApprovalsByThread, disposeThreadInstance, assignLocalThreadId, }), @@ -1704,6 +1775,7 @@ export function CopilotProvider(props: CopilotProviderProps) { toolsConfig, concurrentThreads, busyThreadIds, + pendingApprovalsByThread, disposeThreadInstance, assignLocalThreadId, ],