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({

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