Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions docs/plans/2026-09-27-copy-last-response-failure.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Copy commands say when the clipboard refused (#1250 row 9)

Short plan: a bug with a known root cause. The row comes from `temp/quality-loop/hunt-c3.md` (row 9, P3). #1250 is a batch issue, so this PR is `Refs #1250`.

## Outcome
**Copy Last Response** shows "Copied to clipboard" only when the copy happened. When `navigator.clipboard.writeText` rejects (the document is not focused, or permission is denied), it says so in fixed words. The sibling **Copy Resume Command** failure shows fixed words, not the raw DOMException text.

## Root cause (verified in source, origin/main)
- `paneCommands.ts` `copy-last-assistant.run`: `void navigator.clipboard.writeText(text)`, then an unconditional "Copied to clipboard" toast. A rejection is an unhandled promise, and the toast lies.
- `sessionCommands.ts` `copy-resume-command.run`: catches, but renders `copy failed: ${err.message}` (raw browser text, q22).

## Design (contract)
- `copy-last-assistant.run` awaits the write. On success: "Copied to clipboard". On rejection: "Couldn't copy to the clipboard. Click into the app and try again."
- Clipboard writes need a focused document; the palette or context menu can leave focus elsewhere.
- `copy-resume-command` uses the same failure sentence. The success toast is unchanged.
- The sentence is a constant: `CLIPBOARD_WRITE_FAILED` in `features/workspace/commands/clipboardFailure.ts`.

## Tests
New `paneCommands.copy.renderer.test.ts`. The runtime entries are the recorded bundle `testing/fixtures/rendering-bundles/2026-05-20T19-11-51-193-d4a44a16.json`; the clipboard is stubbed (the one edge).
- A rejecting write shows the failure sentence, never "Copied".
- A resolving write shows "Copied to clipboard".
- The resume command's rejection shows the fixed sentence, without the raw text.
- Red on main.

## Out of scope
- #1250's other rows.
- Debug-panel copy buttons (developer surfaces).

## Review round 1 (a, b: FIX-BEFORE-MERGE; c: MERGE-READY)
- **a1 (Major): a refused copy reports `ran` to `commands.run` callers.** Declined, with reasons:
- Throwing would make `runGuarded` add its own "Command failed: Copy Last Response" toast, so every human path would get two messages for one failure.
- The `commands.run` contract already says `ran` means only that the dispatcher returned, and tells the caller to observe the app afterwards.
- **a2 (Minor): nothing pinned that the command stays pending until the write settles.** Test added with a deferred write: the command is not settled and silent before the write, then says "Copied". It fails with a fire-and-forget `.then`.
- **b1 (Major): Browser Pocket's pick (no composer) swallowed a refused write and said "Element copied".** It now says the refusal. Test in `pick.renderer.test.ts`, red on the old pick.
- **b2 / c1 (Minor): the wording was compared against the constant only.** It is now asserted literally.
- **c2 (Minor): the assistant-message picker and the code-block picker said "Clipboard write failed".** Both now use the shared sentence. The constant moved to `lib/clipboardFailure.ts`, since four features share it.
11 changes: 11 additions & 0 deletions src/renderer/src/features/browser-pocket/ui/pick.renderer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,17 @@ describe('pickIntoComposer', () => {
expect(toast).toHaveBeenCalledWith(expect.stringMatching(/copied/i))
})

// #1421 review b: a refused write was swallowed and "Element copied" shown
// anyway, so the user pasted whatever the clipboard held before.
it('without a composer, says a refused copy instead of claiming it', async () => {
const writeText = vi.fn(async () => { throw new DOMException('Document is not focused.', 'NotAllowedError') })
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true })
const toast = vi.fn()
const { ws } = workspace('claude', '')
await pickIntoComposer('p1', 's1' as never, ws, vi.fn(), toast)
expect(toast.mock.calls).toEqual([["Couldn't copy to the clipboard. Click into the app and try again."]])
})

