From 7a2240e52ad150b4660f2ab3d6e26b496bc32580 Mon Sep 17 00:00:00 2001 From: ankushchhabradelta4infotech-ai Date: Fri, 20 Mar 2026 19:38:05 +0530 Subject: [PATCH 01/12] fix --- packages/copilot-sdk/package.json | 1 + .../src/chat/classes/AbstractChat.ts | 142 +++++++----- .../src/ui/components/ui/markdown.tsx | 3 +- pnpm-lock.yaml | 219 +++++++++++++----- 4 files changed, 249 insertions(+), 116 deletions(-) diff --git a/packages/copilot-sdk/package.json b/packages/copilot-sdk/package.json index 3ece582..6320a8c 100644 --- a/packages/copilot-sdk/package.json +++ b/packages/copilot-sdk/package.json @@ -103,6 +103,7 @@ "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tooltip": "^1.2.8", "@streamdown/code": "^1.0.1", + "@streamdown/math": "^1.0.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.0", "html-to-image": "^1.11.13", diff --git a/packages/copilot-sdk/src/chat/classes/AbstractChat.ts b/packages/copilot-sdk/src/chat/classes/AbstractChat.ts index 43b3d1e..a013879 100644 --- a/packages/copilot-sdk/src/chat/classes/AbstractChat.ts +++ b/packages/copilot-sdk/src/chat/classes/AbstractChat.ts @@ -799,11 +799,25 @@ export class AbstractChat { if (existing) { assistantMessage = existing; } else { - assistantMessage = createEmptyAssistantMessage() as T; + const visibleMessages = this.state.messages; + const currentLeafId = + visibleMessages.length > 0 + ? visibleMessages[visibleMessages.length - 1].id + : undefined; + assistantMessage = createEmptyAssistantMessage(undefined, { + parentId: currentLeafId, + }) as T; this.state.pushMessage(assistantMessage); } } else { - assistantMessage = createEmptyAssistantMessage() as T; + const visibleMessages = this.state.messages; + const currentLeafId = + visibleMessages.length > 0 + ? visibleMessages[visibleMessages.length - 1].id + : undefined; + assistantMessage = createEmptyAssistantMessage(undefined, { + parentId: currentLeafId, + }) as T; this.state.pushMessage(assistantMessage); } @@ -836,61 +850,47 @@ export class AbstractChat { return; } - // Handle message:end mid-stream (server-side agent loop turn completed) - // This creates separate messages for each turn instead of combining them - if (chunk.type === "message:end" && this.streamState?.content) { - this.debug("message:end mid-stream", { + // Handle message:end mid-stream (server-side agent loop turn completed). + // Do NOT create a separate message for each turn — keep accumulating into + // the same message so the user sees one assistant bubble, not three. + // Just skip message:end entirely and let content continue flowing. + if (chunk.type === "message:end" && this.streamState) { + this.debug("message:end mid-stream (keeping streamState alive)", { messageId: this.streamState.messageId, contentLength: this.streamState.content.length, toolCallsInState: this.streamState.toolCalls?.length ?? 0, chunkCount, }); + // Don't reset streamState — next message:start will be ignored and + // subsequent deltas will append to the same message. + continue; + } - // Finalize current message with its content and tool calls - const turnMessage = streamStateToMessage(this.streamState) as T; - - // Add toolCallsHidden metadata if applicable - const toolCallsHidden: Record = {}; - for (const [id, result] of this.streamState.toolResults) { - if (result.hidden !== undefined) { - toolCallsHidden[id] = result.hidden; - } - } - if ( - turnMessage.toolCalls?.length && - Object.keys(toolCallsHidden).length > 0 - ) { - (turnMessage as T & { metadata?: Record }).metadata = - { - ...(turnMessage as T & { metadata?: Record }) - .metadata, - toolCallsHidden, - }; - } - - this.state.updateMessageById( - this.streamState.messageId, - (existing) => ({ - ...turnMessage, - ...(existing.parentId !== undefined - ? { parentId: existing.parentId } - : {}), - ...(existing.childrenIds !== undefined - ? { childrenIds: existing.childrenIds } - : {}), - }), + // Handle message:start after a mid-stream message:end. + // Since we keep streamState alive above, this only fires if streamState + // was null for another reason. Just skip it — deltas will flow into + // the existing streamState. + if (chunk.type === "message:start" && this.streamState !== null) { + this.debug( + "message:start mid-stream (streamState already active, skipping)", ); - this.callbacks.onMessageFinish?.(turnMessage); - - // Reset stream state for next turn - will be initialized on next message:start - this.streamState = null; continue; } - // Handle message:start after a mid-stream finalization + // Handle message:start when streamState is null (shouldn't happen in + // normal flow, but handle gracefully by creating a new message). if (chunk.type === "message:start" && this.streamState === null) { - this.debug("message:start after mid-stream end - creating new message"); - const newMessage = createEmptyAssistantMessage() as T; + this.debug( + "message:start with null streamState - creating new message", + ); + const visibleMessages = this.state.messages; + const currentLeafId = + visibleMessages.length > 0 + ? visibleMessages[visibleMessages.length - 1].id + : undefined; + const newMessage = createEmptyAssistantMessage(undefined, { + parentId: currentLeafId, + }) as T; this.state.pushMessage(newMessage); this.streamState = createStreamState(newMessage.id); this.callbacks.onMessageStart?.(newMessage.id); @@ -936,6 +936,14 @@ export class AbstractChat { const messagesToInsert: T[] = []; let clientAssistantToolCalls: unknown[] | undefined; + // Track parent chain for inserted messages so they don't become + // orphan root children in the MessageTree. + const lastVisibleMsgs = this.state.messages; + let postEndInsertParentId: string | undefined = + lastVisibleMsgs.length > 0 + ? lastVisibleMsgs[lastVisibleMsgs.length - 1].id + : undefined; + for (const msg of chunk.messages) { // This is the client-tool assistant message already in state // (finalized by message:end but without toolCalls). @@ -954,14 +962,19 @@ export class AbstractChat { // Skip plain assistant text — already streamed if (msg.role === "assistant" && !msg.tool_calls?.length) continue; // Everything else (server tool results) needs inserting - messagesToInsert.push({ + const insertedMsg = { id: generateMessageId(), role: msg.role as T["role"], content: msg.content ?? "", toolCalls: msg.tool_calls as T["toolCalls"], toolCallId: msg.tool_call_id, createdAt: new Date(), - } as T); + ...(postEndInsertParentId + ? { parentId: postEndInsertParentId } + : {}), + } as T; + postEndInsertParentId = insertedMsg.id; + messagesToInsert.push(insertedMsg); } // Merge OpenAI-format tool_calls into the existing last assistant message @@ -983,8 +996,9 @@ export class AbstractChat { } if (messagesToInsert.length > 0) { - // Insert server tool results before the last assistant message - const currentMessages = this.state.messages; + // Insert server tool results before the last assistant message. + // Use _allMessages() to preserve inactive branch messages. + const currentMessages = this._allMessages(); let insertIdx = currentMessages.length; for (let i = currentMessages.length - 1; i >= 0; i--) { if (currentMessages[i].role === "assistant") { @@ -1106,11 +1120,25 @@ export class AbstractChat { ), }); - const currentStreamToolCallIds = new Set( - this.streamState?.toolCalls?.map((toolCall) => toolCall.id) ?? [], - ); + const currentStreamToolCallIds = new Set([ + ...(this.streamState?.toolCalls?.map((toolCall) => toolCall.id) ?? + []), + // Also include IDs from toolResults (populated by action:start/args/end + // chunks for server-side tools). Without this, assistant messages with + // tool_calls from done.messages are treated as "new" and inserted as + // duplicates even though the tools were already executed in-stream. + ...(this.streamState?.toolResults + ? Array.from(this.streamState.toolResults.keys()) + : []), + ]); const messagesToInsert: T[] = []; + // Track parent chain for inserted messages so they don't become + // orphan root children in the MessageTree (which would redirect + // the active path and blank the UI). + let insertChainParentId: string | undefined = + this.streamState?.messageId; + // Build hidden map from stream state's toolResults const toolCallsHidden: Record = {}; if (this.streamState?.toolResults) { @@ -1163,13 +1191,19 @@ export class AbstractChat { toolCallId: msg.tool_call_id, createdAt: new Date(), metadata, + ...(insertChainParentId ? { parentId: insertChainParentId } : {}), } as T; + insertChainParentId = message.id; messagesToInsert.push(message); } if (messagesToInsert.length > 0) { - const currentMessages = this.state.messages; + // Use _allMessages() to preserve inactive branch messages. + // this.state.messages only returns the visible path; calling + // setMessages() with just that would destroy all other branches + // when tree.reset() rebuilds. + const currentMessages = this._allMessages(); const currentStreamIndex = this.streamState ? currentMessages.findIndex( (message) => message.id === this.streamState!.messageId, diff --git a/packages/copilot-sdk/src/ui/components/ui/markdown.tsx b/packages/copilot-sdk/src/ui/components/ui/markdown.tsx index b640a54..b586e39 100644 --- a/packages/copilot-sdk/src/ui/components/ui/markdown.tsx +++ b/packages/copilot-sdk/src/ui/components/ui/markdown.tsx @@ -1,6 +1,7 @@ import { memo, ComponentProps } from "react"; import { Streamdown, LinkSafetyConfig } from "streamdown"; import { code } from "@streamdown/code"; +import { math } from "@streamdown/math"; export type MarkdownProps = { children: string; @@ -45,7 +46,7 @@ function MarkdownComponent({ return (
= 6'} + commander@8.3.0: + resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} + engines: {node: '>= 12'} + compute-scroll-into-view@3.1.1: resolution: {integrity: sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==} @@ -5504,9 +5491,21 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + hast-util-from-dom@5.0.1: + resolution: {integrity: sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q==} + + hast-util-from-html-isomorphic@2.0.0: + resolution: {integrity: sha512-zJfpXq44yff2hmE0XmwEOzdWin5xwH+QIhMLOScpX91e/NSGPsAzNCvLQDIEPyO2TXi+lBmU6hjLIhV8MwP2kw==} + + hast-util-from-html@2.0.3: + resolution: {integrity: sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==} + hast-util-from-parse5@8.0.3: resolution: {integrity: sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==} + hast-util-is-element@3.0.0: + resolution: {integrity: sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==} + hast-util-parse-selector@4.0.0: resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==} @@ -5531,6 +5530,9 @@ packages: hast-util-to-string@3.0.1: resolution: {integrity: sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A==} + hast-util-to-text@4.0.2: + resolution: {integrity: sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==} + hast-util-whitespace@3.0.0: resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} @@ -5916,6 +5918,10 @@ packages: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} + katex@0.16.39: + resolution: {integrity: sha512-FR2f6y85+81ZLO0GPhyQ+EJl/E5ILNWltJhpAeOTzRny952Z13x2867lTFDmvMZix//Ux3CuMQ2VkLXRbUwOFg==} + hasBin: true + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -6194,6 +6200,9 @@ packages: mdast-util-gfm@3.1.0: resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==} + mdast-util-math@3.0.0: + resolution: {integrity: sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w==} + mdast-util-mdx-expression@2.0.1: resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==} @@ -6268,6 +6277,9 @@ packages: micromark-extension-gfm@3.0.0: resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==} + micromark-extension-math@3.1.0: + resolution: {integrity: sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==} + micromark-extension-mdx-expression@3.0.1: resolution: {integrity: sha512-dD/ADLJ1AeMvSAKBwO22zG22N4ybhe7kFIZ3LsDI0GlsNr2A3KYxb0LdC1u5rj4Nw+CHKY0RVdnHX8vj8ejm4Q==} @@ -7064,6 +7076,9 @@ packages: rehype-harden@1.1.7: resolution: {integrity: sha512-j5DY0YSK2YavvNGV+qBHma15J9m0WZmRe8posT5AtKDS6TNWtMVTo6RiqF8SidfcASYz8f3k2J/1RWmq5zTXUw==} + rehype-katex@7.0.1: + resolution: {integrity: sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA==} + rehype-raw@7.0.0: resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==} @@ -7076,6 +7091,9 @@ packages: remark-gfm@4.0.1: resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} + remark-math@6.0.0: + resolution: {integrity: sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA==} + remark-mdx@3.1.1: resolution: {integrity: sha512-Pjj2IYlUY3+D8x00UJsIOg5BEvfMyeI+2uLPn9VO9Wg4MEtN/VTIq2NEJQfde9PnX15KgtHyl9S0BcTnWrIuWg==} @@ -7711,6 +7729,9 @@ packages: unified@11.0.5: resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} + unist-util-find-after@5.0.0: + resolution: {integrity: sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==} + unist-util-is@6.0.1: resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} @@ -10909,6 +10930,15 @@ snapshots: react: 18.3.1 shiki: 3.20.0 + '@streamdown/math@1.0.2(react@18.3.1)': + dependencies: + katex: 0.16.39 + react: 18.3.1 + rehype-katex: 7.0.1 + remark-math: 6.0.0 + transitivePeerDependencies: + - supports-color + '@swc/helpers@0.5.15': dependencies: tslib: 2.8.1 @@ -11170,6 +11200,8 @@ snapshots: dependencies: '@types/node': 20.19.27 + '@types/katex@0.16.8': {} + '@types/mdast@4.0.4': dependencies: '@types/unist': 3.0.3 @@ -11772,6 +11804,8 @@ snapshots: commander@4.1.1: {} + commander@8.3.0: {} + compute-scroll-into-view@3.1.1: {} concat-map@0.0.1: {} @@ -12240,13 +12274,13 @@ snapshots: escape-string-regexp@5.0.0: {} - eslint-config-next@16.0.10(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3): + eslint-config-next@16.0.10(@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: '@next/eslint-plugin-next': 16.0.10 eslint: 9.39.2(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) - eslint-plugin-import: 2.32.0(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.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)))(eslint@9.39.2(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-react-hooks: 7.0.1(eslint@9.39.2(jiti@2.6.1)) @@ -12280,32 +12314,12 @@ snapshots: - eslint-plugin-import-x - supports-color - eslint-config-next@16.1.5(@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: - '@next/eslint-plugin-next': 16.1.5 - eslint: 9.39.2(jiti@2.6.1) - eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)) - eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.2(jiti@2.6.1)) - eslint-plugin-react: 7.37.5(eslint@9.39.2(jiti@2.6.1)) - eslint-plugin-react-hooks: 7.0.1(eslint@9.39.2(jiti@2.6.1)) - globals: 16.4.0 - typescript-eslint: 8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - optionalDependencies: - typescript: 5.9.3 - transitivePeerDependencies: - - '@typescript-eslint/parser' - - eslint-import-resolver-webpack - - eslint-plugin-import-x - - supports-color - eslint-config-next@16.1.5(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3): dependencies: '@next/eslint-plugin-next': 16.1.5 eslint: 9.39.2(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-import: 2.32.0(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.39.2(jiti@2.6.1)) @@ -12328,7 +12342,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.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)))(eslint@9.39.2(jiti@2.6.1)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3 @@ -12339,11 +12353,11 @@ snapshots: tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)) transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3 @@ -12354,18 +12368,18 @@ snapshots: tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.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)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)): dependencies: debug: 3.2.7 optionalDependencies: '@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) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.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)))(eslint@9.39.2(jiti@2.6.1)) transitivePeerDependencies: - supports-color @@ -12390,7 +12404,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.2(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.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)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -13087,6 +13101,28 @@ snapshots: dependencies: function-bind: 1.1.2 + hast-util-from-dom@5.0.1: + dependencies: + '@types/hast': 3.0.4 + hastscript: 9.0.1 + web-namespaces: 2.0.1 + + hast-util-from-html-isomorphic@2.0.0: + dependencies: + '@types/hast': 3.0.4 + hast-util-from-dom: 5.0.1 + hast-util-from-html: 2.0.3 + unist-util-remove-position: 5.0.0 + + hast-util-from-html@2.0.3: + dependencies: + '@types/hast': 3.0.4 + devlop: 1.1.0 + hast-util-from-parse5: 8.0.3 + parse5: 7.3.0 + vfile: 6.0.3 + vfile-message: 4.0.3 + hast-util-from-parse5@8.0.3: dependencies: '@types/hast': 3.0.4 @@ -13098,6 +13134,10 @@ snapshots: vfile-location: 5.0.3 web-namespaces: 2.0.1 + hast-util-is-element@3.0.0: + dependencies: + '@types/hast': 3.0.4 + hast-util-parse-selector@4.0.0: dependencies: '@types/hast': 3.0.4 @@ -13193,6 +13233,13 @@ snapshots: dependencies: '@types/hast': 3.0.4 + hast-util-to-text@4.0.2: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + hast-util-is-element: 3.0.0 + unist-util-find-after: 5.0.0 + hast-util-whitespace@3.0.0: dependencies: '@types/hast': 3.0.4 @@ -13537,6 +13584,10 @@ snapshots: object.assign: 4.1.7 object.values: 1.2.1 + katex@0.16.39: + dependencies: + commander: 8.3.0 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -13829,6 +13880,18 @@ snapshots: transitivePeerDependencies: - supports-color + mdast-util-math@3.0.0: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + longest-streak: 3.1.0 + mdast-util-from-markdown: 2.0.2 + mdast-util-to-markdown: 2.1.2 + unist-util-remove-position: 5.0.0 + transitivePeerDependencies: + - supports-color + mdast-util-mdx-expression@2.0.1: dependencies: '@types/estree-jsx': 1.0.5 @@ -14002,6 +14065,16 @@ snapshots: micromark-util-combine-extensions: 2.0.1 micromark-util-types: 2.0.2 + micromark-extension-math@3.1.0: + dependencies: + '@types/katex': 0.16.8 + devlop: 1.1.0 + katex: 0.16.39 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + micromark-extension-mdx-expression@3.0.1: dependencies: '@types/estree': 1.0.8 @@ -15014,6 +15087,16 @@ snapshots: dependencies: unist-util-visit: 5.0.0 + rehype-katex@7.0.1: + dependencies: + '@types/hast': 3.0.4 + '@types/katex': 0.16.8 + hast-util-from-html-isomorphic: 2.0.0 + hast-util-to-text: 4.0.2 + katex: 0.16.39 + unist-util-visit-parents: 6.0.2 + vfile: 6.0.3 + rehype-raw@7.0.0: dependencies: '@types/hast': 3.0.4 @@ -15044,6 +15127,15 @@ snapshots: transitivePeerDependencies: - supports-color + remark-math@6.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-math: 3.0.0 + micromark-extension-math: 3.1.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + remark-mdx@3.1.1: dependencies: mdast-util-mdx: 3.0.0 @@ -15927,6 +16019,11 @@ snapshots: trough: 2.2.0 vfile: 6.0.3 + unist-util-find-after@5.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-is@6.0.1: dependencies: '@types/unist': 3.0.3 From 25cee8928e72c5da90e0f4777ae20a9625c3e99d Mon Sep 17 00:00:00 2001 From: ankushchhabradelta4infotech-ai Date: Wed, 25 Mar 2026 19:40:35 +0530 Subject: [PATCH 02/12] fix: continue with tool execution --- packages/copilot-sdk/src/chat/AbstractAgentLoop.ts | 4 +++- packages/copilot-sdk/src/chat/ChatWithTools.ts | 6 ++++++ packages/copilot-sdk/src/chat/classes/AbstractChat.ts | 9 +++++++++ packages/copilot-sdk/src/react/hooks/useTool.ts | 9 ++++++--- 4 files changed, 24 insertions(+), 4 deletions(-) diff --git a/packages/copilot-sdk/src/chat/AbstractAgentLoop.ts b/packages/copilot-sdk/src/chat/AbstractAgentLoop.ts index c621214..e66170b 100644 --- a/packages/copilot-sdk/src/chat/AbstractAgentLoop.ts +++ b/packages/copilot-sdk/src/chat/AbstractAgentLoop.ts @@ -272,8 +272,10 @@ export class AbstractAgentLoop implements AgentLoopActions { } // Create new abort controller for this batch + // Do NOT reset _isCancelled here — if stop() was called between the + // iteration check above and this line, we must not wipe that signal. + // _isCancelled is only reset in resetIterations() (called by sendMessage). this.abortController = new AbortController(); - this._isCancelled = false; this._isProcessing = true; this.setIteration(this._iteration + 1); diff --git a/packages/copilot-sdk/src/chat/ChatWithTools.ts b/packages/copilot-sdk/src/chat/ChatWithTools.ts index a858854..8f807bb 100644 --- a/packages/copilot-sdk/src/chat/ChatWithTools.ts +++ b/packages/copilot-sdk/src/chat/ChatWithTools.ts @@ -265,6 +265,12 @@ export class ChatWithTools { const results = await this.agentLoop.executeToolCalls(toolCallInfos); this.debug("Tool results:", results); + // If stop() was called while tools were executing, don't restart the loop + if (this.agentLoop.isCancelled) { + this.debug("Skipping continueWithToolResults — loop was cancelled"); + return; + } + // Continue chat with tool results if (results.length > 0) { const toolResults = results.map((r) => ({ diff --git a/packages/copilot-sdk/src/chat/classes/AbstractChat.ts b/packages/copilot-sdk/src/chat/classes/AbstractChat.ts index e3e6943..90c3bbd 100644 --- a/packages/copilot-sdk/src/chat/classes/AbstractChat.ts +++ b/packages/copilot-sdk/src/chat/classes/AbstractChat.ts @@ -428,6 +428,15 @@ export class AbstractChat { // is not enough for React 18 to render the loading state. await new Promise((resolve) => setTimeout(resolve, 0)); + // If stop() was called during the macrotask yield, status will have been + // reset to "ready" — don't restart the loop in that case. + if (this.status === "ready" || this.status === "error") { + this.debug( + "Skipping processRequest — status reset during yield (stop was called)", + ); + return; + } + // Continue request await this.processRequest(); } catch (error) { diff --git a/packages/copilot-sdk/src/react/hooks/useTool.ts b/packages/copilot-sdk/src/react/hooks/useTool.ts index f8c1c88..4ff1a96 100644 --- a/packages/copilot-sdk/src/react/hooks/useTool.ts +++ b/packages/copilot-sdk/src/react/hooks/useTool.ts @@ -240,8 +240,11 @@ export function useTools(tools: ToolSet): void { // Update ref when tools change toolsRef.current = tools; - // Create a stable key from tool names to detect actual changes - const toolsKey = Object.keys(tools).sort().join(","); + // Create a stable key from tool names + availability flags to detect actual changes + const toolsKey = Object.entries(tools) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([name, def]) => `${name}:${def.available ?? true}`) + .join(","); useEffect(() => { const currentTools = toolsRef.current; @@ -269,7 +272,7 @@ export function useTools(tools: ToolSet): void { registeredToolsRef.current = []; }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [toolsKey]); // Only re-run when tool names change, not on every render + }, [toolsKey]); // Re-run when tool names or availability flags change } /** From a2f5667b15482a681c3a317be3272ca10a5dbd2f Mon Sep 17 00:00:00 2001 From: Sahil Date: Sun, 29 Mar 2026 19:58:06 +0530 Subject: [PATCH 03/12] feat(copilot-sdk): add streamMode prop for agent response bubble behavior MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 'multi-step' (default) — one bubble per server agent iteration (OpenAI/LiteLLM style), default unchanged - 'single-turn' — all iterations collapsed into one bubble per user turn (Vercel AI SDK / Claude.ai style) Usage: Co-Authored-By: Claude Sonnet 4.6 --- .../copilot-sdk/src/chat/ChatWithTools.ts | 7 + .../src/chat/classes/AbstractChat.ts | 131 ++++++++++++------ packages/copilot-sdk/src/chat/types/chat.ts | 9 ++ .../src/react/provider/CopilotProvider.tsx | 11 ++ 4 files changed, 114 insertions(+), 44 deletions(-) diff --git a/packages/copilot-sdk/src/chat/ChatWithTools.ts b/packages/copilot-sdk/src/chat/ChatWithTools.ts index 66a7126..0b428ca 100644 --- a/packages/copilot-sdk/src/chat/ChatWithTools.ts +++ b/packages/copilot-sdk/src/chat/ChatWithTools.ts @@ -57,6 +57,12 @@ export interface ChatWithToolsConfig { yourgptConfig?: YourGPTConfig; /** Enable debug logging */ debug?: boolean; + /** + * Controls how multi-turn agent responses appear in the UI. + * - `'multi-step'` (default) — one bubble per server agent iteration. + * - `'single-turn'` — all iterations collapsed into one bubble per user turn. + */ + streamMode?: "multi-step" | "single-turn"; /** Initial messages */ initialMessages?: UIMessage[]; /** Initial tools to register */ @@ -152,6 +158,7 @@ export class ChatWithTools { onCreateSession: config.onCreateSession, yourgptConfig: config.yourgptConfig, debug: config.debug, + streamMode: config.streamMode, initialMessages: config.initialMessages, state: config.state, transport: config.transport, diff --git a/packages/copilot-sdk/src/chat/classes/AbstractChat.ts b/packages/copilot-sdk/src/chat/classes/AbstractChat.ts index 3fa471c..f2b0359 100644 --- a/packages/copilot-sdk/src/chat/classes/AbstractChat.ts +++ b/packages/copilot-sdk/src/chat/classes/AbstractChat.ts @@ -1107,58 +1107,93 @@ export class AbstractChat { return; } - // Handle message:end mid-stream (server-side agent loop turn completed) - // This creates separate messages for each turn instead of combining them - if (chunk.type === "message:end" && this.streamState?.content) { - this.debug("message:end mid-stream", { - messageId: this.streamState.messageId, - contentLength: this.streamState.content.length, - toolCallsInState: this.streamState.toolCalls?.length ?? 0, - chunkCount, - }); + // Handle message:end mid-stream (server-side agent loop turn completed). + // Behaviour depends on streamMode: + // 'multi-step' (default) — finalize a new UIMessage per iteration. + // 'single-turn' — skip entirely; keep accumulating into the + // same streamState so all iterations collapse + // into one bubble (Vercel AI SDK / Claude.ai style). + if (chunk.type === "message:end" && this.streamState) { + if (this.config.streamMode === "single-turn") { + this.debug( + "message:end mid-stream (single-turn: keeping streamState alive)", + { + messageId: this.streamState.messageId, + contentLength: this.streamState.content.length, + chunkCount, + }, + ); + continue; + } - // Finalize current message with its content and tool calls - const turnMessage = streamStateToMessage(this.streamState) as T; + // multi-step (default): finalize current turn as its own UIMessage + if (!this.streamState.content) { + // Nothing streamed yet for this turn — skip finalization + } else { + this.debug("message:end mid-stream", { + messageId: this.streamState.messageId, + contentLength: this.streamState.content.length, + toolCallsInState: this.streamState.toolCalls?.length ?? 0, + chunkCount, + }); - // Add toolCallsHidden metadata if applicable - const toolCallsHidden: Record = {}; - for (const [id, result] of this.streamState.toolResults) { - if (result.hidden !== undefined) { - toolCallsHidden[id] = result.hidden; + // Finalize current message with its content and tool calls + const turnMessage = streamStateToMessage(this.streamState) as T; + + // Add toolCallsHidden metadata if applicable + const toolCallsHidden: Record = {}; + for (const [id, result] of this.streamState.toolResults) { + if (result.hidden !== undefined) { + toolCallsHidden[id] = result.hidden; + } } - } - if ( - turnMessage.toolCalls?.length && - Object.keys(toolCallsHidden).length > 0 - ) { - (turnMessage as T & { metadata?: Record }).metadata = - { + if ( + turnMessage.toolCalls?.length && + Object.keys(toolCallsHidden).length > 0 + ) { + ( + turnMessage as T & { metadata?: Record } + ).metadata = { ...(turnMessage as T & { metadata?: Record }) .metadata, toolCallsHidden, }; - } + } - this.state.updateMessageById( - this.streamState.messageId, - (existing) => ({ - ...turnMessage, - ...(existing.parentId !== undefined - ? { parentId: existing.parentId } - : {}), - ...(existing.childrenIds !== undefined - ? { childrenIds: existing.childrenIds } - : {}), - }), - ); - this.callbacks.onMessageFinish?.(turnMessage); + this.state.updateMessageById( + this.streamState.messageId, + (existing) => ({ + ...turnMessage, + ...(existing.parentId !== undefined + ? { parentId: existing.parentId } + : {}), + ...(existing.childrenIds !== undefined + ? { childrenIds: existing.childrenIds } + : {}), + }), + ); + this.callbacks.onMessageFinish?.(turnMessage); - // Reset stream state for next turn - will be initialized on next message:start - this.streamState = null; - continue; + // Reset stream state — next message:start will create a new message + this.streamState = null; + continue; + } } - // Handle message:start after a mid-stream finalization + // Handle message:start mid-stream: + // single-turn — streamState is still alive, skip to keep accumulating. + // multi-step — streamState was reset to null above; fall through to + // the message:start === null handler below. + if (chunk.type === "message:start" && this.streamState !== null) { + if (this.config.streamMode === "single-turn") { + this.debug( + "message:start mid-stream (single-turn: streamState already active, skipping)", + ); + continue; + } + } + + // Handle message:start after a mid-stream finalization (multi-step mode) if (chunk.type === "message:start" && this.streamState === null) { this.debug("message:start after mid-stream end - creating new message"); // Capture the current leaf BEFORE pushing the new message so the @@ -1428,9 +1463,17 @@ export class AbstractChat { ), }); - const currentStreamToolCallIds = new Set( - this.streamState?.toolCalls?.map((toolCall) => toolCall.id) ?? [], - ); + // In single-turn mode all server-tool IDs land in streamState.toolResults + // (via action:start/args/end chunks). Include them so done.messages doesn't + // re-insert those tools as duplicates. + const currentStreamToolCallIds = new Set([ + ...(this.streamState?.toolCalls?.map((toolCall) => toolCall.id) ?? + []), + ...(this.config.streamMode === "single-turn" && + this.streamState?.toolResults + ? Array.from(this.streamState.toolResults.keys()) + : []), + ]); const messagesToInsert: T[] = []; // Build hidden map from stream state's toolResults diff --git a/packages/copilot-sdk/src/chat/types/chat.ts b/packages/copilot-sdk/src/chat/types/chat.ts index 898c40a..e282dfa 100644 --- a/packages/copilot-sdk/src/chat/types/chat.ts +++ b/packages/copilot-sdk/src/chat/types/chat.ts @@ -99,6 +99,15 @@ export interface ChatConfig { yourgptConfig?: YourGPTConfig; /** Enable debug logging */ debug?: boolean; + /** + * Controls how multi-turn agent responses appear in the UI. + * + * - `'multi-step'` (default) — each server agent iteration gets its own + * assistant bubble. Mirrors OpenAI / LiteLLM multi-turn structure. + * - `'single-turn'` — all iterations are accumulated into one bubble, + * finalized when the server sends `done`. Same as Vercel AI SDK / Claude.ai. + */ + streamMode?: "multi-step" | "single-turn"; /** Available tools (passed to LLM) */ tools?: ToolDefinition[]; /** Optional prompt/tool optimization controls */ diff --git a/packages/copilot-sdk/src/react/provider/CopilotProvider.tsx b/packages/copilot-sdk/src/react/provider/CopilotProvider.tsx index 2bc0dd3..a0e3144 100644 --- a/packages/copilot-sdk/src/react/provider/CopilotProvider.tsx +++ b/packages/copilot-sdk/src/react/provider/CopilotProvider.tsx @@ -320,6 +320,15 @@ export interface CopilotProviderProps { parseError?: (status: number, body: unknown) => string | null | undefined; /** Enable/disable streaming (default: true) */ streaming?: boolean; + /** + * Controls how multi-turn agent responses appear in the UI. + * + * - `'multi-step'` (default) — each server agent iteration gets its own + * assistant bubble. Mirrors OpenAI / LiteLLM multi-turn structure. + * - `'single-turn'` — all iterations are accumulated into one bubble, + * finalized when the server sends `done`. Same as Vercel AI SDK / Claude.ai. + */ + streamMode?: "multi-step" | "single-turn"; /** * Custom headers to send with each request * Can be static object or getter function for dynamic resolution. @@ -560,6 +569,7 @@ export function CopilotProvider(props: CopilotProviderProps) { onError, parseError, streaming, + streamMode, headers, body, debug = false, @@ -658,6 +668,7 @@ export function CopilotProvider(props: CopilotProviderProps) { yourgptConfig, initialMessages: uiInitialMessages, streaming, + streamMode, headers, body, parseError, From 8790f9af8f9dbbca1767488e7a9cd4a30480755b Mon Sep 17 00:00:00 2001 From: ankushchhabradelta4infotech-ai Date: Mon, 30 Mar 2026 18:41:52 +0530 Subject: [PATCH 04/12] single stream --- .../copilot-sdk/src/chat/classes/AbstractChat.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/copilot-sdk/src/chat/classes/AbstractChat.ts b/packages/copilot-sdk/src/chat/classes/AbstractChat.ts index f2b0359..2283574 100644 --- a/packages/copilot-sdk/src/chat/classes/AbstractChat.ts +++ b/packages/copilot-sdk/src/chat/classes/AbstractChat.ts @@ -118,6 +118,7 @@ export class AbstractChat { debug: init.debug, optimization: init.optimization, yourgptConfig: init.yourgptConfig, + streamMode: init.streamMode, }; // Use provided state or create default @@ -1495,6 +1496,18 @@ export class AbstractChat { continue; } + // single-turn: ALL assistant content (including intermediate tool-calling + // messages from earlier server iterations) is already accumulated into the + // one streaming message via message:delta. Inserting them from done.messages + // creates duplicate bubbles after streaming ends. Skip ALL assistant messages + // in single-turn mode — tool execution display is driven by streamState.toolResults. + if ( + this.config.streamMode === "single-turn" && + msg.role === "assistant" + ) { + continue; + } + // The current streamed turn already becomes an assistant message from // streamState/tool_calls handling. Skip the duplicate copy from the // done payload, but keep assistant tool_call messages from earlier From a59915dca056e17e42e17aab777b0d861ffbf9bc Mon Sep 17 00:00:00 2001 From: Sahil Date: Tue, 14 Apr 2026 14:33:36 +0530 Subject: [PATCH 05/12] feat(experimental): rewrite generative UI with text-streaming approach Replace the tool-call-based generative UI with a text-streaming approach (similar to Claude Artifacts pattern). The AI writes HTML wrapped in tags as part of its text response, which streams naturally and renders progressively in a sandboxed iframe. Key changes: SDK (copilot-sdk/experimental): - New useGenerativeUI() hook returns wrapMessage for CopilotChat - New generativeUISystemPrompt() helper for backend system prompts - New GenUIFrame component: iframe with postMessage, auto-height via ResizeObserver, copilot bridge API for interactivity - Simplified types: removed chart/table/stat/card types, HTML-only - Deleted CardRenderer, TableRenderer, StatRenderer SDK (core streaming improvements): - Progressive action:args emission in Anthropic/OpenAI adapters - tool-call-start/delta events in providers and stream-text - Runtime: proper action:args accumulation (don't null currentToolCall) - AbstractChat: handle action events when streamState is null - processChunk: parsePartialJson for streaming arg extraction - connected-chat: attach unmatched streaming executions to messages - ChatWithTools: allow action:start/args for client tools - AbstractAgentLoop: reuse streaming executions Example (generative-ui-demo): - Rewritten with sidebar, dicebear avatars, copilot logo - Uses generativeUISystemPrompt() + loadSkills() on backend - Skills system with frontend-design skill (eager) - SaaS dashboard copilot context (Acme Inc.) Docs: - Rewritten generative-ui.mdx with two approaches documented - Updated BETA-FEATURES.md Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/docs/alpha-docs/BETA-FEATURES.md | 4 +- apps/docs/content/docs/generative-ui.mdx | 475 ++++++++---------- .../app/api/chat/generative-ui/route.ts | 20 +- .../app/generative-ui/chart-renderer.tsx | 184 ------- .../experimental/app/generative-ui/page.tsx | 10 +- examples/generative-ui-demo/server/index.ts | 126 +++-- examples/generative-ui-demo/src/App.tsx | 455 ++++++++++------- .../generative-ui-demo/src/ChartRenderer.tsx | 176 ------- .../generative-ui-demo/src/icons/Add01.tsx | 36 ++ .../src/icons/ArrowLeft01.tsx | 35 ++ .../src/icons/ArrowRight01.tsx | 35 ++ .../src/icons/Attachment01.tsx | 35 ++ .../generative-ui-demo/src/icons/Book01.tsx | 37 ++ .../generative-ui-demo/src/icons/Chat01.tsx | 36 ++ .../generative-ui-demo/src/icons/Home01.tsx | 36 ++ .../generative-ui-demo/src/icons/Mic01.tsx | 37 ++ .../src/icons/MoreHorizontal.tsx | 35 ++ .../generative-ui-demo/src/icons/PlusSign.tsx | 35 ++ .../generative-ui-demo/src/icons/Search01.tsx | 36 ++ .../generative-ui-demo/src/icons/Sent.tsx | 36 ++ examples/generative-ui-demo/src/index.css | 97 ++++ examples/generative-ui-demo/vite.config.ts | 3 + .../copilot-sdk/src/chat/AbstractAgentLoop.ts | 41 +- .../copilot-sdk/src/chat/ChatWithTools.ts | 18 +- .../src/chat/classes/AbstractChat.ts | 63 ++- .../src/chat/functions/stream/processChunk.ts | 35 +- .../src/experimental/generativeUIPrompt.ts | 106 ++++ .../src/experimental/generativeUITool.ts | 127 +---- .../copilot-sdk/src/experimental/index.ts | 55 +- .../experimental/renderers/CardRenderer.tsx | 72 --- .../src/experimental/renderers/GenUIFrame.tsx | 193 +++++++ .../experimental/renderers/HtmlRenderer.tsx | 35 +- .../experimental/renderers/StatRenderer.tsx | 65 --- .../experimental/renderers/TableRenderer.tsx | 86 ---- .../copilot-sdk/src/experimental/types.ts | 183 ++----- .../src/experimental/useGenerativeUI.tsx | 421 +++++----------- .../ui/components/composed/connected-chat.tsx | 25 + packages/llm-sdk/src/adapters/anthropic.ts | 9 + packages/llm-sdk/src/adapters/openai.ts | 6 + packages/llm-sdk/src/core/stream-text.ts | 16 + packages/llm-sdk/src/core/types.ts | 15 + .../src/providers/anthropic/provider.ts | 10 + .../llm-sdk/src/providers/openai/provider.ts | 10 + packages/llm-sdk/src/server/runtime.ts | 63 ++- 44 files changed, 1911 insertions(+), 1722 deletions(-) delete mode 100644 examples/experimental/app/generative-ui/chart-renderer.tsx delete mode 100644 examples/generative-ui-demo/src/ChartRenderer.tsx create mode 100644 examples/generative-ui-demo/src/icons/Add01.tsx create mode 100644 examples/generative-ui-demo/src/icons/ArrowLeft01.tsx create mode 100644 examples/generative-ui-demo/src/icons/ArrowRight01.tsx create mode 100644 examples/generative-ui-demo/src/icons/Attachment01.tsx create mode 100644 examples/generative-ui-demo/src/icons/Book01.tsx create mode 100644 examples/generative-ui-demo/src/icons/Chat01.tsx create mode 100644 examples/generative-ui-demo/src/icons/Home01.tsx create mode 100644 examples/generative-ui-demo/src/icons/Mic01.tsx create mode 100644 examples/generative-ui-demo/src/icons/MoreHorizontal.tsx create mode 100644 examples/generative-ui-demo/src/icons/PlusSign.tsx create mode 100644 examples/generative-ui-demo/src/icons/Search01.tsx create mode 100644 examples/generative-ui-demo/src/icons/Sent.tsx create mode 100644 packages/copilot-sdk/src/experimental/generativeUIPrompt.ts delete mode 100644 packages/copilot-sdk/src/experimental/renderers/CardRenderer.tsx create mode 100644 packages/copilot-sdk/src/experimental/renderers/GenUIFrame.tsx delete mode 100644 packages/copilot-sdk/src/experimental/renderers/StatRenderer.tsx delete mode 100644 packages/copilot-sdk/src/experimental/renderers/TableRenderer.tsx diff --git a/apps/docs/alpha-docs/BETA-FEATURES.md b/apps/docs/alpha-docs/BETA-FEATURES.md index e39eb55..80d997f 100644 --- a/apps/docs/alpha-docs/BETA-FEATURES.md +++ b/apps/docs/alpha-docs/BETA-FEATURES.md @@ -189,9 +189,9 @@ Comprehensive reference for all `csdk-*` CSS classes applied to every chat UI el ### 17. Generative UI — Experimental -The AI can render structured UI components (cards, tables, charts, stat tiles) inline inside the chat, generated from tool call results. Ships with four built-in renderers plus a hook for custom ones. +The AI can render rich HTML components inline inside the chat using Tailwind CSS and Chart.js, generated from tool call results. Ships with a single `HtmlRenderer` in a sandboxed iframe. -- **Renderers:** `CardRenderer` · `TableRenderer` · `StatRenderer` · `HtmlRenderer` +- **Renderer:** `HtmlRenderer` (sandboxed iframe with Tailwind CSS + Chart.js) - **API:** `import { generativeUITool, useGenerativeUI } from "@yourgpt/copilot-sdk/experimental"` — register `generativeUITool` as a tool, use `useGenerativeUI()` to render results - **Package:** `@yourgpt/copilot-sdk/experimental` - **Docs page:** `content/docs/generative-ui.mdx` (page existed on main; full demo + renderers added in beta) diff --git a/apps/docs/content/docs/generative-ui.mdx b/apps/docs/content/docs/generative-ui.mdx index b277df2..3ee5fa9 100644 --- a/apps/docs/content/docs/generative-ui.mdx +++ b/apps/docs/content/docs/generative-ui.mdx @@ -1,341 +1,312 @@ --- title: Generative UI -description: Render rich React components from AI tool results — per-tool custom renderers or AI-driven built-in components +description: Let the AI render rich visual UI — dashboards, charts, tables, cards — directly in the chat icon: AiMagic --- import { Callout } from 'fumadocs-ui/components/callout'; import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; -Instead of showing raw JSON or plain text from tool calls, render interactive UI directly inside the chat — from your own branded React components per tool, to fully AI-generated dashboards, charts, and layouts running in a sandboxed iframe. +Render rich, interactive UI directly inside the chat — from AI-generated dashboards and charts to your own branded React components per tool. --- ## Two Approaches -| | `toolRenderers` | `useGenerativeUI` (experimental) | +| | AI-Generated HTML | Tool Renderers | |---|---|---| -| **What it does** | Your React component renders per tool result | AI writes full HTML + Tailwind + Chart.js, runs in a sandboxed iframe — or picks a typed renderer (table, stat, card, chart) | -| **Who decides the UI** | You — one renderer per tool | The AI — generates or selects based on the data | -| **Setup** | Pass `toolRenderers` to `` | One `useGenerativeUI()` call + backend `generativeUITool()` | -| **Best for** | Domain-specific, branded components | Dashboards, charts, tables, any data layout you haven't pre-built | -| **Customization** | Full control | Override any built-in renderer | +| **What it does** | AI writes HTML + Tailwind + Chart.js, rendered in a sandboxed iframe | Your React component renders per tool result | +| **Who decides the UI** | The AI — generates any layout it wants | You — one renderer per tool | +| **Best for** | Dashboards, charts, tables, cards, any visual you haven't pre-built | Domain-specific, branded components with full interactivity | +| **Streaming** | Progressive — HTML builds up as AI generates | Shows loading → final states | +| **Interactivity** | Via `copilot.action()` bridge | Native React (onClick, state, etc.) | + +Both approaches work together. Use AI-generated HTML for freeform visuals, and tool renderers for structured data with custom components. --- -## Approach 1 — `toolRenderers` +## AI-Generated HTML (Experimental) -Map tool names to React components. Each component receives the tool's args and result as props. + +`@yourgpt/copilot-sdk/experimental` — APIs may change without a semver major bump. + -### Basic example +The AI writes HTML with Tailwind CSS and Chart.js directly in the chat response. The SDK detects it, extracts the HTML, and renders it in a sandboxed iframe — progressively as the AI generates it. -```tsx -import { CopilotChat } from "@yourgpt/copilot-sdk/ui"; +This follows the same pattern as Claude Artifacts — content tags in the text stream, parsed by the frontend, rendered in an isolated iframe. -function WeatherCard({ data, status }) { - if (status === "executing") { - return
Loading weather...
; - } - return ( -
-

{data.city}

-

{data.temp}°F

-

{data.conditions}

-
- ); -} - - +``` +User: "Show me a dashboard of key web metrics" + ↓ +AI streams:
...
+ ↓ +SDK detects tags → extracts HTML → renders in iframe + ↓ +User sees: [Live dashboard with stats, charts, tables — streaming progressively] ``` -### ToolRendererProps +### Setup + + + -Every renderer receives these props: +Add the generative UI system prompt to your runtime: ```typescript -interface ToolRendererProps { - status: "pending" | "executing" | "completed" | "error" | "failed" | "rejected"; - args: Record; // arguments passed to the tool - data?: unknown; // result (when completed) - error?: string; // error message (when failed) - executionId: string; - toolName: string; -} +import { createRuntime } from "@yourgpt/llm-sdk"; +import { generativeUISystemPrompt } from "@yourgpt/copilot-sdk/experimental"; + +const runtime = createRuntime({ + provider, + model, + systemPrompt: generativeUISystemPrompt(), +}); ``` -### Handling all states +The system prompt instructs the AI to wrap visual content in `` tags using Tailwind CSS classes. Chart.js is available for charts. -```tsx -function ChartCard({ status, data, error, args }: ToolRendererProps) { - if (status === "pending" || status === "executing") { - return ( -
-
-

