Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/sim/app/api/mothership/chat/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { MessageActions } from './message-actions'
export { MessageContent } from './message-content'
export { MothershipView } from './mothership-view'
export { QueuedMessages } from './queued-messages'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { MessageActions } from './message-actions'
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
'use client'

import { useCallback, useEffect, useRef, useState } from 'react'
import { Check, Copy, Ellipsis, Hash } from 'lucide-react'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Tooltip,
} 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<number | null>(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 (
<DropdownMenu modal={false}>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<DropdownMenuTrigger asChild>
<button
type='button'
aria-label='More options'
className='flex h-5 w-5 items-center justify-center rounded-sm text-[var(--text-icon)] opacity-0 transition-colors transition-opacity hover:bg-[var(--surface-3)] hover:text-[var(--text-primary)] focus-visible:opacity-100 focus-visible:outline-none group-hover/msg:opacity-100 data-[state=open]:opacity-100'
onClick={(event) => event.stopPropagation()}
>
<Ellipsis className='h-3 w-3' strokeWidth={2} />
</button>
</DropdownMenuTrigger>
</Tooltip.Trigger>
<Tooltip.Content side='top'>More options</Tooltip.Content>
</Tooltip.Root>
<DropdownMenuContent align='end' side='top' sideOffset={4}>
<DropdownMenuItem
disabled={!content}
onSelect={(event) => {
event.stopPropagation()
void copyToClipboard(content, 'message')
}}
>
{copied === 'message' ? <Check /> : <Copy />}
<span>Copy Message</span>
</DropdownMenuItem>
<DropdownMenuItem
disabled={!requestId}
onSelect={(event) => {
event.stopPropagation()
if (requestId) {
void copyToClipboard(requestId, 'request')
}
}}
>
{copied === 'request' ? <Check /> : <Hash />}
<span>Copy Request ID</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
8 changes: 7 additions & 1 deletion apps/sim/app/workspace/[workspaceId]/home/home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { useChatHistory, useMarkTaskRead } from '@/hooks/queries/tasks'
import type { ChatContext } from '@/stores/panel'
import { useSidebarStore } from '@/stores/sidebar/store'
import {
MessageActions,
MessageContent,
MothershipView,
QueuedMessages,
Expand Down Expand Up @@ -414,7 +415,12 @@ export function Home({ chatId }: HomeProps = {}) {
const isLastMessage = index === messages.length - 1

return (
<div key={msg.id} className='pb-4'>
<div key={msg.id} className='group/msg relative pb-5'>
{!isThisStreaming && (msg.content || msg.contentBlocks?.length) && (
<div className='absolute right-0 bottom-0 z-10'>
<MessageActions content={msg.content} requestId={msg.requestId} />
</div>
)}
<MessageContent
blocks={msg.contentBlocks || []}
fallbackContent={msg.content}
Expand Down
21 changes: 19 additions & 2 deletions apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ function mapStoredMessage(msg: TaskStoredMessage): ChatMessage {
id: msg.id,
role: msg.role,
content: msg.content,
...(msg.requestId ? { requestId: msg.requestId } : {}),
}

const hasContentBlocks = Array.isArray(msg.contentBlocks) && msg.contentBlocks.length > 0
Expand Down Expand Up @@ -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 = []
Expand All @@ -526,14 +528,21 @@ export function useChat(
const flush = () => {
if (isStale()) return
streamingBlocksRef.current = [...blocks]
const snapshot = { content: runningText, contentBlocks: [...blocks] }
const snapshot: Partial<ChatMessage> = {
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 },
]
})
}

Expand Down Expand Up @@ -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) {
Expand Down
2 changes: 2 additions & 0 deletions apps/sim/app/workspace/[workspaceId]/home/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export interface QueuedMessage {
*/
export type SSEEventType =
| 'chat_id'
| 'request_id'
| 'title_updated'
| 'content'
| 'reasoning' // openai reasoning - render as thinking text
Expand Down Expand Up @@ -199,6 +200,7 @@ export interface ChatMessage {
contentBlocks?: ContentBlock[]
attachments?: ChatMessageAttachment[]
contexts?: ChatMessageContext[]
requestId?: string
}

export const SUBAGENT_LABELS: Record<SubagentName, string> = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]/home/components/message-actions'
import {
OptionsSelector,
parseSpecialTags,
Expand Down Expand Up @@ -409,10 +410,15 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
if (isAssistant) {
return (
<div
className={`w-full max-w-full flex-none overflow-hidden [max-width:var(--panel-max-width)] ${isDimmed ? 'opacity-40' : 'opacity-100'}`}
className={`group/msg relative w-full max-w-full flex-none overflow-hidden [max-width:var(--panel-max-width)] ${isDimmed ? 'opacity-40' : 'opacity-100'}`}
style={{ '--panel-max-width': `${panelWidth - 16}px` } as React.CSSProperties}
>
<div className='max-w-full space-y-[4px] px-[2px] pb-[4px]'>
{!isStreaming && message.content && (
<div className='absolute right-0 bottom-0 z-10'>
<MessageActions content={message.content} requestId={message.requestId} />
</div>
)}
<div className='max-w-full space-y-[4px] px-[2px] pb-5'>
{/* Content blocks in chronological order */}
{memoizedContentBlocks || (isStreaming && <div className='min-h-0' />)}

Expand Down
1 change: 1 addition & 0 deletions apps/sim/hooks/queries/tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export interface TaskStoredMessage {
id: string
role: 'user' | 'assistant'
content: string
requestId?: string
toolCalls?: TaskStoredToolCall[]
contentBlocks?: TaskStoredContentBlock[]
fileAttachments?: TaskStoredFileAttachment[]
Expand Down
9 changes: 9 additions & 0 deletions apps/sim/lib/copilot/client-sse/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ export function flushStreamingUpdates(set: StoreSet) {
if (update) {
return {
...msg,
requestId: update.requestId ?? msg.requestId,
content: '',
contentBlocks:
update.contentBlocks.length > 0
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -429,6 +432,12 @@ export const sseHandlers: Record<string, SSEHandler> = {
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
Expand Down
1 change: 1 addition & 0 deletions apps/sim/lib/copilot/client-sse/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export interface ClientContentBlock {

export interface StreamingContext {
messageId: string
requestId?: string
accumulatedContent: string
contentBlocks: ClientContentBlock[]
currentTextBlock: ClientContentBlock | null
Expand Down
1 change: 1 addition & 0 deletions apps/sim/lib/copilot/orchestrator/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions apps/sim/lib/copilot/orchestrator/sse/handlers/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,12 @@ export const sseHandlers: Record<string, SSEHandler> = {
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)
Expand Down
2 changes: 2 additions & 0 deletions apps/sim/lib/copilot/orchestrator/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ export interface ContentBlock {

export interface StreamingContext {
chatId?: string
requestId?: string
messageId: string
accumulatedContent: string
contentBlocks: ContentBlock[]
Expand Down Expand Up @@ -154,6 +155,7 @@ export interface OrchestratorResult {
contentBlocks: ContentBlock[]
toolCalls: ToolCallSummary[]
chatId?: string
requestId?: string
error?: string
errors?: string[]
usage?: { prompt: number; completion: number }
Expand Down
2 changes: 2 additions & 0 deletions apps/sim/stores/panel/copilot/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,7 @@ function replaceTextBlocks(blocks: ClientContentBlock[], text: string): ClientCo
function createClientStreamingContext(messageId: string): ClientStreamingContext {
return {
messageId,
requestId: undefined,
accumulatedContent: '',
contentBlocks: [],
currentTextBlock: null,
Expand Down Expand Up @@ -2043,6 +2044,7 @@ export const useCopilotStore = create<CopilotStore>()(
msg.id === assistantMessageId
? {
...msg,
requestId: context.requestId ?? msg.requestId,
content: finalContentWithOptions,
contentBlocks: sanitizedContentBlocks,
}
Expand Down
1 change: 1 addition & 0 deletions apps/sim/stores/panel/copilot/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
Expand Down
Loading