diff --git a/apps/sim/app/api/mothership/chat/route.ts b/apps/sim/app/api/mothership/chat/route.ts index 18b66b5577c..c1478c172fb 100644 --- a/apps/sim/app/api/mothership/chat/route.ts +++ b/apps/sim/app/api/mothership/chat/route.ts @@ -279,6 +279,7 @@ export async function POST(req: NextRequest) { role: 'assistant' as const, content: result.content, timestamp: new Date().toISOString(), + ...(result.requestId ? { requestId: result.requestId } : {}), } if (result.toolCalls.length > 0) { assistantMessage.toolCalls = result.toolCalls diff --git a/apps/sim/app/workspace/[workspaceId]/components/index.ts b/apps/sim/app/workspace/[workspaceId]/components/index.ts index 4a08e4f6c79..28ae1e475a5 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/index.ts @@ -1,5 +1,6 @@ export { ErrorState, type ErrorStateProps } from './error' export { InlineRenameInput } from './inline-rename-input' +export { MessageActions } from './message-actions' export { ownerCell } from './resource/components/owner-cell/owner-cell' export type { BreadcrumbEditing, diff --git a/apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts b/apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts new file mode 100644 index 00000000000..906e30d5a6f --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts @@ -0,0 +1 @@ +export { MessageActions } from './message-actions' diff --git a/apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx b/apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx new file mode 100644 index 00000000000..9d86664c812 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx @@ -0,0 +1,84 @@ +'use client' + +import { useCallback, useEffect, useRef, useState } from 'react' +import { Check, Copy, Ellipsis, Hash } from 'lucide-react' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/emcn' + +interface MessageActionsProps { + content: string + requestId?: string +} + +export function MessageActions({ content, requestId }: MessageActionsProps) { + const [copied, setCopied] = useState<'message' | 'request' | null>(null) + const resetTimeoutRef = useRef(null) + + useEffect(() => { + return () => { + if (resetTimeoutRef.current !== null) { + window.clearTimeout(resetTimeoutRef.current) + } + } + }, []) + + const copyToClipboard = useCallback(async (text: string, type: 'message' | 'request') => { + try { + await navigator.clipboard.writeText(text) + setCopied(type) + if (resetTimeoutRef.current !== null) { + window.clearTimeout(resetTimeoutRef.current) + } + resetTimeoutRef.current = window.setTimeout(() => setCopied(null), 1500) + } catch { + return + } + }, []) + + if (!content && !requestId) { + return null + } + + return ( + + + + + + { + event.stopPropagation() + void copyToClipboard(content, 'message') + }} + > + {copied === 'message' ? : } + Copy Message + + { + event.stopPropagation() + if (requestId) { + void copyToClipboard(requestId, 'request') + } + }} + > + {copied === 'request' ? : } + Copy Request ID + + + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/home.tsx b/apps/sim/app/workspace/[workspaceId]/home/home.tsx index 8bc0dac39b2..b238e60ca82 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/home.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/home.tsx @@ -13,6 +13,7 @@ import { LandingWorkflowSeedStorage, } from '@/lib/core/utils/browser-storage' import { persistImportedWorkflow } from '@/lib/workflows/operations/import-export' +import { MessageActions } from '@/app/workspace/[workspaceId]/components' import { useChatHistory, useMarkTaskRead } from '@/hooks/queries/tasks' import type { ChatContext } from '@/stores/panel' import { useSidebarStore } from '@/stores/sidebar/store' @@ -414,7 +415,12 @@ export function Home({ chatId }: HomeProps = {}) { const isLastMessage = index === messages.length - 1 return ( -
+
+ {!isThisStreaming && (msg.content || msg.contentBlocks?.length) && ( +
+ +
+ )} 0 @@ -509,6 +510,7 @@ export function useChat( let activeSubagent: string | undefined let runningText = '' let lastContentSource: 'main' | 'subagent' | null = null + let streamRequestId: string | undefined streamingContentRef.current = '' streamingBlocksRef.current = [] @@ -526,14 +528,21 @@ export function useChat( const flush = () => { if (isStale()) return streamingBlocksRef.current = [...blocks] - const snapshot = { content: runningText, contentBlocks: [...blocks] } + const snapshot: Partial = { + content: runningText, + contentBlocks: [...blocks], + } + if (streamRequestId) snapshot.requestId = streamRequestId setMessages((prev) => { if (expectedGen !== undefined && streamGenRef.current !== expectedGen) return prev const idx = prev.findIndex((m) => m.id === assistantId) if (idx >= 0) { return prev.map((m) => (m.id === assistantId ? { ...m, ...snapshot } : m)) } - return [...prev, { id: assistantId, role: 'assistant' as const, ...snapshot }] + return [ + ...prev, + { id: assistantId, role: 'assistant' as const, content: '', ...snapshot }, + ] }) } @@ -597,6 +606,14 @@ export function useChat( } break } + case 'request_id': { + const rid = typeof parsed.data === 'string' ? parsed.data : undefined + if (rid) { + streamRequestId = rid + flush() + } + break + } case 'content': { const chunk = typeof parsed.data === 'string' ? parsed.data : (parsed.content ?? '') if (chunk) { diff --git a/apps/sim/app/workspace/[workspaceId]/home/types.ts b/apps/sim/app/workspace/[workspaceId]/home/types.ts index 1ed8d0ac65e..4a827c97aa1 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/types.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/types.ts @@ -33,6 +33,7 @@ export interface QueuedMessage { */ export type SSEEventType = | 'chat_id' + | 'request_id' | 'title_updated' | 'content' | 'reasoning' // openai reasoning - render as thinking text @@ -199,6 +200,7 @@ export interface ChatMessage { contentBlocks?: ContentBlock[] attachments?: ChatMessageAttachment[] contexts?: ChatMessageContext[] + requestId?: string } export const SUBAGENT_LABELS: Record = { 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 187ff159480..0fc34449dec 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 @@ -3,6 +3,7 @@ import { type FC, memo, useCallback, useMemo, useRef, useState } from 'react' import { RotateCcw } from 'lucide-react' import { Button } from '@/components/emcn' +import { MessageActions } from '@/app/workspace/[workspaceId]/components' import { OptionsSelector, parseSpecialTags, @@ -409,10 +410,15 @@ const CopilotMessage: FC = memo( if (isAssistant) { return (
-
+ {!isStreaming && (message.content || message.contentBlocks?.length) && ( +
+ +
+ )} +
{/* Content blocks in chronological order */} {memoizedContentBlocks || (isStreaming &&
)} diff --git a/apps/sim/hooks/queries/tasks.ts b/apps/sim/hooks/queries/tasks.ts index 198d59701c2..184859f2526 100644 --- a/apps/sim/hooks/queries/tasks.ts +++ b/apps/sim/hooks/queries/tasks.ts @@ -54,6 +54,7 @@ export interface TaskStoredMessage { id: string role: 'user' | 'assistant' content: string + requestId?: string toolCalls?: TaskStoredToolCall[] contentBlocks?: TaskStoredContentBlock[] fileAttachments?: TaskStoredFileAttachment[] diff --git a/apps/sim/lib/copilot/client-sse/handlers.ts b/apps/sim/lib/copilot/client-sse/handlers.ts index 7705e1cf3dd..f929d1057b5 100644 --- a/apps/sim/lib/copilot/client-sse/handlers.ts +++ b/apps/sim/lib/copilot/client-sse/handlers.ts @@ -92,6 +92,7 @@ export function flushStreamingUpdates(set: StoreSet) { if (update) { return { ...msg, + requestId: update.requestId ?? msg.requestId, content: '', contentBlocks: update.contentBlocks.length > 0 @@ -129,6 +130,7 @@ export function updateStreamingMessage(set: StoreSet, context: ClientStreamingCo const newMessages = [...messages] newMessages[messages.length - 1] = { ...lastMessage, + requestId: lastMessageUpdate.requestId ?? lastMessage.requestId, content: '', contentBlocks: lastMessageUpdate.contentBlocks.length > 0 @@ -143,6 +145,7 @@ export function updateStreamingMessage(set: StoreSet, context: ClientStreamingCo if (update) { return { ...msg, + requestId: update.requestId ?? msg.requestId, content: '', contentBlocks: update.contentBlocks.length > 0 @@ -429,6 +432,12 @@ export const sseHandlers: Record = { writeActiveStreamToStorage(updatedStream) } }, + request_id: (data, context) => { + const requestId = typeof data.data === 'string' ? data.data : undefined + if (requestId) { + context.requestId = requestId + } + }, title_updated: (_data, _context, get, set) => { const title = _data.title if (!title) return diff --git a/apps/sim/lib/copilot/client-sse/types.ts b/apps/sim/lib/copilot/client-sse/types.ts index 95fa8f077e2..66c18030e75 100644 --- a/apps/sim/lib/copilot/client-sse/types.ts +++ b/apps/sim/lib/copilot/client-sse/types.ts @@ -22,6 +22,7 @@ export interface ClientContentBlock { export interface StreamingContext { messageId: string + requestId?: string accumulatedContent: string contentBlocks: ClientContentBlock[] currentTextBlock: ClientContentBlock | null diff --git a/apps/sim/lib/copilot/messages/serialization.ts b/apps/sim/lib/copilot/messages/serialization.ts index 4a970cc92fe..89a30466806 100644 --- a/apps/sim/lib/copilot/messages/serialization.ts +++ b/apps/sim/lib/copilot/messages/serialization.ts @@ -141,6 +141,10 @@ export function serializeMessagesForDB( timestamp, } + if (msg.requestId) { + serialized.requestId = msg.requestId + } + if (Array.isArray(msg.contentBlocks) && msg.contentBlocks.length > 0) { serialized.contentBlocks = deepClone(msg.contentBlocks) } diff --git a/apps/sim/lib/copilot/orchestrator/index.ts b/apps/sim/lib/copilot/orchestrator/index.ts index 5e07bbf38a1..c29fae00550 100644 --- a/apps/sim/lib/copilot/orchestrator/index.ts +++ b/apps/sim/lib/copilot/orchestrator/index.ts @@ -76,6 +76,7 @@ export async function orchestrateCopilotStream( contentBlocks: context.contentBlocks, toolCalls: buildToolCallSummaries(context), chatId: context.chatId, + requestId: context.requestId, errors: context.errors.length ? context.errors : undefined, usage: context.usage, cost: context.cost, diff --git a/apps/sim/lib/copilot/orchestrator/sse/handlers/handlers.ts b/apps/sim/lib/copilot/orchestrator/sse/handlers/handlers.ts index d0431b59cd5..a20ccd40996 100644 --- a/apps/sim/lib/copilot/orchestrator/sse/handlers/handlers.ts +++ b/apps/sim/lib/copilot/orchestrator/sse/handlers/handlers.ts @@ -187,6 +187,12 @@ export const sseHandlers: Record = { execContext.chatId = chatId } }, + request_id: (event, context) => { + const rid = typeof event.data === 'string' ? event.data : undefined + if (rid) { + context.requestId = rid + } + }, title_updated: () => {}, tool_result: (event, context) => { const data = getEventData(event) diff --git a/apps/sim/lib/copilot/orchestrator/types.ts b/apps/sim/lib/copilot/orchestrator/types.ts index 130666c81e6..e79d0a65360 100644 --- a/apps/sim/lib/copilot/orchestrator/types.ts +++ b/apps/sim/lib/copilot/orchestrator/types.ts @@ -2,6 +2,7 @@ import type { MothershipResource } from '@/lib/copilot/resource-types' export type SSEEventType = | 'chat_id' + | 'request_id' | 'title_updated' | 'content' | 'reasoning' @@ -88,6 +89,7 @@ export interface ContentBlock { export interface StreamingContext { chatId?: string + requestId?: string messageId: string accumulatedContent: string contentBlocks: ContentBlock[] @@ -154,6 +156,7 @@ export interface OrchestratorResult { contentBlocks: ContentBlock[] toolCalls: ToolCallSummary[] chatId?: string + requestId?: string error?: string errors?: string[] usage?: { prompt: number; completion: number } diff --git a/apps/sim/stores/panel/copilot/store.ts b/apps/sim/stores/panel/copilot/store.ts index dba7db4ac42..a2c3249441f 100644 --- a/apps/sim/stores/panel/copilot/store.ts +++ b/apps/sim/stores/panel/copilot/store.ts @@ -224,6 +224,7 @@ function replaceTextBlocks(blocks: ClientContentBlock[], text: string): ClientCo function createClientStreamingContext(messageId: string): ClientStreamingContext { return { messageId, + requestId: undefined, accumulatedContent: '', contentBlocks: [], currentTextBlock: null, @@ -2043,6 +2044,7 @@ export const useCopilotStore = create()( msg.id === assistantMessageId ? { ...msg, + requestId: context.requestId ?? msg.requestId, content: finalContentWithOptions, contentBlocks: sanitizedContentBlocks, } diff --git a/apps/sim/stores/panel/copilot/types.ts b/apps/sim/stores/panel/copilot/types.ts index dde7e3fc552..798c71e1545 100644 --- a/apps/sim/stores/panel/copilot/types.ts +++ b/apps/sim/stores/panel/copilot/types.ts @@ -70,6 +70,7 @@ export interface CopilotMessage { role: 'user' | 'assistant' | 'system' content: string timestamp: string + requestId?: string citations?: { id: number; title: string; url: string; similarity?: number }[] toolCalls?: CopilotToolCall[] contentBlocks?: ClientContentBlock[]