Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
ThumbsUp,
Tooltip,
toast,
useCopyToClipboard,
} from '@sim/emcn'
import { useParams, useRouter } from 'next/navigation'
import { isLiveAssistantMessageId } from '@/lib/copilot/chat/effective-transcript'
Expand All @@ -23,82 +24,54 @@ import { useSubmitCopilotFeedback } from '@/hooks/queries/copilot-feedback'
import { useForkMothershipChat } from '@/hooks/queries/mothership-chats'
import { useFolderStore } from '@/stores/folders/store'

const SPECIAL_TAGS = 'thinking|options|usage_upgrade|credential|mothership-error|file|question'

function toPlainText(raw: string): string {
return (
raw
// Strip special tags and their contents
.replace(new RegExp(`<\\/?(${SPECIAL_TAGS})(?:>[\\s\\S]*?<\\/(${SPECIAL_TAGS})>|>)`, 'g'), '')
// Strip markdown
.replace(/^#{1,6}\s+/gm, '')
.replace(/\*\*(.+?)\*\*/g, '$1')
.replace(/\*(.+?)\*/g, '$1')
.replace(/`{3}[\s\S]*?`{3}/g, '')
.replace(/`(.+?)`/g, '$1')
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
.replace(/^[>\-*]\s+/gm, '')
.replace(/!\[[^\]]*\]\([^)]+\)/g, '')
// Normalize whitespace
.replace(/\n{3,}/g, '\n\n')
.trim()
)
}

const ICON_CLASS = 'size-[14px]'
const BUTTON_CLASS =
'flex size-[26px] items-center justify-center rounded-[6px] text-[var(--text-icon)] transition-colors hover-hover:bg-[var(--surface-hover)] focus-visible:outline-none'

interface MessageActionsProps {
content: string
getCopyContent?: () => string
hasCopyContent?: boolean
prepareContentForCopy?: (content: string) => string
userQuery?: string
requestId?: string
messageId?: string
}

