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 349fc95eee1..2baae14945d 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, @@ -32,7 +33,7 @@ interface MessageActionsProps { content: string getCopyContent?: () => string hasCopyContent?: boolean - prepareContentForCopy?: (content: string) => string + prepareContentForCopy?: (content: string) => ClipboardContent userQuery?: string requestId?: string messageId?: string @@ -69,9 +70,9 @@ export const MessageActions = memo(function MessageActions({ const copyToClipboard = () => { const contentToCopy = getCopyContent?.() ?? content if (!contentToCopy) return - const markdown = prepareContentForCopy?.(contentToCopy) ?? contentToCopy - if (!markdown) return - void copyMessage(markdown) + const copyContent = prepareContentForCopy?.(contentToCopy) ?? contentToCopy + if (typeof copyContent === 'string' && !copyContent) return + void copyMessage(copyContent) } const copyRequestId = async () => { diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/copyable-markdown.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/copyable-markdown.test.ts index 851bd0c1fc9..1bdc3dd78a5 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/copyable-markdown.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/copyable-markdown.test.ts @@ -1,5 +1,25 @@ -import { describe, expect, it } from 'vitest' -import { toCopyableMarkdown } from '@/app/workspace/[workspaceId]/home/components/mothership-chat/copyable-markdown' +import { describe, expect, it, vi } from 'vitest' +import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' +import { + prepareCopyableMarkdown, + toCopyableMarkdown, +} from '@/app/workspace/[workspaceId]/home/components/mothership-chat/copyable-markdown' +import { parseChipLinks } from '@/app/workspace/[workspaceId]/home/components/user-input/components/chip-clipboard-codec' + +const WORKSPACE_FILES: WorkspaceFileRecord[] = [ + { + id: 'file_bell', + workspaceId: 'workspace-1', + name: 'The Bell at Low Tide.md', + key: 'workspace/workspace-1/file_bell', + path: '/api/files/view/file_bell', + size: 0, + type: 'text/markdown', + uploadedBy: 'user-1', + uploadedAt: new Date(0), + updatedAt: new Date(0), + }, +] describe('toCopyableMarkdown', () => { it('preserves message Markdown, including fenced code and its language', () => { @@ -44,4 +64,99 @@ describe('toCopyableMarkdown', () => { expect(toCopyableMarkdown(message)).toBe(message) }) + + it('copies workspace resources as portable Markdown links with real ids', () => { + const message = [ + 'Read', + '{"type":"file","path":"files/The%20Bell%20at%20Low%20Tide.md","title":"The Bell at Low Tide.md"}', + 'and', + `${JSON.stringify({ + type: 'table', + id: 'tbl_f26af6dae98d4222b014b250494d00fb', + title: 'Checked_[rare]\\portal', + })}.`, + ].join('') + + const markdown = toCopyableMarkdown(message, WORKSPACE_FILES) + + expect(markdown).toBe( + 'Read [The Bell at Low Tide.md](sim:file/file_bell) and [Checked_\\[rare\\]\\\\portal](sim:table/tbl_f26af6dae98d4222b014b250494d00fb).' + ) + expect(parseChipLinks(markdown)).toEqual([ + { + kind: 'file', + id: 'file_bell', + label: 'The Bell at Low Tide.md', + start: 5, + end: 50, + }, + { + kind: 'table', + id: 'tbl_f26af6dae98d4222b014b250494d00fb', + label: 'Checked_[rare]\\portal', + start: 55, + end: 129, + }, + ]) + }) + + it('uses resolved file metadata for a resource without a title', () => { + const message = + 'Read {"type":"file","path":"files/The%20Bell%20at%20Low%20Tide.md"}.' + + expect(toCopyableMarkdown(message, WORKSPACE_FILES)).toBe( + 'Read [The Bell at Low Tide.md](sim:file/file_bell).' + ) + }) + + it('refreshes missing file metadata before producing copyable Markdown', async () => { + const message = + 'Read {"type":"file","path":"files/The%20Bell%20at%20Low%20Tide.md","title":"The Bell at Low Tide.md"}.' + const refreshWorkspaceFiles = vi.fn().mockResolvedValue(WORKSPACE_FILES) + + const content = prepareCopyableMarkdown(message, [], refreshWorkspaceFiles) + expect(content).not.toBeTypeOf('string') + if (typeof content === 'string') throw new Error('Expected deferred clipboard content') + expect(content.fallback).toBe('Read The Bell at Low Tide.md.') + expect(parseChipLinks(content.fallback)).toEqual([]) + await expect(content.prepare()).resolves.toBe( + 'Read [The Bell at Low Tide.md](sim:file/file_bell).' + ) + expect(refreshWorkspaceFiles).toHaveBeenCalledOnce() + }) + + it('copies unresolved file references as plain text', () => { + const message = + 'Read {"type":"file","path":"files/Q1 plan).md","title":"Q1 plan).md"}.' + + const markdown = toCopyableMarkdown(message) + + expect(markdown).toBe('Read Q1 plan).md.') + expect(parseChipLinks(markdown)).toEqual([]) + }) + + it('keeps the plain-text fallback when refreshing file metadata fails', async () => { + const message = + 'Read {"type":"file","path":"files/notes.md","title":"notes.md"}.' + const refreshWorkspaceFiles = vi.fn().mockRejectedValue(new Error('Refresh failed')) + + const content = prepareCopyableMarkdown(message, [], refreshWorkspaceFiles) + + expect(content).not.toBeTypeOf('string') + if (typeof content === 'string') throw new Error('Expected deferred clipboard content') + expect(content.fallback).toBe('Read notes.md.') + await expect(content.prepare()).resolves.toBe('Read notes.md.') + expect(refreshWorkspaceFiles).toHaveBeenCalledOnce() + }) + + it('does not refresh metadata when all workspace resources already resolve', () => { + const message = + 'Read {"type":"file","path":"files/The%20Bell%20at%20Low%20Tide.md","title":"The Bell at Low Tide.md"}.' + const refreshWorkspaceFiles = vi.fn() + + expect(prepareCopyableMarkdown(message, WORKSPACE_FILES, refreshWorkspaceFiles)).toBe( + 'Read [The Bell at Low Tide.md](sim:file/file_bell).' + ) + expect(refreshWorkspaceFiles).not.toHaveBeenCalled() + }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/copyable-markdown.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/copyable-markdown.ts index 8d0fc40e0b4..0a27f697f6a 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/copyable-markdown.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/copyable-markdown.ts @@ -1,13 +1,101 @@ +import type { ClipboardContent } from '@sim/emcn' +import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' 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' +import { + type ContentSegment, + parseSpecialTags, + type WorkspaceResourceTagData, +} from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags' +import { serializePortableChipLink } from '@/app/workspace/[workspaceId]/home/components/user-input/components/chip-clipboard-codec' +import { resolveWorkspaceResourceRef } from '@/app/workspace/[workspaceId]/home/resolve-resource-ref' -export function toCopyableMarkdown(raw: string): string { +interface CopyableMarkdownResult { + markdown: string + hasUnresolvedFile: boolean +} + +function workspaceResourceLabel(data: WorkspaceResourceTagData): string { + if (data.title) return data.title + return data.type === 'file' ? (data.path ?? data.id ?? '') : (data.id ?? '') +} + +function appendInlineReferenceMarkdown( + currentMarkdown: string, + referenceMarkdown: string, + nextSegment?: ContentSegment +): string { + const followingText = + nextSegment?.type === 'text' + ? nextSegment.content + : nextSegment?.type === 'workspace_resource' + ? nextSegment.data.title || nextSegment.data.id || '' + : '' + const leadingSpace = /[A-Za-z0-9_)]$/.test(currentMarkdown) ? ' ' : '' + const trailingSpace = + /^[A-Za-z0-9_(]/.test(followingText) && !/\s$/.test(referenceMarkdown) ? ' ' : '' + return `${currentMarkdown}${leadingSpace}${referenceMarkdown}${trailingSpace}` +} + +function portableWorkspaceResourceMarkdown( + data: WorkspaceResourceTagData, + workspaceFiles: readonly WorkspaceFileRecord[] +): CopyableMarkdownResult { + const label = workspaceResourceLabel(data) + const resource = resolveWorkspaceResourceRef({ ...data, title: data.title ?? '' }, workspaceFiles) + return { + markdown: resource + ? serializePortableChipLink(data.type, resource.id, resource.title || label) + : label, + hasUnresolvedFile: data.type === 'file' && !resource, + } +} + +function serializeCopyableMarkdown( + raw: string, + workspaceFiles: readonly WorkspaceFileRecord[] = [] +): CopyableMarkdownResult { const displayContent = sanitizeChatDisplayContent(raw) const { segments } = parseSpecialTags(displayContent, false) + let hasUnresolvedFile = false - return segments - .reduce((markdown, segment) => { - return segment.type === 'text' ? markdown + segment.content : markdown + const markdown = segments + .reduce((markdown, segment, index) => { + if (segment.type === 'text') return markdown + segment.content + if (segment.type === 'workspace_resource') { + const portable = portableWorkspaceResourceMarkdown(segment.data, workspaceFiles) + hasUnresolvedFile ||= portable.hasUnresolvedFile + return appendInlineReferenceMarkdown(markdown, portable.markdown, segments[index + 1]) + } + return markdown }, '') .trim() + + return { markdown, hasUnresolvedFile } +} + +export function toCopyableMarkdown( + raw: string, + workspaceFiles: readonly WorkspaceFileRecord[] = [] +): string { + return serializeCopyableMarkdown(raw, workspaceFiles).markdown +} + +export function prepareCopyableMarkdown( + raw: string, + workspaceFiles: readonly WorkspaceFileRecord[], + refreshWorkspaceFiles: () => Promise +): ClipboardContent { + const initial = serializeCopyableMarkdown(raw, workspaceFiles) + if (!initial.hasUnresolvedFile) return initial.markdown + + return { + fallback: initial.markdown, + prepare: async () => { + try { + return toCopyableMarkdown(raw, await refreshWorkspaceFiles()) + } catch { + return initial.markdown + } + }, + } } diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx index 756d80ed984..0bd179c7d7f 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx @@ -10,9 +10,11 @@ import { useRef, useState, } from 'react' -import { cn } from '@sim/emcn' +import { type ClipboardContent, cn } from '@sim/emcn' +import { useQueryClient } from '@tanstack/react-query' import { defaultRangeExtractor, type Range, useVirtualizer } from '@tanstack/react-virtual' import { SMOOTH_CHASE_RATE } from '@/lib/core/utils/smooth-bottom-chase' +import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' import { MessageActions } from '@/app/workspace/[workspaceId]/components' import { ChatMessageAttachments } from '@/app/workspace/[workspaceId]/home/components/chat-message-attachments' import { ChatSurfaceProvider } from '@/app/workspace/[workspaceId]/home/components/chat-surface-context' @@ -30,7 +32,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 { prepareCopyableMarkdown } 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 { @@ -48,12 +50,14 @@ import type { WorkspaceResourceRef, } from '@/app/workspace/[workspaceId]/home/types' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' +import { getWorkspaceFilesQueryOptions, workspaceFilesKeys } from '@/hooks/queries/workspace-files' import { useAutoScroll } from '@/hooks/use-auto-scroll' import type { ChatContext } from '@/stores/panel' import { MothershipChatSkeleton } from './components/mothership-chat-skeleton' import { shouldShowAssistantMessageActions } from './message-actions-visibility' interface MothershipChatProps { + workspaceId: string messages: ChatMessage[] isSending: boolean isReconnecting?: boolean @@ -150,6 +154,7 @@ const LAYOUT_STYLES = { } as const const EMPTY_BLOCKS: ContentBlock[] = [] +const EMPTY_WORKSPACE_FILES: readonly WorkspaceFileRecord[] = [] interface UserMessageRowProps { content: string @@ -187,6 +192,7 @@ const UserMessageRow = memo(function UserMessageRow({ interface AssistantMessageRowProps { message: ChatMessage + prepareContentForCopy: (content: string) => ClipboardContent isStreaming: boolean isLast: boolean precedingUserContent?: string @@ -203,6 +209,7 @@ interface AssistantMessageRowProps { const AssistantMessageRow = memo(function AssistantMessageRow({ message, + prepareContentForCopy, isStreaming, isLast, precedingUserContent, @@ -231,8 +238,6 @@ const AssistantMessageRow = memo(function AssistantMessageRow({ () => 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 @@ -304,6 +309,7 @@ const AssistantMessageRow = memo(function AssistantMessageRow({ }) export function MothershipChat({ + workspaceId, messages: messagesProp, isSending, isReconnecting = false, @@ -329,6 +335,7 @@ export function MothershipChat({ onInputAnimationEnd, className, }: MothershipChatProps) { + const queryClient = useQueryClient() const styles = LAYOUT_STYLES[layout] const isStreamActive = isSending || isReconnecting /** @@ -347,6 +354,21 @@ export function MothershipChat({ const heldHighWaterRef = useRef(0) const floorChatRef = useRef(undefined) const floorDrainRafRef = useRef(0) + const prepareContentForCopy = useCallback( + (content: string) => + prepareCopyableMarkdown( + content, + queryClient.getQueryData( + workspaceFilesKeys.list(workspaceId) + ) ?? EMPTY_WORKSPACE_FILES, + () => + queryClient.fetchQuery({ + ...getWorkspaceFilesQueryOptions(workspaceId), + staleTime: 0, + }) + ), + [queryClient, workspaceId] + ) useEffect(() => () => cancelAnimationFrame(floorDrainRafRef.current), []) /** @@ -771,6 +793,7 @@ export function MothershipChat({ ) : ( ) : ( > +} + +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..41bff34627c 100644 --- a/packages/emcn/src/index.ts +++ b/packages/emcn/src/index.ts @@ -33,7 +33,11 @@ export { TableHeader, TableRow, } from './components/table/table' -export { useCopyToClipboard } from './hooks/use-copy-to-clipboard' +export { + type ClipboardContent, + useCopyToClipboard, + writeTextToClipboard, +} from './hooks/use-copy-to-clipboard' export { usePrefersReducedMotion } from './hooks/use-prefers-reduced-motion' export * from './icons' export { cn } from './lib/cn'