Skip to content

Commit 3f13f9c

Browse files
committed
Merge remote-tracking branch 'origin/staging' into staging-v64
2 parents 60866a8 + bb07925 commit 3f13f9c

18 files changed

Lines changed: 803 additions & 63 deletions

File tree

apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx

Lines changed: 18 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,15 @@ import {
88
ChipModalField,
99
ChipModalFooter,
1010
ChipModalHeader,
11+
type ClipboardContent,
1112
cn,
1213
Duplicate,
1314
Split,
1415
ThumbsDown,
1516
ThumbsUp,
1617
Tooltip,
1718
toast,
19+
useCopyToClipboard,
1820
} from '@sim/emcn'
1921
import { useParams, useRouter } from 'next/navigation'
2022
import { isLiveAssistantMessageId } from '@/lib/copilot/chat/effective-transcript'
@@ -23,82 +25,54 @@ import { useSubmitCopilotFeedback } from '@/hooks/queries/copilot-feedback'
2325
import { useForkMothershipChat } from '@/hooks/queries/mothership-chats'
2426
import { useFolderStore } from '@/stores/folders/store'
2527

26-
const SPECIAL_TAGS = 'thinking|options|usage_upgrade|credential|mothership-error|file|question'
27-
28-
function toPlainText(raw: string): string {
29-
return (
30-
raw
31-
// Strip special tags and their contents
32-
.replace(new RegExp(`<\\/?(${SPECIAL_TAGS})(?:>[\\s\\S]*?<\\/(${SPECIAL_TAGS})>|>)`, 'g'), '')
33-
// Strip markdown
34-
.replace(/^#{1,6}\s+/gm, '')
35-
.replace(/\*\*(.+?)\*\*/g, '$1')
36-
.replace(/\*(.+?)\*/g, '$1')
37-
.replace(/`{3}[\s\S]*?`{3}/g, '')
38-
.replace(/`(.+?)`/g, '$1')
39-
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
40-
.replace(/^[>\-*]\s+/gm, '')
41-
.replace(/!\[[^\]]*\]\([^)]+\)/g, '')
42-
// Normalize whitespace
43-
.replace(/\n{3,}/g, '\n\n')
44-
.trim()
45-
)
46-
}
47-
4828
const ICON_CLASS = 'size-[14px]'
4929
const BUTTON_CLASS =
5030
'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'
5131

5232
interface MessageActionsProps {
5333
content: string
34+
getCopyContent?: () => string
35+
hasCopyContent?: boolean
36+
prepareContentForCopy?: (content: string) => ClipboardContent
5437
userQuery?: string
5538
requestId?: string
5639
messageId?: string
5740
}
5841