it('a cancelled pick changes nothing', async () => {
window.api = { ...(window.api ?? {}), pickInPocket: vi.fn(async () => null) } as unknown as typeof window.api
composer('s1', 'draft', 5)
Expand Down
11 changes: 10 additions & 1 deletion src/renderer/src/features/browser-pocket/ui/pick.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { getRendererProviderCapabilities } from '@providers/registry.renderer.capabilities'
import { CLIPBOARD_WRITE_FAILED } from '@renderer/lib/clipboardFailure'
import { formatElementChip, insertAtCaret } from '@shared/browserPocket/elementChip'
import { isAgentProviderKind } from '@shared/types/providerKind'
import type { Workspace } from '@renderer/workspace/workspaceStore'
Expand Down Expand Up @@ -31,7 +32,15 @@ export async function pickIntoComposer(pocketId: string, sessionId: SessionId, w
const chip = formatElementChip(result)
const composer = document.querySelector<HTMLTextAreaElement>(`[data-composer-input="${CSS.escape(sessionId)}"]`)
if (!composer) {
await navigator.clipboard.writeText(chip).catch(() => {})
// WHY the write is checked (#1421 review b): a refused write was
// swallowed and "Element copied" shown anyway, so the user pasted
// whatever the clipboard held before.
try {
await navigator.clipboard.writeText(chip)
} catch {
showToast(CLIPBOARD_WRITE_FAILED)
return
}
showToast('Element copied — paste it into the agent')
return
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { readFileSync } from 'node:fs'

import { afterEach, describe, expect, it, vi } from 'vitest'

import { emptyRuntime } from '@renderer/session-runtime/state'
import type { CommandContext } from '@renderer/features/command-palette/types'
import type { Workspace } from '@renderer/workspace/hook'
import { CLIPBOARD_WRITE_FAILED } from '@renderer/lib/clipboardFailure'
import { paneCommands } from '@renderer/features/workspace/commands/paneCommands'
import { sessionCommands } from '@renderer/features/workspace/commands/sessionCommands'
import { oneLaneStage } from '@renderer/workspace/testing/stageFixtures'

// #1250 row 9: Copy Last Response wrote to the clipboard fire-and-forget and
// said "Copied to clipboard" whatever happened, so a refused write (Electron
// refuses when the document is not focused) was silent under a toast that said
// the opposite. The resume-command copy did catch, but showed the raw
// DOMException text (q22).
//
// The runtime entries are a RECORDED session (a Codex rendering bundle). The
// clipboard is the one replaced edge: its rejection is the case under test.

const bundle = JSON.parse(readFileSync('testing/fixtures/rendering-bundles/2026-05-20T19-11-51-193-d4a44a16.json', 'utf8')) as {
input: { provider: string; entries: unknown[] }
}

const originalClipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard')
afterEach(() => {
if (originalClipboard) Object.defineProperty(navigator, 'clipboard', originalClipboard)
else Reflect.deleteProperty(navigator, 'clipboard')
})

function stubClipboard(writeText: (text: string) => Promise<void>) {
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: vi.fn(writeText) } })
}

function context(kind: string) {
const toasts: string[] = []
const ctx = {
workspace: {
state: {
activeTabId: 'tab',
stage: oneLaneStage('agent'),
pinnedSessionIds: [],
sessions: { agent: { cwd: '/projects/app', kind, providerSessionId: 'provider-abc', projectId: 'tab', joinedAt: 0 } },
tabs: [{ id: 'tab' }],
},
getRuntime: () => ({ ...emptyRuntime(), entries: bundle.input.entries }),
showPaneToast: (_sessionId: string, message: string) => { toasts.push(message) },
} as unknown as Workspace,
ui: { closePalette: vi.fn() },
flags: {},
} as unknown as CommandContext
return { ctx, toasts }
}

const copyLast = paneCommands.find(command => command.id === 'copy-last-assistant')!
const copyResume = sessionCommands.find(command => command.id === 'copy-resume-command')!

describe('copy commands and a refused clipboard', () => {
const refused = () => Promise.reject(new DOMException('Document is not focused.', 'NotAllowedError'))

it('says a refused Copy Last Response did not copy, and never claims it did', async () => {
stubClipboard(refused)
const { ctx, toasts } = context(bundle.input.provider)
await copyLast.run(ctx)
// The literal words (#1421 review b, c): comparing against the constant
// alone would let the advice be reworded away unnoticed.
expect(toasts).toEqual(["Couldn't copy to the clipboard. Click into the app and try again."])
expect(CLIPBOARD_WRITE_FAILED).toBe(toasts[0])
})

// #1421 review a: the command's promise is the dispatcher's single-flight
// and outcome; it must not resolve (or say anything) before the write does.
it('stays pending, and silent, until the clipboard write settles', async () => {
let finish!: () => void
stubClipboard(() => new Promise<void>(resolve => { finish = resolve }))
const { ctx, toasts } = context(bundle.input.provider)
let settled = false
const running = Promise.resolve(copyLast.run(ctx)).then(() => { settled = true })
await Promise.resolve(); await Promise.resolve()
expect(settled).toBe(false)
expect(toasts).toEqual([])
finish()
await running
expect(toasts).toEqual(['Copied to clipboard'])
})

it('says Copied only after the clipboard took the recorded response', async () => {
let written = ''
stubClipboard(async text => { written = text })
const { ctx, toasts } = context(bundle.input.provider)
await copyLast.run(ctx)
expect(written.length).toBeGreaterThan(0)
expect(toasts).toEqual(['Copied to clipboard'])
})

it('says a refused resume-command copy in fixed words, not the browser text', async () => {
stubClipboard(refused)
const { ctx, toasts } = context('claude')
expect(copyResume.when?.(ctx)).toBe(true)
await copyResume.run(ctx)
expect(toasts).toEqual([CLIPBOARD_WRITE_FAILED])
expect(toasts.join(' ')).not.toContain('not focused')
})
})
14 changes: 12 additions & 2 deletions src/renderer/src/features/workspace/commands/paneCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { commandTargetSessionId } from '@renderer/workspace/hook/selectors/comma
import { submitActiveComposer } from '@renderer/workspace/tile-tree/TileLeaf/composerEnterRegistry'
import { sessionHasTranscript } from '@renderer/workspace/transcriptAvailability'
import { isWorkingAgent } from '@renderer/workspace/agentFollow'
import { CLIPBOARD_WRITE_FAILED } from '@renderer/lib/clipboardFailure'

// DELETED with the unified layout (#992) — see RETIRED_COMMAND_IDS in
// catalog.test.ts for the ledger:
Expand Down Expand Up @@ -454,14 +455,23 @@ export const paneCommands: CommandDef[] = [
// its committed entries, so there is a real last response to copy.
return sessionHasTranscript(workspace.state.sessions[sessionId])
},
run: ({ workspace, target }) => {
run: async ({ workspace, target }) => {
const sessionId = commandTarget({ workspace, target })
if (!sessionId) return
const runtime = workspace.getRuntime(sessionId)
const kind = workspace.state.sessions[sessionId]?.kind ?? DEFAULT_PROVIDER
const text = extractLastAssistantText(runtime.entries, kind)
if (text) {
void navigator.clipboard.writeText(text)
// WHY awaited (#1250 row 9): the write used to be fire-and-forget
// with "Copied" shown unconditionally, so a refused write (an
// unfocused document) was an unhandled rejection under a toast that
// said the opposite.
try {
await navigator.clipboard.writeText(text)
} catch {
workspace.showPaneToast(sessionId, CLIPBOARD_WRITE_FAILED)
return
}
workspace.showPaneToast(sessionId, 'Copied to clipboard')
return
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { commandTarget } from '@renderer/features/command-palette/commandTarget'
import { CLIPBOARD_WRITE_FAILED } from '@renderer/lib/clipboardFailure'
import { clonedMcpOverrides } from '@renderer/workspace/mcpDomains'
import { DEFAULT_PROVIDER, effectiveProviderRuntime, isAgentProviderKind } from '@shared/types/providerKind'
import { getRendererProviderCapabilities } from '@providers/registry.renderer.capabilities'
Expand Down Expand Up @@ -775,9 +776,10 @@ export const sessionCommands: CommandDef[] = [
try {
await navigator.clipboard.writeText(command)
workspace.showPaneToast(sessionId, `copied resume command · ${command}`, 5000)
} catch (err) {
const msg = (err as Error)?.message ?? String(err)
workspace.showPaneToast(sessionId, `copy failed: ${msg}`, 4000)
} catch {
// Fixed words (q22, #1250 row 9): the rejection's message is browser
// text, not something written for the user.
workspace.showPaneToast(sessionId, CLIPBOARD_WRITE_FAILED, 4000)
}
},
contextMenu: { group: 'copy', order: 10 },
Expand Down
9 changes: 9 additions & 0 deletions src/renderer/src/lib/clipboardFailure.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// What a copy action says when the clipboard refused the write (#1250 row 9):
// the copy commands, the assistant-message picker and Browser Pocket's pick,
// so one failure mode has one sentence (#1421 review c).
//
// WHY fixed words: the rejection is a DOMException whose message is browser
// text ("Document is not focused."), and user-visible text is curated (q22).
// WHY this advice: Electron's clipboard write needs a focused document, and
// the usual cause is focus left elsewhere by the palette or a context menu.
export const CLIPBOARD_WRITE_FAILED = "Couldn't copy to the clipboard. Click into the app and try again."
4 changes: 3 additions & 1 deletion src/renderer/src/workspace/hook/actions/picker.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useCallback } from 'react'

import { CLIPBOARD_WRITE_FAILED } from '@renderer/lib/clipboardFailure'
import { emptyRuntime } from '@renderer/session-runtime/state'
import type { SessionId } from '@renderer/workspace/types'
import {
Expand Down Expand Up @@ -150,7 +151,8 @@ export function usePickerActions(
await navigator.clipboard.writeText(text)
showPaneToast(sessionId, 'Copied assistant message')
} catch {
showPaneToast(sessionId, 'Clipboard write failed')
// The shared sentence (#1421 review c), with its advice.
showPaneToast(sessionId, CLIPBOARD_WRITE_FAILED)
}
},
[refs.latestRuntimesRef, setRuntimes, showPaneToast],
Expand Down
4 changes: 3 additions & 1 deletion src/renderer/src/workspace/tile-tree/useKeybinds.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createTldrHoldController, dismissTldr, observeTldrHoldRelease, useTldrView } from '@renderer/features/tldr/viewState'
import { CLIPBOARD_WRITE_FAILED } from '@renderer/lib/clipboardFailure'
import { useGlobalToast } from '@renderer/ui/GlobalToastContext'
import { dismissGoalLoop, useGoalLoopView } from '@renderer/features/goal-loop/viewState'
import { useEffect, useMemo, useRef } from 'react'
Expand Down Expand Up @@ -827,7 +828,8 @@ export function useKeybinds(
} else {
void navigator.clipboard.writeText(code).then(
() => workspace.showPaneToast(focusedSessionId, 'Copied code block'),
() => workspace.showPaneToast(focusedSessionId, 'Clipboard write failed'),
// The shared sentence (#1421 review c).
() => workspace.showPaneToast(focusedSessionId, CLIPBOARD_WRITE_FAILED),
)
}
return
Expand Down
Loading