export const MessageActions = memo(function MessageActions({
content,
getCopyContent,
hasCopyContent,
prepareContentForCopy,
userQuery,
requestId,
messageId,
}: MessageActionsProps) {
const router = useRouter()
const params = useParams<{ workspaceId: string }>()
const { chatId } = useChatSurface()
const [copied, setCopied] = useState(false)
const { copied, copy: copyMessage } = useCopyToClipboard({ resetMs: 1500 })
const [copiedRequestId, setCopiedRequestId] = useState(false)
const [pendingFeedback, setPendingFeedback] = useState<'up' | 'down' | null>(null)
const [feedbackText, setFeedbackText] = useState('')
const resetTimeoutRef = useRef<number | null>(null)
const requestIdTimeoutRef = useRef<number | null>(null)
const submitFeedback = useSubmitCopilotFeedback()
const forkChat = useForkMothershipChat(params.workspaceId)

useEffect(() => {
return () => {
if (resetTimeoutRef.current !== null) {
window.clearTimeout(resetTimeoutRef.current)
}
if (requestIdTimeoutRef.current !== null) {
window.clearTimeout(requestIdTimeoutRef.current)
}
}
}, [])

const copyToClipboard = async () => {
if (!content) return
const text = toPlainText(content)
if (!text) return
try {
await navigator.clipboard.writeText(text)
setCopied(true)
if (resetTimeoutRef.current !== null) {
window.clearTimeout(resetTimeoutRef.current)
}
resetTimeoutRef.current = window.setTimeout(() => setCopied(false), 1500)
} catch {
/* clipboard unavailable */
}
const copyToClipboard = () => {
const contentToCopy = getCopyContent?.() ?? content
if (!contentToCopy) return
const markdown = prepareContentForCopy?.(contentToCopy) ?? contentToCopy
if (!markdown) return
void copyMessage(markdown)
}

const copyRequestId = async () => {
Expand Down Expand Up @@ -166,18 +139,18 @@ export const MessageActions = memo(function MessageActions({
}
}

const hasContent = Boolean(content)
const canCopyContent = hasCopyContent ?? Boolean(content)
const canSubmitFeedback = Boolean(chatId && userQuery)
// A live (just-streamed) assistant message carries a synthetic id that the
// persisted transcript doesn't know — forking it would 400. The button
// appears once the transcript refetch swaps in the persisted message id.
const canFork = Boolean(chatId && messageId && !isLiveAssistantMessageId(messageId))
if (!hasContent && !canSubmitFeedback && !canFork) return null
if (!canCopyContent && !canSubmitFeedback && !canFork) return null

return (
<>
<div className='flex items-center gap-0.5'>
{hasContent && (
{canCopyContent && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<button
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export {
assistantMessageHasRenderableContent,
getOrchestratorMessageText,
MessageContent,
} from './message-content'
export type { MessagePhase } from './utils'
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import type { ContentBlock } from '../../types'
import {
assistantMessageHasVisibleExecutingTool,
deriveThinkingLabel,
getOrchestratorMessageText,
parseBlocks,
shouldSmoothTextSegment,
} from './message-content'
Expand Down Expand Up @@ -100,6 +101,66 @@ function toolEnvelope(
} as PersistedStreamEventEnvelope
}

describe('getOrchestratorMessageText', () => {
it('copies only orchestrator text from span-based messages', () => {
const blocks: ContentBlock[] = [
subagentStart('research', 'span-visible', 'main'),
{
type: 'subagent_text',
content: 'Visible research. ',
spanId: 'span-visible',
timestamp: 2,
},
{
type: 'subagent_text',
content: 'Hidden orphan. ',
spanId: 'span-orphan',
timestamp: 3,
},
mainText('Main answer.'),
]

expect(getOrchestratorMessageText(blocks, 'Fallback.')).toBe('Main answer.')
})

it('copies only orchestrator text from legacy messages', () => {
const blocks: ContentBlock[] = [
{ type: 'subagent_text', content: 'Hidden orphan. ', timestamp: 1 },
{
type: 'subagent',
content: 'research',
parentToolCallId: 'dispatch-visible',
timestamp: 2,
},
{
type: 'subagent_text',
content: 'Visible research. ',
parentToolCallId: 'dispatch-visible',
timestamp: 3,
},
mainText('Main answer.'),
]

expect(getOrchestratorMessageText(blocks, 'Fallback.')).toBe('Main answer.')
})

it('separates orchestrator text blocks around excluded subagent output', () => {
const blocks: ContentBlock[] = [
mainText('Starting answer.'),
subagentStart('research', 'span-visible', 'main'),
{
type: 'subagent_text',
content: 'Visible research.',
spanId: 'span-visible',
timestamp: 2,
},
mainText('Main answer.'),
]

expect(getOrchestratorMessageText(blocks, 'Fallback.')).toBe('Starting answer.\n\nMain answer.')
})
})

describe('parseBlocks span-identity tree', () => {
it('refines a completed credential rename with its previous and new names', () => {
const segments = parseBlocks([
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -492,6 +492,23 @@ export function parseBlocks(blocks: ContentBlock[]): MessageSegment[] {
return parseBlocksLegacy(blocks)
}

function joinRenderableText(parts: string[]): string {
return parts.filter(Boolean).join('\n\n')
}

/** Returns only top-level orchestrator text, excluding agent groups and other UI segments. */
export function getOrchestratorMessageText(
blocks: ContentBlock[],
fallbackContent: string
): string {
const parsed = blocks.length > 0 ? parseBlocks(blocks) : []
if (parsed.length === 0) return fallbackContent

return joinRenderableText(
parsed.map((segment) => (segment.type === 'text' ? segment.content : ''))
)
}

function parseBlocksLegacy(blocks: ContentBlock[]): MessageSegment[] {
const segments: MessageSegment[] = []
const groupsByKey = new Map<string, AgentGroupSegment>()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest'
import { toCopyableMarkdown } from '@/app/workspace/[workspaceId]/home/components/mothership-chat/copyable-markdown'

describe('toCopyableMarkdown', () => {
it('preserves message Markdown, including fenced code and its language', () => {
const message = [
'# Elevator diagnosis',
'',
'The bug is in `dispatch_legacy.py`:',
'',
'```python',
'def next_stop(requests, current):',
' ranked = sorted(requests)',
' return ranked[1:]',
'```',
'',
'**Result:** the closest request *was not* always selected.',
].join('\n')

expect(toCopyableMarkdown(message)).toBe(message)
})

it('removes internal structured tags without flattening surrounding Markdown', () => {
const message = [
'Before **formatted text**.',
'<credential>{"type":"service_account","provider":"gmail"}</credential>',
'After [a link](https://example.com).',
].join('\n')

expect(toCopyableMarkdown(message)).toBe(
['Before **formatted text**.', '', 'After [a link](https://example.com).'].join('\n')
)
})

it('preserves tag-shaped text that the chat renders literally', () => {
const message = [
'Document `<credential>example</credential>`.',
'',
'```html',
'<file>example</file>',
'<question>example</question>',
'```',
].join('\n')

expect(toCopyableMarkdown(message)).toBe(message)
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { sanitizeChatDisplayContent } from '@/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-sanitize'
import { parseSpecialTags } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'

export function toCopyableMarkdown(raw: string): string {
const displayContent = sanitizeChatDisplayContent(raw)
const { segments } = parseSpecialTags(displayContent, false)

return segments
.reduce((markdown, segment) => {
return segment.type === 'text' ? markdown + segment.content : markdown
}, '')
.trim()
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { ChatMessageAttachments } from '@/app/workspace/[workspaceId]/home/compo
import { ChatSurfaceProvider } from '@/app/workspace/[workspaceId]/home/components/chat-surface-context'
import {
assistantMessageHasRenderableContent,
getOrchestratorMessageText,
MessageContent,
type MessagePhase,
} from '@/app/workspace/[workspaceId]/home/components/message-content'
Expand All @@ -29,6 +30,7 @@ import {
parseLastCredentialTag,
parseLastQuestionTag,
} from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
import { toCopyableMarkdown } from '@/app/workspace/[workspaceId]/home/components/mothership-chat/copyable-markdown'
import { nextSizerFloor } from '@/app/workspace/[workspaceId]/home/components/mothership-chat/sizer-floor'
import { QueuedMessages } from '@/app/workspace/[workspaceId]/home/components/queued-messages'
import {
Expand Down Expand Up @@ -225,6 +227,12 @@ const AssistantMessageRow = memo(function AssistantMessageRow({
onAnimatingChangeRef.current?.(phase !== 'settled')
}, [phase])

const getCopyContent = useCallback(
() => getOrchestratorMessageText(blocks, message.content),
[blocks, message.content]
)
const prepareContentForCopy = useCallback((content: string) => toCopyableMarkdown(content), [])

const hasRenderableAssistant = assistantMessageHasRenderableContent(blocks, message.content ?? '')
if (!hasRenderableAssistant && !trimmedContent && !isStreaming) {
return null
Expand Down Expand Up @@ -281,6 +289,9 @@ const AssistantMessageRow = memo(function AssistantMessageRow({
actionsEligible ? (
<MessageActions
content={message.content}
getCopyContent={getCopyContent}
hasCopyContent={Boolean(getOrchestratorMessageText(blocks, message.content).trim())}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy button ignores prepared content

Medium Severity

hasCopyContent is derived from raw orchestrator text before toCopyableMarkdown, while the click path bails out when the prepared markdown is empty. The Copy control can therefore appear for content that only yields stripable tags or unresolved non-text segments, and a click writes nothing with no feedback.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 8696252. Configure here.

prepareContentForCopy={prepareContentForCopy}
userQuery={precedingUserContent}
requestId={message.requestId}
messageId={message.id}
Expand Down
Loading