diff --git a/apps/default.json b/apps/default.json deleted file mode 100644 index f6bb183..0000000 --- a/apps/default.json +++ /dev/null @@ -1,546 +0,0 @@ -{ - "src": { - "directory": { - "lib": { - "directory": { - "shiki.ts": { - "file": { - "contents": "/**\n * Shiki highlighter — singleton with CDN-based language loading.\n *\n * Instead of importing from \"shiki\" (which bundles ALL ~200 language grammars\n * and ~50 themes as dynamic imports, producing 347 esbuild chunks), we use\n * `@shikijs/core` with:\n * - 2 themes imported statically (github-light, github-dark)\n * - Language grammars fetched from CDN on demand\n *\n * This reduces the chunk count from 347 → 0.\n *\n * @see https://github.com/taskade/taskcade/issues/26056\n */\n\nimport type { HighlighterCore } from \"@shikijs/core\";\nimport { createHighlighterCore } from \"@shikijs/core\";\nimport { createJavaScriptRegexEngine } from \"@shikijs/engine-javascript\";\nimport githubDark from \"@shikijs/themes/github-dark\";\nimport githubLight from \"@shikijs/themes/github-light\";\n\nexport type { ThemedToken } from \"@shikijs/core\";\n\nexport type BundledLanguage = string;\n\n// Keep in sync with the shiki version in package.json\nconst SHIKI_CDN_BASE = \"https://esm.sh/@shikijs/langs@4.0.2/\";\n\n// Singleton highlighter — created once, languages loaded incrementally\nlet highlighterPromise: Promise | null = null;\n\nfunction getOrCreateHighlighter(): Promise {\n if (!highlighterPromise) {\n highlighterPromise = createHighlighterCore({\n themes: [githubLight, githubDark],\n langs: [],\n engine: createJavaScriptRegexEngine(),\n });\n }\n return highlighterPromise;\n}\n\n// Track language loading state to deduplicate concurrent fetches\nconst loadedLangs = new Set();\nconst loadingLangs = new Map>();\n\nasync function ensureLanguage(\n highlighter: HighlighterCore,\n lang: string\n): Promise {\n if (loadedLangs.has(lang)) return;\n\n // Deduplicate concurrent loads for the same language\n const existing = loadingLangs.get(lang);\n if (existing) return existing;\n\n const promise = (async () => {\n try {\n // Dynamic CDN import — intentionally left as a runtime fetch, not bundled\n const mod = await import(`${SHIKI_CDN_BASE}${lang}.mjs`);\n await highlighter.loadLanguage(mod.default ?? mod);\n } catch {\n // Language not available on CDN — will fall back to plaintext\n } finally {\n loadedLangs.add(lang); // Prevent retries, even on failure\n loadingLangs.delete(lang);\n }\n })();\n\n loadingLangs.set(lang, promise);\n return promise;\n}\n\n/**\n * Returns a Shiki highlighter with the requested language loaded.\n * The highlighter is a singleton; languages are loaded incrementally from CDN.\n */\nexport async function getHighlighter(\n language: string\n): Promise {\n const highlighter = await getOrCreateHighlighter();\n await ensureLanguage(highlighter, language);\n return highlighter;\n}\n" - } - }, - "utils.ts": { - "file": { - "contents": "import { type ClassValue, clsx } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n" - } - }, - "agent-chat": { - "directory": { - "v2": { - "directory": { - "index.ts": { - "file": { - "contents": "/**\n * Agent Chat SDK v2 for Taskade Genesis\n *\n * Uses the AI SDK (`@ai-sdk/react`) with the `/chat` endpoint.\n *\n * IMPORTANT: `useChat` requires a real `Chat` instance — it crashes if passed undefined.\n * Always guard with a conditional render so `useChat` is only called after `chat` is created.\n *\n * @example\n * ```typescript\n * import { useChat } from '@ai-sdk/react';\n * import { createConversation, createAgentChat } from '@/lib/agent-chat/v2';\n * import { useState } from 'react';\n * import { ulid } from 'ulidx';\n *\n * function ChatComponent() {\n * const [chat, setChat] = useState | null>(null);\n *\n * const handleStartChat = async () => {\n * const { conversationId } = await createConversation(agentId);\n * setChat(createAgentChat(agentId, conversationId));\n * };\n *\n * if (!chat) return ;\n * return ;\n * }\n *\n * function ActiveChat({ chat }: { chat: ReturnType }) {\n * const { messages, status } = useChat({ chat, id: chat.id });\n *\n * const handleSend = async (text: string) => {\n * await chat.sendMessage({\n * id: ulid(),\n * role: 'user',\n * parts: [{ type: 'text', text }],\n * });\n * };\n *\n * return (\n *
\n * {messages.map(msg => (\n *
\n * {msg.role === 'user' ? 'You: ' : 'Agent: '}\n * {msg.parts.filter(p => p.type === 'text').map(p => p.text).join('')}\n *
\n * ))}\n * \n *
\n * );\n * }\n * ```\n */\n\nexport type { ClientOptions, CreateConversationResponse } from './client';\nexport { createConversation } from './client';\nexport { createAgentChat } from './createAgentChat';\n" - } - }, - "README.md": { - "file": { - "contents": "# Agent Chat SDK v2\n\nSDK for building AI Agent Chat interfaces in Taskade Genesis apps.\nBuilt on the AI SDK (`@ai-sdk/react`), using the `/chat` endpoint.\n\n## Quick Start\n\n```typescript\nimport { useChat } from '@ai-sdk/react';\nimport { createConversation, createAgentChat } from '@/lib/agent-chat/v2';\nimport { isToolUIPart } from 'ai';\nimport type { UIMessage } from 'ai';\nimport { useState } from 'react';\nimport { ulid } from 'ulidx';\n\n// IMPORTANT: useChat requires a real Chat instance — it crashes if passed undefined.\n// Split into two components so useChat is only called after chat is created.\n\nfunction ChatComponent() {\n const [chat, setChat] = useState | null>(null);\n\n const handleStartChat = async () => {\n const { conversationId } = await createConversation(agentId);\n setChat(createAgentChat(agentId, conversationId));\n };\n\n if (!chat) return ;\n return ;\n}\n\nfunction ActiveChat({ chat }: { chat: ReturnType }) {\n const { messages, status, addToolApprovalResponse } = useChat({ chat, id: chat.id });\n const isSending = status === 'submitted' || status === 'streaming';\n\n const handleSend = async (text: string) => {\n await chat.sendMessage({\n id: ulid(),\n role: 'user',\n parts: [{ type: 'text', text }],\n });\n };\n\n return (\n
\n {messages.map(msg => (\n
\n {msg.role === 'user' ? 'You' : 'Agent'}:\n {msg.parts.map((part, i) => {\n // Always render all part types — the agent may use tools even if none\n // are configured yet. Omitting this causes tool calls to be silently dropped.\n if (part.type === 'text') {\n return {part.text};\n }\n if (isToolUIPart(part)) {\n return (\n
\n Tool: {part.toolName} [{part.state}]\n {part.state === 'approval-requested' && part.approval != null && (\n <>\n \n \n \n )}\n
\n );\n }\n return null;\n })}\n
\n ))}\n \n
\n );\n}\n```\n\n## Pre-built AI Elements UI (`@/components/ai-elements/`)\n\nPre-built, styled React components for chat interfaces are available via AI Elements.\nUse these instead of building chat UI from scratch:\n\n| Component | Import | Purpose |\n|-----------|--------|---------|\n| `Conversation` | `@/components/ai-elements/conversation` | Scrollable chat container with auto-stick-to-bottom |\n| `Message` | `@/components/ai-elements/message` | Message bubble with role-based styling + markdown |\n| `PromptInput` | `@/components/ai-elements/prompt-input` | Chat input form with file attachments + submit |\n| `Suggestion` | `@/components/ai-elements/suggestion` | Quick-reply suggestion pills |\n| `Reasoning` | `@/components/ai-elements/reasoning` | Collapsible thinking/reasoning display |\n| `CodeBlock` | `@/components/ai-elements/code-block` | Syntax-highlighted code with copy button |\n| `Tool` | `@/components/ai-elements/tool` | Collapsible tool call display with status |\n| `Confirmation` | `@/components/ai-elements/confirmation` | Tool call approval UI with approve/reject slots |\n| `Shimmer` | `@/components/ai-elements/shimmer` | Animated shimmer text — usage: `Loading...` (children must be a string) |\n\nSee `@/components/ai-elements` for the full list of available components that you may use to build your chat UI.\n\n\n### Full Example with AI Elements\n\n```typescript\nimport { useChat } from '@ai-sdk/react';\nimport { createConversation, createAgentChat } from '@/lib/agent-chat/v2';\nimport { Conversation, ConversationContent, ConversationScrollButton } from '@/components/ai-elements/conversation';\nimport { Message, MessageContent, MessageResponse } from '@/components/ai-elements/message';\nimport { Tool, ToolHeader, ToolContent, ToolInput, ToolOutput } from '@/components/ai-elements/tool';\nimport {\n Confirmation,\n ConfirmationTitle,\n ConfirmationRequest,\n ConfirmationAccepted,\n ConfirmationRejected,\n ConfirmationActions,\n ConfirmationAction,\n} from '@/components/ai-elements/confirmation';\nimport { PromptInput, PromptInputTextarea, PromptInputFooter, PromptInputSubmit } from '@/components/ai-elements/prompt-input';\nimport { Suggestions, Suggestion } from '@/components/ai-elements/suggestion';\nimport { isToolUIPart } from 'ai';\nimport type { UIMessage } from 'ai';\nimport { useState } from 'react';\nimport { ulid } from 'ulidx';\n\nfunction ChatPage() {\n const [chat, setChat] = useState | null>(null);\n\n const handleStartChat = async () => {\n const { conversationId } = await createConversation(agentId);\n setChat(createAgentChat(agentId, conversationId));\n };\n\n if (!chat) return ;\n return ;\n}\n\nfunction ActiveChat({ chat }: { chat: ReturnType }) {\n const { messages, status, addToolApprovalResponse } = useChat({ chat, id: chat.id });\n\n const handleSend = async (text: string) => {\n await chat.sendMessage({\n id: ulid(),\n role: 'user',\n parts: [{ type: 'text', text }],\n });\n };\n\n const hasMessages = messages.length > 0;\n\n return (\n
\n \n \n {messages.map((msg) => (\n \n \n \n \n \n ))}\n \n \n \n\n {!hasMessages && (\n \n \n \n \n )}\n\n handleSend(text)}>\n \n \n \n \n \n
\n );\n}\n\n// Always handle all part types — the agent may call tools even if none are configured\n// yet. Omitting isToolUIPart handling causes tool calls to be silently dropped.\nfunction MessageParts({\n message,\n onApprove,\n}: {\n message: UIMessage;\n onApprove: ReturnType['addToolApprovalResponse'];\n}) {\n return (\n <>\n {message.parts.map((part, i) => {\n const key = `${message.id}-${i}`;\n\n if (part.type === 'text') {\n return message.role === 'user' ? (\n

{part.text}

\n ) : (\n {part.text}\n );\n }\n\n if (isToolUIPart(part)) {\n return (\n \n \n \n \n \n \n Allow this tool to run?\n \n Approved\n Rejected\n \n \n part.approval != null && onApprove({ id: part.approval.id, approved: false })\n }\n >\n Deny\n \n \n part.approval != null && onApprove({ id: part.approval.id, approved: true })\n }\n >\n Approve\n \n \n \n \n \n \n );\n }\n\n return null;\n })}\n \n );\n}\n```\n\n## Tool Call Approval\n\nBoth examples above already include full approval handling — it is part of the standard\n`MessageParts` pattern and should always be present, even if the agent has no tools\nconfigured today. Tool call parts will simply never appear in that case; the code is inert.\n\nThe approval flow is managed by two pieces:\n\n- **`Confirmation` + sub-components** (`@/components/ai-elements/confirmation`) — conditional\n rendering driven by `part.state` and `part.approval`. No handler logic lives inside them.\n- **`addToolApprovalResponse`** (from `useChat`) — call with `{ id: part.approval.id, approved }`.\n The `id` must be the tool part’s **`approval.id`**, not `toolCallId`; wrong `id` updates nothing.\n `createAgentChat` then automatically sends the next request to the server.\n\n### Approval state lifecycle\n\n| State | What's visible |\n|---|---|\n| `approval-requested` | `` + `` (approve/deny buttons) |\n| `approval-responded` | `` or `` based on decision |\n| `output-available` | Tool completed — `` shows result |\n| `output-denied` | Tool was denied — `` stays visible |\n\nOnce `addToolApprovalResponse` is called, `createAgentChat` automatically sends the next\nrequest to the server — no manual trigger required.\n\n## API\n\n**`createConversation(agentId, options?)`**\nCreates a new public conversation. Returns `{ ok, conversationId }`.\n\n**`createAgentChat(agentId, conversationId, options?)`**\nCreates a `Chat` instance configured for the agent. Use with `useChat` from `@ai-sdk/react`.\n\n**`useChat({ chat, id })`** (from `@ai-sdk/react`)\nStandard AI SDK hook. Returns `{ messages, status, error, addToolApprovalResponse }`.\n\n**`addToolApprovalResponse({ id, approved, reason? })`** (from `useChat`)\nSubmits an approve (`true`) or deny (`false`) decision. **`id` is `toolUIPart.approval.id`**, not `toolCallId`.\nThe conversation automatically resumes after the response is submitted.\n\n## Sending Messages\n\n```typescript\nimport { ulid } from 'ulidx';\n\nawait chat.sendMessage({\n id: ulid(),\n role: 'user',\n parts: [{ type: 'text', text: 'Hello!' }],\n});\n```\n\n## Message Format\n\nMessages use the AI SDK `UIMessage` type:\n\n```typescript\nimport { isToolUIPart } from 'ai';\n\nmsg.parts.filter(p => p.type === 'text').map(p => p.text) // Text\nmsg.parts.filter(isToolUIPart) // Tool calls\n```\n\n## Requirements\n\n- Agent must have **public visibility** enabled before creating a conversation\n- `useChat` must receive a real `Chat` instance, never `undefined`\n" - } - }, - "client.ts": { - "file": { - "contents": "/**\n * API Response types\n */\nexport interface CreateConversationResponse {\n ok: boolean;\n conversationId: string;\n}\n\n/**\n * Configuration for API client\n */\nexport interface ClientOptions {\n /** Base URL for API requests (defaults to relative paths) */\n baseUrl?: string;\n}\n\nfunction isEmptyString(value: string | null | undefined): boolean {\n return value == null || value.trim().length === 0;\n}\n\n/**\n * Creates a new public agent conversation\n *\n * @param agentId - The agent ID\n * @param options - Optional client configuration\n * @returns Promise resolving to conversation ID\n * @throws Error if conversation creation fails\n *\n * @example\n * ```typescript\n * const { conversationId } = await createConversation('agent-456');\n * ```\n */\nexport async function createConversation(\n agentId: string,\n options?: ClientOptions,\n): Promise {\n if (isEmptyString(agentId)) {\n throw new Error('Agent ID cannot be empty');\n }\n\n const baseUrl = options?.baseUrl ?? '';\n const url = `${baseUrl}/api/taskade/agents/${encodeURIComponent(agentId)}/public-conversations`;\n\n const response = await fetch(url, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n },\n });\n\n const contentType = response.headers.get('content-type') || '';\n const responseText = await response.text().catch(() => '');\n\n if (!response.ok) {\n throw new Error(\n `Failed to create conversation: ${response.status} ${responseText || 'Unknown error'}`,\n );\n }\n\n if (!contentType.includes('application/json')) {\n throw new Error(\n `Invalid response format: expected JSON, got ${contentType}. Response: ${responseText.substring(0, 100)}`,\n );\n }\n\n try {\n const data = JSON.parse(responseText);\n return data as CreateConversationResponse;\n } catch (err) {\n throw new Error(\n `Failed to parse JSON response: ${err instanceof Error ? err.message : 'Unknown error'}. Response: ${responseText.substring(0, 200)}`,\n );\n }\n}\n" - } - }, - "createAgentChat.ts": { - "file": { - "contents": "import { Chat } from '@ai-sdk/react';\nimport {\n DefaultChatTransport,\n lastAssistantMessageIsCompleteWithApprovalResponses,\n lastAssistantMessageIsCompleteWithToolCalls,\n UIMessage,\n isToolUIPart,\n} from 'ai';\nimport { ulid } from 'ulidx';\n\nimport type { ClientOptions } from './client';\n\nexport type ExtractInputMessagesResult = {\n history: UIMessage[];\n messages: UIMessage[];\n};\n\n/**\n * Generic version of the TAA helper:\n * - Separates \"history\" from the latest actionable message(s) to send to the server.\n * - Handles agentic tool-call loops where the last assistant message contains tool parts.\n */\nfunction extractInputMessages(messages: UIMessage[]): ExtractInputMessagesResult {\n if (messages.length === 0) {\n return { history: messages, messages: [] };\n }\n\n const lastMessageIndex = messages.length - 1;\n const lastMessage = messages[lastMessageIndex];\n if (lastMessage == null) {\n return { history: messages, messages: [] };\n }\n\n if (lastMessage.role === 'user') {\n const history = messages.slice(0, lastMessageIndex);\n return { history, messages: [lastMessage] };\n }\n\n if (lastMessage.role === 'assistant') {\n const parts = lastMessage.parts;\n if (parts != null && parts.length > 0) {\n const lastPart = parts[parts.length - 1];\n if (lastPart != null && isToolUIPart(lastPart)) {\n if (lastPart.state === 'output-available' || lastPart.state === 'output-error') {\n const history = messages.slice(0, lastMessageIndex);\n return { history, messages: [lastMessage] };\n }\n }\n }\n }\n\n const history = messages.slice(0, lastMessageIndex);\n return { history, messages: [lastMessage] };\n}\n\nconst MAX_HISTORY_MESSAGES = 6;\n\n/**\n * Creates a Chat instance configured for a Taskade agent public conversation.\n *\n * Use with `useChat` from `@ai-sdk/react` to build chat interfaces.\n *\n * IMPORTANT: `useChat` requires a real `Chat` instance — it crashes if passed undefined.\n * Always guard with a conditional render so `useChat` is only called after `chat` is created.\n *\n * @param agentId - The agent ID\n * @param conversationId - The conversation ID (from createConversation)\n * @param options - Optional client configuration\n * @returns A Chat instance ready to use with useChat\n *\n * Tool approval: `useChat`'s `addToolApprovalResponse` expects `{ id, approved }` where `id` is\n * `toolUIPart.approval.id` — **not** `toolCallId`. Passing `toolCallId` will not update any part.\n *\n * @example\n * ```typescript\n * import { useChat } from '@ai-sdk/react';\n * import { createConversation, createAgentChat } from '@/lib/agent-chat/v2';\n *\n * function ChatComponent() {\n * const [chat, setChat] = useState | null>(null);\n *\n * const handleStartChat = async () => {\n * const { conversationId } = await createConversation(agentId);\n * setChat(createAgentChat(agentId, conversationId));\n * };\n *\n * if (!chat) return ;\n * return ;\n * }\n *\n * function ActiveChat({ chat }: { chat: ReturnType }) {\n * const { messages, status } = useChat({ chat, id: chat.id });\n * // ...\n * }\n * ```\n */\nexport function createAgentChat(\n agentId: string,\n conversationId: string,\n options?: ClientOptions,\n): Chat {\n const baseUrl = options?.baseUrl ?? '';\n const api = `${baseUrl}/api/taskade/agents/${encodeURIComponent(agentId)}/public-conversations/${encodeURIComponent(conversationId)}/chat`;\n\n const chatState = new Chat({\n messages: [],\n transport: new DefaultChatTransport({\n api,\n prepareSendMessagesRequest: (opts) => {\n const { history, messages } = extractInputMessages(opts.messages);\n\n const maxHistory = Math.max(0, MAX_HISTORY_MESSAGES - messages.length);\n const trimmedHistory = maxHistory === 0 ? [] : history.slice(-maxHistory);\n\n return {\n body: {\n messages,\n history: trimmedHistory,\n },\n };\n },\n }),\n id: conversationId,\n generateId: ulid,\n sendAutomaticallyWhen: (options) => {\n const shouldSendAutomatically =\n lastAssistantMessageIsCompleteWithToolCalls(options) ||\n lastAssistantMessageIsCompleteWithApprovalResponses(options);\n if (!shouldSendAutomatically) {\n return false;\n }\n if (chatState.error != null) {\n return false;\n }\n return true;\n },\n });\n\n return chatState;\n}\n" - } - } - } - }, - "hooks.ts": { - "file": { - "contents": "import { useCallback, useEffect, useMemo, useRef, useState } from 'react';\n\nimport {\n type ClientOptions,\n createConversation as createConversationApi,\n sendMessage as sendMessageApi,\n} from './client';\nimport { AgentChatStream } from './stream';\nimport type { ErrorEvent, MessageState, StreamOptions } from './types';\n\n/**\n * Options for useAgentChat hook\n */\nexport interface UseAgentChatOptions extends StreamOptions {\n /** Auto-connect stream on mount (default: true) */\n autoConnect?: boolean;\n}\n\n/**\n * Return type for useAgentChat hook\n */\nexport interface UseAgentChatReturn {\n /** Send a message to the conversation */\n sendMessage: (text: string) => Promise;\n /** Array of messages (sorted by creation order) */\n messages: MessageState[];\n /** Whether stream is currently connected */\n isConnected: boolean;\n /** Current error, if any */\n error: Error | null;\n /** Current conversation ID */\n conversationId: string | null;\n /** Create a new conversation (stream stays open, switches to new conversation) */\n createConversation: () => Promise;\n /** Switch to a different conversation (stream stays open) */\n switchConversation: (conversationId: string) => void;\n /** Manually connect the stream (useful when autoConnect is false) */\n connect: () => void;\n}\n\n/**\n * React hook for managing agent chat conversation\n *\n * Requires a conversationId to be provided. The stream is opened when conversationId is available\n * and stays open throughout the conversation lifecycle.\n *\n * @param agentId - The agent ID\n * @param conversationId - The conversation ID (required - must be created manually)\n * @param options - Configuration options\n * @returns Chat state and methods\n *\n * @example\n * ```typescript\n * function ChatComponent() {\n * const [conversationId, setConversationId] = useState(null);\n * const { sendMessage, messages, isConnected } = useAgentChat('agent-456', conversationId);\n *\n * // Create conversation manually\n * const handleStartChat = async () => {\n * const { conversationId: newId } = await createConversation('agent-456');\n * setConversationId(newId);\n * };\n *\n * return (\n *
\n * {!conversationId && }\n * {messages.map(msg => (\n *
{msg.content}
\n * ))}\n * \n *
\n * );\n * }\n * ```\n */\nexport function useAgentChat(\n agentId: string,\n conversationId: string | null,\n options?: UseAgentChatOptions,\n): UseAgentChatReturn {\n const [messagesMap, setMessagesMap] = useState>(new Map());\n const [isConnected, setIsConnected] = useState(false);\n const [error, setError] = useState(null);\n const [currentConversationId, setCurrentConversationId] = useState(conversationId);\n const streamRef = useRef(null);\n const listenersRef = useRef void>>([]);\n const streamAgentIdRef = useRef(null);\n const currentConversationIdRef = useRef(conversationId);\n const previousConversationIdRef = useRef(conversationId);\n\n // Sync currentConversationId with prop when prop changes externally\n // This ensures the hook's internal state stays in sync with external prop updates\n useEffect(() => {\n const previousId = previousConversationIdRef.current;\n const newId = conversationId;\n\n // Clear messages when conversationId changes to a different non-null value\n // This handles the case where the prop changes externally (not via createConversation/switchConversation)\n if (previousId !== newId && newId != null && previousId != null) {\n setMessagesMap(new Map());\n // Also clear stream's message states if stream exists\n if (streamRef.current) {\n streamRef.current.clearMessages();\n }\n }\n\n setCurrentConversationId(newId);\n currentConversationIdRef.current = newId;\n previousConversationIdRef.current = newId;\n }, [conversationId]);\n\n // Cleanup on component unmount\n useEffect(() => {\n return () => {\n if (streamRef.current) {\n streamRef.current.disconnect();\n streamRef.current = null;\n streamAgentIdRef.current = null;\n }\n };\n }, []);\n\n // Convert messages map to sorted array\n const messages = useMemo(() => {\n const allMessages = Array.from(messagesMap.values());\n // Sort by message ID (ULID) - ULIDs are lexicographically sortable and encode timestamp\n // This ensures chronological order regardless of message role\n allMessages.sort((a, b) => {\n return a.id.localeCompare(b.id);\n });\n return allMessages;\n }, [messagesMap]);\n\n // Initialize stream when conversationId is available\n // Uses currentConversationId to ensure we're working with the latest state\n // (which may have been updated by createConversation/switchConversation)\n useEffect(() => {\n // Don't initialize if no conversationId\n if (!currentConversationId) {\n // Clean up existing stream if conversationId is removed\n if (streamRef.current) {\n streamRef.current.clearMessages(); // Clear stream's message states\n streamRef.current.disconnect();\n streamRef.current = null;\n streamAgentIdRef.current = null;\n setIsConnected(false);\n }\n // Clear messages when conversation is removed\n setMessagesMap(new Map());\n currentConversationIdRef.current = null;\n return;\n }\n\n // Update ref to track current conversation ID\n currentConversationIdRef.current = currentConversationId;\n\n // Clean up previous listeners before setting up new ones\n // This ensures we don't accumulate duplicate listeners when reusing the stream\n listenersRef.current.forEach((unsub) => unsub());\n listenersRef.current = [];\n\n const unsubscribeFunctions: Array<() => void> = [];\n let isMounted = true;\n\n // Register cleanup function first to ensure it's available immediately\n // This prevents race conditions where component unmounts before cleanup is registered\n const cleanup = () => {\n isMounted = false;\n unsubscribeFunctions.forEach((unsub) => unsub());\n // Note: We don't disconnect the stream here because:\n // 1. The stream may be reused in the next effect run (when switching conversations)\n // 2. Disconnection is handled when conversationId becomes null (early return above)\n // 3. On component unmount, React will call cleanup, but the stream will be garbage collected\n // when the ref is cleared by the early return path if conversationId becomes null\n };\n\n try {\n // Ensure we have valid agentId before creating stream\n // Note: currentConversationId is already validated above (early return if falsy)\n if (!agentId) {\n throw new Error(`Invalid parameters: agentId is required`);\n }\n\n // Helper function to update messages from stream state\n // Defined inline here since it only uses refs and setState (both stable)\n const updateMessages = () => {\n const currentStream = streamRef.current;\n if (currentStream) {\n setMessagesMap((prev) => {\n const next = new Map(prev);\n // Add/update stream messages (assistant responses)\n for (const [id, streamMsg] of currentStream.messages) {\n // Only update if this is an assistant message (or doesn't exist yet)\n // Preserve user messages - they should never be overwritten by stream\n const existing = prev.get(id);\n if (!existing || existing.role === 'assistant') {\n // Merge with existing to preserve content if stream message is missing it\n // Stream message should have latest content, but fallback to existing as safety\n const mergedMsg: MessageState = {\n ...existing,\n ...streamMsg,\n id, // Ensure ID is preserved\n role: 'assistant' as const,\n // Prefer stream message content if it exists and is non-empty, otherwise preserve existing\n content:\n typeof streamMsg.content === 'string' && streamMsg.content.length > 0\n ? streamMsg.content\n : existing?.content || '',\n };\n next.set(id, mergedMsg);\n }\n }\n return next;\n });\n }\n };\n\n let stream: AgentChatStream;\n\n // Check if we need to recreate the stream (agentId changed or stream doesn't exist)\n const existingStream = streamRef.current;\n const agentIdChanged = existingStream && streamAgentIdRef.current !== agentId;\n\n if (agentIdChanged && existingStream) {\n // AgentId changed - preserve messages from old stream before disconnecting\n // The hook's messagesMap should already have all messages, but we ensure\n // any messages in the stream's state are preserved in the hook's state\n setMessagesMap((prev) => {\n const next = new Map(prev);\n // Preserve any messages from the old stream that aren't already in the map\n for (const [id, streamMsg] of existingStream.messages) {\n if (!next.has(id)) {\n next.set(id, streamMsg);\n } else {\n // If message exists, preserve user messages and merge assistant messages\n const existing = prev.get(id);\n if (existing?.role === 'user') {\n // Keep user message as-is\n continue;\n }\n // Merge assistant messages\n if (existing?.role === 'assistant' || !existing) {\n next.set(id, {\n ...existing,\n ...streamMsg,\n id,\n role: 'assistant' as const,\n });\n }\n }\n }\n return next;\n });\n // Now disconnect the old stream\n existingStream.disconnect();\n streamRef.current = null;\n streamAgentIdRef.current = null;\n }\n\n // If stream exists and agentId hasn't changed, update its conversation ID and reuse it\n // We still need to re-register event listeners to ensure fresh closures\n if (streamRef.current && !agentIdChanged) {\n stream = streamRef.current;\n // Update conversation ID if needed - this handles disconnection/reconnection\n stream.setConversationId(currentConversationId);\n } else {\n // Create new stream\n const streamOptions: StreamOptions = {\n baseUrl: options?.baseUrl,\n autoReconnect: options?.autoReconnect ?? true,\n reconnectDelay: options?.reconnectDelay,\n onError: (err) => {\n if (isMounted) {\n setError(err);\n }\n options?.onError?.(err);\n },\n };\n stream = new AgentChatStream(agentId, currentConversationId, streamOptions);\n streamRef.current = stream;\n streamAgentIdRef.current = agentId;\n }\n\n // Always set up event listeners to ensure fresh closures\n // Even if stream exists, we need to re-register listeners with current closures\n // (isMounted, updateMessages, etc.)\n\n // Subscribe to events\n // Note: We always re-register listeners even if stream exists to ensure fresh closures\n const unsubscribeOpen = stream.on('open', () => {\n if (isMounted) {\n setIsConnected(true);\n setError(null);\n }\n });\n unsubscribeFunctions.push(unsubscribeOpen);\n\n const unsubscribeClose = stream.on('close', () => {\n if (isMounted) {\n setIsConnected(false);\n }\n });\n unsubscribeFunctions.push(unsubscribeClose);\n\n const unsubscribeTextDelta = stream.on('text-delta', () => {\n if (isMounted) {\n updateMessages();\n }\n });\n unsubscribeFunctions.push(unsubscribeTextDelta);\n\n const unsubscribeFinish = stream.on('finish', () => {\n if (isMounted) {\n updateMessages();\n }\n });\n unsubscribeFunctions.push(unsubscribeFinish);\n\n const unsubscribeError = stream.on('error', (event: ErrorEvent) => {\n if (isMounted) {\n setError(new Error(event.errorText));\n }\n });\n unsubscribeFunctions.push(unsubscribeError);\n\n // Store unsubscribe functions for cleanup on next effect run\n listenersRef.current = unsubscribeFunctions;\n\n // Connect stream if autoConnect is enabled (default: true)\n if (options?.autoConnect !== false) {\n stream.connect();\n }\n } catch (err) {\n if (isMounted) {\n const initError = err instanceof Error ? err : new Error('Failed to initialize chat');\n setError(initError);\n console.error('[useAgentChat] Initialization error:', initError);\n }\n }\n\n // Return cleanup function\n return cleanup;\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [\n agentId,\n currentConversationId, // Use currentConversationId instead of prop to react to internal updates\n options?.baseUrl,\n options?.autoReconnect,\n options?.reconnectDelay,\n options?.autoConnect,\n options?.onError,\n // Note: updateMessages is defined inline in the effect and only uses refs/setState (stable)\n ]);\n\n // Send message function\n const sendMessage = useCallback(\n async (text: string) => {\n // Use currentConversationId to ensure we're using the latest state\n const convoId = currentConversationId;\n if (!convoId) {\n throw new Error('No conversation available. Create a conversation first.');\n }\n\n const clientOptions: ClientOptions = {\n baseUrl: options?.baseUrl,\n };\n\n try {\n setError(null);\n const response = await sendMessageApi(agentId, convoId, text, clientOptions);\n\n // Check if conversation is still active before adding message\n // This prevents race condition where conversation switches while API call is pending\n // Use ref to check latest conversation ID (closure value might be stale)\n if (currentConversationIdRef.current !== convoId) {\n // Conversation changed during API call, don't add message (it belongs to old conversation)\n return;\n }\n\n // Add user message with the messageId from the API response\n const userMessage: MessageState = {\n id: response.messageId,\n content: text,\n isComplete: true,\n role: 'user',\n };\n // Add user message to messages map\n setMessagesMap((prev) => {\n const next = new Map(prev);\n next.set(response.messageId, userMessage);\n return next;\n });\n } catch (err) {\n const sendError = err instanceof Error ? err : new Error('Failed to send message');\n setError(sendError);\n throw sendError;\n }\n },\n [agentId, currentConversationId, options?.baseUrl],\n );\n\n /**\n * Create a new conversation\n *\n * Creates a new conversation and switches the stream to it. The hook's internal state\n * is updated automatically. If you're managing conversationId as a prop, you should\n * also update it to keep it in sync:\n *\n * @example\n * ```typescript\n * const [conversationId, setConversationId] = useState(null);\n * const { createConversation } = useAgentChat(agentId, conversationId);\n *\n * const handleNewChat = async () => {\n * const newId = await createConversation();\n * setConversationId(newId); // Update prop to keep it in sync\n * };\n * ```\n *\n * @returns The new conversation ID\n */\n const createConversation = useCallback(async (): Promise => {\n const clientOptions: ClientOptions = {\n baseUrl: options?.baseUrl,\n };\n const { conversationId: newConvoId } = await createConversationApi(agentId, clientOptions);\n\n // Update internal state first - this will trigger useEffect to reinitialize stream\n setCurrentConversationId(newConvoId);\n currentConversationIdRef.current = newConvoId;\n // Clear messages when switching to new conversation\n setMessagesMap(new Map());\n\n // If stream exists, update it to the new conversation ID\n // This handles the case where stream was already initialized\n if (streamRef.current) {\n streamRef.current.setConversationId(newConvoId);\n }\n\n return newConvoId;\n }, [agentId, options?.baseUrl]);\n\n /**\n * Switch to a different conversation\n *\n * Switches the stream to an existing conversation. The hook's internal state\n * is updated automatically. If you're managing conversationId as a prop, you should\n * also update it to keep it in sync:\n *\n * @example\n * ```typescript\n * const [conversationId, setConversationId] = useState(null);\n * const { switchConversation } = useAgentChat(agentId, conversationId);\n *\n * const handleSwitchChat = (existingConvoId: string) => {\n * switchConversation(existingConvoId);\n * setConversationId(existingConvoId); // Update prop to keep it in sync\n * };\n * ```\n *\n * @param convoId - The conversation ID to switch to\n */\n const switchConversation = useCallback((convoId: string) => {\n // Update internal state first - this will trigger useEffect to reinitialize stream\n setCurrentConversationId(convoId);\n currentConversationIdRef.current = convoId;\n // Clear messages when switching conversations\n setMessagesMap(new Map());\n\n // If stream exists, update it to the new conversation ID\n // This handles the case where stream was already initialized\n if (streamRef.current) {\n streamRef.current.setConversationId(convoId);\n }\n }, []);\n\n /**\n * Manually connect the stream\n *\n * Useful when `autoConnect` is set to `false`. The stream must have a valid\n * conversationId before connecting.\n *\n * @example\n * ```typescript\n * const { connect, conversationId } = useAgentChat(agentId, conversationId, {\n * autoConnect: false,\n * });\n *\n * // Connect manually after conversation is created\n * const handleStartChat = async () => {\n * const { conversationId: newId } = await createConversation(agentId);\n * setConversationId(newId);\n * // Stream will be initialized but not connected due to autoConnect: false\n * // Manually connect it\n * connect();\n * };\n * ```\n */\n const connect = useCallback(() => {\n if (!currentConversationId) {\n throw new Error('Cannot connect: no conversation available. Create a conversation first.');\n }\n\n if (!streamRef.current) {\n throw new Error('Cannot connect: stream not initialized. Ensure conversationId is provided.');\n }\n\n streamRef.current.connect();\n }, [currentConversationId]);\n\n return {\n sendMessage,\n messages,\n isConnected,\n error,\n conversationId: currentConversationId,\n createConversation,\n switchConversation,\n connect,\n };\n}\n" - } - }, - "index.ts": { - "file": { - "contents": "/**\n * Agent Chat SDK for Taskade Genesis (Legacy)\n *\n * @deprecated Use `@/lib/agent-chat/v2` instead for new code.\n *\n * Low-level SDK for building AI Agent Chat interfaces in React applications.\n * Provides API client, SSE stream management, and optional React hooks.\n *\n * @example\n * ```typescript\n * // React hook usage\n * import { useAgentChat, createConversation } from '@/lib/agent-chat';\n * import { useState } from 'react';\n *\n * function ChatComponent() {\n * const [conversationId, setConversationId] = useState(null);\n * const { sendMessage, messages, isConnected } = useAgentChat(agentId, conversationId);\n *\n * const handleStartChat = async () => {\n * const { conversationId: newId } = await createConversation(agentId);\n * setConversationId(newId);\n * };\n *\n * return (\n *
\n * {!conversationId && }\n * {messages.map(msg => (\n *
\n * {msg.role === 'user' ? 'You: ' : 'Agent: '}\n * {msg.content}\n *
\n * ))}\n * \n *
\n * );\n * }\n * ```\n */\n\n// Core API client (for advanced usage)\nexport type { ClientOptions } from './client';\nexport { createConversation, sendMessage } from './client';\n\n// Stream manager (for advanced usage)\nexport { AgentChatStream } from './stream';\n\n// Types\nexport type {\n CreateConversationResponse,\n ErrorEvent,\n ErrorHandler,\n FinishEvent,\n FinishHandler,\n MessageState,\n SendMessageResponse,\n StartEvent,\n StreamEvent,\n StreamEventHandler,\n StreamOptions,\n TextDeltaEvent,\n TextDeltaHandler,\n TextEndEvent,\n TextStartEvent,\n ToolCallEndEvent,\n ToolCallState,\n ToolInputAvailableEvent,\n ToolInputDeltaEvent,\n ToolInputStartEvent,\n ToolOutputAvailableEvent,\n} from './types';\n\n// React hook (main API)\nexport type { UseAgentChatOptions, UseAgentChatReturn } from './hooks';\nexport { useAgentChat } from './hooks';\n" - } - }, - "types.ts": { - "file": { - "contents": "import { z } from 'zod';\n\n/**\n * SSE Event schemas matching backend AgentPublicConversationMessageStreamResponseSchema\n */\nexport const StreamEventSchema = z.union([\n z.object({\n type: z.literal('start'),\n messageId: z.string(),\n }),\n z.object({\n type: z.literal('text-start'),\n id: z.string(),\n }),\n z.object({\n type: z.literal('text-delta'),\n id: z.string(),\n delta: z.string(),\n }),\n z.object({\n type: z.literal('text-end'),\n id: z.string(),\n }),\n z.object({\n type: z.literal('tool-input-start'),\n toolCallId: z.string(),\n toolName: z.string(),\n messageId: z.string().optional(),\n }),\n z.object({\n type: z.literal('tool-input-delta'),\n toolCallId: z.string(),\n inputTextDelta: z.string(),\n messageId: z.string().optional(),\n }),\n z.object({\n type: z.literal('tool-input-available'),\n toolCallId: z.string(),\n input: z.unknown(),\n messageId: z.string().optional(),\n }),\n z.object({\n type: z.literal('tool-output-available'),\n toolCallId: z.string(),\n output: z.unknown(),\n messageId: z.string().optional(),\n }),\n z.object({\n type: z.literal('tool-call-end'),\n toolCallId: z.string(),\n messageId: z.string().optional(),\n }),\n z.object({\n type: z.literal('finish'),\n }),\n z.object({\n type: z.literal('error'),\n errorText: z.string(),\n }),\n]);\n\nexport type StreamEvent = z.infer;\n\n/**\n * Specific event types for type narrowing\n */\nexport type StartEvent = Extract;\nexport type TextStartEvent = Extract;\nexport type TextDeltaEvent = Extract;\nexport type TextEndEvent = Extract;\nexport type ToolInputStartEvent = Extract;\nexport type ToolInputDeltaEvent = Extract;\nexport type ToolInputAvailableEvent = Extract;\nexport type ToolOutputAvailableEvent = Extract;\nexport type ToolCallEndEvent = Extract;\nexport type FinishEvent = Extract;\nexport type ErrorEvent = Extract;\n\n/**\n * API Response types\n */\nexport interface CreateConversationResponse {\n ok: boolean;\n conversationId: string;\n}\n\nexport interface SendMessageResponse {\n ok: boolean;\n messageId: string;\n}\n\n/**\n * Configuration options for stream\n */\nexport interface StreamOptions {\n /** Base URL for API requests (defaults to relative paths) */\n baseUrl?: string;\n /** Automatically reconnect on disconnect (default: true) */\n autoReconnect?: boolean;\n /** Delay in ms before reconnecting (default: 1000) */\n reconnectDelay?: number;\n /**\n * Callback for stream errors\n * @default Logs to console.error\n * @remarks In production, provide your own error handler to properly handle errors\n */\n onError?: (error: Error) => void;\n}\n\n/**\n * Accumulated message state\n */\nexport interface MessageState {\n id: string;\n content: string;\n isComplete: boolean;\n role: 'user' | 'assistant';\n toolCalls?: ToolCallState[];\n}\n\n/**\n * Tool call state\n */\nexport interface ToolCallState {\n toolCallId: string;\n toolName: string;\n input?: unknown;\n output?: unknown;\n isComplete: boolean;\n}\n\n/**\n * Event handler types\n */\nexport type StreamEventHandler = (event: StreamEvent) => void;\nexport type TextDeltaHandler = (event: TextDeltaEvent) => void;\nexport type FinishHandler = (event: FinishEvent) => void;\nexport type ErrorHandler = (event: ErrorEvent) => void;\n" - } - }, - "README.md": { - "file": { - "contents": "# Agent Chat SDK (Legacy)\n\n> **DEPRECATED**: Do not use this SDK for new code. Use `@/lib/agent-chat/v2` instead — see `src/lib/agent-chat/v2/README.md` for docs.\n\n---\n\nSimple SDK for building AI Agent Chat interfaces in Taskade Genesis apps.\n\n**Key Features:**\n- Manual conversation creation (consumer must create conversation before use)\n- Stream opens when conversationId is provided\n- Stream stays open throughout (handles reconnection automatically)\n- Supports creating/switching conversations\n\n## Quick Start\n\n```typescript\nimport { useAgentChat, createConversation } from '@/lib/agent-chat';\nimport { useState } from 'react';\n\nfunction ChatComponent() {\n const [conversationId, setConversationId] = useState(null);\n const { sendMessage, messages, isConnected } = useAgentChat(agentId, conversationId);\n\n // Create conversation manually\n const handleStartChat = async () => {\n const { conversationId: newId } = await createConversation(agentId);\n setConversationId(newId);\n };\n \n return (\n
\n {!conversationId && }\n {messages.map(msg => (\n
\n {msg.role === 'user' ? 'You: ' : 'Agent: '}\n {msg.content}\n {msg.toolCalls && msg.toolCalls.length > 0 && (\n
Tool calls: {msg.toolCalls.length}
\n )}\n
\n ))}\n \n
\n );\n}\n```\n\n## Multiple Conversations\n\n```typescript\nconst [conversationId, setConversationId] = useState(null);\nconst {\n sendMessage,\n messages,\n createConversation: createNewConversation, // Create new conversation\n switchConversation, // Switch to existing conversation\n} = useAgentChat(agentId, conversationId);\n\n// Create new chat\nconst handleNewChat = async () => {\n const newConvoId = await createNewConversation();\n setConversationId(newConvoId);\n};\n\n// Switch to different conversation\nconst handleSwitchChat = (existingConvoId: string) => {\n switchConversation(existingConvoId);\n setConversationId(existingConvoId);\n};\n```\n\n## API Reference\n\n**`useAgentChat(agentId, conversationId)`**\n- `agentId` - The agent ID (required)\n- `conversationId` - The conversation ID (required, pass `null` if not yet created)\n- `sendMessage(text)` - Send message to current conversation (requires conversationId)\n- `messages` - Array of messages (MessageState[])\n- `isConnected` - Stream connection status\n- `conversationId` - Current conversation ID (from hook return)\n- `createConversation()` - Create new conversation (returns ID)\n- `switchConversation(id)` - Switch to different conversation\n- `error` - Current error, if any\n\n**Note:** The hook will only connect the stream when `conversationId` is provided (not `null`). You must create a conversation manually before the stream can connect.\n\n**Advanced (low-level):**\n- `createConversation(agentId)` - Direct API call\n- `sendMessage(agentId, conversationId, text)` - Direct API call\n- `AgentChatStream` - Stream manager class\n\nSee `index.ts` for full type exports.\n\n## Requirements\n\n- Agent must have **public visibility** enabled before creating conversation\n- Stream stays open permanently (never close after first response)\n- Text deltas are automatically accumulated (append, never replace)\n" - } - }, - "client.ts": { - "file": { - "contents": "import type { CreateConversationResponse, SendMessageResponse } from './types';\n\n/**\n * Configuration for API client\n */\nexport interface ClientOptions {\n /** Base URL for API requests (defaults to relative paths) */\n baseUrl?: string;\n}\n\n/**\n * Checks if a string is null, undefined, or empty after trimming\n */\nfunction isEmptyString(value: string | null | undefined): boolean {\n return value == null || value.trim().length === 0;\n}\n\n/**\n * Creates a new public agent conversation\n *\n * @param agentId - The agent ID\n * @param options - Optional client configuration\n * @returns Promise resolving to conversation ID\n * @throws Error if conversation creation fails\n *\n * @example\n * ```typescript\n * const { conversationId } = await createConversation('agent-456');\n * ```\n */\nexport async function createConversation(\n agentId: string,\n options?: ClientOptions,\n): Promise {\n if (isEmptyString(agentId)) {\n throw new Error('Agent ID cannot be empty');\n }\n\n const baseUrl = options?.baseUrl ?? '';\n const url = `${baseUrl}/api/taskade/agents/${encodeURIComponent(agentId)}/public-conversations`;\n\n const response = await fetch(url, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n },\n });\n\n // Read response body once\n const contentType = response.headers.get('content-type') || '';\n const responseText = await response.text().catch(() => '');\n\n if (!response.ok) {\n throw new Error(\n `Failed to create conversation: ${response.status} ${responseText || 'Unknown error'}`,\n );\n }\n\n if (!contentType.includes('application/json')) {\n throw new Error(\n `Invalid response format: expected JSON, got ${contentType}. Response: ${responseText.substring(\n 0,\n 100,\n )}`,\n );\n }\n\n try {\n const data = JSON.parse(responseText);\n return data as CreateConversationResponse;\n } catch (err) {\n throw new Error(\n `Failed to parse JSON response: ${\n err instanceof Error ? err.message : 'Unknown error'\n }. Response: ${responseText.substring(0, 200)}`,\n );\n }\n}\n\n/**\n * Sends a message to an existing conversation\n *\n * @param agentId - The agent ID\n * @param conversationId - The conversation ID\n * @param text - The message text to send\n * @param options - Optional client configuration\n * @returns Promise resolving when message is sent\n * @throws Error if message sending fails (e.g., conversation not idle, conversation ended)\n *\n * @example\n * ```typescript\n * await sendMessage('agent-456', 'convo-789', 'Hello!');\n * ```\n */\nexport async function sendMessage(\n agentId: string,\n conversationId: string,\n text: string,\n options?: ClientOptions,\n): Promise {\n if (isEmptyString(agentId)) {\n throw new Error('Agent ID cannot be empty');\n }\n\n if (isEmptyString(conversationId)) {\n throw new Error('Conversation ID cannot be empty');\n }\n\n const trimmedText = text.trim();\n if (isEmptyString(trimmedText)) {\n throw new Error('Message text cannot be empty');\n }\n\n const baseUrl = options?.baseUrl ?? '';\n const url = `${baseUrl}/api/taskade/agents/${encodeURIComponent(\n agentId,\n )}/public-conversations/${encodeURIComponent(conversationId)}/messages`;\n\n const response = await fetch(url, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify({ text: trimmedText }),\n });\n\n // Read response body once\n const contentType = response.headers.get('content-type') || '';\n const responseText = await response.text().catch(() => '');\n\n if (!response.ok) {\n // Parse error message if available\n let errorMessage = `Failed to send message: ${response.status}`;\n try {\n const errorData = JSON.parse(responseText);\n if (errorData.message) {\n errorMessage = errorData.message;\n }\n } catch {\n // Use default error message with response text\n if (responseText) {\n errorMessage = `${errorMessage}: ${responseText.substring(0, 100)}`;\n }\n }\n throw new Error(errorMessage);\n }\n\n if (!contentType.includes('application/json')) {\n throw new Error(\n `Invalid response format: expected JSON, got ${contentType}. Response: ${responseText.substring(\n 0,\n 100,\n )}`,\n );\n }\n\n try {\n const data = JSON.parse(responseText);\n return data as SendMessageResponse;\n } catch (err) {\n throw new Error(\n `Failed to parse JSON response: ${\n err instanceof Error ? err.message : 'Unknown error'\n }. Response: ${responseText.substring(0, 200)}`,\n );\n }\n}\n" - } - }, - "stream.ts": { - "file": { - "contents": "import type {\n ErrorEvent,\n FinishEvent,\n MessageState,\n StreamEvent,\n StreamEventHandler,\n StreamOptions,\n TextDeltaEvent,\n} from './types';\nimport { StreamEventSchema } from './types';\n\n/**\n * Event emitter interface for stream events\n */\ntype EventMap = {\n event: StreamEventHandler;\n 'text-delta': (event: TextDeltaEvent) => void;\n finish: (event: FinishEvent) => void;\n error: (event: ErrorEvent) => void;\n open: () => void;\n close: () => void;\n};\n\n/**\n * Manages SSE connection for agent conversation streaming\n *\n * Handles connection lifecycle, event parsing, and message state accumulation.\n * The stream stays open permanently and handles all messages in the conversation.\n *\n * **Memory Management**: The `messageStates` Map accumulates messages throughout the conversation.\n * For long-running conversations, call `clearMessages()` when switching conversations\n * or create a new stream instance for new conversations.\n *\n * @example\n * ```typescript\n * const stream = new AgentChatStream('agent-456', 'convo-789');\n * stream.on('text-delta', ({ id, delta }) => {\n * // Append delta to message content\n * });\n * stream.connect();\n * ```\n */\nexport class AgentChatStream {\n private agentId: string;\n private conversationId: string;\n private options: Required;\n private eventSource: EventSource | null = null;\n private listeners: Map> = new Map();\n private reconnectTimeout: ReturnType | null = null;\n private messageStates: Map = new Map();\n private currentMessageId: string | null = null;\n private isConnecting = false;\n\n constructor(agentId: string, conversationId: string, options?: StreamOptions) {\n this.agentId = agentId;\n this.conversationId = conversationId;\n this.options = {\n baseUrl: options?.baseUrl ?? '',\n autoReconnect: options?.autoReconnect ?? true,\n reconnectDelay: options?.reconnectDelay ?? 1000,\n onError:\n options?.onError ??\n ((error: Error) => {\n // Log errors by default to help with debugging\n // In production, consumers should provide their own error handler\n console.error('[AgentChatStream] Unhandled error:', error);\n }),\n };\n }\n\n /**\n * Clear all accumulated message states\n * Useful when switching conversations or resetting the stream state\n */\n clearMessages(): void {\n this.messageStates.clear();\n this.currentMessageId = null;\n }\n\n /**\n * Update the conversation ID for this stream\n * Useful when switching to a new conversation while keeping stream open\n * Automatically clears message states when switching conversations\n */\n setConversationId(conversationId: string): void {\n if (this.conversationId === conversationId) {\n return;\n }\n // Track both connected and connecting states to handle race conditions\n const wasConnected = this.isConnected;\n const wasConnecting = this.isConnecting;\n this.disconnect();\n this.conversationId = conversationId;\n // Clear message state when switching conversations\n this.clearMessages();\n // Reconnect if we were connected or in the process of connecting\n if (wasConnected || wasConnecting) {\n this.connect();\n }\n }\n\n /**\n * Opens SSE connection to stream endpoint\n * Stream stays open permanently - never close after first response\n */\n connect(): void {\n // Check isConnecting first since it's set synchronously before any async work\n // This prevents race conditions where connect() is called multiple times\n // before eventSource is assigned\n if (this.isConnecting) {\n return; // Already connecting\n }\n\n if (this.eventSource?.readyState === EventSource.OPEN) {\n return; // Already connected\n }\n\n this.isConnecting = true;\n this.disconnect(); // Clean up any existing connection\n\n const baseUrl = this.options.baseUrl;\n const url = `${baseUrl}/api/taskade/agents/${encodeURIComponent(\n this.agentId,\n )}/public-conversations/${encodeURIComponent(this.conversationId)}/stream`;\n\n // Validate required parameters\n if (!this.agentId || !this.conversationId) {\n const error = new Error(\n `Missing required parameters: agentId=${this.agentId || 'null'}, conversationId=${\n this.conversationId || 'null'\n }`,\n );\n this.options.onError(error);\n this.emit('error', {\n type: 'error',\n errorText: error.message,\n });\n this.isConnecting = false;\n return;\n }\n\n try {\n this.eventSource = new EventSource(url);\n\n this.eventSource.onopen = () => {\n this.isConnecting = false;\n this.emit('open');\n };\n\n this.eventSource.onmessage = (e) => {\n try {\n const data = JSON.parse(e.data);\n const event = StreamEventSchema.parse(data);\n this.handleEvent(event);\n } catch (error) {\n const parseError = error instanceof Error ? error : new Error('Failed to parse event');\n this.options.onError(parseError);\n this.emit('error', {\n type: 'error',\n errorText: `Parse error: ${parseError.message}`,\n });\n }\n };\n\n this.eventSource.onerror = () => {\n this.isConnecting = false;\n\n // EventSource doesn't provide detailed error info, but we can check readyState\n if (this.eventSource?.readyState === EventSource.CLOSED) {\n this.emit('close');\n\n // Auto-reconnect if enabled\n if (this.options.autoReconnect) {\n this.scheduleReconnect();\n }\n } else if (this.eventSource?.readyState === EventSource.CONNECTING) {\n // Still connecting, might be a temporary issue\n // Don't emit error yet, wait for connection to complete or fail\n } else {\n // Connection error\n const error = new Error('EventSource connection error');\n this.options.onError(error);\n this.emit('error', {\n type: 'error',\n errorText: 'Stream connection error',\n });\n }\n };\n } catch (error) {\n // EventSource constructor threw an error (e.g., invalid URL)\n this.isConnecting = false;\n const constructorError =\n error instanceof Error ? error : new Error('Failed to create EventSource');\n this.options.onError(constructorError);\n this.emit('error', {\n type: 'error',\n errorText: `Failed to create connection: ${constructorError.message}`,\n });\n }\n }\n\n /**\n * Closes SSE connection\n */\n disconnect(): void {\n if (this.reconnectTimeout) {\n clearTimeout(this.reconnectTimeout);\n this.reconnectTimeout = null;\n }\n\n if (this.eventSource) {\n this.eventSource.close();\n this.eventSource = null;\n }\n\n this.isConnecting = false;\n }\n\n /**\n * Subscribe to stream events\n *\n * @param event - Event type to listen for\n * @param handler - Callback function\n * @returns Unsubscribe function\n */\n on(event: K, handler: EventMap[K]): () => void {\n if (!this.listeners.has(event)) {\n this.listeners.set(event, new Set());\n }\n this.listeners.get(event)!.add(handler);\n\n // Return unsubscribe function\n return () => {\n this.listeners.get(event)?.delete(handler);\n };\n }\n\n /**\n * Unsubscribe from stream events\n */\n off(event: K, handler: EventMap[K]): void {\n this.listeners.get(event)?.delete(handler);\n }\n\n /**\n * Get current connection state\n */\n get isConnected(): boolean {\n // Check readyState directly - more reliable across browsers\n return this.eventSource?.readyState === EventSource.OPEN;\n }\n\n /**\n * Get accumulated message states\n */\n get messages(): Map {\n return new Map(this.messageStates);\n }\n\n /**\n * Get message state by ID\n */\n getMessage(id: string): MessageState | undefined {\n return this.messageStates.get(id);\n }\n\n /**\n * Handle incoming SSE event\n */\n private handleEvent(event: StreamEvent): void {\n // Emit generic event\n this.emit('event', event);\n\n switch (event.type) {\n case 'start':\n this.currentMessageId = event.messageId;\n // Always create/update message on start to ensure it exists\n if (!this.messageStates.has(event.messageId)) {\n this.messageStates.set(event.messageId, {\n id: event.messageId,\n content: '',\n isComplete: false,\n role: 'assistant',\n });\n }\n break;\n\n case 'text-start':\n // event.id is a text segment ID, but text belongs to the current message (from 'start' event)\n // Ensure the current message exists\n if (this.currentMessageId) {\n if (!this.messageStates.has(this.currentMessageId)) {\n this.messageStates.set(this.currentMessageId, {\n id: this.currentMessageId,\n content: '',\n isComplete: false,\n role: 'assistant',\n });\n }\n }\n break;\n\n case 'text-delta': {\n // event.id is a text segment ID, but text belongs to the current message (from 'start' event)\n // Append delta to the current message's content\n if (this.currentMessageId) {\n const existingState = this.messageStates.get(this.currentMessageId);\n if (existingState) {\n // Create new object with updated content to ensure change detection\n const updatedState: MessageState = {\n ...existingState,\n content: existingState.content + event.delta,\n };\n this.messageStates.set(this.currentMessageId, updatedState);\n } else {\n // Create message if it doesn't exist (shouldn't happen, but handle gracefully)\n this.messageStates.set(this.currentMessageId, {\n id: this.currentMessageId,\n content: event.delta,\n isComplete: false,\n role: 'assistant',\n });\n }\n }\n this.emit('text-delta', event);\n break;\n }\n\n case 'text-end': {\n // event.id is a text segment ID, but text belongs to the current message (from 'start' event)\n // Text part is complete, but don't mark entire message as complete (message completes on 'finish' event)\n break;\n }\n\n case 'tool-input-start': {\n // Use messageId from event if available (for child segments), otherwise fall back to currentMessageId\n const messageId =\n 'messageId' in event && event.messageId ? event.messageId : this.currentMessageId;\n const toolMessage = messageId ? this.messageStates.get(messageId) : undefined;\n if (toolMessage) {\n const updatedState: MessageState = {\n ...toolMessage,\n toolCalls: [\n ...(toolMessage.toolCalls ?? []),\n {\n toolCallId: event.toolCallId,\n toolName: event.toolName,\n isComplete: false,\n },\n ],\n };\n this.messageStates.set(toolMessage.id, updatedState);\n }\n break;\n }\n\n case 'tool-input-delta': {\n // Use messageId from event if available (for child segments), otherwise fall back to currentMessageId\n const messageId =\n 'messageId' in event && event.messageId ? event.messageId : this.currentMessageId;\n const toolMessage = messageId ? this.messageStates.get(messageId) : undefined;\n if (toolMessage?.toolCalls) {\n const toolCallIndex = toolMessage.toolCalls.findIndex(\n (tc) => tc.toolCallId === event.toolCallId,\n );\n if (toolCallIndex !== -1) {\n const existingToolCall = toolMessage.toolCalls[toolCallIndex];\n // Accumulate inputTextDelta into input (stored as string during streaming)\n // When tool-input-available arrives, it will replace this with the parsed object\n const currentInput =\n typeof existingToolCall.input === 'string' ? existingToolCall.input : '';\n const updatedState: MessageState = {\n ...toolMessage,\n toolCalls: toolMessage.toolCalls.map((tc, index) =>\n index === toolCallIndex\n ? { ...tc, input: currentInput + event.inputTextDelta }\n : tc,\n ),\n };\n this.messageStates.set(toolMessage.id, updatedState);\n }\n }\n break;\n }\n\n case 'tool-input-available': {\n // Use messageId from event if available (for child segments), otherwise fall back to currentMessageId\n const messageId =\n 'messageId' in event && event.messageId ? event.messageId : this.currentMessageId;\n const toolMessage = messageId ? this.messageStates.get(messageId) : undefined;\n if (toolMessage?.toolCalls) {\n const toolCallIndex = toolMessage.toolCalls.findIndex(\n (tc) => tc.toolCallId === event.toolCallId,\n );\n if (toolCallIndex !== -1) {\n const updatedState: MessageState = {\n ...toolMessage,\n toolCalls: toolMessage.toolCalls.map((tc, index) =>\n index === toolCallIndex ? { ...tc, input: event.input } : tc,\n ),\n };\n this.messageStates.set(toolMessage.id, updatedState);\n }\n }\n break;\n }\n\n case 'tool-output-available': {\n // Use messageId from event if available (for child segments), otherwise fall back to currentMessageId\n const messageId =\n 'messageId' in event && event.messageId ? event.messageId : this.currentMessageId;\n const toolMessage = messageId ? this.messageStates.get(messageId) : undefined;\n if (toolMessage?.toolCalls) {\n const toolCallIndex = toolMessage.toolCalls.findIndex(\n (tc) => tc.toolCallId === event.toolCallId,\n );\n if (toolCallIndex !== -1) {\n const updatedState: MessageState = {\n ...toolMessage,\n toolCalls: toolMessage.toolCalls.map((tc, index) =>\n index === toolCallIndex ? { ...tc, output: event.output } : tc,\n ),\n };\n this.messageStates.set(toolMessage.id, updatedState);\n }\n }\n break;\n }\n\n case 'tool-call-end': {\n // Use messageId from event if available (for child segments), otherwise fall back to currentMessageId\n const messageId =\n 'messageId' in event && event.messageId ? event.messageId : this.currentMessageId;\n const toolMessage = messageId ? this.messageStates.get(messageId) : undefined;\n if (toolMessage?.toolCalls) {\n const toolCallIndex = toolMessage.toolCalls.findIndex(\n (tc) => tc.toolCallId === event.toolCallId,\n );\n if (toolCallIndex !== -1) {\n const updatedState: MessageState = {\n ...toolMessage,\n toolCalls: toolMessage.toolCalls.map((tc, index) =>\n index === toolCallIndex ? { ...tc, isComplete: true } : tc,\n ),\n };\n this.messageStates.set(toolMessage.id, updatedState);\n }\n }\n break;\n }\n\n case 'finish':\n if (this.currentMessageId) {\n const finishedMessage = this.messageStates.get(this.currentMessageId);\n if (finishedMessage) {\n const updatedState: MessageState = {\n ...finishedMessage,\n isComplete: true,\n };\n this.messageStates.set(this.currentMessageId, updatedState);\n }\n }\n this.emit('finish', event);\n break;\n\n case 'error':\n this.emit('error', event);\n this.options.onError(new Error(event.errorText));\n break;\n }\n }\n\n /**\n * Emit event to all listeners\n */\n private emit(event: K, ...args: Parameters): void {\n const handlers = this.listeners.get(event);\n if (handlers) {\n handlers.forEach((handler) => {\n try {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n (handler as (...args: any[]) => void)(...args);\n } catch (error) {\n this.options.onError(error instanceof Error ? error : new Error('Handler error'));\n }\n });\n }\n }\n\n /**\n * Schedule reconnection attempt\n */\n private scheduleReconnect(): void {\n if (this.reconnectTimeout) {\n return; // Already scheduled\n }\n\n this.reconnectTimeout = setTimeout(() => {\n this.reconnectTimeout = null;\n if (this.options.autoReconnect) {\n this.connect();\n }\n }, this.options.reconnectDelay);\n }\n}\n" - } - } - } - }, - "genesis.tsx": { - "file": { - "contents": "import type {\n LogFunction,\n LoggerEntryInput,\n SpaceAppLogLifecycleData,\n} from '@taskade/parade-shared';\nimport * as React from 'react';\nimport { ErrorBoundary as ReactErrorBoundary } from 'react-error-boundary';\n\n// ---------------------------------------------------------------------------\n// Lifecycle logger (injected by Director Preview via window global)\n// ---------------------------------------------------------------------------\n\ninterface GenesisLogger {\n log: LogFunction;\n}\ndeclare global {\n interface Window {\n __TASKADE_APP_LIFECYCLE_LOGGER__?: GenesisLogger;\n }\n}\n\n/**\n * Returns the Taskade lifecycle logger if running inside Director Preview.\n * The logger is injected into `window.__TASKADE_APP_LIFECYCLE_LOGGER__` by\n * the preview inject script. Returns `null` in published mode.\n */\nexport function getGenesisAppLifecycleLogger(): GenesisLogger | null {\n if (typeof window === 'undefined') {\n return null;\n }\n return window.__TASKADE_APP_LIFECYCLE_LOGGER__ ?? null;\n}\n\n/**\n * Report a runtime error to the parent frame via postMessage.\n * In preview mode this triggers the \"Fix with AI\" popup.\n * In published mode this is a no-op (logger not available).\n */\nexport function reportGenesisError(\n code: 'error.boundary',\n error: unknown,\n componentStack?: string | null,\n) {\n getGenesisAppLifecycleLogger()?.log({\n level: 'error',\n message: 'Runtime Error',\n data: {\n code,\n message: error instanceof Error ? error.message : String(error),\n stack: [error instanceof Error ? error.stack : undefined, componentStack]\n .filter(Boolean)\n .join('\\n'),\n } satisfies SpaceAppLogLifecycleData,\n } satisfies LoggerEntryInput);\n}\n\n// ---------------------------------------------------------------------------\n// Error boundary fallback UI (inline styles for resilience)\n// ---------------------------------------------------------------------------\n\n/** Fallback UI shown when the ErrorBoundary catches a render error. */\nfunction ErrorFallback({ error }: { error: Error }) {\n return (\n \n

Something went wrong

\n

\n The app encountered an error. Try refreshing the page.\n

\n \n {String(error)}\n \n \n );\n}\n\n// ---------------------------------------------------------------------------\n// GenesisRoot\n// ---------------------------------------------------------------------------\n\n/**\n * Genesis root wrapper — must be provided by the base template, not LLM-generated.\n *\n * Wraps children in an ErrorBoundary that:\n * 1. Catches render-phase errors and shows {@link ErrorFallback} instead of a blank page.\n * 2. Reports the error to the parent frame via {@link reportGenesisError} so\n * Director Preview can show the \"Fix with AI\" popup.\n */\nexport function GenesisRoot({ children }: { children: React.ReactNode }) {\n return (\n {\n console.error('[Genesis] Uncaught render error:', error, info);\n reportGenesisError('error.boundary', error, info.componentStack);\n }}\n >\n {children}\n \n );\n}\n" - } - }, - "theme-bridge.ts": { - "file": { - "contents": "/**\n * Listen for TASKADE_THEME_UPDATE messages from the parent Taskade 01KP2NYSV3FEZ09WSYFBGKCM6C\n * and apply CSS variable overrides to the document root in real-time.\n * Also responds to TASKADE_THEME_READ requests with current CSS variable values.\n *\n * Theme overrides are injected via a