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) {