diff --git a/desktop/src-tauri/capabilities/default.json b/desktop/src-tauri/capabilities/default.json index 60f575e3..02123d46 100644 --- a/desktop/src-tauri/capabilities/default.json +++ b/desktop/src-tauri/capabilities/default.json @@ -7,6 +7,7 @@ "core:default", "core:window:allow-close", "core:window:allow-minimize", + "core:window:allow-request-user-attention", "core:window:allow-start-dragging", "core:window:allow-toggle-maximize", "shell:allow-open", diff --git a/desktop/src/lib/desktopNotifications.test.ts b/desktop/src/lib/desktopNotifications.test.ts index bf6ddaec..c64b6336 100644 --- a/desktop/src/lib/desktopNotifications.test.ts +++ b/desktop/src/lib/desktopNotifications.test.ts @@ -5,8 +5,20 @@ const notificationPluginMock = vi.hoisted(() => ({ requestPermission: vi.fn(), sendNotification: vi.fn(), })) +const requestUserAttentionMock = vi.hoisted(() => vi.fn()) +const windowApiMock = vi.hoisted(() => ({ + requestUserAttention: requestUserAttentionMock, + getCurrentWindow: vi.fn(() => ({ + requestUserAttention: requestUserAttentionMock, + })), + UserAttentionType: { + Critical: 1, + Informational: 2, + }, +})) vi.mock('@tauri-apps/plugin-notification', () => notificationPluginMock) +vi.mock('@tauri-apps/api/window', () => windowApiMock) import { getDesktopNotificationPermission, @@ -24,6 +36,8 @@ describe('desktopNotifications', () => { notificationPluginMock.isPermissionGranted.mockReset() notificationPluginMock.requestPermission.mockReset() notificationPluginMock.sendNotification.mockReset() + windowApiMock.getCurrentWindow.mockClear() + windowApiMock.requestUserAttention.mockReset() useSettingsStore.setState({ desktopNotificationsEnabled: true }) }) @@ -110,6 +124,21 @@ describe('desktopNotifications', () => { }) }) + it('requests OS-level window attention for blocking prompts', async () => { + const sender = vi.fn(async () => true) + setNativeNotificationSenderForTests(sender) + + notifyDesktop({ + requestAttention: true, + title: 'Permission required', + body: 'Approve command execution', + }) + + await vi.waitFor(() => expect(sender).toHaveBeenCalledTimes(1)) + await vi.waitFor(() => expect(windowApiMock.requestUserAttention).toHaveBeenCalledTimes(1)) + expect(windowApiMock.requestUserAttention).toHaveBeenCalledWith(windowApiMock.UserAttentionType.Critical) + }) + it('throttles bursts within the same cooldown scope', async () => { vi.useFakeTimers() const sender = vi.fn(async () => true) diff --git a/desktop/src/lib/desktopNotifications.ts b/desktop/src/lib/desktopNotifications.ts index 6e5b06a7..8a2fa5a6 100644 --- a/desktop/src/lib/desktopNotifications.ts +++ b/desktop/src/lib/desktopNotifications.ts @@ -8,6 +8,7 @@ export type DesktopNotificationOptions = { dedupeKey?: string cooldownScope?: string cooldownMs?: number + requestAttention?: boolean } type NativeNotificationSender = (options: { title: string; body?: string }) => Promise | boolean @@ -108,6 +109,16 @@ async function sendNativeNotification(options: { title: string; body?: string }) return true } +async function requestWindowAttention(): Promise { + try { + const { getCurrentWindow, UserAttentionType } = await import('@tauri-apps/api/window') + await getCurrentWindow().requestUserAttention(UserAttentionType.Critical) + return true + } catch { + return false + } +} + export function notifyDesktop(options: DesktopNotificationOptions): void { if (!useSettingsStore.getState().desktopNotificationsEnabled) { return @@ -131,6 +142,10 @@ export function notifyDesktop(options: DesktopNotificationOptions): void { notifiedKeys.add(options.dedupeKey) } + if (options.requestAttention) { + void requestWindowAttention() + } + const sender = overrideNativeNotificationSender ?? sendNativeNotification void Promise.resolve(sender({ title: options.title, body: options.body })).then((sent) => { if (!sent && typeof console !== 'undefined') { diff --git a/desktop/src/stores/chatStore.test.ts b/desktop/src/stores/chatStore.test.ts index 7eeb9094..31853243 100644 --- a/desktop/src/stores/chatStore.test.ts +++ b/desktop/src/stores/chatStore.test.ts @@ -696,6 +696,7 @@ describe('chatStore history mapping', () => { expect(notifyDesktopMock).toHaveBeenCalledWith({ dedupeKey: 'permission:perm-ask-1', cooldownScope: 'permission-prompt', + requestAttention: true, title: 'Claude Code Haha 需要你的确认', body: 'AskUserQuestion 请求执行,正在等待允许。', }) @@ -1009,6 +1010,7 @@ describe('chatStore history mapping', () => { expect(notifyDesktopMock).toHaveBeenCalledWith({ dedupeKey: 'computer-use-permission:cu-1', cooldownScope: 'permission-prompt', + requestAttention: true, title: 'Claude Code Haha 需要你的确认', body: 'Open Finder and inspect a file', }) diff --git a/desktop/src/stores/chatStore.ts b/desktop/src/stores/chatStore.ts index 00550aae..5aacb2a0 100644 --- a/desktop/src/stores/chatStore.ts +++ b/desktop/src/stores/chatStore.ts @@ -674,6 +674,7 @@ export const useChatStore = create((set, get) => ({ notifyDesktop({ dedupeKey: `permission:${msg.requestId}`, cooldownScope: 'permission-prompt', + requestAttention: true, title: 'Claude Code Haha 需要你的确认', body: msg.toolName ? `${msg.toolName} 请求执行,正在等待允许。` @@ -710,6 +711,7 @@ export const useChatStore = create((set, get) => ({ notifyDesktop({ dedupeKey: `computer-use-permission:${msg.requestId}`, cooldownScope: 'permission-prompt', + requestAttention: true, title: 'Claude Code Haha 需要你的确认', body: msg.request.reason || 'Computer Use 正在等待允许。', })