Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions apps/sim/app/workspace/[workspaceId]/components/index.ts
Original file line number Diff line number Diff line change
@@ -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,
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,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<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}>
<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>
<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 @@ -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'
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]/components'
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 || message.contentBlocks?.length) && (
<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
4 changes: 4 additions & 0 deletions apps/sim/lib/copilot/messages/serialization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
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
3 changes: 3 additions & 0 deletions apps/sim/lib/copilot/orchestrator/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { MothershipResource } from '@/lib/copilot/resource-types'

export type SSEEventType =
| 'chat_id'
| 'request_id'
| 'title_updated'
| 'content'
| 'reasoning'
Expand Down Expand Up @@ -88,6 +89,7 @@ export interface ContentBlock {

export interface StreamingContext {
chatId?: string
requestId?: string
messageId: string
accumulatedContent: string
contentBlocks: ContentBlock[]
Expand Down Expand Up @@ -154,6 +156,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