- Generating {args.metric} chart... -

-
- ); - } + + - if (status === "error" || status === "failed") { - return ( -
-

Failed to load chart

-

{error}

-
- ); - } +Call `useGenerativeUI()` and pass `wrapMessage` to your chat: - if (status === "rejected") { - return ( -
-

Chart request was declined

-
- ); - } +```tsx +import { useGenerativeUI } from "@yourgpt/copilot-sdk/experimental"; +import { CopilotChat } from "@yourgpt/copilot-sdk/ui"; - return ( -
-

{data.title}

- - - - - - - -
- ); +function App() { + const { wrapMessage } = useGenerativeUI(); + + return ; } ``` -### Interactive components +That's it. The SDK handles `` detection, iframe rendering, auto-height, streaming, and script deferral. -Renderers can be fully interactive and call back into the chat: +
+ -```tsx -function ProductCard({ data }: ToolRendererProps) { - const [quantity, setQuantity] = useState(1); - const { sendMessage } = useCopilot(); +### What the AI generates - return ( -
- {data.name} -

{data.name}

-

${data.price}

-
- setQuantity(Number(e.target.value))} - className="w-16 border rounded px-2 py-1" - /> - -
-
- ); -} +The AI writes HTML with Tailwind classes wrapped in `` tags: + +```html + +
+

