diff --git a/packages/desktop/apps/electron/src/main/__tests__/default-session-permissions.test.ts b/packages/desktop/apps/electron/src/main/__tests__/default-session-permissions.test.ts
new file mode 100644
index 00000000000..17a8062d0df
--- /dev/null
+++ b/packages/desktop/apps/electron/src/main/__tests__/default-session-permissions.test.ts
@@ -0,0 +1,49 @@
+import { describe, expect, it } from 'bun:test';
+import { canUseDefaultSessionClipboard } from '../default-session-permissions';
+
+const trustedRequest = {
+ permission: 'clipboard-sanitized-write',
+ isMainFrame: true,
+ isWorkspaceWindow: true,
+ requestingUrl: 'file:///app/index.html',
+ devServerUrl: undefined,
+};
+
+describe('default session permissions', () => {
+ it('allows clipboard writes from the packaged app renderer', () => {
+ expect(canUseDefaultSessionClipboard(trustedRequest)).toBe(true);
+ });
+
+ it('allows clipboard writes from the configured Vite dev origin', () => {
+ expect(
+ canUseDefaultSessionClipboard({
+ ...trustedRequest,
+ requestingUrl: 'http://localhost:5173/chat',
+ devServerUrl: 'http://localhost:5173',
+ }),
+ ).toBe(true);
+ });
+
+ it.each([
+ ['clipboard reads', { permission: 'clipboard-read' }],
+ ['unrelated permissions', { permission: 'geolocation' }],
+ ['subframes', { isMainFrame: false }],
+ ['unregistered windows', { isWorkspaceWindow: false }],
+ ['external pages', { requestingUrl: 'https://example.com' }],
+ [
+ 'a different dev port',
+ {
+ requestingUrl: 'http://localhost:5174/chat',
+ devServerUrl: 'http://localhost:5173',
+ },
+ ],
+ ['requests without a URL', { requestingUrl: undefined }],
+ ])('rejects %s', (_label, overrides) => {
+ expect(
+ canUseDefaultSessionClipboard({
+ ...trustedRequest,
+ ...overrides,
+ }),
+ ).toBe(false);
+ });
+});
diff --git a/packages/desktop/apps/electron/src/main/default-session-permissions.ts b/packages/desktop/apps/electron/src/main/default-session-permissions.ts
new file mode 100644
index 00000000000..5fd0777b3a8
--- /dev/null
+++ b/packages/desktop/apps/electron/src/main/default-session-permissions.ts
@@ -0,0 +1,24 @@
+import { isTrustedRendererFrameUrl } from './voice/frame-trust';
+
+export interface DefaultSessionClipboardRequest {
+ permission: string;
+ isMainFrame: boolean;
+ isWorkspaceWindow: boolean;
+ requestingUrl: string | undefined;
+ devServerUrl: string | undefined;
+}
+
+export function canUseDefaultSessionClipboard({
+ permission,
+ isMainFrame,
+ isWorkspaceWindow,
+ requestingUrl,
+ devServerUrl,
+}: DefaultSessionClipboardRequest): boolean {
+ return (
+ permission === 'clipboard-sanitized-write' &&
+ isMainFrame &&
+ isWorkspaceWindow &&
+ isTrustedRendererFrameUrl(requestingUrl, devServerUrl)
+ );
+}
diff --git a/packages/desktop/apps/electron/src/main/index.ts b/packages/desktop/apps/electron/src/main/index.ts
index c26d913a8d6..2db73c81176 100644
--- a/packages/desktop/apps/electron/src/main/index.ts
+++ b/packages/desktop/apps/electron/src/main/index.ts
@@ -116,6 +116,7 @@ import { initNotificationService, initBadgeIcon, initInstanceBadge, updateBadgeC
import { checkForUpdatesOnLaunch, setAutoUpdateEventSink, isUpdating } from './auto-update'
import type { EventSink } from '@craft-agent/server-core/transport'
import { validateGitBashPath, checkVCRedistInstalled } from '@craft-agent/server-core/services'
+import { canUseDefaultSessionClipboard } from './default-session-permissions'
// Initialize electron-log for renderer process support
log.initialize()
@@ -457,8 +458,25 @@ app.whenReady().then(async () => {
isAudioOnlyMediaRequest(permission, details) &&
windowManager?.getWorkspaceForWindow(wc.id) != null,
)
+ const canWriteClipboard = (
+ wc: { id: number } | null | undefined,
+ permission: string,
+ details: { isMainFrame?: boolean; requestingUrl?: string } | undefined,
+ ) => canUseDefaultSessionClipboard({
+ permission,
+ isMainFrame: details?.isMainFrame === true,
+ isWorkspaceWindow: Boolean(
+ wc && windowManager?.getWorkspaceForWindow(wc.id) != null,
+ ),
+ requestingUrl: details?.requestingUrl,
+ devServerUrl: process.env.VITE_DEV_SERVER_URL,
+ })
session.defaultSession.setPermissionRequestHandler(
(wc, permission, callback, details) => {
+ if (canWriteClipboard(wc, permission, details)) {
+ callback(true)
+ return
+ }
if (!VOICE_PERMISSIONS.has(permission)) {
mainLog.debug(`defaultSession: denied non-voice permission '${permission}'`)
callback(false)
@@ -472,6 +490,9 @@ app.whenReady().then(async () => {
},
)
session.defaultSession.setPermissionCheckHandler((wc, permission, _origin, details) => {
+ if (canWriteClipboard(wc, permission, details)) {
+ return true
+ }
if (!VOICE_PERMISSIONS.has(permission)) {
mainLog.debug(`defaultSession: denied non-voice permission check '${permission}'`)
return false
diff --git a/packages/desktop/packages/ui/src/components/chat/TurnCard.tsx b/packages/desktop/packages/ui/src/components/chat/TurnCard.tsx
index cf234d0e9e3..af95264698b 100644
--- a/packages/desktop/packages/ui/src/components/chat/TurnCard.tsx
+++ b/packages/desktop/packages/ui/src/components/chat/TurnCard.tsx
@@ -39,6 +39,7 @@ import { Tooltip, TooltipTrigger, TooltipContent } from '../tooltip'
import { parseDiffFromFile, type FileContents } from '@pierre/diffs'
import { getDiffStats, getUnifiedDiffStats } from '../code-viewer'
import { TurnCardActionsMenu } from './TurnCardActionsMenu'
+import { copyResponseText } from './copy-response'
import { computeLastChildSet, groupActivitiesByParent, isActivityGroup, formatDuration, formatTokens, deriveTurnPhase, shouldShowThinkingIndicator, type ActivityGroup, type AssistantTurn } from './turn-utils'
import {
buildTurnTimelineItems,
@@ -1815,7 +1816,7 @@ export function ResponseCard({
const [displayedText, setDisplayedText] = useState(text)
const lastUpdateRef = useRef(Date.now())
// Copy to clipboard state
- const [copied, setCopied] = useState(false)
+ const [copyStatus, setCopyStatus] = useState<'idle' | 'copied' | 'failed'>('idle')
// Fullscreen state
const [isFullscreen, setIsFullscreen] = useState(false)
// Dark mode detection - scroll fade only shown in dark mode
@@ -1910,12 +1911,14 @@ export function ResponseCard({
})
const handleCopy = useCallback(async () => {
- try {
- await navigator.clipboard.writeText(text)
- setCopied(true)
- setTimeout(() => setCopied(false), 2000)
- } catch (err) {
- console.error('Failed to copy:', err)
+ const result = await copyResponseText(
+ text,
+ (value) => navigator.clipboard.writeText(value),
+ )
+ setCopyStatus(result.status)
+ setTimeout(() => setCopyStatus('idle'), 2000)
+ if (result.status === 'failed') {
+ console.error('Failed to copy response:', result.error)
}
}, [text])
@@ -2650,15 +2653,20 @@ export function ResponseCard({
onClick={handleCopy}
className={cn(
"turn-action-btn flex items-center gap-1.5 transition-colors select-none",
- copied ? "text-success" : "text-muted-foreground hover:text-foreground",
+ copyStatus === 'copied' ? "text-success" : copyStatus === 'failed' ? "text-destructive" : "text-muted-foreground hover:text-foreground",
"focus:outline-none focus-visible:underline",
)}
>
- {copied ? (
+ {copyStatus === 'copied' ? (
<>