diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3daca20..befd5f4 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -12,6 +12,7 @@ "@tauri-apps/api": "^2.10.1", "@tauri-apps/plugin-clipboard-manager": "^2.3.2", "@tauri-apps/plugin-dialog": "^2.6.0", + "@tauri-apps/plugin-global-shortcut": "^2.3.1", "@tauri-apps/plugin-http": "^2.5.7", "@tauri-apps/plugin-notification": "^2.3.3", "@tauri-apps/plugin-store": "^2.4.2", @@ -2667,6 +2668,15 @@ "@tauri-apps/api": "^2.8.0" } }, + "node_modules/@tauri-apps/plugin-global-shortcut": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-global-shortcut/-/plugin-global-shortcut-2.3.1.tgz", + "integrity": "sha512-vr40W2N6G63dmBPaha1TsBQLLURXG538RQbH5vAm0G/ovVZyXJrmZR1HF1W+WneNloQvwn4dm8xzwpEXRW560g==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.8.0" + } + }, "node_modules/@tauri-apps/plugin-http": { "version": "2.5.7", "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-http/-/plugin-http-2.5.7.tgz", diff --git a/frontend/package.json b/frontend/package.json index d4f548f..bf670d0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -24,6 +24,7 @@ "@tauri-apps/api": "^2.10.1", "@tauri-apps/plugin-clipboard-manager": "^2.3.2", "@tauri-apps/plugin-dialog": "^2.6.0", + "@tauri-apps/plugin-global-shortcut": "^2.3.1", "@tauri-apps/plugin-http": "^2.5.7", "@tauri-apps/plugin-notification": "^2.3.3", "@tauri-apps/plugin-store": "^2.4.2", diff --git a/frontend/public/loading.html b/frontend/public/loading.html new file mode 100644 index 0000000..6121c78 --- /dev/null +++ b/frontend/public/loading.html @@ -0,0 +1,42 @@ + + + + + + + +
+
+
+
+ + + diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 6163e9d..be32d58 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -22,7 +22,9 @@ import { useCanvasStore } from './stores/canvas' import { useProjectCanvasStore } from './stores/projectCanvas' import { useSessionState } from './stores/session-state' import { isTauri, isMobileTauri, getTauriNotification } from './lib/tauri' +import { initApprovalNotifications } from './services/approvalNotifications' import { useServerConfig } from './stores/server-config' +import { useApprovalWindow } from './composables/useApprovalWindow' const route = useRoute() const router = useRouter() @@ -32,7 +34,7 @@ const serverConfig = isTauri ? useServerConfig() : null const showServerConfig = ref(false) const needsServerConfig = computed(() => isTauri && serverConfig && !serverConfig.isConfigured) -const isPipWindow = computed(() => route.query.pip === '1') +const isPipWindow = computed(() => route.query.pip === '1' || route.query.window === '1') const showVoice = ref(false) const showTranscriptDebug = ref(false) const showDebugConsole = ref(false) @@ -85,6 +87,15 @@ const canvasStore = useCanvasStore() const projectCanvasStore = useProjectCanvasStore() const sessionState = useSessionState() const { totalPending, modalVisible, connect: connectApproval, disconnect: disconnectApproval, fetchPending: fetchApprovalPending } = useGlobalApproval() +const { approvalWindowOpen, openApprovalWindow } = useApprovalWindow() + +function handleApprovalBadgeClick() { + if (isTauri) { + openApprovalWindow() + } else { + modalVisible.value = !modalVisible.value + } +} // Voice FAB push-to-talk state const voicePTTActive = ref(false) let voiceTouchStarted = false @@ -173,6 +184,16 @@ function handleGlobalKeydown(e: KeyboardEvent) { showTranscriptDebug.value = !showTranscriptDebug.value } } + + // Ctrl+W → close current window (PiP closes, main hides to tray) + if (e.ctrlKey && e.key === 'w') { + e.preventDefault() + if (isTauri) { + import('@tauri-apps/api/webviewWindow').then(({ getCurrentWebviewWindow }) => { + getCurrentWebviewWindow().close() + }).catch(() => {}) + } + } } // Voice FAB push-to-talk handlers @@ -249,6 +270,11 @@ onMounted(async () => { connectApproval() fetchApprovalPending() + // Initialize native approval notifications (Tauri only) + if (isTauri) { + initApprovalNotifications() + } + // Connect centralized session state WS initSessionStateWS() @@ -309,21 +335,145 @@ onMounted(async () => { async function sendTestNotification() { const title = 'Agent UI' - const body = 'Test notification from Agent UI — all platforms!' + const body = 'Test notification — ' + new Date().toLocaleTimeString() if (isTauri) { + const isMobile = isMobileTauri() try { - const { isPermissionGranted, requestPermission, sendNotification } = await getTauriNotification() - let granted = await isPermissionGranted() + const { invoke } = await import('@tauri-apps/api/core') + + // ── Step 1: Check permission ── + console.log('[TestNotif] === START === isMobile:', isMobile) + console.log('[TestNotif] Step 1: checking permission...') + let granted = false + + if (isMobile) { + // Android: use Kotlin @Command methods directly (bypass window.Notification polyfill) + // check_permissions → Kotlin checkPermissions (auto snake→camel routing) + try { + const check = await invoke<{ permissionState: string }>('plugin:notification|check_permissions') + console.log('[TestNotif] check_permissions result:', JSON.stringify(check)) + granted = check?.permissionState === 'granted' + } catch (e) { + console.warn('[TestNotif] check_permissions failed, trying is_permission_granted:', e) + try { + const result = await invoke('plugin:notification|is_permission_granted') + console.log('[TestNotif] is_permission_granted:', result) + granted = result === true + } catch (e2) { + console.error('[TestNotif] is_permission_granted also failed:', e2) + } + } + + // ── Step 2: Request if needed ── + if (!granted) { + console.log('[TestNotif] Step 2: requesting permission...') + try { + const req = await invoke<{ permissionState: string }>('plugin:notification|request_permissions') + console.log('[TestNotif] request_permissions result:', JSON.stringify(req)) + granted = req?.permissionState === 'granted' + } catch (e) { + console.warn('[TestNotif] request_permissions failed, trying request_permission:', e) + try { + const perm = await invoke('plugin:notification|request_permission') + console.log('[TestNotif] request_permission (Rust):', perm) + granted = perm === 'granted' + } catch (e2) { + console.error('[TestNotif] request_permission also failed:', e2) + } + } + } + } else { + // Desktop: JS wrapper works fine (window.Notification exists on desktop WebView) + try { + const { isPermissionGranted, requestPermission } = await getTauriNotification() + granted = await isPermissionGranted() + console.log('[TestNotif] Desktop isPermissionGranted:', granted) + if (!granted) { + const perm = await requestPermission() + granted = perm === 'granted' + console.log('[TestNotif] Desktop requestPermission:', perm) + } + } catch (e) { + console.warn('[TestNotif] Desktop permission check failed:', e) + granted = true // Desktop usually grants by default + } + } + + console.log('[TestNotif] Permission granted:', granted) if (!granted) { - const perm = await requestPermission() - granted = perm === 'granted' + console.error('[TestNotif] STOPPED: Permission denied') + return } - if (granted) { - sendNotification({ title, body }) + + // ── Step 3: Ensure channel exists (Android only) ── + if (isMobile) { + console.log('[TestNotif] Step 3: ensuring channel exists...') + try { + await invoke('plugin:notification|create_channel', { + id: 'test', + name: 'Test', + description: 'Test notifications', + importance: 4, + visibility: 1, + vibration: true, + lights: true, + }) + console.log('[TestNotif] Channel "test" created/updated') + } catch (e) { + console.warn('[TestNotif] create_channel failed (will use default):', e) + } } + + // ── Step 4: Send notification via invoke ── + console.log('[TestNotif] Step 4: sending via invoke...') + const notifId = Math.floor(Math.random() * 100000) + try { + const result = await invoke('plugin:notification|notify', { + options: { + id: notifId, + title, + body, + channelId: isMobile ? 'test' : undefined, + autoCancel: true, + } + }) + console.log('[TestNotif] notify result:', JSON.stringify(result), 'id:', notifId) + } catch (e) { + console.error('[TestNotif] notify FAILED:', e) + // Fallback: try without channel (uses "default") + if (isMobile) { + console.log('[TestNotif] Retrying without channelId...') + try { + const result2 = await invoke('plugin:notification|notify', { + options: { id: notifId + 1, title, body: body + ' (default channel)', autoCancel: true } + }) + console.log('[TestNotif] notify (default channel) result:', JSON.stringify(result2)) + } catch (e2) { + console.error('[TestNotif] notify (default channel) also FAILED:', e2) + } + } + } + + // ── Step 5: Diagnostic — list active notifications ── + if (isMobile) { + try { + const active = await invoke('plugin:notification|get_active') + console.log('[TestNotif] Active notifications:', JSON.stringify(active)) + } catch (e) { + console.warn('[TestNotif] get_active failed:', e) + } + try { + const channels = await invoke('plugin:notification|listChannels') + console.log('[TestNotif] Channels:', JSON.stringify(channels)) + } catch (e) { + console.warn('[TestNotif] listChannels failed:', e) + } + } + + console.log('[TestNotif] === DONE ===') } catch (e) { - console.warn('[Notification] Tauri plugin failed:', e) + console.error('[TestNotif] Fatal error:', e) } } else if ('Notification' in window) { if (Notification.permission === 'granted') { @@ -404,10 +554,10 @@ if (serverConfig) {