Monthly Revenue

+

$84,200

+ +12% +
+
``` -### Control AI response verbosity +The iframe automatically has **Tailwind CSS** and **Chart.js** pre-loaded. The AI can build anything: tables, stat cards, charts, pricing pages, data grids. -Return `_aiResponseMode: "brief"` from your tool handler to prevent the AI from describing what the UI already shows: +### Chart.js support -```tsx -handler: async ({ timeRange }) => { - const data = await fetchDashboardData(timeRange); - return { - success: true, - data, - _aiResponseMode: "brief", - _aiContext: `Dashboard for ${timeRange}`, - }; -}, -``` +The AI can create charts using Chart.js with inline scripts: - -Use `_aiResponseMode: "brief"` when your UI component is self-explanatory. The AI gives a short acknowledgment instead of narrating the data. - +```html + +
+ + +
+
+``` ---- +Scripts are deferred during streaming and only execute once the full HTML arrives. -## Approach 2 — AI-Generated UI (Experimental) +### Interactive actions (bridge API) - -`@yourgpt/copilot-sdk/experimental` — APIs may change without a semver major bump. - +The AI-generated HTML can communicate back to your app via the `copilot` bridge: -The AI calls a single `render_ui` tool and generates the UI itself. The standout capability is `type: "html"` — the AI writes full HTML with Tailwind CSS and Chart.js, rendered in a sandboxed iframe. No pre-built component needed. For structured data it can also pick typed renderers (`table`, `stat`, `card`, `chart`) automatically. +```tsx +// Frontend — register action handlers +const { wrapMessage } = useGenerativeUI({ + actions: { + addToCart: (data) => cartStore.add(data.itemId), + navigate: (data) => router.push(data.url), + }, +}); +``` +```typescript +// Backend — tell the AI what actions are available +systemPrompt: generativeUISystemPrompt({ + actions: { + addToCart: "Add an item to the shopping cart. Params: { itemId: string, qty: number }", + navigate: "Navigate to a URL. Params: { url: string }", + }, +}), ``` -User: "Show Q1 revenue by region" - ↓ -AI calls: render_ui({ type: "chart", chartType: "bar", labels: ["NA","EU","APAC"], datasets: [...] }) - ↓ -UI renders: [Bar chart] -User: "Build an analytics dashboard" - ↓ -AI calls: render_ui({ type: "html", html: "
...
", height: "600px" }) - ↓ -UI renders: [Full dashboard in sandboxed iframe with Tailwind + Chart.js] +The AI can then write interactive buttons: + +```html + + ``` -### Setup +- `copilot.action(name, data)` — calls your registered handler, returns a result +- `copilot.sendMessage(text)` — sends a message in the chat as the user - - +### Custom design guidelines -Register `generativeUITool()` in your route. The key becomes the tool name. +Pass additional styling instructions to the system prompt: ```typescript -import { generativeUITool } from "@yourgpt/copilot-sdk/experimental"; -import { streamText } from "@yourgpt/llm-sdk"; - -export async function POST(req: Request) { - const { messages } = await req.json(); - - const result = await streamText({ - model: openai("gpt-4o"), - system: "Use render_ui for any data, charts, or structured results.", - messages, - tools: { - render_ui: generativeUITool(), - }, - }); - - return result.toDataStreamResponse(); -} +generativeUISystemPrompt({ + designGuidelines: ` + - Use the brand color #6366f1 for primary elements + - Always include a header with the company logo + - Tables should have zebra striping + `, +}) ``` - - +### Using `GenUIFrame` directly -Call `useGenerativeUI()` once in your component tree — it registers the renderer automatically. +For advanced use cases, render the iframe component directly: ```tsx -import { useGenerativeUI } from "@yourgpt/copilot-sdk/experimental"; -import { CopilotChat } from "@yourgpt/copilot-sdk/ui"; - -function App() { - useGenerativeUI({ - chartRenderer: MyChartComponent, // required for chart type - }); +import { GenUIFrame } from "@yourgpt/copilot-sdk/experimental"; - return ; -} + sendMessage(msg)} + onAction={(name, data) => handleAction(name, data)} +/> ``` - - +--- -### Built-in component types +## Tool Renderers -| Type | When the AI uses it | Renderer | -|------|-------------------|----------| -| `html` | Dashboards, custom layouts, anything freeform | `HtmlRenderer` — sandboxed `