5942
export const MessageActions = memo(function MessageActions({
6043
content,
44+
getCopyContent,
45+
hasCopyContent,
46+
prepareContentForCopy,
6147
userQuery,
6248
requestId,
6349
messageId,
6450
}: MessageActionsProps) {
6551
const router = useRouter()
6652
const params = useParams<{ workspaceId: string }>()
6753
const { chatId } = useChatSurface()
68-
const [copied, setCopied] = useState(false)
54+
const { copied, copy: copyMessage } = useCopyToClipboard({ resetMs: 1500 })
6955
const [copiedRequestId, setCopiedRequestId] = useState(false)
7056
const [pendingFeedback, setPendingFeedback] = useState<'up' | 'down' | null>(null)
7157
const [feedbackText, setFeedbackText] = useState('')
72-
const resetTimeoutRef = useRef<number | null>(null)
7358
const requestIdTimeoutRef = useRef<number | null>(null)
7459
const submitFeedback = useSubmitCopilotFeedback()
7560
const forkChat = useForkMothershipChat(params.workspaceId)
7661

7762
useEffect(() => {
7863
return () => {
79-
if (resetTimeoutRef.current !== null) {
80-
window.clearTimeout(resetTimeoutRef.current)
81-
}
8264
if (requestIdTimeoutRef.current !== null) {
8365
window.clearTimeout(requestIdTimeoutRef.current)
8466
}
8567
}
8668
}, [])
8769

88-
const copyToClipboard = async () => {
89-
if (!content) return
90-
const text = toPlainText(content)
91-
if (!text) return
92-
try {
93-
await navigator.clipboard.writeText(text)
94-
setCopied(true)
95-
if (resetTimeoutRef.current !== null) {
96-
window.clearTimeout(resetTimeoutRef.current)
97-
}
98-
resetTimeoutRef.current = window.setTimeout(() => setCopied(false), 1500)
99-
} catch {
100-
/* clipboard unavailable */
101-
}
70+
const copyToClipboard = () => {
71+
const contentToCopy = getCopyContent?.() ?? content
72+
if (!contentToCopy) return
73+
const copyContent = prepareContentForCopy?.(contentToCopy) ?? contentToCopy
74+
if (typeof copyContent === 'string' && !copyContent) return
75+
void copyMessage(copyContent)
10276
}
10377

10478
const copyRequestId = async () => {
@@ -166,18 +140,18 @@ export const MessageActions = memo(function MessageActions({
166140
}
167141
}
168142

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

177151
return (
178152
<>
179153
<div className='flex items-center gap-0.5'>
180-
{hasContent && (
154+
{canCopyContent && (
181155
<Tooltip.Root>
182156
<Tooltip.Trigger asChild>
183157
<button
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export {
22
assistantMessageHasRenderableContent,
3+
getOrchestratorMessageText,
34
MessageContent,
45
} from './message-content'
56
export type { MessagePhase } from './utils'

apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import type { ContentBlock } from '../../types'
2727
import {
2828
assistantMessageHasVisibleExecutingTool,
2929
deriveThinkingLabel,
30+
getOrchestratorMessageText,
3031
parseBlocks,
3132
shouldSmoothTextSegment,
3233
} from './message-content'
@@ -100,6 +101,66 @@ function toolEnvelope(
100101
} as PersistedStreamEventEnvelope
101102
}
102103

104+
describe('getOrchestratorMessageText', () => {
105+
it('copies only orchestrator text from span-based messages', () => {
106+
const blocks: ContentBlock[] = [
107+
subagentStart('research', 'span-visible', 'main'),
108+
{
109+
type: 'subagent_text',
110+
content: 'Visible research. ',
111+
spanId: 'span-visible',
112+
timestamp: 2,
113+
},
114+
{
115+
type: 'subagent_text',
116+
content: 'Hidden orphan. ',
117+
spanId: 'span-orphan',
118+
timestamp: 3,
119+
},
120+
mainText('Main answer.'),
121+
]
122+
123+
expect(getOrchestratorMessageText(blocks, 'Fallback.')).toBe('Main answer.')
124+
})
125+
126+
it('copies only orchestrator text from legacy messages', () => {
127+
const blocks: ContentBlock[] = [
128+
{ type: 'subagent_text', content: 'Hidden orphan. ', timestamp: 1 },
129+
{
130+
type: 'subagent',
131+
content: 'research',
132+
parentToolCallId: 'dispatch-visible',
133+
timestamp: 2,
134+
},
135+
{
136+
type: 'subagent_text',
137+
content: 'Visible research. ',
138+
parentToolCallId: 'dispatch-visible',
139+
timestamp: 3,
140+
},
141+
mainText('Main answer.'),
142+
]
143+
144+
expect(getOrchestratorMessageText(blocks, 'Fallback.')).toBe('Main answer.')
145+
})
146+
147+
it('separates orchestrator text blocks around excluded subagent output', () => {
148+
const blocks: ContentBlock[] = [
149+
mainText('Starting answer.'),
150+
subagentStart('research', 'span-visible', 'main'),
151+
{
152+
type: 'subagent_text',
153+
content: 'Visible research.',
154+
spanId: 'span-visible',
155+
timestamp: 2,
156+
},
157+
mainText('Main answer.'),
158+
]
159+
160+
expect(getOrchestratorMessageText(blocks, 'Fallback.')).toBe('Starting answer.\n\nMain answer.')
161+
})
162+
})
163+
103164
describe('parseBlocks span-identity tree', () => {
104165
it('refines a completed credential rename with its previous and new names', () => {
105166
const segments = parseBlocks([

apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -492,6 +492,23 @@ export function parseBlocks(blocks: ContentBlock[]): MessageSegment[] {
492492
return parseBlocksLegacy(blocks)
493493
}
494494

495+
function joinRenderableText(parts: string[]): string {
496+
return parts.filter(Boolean).join('\n\n')
497+
}
498+
499+
/** Returns only top-level orchestrator text, excluding agent groups and other UI segments. */
500+
export function getOrchestratorMessageText(
501+
blocks: ContentBlock[],
502+
fallbackContent: string
503+
): string {
504+
const parsed = blocks.length > 0 ? parseBlocks(blocks) : []
505+
if (parsed.length === 0) return fallbackContent
506+
507+
return joinRenderableText(
508+
parsed.map((segment) => (segment.type === 'text' ? segment.content : ''))
509+
)
510+
}
511+
495512
function parseBlocksLegacy(blocks: ContentBlock[]): MessageSegment[] {
496513
const segments: MessageSegment[] = []
497514
const groupsByKey = new Map<string, AgentGroupSegment>()

0 commit comments

Comments
 (0)