fix(web): a TUI copy the browser refuses offers a Copy button (#34135)

Browser clipboards need a recent user gesture, and an OSC 52 copy from a
TUI arrives after a host round trip that can outlive it. The web client
now shows a replaceable toast whose Copy click writes the refused text.
Desktop keeps its existing failure toast.

Refs #33937
This commit is contained in:
Neil
2026-10-26 17:13:06 -07:00
committed by GitHub
parent a8c871a271
commit a37c15904c
12 changed files with 114 additions and 12 deletions
@@ -142,3 +142,27 @@ describe('showOsc52ClipboardFailedToast', () => {
expect(toastErrorMock).toHaveBeenCalledTimes(2)
})
})
describe('showOsc52ClipboardCopyRetryToast', () => {
beforeEach(() => {
vi.resetModules()
toastErrorMock.mockReset()
})
// #33937: the Copy click is the browser gesture the original OSC 52 write lacked.
it('copies the refused text from the Copy click and replaces itself on every refusal', async () => {
const { showOsc52ClipboardCopyRetryToast } = await importToastModule()
const writeClipboardText = vi.fn<(text: string) => Promise<void>>().mockResolvedValue()
showOsc52ClipboardCopyRetryToast('first', writeClipboardText)
showOsc52ClipboardCopyRetryToast('copy me', writeClipboardText)
expect(toastErrorMock).toHaveBeenCalledTimes(2)
const [, first] = toastErrorMock.mock.calls[0]
const [, latest] = toastErrorMock.mock.calls[1]
expect(latest.id).toBe(first.id)
expect(writeClipboardText).not.toHaveBeenCalled()
latest.action.onClick()
expect(writeClipboardText).toHaveBeenCalledWith('copy me')
})
})
@@ -66,3 +66,35 @@ export function showOsc52ClipboardFailedToast(): void {
// Latch only after successful display, as for blocked writes.
hasShownOsc52ClipboardFailedToast = true
}
const OSC52_CLIPBOARD_COPY_RETRY_TOAST_ID = 'osc52-clipboard-copy-retry'
/** Browsers refuse clipboard writes without a recent gesture; the Copy click supplies one. Not latched: each refusal replaces the last. */
export function showOsc52ClipboardCopyRetryToast(
text: string,
writeClipboardText: (text: string) => Promise<void>
): void {
toast.error(
translate(
'auto.components.terminal.pane.osc52.clipboard.failed.toast.de286dcfe1',
'Terminal copy needs a click'
),
{
id: OSC52_CLIPBOARD_COPY_RETRY_TOAST_ID,
description: translate(
'auto.components.terminal.pane.osc52.clipboard.failed.toast.84c938ffa2',
'The browser blocked a copy from the terminal app. Click Copy to put the text on your clipboard.'
),
duration: 30_000,
action: {
label: translate(
'auto.components.terminal.pane.osc52.clipboard.failed.toast.a73165c6c5',
'Copy'
),
onClick: () => {
void writeClipboardText(text).catch(showOsc52ClipboardFailedToast)
}
}
}
)
}
@@ -1,10 +1,12 @@
import type { ManagedPane } from '@/lib/pane-manager/pane-manager'
import type { PaneSpawnHints } from '@/lib/pane-manager/pane-manager-types'
import { useAppStore } from '@/store'
import { isWebClientLocation } from '@/lib/web-client-location'
import { makePaneKey } from '../../../../shared/stable-pane-id'
import { createOsc52OscHandler } from '../../../../shared/osc52-clipboard'
import {
showOsc52ClipboardBlockedToast,
showOsc52ClipboardCopyRetryToast,
showOsc52ClipboardFailedToast
} from './osc52-clipboard-toast'
import { parseOsc7 } from './parse-osc7'
@@ -81,7 +83,13 @@ export function createTerminalPaneCreatedHandler(
getReplaying: () => isPaneReplaying(replayingPanesRef, pane.id),
writeClipboardText: (text) => window.api.ui.writeTerminalClipboardText(text),
showBlockedWriteToast: showOsc52ClipboardBlockedToast,
showWriteFailedToast: showOsc52ClipboardFailedToast
// Web clipboards need a recent gesture that a host round trip can outlive (#33937).
showWriteFailedToast: isWebClientLocation()
? (text) =>
showOsc52ClipboardCopyRetryToast(text, (retry) =>
window.api.ui.writeTerminalClipboardText(retry)
)
: showOsc52ClipboardFailedToast
})
)
)
+4 -1
View File
@@ -3830,7 +3830,10 @@
"failed": {
"toast": {
"62a0af2cb4": "Terminal clipboard copy could not be confirmed",
"fdd3e7e977": "The terminal app requested a copy, but Orca could not confirm that it reached the system clipboard."
"fdd3e7e977": "The terminal app requested a copy, but Orca could not confirm that it reached the system clipboard.",
"de286dcfe1": "Terminal copy needs a click",
"84c938ffa2": "The browser blocked a copy from the terminal app. Click Copy to put the text on your clipboard.",
"a73165c6c5": "Copy"
}
}
}
+4 -1
View File
@@ -3470,7 +3470,10 @@
"failed": {
"toast": {
"62a0af2cb4": "No se pudo confirmar la copia del portapapeles del terminal",
"fdd3e7e977": "La app del terminal solicitó una copia, pero Orca no pudo confirmar que llegara al portapapeles del sistema."
"fdd3e7e977": "La app del terminal solicitó una copia, pero Orca no pudo confirmar que llegara al portapapeles del sistema.",
"de286dcfe1": "La copia del terminal necesita un clic",
"84c938ffa2": "El navegador bloqueó una copia de la aplicación del terminal. Haz clic en Copiar para ponerla en tu portapapeles.",
"a73165c6c5": "Copiar"
}
}
}
+4 -1
View File
@@ -3635,7 +3635,10 @@
"failed": {
"toast": {
"62a0af2cb4": "La copie du presse-papiers du terminal n'a pas pu être confirmée",
"fdd3e7e977": "L'application de terminal a demandé une copie, mais Orca n'a pas pu confirmer qu'elle a bien atteint le presse-papiers système."
"fdd3e7e977": "L'application de terminal a demandé une copie, mais Orca n'a pas pu confirmer qu'elle a bien atteint le presse-papiers système.",
"de286dcfe1": "La copie du terminal nécessite un clic",
"84c938ffa2": "Le navigateur a bloqué une copie depuis l'application du terminal. Cliquez sur Copier pour placer le texte dans votre presse-papiers.",
"a73165c6c5": "Copier"
}
}
}
+4 -1
View File
@@ -3455,7 +3455,10 @@
"failed": {
"toast": {
"62a0af2cb4": "ターミナルのクリップボードコピーを確認できませんでした",
"fdd3e7e977": "ターミナルアプリがコピーを要求しましたが、システムクリップボードに反映されたことを Orca で確認できませんでした。"
"fdd3e7e977": "ターミナルアプリがコピーを要求しましたが、システムクリップボードに反映されたことを Orca で確認できませんでした。",
"de286dcfe1": "ターミナルのコピーにはクリックが必要です",
"84c938ffa2": "ブラウザがターミナルアプリからのコピーをブロックしました。「コピー」をクリックするとクリップボードに入ります。",
"a73165c6c5": "コピー"
}
}
}
+4 -1
View File
@@ -3455,7 +3455,10 @@
"failed": {
"toast": {
"62a0af2cb4": "터미널 클립보드 복사를 확인할 수 없습니다",
"fdd3e7e977": "터미널 앱이 복사를 요청했지만 Orca가 시스템 클립보드에 반영되었는지 확인하지 못했습니다."
"fdd3e7e977": "터미널 앱이 복사를 요청했지만 Orca가 시스템 클립보드에 반영되었는지 확인하지 못했습니다.",
"de286dcfe1": "터미널 복사에 클릭이 필요합니다",
"84c938ffa2": "브라우저가 터미널 앱의 복사를 차단했습니다. 복사를 클릭하면 클립보드에 넣습니다.",
"a73165c6c5": "복사"
}
}
}
+4 -1
View File
@@ -15658,7 +15658,10 @@
"failed": {
"toast": {
"62a0af2cb4": "Não foi possível confirmar a cópia para a área de transferência do terminal",
"fdd3e7e977": "O aplicativo de terminal solicitou uma cópia, mas o Orca não conseguiu confirmar que ela chegou à área de transferência do sistema."
"fdd3e7e977": "O aplicativo de terminal solicitou uma cópia, mas o Orca não conseguiu confirmar que ela chegou à área de transferência do sistema.",
"de286dcfe1": "A cópia do terminal precisa de um clique",
"84c938ffa2": "O navegador bloqueou uma cópia do aplicativo do terminal. Clique em Copiar para colocar o texto na sua área de transferência.",
"a73165c6c5": "Copiar"
}
}
}
+4 -1
View File
@@ -3455,7 +3455,10 @@
"failed": {
"toast": {
"62a0af2cb4": "无法确认终端剪贴板复制",
"fdd3e7e977": "终端应用请求了复制,但 Orca 无法确认内容已写入系统剪贴板。"
"fdd3e7e977": "终端应用请求了复制,但 Orca 无法确认内容已写入系统剪贴板。",
"de286dcfe1": "终端复制需要点击",
"84c938ffa2": "浏览器阻止了来自终端应用的复制。点击“复制”即可将文本放入剪贴板。",
"a73165c6c5": "复制"
}
}
}
+17 -1
View File
@@ -184,7 +184,7 @@ describe('createOsc52OscHandler', () => {
settingEnabled?: boolean | null
replaying?: boolean
writeClipboardText?: ReturnType<typeof vi.fn<(text: string) => Promise<void>>>
showWriteFailedToast?: ReturnType<typeof vi.fn<() => void>>
showWriteFailedToast?: ReturnType<typeof vi.fn<(text: string) => void>>
} = {}
) {
const settingEnabled = 'settingEnabled' in overrides ? overrides.settingEnabled : true
@@ -310,6 +310,22 @@ describe('createOsc52OscHandler', () => {
expect(showWriteFailedToast).toHaveBeenCalledTimes(1)
})
// #33937: a web client retries the refused text from a click, so the failure must carry it.
it('hands the refused text to the failed write toast', async () => {
const { handler, showWriteFailedToast } = setup({
writeClipboardText: vi
.fn<(text: string) => Promise<void>>()
.mockRejectedValue(new Error('NotAllowedError'))
})
expect(handler(`c;${b64('first')}`)).toBe(true)
expect(handler(`c;${b64('copy me')}`)).toBe(true)
await new Promise((resolve) => setTimeout(resolve, 0))
expect(showWriteFailedToast).toHaveBeenCalledTimes(1)
expect(showWriteFailedToast).toHaveBeenCalledWith('copy me')
})
it('surfaces a synchronous clipboard bridge throw as a failed write toast', async () => {
const showWriteFailedToast = vi.fn(() => {
throw new Error('toast unavailable')
+4 -3
View File
@@ -81,7 +81,8 @@ export function createOsc52OscHandler(deps: {
getReplaying: () => boolean
writeClipboardText: (text: string) => Promise<void>
showBlockedWriteToast?: () => void
showWriteFailedToast?: () => void
/** Receives the refused text so a web client can retry it from a fresh click. */
showWriteFailedToast?: (text: string) => void
}): (data: string) => boolean {
// Hostile chunks can issue millions of writes. Coalesce to the last per microtask, roughly one native write per xterm parse yield.
let pendingText: string | null = null
@@ -98,10 +99,10 @@ export function createOsc52OscHandler(deps: {
// Microtasks run outside the parser guard; catch synchronous missing/broken bridges as well as rejected writes and surface failures.
try {
void deps.writeClipboardText(next)?.catch(() => {
reportOsc52ClipboardWriteFailure(deps.showWriteFailedToast)
reportOsc52ClipboardWriteFailure(() => deps.showWriteFailedToast?.(next))
})
} catch {
reportOsc52ClipboardWriteFailure(deps.showWriteFailedToast)
reportOsc52ClipboardWriteFailure(() => deps.showWriteFailedToast?.(next))
}
}
})