onUpdate("yourgptAuthEnabled", v)}
+ />
+ {alphaConfig.yourgptAuthEnabled && (
+
+
API Key
+
onUpdate("yourgptApiKey", e.target.value)}
+ placeholder="ygpt_..."
+ className="h-7 text-xs"
+ />
+
Widget UID
+
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({
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..501e863 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,49 @@ 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;
+
+ /**
+ * 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
+ * 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 +639,7 @@ export function CopilotProvider(props: CopilotProviderProps) {
optimization,
messageHistory,
skills,
+ concurrentThreads = false,
} = props;
const isThreadIdControlled = Object.prototype.hasOwnProperty.call(
props,
@@ -636,114 +705,514 @@ 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]);
+
+ // 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();
+ 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.
+ 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();
+ recomputePendingApprovalsByThread();
+ },
+ [
+ concurrentThreads,
+ debugLog,
+ recomputeBusyThreadIds,
+ recomputePendingApprovalsByThread,
+ ],
+ );
+
+ // 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);
}
- }
+ // 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) {
+ 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,
+ recomputePendingApprovalsByThread,
+ ],
+ );
+
+ // 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 +1234,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 +1261,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 +1289,97 @@ 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();
+ recomputePendingApprovalsByThread();
+ }
+ },
+ [
+ concurrentThreads,
+ switchActiveInstance,
+ recomputeBusyThreadIds,
+ recomputePendingApprovalsByThread,
+ ],
+ );
+
+ // 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();
+ recomputePendingApprovalsByThread();
+ debugLog("Assigned local thread id", { localId });
+ },
+ [
+ concurrentThreads,
+ debugLog,
+ recomputeBusyThreadIds,
+ recomputePendingApprovalsByThread,
+ ],
+ );
const renewSession = useCallback(() => {
chatRef.current?.renewSession();
@@ -821,11 +1388,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 +1476,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 +1498,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 +1511,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 +1526,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 +1543,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 +1608,7 @@ export function CopilotProvider(props: CopilotProviderProps) {
[],
);
const hasBranches = useSyncExternalStore(
- chatRef.current.subscribe,
+ stableSubscribe,
getHasBranchesSnapshot,
() => false,
);
@@ -1043,10 +1651,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 +1726,13 @@ export function CopilotProvider(props: CopilotProviderProps) {
// Headless primitives
subscribeToStreamEvents,
messageMeta: messageMetaStoreRef.current,
+
+ // Multi-thread streaming
+ concurrentThreads,
+ busyThreadIds,
+ pendingApprovalsByThread,
+ disposeThreadInstance,
+ assignLocalThreadId,
}),
[
messages,
@@ -1155,6 +1773,11 @@ export function CopilotProvider(props: CopilotProviderProps) {
sessionStatus,
runtimeUrl,
toolsConfig,
+ concurrentThreads,
+ busyThreadIds,
+ pendingApprovalsByThread,
+ 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..bf21d78 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,16 +510,28 @@ export function useInternalThreadManager(
requestAnimationFrame(() => {
isLoadingRef.current = false;
});
- }, [clearCurrentThread, setMessages, setActiveThread, onThreadChange]);
+ }, [
+ clearCurrentThread,
+ setMessages,
+ setActiveThread,
+ onThreadChange,
+ concurrentThreads,
+ ]);
// ── Return ─────────────────────────────────────────────────────────────
- const isBusy = isLoading || status === "streaming" || status === "submitted";
+ // In concurrent-threads mode, the user must be able to switch away from a
+ // streaming thread — per-thread busy state is surfaced via `busyThreadIds`
+ // instead. In single-thread mode, streaming locks picker & new-chat button.
+ const isBusy =
+ !concurrentThreads &&
+ (isLoading || status === "streaming" || status === "submitted");
return {
threadManager,
handleSwitchThread,
handleNewThread,
isBusy,
+ busyThreadIds,
};
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 12761d4..7d403eb 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -906,11 +906,11 @@ importers:
specifier: ^0.5.19
version: 0.5.19(tailwindcss@4.1.18)
'@yourgpt/copilot-sdk':
- specifier: ^2.1.8
- version: 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)
+ specifier: workspace:*
+ version: link:../../packages/copilot-sdk
'@yourgpt/llm-sdk':
- specifier: ^2.1.8
- version: 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))
+ specifier: workspace:*
+ version: link:../../packages/llm-sdk
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@@ -1441,40 +1441,6 @@ importers:
specifier: ^5
version: 5.9.3
- examples/yourgpt-server-demo:
- dependencies:
- '@yourgpt/llm-sdk':
- specifier: workspace:*
- version: link:../../packages/llm-sdk
- cors:
- specifier: ^2.8.5
- version: 2.8.6
- dotenv:
- specifier: ^16.4.0
- version: 16.6.1
- express:
- specifier: ^4.21.0
- version: 4.22.1
- ws:
- specifier: ^8.18.0
- version: 8.18.0
- devDependencies:
- '@types/cors':
- specifier: ^2.8.17
- version: 2.8.19
- '@types/express':
- specifier: ^5.0.0
- version: 5.0.6
- '@types/ws':
- specifier: ^8.5.13
- version: 8.18.1
- tsx:
- specifier: ^4.19.0
- version: 4.21.0
- typescript:
- specifier: ^5.6.0
- version: 5.9.3
-
packages/copilot-sdk:
dependencies:
'@base-ui/react':
@@ -4500,9 +4466,6 @@ packages:
'@types/validate-npm-package-name@4.0.2':
resolution: {integrity: sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==}
- '@types/ws@8.18.1':
- resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
-
'@typescript-eslint/eslint-plugin@8.50.0':
resolution: {integrity: sha512-O7QnmOXYKVtPrfYzMolrCTfkezCJS9+ljLdKW/+DCvRsc3UAz+sbH6Xcsv7p30+0OwUbeWfUDAQE0vpabZ3QLg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=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