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'