diff --git a/apps/sim/app/api/billing/update-cost/route.ts b/apps/sim/app/api/billing/update-cost/route.ts index 418691a97ba..0b8a0048e66 100644 --- a/apps/sim/app/api/billing/update-cost/route.ts +++ b/apps/sim/app/api/billing/update-cost/route.ts @@ -8,22 +8,17 @@ import { checkInternalApiKey } from '@/lib/copilot/utils' import { isBillingEnabled } from '@/lib/environment' import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' -import { calculateCost } from '@/providers/utils' const logger = createLogger('BillingUpdateCostAPI') const UpdateCostSchema = z.object({ userId: z.string().min(1, 'User ID is required'), - input: z.number().min(0, 'Input tokens must be a non-negative number'), - output: z.number().min(0, 'Output tokens must be a non-negative number'), - model: z.string().min(1, 'Model is required'), - inputMultiplier: z.number().min(0), - outputMultiplier: z.number().min(0), + cost: z.number().min(0, 'Cost must be a non-negative number'), }) /** * POST /api/billing/update-cost - * Update user cost based on token usage with internal API key auth + * Update user cost with a pre-calculated cost value (internal API key auth required) */ export async function POST(req: NextRequest) { const requestId = generateRequestId() @@ -77,45 +72,13 @@ export async function POST(req: NextRequest) { ) } - const { userId, input, output, model, inputMultiplier, outputMultiplier } = validation.data + const { userId, cost } = validation.data logger.info(`[${requestId}] Processing cost update`, { userId, - input, - output, - model, - inputMultiplier, - outputMultiplier, + cost, }) - const finalPromptTokens = input - const finalCompletionTokens = output - const totalTokens = input + output - - // Calculate cost using provided multiplier (required) - const costResult = calculateCost( - model, - finalPromptTokens, - finalCompletionTokens, - false, - inputMultiplier, - outputMultiplier - ) - - logger.info(`[${requestId}] Cost calculation result`, { - userId, - model, - promptTokens: finalPromptTokens, - completionTokens: finalCompletionTokens, - totalTokens: totalTokens, - inputMultiplier, - outputMultiplier, - costResult, - }) - - // Follow the exact same logic as ExecutionLogger.updateUserStats but with direct userId - const costToStore = costResult.total // No additional multiplier needed since calculateCost already applied it - // Check if user stats record exists (same as ExecutionLogger) const userStatsRecords = await db.select().from(userStats).where(eq(userStats.userId, userId)) @@ -128,16 +91,13 @@ export async function POST(req: NextRequest) { ) return NextResponse.json({ error: 'User stats record not found' }, { status: 500 }) } - // Update existing user stats record (same logic as ExecutionLogger) + // Update existing user stats record const updateFields = { - totalTokensUsed: sql`total_tokens_used + ${totalTokens}`, - totalCost: sql`total_cost + ${costToStore}`, - currentPeriodCost: sql`current_period_cost + ${costToStore}`, + totalCost: sql`total_cost + ${cost}`, + currentPeriodCost: sql`current_period_cost + ${cost}`, // Copilot usage tracking increments - totalCopilotCost: sql`total_copilot_cost + ${costToStore}`, - totalCopilotTokens: sql`total_copilot_tokens + ${totalTokens}`, + totalCopilotCost: sql`total_copilot_cost + ${cost}`, totalCopilotCalls: sql`total_copilot_calls + 1`, - totalApiCalls: sql`total_api_calls`, lastActive: new Date(), } @@ -145,8 +105,7 @@ export async function POST(req: NextRequest) { logger.info(`[${requestId}] Updated user stats record`, { userId, - addedCost: costToStore, - addedTokens: totalTokens, + addedCost: cost, }) // Check if user has hit overage threshold and bill incrementally @@ -157,29 +116,14 @@ export async function POST(req: NextRequest) { logger.info(`[${requestId}] Cost update completed successfully`, { userId, duration, - cost: costResult.total, - totalTokens, + cost, }) return NextResponse.json({ success: true, data: { userId, - input, - output, - totalTokens, - model, - cost: { - input: costResult.input, - output: costResult.output, - total: costResult.total, - }, - tokenBreakdown: { - prompt: finalPromptTokens, - completion: finalCompletionTokens, - total: totalTokens, - }, - pricing: costResult.pricing, + cost, processedAt: new Date().toISOString(), requestId, }, diff --git a/apps/sim/app/api/copilot/chat/delete/route.ts b/apps/sim/app/api/copilot/chat/delete/route.ts new file mode 100644 index 00000000000..203a2b5c6d8 --- /dev/null +++ b/apps/sim/app/api/copilot/chat/delete/route.ts @@ -0,0 +1,35 @@ +import { db } from '@sim/db' +import { copilotChats } from '@sim/db/schema' +import { eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { getSession } from '@/lib/auth' +import { createLogger } from '@/lib/logs/console/logger' + +const logger = createLogger('DeleteChatAPI') + +const DeleteChatSchema = z.object({ + chatId: z.string(), +}) + +export async function DELETE(request: NextRequest) { + try { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }) + } + + const body = await request.json() + const parsed = DeleteChatSchema.parse(body) + + // Delete the chat + await db.delete(copilotChats).where(eq(copilotChats.id, parsed.chatId)) + + logger.info('Chat deleted', { chatId: parsed.chatId }) + + return NextResponse.json({ success: true }) + } catch (error) { + logger.error('Error deleting chat:', error) + return NextResponse.json({ success: false, error: 'Failed to delete chat' }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/copilot/chat/route.test.ts b/apps/sim/app/api/copilot/chat/route.test.ts index f2ae1103b33..d2c00d47be7 100644 --- a/apps/sim/app/api/copilot/chat/route.test.ts +++ b/apps/sim/app/api/copilot/chat/route.test.ts @@ -214,18 +214,7 @@ describe('Copilot Chat API Route', () => { 'x-api-key': 'test-sim-agent-key', }, body: JSON.stringify({ - messages: [ - { - role: 'user', - content: 'Hello', - }, - ], - chatMessages: [ - { - role: 'user', - content: 'Hello', - }, - ], + message: 'Hello', workflowId: 'workflow-123', userId: 'user-123', stream: true, @@ -233,7 +222,7 @@ describe('Copilot Chat API Route', () => { model: 'claude-4.5-sonnet', mode: 'agent', messageId: 'mock-uuid-1234-5678', - version: '1.0.1', + version: '1.0.2', chatId: 'chat-123', }), }) @@ -286,16 +275,7 @@ describe('Copilot Chat API Route', () => { 'http://localhost:8000/api/chat-completion-streaming', expect.objectContaining({ body: JSON.stringify({ - messages: [ - { role: 'user', content: 'Previous message' }, - { role: 'assistant', content: 'Previous response' }, - { role: 'user', content: 'New message' }, - ], - chatMessages: [ - { role: 'user', content: 'Previous message' }, - { role: 'assistant', content: 'Previous response' }, - { role: 'user', content: 'New message' }, - ], + message: 'New message', workflowId: 'workflow-123', userId: 'user-123', stream: true, @@ -303,7 +283,7 @@ describe('Copilot Chat API Route', () => { model: 'claude-4.5-sonnet', mode: 'agent', messageId: 'mock-uuid-1234-5678', - version: '1.0.1', + version: '1.0.2', chatId: 'chat-123', }), }) @@ -341,19 +321,12 @@ describe('Copilot Chat API Route', () => { const { POST } = await import('@/app/api/copilot/chat/route') await POST(req) - // Verify implicit feedback was included as system message + // Verify implicit feedback was included expect(global.fetch).toHaveBeenCalledWith( 'http://localhost:8000/api/chat-completion-streaming', expect.objectContaining({ body: JSON.stringify({ - messages: [ - { role: 'system', content: 'User seems confused about the workflow' }, - { role: 'user', content: 'Hello' }, - ], - chatMessages: [ - { role: 'system', content: 'User seems confused about the workflow' }, - { role: 'user', content: 'Hello' }, - ], + message: 'Hello', workflowId: 'workflow-123', userId: 'user-123', stream: true, @@ -361,7 +334,7 @@ describe('Copilot Chat API Route', () => { model: 'claude-4.5-sonnet', mode: 'agent', messageId: 'mock-uuid-1234-5678', - version: '1.0.1', + version: '1.0.2', chatId: 'chat-123', }), }) @@ -444,8 +417,7 @@ describe('Copilot Chat API Route', () => { 'http://localhost:8000/api/chat-completion-streaming', expect.objectContaining({ body: JSON.stringify({ - messages: [{ role: 'user', content: 'What is this workflow?' }], - chatMessages: [{ role: 'user', content: 'What is this workflow?' }], + message: 'What is this workflow?', workflowId: 'workflow-123', userId: 'user-123', stream: true, @@ -453,7 +425,7 @@ describe('Copilot Chat API Route', () => { model: 'claude-4.5-sonnet', mode: 'ask', messageId: 'mock-uuid-1234-5678', - version: '1.0.1', + version: '1.0.2', chatId: 'chat-123', }), }) diff --git a/apps/sim/app/api/copilot/chat/route.ts b/apps/sim/app/api/copilot/chat/route.ts index 558f61a94cb..6617566a7d0 100644 --- a/apps/sim/app/api/copilot/chat/route.ts +++ b/apps/sim/app/api/copilot/chat/route.ts @@ -48,6 +48,7 @@ const ChatMessageSchema = z.object({ 'gpt-4.1', 'o3', 'claude-4-sonnet', + 'claude-4.5-haiku', 'claude-4.5-sonnet', 'claude-4.1-opus', ]) @@ -356,18 +357,12 @@ export async function POST(req: NextRequest) { } } - // Determine provider and conversationId to use for this request + // Determine conversationId to use for this request const effectiveConversationId = (currentChat?.conversationId as string | undefined) || conversationId - // If we have a conversationId, only send the most recent user message; else send full history - const latestUserMessage = - [...messages].reverse().find((m) => m?.role === 'user') || messages[messages.length - 1] - const messagesForAgent = effectiveConversationId ? [latestUserMessage] : messages - const requestPayload = { - messages: messagesForAgent, - chatMessages: messages, // Full unfiltered messages array + message: message, // Just send the current user message text workflowId, userId: authenticatedUserId, stream: stream, @@ -382,14 +377,16 @@ export async function POST(req: NextRequest) { ...(session?.user?.name && { userName: session.user.name }), ...(agentContexts.length > 0 && { context: agentContexts }), ...(actualChatId ? { chatId: actualChatId } : {}), + ...(processedFileContents.length > 0 && { fileAttachments: processedFileContents }), } try { - logger.info(`[${tracker.requestId}] About to call Sim Agent with context`, { - context: (requestPayload as any).context, - messagesCount: messagesForAgent.length, - chatMessagesCount: messages.length, + logger.info(`[${tracker.requestId}] About to call Sim Agent`, { + hasContext: agentContexts.length > 0, + contextCount: agentContexts.length, hasConversationId: !!effectiveConversationId, + hasFileAttachments: processedFileContents.length > 0, + messageLength: message.length, }) } catch {} @@ -463,8 +460,6 @@ export async function POST(req: NextRequest) { logger.debug(`[${tracker.requestId}] Sent initial chatId event to client`) } - // Note: context_usage events are forwarded from sim-agent (which has accurate token counts) - // Start title generation in parallel if needed if (actualChatId && !currentChat?.title && conversationHistory.length === 0) { generateChatTitle(message) @@ -596,7 +591,6 @@ export async function POST(req: NextRequest) { lastSafeDoneResponseId = responseIdFromDone } } - // Note: context_usage events are forwarded from sim-agent break case 'error': diff --git a/apps/sim/app/api/copilot/chat/update-title/route.ts b/apps/sim/app/api/copilot/chat/update-title/route.ts new file mode 100644 index 00000000000..bb5603a7dc0 --- /dev/null +++ b/apps/sim/app/api/copilot/chat/update-title/route.ts @@ -0,0 +1,45 @@ +import { db } from '@sim/db' +import { copilotChats } from '@sim/db/schema' +import { eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { getSession } from '@/lib/auth' +import { createLogger } from '@/lib/logs/console/logger' + +const logger = createLogger('UpdateChatTitleAPI') + +const UpdateTitleSchema = z.object({ + chatId: z.string(), + title: z.string(), +}) + +export async function POST(request: NextRequest) { + try { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }) + } + + const body = await request.json() + const parsed = UpdateTitleSchema.parse(body) + + // Update the chat title + await db + .update(copilotChats) + .set({ + title: parsed.title, + updatedAt: new Date(), + }) + .where(eq(copilotChats.id, parsed.chatId)) + + logger.info('Chat title updated', { chatId: parsed.chatId, title: parsed.title }) + + return NextResponse.json({ success: true }) + } catch (error) { + logger.error('Error updating chat title:', error) + return NextResponse.json( + { success: false, error: 'Failed to update chat title' }, + { status: 500 } + ) + } +} diff --git a/apps/sim/app/api/copilot/checkpoints/revert/route.ts b/apps/sim/app/api/copilot/checkpoints/revert/route.ts index 16162a96a22..2778e554d0b 100644 --- a/apps/sim/app/api/copilot/checkpoints/revert/route.ts +++ b/apps/sim/app/api/copilot/checkpoints/revert/route.ts @@ -118,6 +118,18 @@ export async function POST(request: NextRequest) { `[${tracker.requestId}] Successfully reverted workflow ${checkpoint.workflowId} to checkpoint ${checkpointId}` ) + // Delete the checkpoint after successfully reverting to it + try { + await db.delete(workflowCheckpoints).where(eq(workflowCheckpoints.id, checkpointId)) + logger.info(`[${tracker.requestId}] Deleted checkpoint after reverting`, { checkpointId }) + } catch (deleteError) { + logger.warn(`[${tracker.requestId}] Failed to delete checkpoint after revert`, { + checkpointId, + error: deleteError, + }) + // Don't fail the request if deletion fails - the revert was successful + } + return NextResponse.json({ success: true, workflowId: checkpoint.workflowId, diff --git a/apps/sim/app/api/copilot/context-usage/route.ts b/apps/sim/app/api/copilot/context-usage/route.ts new file mode 100644 index 00000000000..fdac63b8361 --- /dev/null +++ b/apps/sim/app/api/copilot/context-usage/route.ts @@ -0,0 +1,126 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { getSession } from '@/lib/auth' +import { getCopilotModel } from '@/lib/copilot/config' +import type { CopilotProviderConfig } from '@/lib/copilot/types' +import { env } from '@/lib/env' +import { createLogger } from '@/lib/logs/console/logger' +import { SIM_AGENT_API_URL_DEFAULT } from '@/lib/sim-agent/constants' + +const logger = createLogger('ContextUsageAPI') + +const SIM_AGENT_API_URL = env.SIM_AGENT_API_URL || SIM_AGENT_API_URL_DEFAULT + +const ContextUsageRequestSchema = z.object({ + chatId: z.string(), + model: z.string(), + workflowId: z.string(), + provider: z.any().optional(), +}) + +/** + * POST /api/copilot/context-usage + * Fetch context usage from sim-agent API + */ +export async function POST(req: NextRequest) { + try { + logger.info('[Context Usage API] Request received') + + const session = await getSession() + if (!session?.user?.id) { + logger.warn('[Context Usage API] No session/user ID') + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const body = await req.json() + logger.info('[Context Usage API] Request body', body) + + const parsed = ContextUsageRequestSchema.safeParse(body) + + if (!parsed.success) { + logger.warn('[Context Usage API] Invalid request body', parsed.error.errors) + return NextResponse.json( + { error: 'Invalid request body', details: parsed.error.errors }, + { status: 400 } + ) + } + + const { chatId, model, workflowId, provider } = parsed.data + const userId = session.user.id // Get userId from session, not from request + + logger.info('[Context Usage API] Request validated', { chatId, model, userId, workflowId }) + + // Build provider config similar to chat route + let providerConfig: CopilotProviderConfig | undefined = provider + if (!providerConfig) { + const defaults = getCopilotModel('chat') + const modelToUse = env.COPILOT_MODEL || defaults.model + const providerEnv = env.COPILOT_PROVIDER as any + + if (providerEnv) { + if (providerEnv === 'azure-openai') { + providerConfig = { + provider: 'azure-openai', + model: modelToUse, + apiKey: env.AZURE_OPENAI_API_KEY, + apiVersion: env.AZURE_OPENAI_API_VERSION, + endpoint: env.AZURE_OPENAI_ENDPOINT, + } + } else { + providerConfig = { + provider: providerEnv, + model: modelToUse, + apiKey: env.COPILOT_API_KEY, + } + } + } + } + + // Call sim-agent API + const requestPayload = { + chatId, + model, + userId, + workflowId, + ...(providerConfig ? { provider: providerConfig } : {}), + } + + logger.info('[Context Usage API] Calling sim-agent', { + url: `${SIM_AGENT_API_URL}/api/get-context-usage`, + payload: requestPayload, + }) + + const simAgentResponse = await fetch(`${SIM_AGENT_API_URL}/api/get-context-usage`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(env.COPILOT_API_KEY ? { 'x-api-key': env.COPILOT_API_KEY } : {}), + }, + body: JSON.stringify(requestPayload), + }) + + logger.info('[Context Usage API] Sim-agent response', { + status: simAgentResponse.status, + ok: simAgentResponse.ok, + }) + + if (!simAgentResponse.ok) { + const errorText = await simAgentResponse.text().catch(() => '') + logger.warn('[Context Usage API] Sim agent request failed', { + status: simAgentResponse.status, + error: errorText, + }) + return NextResponse.json( + { error: 'Failed to fetch context usage from sim-agent' }, + { status: simAgentResponse.status } + ) + } + + const data = await simAgentResponse.json() + logger.info('[Context Usage API] Sim-agent data received', data) + return NextResponse.json(data) + } catch (error) { + logger.error('Error fetching context usage:', error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/copilot/user-models/route.ts b/apps/sim/app/api/copilot/user-models/route.ts index 3e5f782fb50..a0e8c65e118 100644 --- a/apps/sim/app/api/copilot/user-models/route.ts +++ b/apps/sim/app/api/copilot/user-models/route.ts @@ -15,7 +15,8 @@ const DEFAULT_ENABLED_MODELS: Record = { 'gpt-5-medium': true, 'gpt-5-high': false, o3: true, - 'claude-4-sonnet': true, + 'claude-4-sonnet': false, + 'claude-4.5-haiku': true, 'claude-4.5-sonnet': true, 'claude-4.1-opus': true, } @@ -67,15 +68,14 @@ export async function GET(request: NextRequest) { }) } - // If no settings record exists, create one with empty object (client will use defaults) - const [created] = await db - .insert(settings) - .values({ - id: userId, - userId, - copilotEnabledModels: {}, - }) - .returning() + // If no settings record exists, create one with defaults + await db.insert(settings).values({ + id: userId, + userId, + copilotEnabledModels: DEFAULT_ENABLED_MODELS, + }) + + logger.info('Created new settings record with default models', { userId }) return NextResponse.json({ enabledModels: DEFAULT_ENABLED_MODELS, diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/markdown-renderer.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/markdown-renderer.tsx index 6cf689f6224..1852e71e25e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/markdown-renderer.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/markdown-renderer.tsx @@ -141,29 +141,29 @@ export default function CopilotMarkdownRenderer({ content }: CopilotMarkdownRend () => ({ // Paragraph p: ({ children }: React.HTMLAttributes) => ( -

