Skip to content

Commit 5a2d3da

Browse files
committed
fix(chat): preserve clipboard activation
1 parent 930d6ca commit 5a2d3da

5 files changed

Lines changed: 78 additions & 15 deletions

File tree

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -66,12 +66,12 @@ export const MessageActions = memo(function MessageActions({
6666
}
6767
}, [])
6868

69-
const copyToClipboard = async () => {
69+
const copyToClipboard = () => {
7070
const contentToCopy = getCopyContent?.() ?? content
7171
if (!contentToCopy) return
72-
const markdown = (await prepareContentForCopy?.(contentToCopy)) ?? contentToCopy
73-
if (!markdown) return
74-
await copyMessage(markdown)
72+
const markdown = prepareContentForCopy?.(contentToCopy) ?? contentToCopy
73+
if (typeof markdown === 'string' && !markdown) return
74+
void copyMessage(markdown)
7575
}
7676

7777
const copyRequestId = async () => {

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -129,14 +129,14 @@ describe('toCopyableMarkdown', () => {
129129
expect(refreshWorkspaceFiles).toHaveBeenCalledOnce()
130130
})
131131

132-
it('does not refresh metadata when all workspace resources already resolve', async () => {
132+
it('does not refresh metadata when all workspace resources already resolve', () => {
133133
const message =
134134
'Read <workspace_resource>{"type":"file","path":"files/The%20Bell%20at%20Low%20Tide.md","title":"The Bell at Low Tide.md"}</workspace_resource>.'
135135
const refreshWorkspaceFiles = vi.fn()
136136

137-
await expect(
138-
prepareCopyableMarkdown(message, WORKSPACE_FILES, refreshWorkspaceFiles)
139-
).resolves.toBe('Read [The Bell at Low Tide.md](sim:file/file_bell).')
137+
expect(prepareCopyableMarkdown(message, WORKSPACE_FILES, refreshWorkspaceFiles)).toBe(
138+
'Read [The Bell at Low Tide.md](sim:file/file_bell).'
139+
)
140140
expect(refreshWorkspaceFiles).not.toHaveBeenCalled()
141141
})
142142

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

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -63,14 +63,15 @@ export function toCopyableMarkdown(
6363
return serializeCopyableMarkdown(raw, workspaceFiles).markdown
6464
}
6565

66-
export async function prepareCopyableMarkdown(
66+
export function prepareCopyableMarkdown(
6767
raw: string,
6868
workspaceFiles: readonly WorkspaceFileRecord[],
6969
refreshWorkspaceFiles: () => Promise<readonly WorkspaceFileRecord[]>
70-
): Promise<string> {
70+
): string | Promise<string> {
7171
const initial = serializeCopyableMarkdown(raw, workspaceFiles)
7272
if (!initial.hasUnresolvedFile) return initial.markdown
7373

74-
const refreshedFiles = await refreshWorkspaceFiles().catch(() => workspaceFiles)
75-
return toCopyableMarkdown(raw, refreshedFiles)
74+
return refreshWorkspaceFiles()
75+
.catch(() => workspaceFiles)
76+
.then((refreshedFiles) => toCopyableMarkdown(raw, refreshedFiles))
7677
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { afterEach, describe, expect, it, vi } from 'vitest'
2+
import { writeTextToClipboard } from './use-copy-to-clipboard'
3+
4+
interface MockClipboardItem {
5+
items: Record<string, Blob | Promise<Blob>>
6+
}
7+
8+
describe('writeTextToClipboard', () => {
9+
afterEach(() => {
10+
vi.unstubAllGlobals()
11+
})
12+
13+
it('writes prepared text directly', async () => {
14+
const writeText = vi.fn().mockResolvedValue(undefined)
15+
vi.stubGlobal('navigator', { clipboard: { writeText } })
16+
17+
await writeTextToClipboard('ready')
18+
19+
expect(writeText).toHaveBeenCalledWith('ready')
20+
})
21+
22+
it('starts a ClipboardItem write before promised text resolves', async () => {
23+
const write = vi.fn().mockResolvedValue(undefined)
24+
const writeText = vi.fn().mockResolvedValue(undefined)
25+
vi.stubGlobal('navigator', { clipboard: { write, writeText } })
26+
vi.stubGlobal(
27+
'ClipboardItem',
28+
class {
29+
constructor(readonly items: Record<string, Blob | Promise<Blob>>) {}
30+
}
31+
)
32+
let resolveText: (value: string) => void = () => undefined
33+
const text = new Promise<string>((resolve) => {
34+
resolveText = resolve
35+
})
36+
37+
const result = writeTextToClipboard(text)
38+
39+
expect(write).toHaveBeenCalledOnce()
40+
expect(writeText).not.toHaveBeenCalled()
41+
const [clipboardItems] = write.mock.calls[0] as [MockClipboardItem[]]
42+
resolveText('prepared later')
43+
const blob = await clipboardItems[0].items['text/plain']
44+
expect(await blob.text()).toBe('prepared later')
45+
await result
46+
})
47+
})

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

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,22 @@ interface UseCopyToClipboardOptions {
99

1010
interface UseCopyToClipboardReturn {
1111
copied: boolean
12-
copy: (text: string) => Promise<boolean>
12+
copy: (text: string | Promise<string>) => Promise<boolean>
13+
}
14+
15+
/**
16+
* 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.
18+
*/
19+
export function writeTextToClipboard(text: string | Promise<string>): Promise<void> {
20+
if (typeof text === 'string') return navigator.clipboard.writeText(text)
21+
22+
if (typeof ClipboardItem !== 'undefined' && typeof navigator.clipboard.write === 'function') {
23+
const blob = text.then((value) => new Blob([value], { type: 'text/plain' }))
24+
return navigator.clipboard.write([new ClipboardItem({ 'text/plain': blob })])
25+
}
26+
27+
return text.then((value) => navigator.clipboard.writeText(value))
1328
}
1429

1530
/**
@@ -34,9 +49,9 @@ export function useCopyToClipboard(
3449
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
3550

3651
const copy = useCallback(
37-
async (text: string): Promise<boolean> => {
52+
async (text: string | Promise<string>): Promise<boolean> => {
3853
try {
39-
await navigator.clipboard.writeText(text)
54+
await writeTextToClipboard(text)
4055
setCopied(true)
4156
if (timerRef.current) clearTimeout(timerRef.current)
4257
timerRef.current = setTimeout(() => setCopied(false), resetMs)

0 commit comments

Comments
 (0)