diff --git a/apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx b/apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx index 9e0085ae9cd..c8a2233a700 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx @@ -8,6 +8,7 @@ import { ChipModalField, ChipModalFooter, ChipModalHeader, + type ClipboardContent, cn, Duplicate, Split, @@ -15,6 +16,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 +25,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) => ClipboardContent userQuery?: string requestId?: string messageId?: string @@ -58,6 +41,9 @@ interface MessageActionsProps { export const MessageActions = memo(function MessageActions({ content, + getCopyContent, + hasCopyContent, + prepareContentForCopy, userQuery, requestId, messageId, @@ -65,40 +51,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(null) const requestIdTimeoutRef = useRef(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 (typeof markdown === 'string' && !markdown) return + void copyMessage(markdown) } const copyRequestId = async () => { @@ -166,18 +140,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 ( <>
- {hasContent && ( + {canCopyContent && (
) : ( { + return queryClient.fetchQuery({ + ...getWorkspaceFilesQueryOptions(workspaceId, scope), + staleTime: WORKSPACE_FILES_FORCE_REFRESH_STALE_TIME, + }) +} + /** * Hook to fetch workspace files */ diff --git a/packages/emcn/src/hooks/use-copy-to-clipboard.test.ts b/packages/emcn/src/hooks/use-copy-to-clipboard.test.ts new file mode 100644 index 00000000000..57461b13f31 --- /dev/null +++ b/packages/emcn/src/hooks/use-copy-to-clipboard.test.ts @@ -0,0 +1,65 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { writeTextToClipboard } from './use-copy-to-clipboard' + +interface MockClipboardItem { + items: Record> +} + +describe('writeTextToClipboard', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('writes prepared text directly', async () => { + const writeText = vi.fn().mockResolvedValue(undefined) + vi.stubGlobal('navigator', { clipboard: { writeText } }) + + await writeTextToClipboard('ready') + + expect(writeText).toHaveBeenCalledWith('ready') + }) + + it('starts a ClipboardItem write before promised text resolves', async () => { + const write = vi.fn().mockResolvedValue(undefined) + const writeText = vi.fn().mockResolvedValue(undefined) + vi.stubGlobal('navigator', { clipboard: { write, writeText } }) + vi.stubGlobal( + 'ClipboardItem', + class { + constructor(readonly items: Record>) {} + } + ) + let resolveText: (value: string) => void = () => undefined + const text = new Promise((resolve) => { + resolveText = resolve + }) + + const result = writeTextToClipboard({ fallback: 'available now', prepare: () => text }) + + expect(write).toHaveBeenCalledOnce() + expect(writeText).not.toHaveBeenCalled() + const [clipboardItems] = write.mock.calls[0] as [MockClipboardItem[]] + resolveText('prepared later') + const blob = await clipboardItems[0].items['text/plain'] + expect(await blob.text()).toBe('prepared later') + await result + }) + + it('writes the immediate fallback when ClipboardItem is unavailable', async () => { + const writeText = vi.fn().mockResolvedValue(undefined) + vi.stubGlobal('navigator', { clipboard: { writeText } }) + vi.stubGlobal('ClipboardItem', undefined) + let resolveText: (value: string) => void = () => undefined + const text = new Promise((resolve) => { + resolveText = resolve + }) + const prepare = vi.fn(() => text) + + const result = writeTextToClipboard({ fallback: 'available now', prepare }) + + expect(writeText).toHaveBeenCalledWith('available now') + expect(prepare).not.toHaveBeenCalled() + await result + resolveText('prepared later') + }) +}) diff --git a/packages/emcn/src/hooks/use-copy-to-clipboard.ts b/packages/emcn/src/hooks/use-copy-to-clipboard.ts index 751a94cf76c..d8fc0397ae9 100644 --- a/packages/emcn/src/hooks/use-copy-to-clipboard.ts +++ b/packages/emcn/src/hooks/use-copy-to-clipboard.ts @@ -7,9 +7,35 @@ interface UseCopyToClipboardOptions { resetMs?: number } +export interface DeferredClipboardContent { + /** Safe text that can be written immediately when promise-backed writes are unavailable. */ + fallback: string + /** Produces the preferred text when the browser supports promise-backed clipboard items. */ + prepare: () => Promise +} + +export type ClipboardContent = string | DeferredClipboardContent + interface UseCopyToClipboardReturn { copied: boolean - copy: (text: string) => Promise + copy: (content: ClipboardContent) => Promise +} + +/** + * Starts an async clipboard write while the caller still has transient user activation. + * Deferred text uses `ClipboardItem` when available and an immediate fallback otherwise. + */ +export function writeTextToClipboard(content: ClipboardContent): Promise { + if (typeof content === 'string') return navigator.clipboard.writeText(content) + + if (typeof ClipboardItem !== 'undefined' && typeof navigator.clipboard.write === 'function') { + const blob = Promise.resolve() + .then(() => content.prepare()) + .then((value) => new Blob([value], { type: 'text/plain' })) + return navigator.clipboard.write([new ClipboardItem({ 'text/plain': blob })]) + } + + return navigator.clipboard.writeText(content.fallback) } /** @@ -34,9 +60,9 @@ export function useCopyToClipboard( const timerRef = useRef | null>(null) const copy = useCallback( - async (text: string): Promise => { + async (content: ClipboardContent): Promise => { try { - await navigator.clipboard.writeText(text) + await writeTextToClipboard(content) setCopied(true) if (timerRef.current) clearTimeout(timerRef.current) timerRef.current = setTimeout(() => setCopied(false), resetMs) diff --git a/packages/emcn/src/index.ts b/packages/emcn/src/index.ts index cdb37cf1af2..8e474e58b89 100644 --- a/packages/emcn/src/index.ts +++ b/packages/emcn/src/index.ts @@ -33,7 +33,10 @@ export { TableHeader, TableRow, } from './components/table/table' -export { useCopyToClipboard } from './hooks/use-copy-to-clipboard' +export { + type ClipboardContent, + useCopyToClipboard, +} from './hooks/use-copy-to-clipboard' export { usePrefersReducedMotion } from './hooks/use-prefers-reduced-motion' export * from './icons' export { cn } from './lib/cn' diff --git a/scripts/check-tool-registry-boundary.baseline.json b/scripts/check-tool-registry-boundary.baseline.json index b8106a8cb7c..5346eb10340 100644 --- a/scripts/check-tool-registry-boundary.baseline.json +++ b/scripts/check-tool-registry-boundary.baseline.json @@ -10,10 +10,10 @@ "gateways": {} }, "app/workspace/[workspaceId]/chat/[chatId]/page.tsx": { - "modules": 3039, + "modules": 3041, "gateways": { - "apps/sim/app/workspace/[workspaceId]/home/home.tsx": 1375, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx": 1027, + "apps/sim/app/workspace/[workspaceId]/home/home.tsx": 1377, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx": 1026, "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/index.ts": 888, "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/index.ts": 885, "apps/sim/triggers/registry.ts": 452, @@ -60,10 +60,10 @@ "gateways": {} }, "app/workspace/[workspaceId]/home/page.tsx": { - "modules": 3039, + "modules": 3041, "gateways": { - "apps/sim/app/workspace/[workspaceId]/home/home.tsx": 1375, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx": 1027, + "apps/sim/app/workspace/[workspaceId]/home/home.tsx": 1377, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx": 1026, "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/index.ts": 888, "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/index.ts": 885, "apps/sim/triggers/registry.ts": 452, @@ -314,22 +314,22 @@ } }, "app/workspace/[workspaceId]/w/[workflowId]/layout.tsx": { - "modules": 2240, + "modules": 2243, "gateways": { - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/error/index.tsx": 2239, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts": 544, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/error/index.tsx": 2242, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts": 547, "apps/sim/triggers/registry.ts": 488, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/index.ts": 461, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/index.ts": 464, "apps/sim/blocks/registry.ts": 335, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/index.ts": 289, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/index.ts": 288, "apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx": 162, "apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/index.ts": 151 } }, "app/workspace/[workspaceId]/w/[workflowId]/page.tsx": { - "modules": 2267, + "modules": 2270, "gateways": { - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx": 2266, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx": 2269, "apps/sim/triggers/registry.ts": 488, "apps/sim/blocks/registry.ts": 335, "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts": 308, @@ -340,13 +340,13 @@ } }, "app/workspace/[workspaceId]/w/page.tsx": { - "modules": 2240, + "modules": 2243, "gateways": { - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts": 950, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts": 953, "apps/sim/triggers/registry.ts": 488, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/index.ts": 461, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/index.ts": 464, "apps/sim/blocks/registry.ts": 335, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/index.ts": 289, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/index.ts": 288, "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/error/index.tsx": 163, "apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/index.ts": 151, "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 145