Skip to content

Commit 78219f8

Browse files
committed
fix(chat): copy safely without ClipboardItem
1 parent 5a2d3da commit 78219f8

6 files changed

Lines changed: 58 additions & 16 deletions

File tree

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
ChipModalField,
99
ChipModalFooter,
1010
ChipModalHeader,
11+
type ClipboardContent,
1112
cn,
1213
Duplicate,
1314
Split,
@@ -32,7 +33,7 @@ interface MessageActionsProps {
3233
content: string
3334
getCopyContent?: () => string
3435
hasCopyContent?: boolean
35-
prepareContentForCopy?: (content: string) => string | Promise<string>
36+
prepareContentForCopy?: (content: string) => ClipboardContent
3637
userQuery?: string
3738
requestId?: string
3839
messageId?: string

apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/copyable-markdown.test.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,11 @@ describe('toCopyableMarkdown', () => {
123123
'Read <workspace_resource>{"type":"file","path":"files/The%20Bell%20at%20Low%20Tide.md","title":"The Bell at Low Tide.md"}</workspace_resource>.'
124124
const refreshWorkspaceFiles = vi.fn().mockResolvedValue(WORKSPACE_FILES)
125125

126-
await expect(prepareCopyableMarkdown(message, [], refreshWorkspaceFiles)).resolves.toBe(
126+
const content = prepareCopyableMarkdown(message, [], refreshWorkspaceFiles)
127+
expect(content).not.toBeTypeOf('string')
128+
if (typeof content === 'string') throw new Error('Expected deferred clipboard content')
129+
expect(content.fallback).toBe('Read The Bell at Low Tide.md.')
130+
await expect(content.prepare()).resolves.toBe(
127131
'Read [The Bell at Low Tide.md](sim:file/file_bell).'
128132
)
129133
expect(refreshWorkspaceFiles).toHaveBeenCalledOnce()

apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/copyable-markdown.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { ClipboardContent } from '@sim/emcn'
12
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
23
import { toSimMarkdownLink } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/sim-link'
34
import { sanitizeChatDisplayContent } from '@/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-sanitize'
@@ -67,11 +68,15 @@ export function prepareCopyableMarkdown(
6768
raw: string,
6869
workspaceFiles: readonly WorkspaceFileRecord[],
6970
refreshWorkspaceFiles: () => Promise<readonly WorkspaceFileRecord[]>
70-
): string | Promise<string> {
71+
): ClipboardContent {
7172
const initial = serializeCopyableMarkdown(raw, workspaceFiles)
7273
if (!initial.hasUnresolvedFile) return initial.markdown
7374

74-
return refreshWorkspaceFiles()
75-
.catch(() => workspaceFiles)
76-
.then((refreshedFiles) => toCopyableMarkdown(raw, refreshedFiles))
75+
return {
76+
fallback: initial.markdown,
77+
prepare: () =>
78+
refreshWorkspaceFiles()
79+
.catch(() => workspaceFiles)
80+
.then((refreshedFiles) => toCopyableMarkdown(raw, refreshedFiles)),
81+
}
7782
}

packages/emcn/src/hooks/use-copy-to-clipboard.test.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ describe('writeTextToClipboard', () => {
3434
resolveText = resolve
3535
})
3636

37-
const result = writeTextToClipboard(text)
37+
const result = writeTextToClipboard({ fallback: 'available now', prepare: () => text })
3838

3939
expect(write).toHaveBeenCalledOnce()
4040
expect(writeText).not.toHaveBeenCalled()
@@ -44,4 +44,22 @@ describe('writeTextToClipboard', () => {
4444
expect(await blob.text()).toBe('prepared later')
4545
await result
4646
})
47+
48+
it('writes the immediate fallback when ClipboardItem is unavailable', async () => {
49+
const writeText = vi.fn().mockResolvedValue(undefined)
50+
vi.stubGlobal('navigator', { clipboard: { writeText } })
51+
vi.stubGlobal('ClipboardItem', undefined)
52+
let resolveText: (value: string) => void = () => undefined
53+
const text = new Promise<string>((resolve) => {
54+
resolveText = resolve
55+
})
56+
const prepare = vi.fn(() => text)
57+
58+
const result = writeTextToClipboard({ fallback: 'available now', prepare })
59+
60+
expect(writeText).toHaveBeenCalledWith('available now')
61+
expect(prepare).not.toHaveBeenCalled()
62+
await result
63+
resolveText('prepared later')
64+
})
4765
})

packages/emcn/src/hooks/use-copy-to-clipboard.ts

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,24 +7,35 @@ interface UseCopyToClipboardOptions {
77
resetMs?: number
88
}
99

10+
export interface DeferredClipboardContent {
11+
/** Safe text that can be written immediately when promise-backed writes are unavailable. */
12+
fallback: string
13+
/** Produces the preferred text when the browser supports promise-backed clipboard items. */
14+
prepare: () => Promise<string>
15+
}
16+
17+
export type ClipboardContent = string | DeferredClipboardContent
18+
1019
interface UseCopyToClipboardReturn {
1120
copied: boolean
12-
copy: (text: string | Promise<string>) => Promise<boolean>
21+
copy: (content: ClipboardContent) => Promise<boolean>
1322
}
1423

1524
/**
1625
* Starts an async clipboard write while the caller still has transient user activation.
17-
* Promise-backed text uses `ClipboardItem` so preparation can finish after the write begins.
26+
* Deferred text uses `ClipboardItem` when available and an immediate fallback otherwise.
1827
*/
19-
export function writeTextToClipboard(text: string | Promise<string>): Promise<void> {
20-
if (typeof text === 'string') return navigator.clipboard.writeText(text)
28+
export function writeTextToClipboard(content: ClipboardContent): Promise<void> {
29+
if (typeof content === 'string') return navigator.clipboard.writeText(content)
2130

2231
if (typeof ClipboardItem !== 'undefined' && typeof navigator.clipboard.write === 'function') {
23-
const blob = text.then((value) => new Blob([value], { type: 'text/plain' }))
32+
const blob = Promise.resolve()
33+
.then(() => content.prepare())
34+
.then((value) => new Blob([value], { type: 'text/plain' }))
2435
return navigator.clipboard.write([new ClipboardItem({ 'text/plain': blob })])
2536
}
2637

27-
return text.then((value) => navigator.clipboard.writeText(value))
38+
return navigator.clipboard.writeText(content.fallback)
2839
}
2940

3041
/**
@@ -49,9 +60,9 @@ export function useCopyToClipboard(
4960
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
5061

5162
const copy = useCallback(
52-
async (text: string | Promise<string>): Promise<boolean> => {
63+
async (content: ClipboardContent): Promise<boolean> => {
5364
try {
54-
await writeTextToClipboard(text)
65+
await writeTextToClipboard(content)
5566
setCopied(true)
5667
if (timerRef.current) clearTimeout(timerRef.current)
5768
timerRef.current = setTimeout(() => setCopied(false), resetMs)

packages/emcn/src/index.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,10 @@ export {
3333
TableHeader,
3434
TableRow,
3535
} from './components/table/table'
36-
export { useCopyToClipboard } from './hooks/use-copy-to-clipboard'
36+
export {
37+
type ClipboardContent,
38+
useCopyToClipboard,
39+
} from './hooks/use-copy-to-clipboard'
3740
export { usePrefersReducedMotion } from './hooks/use-prefers-reduced-motion'
3841
export * from './icons'
3942
export { cn } from './lib/cn'

0 commit comments

Comments
 (0)