fix(chat): copy orchestrator responses as markdown (#6903)

This commit is contained in:
Justin Blumencranz
2026-08-22 17:11:36 -07:00
committed by GitHub
parent e406b8a277
commit 61c405afb6
7 changed files with 167 additions and 44 deletions
@@ -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'
@@ -23,34 +24,15 @@ 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
@@ -58,6 +40,9 @@ interface MessageActionsProps {
export const MessageActions = memo(function MessageActions({
content,
getCopyContent,
hasCopyContent,
prepareContentForCopy,
userQuery,
requestId,
messageId,
@@ -65,40 +50,28 @@ export const MessageActions = memo(function MessageActions({
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 () => {
@@ -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
@@ -1,5 +1,6 @@
export {
assistantMessageHasRenderableContent,
getOrchestratorMessageText,
MessageContent,
} from './message-content'
export type { MessagePhase } from './utils'
@@ -27,6 +27,7 @@ import type { ContentBlock } from '../../types'
import {
assistantMessageHasVisibleExecutingTool,
deriveThinkingLabel,
getOrchestratorMessageText,
parseBlocks,
shouldSmoothTextSegment,
} from './message-content'
@@ -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([
@@ -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>()
@@ -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)
})
})
@@ -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()
}
@@ -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'
@@ -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 {
@@ -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
@@ -281,6 +289,9 @@ const AssistantMessageRow = memo(function AssistantMessageRow({
actionsEligible ? (
<MessageActions
content={message.content}
getCopyContent={getCopyContent}
hasCopyContent={Boolean(getOrchestratorMessageText(blocks, message.content).trim())}
prepareContentForCopy={prepareContentForCopy}
userQuery={precedingUserContent}
requestId={message.requestId}
messageId={message.id}