mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3455,7 +3455,10 @@
|
||||
"failed": {
|
||||
"toast": {
|
||||
"62a0af2cb4": "ターミナルのクリップボードコピーを確認できませんでした",
|
||||
"fdd3e7e977": "ターミナルアプリがコピーを要求しましたが、システムクリップボードに反映されたことを Orca で確認できませんでした。"
|
||||
"fdd3e7e977": "ターミナルアプリがコピーを要求しましたが、システムクリップボードに反映されたことを Orca で確認できませんでした。",
|
||||
"de286dcfe1": "ターミナルのコピーにはクリックが必要です",
|
||||
"84c938ffa2": "ブラウザがターミナルアプリからのコピーをブロックしました。「コピー」をクリックするとクリップボードに入ります。",
|
||||
"a73165c6c5": "コピー"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3455,7 +3455,10 @@
|
||||
"failed": {
|
||||
"toast": {
|
||||
"62a0af2cb4": "터미널 클립보드 복사를 확인할 수 없습니다",
|
||||
"fdd3e7e977": "터미널 앱이 복사를 요청했지만 Orca가 시스템 클립보드에 반영되었는지 확인하지 못했습니다."
|
||||
"fdd3e7e977": "터미널 앱이 복사를 요청했지만 Orca가 시스템 클립보드에 반영되었는지 확인하지 못했습니다.",
|
||||
"de286dcfe1": "터미널 복사에 클릭이 필요합니다",
|
||||
"84c938ffa2": "브라우저가 터미널 앱의 복사를 차단했습니다. 복사를 클릭하면 클립보드에 넣습니다.",
|
||||
"a73165c6c5": "복사"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3455,7 +3455,10 @@
|
||||
"failed": {
|
||||
"toast": {
|
||||
"62a0af2cb4": "无法确认终端剪贴板复制",
|
||||
"fdd3e7e977": "终端应用请求了复制,但 Orca 无法确认内容已写入系统剪贴板。"
|
||||
"fdd3e7e977": "终端应用请求了复制,但 Orca 无法确认内容已写入系统剪贴板。",
|
||||
"de286dcfe1": "终端复制需要点击",
|
||||
"84c938ffa2": "浏览器阻止了来自终端应用的复制。点击“复制”即可将文本放入剪贴板。",
|
||||
"a73165c6c5": "复制"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user