+

{children}

), // Headings h1: ({ children }: React.HTMLAttributes) => ( -

+

{children}

), h2: ({ children }: React.HTMLAttributes) => ( -

+

{children}

), h3: ({ children }: React.HTMLAttributes) => ( -

+

{children}

), h4: ({ children }: React.HTMLAttributes) => ( -

+

{children}

), @@ -171,7 +171,7 @@ export default function CopilotMarkdownRenderer({ content }: CopilotMarkdownRend // Lists ul: ({ children }: React.HTMLAttributes) => (
    {children} @@ -179,7 +179,7 @@ export default function CopilotMarkdownRenderer({ content }: CopilotMarkdownRend ), ol: ({ children }: React.HTMLAttributes) => (
      {children} @@ -189,10 +189,7 @@ export default function CopilotMarkdownRenderer({ content }: CopilotMarkdownRend children, ordered, }: React.LiHTMLAttributes & { ordered?: boolean }) => ( -
    1. +
    2. {children}
    3. ), @@ -321,7 +318,7 @@ export default function CopilotMarkdownRenderer({ content }: CopilotMarkdownRend // Blockquotes blockquote: ({ children }: React.HTMLAttributes) => ( -
      +
      {children}
      ), @@ -339,7 +336,7 @@ export default function CopilotMarkdownRenderer({ content }: CopilotMarkdownRend // Tables table: ({ children }: React.TableHTMLAttributes) => (
      - +
      {children}
      @@ -380,7 +377,7 @@ export default function CopilotMarkdownRenderer({ content }: CopilotMarkdownRend ) return ( -
      +
      {content} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/thinking-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/thinking-block.tsx index 2fbfa170971..afde595f89f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/thinking-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/thinking-block.tsx @@ -79,7 +79,7 @@ export function ThinkingBlock({ }) }} className={cn( - 'mb-1 inline-flex items-center gap-1 text-gray-400 text-xs transition-colors hover:text-gray-500', + 'mb-1 inline-flex items-center gap-1 text-[11px] text-gray-400 transition-colors hover:text-gray-500', 'font-normal italic' )} type='button' @@ -96,7 +96,7 @@ export function ThinkingBlock({ {isExpanded && (
      -
      +          
                   {content}
                   {isStreaming && (
                     
      diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/copilot-message.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/copilot-message.tsx
      index 2aaf4494c1f..40c434f81ee 100644
      --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/copilot-message.tsx
      +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/copilot-message.tsx
      @@ -1,6 +1,6 @@
       'use client'
       
      -import { type FC, memo, useEffect, useMemo, useState } from 'react'
      +import { type FC, memo, useEffect, useMemo, useRef, useState } from 'react'
       import {
         Blocks,
         BookOpen,
      @@ -8,9 +8,9 @@ import {
         Box,
         Check,
         Clipboard,
      +  CornerDownLeft,
         Info,
         LibraryBig,
      -  Loader2,
         RotateCcw,
         Shapes,
         SquareChevronRight,
      @@ -26,9 +26,12 @@ import {
         SmoothStreamingText,
         StreamingIndicator,
         ThinkingBlock,
      -  WordWrap,
       } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components'
       import CopilotMarkdownRenderer from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/markdown-renderer'
      +import {
      +  UserInput,
      +  type UserInputRef,
      +} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/user-input'
       import { usePreviewStore } from '@/stores/copilot/preview-store'
       import { useCopilotStore } from '@/stores/copilot/store'
       import type { CopilotMessage as CopilotMessageType } from '@/stores/copilot/types'
      @@ -38,10 +41,23 @@ const logger = createLogger('CopilotMessage')
       interface CopilotMessageProps {
         message: CopilotMessageType
         isStreaming?: boolean
      +  panelWidth?: number
      +  isDimmed?: boolean
      +  checkpointCount?: number
      +  onEditModeChange?: (isEditing: boolean) => void
      +  onRevertModeChange?: (isReverting: boolean) => void
       }
       
       const CopilotMessage: FC = memo(
      -  ({ message, isStreaming }) => {
      +  ({
      +    message,
      +    isStreaming,
      +    panelWidth = 308,
      +    isDimmed = false,
      +    checkpointCount = 0,
      +    onEditModeChange,
      +    onRevertModeChange,
      +  }) => {
           const isUser = message.role === 'user'
           const isAssistant = message.role === 'assistant'
           const [showCopySuccess, setShowCopySuccess] = useState(false)
      @@ -49,6 +65,20 @@ const CopilotMessage: FC = memo(
           const [showDownvoteSuccess, setShowDownvoteSuccess] = useState(false)
           const [showRestoreConfirmation, setShowRestoreConfirmation] = useState(false)
           const [showAllContexts, setShowAllContexts] = useState(false)
      +    const [isEditMode, setIsEditMode] = useState(false)
      +    const [isExpanded, setIsExpanded] = useState(false)
      +    const [editedContent, setEditedContent] = useState(message.content)
      +    const [isHoveringMessage, setIsHoveringMessage] = useState(false)
      +    const editContainerRef = useRef(null)
      +    const messageContentRef = useRef(null)
      +    const userInputRef = useRef(null)
      +    const [needsExpansion, setNeedsExpansion] = useState(false)
      +    const [showCheckpointDiscardModal, setShowCheckpointDiscardModal] = useState(false)
      +    const pendingEditRef = useRef<{
      +      message: string
      +      fileAttachments?: any[]
      +      contexts?: any[]
      +    } | null>(null)
       
           // Get checkpoint functionality from copilot store
           const {
      @@ -58,6 +88,11 @@ const CopilotMessage: FC = memo(
             currentChat,
             messages,
             workflowId,
      +      sendMessage,
      +      isSendingMessage,
      +      abortMessage,
      +      mode,
      +      setMode,
           } = useCopilotStore()
       
           // Get preview store for accessing workflow YAML after rejection
      @@ -68,7 +103,15 @@ const CopilotMessage: FC = memo(
       
           // Get checkpoints for this message if it's a user message
           const messageCheckpoints = isUser ? allMessageCheckpoints[message.id] || [] : []
      -    const hasCheckpoints = messageCheckpoints.length > 0
      +    // Only consider it as having checkpoints if there's at least one valid checkpoint with an id
      +    const hasCheckpoints = messageCheckpoints.length > 0 && messageCheckpoints.some((cp) => cp?.id)
      +
      +    // Check if this is the last user message (for showing abort button)
      +    const isLastUserMessage = useMemo(() => {
      +      if (!isUser) return false
      +      const userMessages = messages.filter((m) => m.role === 'user')
      +      return userMessages.length > 0 && userMessages[userMessages.length - 1]?.id === message.id
      +    }, [isUser, messages, message.id])
       
           const handleCopyContent = () => {
             // Copy clean text content
      @@ -238,6 +281,7 @@ const CopilotMessage: FC = memo(
       
           const handleRevertToCheckpoint = () => {
             setShowRestoreConfirmation(true)
      +      onRevertModeChange?.(true)
           }
       
           const handleConfirmRevert = async () => {
      @@ -246,16 +290,194 @@ const CopilotMessage: FC = memo(
               const latestCheckpoint = messageCheckpoints[0]
               try {
                 await revertToCheckpoint(latestCheckpoint.id)
      +
      +          // Remove the used checkpoint from the store
      +          const { messageCheckpoints: currentCheckpoints } = useCopilotStore.getState()
      +          const updatedCheckpoints = {
      +            ...currentCheckpoints,
      +            [message.id]: messageCheckpoints.slice(1), // Remove the first (used) checkpoint
      +          }
      +          useCopilotStore.setState({ messageCheckpoints: updatedCheckpoints })
      +
      +          // Truncate all messages after this point
      +          const currentMessages = messages
      +          const revertIndex = currentMessages.findIndex((m) => m.id === message.id)
      +          if (revertIndex !== -1) {
      +            const truncatedMessages = currentMessages.slice(0, revertIndex + 1)
      +            useCopilotStore.setState({ messages: truncatedMessages })
      +
      +            // Update DB to remove messages after this point
      +            if (currentChat?.id) {
      +              try {
      +                await fetch('/api/copilot/chat/update-messages', {
      +                  method: 'POST',
      +                  headers: { 'Content-Type': 'application/json' },
      +                  body: JSON.stringify({
      +                    chatId: currentChat.id,
      +                    messages: truncatedMessages.map((m) => ({
      +                      id: m.id,
      +                      role: m.role,
      +                      content: m.content,
      +                      timestamp: m.timestamp,
      +                      ...(m.contentBlocks && { contentBlocks: m.contentBlocks }),
      +                      ...(m.fileAttachments && { fileAttachments: m.fileAttachments }),
      +                      ...((m as any).contexts && { contexts: (m as any).contexts }),
      +                    })),
      +                  }),
      +                })
      +              } catch (error) {
      +                logger.error('Failed to update messages in DB after revert:', error)
      +              }
      +            }
      +          }
      +
                 setShowRestoreConfirmation(false)
      +          onRevertModeChange?.(false)
      +
      +          // Enter edit mode after reverting
      +          setIsEditMode(true)
      +          onEditModeChange?.(true)
      +
      +          // Focus the input after render
      +          setTimeout(() => {
      +            userInputRef.current?.focus()
      +          }, 100)
      +
      +          logger.info('Checkpoint reverted and removed from message', {
      +            messageId: message.id,
      +            checkpointId: latestCheckpoint.id,
      +          })
               } catch (error) {
                 logger.error('Failed to revert to checkpoint:', error)
                 setShowRestoreConfirmation(false)
      +          onRevertModeChange?.(false)
               }
             }
           }
       
           const handleCancelRevert = () => {
             setShowRestoreConfirmation(false)
      +      onRevertModeChange?.(false)
      +    }
      +
      +    const handleEditMessage = () => {
      +      setIsEditMode(true)
      +      setIsExpanded(false)
      +      setEditedContent(message.content)
      +      setShowRestoreConfirmation(false) // Dismiss any open confirmation popup
      +      onRevertModeChange?.(false) // Notify parent
      +      onEditModeChange?.(true)
      +      // Focus the input and position cursor at the end after render
      +      setTimeout(() => {
      +        userInputRef.current?.focus()
      +      }, 0)
      +    }
      +
      +    const handleCancelEdit = () => {
      +      setIsEditMode(false)
      +      setEditedContent(message.content)
      +      onEditModeChange?.(false)
      +    }
      +
      +    const handleMessageClick = () => {
      +      // Allow entering edit mode even while streaming
      +
      +      // If message needs expansion and is not expanded, expand it
      +      if (needsExpansion && !isExpanded) {
      +        setIsExpanded(true)
      +      }
      +
      +      // Always enter edit mode on click
      +      handleEditMessage()
      +    }
      +
      +    const handleSubmitEdit = async (
      +      editedMessage: string,
      +      fileAttachments?: any[],
      +      contexts?: any[]
      +    ) => {
      +      if (!editedMessage.trim()) return
      +
      +      // If a stream is in progress, abort it first
      +      if (isSendingMessage) {
      +        abortMessage()
      +        // Wait a brief moment for abort to complete
      +        await new Promise((resolve) => setTimeout(resolve, 100))
      +      }
      +
      +      // Check if this message has checkpoints
      +      if (hasCheckpoints) {
      +        // Store the pending edit
      +        pendingEditRef.current = { message: editedMessage, fileAttachments, contexts }
      +        // Show confirmation modal
      +        setShowCheckpointDiscardModal(true)
      +        return
      +      }
      +
      +      // Proceed with the edit
      +      await performEdit(editedMessage, fileAttachments, contexts)
      +    }
      +
      +    const performEdit = async (
      +      editedMessage: string,
      +      fileAttachments?: any[],
      +      contexts?: any[]
      +    ) => {
      +      // Find the index of this message and truncate conversation
      +      const currentMessages = messages
      +      const editIndex = currentMessages.findIndex((m) => m.id === message.id)
      +
      +      if (editIndex !== -1) {
      +        // Exit edit mode visually
      +        setIsEditMode(false)
      +        // Clear editing state in parent immediately to prevent dimming of new messages
      +        onEditModeChange?.(false)
      +
      +        // Truncate messages after the edited message (but keep the edited message with updated content)
      +        const truncatedMessages = currentMessages.slice(0, editIndex)
      +
      +        // Update the edited message with new content but keep it in the array
      +        const updatedMessage = {
      +          ...message,
      +          content: editedMessage,
      +          fileAttachments: fileAttachments || message.fileAttachments,
      +          contexts: contexts || (message as any).contexts,
      +        }
      +
      +        // Show the updated message immediately to prevent disappearing
      +        useCopilotStore.setState({ messages: [...truncatedMessages, updatedMessage] })
      +
      +        // If we have a current chat, update the DB to remove messages after this point
      +        if (currentChat?.id) {
      +          try {
      +            await fetch('/api/copilot/chat/update-messages', {
      +              method: 'POST',
      +              headers: { 'Content-Type': 'application/json' },
      +              body: JSON.stringify({
      +                chatId: currentChat.id,
      +                messages: truncatedMessages.map((m) => ({
      +                  id: m.id,
      +                  role: m.role,
      +                  content: m.content,
      +                  timestamp: m.timestamp,
      +                  ...(m.contentBlocks && { contentBlocks: m.contentBlocks }),
      +                  ...(m.fileAttachments && { fileAttachments: m.fileAttachments }),
      +                  ...((m as any).contexts && { contexts: (m as any).contexts }),
      +                })),
      +              }),
      +            })
      +          } catch (error) {
      +            logger.error('Failed to update messages in DB after edit:', error)
      +          }
      +        }
      +
      +        // Send the edited message with the SAME message ID
      +        await sendMessage(editedMessage, {
      +          fileAttachments: fileAttachments || message.fileAttachments,
      +          contexts: contexts || (message as any).contexts,
      +          messageId: message.id, // Reuse the original message ID
      +        })
      +      }
           }
       
           useEffect(() => {
      @@ -285,6 +507,139 @@ const CopilotMessage: FC = memo(
             }
           }, [showDownvoteSuccess])
       
      +    // Handle Escape and Enter keys for restore confirmation
      +    useEffect(() => {
      +      if (!showRestoreConfirmation) return
      +
      +      const handleKeyDown = (event: KeyboardEvent) => {
      +        if (event.key === 'Escape') {
      +          setShowRestoreConfirmation(false)
      +          onRevertModeChange?.(false)
      +        } else if (event.key === 'Enter') {
      +          event.preventDefault()
      +          handleConfirmRevert()
      +        }
      +      }
      +
      +      document.addEventListener('keydown', handleKeyDown)
      +      return () => document.removeEventListener('keydown', handleKeyDown)
      +    }, [showRestoreConfirmation, onRevertModeChange, handleConfirmRevert])
      +
      +    // Handle Escape and Enter keys for checkpoint discard confirmation
      +    useEffect(() => {
      +      if (!showCheckpointDiscardModal) return
      +
      +      const handleKeyDown = async (event: KeyboardEvent) => {
      +        if (event.key === 'Escape') {
      +          setShowCheckpointDiscardModal(false)
      +          pendingEditRef.current = null
      +        } else if (event.key === 'Enter') {
      +          event.preventDefault()
      +          // Trigger "Continue and revert" action on Enter
      +          if (messageCheckpoints.length > 0) {
      +            const latestCheckpoint = messageCheckpoints[0]
      +            try {
      +              await revertToCheckpoint(latestCheckpoint.id)
      +
      +              // Remove the used checkpoint from the store
      +              const { messageCheckpoints: currentCheckpoints } = useCopilotStore.getState()
      +              const updatedCheckpoints = {
      +                ...currentCheckpoints,
      +                [message.id]: messageCheckpoints.slice(1),
      +              }
      +              useCopilotStore.setState({ messageCheckpoints: updatedCheckpoints })
      +
      +              logger.info('Reverted to checkpoint before editing message', {
      +                messageId: message.id,
      +                checkpointId: latestCheckpoint.id,
      +              })
      +            } catch (error) {
      +              logger.error('Failed to revert to checkpoint:', error)
      +            }
      +          }
      +
      +          setShowCheckpointDiscardModal(false)
      +
      +          if (pendingEditRef.current) {
      +            const { message: msg, fileAttachments, contexts } = pendingEditRef.current
      +            await performEdit(msg, fileAttachments, contexts)
      +            pendingEditRef.current = null
      +          }
      +        }
      +      }
      +
      +      document.addEventListener('keydown', handleKeyDown)
      +      return () => document.removeEventListener('keydown', handleKeyDown)
      +    }, [showCheckpointDiscardModal, messageCheckpoints, message.id])
      +
      +    // Handle click outside to exit edit mode
      +    useEffect(() => {
      +      if (!isEditMode) return
      +
      +      const handleClickOutside = (event: MouseEvent) => {
      +        const target = event.target as HTMLElement
      +
      +        // Don't close if clicking inside the edit container
      +        if (editContainerRef.current?.contains(target)) {
      +          return
      +        }
      +
      +        // Check if clicking on another user message box
      +        const clickedMessageBox = target.closest('[data-message-box]') as HTMLElement
      +        if (clickedMessageBox) {
      +          const clickedMessageId = clickedMessageBox.getAttribute('data-message-id')
      +          // If clicking on a different message, close this one (the other will open via its own click handler)
      +          if (clickedMessageId && clickedMessageId !== message.id) {
      +            handleCancelEdit()
      +          }
      +          return
      +        }
      +
      +        // Check if clicking on the main user input at the bottom
      +        if (target.closest('textarea') || target.closest('input[type="text"]')) {
      +          handleCancelEdit()
      +          return
      +        }
      +
      +        // Only close if NOT clicking on any component (i.e., clicking directly on panel background)
      +        // If the target has children or is a component, don't close
      +        if (target.children.length > 0 || target.tagName !== 'DIV') {
      +          return
      +        }
      +
      +        handleCancelEdit()
      +      }
      +
      +      const handleKeyDown = (event: KeyboardEvent) => {
      +        if (event.key === 'Escape') {
      +          handleCancelEdit()
      +        }
      +      }
      +
      +      // Use click event instead of mousedown to allow the target's click handler to fire first
      +      // Add listener with a slight delay to avoid immediate trigger when entering edit mode
      +      const timeoutId = setTimeout(() => {
      +        document.addEventListener('click', handleClickOutside, true) // Use capture phase
      +        document.addEventListener('keydown', handleKeyDown)
      +      }, 100)
      +
      +      return () => {
      +        clearTimeout(timeoutId)
      +        document.removeEventListener('click', handleClickOutside, true)
      +        document.removeEventListener('keydown', handleKeyDown)
      +      }
      +    }, [isEditMode, message.id])
      +
      +    // Check if message content needs expansion (is tall)
      +    useEffect(() => {
      +      if (messageContentRef.current && isUser) {
      +        const scrollHeight = messageContentRef.current.scrollHeight
      +        const clientHeight = messageContentRef.current.clientHeight
      +        // If content is taller than the max height (3 lines ~60px), mark as needing expansion
      +        setNeedsExpansion(scrollHeight > 60)
      +      }
      +    }, [message.content, isUser])
      +
           // Get clean text content with double newline parsing
           const cleanTextContent = useMemo(() => {
             if (!message.content) return ''
      @@ -365,23 +720,119 @@ const CopilotMessage: FC = memo(
       
           if (isUser) {
             return (
      -        
      - {/* File attachments displayed above the message, completely separate from message box width */} - {message.fileAttachments && message.fileAttachments.length > 0 && ( -
      -
      - -
      +
      + {isEditMode ? ( +
      + + + {/* Inline Checkpoint Discard Confirmation - shown below input in edit mode */} + {showCheckpointDiscardModal && ( +
      +

      Continue from a previous message?

      +
      + + + +
      +
      + )}
      - )} + ) : ( +
      + {/* File attachments displayed above the message box */} + {message.fileAttachments && message.fileAttachments.length > 0 && ( +
      + +
      + )} - {/* Context chips displayed above the message bubble, independent of inline text */} - {(Array.isArray((message as any).contexts) && (message as any).contexts.length > 0) || - (Array.isArray(message.contentBlocks) && - (message.contentBlocks as any[]).some((b: any) => b?.type === 'contexts')) ? ( -
      -
      -
      + {/* Context chips displayed above the message box */} + {(Array.isArray((message as any).contexts) && (message as any).contexts.length > 0) || + (Array.isArray(message.contentBlocks) && + (message.contentBlocks as any[]).some((b: any) => b?.type === 'contexts')) ? ( +
      {(() => { const direct = Array.isArray((message as any).contexts) ? ((message as any).contexts as any[]) @@ -451,21 +902,26 @@ const CopilotMessage: FC = memo( ) })()}
      -
      -
      - ) : null} + ) : null} -
      -
      - {/* Message content in purple box */} + {/* Message box - styled like input, clickable to edit */}
      setIsHoveringMessage(true)} + onMouseLeave={() => setIsHoveringMessage(false)} + className='group relative cursor-text rounded-[8px] border border-[#E5E5E5] bg-[#FFFFFF] px-3 py-1.5 shadow-xs transition-all duration-200 hover:border-[#D0D0D0] dark:border-[#414141] dark:bg-[var(--surface-elevated)] dark:hover:border-[#525252]' > -
      +
      {(() => { const text = message.content || '' const contexts: any[] = Array.isArray((message as any).contexts) @@ -475,7 +931,7 @@ const CopilotMessage: FC = memo( .filter((c) => c?.kind !== 'current_workflow') .map((c) => c?.label) .filter(Boolean) as string[] - if (!labels.length) return + if (!labels.length) return text const escapeRegex = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') const pattern = new RegExp(`@(${labels.map(escapeRegex).join('|')})`, 'g') @@ -502,60 +958,86 @@ const CopilotMessage: FC = memo( if (tail) nodes.push(tail) return nodes })()} + + {/* Gradient fade when truncated */} + {!isExpanded && needsExpansion && ( +
      + )}
      -
      - {hasCheckpoints && ( -
      - {showRestoreConfirmation ? ( -
      - Restore Checkpoint? - - -
      - ) : ( + + {/* Abort button when hovering and response is generating (only on last user message) */} + {isSendingMessage && isHoveringMessage && isLastUserMessage && ( +
      + +
      + )} + + {/* Revert button on hover (only when has checkpoints and not generating) */} + {!isSendingMessage && hasCheckpoints && ( +
      - )} -
      - )} +
      + )} +
      -
      + )} + + {/* Inline Restore Checkpoint Confirmation */} + {showRestoreConfirmation && ( +
      +

      + Revert to checkpoint? This will restore your workflow to the state saved at this + checkpoint.{' '} + + This action cannot be undone. + +

      +
      + + +
      +
      + )}
      ) } if (isAssistant) { return ( -
      -
      +
      +
      {/* Content blocks in chronological order */} {memoizedContentBlocks} @@ -651,6 +1133,21 @@ const CopilotMessage: FC = memo( return false } + // If dimmed state changed, re-render + if (prevProps.isDimmed !== nextProps.isDimmed) { + return false + } + + // If panel width changed, re-render + if (prevProps.panelWidth !== nextProps.panelWidth) { + return false + } + + // If checkpoint count changed, re-render + if (prevProps.checkpointCount !== nextProps.checkpointCount) { + return false + } + // For streaming messages, check if content actually changed if (nextProps.isStreaming) { const prevBlocks = prevMessage.contentBlocks || [] diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/user-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/user-input.tsx index ebb6e47a1ec..f0882a7abba 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/user-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/user-input.tsx @@ -36,6 +36,7 @@ import { Zap, } from 'lucide-react' import { useParams } from 'next/navigation' +import { createPortal } from 'react-dom' import { Button, DropdownMenu, @@ -49,7 +50,6 @@ import { TooltipTrigger, } from '@/components/ui' import { useSession } from '@/lib/auth-client' -import { isHosted } from '@/lib/environment' import { createLogger } from '@/lib/logs/console/logger' import { cn } from '@/lib/utils' import { useCopilotStore } from '@/stores/copilot/store' @@ -95,6 +95,8 @@ interface UserInputProps { value?: string // Controlled value from outside onChange?: (value: string) => void // Callback when value changes panelWidth?: number // Panel width to adjust truncation + hideContextUsage?: boolean // Hide the context usage pill + clearOnSubmit?: boolean // Whether to clear input after submit (default true for bottom input, false for edit mode) } interface UserInputRef { @@ -116,6 +118,8 @@ const UserInput = forwardRef( value: controlledValue, onChange: onControlledChange, panelWidth = 308, + hideContextUsage = false, + clearOnSubmit = true, }, ref ) => { @@ -127,8 +131,19 @@ const UserInput = forwardRef( const textareaRef = useRef(null) const overlayRef = useRef(null) const fileInputRef = useRef(null) + const containerRef = useRef(null) const [showMentionMenu, setShowMentionMenu] = useState(false) const mentionMenuRef = useRef(null) + const mentionPortalRef = useRef(null) + const [isNearTop, setIsNearTop] = useState(false) + const [mentionMenuMaxHeight, setMentionMenuMaxHeight] = useState(undefined) + const [mentionPortalStyle, setMentionPortalStyle] = useState<{ + top: number + left: number + width: number + maxHeight: number + showBelow: boolean + } | null>(null) const submenuRef = useRef(null) const menuListRef = useRef(null) const [mentionActiveIndex, setMentionActiveIndex] = useState(0) @@ -208,7 +223,15 @@ const UserInput = forwardRef( ref, () => ({ focus: () => { - textareaRef.current?.focus() + const textarea = textareaRef.current + if (textarea) { + textarea.focus() + // Position cursor at the end of the text + const length = textarea.value.length + textarea.setSelectionRange(length, length) + // Scroll to the end + textarea.scrollTop = textarea.scrollHeight + } }, }), [] @@ -226,9 +249,14 @@ const UserInput = forwardRef( } }, [workflowId]) + // Reset past chats when workflow changes to ensure we only load chats from the current workflow + useEffect(() => { + setPastChats([]) + setIsLoadingPastChats(false) + }, [workflowId]) + // Fetch enabled models when dropdown is opened for the first time const fetchEnabledModelsOnce = useCallback(async () => { - if (!isHosted) return if (enabledModels !== null) return // Already loaded try { @@ -284,6 +312,44 @@ const UserInput = forwardRef( return () => textarea.removeEventListener('scroll', handleScroll) }, []) + // Detect if input is near the top of the screen (update dynamically) + useEffect(() => { + const checkPosition = () => { + if (containerRef.current) { + const rect = containerRef.current.getBoundingClientRect() + // Consider "near top" if less than 300px from top of viewport + setIsNearTop(rect.top < 300) + } + } + + checkPosition() + + // Check position on scroll within the copilot panel + const scrollContainer = containerRef.current?.closest('[data-radix-scroll-area-viewport]') + if (scrollContainer) { + scrollContainer.addEventListener('scroll', checkPosition, { passive: true }) + } + + window.addEventListener('scroll', checkPosition, true) + window.addEventListener('resize', checkPosition) + + return () => { + if (scrollContainer) { + scrollContainer.removeEventListener('scroll', checkPosition) + } + window.removeEventListener('scroll', checkPosition, true) + window.removeEventListener('resize', checkPosition) + } + }, []) + + // Also check position when mention menu opens + useEffect(() => { + if (showMentionMenu && containerRef.current) { + const rect = containerRef.current.getBoundingClientRect() + setIsNearTop(rect.top < 300) + } + }, [showMentionMenu]) + // Close mention menu on outside click useEffect(() => { if (!showMentionMenu) return @@ -292,6 +358,7 @@ const UserInput = forwardRef( if ( mentionMenuRef.current && !mentionMenuRef.current.contains(target) && + (!mentionPortalRef.current || !mentionPortalRef.current.contains(target)) && (!submenuRef.current || !submenuRef.current.contains(target)) && textareaRef.current && !textareaRef.current.contains(target as Node) @@ -313,18 +380,11 @@ const UserInput = forwardRef( const data = await resp.json() const items = Array.isArray(data?.chats) ? data.chats : [] - if (workflows.length === 0) { - await ensureWorkflowsLoaded() - } - - const workspaceWorkflowIds = new Set(workflows.map((w) => w.id)) - - const workspaceChats = items.filter( - (c: any) => !c.workflowId || workspaceWorkflowIds.has(c.workflowId) - ) + // Filter chats to only include those from the current workflow + const currentWorkflowChats = items.filter((c: any) => c.workflowId === workflowId) setPastChats( - workspaceChats.map((c: any) => ({ + currentWorkflowChats.map((c: any) => ({ id: c.id, title: c.title ?? null, workflowId: c.workflowId ?? null, @@ -620,25 +680,28 @@ const UserInput = forwardRef( // Send only the explicitly selected contexts onSubmit(trimmedMessage, fileAttachments, selectedContexts as any) - // Clean up preview URLs before clearing - attachedFiles.forEach((f) => { - if (f.previewUrl) { - URL.revokeObjectURL(f.previewUrl) - } - }) + // Only clear after submit if clearOnSubmit is true (default behavior for bottom input) + if (clearOnSubmit) { + // Clean up preview URLs before clearing + attachedFiles.forEach((f) => { + if (f.previewUrl) { + URL.revokeObjectURL(f.previewUrl) + } + }) - // Clear the message and files after submit - if (controlledValue !== undefined) { - onControlledChange?.('') - } else { - setInternalMessage('') - } - setAttachedFiles([]) + // Clear the message and files after submit + if (controlledValue !== undefined) { + onControlledChange?.('') + } else { + setInternalMessage('') + } + setAttachedFiles([]) - // Clear @mention contexts after submission - setSelectedContexts([]) + // Clear @mention contexts after submission + setSelectedContexts([]) - setOpenSubmenuFor(null) + setOpenSubmenuFor(null) + } setShowMentionMenu(false) } @@ -1689,13 +1752,14 @@ const UserInput = forwardRef( { value: 'gpt-4.1', label: 'gpt-4.1' }, { value: 'o3', label: 'o3' }, { value: 'claude-4-sonnet', label: 'claude-4-sonnet' }, + { value: 'claude-4.5-haiku', label: 'claude-4.5-haiku' }, { value: 'claude-4.5-sonnet', label: 'claude-4.5-sonnet' }, { value: 'claude-4.1-opus', label: 'claude-4.1-opus' }, ] as const - // Filter models based on user preferences (only for hosted) + // Filter models based on user preferences const modelOptions = - isHosted && enabledModels !== null + enabledModels !== null ? allModelOptions.filter((model) => enabledModels.includes(model.value)) : allModelOptions @@ -1705,7 +1769,7 @@ const UserInput = forwardRef( } const getModelIcon = () => { - // Only Brain and BrainCircuit models show purple when agentPrefetch is false + // Brain and BrainCircuit models show purple when agentPrefetch is false const isBrainModel = [ 'gpt-5', 'gpt-5-medium', @@ -1713,8 +1777,11 @@ const UserInput = forwardRef( 'claude-4.5-sonnet', ].includes(selectedModel) const isBrainCircuitModel = ['gpt-5-high', 'o3', 'claude-4.1-opus'].includes(selectedModel) + const isHaikuModel = selectedModel === 'claude-4.5-haiku' + + // Haiku shows purple when selected, other zap models don't const colorClass = - (isBrainModel || isBrainCircuitModel) && !agentPrefetch + (isBrainModel || isBrainCircuitModel || isHaikuModel) && !agentPrefetch ? 'text-[var(--brand-primary-hover-hex)]' : 'text-muted-foreground' @@ -1725,7 +1792,7 @@ const UserInput = forwardRef( if (isBrainModel) { return } - if (['gpt-4o', 'gpt-4.1', 'gpt-5-fast'].includes(selectedModel)) { + if (['gpt-4o', 'gpt-4.1', 'gpt-5-fast', 'claude-4.5-haiku'].includes(selectedModel)) { return } return @@ -1919,11 +1986,175 @@ const UserInput = forwardRef( setOpenSubmenuFor(null) } + useEffect(() => { + const textarea = textareaRef.current + const overlay = overlayRef.current + if (!textarea || !overlay || typeof window === 'undefined') return + + const syncOverlayStyles = () => { + const styles = window.getComputedStyle(textarea) + overlay.style.font = styles.font + overlay.style.letterSpacing = styles.letterSpacing + overlay.style.padding = styles.padding + overlay.style.lineHeight = styles.lineHeight + overlay.style.color = styles.color + overlay.style.whiteSpace = styles.whiteSpace + overlay.style.wordBreak = styles.wordBreak + overlay.style.width = `${textarea.clientWidth}px` + overlay.style.height = `${textarea.clientHeight}px` + overlay.style.borderRadius = styles.borderRadius + } + + syncOverlayStyles() + + let resizeObserver: ResizeObserver | null = null + if (typeof ResizeObserver !== 'undefined') { + resizeObserver = new ResizeObserver(() => syncOverlayStyles()) + resizeObserver.observe(textarea) + } + window.addEventListener('resize', syncOverlayStyles) + + return () => { + resizeObserver?.disconnect() + window.removeEventListener('resize', syncOverlayStyles) + } + }, [panelWidth, message, selectedContexts]) + + // Update mention menu max height when visibility or position changes + useEffect(() => { + if (!showMentionMenu || !containerRef.current) { + setMentionMenuMaxHeight(undefined) + return + } + const rect = containerRef.current.getBoundingClientRect() + const margin = 16 + let available = isNearTop ? window.innerHeight - rect.bottom - margin : rect.top - margin + available = Math.max(available, 120) + setMentionMenuMaxHeight(available) + }, [showMentionMenu, isNearTop]) + + // Position the portal mention menu + useEffect(() => { + const updatePosition = () => { + if (!showMentionMenu || !containerRef.current || !textareaRef.current) { + setMentionPortalStyle(null) + return + } + const rect = containerRef.current.getBoundingClientRect() + const margin = 8 + + // Calculate cursor position using a temporary span + const textarea = textareaRef.current + const caretPos = getCaretPos() + + // Create a mirror div to calculate caret position + const div = document.createElement('div') + const style = window.getComputedStyle(textarea) + + // Copy relevant styles + div.style.position = 'absolute' + div.style.visibility = 'hidden' + div.style.whiteSpace = 'pre-wrap' + div.style.wordWrap = 'break-word' + div.style.font = style.font + div.style.padding = style.padding + div.style.border = style.border + div.style.width = style.width + div.style.lineHeight = style.lineHeight + + // Add text up to cursor position + const textBeforeCaret = message.substring(0, caretPos) + div.textContent = textBeforeCaret + + // Add a span at the end to measure position + const span = document.createElement('span') + span.textContent = '|' + div.appendChild(span) + + document.body.appendChild(div) + const spanRect = span.getBoundingClientRect() + const divRect = div.getBoundingClientRect() + document.body.removeChild(div) + + // Calculate the left offset relative to the textarea + const caretLeftOffset = spanRect.left - divRect.left + + // Calculate available space above and below + const spaceAbove = rect.top - margin + const spaceBelow = window.innerHeight - rect.bottom - margin + + // Cap max height to show ~8-10 items before scrolling (each item ~40px) + // This prevents the menu from extending too far in either direction + const maxMenuHeight = 360 + + // Show below if near top OR if more space below, otherwise show above + const showBelow = rect.top < 300 || spaceBelow > spaceAbove + + // Calculate max height based on available space, but never exceed maxMenuHeight + // Use the smaller of available space and our cap to ensure menu fits + const maxHeight = Math.min( + Math.max(showBelow ? spaceBelow : spaceAbove, 120), + maxMenuHeight + ) + + // Determine menu width based on submenu state + const menuWidth = + openSubmenuFor === 'Blocks' + ? 320 + : openSubmenuFor === 'Templates' || openSubmenuFor === 'Logs' || aggregatedActive + ? 384 + : 224 + + // Calculate left position: use caret position but ensure menu doesn't go off-screen + const idealLeft = rect.left + caretLeftOffset + const maxLeft = window.innerWidth - menuWidth - margin + const finalLeft = Math.min(idealLeft, maxLeft) + + setMentionPortalStyle({ + top: showBelow ? rect.bottom + 4 : rect.top - 4, + left: Math.max(rect.left, finalLeft), // Don't go past left edge of container + width: menuWidth, + maxHeight: maxHeight, + showBelow, + }) + + // Update isNearTop state for reference + setIsNearTop(showBelow) + } + + let rafId: number | null = null + if (showMentionMenu) { + updatePosition() + window.addEventListener('resize', updatePosition) + + // Update position on scroll + const scrollContainer = containerRef.current?.closest('[data-radix-scroll-area-viewport]') + if (scrollContainer) { + scrollContainer.addEventListener('scroll', updatePosition, { passive: true }) + } + + // Continuously update position (for smooth tracking) + const loop = () => { + updatePosition() + rafId = requestAnimationFrame(loop) + } + rafId = requestAnimationFrame(loop) + + return () => { + window.removeEventListener('resize', updatePosition) + if (scrollContainer) { + scrollContainer.removeEventListener('scroll', updatePosition) + } + if (rafId) cancelAnimationFrame(rafId) + } + } + }, [showMentionMenu, openSubmenuFor, aggregatedActive, message]) + return ( -
      +
      ( onDrop={handleDrop} > {/* Context Usage Pill - Top Right */} - {contextUsage && contextUsage.percentage > 0 && ( + {!hideContextUsage && contextUsage && contextUsage.percentage > 0 && (
      ( />
      )} + {/* Attached Files Display with Thumbnails */} {attachedFiles.length > 0 && (
      @@ -2054,9 +2286,10 @@ const UserInput = forwardRef( {/* Highlight overlay */}
      -
      +              
                       {(() => {
                         const elements: React.ReactNode[] = []
                         const remaining = message
      @@ -2065,7 +2298,7 @@ const UserInput = forwardRef(
                         // Build regex for all labels
                         const labels = contexts.map((c) => c.label).filter(Boolean)
                         const pattern = new RegExp(
      -                    `@(${labels.map((l) => l.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')).join('|')})`,
      +                    `@(${labels.map((l) => l.replace(/[.*+?^${}()|[\\]\\]/g, '\\$&')).join('|')})`,
                           'g'
                         )
                         let lastIndex = 0
      @@ -2075,11 +2308,14 @@ const UserInput = forwardRef(
                           const before = remaining.slice(lastIndex, i)
                           if (before) elements.push(before)
                           const mentionText = match[0]
      -                    const mentionLabel = match[1]
                           elements.push(
                             
                               {mentionText}
                             
      @@ -2099,760 +2335,496 @@ const UserInput = forwardRef(
                     onKeyDown={handleKeyDown}
                     onSelect={handleSelectAdjust}
                     onMouseUp={handleSelectAdjust}
      +              onScroll={(e) => {
      +                if (overlayRef.current) {
      +                  overlayRef.current.scrollTop = e.currentTarget.scrollTop
      +                  overlayRef.current.scrollLeft = e.currentTarget.scrollLeft
      +                }
      +              }}
                     placeholder={isDragging ? 'Drop files here...' : effectivePlaceholder}
                     disabled={disabled}
                     rows={1}
      -              className='relative z-[2] mb-2 min-h-[32px] w-full resize-none overflow-y-auto overflow-x-hidden break-words border-0 bg-transparent py-1 pr-14 pl-[2px] font-sans text-sm text-transparent leading-[1.25rem] caret-foreground focus-visible:ring-0 focus-visible:ring-offset-0'
      -              style={{ height: 'auto', wordBreak: 'break-word' }}
      +              className='relative z-[2] mb-2 min-h-[32px] w-full resize-none overflow-y-auto overflow-x-hidden break-words border-0 bg-transparent py-1 pr-14 pl-[2px] font-sans text-sm text-transparent leading-[1.25rem] caret-foreground focus-visible:ring-0 focus-visible:ring-offset-0 [&::-webkit-scrollbar]:hidden'
      +              style={{
      +                height: 'auto',
      +                wordBreak: 'break-word',
      +                scrollbarWidth: 'none',
      +                msOverflowStyle: 'none',
      +              }}
                   />
       
      -            {showMentionMenu && (
      -              <>
      +            {showMentionMenu &&
      +              mentionPortalStyle &&
      +              createPortal(
                       
      - {openSubmenuFor ? ( - <> -
      - {openSubmenuFor === 'Chats' - ? 'Chats' - : openSubmenuFor === 'Workflows' - ? 'All workflows' - : openSubmenuFor === 'Knowledge' - ? 'Knowledge Bases' - : openSubmenuFor === 'Blocks' - ? 'Blocks' - : openSubmenuFor === 'Workflow Blocks' - ? 'Workflow Blocks' - : openSubmenuFor === 'Templates' - ? 'Templates' - : 'Logs'} -
      -
      - {isSubmenu('Chats') && ( - <> - {isLoadingPastChats ? ( -
      - Loading... -
      - ) : pastChats.length === 0 ? ( -
      - No past chats -
      - ) : ( - pastChats - .filter((c) => - (c.title || 'Untitled Chat') - .toLowerCase() - .includes(getSubmenuQuery().toLowerCase()) - ) - .map((chat, idx) => ( -
      setSubmenuActiveIndex(idx)} - onClick={() => { - insertPastChatMention(chat) - setSubmenuQueryStart(null) - }} - > -
      - -
      - - {chat.title || 'Untitled Chat'} - -
      - )) - )} - - )} - {isSubmenu('Workflows') && ( - <> - {isLoadingWorkflows ? ( -
      - Loading... -
      - ) : workflows.length === 0 ? ( -
      - No workflows -
      - ) : ( - workflows - .filter((w) => - (w.name || 'Untitled Workflow') - .toLowerCase() - .includes(getSubmenuQuery().toLowerCase()) - ) - .map((wf, idx) => ( -
      setSubmenuActiveIndex(idx)} - onClick={() => { - insertWorkflowMention(wf) - setSubmenuQueryStart(null) - }} - > -
      - - {wf.name || 'Untitled Workflow'} - -
      - )) - )} - - )} - {isSubmenu('Knowledge') && ( - <> - {isLoadingKnowledge ? ( -
      - Loading... -
      - ) : knowledgeBases.length === 0 ? ( -
      - No knowledge bases -
      - ) : ( - knowledgeBases - .filter((k) => - (k.name || 'Untitled') - .toLowerCase() - .includes(getSubmenuQuery().toLowerCase()) - ) - .map((kb, idx) => ( -
      setSubmenuActiveIndex(idx)} - onClick={() => { - insertKnowledgeMention(kb) - setSubmenuQueryStart(null) - }} - > - - {kb.name || 'Untitled'} -
      - )) - )} - - )} - {isSubmenu('Blocks') && ( - <> - {isLoadingBlocks ? ( -
      - Loading... -
      - ) : blocksList.length === 0 ? ( -
      - No blocks found -
      - ) : ( - blocksList - .filter((b) => - (b.name || b.id) - .toLowerCase() - .includes(getSubmenuQuery().toLowerCase()) - ) - .map((blk, idx) => ( -
      setSubmenuActiveIndex(idx)} - onClick={() => { - insertBlockMention(blk) - setSubmenuQueryStart(null) - }} - > +
      + {openSubmenuFor ? ( + <> +
      + {openSubmenuFor === 'Chats' + ? 'Chats' + : openSubmenuFor === 'Workflows' + ? 'All workflows' + : openSubmenuFor === 'Knowledge' + ? 'Knowledge Bases' + : openSubmenuFor === 'Blocks' + ? 'Blocks' + : openSubmenuFor === 'Workflow Blocks' + ? 'Workflow Blocks' + : openSubmenuFor === 'Templates' + ? 'Templates' + : 'Logs'} +
      +
      + {isSubmenu('Chats') && ( + <> + {isLoadingPastChats ? ( +
      + Loading... +
      + ) : pastChats.length === 0 ? ( +
      + No past chats +
      + ) : ( + pastChats + .filter((c) => + (c.title || 'Untitled Chat') + .toLowerCase() + .includes(getSubmenuQuery().toLowerCase()) + ) + .map((chat, idx) => (
      - {blk.iconComponent && ( - + key={chat.id} + data-idx={idx} + className={cn( + 'flex items-center gap-2 rounded-[6px] px-2 py-1.5 text-sm hover:bg-muted/60', + submenuActiveIndex === idx && 'bg-muted' )} + role='menuitem' + aria-selected={submenuActiveIndex === idx} + onMouseEnter={() => setSubmenuActiveIndex(idx)} + onClick={() => { + insertPastChatMention(chat) + setSubmenuQueryStart(null) + }} + > +
      + +
      + + {chat.title || 'Untitled Chat'} +
      - {blk.name || blk.id} -
      - )) - )} - - )} - {isSubmenu('Workflow Blocks') && ( - <> - {isLoadingWorkflowBlocks ? ( -
      - Loading... -
      - ) : workflowBlocks.length === 0 ? ( -
      - No blocks in this workflow -
      - ) : ( - workflowBlocks - .filter((b) => - (b.name || b.id) - .toLowerCase() - .includes(getSubmenuQuery().toLowerCase()) - ) - .map((blk, idx) => ( -
      setSubmenuActiveIndex(idx)} - onClick={() => { - insertWorkflowBlockMention(blk) - setSubmenuQueryStart(null) - }} - > + )) + )} + + )} + {isSubmenu('Workflows') && ( + <> + {isLoadingWorkflows ? ( +
      + Loading... +
      + ) : workflows.length === 0 ? ( +
      + No workflows +
      + ) : ( + workflows + .filter((w) => + (w.name || 'Untitled Workflow') + .toLowerCase() + .includes(getSubmenuQuery().toLowerCase()) + ) + .map((wf, idx) => (
      - {blk.iconComponent && ( - + key={wf.id} + data-idx={idx} + className={cn( + 'flex items-center gap-2 rounded-[6px] px-2 py-1.5 text-sm hover:bg-muted/60', + submenuActiveIndex === idx && 'bg-muted' )} + role='menuitem' + aria-selected={submenuActiveIndex === idx} + onMouseEnter={() => setSubmenuActiveIndex(idx)} + onClick={() => { + insertWorkflowMention(wf) + setSubmenuQueryStart(null) + }} + > +
      + + {wf.name || 'Untitled Workflow'} +
      - {blk.name || blk.id} -
      - )) - )} - - )} - {isSubmenu('Templates') && ( - <> - {isLoadingTemplates ? ( -
      - Loading... -
      - ) : templatesList.length === 0 ? ( -
      - No templates found -
      - ) : ( - templatesList - .filter((t) => - (t.name || 'Untitled Template') - .toLowerCase() - .includes(getSubmenuQuery().toLowerCase()) - ) - .map((tpl, idx) => ( -
      setSubmenuActiveIndex(idx)} - onClick={() => { - insertTemplateMention(tpl) - setSubmenuQueryStart(null) - }} - > -
      - ★ + )) + )} + + )} + {isSubmenu('Knowledge') && ( + <> + {isLoadingKnowledge ? ( +
      + Loading... +
      + ) : knowledgeBases.length === 0 ? ( +
      + No knowledge bases +
      + ) : ( + knowledgeBases + .filter((k) => + (k.name || 'Untitled') + .toLowerCase() + .includes(getSubmenuQuery().toLowerCase()) + ) + .map((kb, idx) => ( +
      setSubmenuActiveIndex(idx)} + onClick={() => { + insertKnowledgeMention(kb) + setSubmenuQueryStart(null) + }} + > + + {kb.name || 'Untitled'}
      - {tpl.name} - - {tpl.stars} - -
      - )) - )} - - )} - {isSubmenu('Logs') && ( - <> - {isLoadingLogs ? ( -
      - Loading... -
      - ) : logsList.length === 0 ? ( -
      - No executions found -
      - ) : ( - logsList - .filter((l) => - [l.workflowName, l.trigger || ''] - .join(' ') - .toLowerCase() - .includes(getSubmenuQuery().toLowerCase()) - ) - .map((log, idx) => ( -
      setSubmenuActiveIndex(idx)} - onClick={() => { - insertLogMention(log) - setSubmenuQueryStart(null) - }} - > - {log.level === 'error' ? ( - - ) : ( - - )} - {log.workflowName} - · - - {formatTimestamp(log.createdAt)} - - · - - {(log.trigger || 'manual').toLowerCase()} - -
      - )) - )} - - )} -
      - - ) : ( - <> - {(() => { - const q = ( - getActiveMentionQueryAtPosition(getCaretPos())?.query || '' - ).toLowerCase() - const filtered = mentionOptions.filter((label) => - label.toLowerCase().includes(q) - ) - if (q.length > 0 && filtered.length === 0) { - // Aggregated search view - const aggregated = [ - ...workflowBlocks - .filter((b) => (b.name || b.id).toLowerCase().includes(q)) - .map((b) => ({ - type: 'Workflow Blocks' as const, - id: b.id, - value: b, - onClick: () => insertWorkflowBlockMention(b), - })), - ...workflows - .filter((w) => - (w.name || 'Untitled Workflow').toLowerCase().includes(q) - ) - .map((w) => ({ - type: 'Workflows' as const, - id: w.id, - value: w, - onClick: () => insertWorkflowMention(w), - })), - ...blocksList - .filter((b) => (b.name || b.id).toLowerCase().includes(q)) - .map((b) => ({ - type: 'Blocks' as const, - id: b.id, - value: b, - onClick: () => insertBlockMention(b), - })), - ...knowledgeBases - .filter((k) => (k.name || 'Untitled').toLowerCase().includes(q)) - .map((k) => ({ - type: 'Knowledge' as const, - id: k.id, - value: k, - onClick: () => insertKnowledgeMention(k), - })), - ...templatesList - .filter((t) => - (t.name || 'Untitled Template').toLowerCase().includes(q) - ) - .map((t) => ({ - type: 'Templates' as const, - id: t.id, - value: t, - onClick: () => insertTemplateMention(t), - })), - ...pastChats - .filter((c) => (c.title || 'Untitled Chat').toLowerCase().includes(q)) - .map((c) => ({ - type: 'Chats' as const, - id: c.id, - value: c, - onClick: () => insertPastChatMention(c), - })), - ...logsList - .filter((l) => - (l.workflowName || 'Untitled Workflow').toLowerCase().includes(q) - ) - .map((l) => ({ - type: 'Logs' as const, - id: l.id, - value: l, - onClick: () => insertLogMention(l), - })), - ] - return ( -
      - {aggregated.length === 0 ? ( + )) + )} + + )} + {isSubmenu('Blocks') && ( + <> + {isLoadingBlocks ? ( +
      + Loading... +
      + ) : blocksList.length === 0 ? (
      - No matches + No blocks found
      ) : ( - aggregated.map((item, idx) => ( -
      setSubmenuActiveIndex(idx)} - onClick={() => item.onClick()} - > - {item.type === 'Chats' ? ( - <> -
      - -
      - - {(item.value as any).title || 'Untitled Chat'} - - - ) : item.type === 'Workflows' ? ( - <> -
      - - {(item.value as any).name || 'Untitled Workflow'} - - - ) : item.type === 'Knowledge' ? ( - <> - - - {(item.value as any).name || 'Untitled'} - - - ) : item.type === 'Blocks' ? ( - <> -
      - {(() => { - const Icon = (item.value as any).iconComponent - return Icon ? ( - - ) : null - })()} -
      - - {(item.value as any).name || (item.value as any).id} - - - ) : item.type === 'Workflow Blocks' ? ( - <> -
      - {(() => { - const Icon = (item.value as any).iconComponent - return Icon ? ( - - ) : null - })()} -
      - - {(item.value as any).name || (item.value as any).id} - - - ) : item.type === 'Logs' ? ( - <> - {(() => { - const v = item.value as any - return v.level === 'error' ? ( - - ) : ( - - ) - })()} - - {(item.value as any).workflowName} - - · - - {formatTimestamp((item.value as any).createdAt)} - - · - - {( - ((item.value as any).trigger as string) || 'manual' - ).toLowerCase()} - - - ) : ( - <> -
      - ★ -
      - - {(item.value as any).name || 'Untitled Template'} - - {typeof (item.value as any).stars === 'number' && ( - - {(item.value as any).stars} - + blocksList + .filter((b) => + (b.name || b.id) + .toLowerCase() + .includes(getSubmenuQuery().toLowerCase()) + ) + .map((blk, idx) => ( +
      setSubmenuActiveIndex(idx)} + onClick={() => { + insertBlockMention(blk) + setSubmenuQueryStart(null) + }} + > +
      + {blk.iconComponent && ( + )} - - )} -
      - )) +
      + {blk.name || blk.id} +
      + )) )} -
      - ) - } - // Filtered top-level options view - return ( -
      - {filtered.map((label, idx) => ( -
      { - setInAggregated(false) - setMentionActiveIndex(idx) - }} - onClick={() => { - if (label === 'Chats') { - resetActiveMentionQuery() - setOpenSubmenuFor('Chats') - setSubmenuActiveIndex(0) - setSubmenuQueryStart(getCaretPos()) - void ensurePastChatsLoaded() - } else if (label === 'Workflows') { - resetActiveMentionQuery() - setOpenSubmenuFor('Workflows') - setSubmenuActiveIndex(0) - setSubmenuQueryStart(getCaretPos()) - void ensureWorkflowsLoaded() - } else if (label === 'Knowledge') { - resetActiveMentionQuery() - setOpenSubmenuFor('Knowledge') - setSubmenuActiveIndex(0) - setSubmenuQueryStart(getCaretPos()) - void ensureKnowledgeLoaded() - } else if (label === 'Blocks') { - resetActiveMentionQuery() - setOpenSubmenuFor('Blocks') - setSubmenuActiveIndex(0) - setSubmenuQueryStart(getCaretPos()) - void ensureBlocksLoaded() - } else if (label === 'Workflow Blocks') { - resetActiveMentionQuery() - setOpenSubmenuFor('Workflow Blocks') - setSubmenuActiveIndex(0) - setSubmenuQueryStart(getCaretPos()) - void ensureWorkflowBlocksLoaded() - } else if (label === 'Docs') { - // No submenu; insert immediately - insertDocsMention() - } else if (label === 'Templates') { - resetActiveMentionQuery() - setOpenSubmenuFor('Templates') - setSubmenuActiveIndex(0) - setSubmenuQueryStart(getCaretPos()) - void ensureTemplatesLoaded() - } else if (label === 'Logs') { - resetActiveMentionQuery() - setOpenSubmenuFor('Logs') - setSubmenuActiveIndex(0) - setSubmenuQueryStart(getCaretPos()) - void ensureLogsLoaded() - } - }} - > -
      - {label === 'Chats' ? ( - - ) : label === 'Workflows' ? ( - - ) : label === 'Blocks' ? ( - - ) : label === 'Workflow Blocks' ? ( - - ) : label === 'Knowledge' ? ( - - ) : label === 'Docs' ? ( - - ) : label === 'Templates' ? ( - - ) : label === 'Logs' ? ( - - ) : ( -
      - )} - {label === 'Workflows' ? 'All workflows' : label} + + )} + {isSubmenu('Workflow Blocks') && ( + <> + {isLoadingWorkflowBlocks ? ( +
      + Loading...
      - {label !== 'Docs' && ( - - )} -
      - ))} - - {(() => { - const aq = ( - getActiveMentionQueryAtPosition(getCaretPos())?.query || '' - ).toLowerCase() - const filteredLen = mentionOptions.filter((label) => - label.toLowerCase().includes(aq) - ).length - const aggregated = [ - ...workflowBlocks - .filter((b) => (b.name || b.id).toLowerCase().includes(aq)) - .map((b) => ({ type: 'Workflow Blocks' as const, value: b })), - ...workflows - .filter((w) => - (w.name || 'Untitled Workflow').toLowerCase().includes(aq) + ) : workflowBlocks.length === 0 ? ( +
      + No blocks in this workflow +
      + ) : ( + workflowBlocks + .filter((b) => + (b.name || b.id) + .toLowerCase() + .includes(getSubmenuQuery().toLowerCase()) ) - .map((w) => ({ type: 'Workflows' as const, value: w })), - ...blocksList - .filter((b) => (b.name || b.id).toLowerCase().includes(aq)) - .map((b) => ({ type: 'Blocks' as const, value: b })), - ...knowledgeBases - .filter((k) => (k.name || 'Untitled').toLowerCase().includes(aq)) - .map((k) => ({ type: 'Knowledge' as const, value: k })), - ...templatesList + .map((blk, idx) => ( +
      setSubmenuActiveIndex(idx)} + onClick={() => { + insertWorkflowBlockMention(blk) + setSubmenuQueryStart(null) + }} + > +
      + {blk.iconComponent && ( + + )} +
      + {blk.name || blk.id} +
      + )) + )} + + )} + {isSubmenu('Templates') && ( + <> + {isLoadingTemplates ? ( +
      + Loading... +
      + ) : templatesList.length === 0 ? ( +
      + No templates found +
      + ) : ( + templatesList .filter((t) => - (t.name || 'Untitled Template').toLowerCase().includes(aq) - ) - .map((t) => ({ type: 'Templates' as const, value: t })), - ...pastChats - .filter((c) => - (c.title || 'Untitled Chat').toLowerCase().includes(aq) + (t.name || 'Untitled Template') + .toLowerCase() + .includes(getSubmenuQuery().toLowerCase()) ) - .map((c) => ({ type: 'Chats' as const, value: c })), - ...logsList + .map((tpl, idx) => ( +
      setSubmenuActiveIndex(idx)} + onClick={() => { + insertTemplateMention(tpl) + setSubmenuQueryStart(null) + }} + > +
      + ★ +
      + {tpl.name} + + {tpl.stars} + +
      + )) + )} + + )} + {isSubmenu('Logs') && ( + <> + {isLoadingLogs ? ( +
      + Loading... +
      + ) : logsList.length === 0 ? ( +
      + No executions found +
      + ) : ( + logsList .filter((l) => - (l.workflowName || 'Untitled Workflow') + [l.workflowName, l.trigger || ''] + .join(' ') .toLowerCase() - .includes(aq) + .includes(getSubmenuQuery().toLowerCase()) ) - .map((l) => ({ type: 'Logs' as const, value: l })), - ] - if (!aq || aq.length === 0 || aggregated.length === 0) return null - return ( - <> -
      -
      - Matches -
      - {aggregated.map((item, idx) => ( + .map((log, idx) => (
      { - setInAggregated(true) - setSubmenuActiveIndex(idx) - }} + aria-selected={submenuActiveIndex === idx} + onMouseEnter={() => setSubmenuActiveIndex(idx)} onClick={() => { - if (item.type === 'Chats') - insertPastChatMention(item.value as any) - else if (item.type === 'Workflows') - insertWorkflowMention(item.value as any) - else if (item.type === 'Knowledge') - insertKnowledgeMention(item.value as any) - else if (item.type === 'Blocks') - insertBlockMention(item.value as any) - else if ((item as any).type === 'Workflow Blocks') - insertWorkflowBlockMention(item.value as any) - else if (item.type === 'Templates') - insertTemplateMention(item.value as any) - else if (item.type === 'Logs') - insertLogMention(item.value as any) + insertLogMention(log) + setSubmenuQueryStart(null) }} + > + {log.level === 'error' ? ( + + ) : ( + + )} + {log.workflowName} + · + + {formatTimestamp(log.createdAt)} + + · + + {(log.trigger || 'manual').toLowerCase()} + +
      + )) + )} + + )} +
      + + ) : ( + <> + {(() => { + const q = ( + getActiveMentionQueryAtPosition(getCaretPos())?.query || '' + ).toLowerCase() + const filtered = mentionOptions.filter((label) => + label.toLowerCase().includes(q) + ) + if (q.length > 0 && filtered.length === 0) { + // Aggregated search view + const aggregated = [ + ...workflowBlocks + .filter((b) => (b.name || b.id).toLowerCase().includes(q)) + .map((b) => ({ + type: 'Workflow Blocks' as const, + id: b.id, + value: b, + onClick: () => insertWorkflowBlockMention(b), + })), + ...workflows + .filter((w) => + (w.name || 'Untitled Workflow').toLowerCase().includes(q) + ) + .map((w) => ({ + type: 'Workflows' as const, + id: w.id, + value: w, + onClick: () => insertWorkflowMention(w), + })), + ...blocksList + .filter((b) => (b.name || b.id).toLowerCase().includes(q)) + .map((b) => ({ + type: 'Blocks' as const, + id: b.id, + value: b, + onClick: () => insertBlockMention(b), + })), + ...knowledgeBases + .filter((k) => (k.name || 'Untitled').toLowerCase().includes(q)) + .map((k) => ({ + type: 'Knowledge' as const, + id: k.id, + value: k, + onClick: () => insertKnowledgeMention(k), + })), + ...templatesList + .filter((t) => + (t.name || 'Untitled Template').toLowerCase().includes(q) + ) + .map((t) => ({ + type: 'Templates' as const, + id: t.id, + value: t, + onClick: () => insertTemplateMention(t), + })), + ...pastChats + .filter((c) => + (c.title || 'Untitled Chat').toLowerCase().includes(q) + ) + .map((c) => ({ + type: 'Chats' as const, + id: c.id, + value: c, + onClick: () => insertPastChatMention(c), + })), + ...logsList + .filter((l) => + (l.workflowName || 'Untitled Workflow').toLowerCase().includes(q) + ) + .map((l) => ({ + type: 'Logs' as const, + id: l.id, + value: l, + onClick: () => insertLogMention(l), + })), + ] + return ( +
      + {aggregated.length === 0 ? ( +
      + No matches +
      + ) : ( + aggregated.map((item, idx) => ( +
      setSubmenuActiveIndex(idx)} + onClick={() => item.onClick()} > {item.type === 'Chats' ? ( <> @@ -2966,18 +2938,313 @@ const UserInput = forwardRef( )}
      - ))} - - ) - })()} -
      - ) - })()} - - )} -
      - - )} + )) + )} +
      + ) + } + // Filtered top-level options view + return ( +
      + {filtered.map((label, idx) => ( +
      { + setInAggregated(false) + setMentionActiveIndex(idx) + }} + onClick={() => { + if (label === 'Chats') { + resetActiveMentionQuery() + setOpenSubmenuFor('Chats') + setSubmenuActiveIndex(0) + setSubmenuQueryStart(getCaretPos()) + void ensurePastChatsLoaded() + } else if (label === 'Workflows') { + resetActiveMentionQuery() + setOpenSubmenuFor('Workflows') + setSubmenuActiveIndex(0) + setSubmenuQueryStart(getCaretPos()) + void ensureWorkflowsLoaded() + } else if (label === 'Knowledge') { + resetActiveMentionQuery() + setOpenSubmenuFor('Knowledge') + setSubmenuActiveIndex(0) + setSubmenuQueryStart(getCaretPos()) + void ensureKnowledgeLoaded() + } else if (label === 'Blocks') { + resetActiveMentionQuery() + setOpenSubmenuFor('Blocks') + setSubmenuActiveIndex(0) + setSubmenuQueryStart(getCaretPos()) + void ensureBlocksLoaded() + } else if (label === 'Workflow Blocks') { + resetActiveMentionQuery() + setOpenSubmenuFor('Workflow Blocks') + setSubmenuActiveIndex(0) + setSubmenuQueryStart(getCaretPos()) + void ensureWorkflowBlocksLoaded() + } else if (label === 'Docs') { + // No submenu; insert immediately + insertDocsMention() + } else if (label === 'Templates') { + resetActiveMentionQuery() + setOpenSubmenuFor('Templates') + setSubmenuActiveIndex(0) + setSubmenuQueryStart(getCaretPos()) + void ensureTemplatesLoaded() + } else if (label === 'Logs') { + resetActiveMentionQuery() + setOpenSubmenuFor('Logs') + setSubmenuActiveIndex(0) + setSubmenuQueryStart(getCaretPos()) + void ensureLogsLoaded() + } + }} + > +
      + {label === 'Chats' ? ( + + ) : label === 'Workflows' ? ( + + ) : label === 'Blocks' ? ( + + ) : label === 'Workflow Blocks' ? ( + + ) : label === 'Knowledge' ? ( + + ) : label === 'Docs' ? ( + + ) : label === 'Templates' ? ( + + ) : label === 'Logs' ? ( + + ) : ( +
      + )} + {label === 'Workflows' ? 'All workflows' : label} +
      + {label !== 'Docs' && ( + + )} +
      + ))} + + {(() => { + const aq = ( + getActiveMentionQueryAtPosition(getCaretPos())?.query || '' + ).toLowerCase() + const filteredLen = mentionOptions.filter((label) => + label.toLowerCase().includes(aq) + ).length + const aggregated = [ + ...workflowBlocks + .filter((b) => (b.name || b.id).toLowerCase().includes(aq)) + .map((b) => ({ type: 'Workflow Blocks' as const, value: b })), + ...workflows + .filter((w) => + (w.name || 'Untitled Workflow').toLowerCase().includes(aq) + ) + .map((w) => ({ type: 'Workflows' as const, value: w })), + ...blocksList + .filter((b) => (b.name || b.id).toLowerCase().includes(aq)) + .map((b) => ({ type: 'Blocks' as const, value: b })), + ...knowledgeBases + .filter((k) => + (k.name || 'Untitled').toLowerCase().includes(aq) + ) + .map((k) => ({ type: 'Knowledge' as const, value: k })), + ...templatesList + .filter((t) => + (t.name || 'Untitled Template').toLowerCase().includes(aq) + ) + .map((t) => ({ type: 'Templates' as const, value: t })), + ...pastChats + .filter((c) => + (c.title || 'Untitled Chat').toLowerCase().includes(aq) + ) + .map((c) => ({ type: 'Chats' as const, value: c })), + ...logsList + .filter((l) => + (l.workflowName || 'Untitled Workflow') + .toLowerCase() + .includes(aq) + ) + .map((l) => ({ type: 'Logs' as const, value: l })), + ] + if (!aq || aq.length === 0 || aggregated.length === 0) return null + return ( + <> +
      +
      + Matches +
      + {aggregated.map((item, idx) => ( +
      { + setInAggregated(true) + setSubmenuActiveIndex(idx) + }} + onClick={() => { + if (item.type === 'Chats') + insertPastChatMention(item.value as any) + else if (item.type === 'Workflows') + insertWorkflowMention(item.value as any) + else if (item.type === 'Knowledge') + insertKnowledgeMention(item.value as any) + else if (item.type === 'Blocks') + insertBlockMention(item.value as any) + else if ((item as any).type === 'Workflow Blocks') + insertWorkflowBlockMention(item.value as any) + else if (item.type === 'Templates') + insertTemplateMention(item.value as any) + else if (item.type === 'Logs') + insertLogMention(item.value as any) + }} + > + {item.type === 'Chats' ? ( + <> +
      + +
      + + {(item.value as any).title || 'Untitled Chat'} + + + ) : item.type === 'Workflows' ? ( + <> +
      + + {(item.value as any).name || 'Untitled Workflow'} + + + ) : item.type === 'Knowledge' ? ( + <> + + + {(item.value as any).name || 'Untitled'} + + + ) : item.type === 'Blocks' ? ( + <> +
      + {(() => { + const Icon = (item.value as any).iconComponent + return Icon ? ( + + ) : null + })()} +
      + + {(item.value as any).name || (item.value as any).id} + + + ) : item.type === 'Workflow Blocks' ? ( + <> +
      + {(() => { + const Icon = (item.value as any).iconComponent + return Icon ? ( + + ) : null + })()} +
      + + {(item.value as any).name || (item.value as any).id} + + + ) : item.type === 'Logs' ? ( + <> + {(() => { + const v = item.value as any + return v.level === 'error' ? ( + + ) : ( + + ) + })()} + + {(item.value as any).workflowName} + + · + + {formatTimestamp((item.value as any).createdAt)} + + · + + {( + ((item.value as any).trigger as string) || 'manual' + ).toLowerCase()} + + + ) : ( + <> +
      + ★ +
      + + {(item.value as any).name || 'Untitled Template'} + + {typeof (item.value as any).stars === 'number' && ( + + {(item.value as any).stars} + + )} + + )} +
      + ))} + + ) + })()} +
      + ) + })()} + + )} +
      +
      , + document.body + )}
      {/* Bottom Row: Mode Selector + Attach Button + Send Button */} @@ -2996,7 +3263,11 @@ const UserInput = forwardRef( {getModeText()} - +
      @@ -3066,7 +3337,9 @@ const UserInput = forwardRef( const isBrainCircuitModel = ['gpt-5-high', 'o3', 'claude-4.1-opus'].includes( selectedModel ) - const showPurple = (isBrainModel || isBrainCircuitModel) && !agentPrefetch + const isHaikuModel = selectedModel === 'claude-4.5-haiku' + const showPurple = + (isBrainModel || isBrainCircuitModel || isHaikuModel) && !agentPrefetch return ( ( - +
      @@ -3127,7 +3404,14 @@ const UserInput = forwardRef( ) { return } - if (['gpt-4o', 'gpt-4.1', 'gpt-5-fast'].includes(modelValue)) { + if ( + [ + 'gpt-4o', + 'gpt-4.1', + 'gpt-5-fast', + 'claude-4.5-haiku', + ].includes(modelValue) + ) { return } return
      @@ -3162,65 +3446,64 @@ const UserInput = forwardRef( return ( <> - {/* OpenAI Models */} + {/* Anthropic Models */}
      - OpenAI + Anthropic
      {modelOptions .filter((option) => [ - 'gpt-5-fast', - 'gpt-5', - 'gpt-5-medium', - 'gpt-5-high', - 'gpt-4o', - 'gpt-4.1', - 'o3', + 'claude-4-sonnet', + 'claude-4.5-haiku', + 'claude-4.5-sonnet', + 'claude-4.1-opus', ].includes(option.value) ) .map(renderModelOption)}
      - {/* Anthropic Models */} + {/* OpenAI Models */}
      - Anthropic + OpenAI
      {modelOptions .filter((option) => [ - 'claude-4-sonnet', - 'claude-4.5-sonnet', - 'claude-4.1-opus', + 'gpt-5-fast', + 'gpt-5', + 'gpt-5-medium', + 'gpt-5-high', + 'gpt-4o', + 'gpt-4.1', + 'o3', ].includes(option.value) ) .map(renderModelOption)}
      - {/* More Models Button (only for hosted) */} - {isHosted && ( -
      - -
      - )} + {/* More Models Button */} +
      + +
      ) })()} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot.tsx index 2e9fed2a73f..25ae4508265 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot.tsx @@ -23,6 +23,21 @@ import { useWorkflowRegistry } from '@/stores/workflows/registry/store' const logger = createLogger('Copilot') +// Default enabled/disabled state for all models (must match API) +const DEFAULT_ENABLED_MODELS: Record = { + 'gpt-4o': false, + 'gpt-4.1': false, + 'gpt-5-fast': false, + 'gpt-5': true, + 'gpt-5-medium': true, + 'gpt-5-high': false, + o3: true, + 'claude-4-sonnet': false, + 'claude-4.5-haiku': true, + 'claude-4.5-sonnet': true, + 'claude-4.1-opus': true, +} + interface CopilotProps { panelWidth: number } @@ -40,6 +55,10 @@ export const Copilot = forwardRef(({ panelWidth }, ref const [todosCollapsed, setTodosCollapsed] = useState(false) const lastWorkflowIdRef = useRef(null) const hasMountedRef = useRef(false) + const hasLoadedModelsRef = useRef(false) + const [editingMessageId, setEditingMessageId] = useState(null) + const [isEditingMessage, setIsEditingMessage] = useState(false) + const [revertingMessageId, setRevertingMessageId] = useState(null) // Scroll state const [isNearBottom, setIsNearBottom] = useState(true) @@ -71,8 +90,82 @@ export const Copilot = forwardRef(({ panelWidth }, ref chatsLoadedForWorkflow, setWorkflowId: setCopilotWorkflowId, loadChats, + enabledModels, + setEnabledModels, + selectedModel, + setSelectedModel, + messageCheckpoints, + currentChat, + fetchContextUsage, } = useCopilotStore() + // Load user's enabled models on mount + useEffect(() => { + const loadEnabledModels = async () => { + if (hasLoadedModelsRef.current) return + hasLoadedModelsRef.current = true + + try { + const res = await fetch('/api/copilot/user-models') + if (!res.ok) { + logger.warn('Failed to fetch user models, using defaults') + // Use defaults if fetch fails + const enabledArray = Object.keys(DEFAULT_ENABLED_MODELS).filter( + (key) => DEFAULT_ENABLED_MODELS[key] + ) + setEnabledModels(enabledArray) + return + } + + const data = await res.json() + const modelsMap = data.enabledModels || DEFAULT_ENABLED_MODELS + + // Convert map to array of enabled model IDs + const enabledArray = Object.entries(modelsMap) + .filter(([_, enabled]) => enabled) + .map(([modelId]) => modelId) + + setEnabledModels(enabledArray) + logger.info('Loaded user enabled models', { count: enabledArray.length }) + } catch (error) { + logger.error('Failed to load enabled models', { error }) + // Use defaults on error + const enabledArray = Object.keys(DEFAULT_ENABLED_MODELS).filter( + (key) => DEFAULT_ENABLED_MODELS[key] + ) + setEnabledModels(enabledArray) + } + } + + loadEnabledModels() + }, [setEnabledModels]) + + // Ensure selected model is in the enabled models list + useEffect(() => { + if (!enabledModels || enabledModels.length === 0) return + + // Check if current selected model is in the enabled list + if (selectedModel && !enabledModels.includes(selectedModel)) { + // Switch to the first enabled model (prefer claude-4.5-sonnet if available) + const preferredModel = 'claude-4.5-sonnet' + const fallbackModel = enabledModels[0] as typeof selectedModel + + if (enabledModels.includes(preferredModel)) { + setSelectedModel(preferredModel) + logger.info('Selected model not enabled, switching to preferred model', { + from: selectedModel, + to: preferredModel, + }) + } else if (fallbackModel) { + setSelectedModel(fallbackModel) + logger.info('Selected model not enabled, switching to first available', { + from: selectedModel, + to: fallbackModel, + }) + } + } + }, [enabledModels, selectedModel, setSelectedModel]) + // Force fresh initialization on mount (handles hot reload) useEffect(() => { if (activeWorkflowId && !hasMountedRef.current) { @@ -110,6 +203,16 @@ export const Copilot = forwardRef(({ panelWidth }, ref } }, [activeWorkflowId, isLoadingChats, chatsLoadedForWorkflow, isInitialized]) + // Fetch context usage when component is initialized and has a current chat + useEffect(() => { + if (isInitialized && currentChat?.id && activeWorkflowId) { + logger.info('[Copilot] Component initialized, fetching context usage') + fetchContextUsage().catch((err) => { + logger.warn('[Copilot] Failed to fetch context usage on mount', err) + }) + } + }, [isInitialized, currentChat?.id, activeWorkflowId, fetchContextUsage]) + // Clear any existing preview when component mounts or workflow changes useEffect(() => { // Preview clearing is now handled automatically by the copilot store @@ -357,6 +460,16 @@ export const Copilot = forwardRef(({ panelWidth }, ref [isSendingMessage, activeWorkflowId, sendMessage, showPlanTodos] ) + const handleEditModeChange = useCallback((messageId: string, isEditing: boolean) => { + setEditingMessageId(isEditing ? messageId : null) + setIsEditingMessage(isEditing) + logger.info('Edit mode changed', { messageId, isEditing, willDimMessages: isEditing }) + }, []) + + const handleRevertModeChange = useCallback((messageId: string, isReverting: boolean) => { + setRevertingMessageId(isReverting ? messageId : null) + }, []) + return ( <>
      @@ -376,8 +489,8 @@ export const Copilot = forwardRef(({ panelWidth }, ref ) : (
      -
      - {messages.length === 0 ? ( +
      + {messages.length === 0 && !isSendingMessage && !isEditingMessage ? (
      (({ panelWidth }, ref />
      ) : ( - messages.map((message) => ( - - )) + messages.map((message, index) => { + // Determine if this message should be dimmed + let isDimmed = false + + // Dim messages after the one being edited + if (editingMessageId) { + const editingIndex = messages.findIndex((m) => m.id === editingMessageId) + isDimmed = editingIndex !== -1 && index > editingIndex + } + + // Also dim messages after the one showing restore confirmation + if (!isDimmed && revertingMessageId) { + const revertingIndex = messages.findIndex( + (m) => m.id === revertingMessageId + ) + isDimmed = revertingIndex !== -1 && index > revertingIndex + } + + // Get checkpoint count for this message to force re-render when it changes + const checkpointCount = messageCheckpoints[message.id]?.length || 0 + + return ( + + handleEditModeChange(message.id, isEditing) + } + onRevertModeChange={(isReverting) => + handleRevertModeChange(message.id, isReverting) + } + /> + ) + }) )}
      @@ -429,19 +573,21 @@ export const Copilot = forwardRef(({ panelWidth }, ref {/* Input area with integrated mode selector */} {!showCheckpoints && ( - +
      + +
      )} )} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx index e0d2a7a51c0..72f1d0ad130 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx @@ -1,13 +1,12 @@ 'use client' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { ArrowDownToLine, CircleSlash, History, Plus, X } from 'lucide-react' +import { ArrowDownToLine, CircleSlash, History, Pencil, Plus, Trash2, X } from 'lucide-react' import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' -import { ScrollArea } from '@/components/ui/scroll-area' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { LandingPromptStorage } from '@/lib/browser-storage' import { createLogger } from '@/lib/logs/console/logger' @@ -26,6 +25,8 @@ const logger = createLogger('Panel') export function Panel() { const [chatMessage, setChatMessage] = useState('') const [isHistoryDropdownOpen, setIsHistoryDropdownOpen] = useState(false) + const [editingChatId, setEditingChatId] = useState(null) + const [editingChatTitle, setEditingChatTitle] = useState('') const [isResizing, setIsResizing] = useState(false) const [resizeStartX, setResizeStartX] = useState(0) @@ -432,61 +433,135 @@ export function Panel() { {isLoadingChats ? ( - +
      - +
      ) : groupedChats.length === 0 ? ( -
      No chats yet
      +
      + No chats yet +
      ) : ( - +
      {groupedChats.map(([groupName, chats], groupIndex) => (
      {groupName}
      -
      +
      {chats.map((chat) => (
      { - // Only call selectChat if it's a different chat - // This prevents aborting streams when clicking the currently active chat - if (currentChat?.id !== chat.id) { - selectChat(chat) - } - setIsHistoryDropdownOpen(false) - }} - className={`group mx-1 flex h-8 cursor-pointer items-center rounded-lg px-2 py-1.5 text-left transition-colors ${ + className={`group relative flex items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors ${ currentChat?.id === chat.id - ? 'bg-accent' - : 'hover:bg-accent/50' + ? 'bg-accent text-accent-foreground' + : 'text-foreground hover:bg-accent/50' }`} - style={{ width: '176px', maxWidth: '176px' }} > - - {chat.title || 'Untitled Chat'} - + {editingChatId === chat.id ? ( + setEditingChatTitle(e.target.value)} + onKeyDown={async (e) => { + if (e.key === 'Enter') { + e.preventDefault() + const newTitle = + editingChatTitle.trim() || 'Untitled Chat' + + // Update optimistically in store first + const updatedChats = chats.map((c) => + c.id === chat.id ? { ...c, title: newTitle } : c + ) + useCopilotStore.setState({ chats: updatedChats }) + + // Exit edit mode immediately + setEditingChatId(null) + + // Save to database in background + try { + await fetch('/api/copilot/chat/update-title', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + chatId: chat.id, + title: newTitle, + }), + }) + } catch (error) { + logger.error('Failed to update chat title:', error) + // Revert on error + await loadChats(true) + } + } else if (e.key === 'Escape') { + setEditingChatId(null) + } + }} + onBlur={() => setEditingChatId(null)} + className='min-w-0 flex-1 rounded border-none bg-transparent px-0 text-sm outline-none focus:outline-none' + /> + ) : ( + <> + { + // Only call selectChat if it's a different chat + if (currentChat?.id !== chat.id) { + selectChat(chat) + } + setIsHistoryDropdownOpen(false) + }} + className='min-w-0 cursor-pointer truncate text-sm' + style={{ maxWidth: 'calc(100% - 60px)' }} + > + {chat.title || 'Untitled Chat'} + +
      + + +
      + + )}
      ))}
      ))} - +
      )} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/copilot/copilot.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/copilot/copilot.tsx index 7def67e0f9c..f4c403e0e87 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/copilot/copilot.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/copilot/copilot.tsx @@ -44,6 +44,8 @@ const OPENAI_MODELS: ModelOption[] = [ ] const ANTHROPIC_MODELS: ModelOption[] = [ + // Zap model (Haiku) + { value: 'claude-4.5-haiku', label: 'claude-4.5-haiku', icon: 'zap' }, // Brain models { value: 'claude-4-sonnet', label: 'claude-4-sonnet', icon: 'brain' }, { value: 'claude-4.5-sonnet', label: 'claude-4.5-sonnet', icon: 'brain' }, @@ -62,7 +64,8 @@ const DEFAULT_ENABLED_MODELS: Record = { 'gpt-5-medium': true, 'gpt-5-high': false, o3: true, - 'claude-4-sonnet': true, + 'claude-4-sonnet': false, + 'claude-4.5-haiku': true, 'claude-4.5-sonnet': true, 'claude-4.1-opus': true, } @@ -328,13 +331,13 @@ export function Copilot() {
      ) : (
      - {/* OpenAI Models */} + {/* Anthropic Models */}
      - OpenAI + Anthropic
      - {OPENAI_MODELS.map((model) => { + {ANTHROPIC_MODELS.map((model) => { const isEnabled = enabledModelsMap[model.value] ?? false return (
      - {/* Anthropic Models */} + {/* OpenAI Models */}
      - Anthropic + OpenAI
      - {ANTHROPIC_MODELS.map((model) => { + {OPENAI_MODELS.map((model) => { const isEnabled = enabledModelsMap[model.value] ?? false return (
      = [] + Object.entries(variables).forEach(([key, value]) => { + if (typeof value === 'object' && value !== null && 'name' in value && 'value' in value) { + // Handle {name: "key", value: "val"} format + normalizedEntries.push([String((value as any).name), String((value as any).value)]) + } else { + // Handle direct key-value format + normalizedEntries.push([key, String(value)]) + } + }) + return (
      @@ -337,18 +349,21 @@ export function InlineToolCall({ Value
      - {entries.length === 0 ? ( + {normalizedEntries.length === 0 ? (
      No variables provided
      ) : (
      - {entries.map(([k, v]) => ( -
      + {normalizedEntries.map(([name, value]) => ( +
      - {k} + {name}
      - {String(v)} + {value}
      @@ -455,7 +470,7 @@ export function InlineToolCall({ >
      {renderDisplayIcon()}
      - {displayName} + {displayName}
      {showButtons ? ( diff --git a/apps/sim/lib/copilot/tools/client/user/set-environment-variables.ts b/apps/sim/lib/copilot/tools/client/user/set-environment-variables.ts index a5e1eb827eb..1c0dca81602 100644 --- a/apps/sim/lib/copilot/tools/client/user/set-environment-variables.ts +++ b/apps/sim/lib/copilot/tools/client/user/set-environment-variables.ts @@ -6,6 +6,7 @@ import { } from '@/lib/copilot/tools/client/base-tool' import { ExecuteResponseSuccessSchema } from '@/lib/copilot/tools/shared/schemas' import { createLogger } from '@/lib/logs/console/logger' +import { useEnvironmentStore } from '@/stores/settings/environment/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' interface SetEnvArgs { @@ -77,6 +78,14 @@ export class SetEnvironmentVariablesClientTool extends BaseClientTool { this.setState(ClientToolCallState.success) await this.markToolComplete(200, 'Environment variables updated', parsed.result) this.setState(ClientToolCallState.success) + + // Refresh the environment store so the UI reflects the new variables + try { + await useEnvironmentStore.getState().loadEnvironmentVariables() + logger.info('Environment store refreshed after setting variables') + } catch (error) { + logger.warn('Failed to refresh environment store:', error) + } } catch (e: any) { logger.error('execute failed', { message: e?.message }) this.setState(ClientToolCallState.error) diff --git a/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts index 94328c68a13..9038b53df55 100644 --- a/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts +++ b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts @@ -95,6 +95,7 @@ export interface CopilotBlockMetadata { inputSchema?: CopilotSubblockMetadata[] } > + outputs?: Record yamlDocumentation?: string } @@ -130,6 +131,7 @@ export const getBlocksMetadataServerTool: BaseServerTool< tools: [], triggers: [], operationInputSchema: operationParameters, + outputs: specialBlock.outputs, } ;(metadata as any).subBlocks = undefined } else { @@ -209,6 +211,7 @@ export const getBlocksMetadataServerTool: BaseServerTool< triggers, operationInputSchema: operationParameters, operations, + outputs: blockConfig.outputs, } } @@ -236,10 +239,345 @@ export const getBlocksMetadataServerTool: BaseServerTool< } } - return GetBlocksMetadataResult.parse({ metadata: result }) + // Transform metadata to cleaner format + const transformedResult: Record = {} + for (const [blockId, metadata] of Object.entries(result)) { + transformedResult[blockId] = transformBlockMetadata(metadata) + } + + return GetBlocksMetadataResult.parse({ metadata: transformedResult }) }, } +function transformBlockMetadata(metadata: CopilotBlockMetadata): any { + const transformed: any = { + blockType: metadata.id, + name: metadata.name, + description: metadata.description, + } + + // Add best practices if available + if (metadata.bestPractices) { + transformed.bestPractices = metadata.bestPractices + } + + // Add auth type and required credentials if available + if (metadata.authType) { + transformed.authType = metadata.authType + + // Add credential requirements based on auth type + if (metadata.authType === 'OAuth') { + transformed.requiredCredentials = { + type: 'oauth', + service: metadata.id, // e.g., 'gmail', 'slack', etc. + description: `OAuth authentication required for ${metadata.name}`, + } + } else if (metadata.authType === 'API Key') { + transformed.requiredCredentials = { + type: 'api_key', + description: `API key required for ${metadata.name}`, + } + } else if (metadata.authType === 'Bot Token') { + transformed.requiredCredentials = { + type: 'bot_token', + description: `Bot token required for ${metadata.name}`, + } + } + } + + // Process inputs + const inputs = extractInputs(metadata) + if (inputs.required.length > 0 || inputs.optional.length > 0) { + transformed.inputs = inputs + } + + // Add operations if available + const hasOperations = metadata.operations && Object.keys(metadata.operations).length > 0 + if (hasOperations && metadata.operations) { + const blockLevelInputs = new Set(Object.keys(metadata.inputDefinitions || {})) + transformed.operations = Object.entries(metadata.operations).reduce( + (acc, [opId, opData]) => { + acc[opId] = { + name: opData.toolName || opId, + description: opData.description, + inputs: extractOperationInputs(opData, blockLevelInputs), + outputs: formatOutputsFromDefinition(opData.outputs || {}), + } + return acc + }, + {} as Record + ) + } + + // Process outputs - only show at block level if there are NO operations + // For blocks with operations, outputs are shown per-operation to avoid ambiguity + if (!hasOperations) { + const outputs = extractOutputs(metadata) + if (outputs.length > 0) { + transformed.outputs = outputs + } + } + + // Don't include availableTools - it's internal implementation detail + // For agent block, tools.access contains LLM provider APIs (not useful) + // For other blocks, it's redundant with operations + + // Add triggers if present + if (metadata.triggers && metadata.triggers.length > 0) { + transformed.triggers = metadata.triggers.map((t) => ({ + id: t.id, + outputs: formatOutputsFromDefinition(t.outputs || {}), + })) + } + + // Add YAML documentation if available + if (metadata.yamlDocumentation) { + transformed.yamlDocumentation = metadata.yamlDocumentation + } + + return transformed +} + +function extractInputs(metadata: CopilotBlockMetadata): { + required: any[] + optional: any[] +} { + const required: any[] = [] + const optional: any[] = [] + const inputDefs = metadata.inputDefinitions || {} + + // Process inputSchema to get UI-level input information + for (const schema of metadata.inputSchema || []) { + // Skip credential inputs (handled by requiredCredentials) + if ( + schema.type === 'oauth-credential' || + schema.type === 'credential-input' || + schema.type === 'oauth-input' + ) { + continue + } + + // Skip trigger config (only relevant when setting up triggers) + if (schema.id === 'triggerConfig' || schema.type === 'trigger-config') { + continue + } + + const inputDef = inputDefs[schema.id] || inputDefs[schema.canonicalParamId || ''] + + // For operation field, provide a clearer description + let description = schema.description || inputDef?.description || schema.title + if (schema.id === 'operation') { + description = 'Operation to perform' + } + + const input: any = { + name: schema.id, + type: mapSchemaTypeToSimpleType(schema.type, schema), + description, + } + + // Add options for dropdown/combobox types + // For operation field, use IDs instead of labels for clarity + if (schema.options && schema.options.length > 0) { + if (schema.id === 'operation') { + input.options = schema.options.map((opt) => opt.id) + } else { + input.options = schema.options.map((opt) => opt.label || opt.id) + } + } + + // Add enum from input definitions + if (inputDef?.enum && Array.isArray(inputDef.enum)) { + input.options = inputDef.enum + } + + // Add default value if present + if (schema.defaultValue !== undefined) { + input.default = schema.defaultValue + } else if (inputDef?.default !== undefined) { + input.default = inputDef.default + } + + // Add constraints for numbers + if (schema.type === 'slider' || schema.type === 'number-input') { + if (schema.min !== undefined) input.min = schema.min + if (schema.max !== undefined) input.max = schema.max + } else if (inputDef?.minimum !== undefined || inputDef?.maximum !== undefined) { + if (inputDef.minimum !== undefined) input.min = inputDef.minimum + if (inputDef.maximum !== undefined) input.max = inputDef.maximum + } + + // Add example if we can infer one + const example = generateInputExample(schema, inputDef) + if (example !== undefined) { + input.example = example + } + + // Determine if required + // For blocks with operations, the operation field is always required + const isOperationField = + schema.id === 'operation' && + metadata.operations && + Object.keys(metadata.operations).length > 0 + const isRequired = schema.required || inputDef?.required || isOperationField + + if (isRequired) { + required.push(input) + } else { + optional.push(input) + } + } + + return { required, optional } +} + +function extractOperationInputs( + opData: any, + blockLevelInputs: Set +): { + required: any[] + optional: any[] +} { + const required: any[] = [] + const optional: any[] = [] + const inputs = opData.inputs || {} + + for (const [key, inputDef] of Object.entries(inputs)) { + // Skip inputs that are already defined at block level (avoid duplication) + if (blockLevelInputs.has(key)) { + continue + } + + // Skip credential-related inputs (these are inherited from block-level auth) + const lowerKey = key.toLowerCase() + if ( + lowerKey.includes('token') || + lowerKey.includes('credential') || + lowerKey.includes('apikey') + ) { + continue + } + + const input: any = { + name: key, + type: (inputDef as any)?.type || 'string', + description: (inputDef as any)?.description, + } + + if ((inputDef as any)?.enum) { + input.options = (inputDef as any).enum + } + + if ((inputDef as any)?.default !== undefined) { + input.default = (inputDef as any).default + } + + if ((inputDef as any)?.example !== undefined) { + input.example = (inputDef as any).example + } + + if ((inputDef as any)?.required) { + required.push(input) + } else { + optional.push(input) + } + } + + return { required, optional } +} + +function extractOutputs(metadata: CopilotBlockMetadata): any[] { + const outputs: any[] = [] + + // Use block's defined outputs if available + if (metadata.outputs && Object.keys(metadata.outputs).length > 0) { + return formatOutputsFromDefinition(metadata.outputs) + } + + // If block has operations, use the first operation's outputs as representative + if (metadata.operations && Object.keys(metadata.operations).length > 0) { + const firstOp = Object.values(metadata.operations)[0] + return formatOutputsFromDefinition(firstOp.outputs || {}) + } + + return outputs +} + +function formatOutputsFromDefinition(outputDefs: Record): any[] { + const outputs: any[] = [] + + for (const [key, def] of Object.entries(outputDefs)) { + const output: any = { + name: key, + type: typeof def === 'string' ? def : def?.type || 'any', + } + + if (typeof def === 'object') { + if (def.description) output.description = def.description + if (def.example) output.example = def.example + } + + outputs.push(output) + } + + return outputs +} + +function mapSchemaTypeToSimpleType(schemaType: string, schema: CopilotSubblockMetadata): string { + const typeMap: Record = { + 'short-input': 'string', + 'long-input': 'string', + 'code-input': 'string', + 'number-input': 'number', + slider: 'number', + dropdown: 'string', + combobox: 'string', + toggle: 'boolean', + 'json-input': 'json', + 'file-upload': 'file', + 'multi-select': 'array', + 'credential-input': 'credential', + 'oauth-credential': 'credential', + } + + const mappedType = typeMap[schemaType] || schemaType + + // Override with multiSelect + if (schema.multiSelect) return 'array' + + return mappedType +} + +function generateInputExample(schema: CopilotSubblockMetadata, inputDef?: any): any { + // Return explicit example if available + if (inputDef?.example !== undefined) return inputDef.example + + // Generate based on type + switch (schema.type) { + case 'short-input': + case 'long-input': + if (schema.id === 'systemPrompt') return 'You are a helpful assistant...' + if (schema.id === 'userPrompt') return 'What is the weather today?' + if (schema.placeholder) return schema.placeholder + return undefined + case 'number-input': + case 'slider': + return schema.defaultValue ?? schema.min ?? 0 + case 'toggle': + return schema.defaultValue ?? false + case 'json-input': + return schema.defaultValue ?? {} + case 'dropdown': + case 'combobox': + if (schema.options && schema.options.length > 0) { + return schema.options[0].id + } + return undefined + default: + return undefined + } +} + function processSubBlock(sb: any): CopilotSubblockMetadata { // Start with required fields const processed: CopilotSubblockMetadata = { @@ -541,16 +879,41 @@ const SPECIAL_BLOCKS_METADATA: Record = { - For yaml it needs to connect blocks inside to the start field of the block. `, inputs: { - loopType: { type: 'string', required: true, enum: ['for', 'forEach'] }, - iterations: { type: 'number', required: false, minimum: 1, maximum: 1000 }, - collection: { type: 'string', required: false }, - maxConcurrency: { type: 'number', required: false, default: 1, minimum: 1, maximum: 10 }, + loopType: { + type: 'string', + required: true, + enum: ['for', 'forEach'], + description: "Loop Type - 'for' runs N times, 'forEach' iterates over collection", + }, + iterations: { + type: 'number', + required: false, + minimum: 1, + maximum: 1000, + description: "Number of iterations (for 'for' loopType)", + example: 5, + }, + collection: { + type: 'string', + required: false, + description: "Collection to iterate over (for 'forEach' loopType)", + example: '', + }, + maxConcurrency: { + type: 'number', + required: false, + default: 1, + minimum: 1, + maximum: 10, + description: 'Max parallel executions (1 = sequential)', + example: 1, + }, }, outputs: { - results: 'array', - currentIndex: 'number', - currentItem: 'any', - totalIterations: 'number', + results: { type: 'array', description: 'Array of results from each iteration' }, + currentIndex: { type: 'number', description: 'Current iteration index (0-based)' }, + currentItem: { type: 'any', description: 'Current item being iterated (for forEach loops)' }, + totalIterations: { type: 'number', description: 'Total number of iterations' }, }, subBlocks: [ { @@ -602,12 +965,45 @@ const SPECIAL_BLOCKS_METADATA: Record = { - For yaml it needs to connect blocks inside to the start field of the block. `, inputs: { - parallelType: { type: 'string', required: true, enum: ['count', 'collection'] }, - count: { type: 'number', required: false, minimum: 1, maximum: 100 }, - collection: { type: 'string', required: false }, - maxConcurrency: { type: 'number', required: false, default: 10, minimum: 1, maximum: 50 }, + parallelType: { + type: 'string', + required: true, + enum: ['count', 'collection'], + description: "Parallel Type - 'count' runs N branches, 'collection' runs one per item", + }, + count: { + type: 'number', + required: false, + minimum: 1, + maximum: 100, + description: "Number of parallel branches (for 'count' type)", + example: 3, + }, + collection: { + type: 'string', + required: false, + description: "Collection to process in parallel (for 'collection' type)", + example: '', + }, + maxConcurrency: { + type: 'number', + required: false, + default: 10, + minimum: 1, + maximum: 50, + description: 'Max concurrent executions at once', + example: 10, + }, + }, + outputs: { + results: { type: 'array', description: 'Array of results from all parallel branches' }, + branchId: { type: 'number', description: 'Current branch ID (0-based)' }, + branchItem: { + type: 'any', + description: 'Current item for this branch (for collection type)', + }, + totalBranches: { type: 'number', description: 'Total number of parallel branches' }, }, - outputs: { results: 'array', branchId: 'number', branchItem: 'any', totalBranches: 'number' }, subBlocks: [ { id: 'parallelType', diff --git a/apps/sim/lib/copilot/tools/server/other/search-online.ts b/apps/sim/lib/copilot/tools/server/other/search-online.ts index 9169e9e4c4a..c49b1e928fd 100644 --- a/apps/sim/lib/copilot/tools/server/other/search-online.ts +++ b/apps/sim/lib/copilot/tools/server/other/search-online.ts @@ -18,17 +18,75 @@ export const searchOnlineServerTool: BaseServerTool = { const { query, num = 10, type = 'search', gl, hl } = params if (!query || typeof query !== 'string') throw new Error('query is required') - // Input diagnostics (no secrets) - const hasApiKey = Boolean(env.SERPER_API_KEY && String(env.SERPER_API_KEY).length > 0) - logger.info('Performing online search (new runtime)', { + // Check which API keys are available + const hasExaApiKey = Boolean(env.EXA_API_KEY && String(env.EXA_API_KEY).length > 0) + const hasSerperApiKey = Boolean(env.SERPER_API_KEY && String(env.SERPER_API_KEY).length > 0) + + logger.info('Performing online search', { queryLength: query.length, num, type, gl, hl, - hasApiKey, + hasExaApiKey, + hasSerperApiKey, }) + // Try Exa first if available + if (hasExaApiKey) { + try { + logger.debug('Attempting exa_search', { num }) + const exaResult = await executeTool('exa_search', { + query, + numResults: num, + type: 'auto', + apiKey: env.EXA_API_KEY || '', + }) + + const exaResults = (exaResult as any)?.output?.results || [] + const count = Array.isArray(exaResults) ? exaResults.length : 0 + const firstTitle = count > 0 ? String(exaResults[0]?.title || '') : undefined + + logger.info('exa_search completed', { + success: exaResult.success, + resultsCount: count, + firstTitlePreview: firstTitle?.slice(0, 120), + }) + + if (exaResult.success && count > 0) { + // Transform Exa results to match expected format + const transformedResults = exaResults.map((result: any) => ({ + title: result.title || '', + link: result.url || '', + snippet: result.text || result.summary || '', + date: result.publishedDate, + position: exaResults.indexOf(result) + 1, + })) + + return { + results: transformedResults, + query, + type, + totalResults: count, + source: 'exa', + } + } + + logger.warn('exa_search returned no results, falling back to Serper', { + queryLength: query.length, + }) + } catch (exaError: any) { + logger.warn('exa_search failed, falling back to Serper', { + error: exaError?.message, + }) + } + } + + // Fall back to Serper if Exa failed or wasn't available + if (!hasSerperApiKey) { + throw new Error('No search API keys available (EXA_API_KEY or SERPER_API_KEY required)') + } + const toolParams = { query, num, @@ -65,6 +123,7 @@ export const searchOnlineServerTool: BaseServerTool = { query, type, totalResults: count, + source: 'serper', } } catch (e: any) { logger.error('search_online execution error', { message: e?.message }) diff --git a/apps/sim/lib/env.ts b/apps/sim/lib/env.ts index 713d186d882..39ba370cbca 100644 --- a/apps/sim/lib/env.ts +++ b/apps/sim/lib/env.ts @@ -79,6 +79,7 @@ export const env = createEnv({ OLLAMA_URL: z.string().url().optional(), // Ollama local LLM server URL ELEVENLABS_API_KEY: z.string().min(1).optional(), // ElevenLabs API key for text-to-speech in deployed chat SERPER_API_KEY: z.string().min(1).optional(), // Serper API key for online search + EXA_API_KEY: z.string().min(1).optional(), // Exa AI API key for enhanced online search // Azure Configuration - Shared credentials with feature-specific models AZURE_OPENAI_ENDPOINT: z.string().url().optional(), // Shared Azure OpenAI service endpoint diff --git a/apps/sim/lib/sim-agent/constants.ts b/apps/sim/lib/sim-agent/constants.ts index 22e07c22c13..996615278f5 100644 --- a/apps/sim/lib/sim-agent/constants.ts +++ b/apps/sim/lib/sim-agent/constants.ts @@ -1,2 +1,2 @@ export const SIM_AGENT_API_URL_DEFAULT = 'https://copilot.sim.ai' -export const SIM_AGENT_VERSION = '1.0.1' +export const SIM_AGENT_VERSION = '1.0.2' diff --git a/apps/sim/providers/models.ts b/apps/sim/providers/models.ts index 89c7f976f1c..87f961a1fea 100644 --- a/apps/sim/providers/models.ts +++ b/apps/sim/providers/models.ts @@ -367,6 +367,18 @@ export const PROVIDER_DEFINITIONS: Record = { toolUsageControl: true, }, models: [ + { + id: 'claude-haiku-4-5', + pricing: { + input: 1.0, + cachedInput: 0.5, + output: 5.0, + updatedAt: '2025-10-11', + }, + capabilities: { + temperature: { min: 0, max: 1 }, + }, + }, { id: 'claude-sonnet-4-5', pricing: { diff --git a/apps/sim/stores/copilot/store.ts b/apps/sim/stores/copilot/store.ts index c39d9316fa5..615b1926061 100644 --- a/apps/sim/stores/copilot/store.ts +++ b/apps/sim/stores/copilot/store.ts @@ -291,67 +291,76 @@ function normalizeMessagesForUI(messages: CopilotMessage[]): CopilotMessage[] { // Use existing contentBlocks ordering if present; otherwise only render text content const blocks: any[] = Array.isArray(message.contentBlocks) - ? (message.contentBlocks as any[]).map((b: any) => - b?.type === 'tool_call' && b.toolCall - ? { - ...b, - toolCall: { - ...b.toolCall, - state: - isRejectedState(b.toolCall?.state) || - isReviewState(b.toolCall?.state) || - isBackgroundState(b.toolCall?.state) || - b.toolCall?.state === ClientToolCallState.success || - b.toolCall?.state === ClientToolCallState.error || - b.toolCall?.state === ClientToolCallState.aborted - ? b.toolCall.state - : ClientToolCallState.rejected, - display: resolveToolDisplay( - b.toolCall?.name, - (isRejectedState(b.toolCall?.state) || - isReviewState(b.toolCall?.state) || - isBackgroundState(b.toolCall?.state) || - b.toolCall?.state === ClientToolCallState.success || - b.toolCall?.state === ClientToolCallState.error || - b.toolCall?.state === ClientToolCallState.aborted - ? (b.toolCall?.state as any) - : ClientToolCallState.rejected) as any, - b.toolCall?.id, - b.toolCall?.params - ), - }, - } - : b - ) + ? (message.contentBlocks as any[]).map((b: any) => { + if (b?.type === 'tool_call' && b.toolCall) { + // Ensure client tool instance is registered for this tool call + ensureClientToolInstance(b.toolCall?.name, b.toolCall?.id) + + return { + ...b, + toolCall: { + ...b.toolCall, + state: + isRejectedState(b.toolCall?.state) || + isReviewState(b.toolCall?.state) || + isBackgroundState(b.toolCall?.state) || + b.toolCall?.state === ClientToolCallState.success || + b.toolCall?.state === ClientToolCallState.error || + b.toolCall?.state === ClientToolCallState.aborted + ? b.toolCall.state + : ClientToolCallState.rejected, + display: resolveToolDisplay( + b.toolCall?.name, + (isRejectedState(b.toolCall?.state) || + isReviewState(b.toolCall?.state) || + isBackgroundState(b.toolCall?.state) || + b.toolCall?.state === ClientToolCallState.success || + b.toolCall?.state === ClientToolCallState.error || + b.toolCall?.state === ClientToolCallState.aborted + ? (b.toolCall?.state as any) + : ClientToolCallState.rejected) as any, + b.toolCall?.id, + b.toolCall?.params + ), + }, + } + } + return b + }) : [] // Prepare toolCalls with display for non-block UI components, but do not fabricate blocks const updatedToolCalls = Array.isArray((message as any).toolCalls) - ? (message as any).toolCalls.map((tc: any) => ({ - ...tc, - state: - isRejectedState(tc?.state) || - isReviewState(tc?.state) || - isBackgroundState(tc?.state) || - tc?.state === ClientToolCallState.success || - tc?.state === ClientToolCallState.error || - tc?.state === ClientToolCallState.aborted - ? tc.state - : ClientToolCallState.rejected, - display: resolveToolDisplay( - tc?.name, - (isRejectedState(tc?.state) || - isReviewState(tc?.state) || - isBackgroundState(tc?.state) || - tc?.state === ClientToolCallState.success || - tc?.state === ClientToolCallState.error || - tc?.state === ClientToolCallState.aborted - ? (tc?.state as any) - : ClientToolCallState.rejected) as any, - tc?.id, - tc?.params - ), - })) + ? (message as any).toolCalls.map((tc: any) => { + // Ensure client tool instance is registered for this tool call + ensureClientToolInstance(tc?.name, tc?.id) + + return { + ...tc, + state: + isRejectedState(tc?.state) || + isReviewState(tc?.state) || + isBackgroundState(tc?.state) || + tc?.state === ClientToolCallState.success || + tc?.state === ClientToolCallState.error || + tc?.state === ClientToolCallState.aborted + ? tc.state + : ClientToolCallState.rejected, + display: resolveToolDisplay( + tc?.name, + (isRejectedState(tc?.state) || + isReviewState(tc?.state) || + isBackgroundState(tc?.state) || + tc?.state === ClientToolCallState.success || + tc?.state === ClientToolCallState.error || + tc?.state === ClientToolCallState.aborted + ? (tc?.state as any) + : ClientToolCallState.rejected) as any, + tc?.id, + tc?.params + ), + } + }) : (message as any).toolCalls return { @@ -431,10 +440,11 @@ class StringBuilder { function createUserMessage( content: string, fileAttachments?: MessageFileAttachment[], - contexts?: ChatContext[] + contexts?: ChatContext[], + messageId?: string ): CopilotMessage { return { - id: crypto.randomUUID(), + id: messageId || crypto.randomUUID(), role: 'user', content, timestamp: new Date().toISOString(), @@ -1166,25 +1176,6 @@ const sseHandlers: Record = { context.currentTextBlock = null updateStreamingMessage(set, context) }, - context_usage: (data, _context, _get, set) => { - try { - const usageData = data?.data - if (usageData) { - set({ - contextUsage: { - usage: usageData.usage || 0, - percentage: usageData.percentage || 0, - model: usageData.model || '', - contextWindow: usageData.context_window || usageData.contextWindow || 0, - when: usageData.when || 'start', - estimatedTokens: usageData.estimated_tokens || usageData.estimatedTokens, - }, - }) - } - } catch (err) { - logger.warn('Failed to handle context_usage event:', err) - } - }, default: () => {}, } @@ -1417,16 +1408,35 @@ export const useCopilotStore = create()( if (data.success && Array.isArray(data.chats)) { const latestChat = data.chats.find((c: CopilotChat) => c.id === chat.id) if (latestChat) { + const normalizedMessages = normalizeMessagesForUI(latestChat.messages || []) + + // Build toolCallsById map from all tool calls in normalized messages + const toolCallsById: Record = {} + for (const msg of normalizedMessages) { + if (msg.contentBlocks) { + for (const block of msg.contentBlocks as any[]) { + if (block?.type === 'tool_call' && block.toolCall?.id) { + toolCallsById[block.toolCall.id] = block.toolCall + } + } + } + } + set({ currentChat: latestChat, - messages: normalizeMessagesForUI(latestChat.messages || []), + messages: normalizedMessages, chats: (get().chats || []).map((c: CopilotChat) => c.id === chat.id ? latestChat : c ), + contextUsage: null, + toolCallsById, }) try { await get().loadMessageCheckpoints(latestChat.id) } catch {} + // Fetch context usage for the selected chat + logger.info('[Context Usage] Chat selected, fetching usage') + await get().fetchContextUsage() } } } catch {} @@ -1456,6 +1466,7 @@ export const useCopilotStore = create()( } } catch {} + logger.info('[Context Usage] New chat created, clearing context usage') set({ currentChat: null, messages: [], @@ -1467,8 +1478,32 @@ export const useCopilotStore = create()( }) }, - deleteChat: async (_chatId: string) => { - // no-op for now + deleteChat: async (chatId: string) => { + try { + // Call delete API + const response = await fetch('/api/copilot/chat/delete', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ chatId }), + }) + + if (!response.ok) { + throw new Error(`Failed to delete chat: ${response.status}`) + } + + // Remove from local state + set((state) => ({ + chats: state.chats.filter((c) => c.id !== chatId), + // If deleted chat was current, clear it + currentChat: state.currentChat?.id === chatId ? null : state.currentChat, + messages: state.currentChat?.id === chatId ? [] : state.messages, + })) + + logger.info('Chat deleted', { chatId }) + } catch (error) { + logger.error('Failed to delete chat:', error) + throw error + } }, areChatsFresh: (_workflowId: string) => false, @@ -1509,9 +1544,24 @@ export const useCopilotStore = create()( if (isSendingMessage) { set({ currentChat: { ...updatedCurrentChat, messages: get().messages } }) } else { + const normalizedMessages = normalizeMessagesForUI(updatedCurrentChat.messages || []) + + // Build toolCallsById map from all tool calls in normalized messages + const toolCallsById: Record = {} + for (const msg of normalizedMessages) { + if (msg.contentBlocks) { + for (const block of msg.contentBlocks as any[]) { + if (block?.type === 'tool_call' && block.toolCall?.id) { + toolCallsById[block.toolCall.id] = block.toolCall + } + } + } + } + set({ currentChat: updatedCurrentChat, - messages: normalizeMessagesForUI(updatedCurrentChat.messages || []), + messages: normalizedMessages, + toolCallsById, }) } try { @@ -1519,9 +1569,24 @@ export const useCopilotStore = create()( } catch {} } else if (!isSendingMessage && !suppressAutoSelect) { const mostRecentChat: CopilotChat = data.chats[0] + const normalizedMessages = normalizeMessagesForUI(mostRecentChat.messages || []) + + // Build toolCallsById map from all tool calls in normalized messages + const toolCallsById: Record = {} + for (const msg of normalizedMessages) { + if (msg.contentBlocks) { + for (const block of msg.contentBlocks as any[]) { + if (block?.type === 'tool_call' && block.toolCall?.id) { + toolCallsById[block.toolCall.id] = block.toolCall + } + } + } + } + set({ currentChat: mostRecentChat, - messages: normalizeMessagesForUI(mostRecentChat.messages || []), + messages: normalizedMessages, + toolCallsById, }) try { await get().loadMessageCheckpoints(mostRecentChat.id) @@ -1549,17 +1614,19 @@ export const useCopilotStore = create()( stream = true, fileAttachments, contexts, + messageId, } = options as { stream?: boolean fileAttachments?: MessageFileAttachment[] contexts?: ChatContext[] + messageId?: string } if (!workflowId) return const abortController = new AbortController() set({ isSendingMessage: true, error: null, abortController }) - const userMessage = createUserMessage(message, fileAttachments, contexts) + const userMessage = createUserMessage(message, fileAttachments, contexts, messageId) const streamingMessage = createStreamingMessage() let newMessages: CopilotMessage[] @@ -1568,7 +1635,16 @@ export const useCopilotStore = create()( newMessages = [...currentMessages, userMessage, streamingMessage] set({ revertState: null, inputValue: '' }) } else { - newMessages = [...get().messages, userMessage, streamingMessage] + const currentMessages = get().messages + // If messageId is provided, check if it already exists (e.g., from edit flow) + const existingIndex = messageId ? currentMessages.findIndex((m) => m.id === messageId) : -1 + if (existingIndex !== -1) { + // Replace existing message instead of adding new one + newMessages = [...currentMessages.slice(0, existingIndex), userMessage, streamingMessage] + } else { + // Add new messages normally + newMessages = [...currentMessages, userMessage, streamingMessage] + } } const isFirstMessage = get().messages.length === 0 && !currentChat?.title @@ -1716,6 +1792,14 @@ export const useCopilotStore = create()( }).catch(() => {}) } catch {} } + + // Fetch context usage after abort + logger.info('[Context Usage] Message aborted, fetching usage') + get() + .fetchContextUsage() + .catch((err) => { + logger.warn('[Context Usage] Failed to fetch after abort', err) + }) } catch { set({ isSendingMessage: false, isAborting: false, abortController: null }) } @@ -1969,6 +2053,11 @@ export const useCopilotStore = create()( const result = await response.json() const reverted = result?.checkpoint?.workflowState || null if (reverted) { + // Clear any active diff preview + try { + useWorkflowDiffStore.getState().clearDiff() + } catch {} + // Apply to main workflow store useWorkflowStore.setState({ blocks: reverted.blocks || {}, @@ -2123,6 +2212,10 @@ export const useCopilotStore = create()( try { // Removed: stats sending now occurs only on accept/reject with minimal payload } catch {} + + // Fetch context usage after response completes + logger.info('[Context Usage] Stream completed, fetching usage') + await get().fetchContextUsage() } finally { clearTimeout(timeoutId) } @@ -2206,9 +2299,86 @@ export const useCopilotStore = create()( updateDiffStore: async (_yamlContent: string) => {}, updateDiffStoreWithWorkflowState: async (_workflowState: any) => {}, - setSelectedModel: (model) => set({ selectedModel: model }), + setSelectedModel: async (model) => { + logger.info('[Context Usage] Model changed', { from: get().selectedModel, to: model }) + set({ selectedModel: model }) + // Fetch context usage after model switch + await get().fetchContextUsage() + }, setAgentPrefetch: (prefetch) => set({ agentPrefetch: prefetch }), setEnabledModels: (models) => set({ enabledModels: models }), + + // Fetch context usage from sim-agent API + fetchContextUsage: async () => { + try { + const { currentChat, selectedModel, workflowId } = get() + logger.info('[Context Usage] Starting fetch', { + hasChatId: !!currentChat?.id, + hasWorkflowId: !!workflowId, + chatId: currentChat?.id, + workflowId, + model: selectedModel, + }) + + if (!currentChat?.id || !workflowId) { + logger.info('[Context Usage] Skipping: missing chat or workflow', { + hasChatId: !!currentChat?.id, + hasWorkflowId: !!workflowId, + }) + return + } + + const requestPayload = { + chatId: currentChat.id, + model: selectedModel, + workflowId, + } + + logger.info('[Context Usage] Calling API', requestPayload) + + // Call the backend API route which proxies to sim-agent + const response = await fetch('/api/copilot/context-usage', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(requestPayload), + }) + + logger.info('[Context Usage] API response', { status: response.status, ok: response.ok }) + + if (response.ok) { + const data = await response.json() + logger.info('[Context Usage] Received data', data) + + // Check for either tokensUsed or usage field + if ( + data.tokensUsed !== undefined || + data.usage !== undefined || + data.percentage !== undefined + ) { + const contextUsage = { + usage: data.tokensUsed || data.usage || 0, + percentage: data.percentage || 0, + model: data.model || selectedModel, + contextWindow: data.contextWindow || data.context_window || 0, + when: data.when || 'end', + estimatedTokens: data.tokensUsed || data.estimated_tokens || data.estimatedTokens, + } + set({ contextUsage }) + logger.info('[Context Usage] Updated store', contextUsage) + } else { + logger.warn('[Context Usage] No usage data in response', data) + } + } else { + const errorText = await response.text().catch(() => 'Unable to read error') + logger.warn('[Context Usage] API call failed', { + status: response.status, + error: errorText, + }) + } + } catch (err) { + logger.error('[Context Usage] Error fetching:', err) + } + }, })) ) diff --git a/apps/sim/stores/copilot/types.ts b/apps/sim/stores/copilot/types.ts index 48ee024bae8..f615dd6d211 100644 --- a/apps/sim/stores/copilot/types.ts +++ b/apps/sim/stores/copilot/types.ts @@ -77,6 +77,7 @@ export interface CopilotState { | 'gpt-4.1' | 'o3' | 'claude-4-sonnet' + | 'claude-4.5-haiku' | 'claude-4.5-sonnet' | 'claude-4.1-opus' agentPrefetch: boolean @@ -138,9 +139,10 @@ export interface CopilotState { export interface CopilotActions { setMode: (mode: CopilotMode) => void - setSelectedModel: (model: CopilotStore['selectedModel']) => void + setSelectedModel: (model: CopilotStore['selectedModel']) => Promise setAgentPrefetch: (prefetch: boolean) => void setEnabledModels: (models: string[] | null) => void + fetchContextUsage: () => Promise setWorkflowId: (workflowId: string | null) => Promise validateCurrentChat: () => boolean @@ -156,6 +158,7 @@ export interface CopilotActions { stream?: boolean fileAttachments?: MessageFileAttachment[] contexts?: ChatContext[] + messageId?: string } ) => Promise abortMessage: () => void