feat: compact boundary divider, overlay fix, approval window, PiP, Tauri enhancements
- Add CompactBoundaryDivider component for compact_boundary system messages - Fix readability overlay: v-if removes element entirely at 0% opacity - Add approval page and window composable - Add PiP window support and loading screen - Tauri: add window management commands and capabilities - Disable Ctrl+1..5 shortcuts in Tauri (handled by global shortcuts)
This commit is contained in:
10
frontend/package-lock.json
generated
10
frontend/package-lock.json
generated
@@ -12,6 +12,7 @@
|
|||||||
"@tauri-apps/api": "^2.10.1",
|
"@tauri-apps/api": "^2.10.1",
|
||||||
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
|
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
|
||||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
"@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-http": "^2.5.7",
|
||||||
"@tauri-apps/plugin-notification": "^2.3.3",
|
"@tauri-apps/plugin-notification": "^2.3.3",
|
||||||
"@tauri-apps/plugin-store": "^2.4.2",
|
"@tauri-apps/plugin-store": "^2.4.2",
|
||||||
@@ -2667,6 +2668,15 @@
|
|||||||
"@tauri-apps/api": "^2.8.0"
|
"@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": {
|
"node_modules/@tauri-apps/plugin-http": {
|
||||||
"version": "2.5.7",
|
"version": "2.5.7",
|
||||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-http/-/plugin-http-2.5.7.tgz",
|
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-http/-/plugin-http-2.5.7.tgz",
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
"@tauri-apps/api": "^2.10.1",
|
"@tauri-apps/api": "^2.10.1",
|
||||||
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
|
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
|
||||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
"@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-http": "^2.5.7",
|
||||||
"@tauri-apps/plugin-notification": "^2.3.3",
|
"@tauri-apps/plugin-notification": "^2.3.3",
|
||||||
"@tauri-apps/plugin-store": "^2.4.2",
|
"@tauri-apps/plugin-store": "^2.4.2",
|
||||||
|
|||||||
42
frontend/public/loading.html
Normal file
42
frontend/public/loading.html
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
html, body { width: 100%; height: 100%; background: transparent; overflow: hidden; }
|
||||||
|
body { display: flex; align-items: center; justify-content: center; }
|
||||||
|
.wrap { position: relative; width: 36px; height: 36px; }
|
||||||
|
.dot {
|
||||||
|
width: 36px; height: 36px;
|
||||||
|
border: 2.5px solid rgba(255,255,255,0.06);
|
||||||
|
border-top-color: #6366f1;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.6s linear infinite;
|
||||||
|
filter: drop-shadow(0 0 8px rgba(99,102,241,0.5));
|
||||||
|
}
|
||||||
|
.badge {
|
||||||
|
display: none;
|
||||||
|
position: absolute; top: -4px; right: -6px;
|
||||||
|
background: #ef4444; color: #fff;
|
||||||
|
font: 700 9px/1 -apple-system, sans-serif;
|
||||||
|
min-width: 14px; height: 14px;
|
||||||
|
border-radius: 7px;
|
||||||
|
text-align: center;
|
||||||
|
padding: 2px 3px;
|
||||||
|
}
|
||||||
|
.badge.show { display: block; }
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="dot"></div>
|
||||||
|
<div class="badge" id="b"></div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
var c = new URLSearchParams(location.search).get('n');
|
||||||
|
if (c && parseInt(c) > 1) { var b = document.getElementById('b'); b.textContent = c; b.className = 'badge show'; }
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -22,7 +22,9 @@ import { useCanvasStore } from './stores/canvas'
|
|||||||
import { useProjectCanvasStore } from './stores/projectCanvas'
|
import { useProjectCanvasStore } from './stores/projectCanvas'
|
||||||
import { useSessionState } from './stores/session-state'
|
import { useSessionState } from './stores/session-state'
|
||||||
import { isTauri, isMobileTauri, getTauriNotification } from './lib/tauri'
|
import { isTauri, isMobileTauri, getTauriNotification } from './lib/tauri'
|
||||||
|
import { initApprovalNotifications } from './services/approvalNotifications'
|
||||||
import { useServerConfig } from './stores/server-config'
|
import { useServerConfig } from './stores/server-config'
|
||||||
|
import { useApprovalWindow } from './composables/useApprovalWindow'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -32,7 +34,7 @@ const serverConfig = isTauri ? useServerConfig() : null
|
|||||||
const showServerConfig = ref(false)
|
const showServerConfig = ref(false)
|
||||||
const needsServerConfig = computed(() => isTauri && serverConfig && !serverConfig.isConfigured)
|
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 showVoice = ref(false)
|
||||||
const showTranscriptDebug = ref(false)
|
const showTranscriptDebug = ref(false)
|
||||||
const showDebugConsole = ref(false)
|
const showDebugConsole = ref(false)
|
||||||
@@ -85,6 +87,15 @@ const canvasStore = useCanvasStore()
|
|||||||
const projectCanvasStore = useProjectCanvasStore()
|
const projectCanvasStore = useProjectCanvasStore()
|
||||||
const sessionState = useSessionState()
|
const sessionState = useSessionState()
|
||||||
const { totalPending, modalVisible, connect: connectApproval, disconnect: disconnectApproval, fetchPending: fetchApprovalPending } = useGlobalApproval()
|
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
|
// Voice FAB push-to-talk state
|
||||||
const voicePTTActive = ref(false)
|
const voicePTTActive = ref(false)
|
||||||
let voiceTouchStarted = false
|
let voiceTouchStarted = false
|
||||||
@@ -173,6 +184,16 @@ function handleGlobalKeydown(e: KeyboardEvent) {
|
|||||||
showTranscriptDebug.value = !showTranscriptDebug.value
|
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
|
// Voice FAB push-to-talk handlers
|
||||||
@@ -249,6 +270,11 @@ onMounted(async () => {
|
|||||||
connectApproval()
|
connectApproval()
|
||||||
fetchApprovalPending()
|
fetchApprovalPending()
|
||||||
|
|
||||||
|
// Initialize native approval notifications (Tauri only)
|
||||||
|
if (isTauri) {
|
||||||
|
initApprovalNotifications()
|
||||||
|
}
|
||||||
|
|
||||||
// Connect centralized session state WS
|
// Connect centralized session state WS
|
||||||
initSessionStateWS()
|
initSessionStateWS()
|
||||||
|
|
||||||
@@ -309,21 +335,145 @@ onMounted(async () => {
|
|||||||
|
|
||||||
async function sendTestNotification() {
|
async function sendTestNotification() {
|
||||||
const title = 'Agent UI'
|
const title = 'Agent UI'
|
||||||
const body = 'Test notification from Agent UI — all platforms!'
|
const body = 'Test notification — ' + new Date().toLocaleTimeString()
|
||||||
|
|
||||||
if (isTauri) {
|
if (isTauri) {
|
||||||
|
const isMobile = isMobileTauri()
|
||||||
try {
|
try {
|
||||||
const { isPermissionGranted, requestPermission, sendNotification } = await getTauriNotification()
|
const { invoke } = await import('@tauri-apps/api/core')
|
||||||
let granted = await isPermissionGranted()
|
|
||||||
|
// ── 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<boolean | null>('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<string>('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) {
|
if (!granted) {
|
||||||
const perm = await requestPermission()
|
console.error('[TestNotif] STOPPED: Permission denied')
|
||||||
granted = perm === 'granted'
|
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) {
|
} catch (e) {
|
||||||
console.warn('[Notification] Tauri plugin failed:', e)
|
console.error('[TestNotif] Fatal error:', e)
|
||||||
}
|
}
|
||||||
} else if ('Notification' in window) {
|
} else if ('Notification' in window) {
|
||||||
if (Notification.permission === 'granted') {
|
if (Notification.permission === 'granted') {
|
||||||
@@ -404,10 +554,10 @@ if (serverConfig) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
<button
|
<button
|
||||||
v-if="totalPending > 0 || modalVisible"
|
v-if="totalPending > 0 || modalVisible || approvalWindowOpen"
|
||||||
class="approval-badge-btn"
|
class="approval-badge-btn"
|
||||||
:class="{ active: modalVisible, pulse: totalPending > 0 }"
|
:class="{ active: modalVisible || approvalWindowOpen, pulse: totalPending > 0 }"
|
||||||
@click="modalVisible = !modalVisible"
|
@click="handleApprovalBadgeClick"
|
||||||
title="Hooks Approval"
|
title="Hooks Approval"
|
||||||
>
|
>
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
@@ -506,8 +656,8 @@ if (serverConfig) {
|
|||||||
<!-- Floating Transcript Debug -->
|
<!-- Floating Transcript Debug -->
|
||||||
<FloatingTranscriptDebug ref="transcriptDebugRef" v-model="showTranscriptDebug" />
|
<FloatingTranscriptDebug ref="transcriptDebugRef" v-model="showTranscriptDebug" />
|
||||||
|
|
||||||
<!-- Global Hooks Approval Modal -->
|
<!-- Global Hooks Approval Modal (web-only; Tauri desktop uses approval-window) -->
|
||||||
<HooksApprovalModal />
|
<HooksApprovalModal v-if="!isTauri" />
|
||||||
|
|
||||||
<!-- Tauri Server Config Dialog -->
|
<!-- Tauri Server Config Dialog -->
|
||||||
<ServerConfigDialog v-if="needsServerConfig || showServerConfig" />
|
<ServerConfigDialog v-if="needsServerConfig || showServerConfig" />
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useTranscriptDebug } from '@/composables/transcript-debug'
|
|||||||
import { useVoiceInput } from '@/composables/useVoiceInput'
|
import { useVoiceInput } from '@/composables/useVoiceInput'
|
||||||
import { ChatContainer, AquaticBackground, AgentBadge, NewSessionModal } from '@/components/transcript-debug'
|
import { ChatContainer, AquaticBackground, AgentBadge, NewSessionModal } from '@/components/transcript-debug'
|
||||||
import type { AgentName } from '@/types/transcript-debug'
|
import type { AgentName } from '@/types/transcript-debug'
|
||||||
|
import { isTauri } from '@/lib/tauri'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
modelValue: boolean
|
modelValue: boolean
|
||||||
@@ -506,15 +507,17 @@ watch(isOpen, async (open) => {
|
|||||||
function handleGlobalKeydown(e: KeyboardEvent) {
|
function handleGlobalKeydown(e: KeyboardEvent) {
|
||||||
if (!e.ctrlKey) return
|
if (!e.ctrlKey) return
|
||||||
|
|
||||||
// Ctrl+1..5 → switch to terminal by index
|
// Ctrl+1..5 → switch to terminal by index (disabled in Tauri — handled by global shortcuts)
|
||||||
const num = parseInt(e.key)
|
if (!isTauri) {
|
||||||
if (num >= 1 && num <= 5) {
|
const num = parseInt(e.key)
|
||||||
const terminal = openTerminals.value[num - 1]
|
if (num >= 1 && num <= 5) {
|
||||||
if (!terminal) return
|
const terminal = openTerminals.value[num - 1]
|
||||||
e.preventDefault()
|
if (!terminal) return
|
||||||
if (!isOpen.value) isOpen.value = true
|
e.preventDefault()
|
||||||
switchToTerminal(terminal.sessionId)
|
if (!isOpen.value) isOpen.value = true
|
||||||
return
|
switchToTerminal(terminal.sessionId)
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Zoom shortcuts (only when open)
|
// Zoom shortcuts (only when open)
|
||||||
@@ -710,7 +713,7 @@ onBeforeUnmount(() => {
|
|||||||
<!-- Content -->
|
<!-- Content -->
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<AquaticBackground />
|
<AquaticBackground />
|
||||||
<div class="readability-overlay" :style="{ background: `rgba(0, 0, 0, ${overlayOpacity})` }" />
|
<div v-if="overlayOpacity > 0" class="readability-overlay" :style="{ background: `rgba(0, 0, 0, ${overlayOpacity})` }" />
|
||||||
<Transition name="terminal-loading">
|
<Transition name="terminal-loading">
|
||||||
<div v-if="transitioning" class="terminal-loading-overlay">
|
<div v-if="transitioning" class="terminal-loading-overlay">
|
||||||
<div class="terminal-loading-spinner" />
|
<div class="terminal-loading-spinner" />
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { ParsedSystemMessage } from '@/types/transcript-debug'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
message: ParsedSystemMessage
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="compact-boundary">
|
||||||
|
<div class="compact-line">
|
||||||
|
<!-- Left compression wave -->
|
||||||
|
<svg class="wave wave-left" viewBox="0 0 200 14" preserveAspectRatio="xMinYMid slice" shape-rendering="crispEdges">
|
||||||
|
<!-- Base void layer -->
|
||||||
|
<rect x="0" y="6" width="200" height="2" fill="#f59e0b" opacity="0.08"/>
|
||||||
|
|
||||||
|
<!-- Compression chevrons pushing right → -->
|
||||||
|
<rect x="4" y="5" width="1" height="4" fill="#f59e0b" opacity="0.15"/>
|
||||||
|
<rect x="5" y="4" width="1" height="6" fill="#f59e0b" opacity="0.18"/>
|
||||||
|
<rect x="6" y="3" width="1" height="8" fill="#f59e0b" opacity="0.2"/>
|
||||||
|
|
||||||
|
<rect x="14" y="5" width="1" height="4" fill="#f59e0b" opacity="0.2"/>
|
||||||
|
<rect x="15" y="4" width="1" height="6" fill="#f59e0b" opacity="0.25"/>
|
||||||
|
<rect x="16" y="3" width="1" height="8" fill="#f59e0b" opacity="0.28"/>
|
||||||
|
|
||||||
|
<rect x="26" y="5" width="1" height="4" fill="#f59e0b" opacity="0.25"/>
|
||||||
|
<rect x="27" y="4" width="1" height="6" fill="#f59e0b" opacity="0.3"/>
|
||||||
|
<rect x="28" y="3" width="1" height="8" fill="#fbbf24" opacity="0.32"/>
|
||||||
|
|
||||||
|
<rect x="40" y="4" width="1" height="6" fill="#f59e0b" opacity="0.3"/>
|
||||||
|
<rect x="41" y="3" width="1" height="8" fill="#fbbf24" opacity="0.35"/>
|
||||||
|
<rect x="42" y="2" width="1" height="10" fill="#fbbf24" opacity="0.38"/>
|
||||||
|
|
||||||
|
<rect x="56" y="4" width="1" height="6" fill="#fbbf24" opacity="0.35"/>
|
||||||
|
<rect x="57" y="3" width="1" height="8" fill="#fbbf24" opacity="0.4"/>
|
||||||
|
<rect x="58" y="2" width="1" height="10" fill="#fbbf24" opacity="0.42"/>
|
||||||
|
|
||||||
|
<rect x="74" y="3" width="1" height="8" fill="#fbbf24" opacity="0.4"/>
|
||||||
|
<rect x="75" y="2" width="1" height="10" fill="#fbbf24" opacity="0.45"/>
|
||||||
|
<rect x="76" y="1" width="1" height="12" fill="#fbbf24" opacity="0.48"/>
|
||||||
|
|
||||||
|
<rect x="94" y="3" width="1" height="8" fill="#fbbf24" opacity="0.45"/>
|
||||||
|
<rect x="95" y="2" width="1" height="10" fill="#fde68a" opacity="0.5"/>
|
||||||
|
<rect x="96" y="1" width="1" height="12" fill="#fde68a" opacity="0.52"/>
|
||||||
|
|
||||||
|
<rect x="116" y="2" width="1" height="10" fill="#fde68a" opacity="0.5"/>
|
||||||
|
<rect x="117" y="1" width="1" height="12" fill="#fde68a" opacity="0.55"/>
|
||||||
|
<rect x="118" y="0" width="1" height="14" fill="#fef3c7" opacity="0.55"/>
|
||||||
|
|
||||||
|
<rect x="140" y="2" width="1" height="10" fill="#fde68a" opacity="0.55"/>
|
||||||
|
<rect x="141" y="1" width="1" height="12" fill="#fef3c7" opacity="0.58"/>
|
||||||
|
<rect x="142" y="0" width="1" height="14" fill="#fef3c7" opacity="0.6"/>
|
||||||
|
|
||||||
|
<rect x="160" y="1" width="1" height="12" fill="#fef3c7" opacity="0.58"/>
|
||||||
|
<rect x="161" y="0" width="1" height="14" fill="#fef3c7" opacity="0.62"/>
|
||||||
|
<rect x="162" y="0" width="1" height="14" fill="#fffbeb" opacity="0.65"/>
|
||||||
|
|
||||||
|
<rect x="178" y="1" width="1" height="12" fill="#fef3c7" opacity="0.6"/>
|
||||||
|
<rect x="179" y="0" width="1" height="14" fill="#fffbeb" opacity="0.65"/>
|
||||||
|
<rect x="180" y="0" width="1" height="14" fill="#fffbeb" opacity="0.7"/>
|
||||||
|
|
||||||
|
<!-- Data particles being pulled inward -->
|
||||||
|
<rect x="8" y="6" width="2" height="2" fill="#f59e0b" opacity="0.2"/>
|
||||||
|
<rect x="20" y="5" width="2" height="1" fill="#fbbf24" opacity="0.25"/>
|
||||||
|
<rect x="34" y="8" width="2" height="1" fill="#f59e0b" opacity="0.3"/>
|
||||||
|
<rect x="48" y="4" width="1" height="1" fill="#fbbf24" opacity="0.35"/>
|
||||||
|
<rect x="52" y="9" width="2" height="1" fill="#fde68a" opacity="0.3"/>
|
||||||
|
<rect x="66" y="5" width="1" height="1" fill="#fbbf24" opacity="0.4"/>
|
||||||
|
<rect x="68" y="8" width="2" height="1" fill="#f59e0b" opacity="0.35"/>
|
||||||
|
<rect x="84" y="4" width="1" height="1" fill="#fde68a" opacity="0.45"/>
|
||||||
|
<rect x="86" y="9" width="1" height="1" fill="#fbbf24" opacity="0.4"/>
|
||||||
|
<rect x="104" y="6" width="2" height="1" fill="#fde68a" opacity="0.45"/>
|
||||||
|
<rect x="108" y="3" width="1" height="1" fill="#fbbf24" opacity="0.5"/>
|
||||||
|
<rect x="128" y="5" width="1" height="1" fill="#fef3c7" opacity="0.5"/>
|
||||||
|
<rect x="132" y="8" width="1" height="1" fill="#fde68a" opacity="0.5"/>
|
||||||
|
<rect x="150" y="4" width="1" height="1" fill="#fef3c7" opacity="0.55"/>
|
||||||
|
<rect x="154" y="9" width="1" height="1" fill="#fde68a" opacity="0.55"/>
|
||||||
|
<rect x="170" y="6" width="1" height="1" fill="#fffbeb" opacity="0.6"/>
|
||||||
|
<rect x="186" y="5" width="1" height="1" fill="#fffbeb" opacity="0.6"/>
|
||||||
|
<rect x="192" y="7" width="1" height="1" fill="#fffbeb" opacity="0.65"/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<!-- Center badge -->
|
||||||
|
<span class="compact-badge">Compactado</span>
|
||||||
|
|
||||||
|
<!-- Right compression wave -->
|
||||||
|
<svg class="wave wave-right" viewBox="0 0 200 14" preserveAspectRatio="xMaxYMid slice" shape-rendering="crispEdges">
|
||||||
|
<!-- Base void layer -->
|
||||||
|
<rect x="0" y="6" width="200" height="2" fill="#f59e0b" opacity="0.08"/>
|
||||||
|
|
||||||
|
<!-- Compression chevrons pushing left ← (mirrored) -->
|
||||||
|
<rect x="195" y="5" width="1" height="4" fill="#f59e0b" opacity="0.15"/>
|
||||||
|
<rect x="194" y="4" width="1" height="6" fill="#f59e0b" opacity="0.18"/>
|
||||||
|
<rect x="193" y="3" width="1" height="8" fill="#f59e0b" opacity="0.2"/>
|
||||||
|
|
||||||
|
<rect x="185" y="5" width="1" height="4" fill="#f59e0b" opacity="0.2"/>
|
||||||
|
<rect x="184" y="4" width="1" height="6" fill="#f59e0b" opacity="0.25"/>
|
||||||
|
<rect x="183" y="3" width="1" height="8" fill="#f59e0b" opacity="0.28"/>
|
||||||
|
|
||||||
|
<rect x="173" y="5" width="1" height="4" fill="#f59e0b" opacity="0.25"/>
|
||||||
|
<rect x="172" y="4" width="1" height="6" fill="#f59e0b" opacity="0.3"/>
|
||||||
|
<rect x="171" y="3" width="1" height="8" fill="#fbbf24" opacity="0.32"/>
|
||||||
|
|
||||||
|
<rect x="159" y="4" width="1" height="6" fill="#f59e0b" opacity="0.3"/>
|
||||||
|
<rect x="158" y="3" width="1" height="8" fill="#fbbf24" opacity="0.35"/>
|
||||||
|
<rect x="157" y="2" width="1" height="10" fill="#fbbf24" opacity="0.38"/>
|
||||||
|
|
||||||
|
<rect x="143" y="4" width="1" height="6" fill="#fbbf24" opacity="0.35"/>
|
||||||
|
<rect x="142" y="3" width="1" height="8" fill="#fbbf24" opacity="0.4"/>
|
||||||
|
<rect x="141" y="2" width="1" height="10" fill="#fbbf24" opacity="0.42"/>
|
||||||
|
|
||||||
|
<rect x="125" y="3" width="1" height="8" fill="#fbbf24" opacity="0.4"/>
|
||||||
|
<rect x="124" y="2" width="1" height="10" fill="#fbbf24" opacity="0.45"/>
|
||||||
|
<rect x="123" y="1" width="1" height="12" fill="#fbbf24" opacity="0.48"/>
|
||||||
|
|
||||||
|
<rect x="105" y="3" width="1" height="8" fill="#fbbf24" opacity="0.45"/>
|
||||||
|
<rect x="104" y="2" width="1" height="10" fill="#fde68a" opacity="0.5"/>
|
||||||
|
<rect x="103" y="1" width="1" height="12" fill="#fde68a" opacity="0.52"/>
|
||||||
|
|
||||||
|
<rect x="83" y="2" width="1" height="10" fill="#fde68a" opacity="0.5"/>
|
||||||
|
<rect x="82" y="1" width="1" height="12" fill="#fde68a" opacity="0.55"/>
|
||||||
|
<rect x="81" y="0" width="1" height="14" fill="#fef3c7" opacity="0.55"/>
|
||||||
|
|
||||||
|
<rect x="59" y="2" width="1" height="10" fill="#fde68a" opacity="0.55"/>
|
||||||
|
<rect x="58" y="1" width="1" height="12" fill="#fef3c7" opacity="0.58"/>
|
||||||
|
<rect x="57" y="0" width="1" height="14" fill="#fef3c7" opacity="0.6"/>
|
||||||
|
|
||||||
|
<rect x="39" y="1" width="1" height="12" fill="#fef3c7" opacity="0.58"/>
|
||||||
|
<rect x="38" y="0" width="1" height="14" fill="#fef3c7" opacity="0.62"/>
|
||||||
|
<rect x="37" y="0" width="1" height="14" fill="#fffbeb" opacity="0.65"/>
|
||||||
|
|
||||||
|
<rect x="21" y="1" width="1" height="12" fill="#fef3c7" opacity="0.6"/>
|
||||||
|
<rect x="20" y="0" width="1" height="14" fill="#fffbeb" opacity="0.65"/>
|
||||||
|
<rect x="19" y="0" width="1" height="14" fill="#fffbeb" opacity="0.7"/>
|
||||||
|
|
||||||
|
<!-- Data particles being pulled inward -->
|
||||||
|
<rect x="190" y="6" width="2" height="2" fill="#f59e0b" opacity="0.2"/>
|
||||||
|
<rect x="178" y="8" width="2" height="1" fill="#fbbf24" opacity="0.25"/>
|
||||||
|
<rect x="164" y="5" width="2" height="1" fill="#f59e0b" opacity="0.3"/>
|
||||||
|
<rect x="151" y="9" width="1" height="1" fill="#fbbf24" opacity="0.35"/>
|
||||||
|
<rect x="146" y="4" width="2" height="1" fill="#fde68a" opacity="0.3"/>
|
||||||
|
<rect x="133" y="8" width="1" height="1" fill="#fbbf24" opacity="0.4"/>
|
||||||
|
<rect x="130" y="5" width="2" height="1" fill="#f59e0b" opacity="0.35"/>
|
||||||
|
<rect x="115" y="9" width="1" height="1" fill="#fde68a" opacity="0.45"/>
|
||||||
|
<rect x="112" y="4" width="1" height="1" fill="#fbbf24" opacity="0.4"/>
|
||||||
|
<rect x="94" y="7" width="2" height="1" fill="#fde68a" opacity="0.45"/>
|
||||||
|
<rect x="90" y="3" width="1" height="1" fill="#fbbf24" opacity="0.5"/>
|
||||||
|
<rect x="71" y="8" width="1" height="1" fill="#fef3c7" opacity="0.5"/>
|
||||||
|
<rect x="67" y="5" width="1" height="1" fill="#fde68a" opacity="0.5"/>
|
||||||
|
<rect x="49" y="9" width="1" height="1" fill="#fef3c7" opacity="0.55"/>
|
||||||
|
<rect x="45" y="4" width="1" height="1" fill="#fde68a" opacity="0.55"/>
|
||||||
|
<rect x="29" y="7" width="1" height="1" fill="#fffbeb" opacity="0.6"/>
|
||||||
|
<rect x="13" y="8" width="1" height="1" fill="#fffbeb" opacity="0.6"/>
|
||||||
|
<rect x="7" y="6" width="1" height="1" fill="#fffbeb" opacity="0.65"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.compact-boundary {
|
||||||
|
padding: 0.25rem 0;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-line {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0;
|
||||||
|
height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wave {
|
||||||
|
flex: 1;
|
||||||
|
height: 100%;
|
||||||
|
image-rendering: pixelated;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-badge {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
color: rgba(245, 158, 11, 0.85);
|
||||||
|
padding: 0 6px;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
z-index: 1;
|
||||||
|
animation: compress-pulse 3s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes compress-pulse {
|
||||||
|
0%, 100% {
|
||||||
|
text-shadow: 0 0 4px rgba(245, 158, 11, 0.2);
|
||||||
|
color: rgba(245, 158, 11, 0.7);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
text-shadow: 0 0 10px rgba(245, 158, 11, 0.5), 0 0 3px rgba(251, 191, 36, 0.3);
|
||||||
|
color: rgba(245, 158, 11, 0.95);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
124
frontend/src/composables/useApprovalWindow.ts
Normal file
124
frontend/src/composables/useApprovalWindow.ts
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
import { isTauri, getTauriStore } from '@/lib/tauri'
|
||||||
|
import { trackLoading } from '@/lib/loadingWindow'
|
||||||
|
|
||||||
|
const LABEL = 'approval-window'
|
||||||
|
const STORE_KEY = 'approvalWindow.geometry'
|
||||||
|
|
||||||
|
const DEFAULT_WIDTH = 520
|
||||||
|
const DEFAULT_HEIGHT = 480
|
||||||
|
|
||||||
|
interface WindowGeometry {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const approvalWindowOpen = ref(false)
|
||||||
|
|
||||||
|
async function loadGeometry(): Promise<WindowGeometry | null> {
|
||||||
|
try {
|
||||||
|
const store = await getTauriStore()
|
||||||
|
return await store.get<WindowGeometry>(STORE_KEY) ?? null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveGeometry(geo: WindowGeometry): Promise<void> {
|
||||||
|
try {
|
||||||
|
const store = await getTauriStore()
|
||||||
|
await store.set(STORE_KEY, geo)
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useApprovalWindow() {
|
||||||
|
|
||||||
|
async function openApprovalWindow(): Promise<boolean> {
|
||||||
|
if (!isTauri) return false
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow')
|
||||||
|
|
||||||
|
// If already open, just focus it
|
||||||
|
const existing = await WebviewWindow.getByLabel(LABEL)
|
||||||
|
if (existing) {
|
||||||
|
await existing.setFocus()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore last position/size or use defaults
|
||||||
|
const saved = await loadGeometry()
|
||||||
|
const x = saved?.x ?? Math.round(window.screenX + window.outerWidth - DEFAULT_WIDTH - 20)
|
||||||
|
const y = saved?.y ?? Math.round(window.screenY + 60)
|
||||||
|
const width = saved?.width ?? DEFAULT_WIDTH
|
||||||
|
const height = saved?.height ?? DEFAULT_HEIGHT
|
||||||
|
|
||||||
|
// Track in the shared loading indicator (bottom-right spinner)
|
||||||
|
const dismissLoading = await trackLoading(LABEL)
|
||||||
|
|
||||||
|
// Create real window hidden
|
||||||
|
const win = new WebviewWindow(LABEL, {
|
||||||
|
url: '/approval?window=1',
|
||||||
|
title: 'Hooks Approval',
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
visible: false,
|
||||||
|
alwaysOnTop: true,
|
||||||
|
decorations: false,
|
||||||
|
resizable: true,
|
||||||
|
focus: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
approvalWindowOpen.value = true
|
||||||
|
|
||||||
|
// When real window is ready: show it, remove from loading tracker
|
||||||
|
win.once('tauri://webview-created', () => {
|
||||||
|
setTimeout(async () => {
|
||||||
|
try { await win.show() } catch {}
|
||||||
|
try { await win.setFocus() } catch {}
|
||||||
|
await dismissLoading()
|
||||||
|
}, 200)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Persist geometry on close
|
||||||
|
win.onCloseRequested(async () => {
|
||||||
|
try {
|
||||||
|
const pos = await win.outerPosition()
|
||||||
|
const size = await win.outerSize()
|
||||||
|
await saveGeometry({
|
||||||
|
x: pos.x,
|
||||||
|
y: pos.y,
|
||||||
|
width: size.width,
|
||||||
|
height: size.height,
|
||||||
|
})
|
||||||
|
} catch {}
|
||||||
|
approvalWindowOpen.value = false
|
||||||
|
})
|
||||||
|
|
||||||
|
return true
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[ApprovalWindow] Failed to open:', e)
|
||||||
|
approvalWindowOpen.value = false
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function closeApprovalWindow(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow')
|
||||||
|
const existing = await WebviewWindow.getByLabel(LABEL)
|
||||||
|
if (existing) await existing.close()
|
||||||
|
} catch {}
|
||||||
|
approvalWindowOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
approvalWindowOpen,
|
||||||
|
openApprovalWindow,
|
||||||
|
closeApprovalWindow,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ref, computed, watch } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
import { useSessionState } from '@/stores/session-state'
|
import { useSessionState } from '@/stores/session-state'
|
||||||
import { apiFetch } from '@/lib/tauri'
|
import { apiFetch, isTauri } from '@/lib/tauri'
|
||||||
import type { HooksApprovalPermissionRequest, HooksApprovalPlanRequest } from '@/types/hooks-approval'
|
import type { HooksApprovalPermissionRequest, HooksApprovalPlanRequest } from '@/types/hooks-approval'
|
||||||
|
|
||||||
export interface ApprovalSessionGroup {
|
export interface ApprovalSessionGroup {
|
||||||
@@ -68,13 +68,53 @@ export function useGlobalApproval() {
|
|||||||
return Array.from(map.values())
|
return Array.from(map.values())
|
||||||
})
|
})
|
||||||
|
|
||||||
// Auto-show modal when new approvals arrive
|
// Auto-show modal (web) or open approval window (Tauri desktop) when new approvals arrive
|
||||||
watch(totalPending, (val, oldVal) => {
|
const isSubWindow = window.location.search.includes('window=1') || window.location.search.includes('pip=1')
|
||||||
if (val > 0 && (oldVal === 0 || oldVal === undefined) && !modalVisible.value) {
|
|
||||||
modalVisible.value = true
|
watch(totalPending, async (val, oldVal) => {
|
||||||
|
if (val > 0 && (oldVal === 0 || oldVal === undefined)) {
|
||||||
|
if (isTauri && !isSubWindow) {
|
||||||
|
// Desktop Tauri: open separate approval window
|
||||||
|
const { useApprovalWindow } = await import('./useApprovalWindow')
|
||||||
|
const { openApprovalWindow } = useApprovalWindow()
|
||||||
|
openApprovalWindow()
|
||||||
|
} else if (!isTauri && !modalVisible.value) {
|
||||||
|
// Web mode: show in-app modal
|
||||||
|
modalVisible.value = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── Native notification watch (Tauri main window only) ──
|
||||||
|
if (isTauri && !isSubWindow) {
|
||||||
|
let prevIds = new Set<string>()
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => sessionStore.allPendingApprovals,
|
||||||
|
async (current) => {
|
||||||
|
const currentIds = new Set(current.map(a => a.requestId))
|
||||||
|
|
||||||
|
// New approvals → send notification
|
||||||
|
const added = current.filter(a => !prevIds.has(a.requestId))
|
||||||
|
for (const approval of added) {
|
||||||
|
const { sendApprovalNotification } = await import('@/services/approvalNotifications')
|
||||||
|
sendApprovalNotification(approval)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Removed approvals → cancel notification
|
||||||
|
for (const id of prevIds) {
|
||||||
|
if (!currentIds.has(id)) {
|
||||||
|
const { cancelApprovalNotification } = await import('@/services/approvalNotifications')
|
||||||
|
cancelApprovalNotification(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
prevIds = currentIds
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Actions (call API, server broadcasts resolution to all clients) ──
|
// ── Actions (call API, server broadcasts resolution to all clients) ──
|
||||||
|
|
||||||
async function respondPermission(requestId: string, decision: string, reason?: string) {
|
async function respondPermission(requestId: string, decision: string, reason?: string) {
|
||||||
|
|||||||
123
frontend/src/composables/usePipWindow.ts
Normal file
123
frontend/src/composables/usePipWindow.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
import { isTauri, getTauriStore } from '@/lib/tauri'
|
||||||
|
import { trackLoading } from '@/lib/loadingWindow'
|
||||||
|
|
||||||
|
const STORE_KEY_PREFIX = 'pipWindow.geometry.'
|
||||||
|
|
||||||
|
interface WindowGeometry {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const pipWindows = ref<Map<number, boolean>>(new Map())
|
||||||
|
|
||||||
|
async function loadGeometry(idx: number): Promise<WindowGeometry | null> {
|
||||||
|
try {
|
||||||
|
const store = await getTauriStore()
|
||||||
|
return await store.get<WindowGeometry>(`${STORE_KEY_PREFIX}${idx}`) ?? null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveGeometry(idx: number, geo: WindowGeometry): Promise<void> {
|
||||||
|
try {
|
||||||
|
const store = await getTauriStore()
|
||||||
|
await store.set(`${STORE_KEY_PREFIX}${idx}`, geo)
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePipWindow() {
|
||||||
|
async function openPip(terminalIndex: number): Promise<boolean> {
|
||||||
|
if (!isTauri) return false
|
||||||
|
|
||||||
|
const pipLabel = `pip-terminal-${terminalIndex}`
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow')
|
||||||
|
|
||||||
|
const existing = await WebviewWindow.getByLabel(pipLabel)
|
||||||
|
if (existing) {
|
||||||
|
await existing.setFocus()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const pipUrl = `/transcript-debug/${terminalIndex}?pip=1`
|
||||||
|
|
||||||
|
// Restore last position/size or use defaults
|
||||||
|
const saved = await loadGeometry(terminalIndex)
|
||||||
|
const x = saved?.x ?? (window.screen.width - 400)
|
||||||
|
const y = saved?.y ?? (60 + (terminalIndex - 1) * 40)
|
||||||
|
const width = saved?.width ?? 380
|
||||||
|
const height = saved?.height ?? 620
|
||||||
|
|
||||||
|
// Track in the shared loading indicator (bottom-right spinner)
|
||||||
|
const dismissLoading = await trackLoading(pipLabel)
|
||||||
|
|
||||||
|
// Create real window hidden
|
||||||
|
const pip = new WebviewWindow(pipLabel, {
|
||||||
|
url: pipUrl,
|
||||||
|
title: `T${terminalIndex} - Agent UI`,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
visible: false,
|
||||||
|
alwaysOnTop: false,
|
||||||
|
decorations: false,
|
||||||
|
resizable: true,
|
||||||
|
focus: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
pipWindows.value.set(terminalIndex, true)
|
||||||
|
|
||||||
|
// When real window is ready: show it, remove from loading tracker
|
||||||
|
pip.once('tauri://webview-created', () => {
|
||||||
|
setTimeout(async () => {
|
||||||
|
try { await pip.show() } catch {}
|
||||||
|
try { await pip.setFocus() } catch {}
|
||||||
|
await dismissLoading()
|
||||||
|
}, 200)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Persist geometry on close
|
||||||
|
pip.onCloseRequested(async () => {
|
||||||
|
try {
|
||||||
|
const pos = await pip.outerPosition()
|
||||||
|
const size = await pip.outerSize()
|
||||||
|
await saveGeometry(terminalIndex, {
|
||||||
|
x: pos.x,
|
||||||
|
y: pos.y,
|
||||||
|
width: size.width,
|
||||||
|
height: size.height,
|
||||||
|
})
|
||||||
|
} catch {}
|
||||||
|
pipWindows.value.delete(terminalIndex)
|
||||||
|
})
|
||||||
|
|
||||||
|
return true
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`Failed to open PiP window for T${terminalIndex}:`, e)
|
||||||
|
pipWindows.value.delete(terminalIndex)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function closePip(terminalIndex: number): Promise<void> {
|
||||||
|
const pipLabel = `pip-terminal-${terminalIndex}`
|
||||||
|
try {
|
||||||
|
const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow')
|
||||||
|
const existing = await WebviewWindow.getByLabel(pipLabel)
|
||||||
|
if (existing) await existing.close()
|
||||||
|
} catch {}
|
||||||
|
pipWindows.value.delete(terminalIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPipOpen(terminalIndex: number): boolean {
|
||||||
|
return pipWindows.value.get(terminalIndex) ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
return { openPip, closePip, isPipOpen, pipWindows }
|
||||||
|
}
|
||||||
52
frontend/src/lib/loadingWindow.ts
Normal file
52
frontend/src/lib/loadingWindow.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* Tiny transparent loading indicator — fixed to bottom-right of screen.
|
||||||
|
* Shows a single spinner with a badge count when multiple windows are loading.
|
||||||
|
* Static HTML (/loading.html?n=count), no Vue, no JS frameworks.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const LABEL = 'loading-window'
|
||||||
|
const SIZE = 56
|
||||||
|
const MARGIN = 24
|
||||||
|
|
||||||
|
const pending = new Set<string>()
|
||||||
|
|
||||||
|
async function syncWindow() {
|
||||||
|
const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow')
|
||||||
|
|
||||||
|
// Destroy current loading window (recreate with new count)
|
||||||
|
try {
|
||||||
|
const prev = await WebviewWindow.getByLabel(LABEL)
|
||||||
|
if (prev) await prev.destroy()
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
if (pending.size === 0) return
|
||||||
|
|
||||||
|
const x = window.screen.width - SIZE - MARGIN
|
||||||
|
const y = window.screen.height - SIZE - MARGIN - 48 // above taskbar
|
||||||
|
|
||||||
|
new WebviewWindow(LABEL, {
|
||||||
|
url: `/loading.html?n=${pending.size}`,
|
||||||
|
title: '',
|
||||||
|
width: SIZE,
|
||||||
|
height: SIZE,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
alwaysOnTop: true,
|
||||||
|
decorations: false,
|
||||||
|
resizable: false,
|
||||||
|
focus: false,
|
||||||
|
transparent: true,
|
||||||
|
skipTaskbar: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Call when a window starts loading. Returns a dismiss function. */
|
||||||
|
export async function trackLoading(id: string): Promise<() => Promise<void>> {
|
||||||
|
pending.add(id)
|
||||||
|
await syncWindow()
|
||||||
|
|
||||||
|
return async () => {
|
||||||
|
pending.delete(id)
|
||||||
|
await syncWindow()
|
||||||
|
}
|
||||||
|
}
|
||||||
274
frontend/src/pages/ApprovalPage.vue
Normal file
274
frontend/src/pages/ApprovalPage.vue
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { watch, computed } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import { useGlobalApproval } from '@/composables/useGlobalApproval'
|
||||||
|
import { useSessionState } from '@/stores/session-state'
|
||||||
|
import PermissionApproval from '@/components/transcript-debug/PermissionApproval.vue'
|
||||||
|
import PlanApproval from '@/components/transcript-debug/PlanApproval.vue'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const sessionState = useSessionState()
|
||||||
|
const isWindow = computed(() => route.query.window === '1')
|
||||||
|
|
||||||
|
const {
|
||||||
|
totalPending,
|
||||||
|
groupedBySession,
|
||||||
|
respondPermission,
|
||||||
|
respondPlan,
|
||||||
|
ignoreApproval
|
||||||
|
} = useGlobalApproval()
|
||||||
|
|
||||||
|
function truncateId(id: string): string {
|
||||||
|
if (id.length <= 12) return id
|
||||||
|
return id.slice(0, 6) + '...' + id.slice(-4)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-close window when no pending approvals remain
|
||||||
|
let autoCloseTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
watch(totalPending, async (val) => {
|
||||||
|
if (autoCloseTimer) {
|
||||||
|
clearTimeout(autoCloseTimer)
|
||||||
|
autoCloseTimer = null
|
||||||
|
}
|
||||||
|
if (val === 0 && isWindow.value && sessionState.connected) {
|
||||||
|
autoCloseTimer = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const { getCurrentWebviewWindow } = await import('@tauri-apps/api/webviewWindow')
|
||||||
|
await getCurrentWebviewWindow().close()
|
||||||
|
} catch {
|
||||||
|
window.close()
|
||||||
|
}
|
||||||
|
}, 1500)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function closeWindow() {
|
||||||
|
try {
|
||||||
|
const { getCurrentWebviewWindow } = await import('@tauri-apps/api/webviewWindow')
|
||||||
|
await getCurrentWebviewWindow().close()
|
||||||
|
} catch {
|
||||||
|
window.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="approval-window">
|
||||||
|
<!-- Custom drag titlebar -->
|
||||||
|
<div class="titlebar" data-tauri-drag-region>
|
||||||
|
<span class="titlebar-title" data-tauri-drag-region>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
|
||||||
|
</svg>
|
||||||
|
Hooks Approval
|
||||||
|
</span>
|
||||||
|
<span v-if="totalPending > 0" class="titlebar-count">{{ totalPending }}</span>
|
||||||
|
<button class="titlebar-close" @click="closeWindow" title="Close">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div class="approval-body">
|
||||||
|
<!-- Loading state while WS connects -->
|
||||||
|
<div v-if="!sessionState.connected" class="loading-state">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<span>Connecting...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else-if="groupedBySession.length > 0">
|
||||||
|
<div v-for="group in groupedBySession" :key="group.sessionId" class="session-group">
|
||||||
|
<div class="session-header">
|
||||||
|
<span class="session-agent">{{ group.agent }}</span>
|
||||||
|
<span class="session-sep">/</span>
|
||||||
|
<span class="session-id" :title="group.sessionId">{{ truncateId(group.sessionId) }}</span>
|
||||||
|
<span class="session-count">{{ group.permissions.length + group.plans.length }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PermissionApproval
|
||||||
|
v-for="perm in group.permissions"
|
||||||
|
:key="perm.requestId"
|
||||||
|
:request="perm"
|
||||||
|
@respond="(id, decision, reason) => respondPermission(id, decision, reason)"
|
||||||
|
@ignore="(id) => ignoreApproval(id)"
|
||||||
|
/>
|
||||||
|
<PlanApproval
|
||||||
|
v-for="plan in group.plans"
|
||||||
|
:key="plan.requestId"
|
||||||
|
:request="plan"
|
||||||
|
@respond="(id, decision, reason) => respondPlan(id, decision, reason)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div v-else class="empty-state">
|
||||||
|
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" opacity="0.4">
|
||||||
|
<polyline points="20 6 9 17 4 12"/>
|
||||||
|
</svg>
|
||||||
|
<span>No pending approvals</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.approval-window {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Titlebar ── */
|
||||||
|
.titlebar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
flex-shrink: 0;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-app-region: drag;
|
||||||
|
}
|
||||||
|
|
||||||
|
.titlebar-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
-webkit-app-region: drag;
|
||||||
|
}
|
||||||
|
|
||||||
|
.titlebar-title svg {
|
||||||
|
color: #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.titlebar-count {
|
||||||
|
background: #ef4444;
|
||||||
|
color: white;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 0.05rem 0.4rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
min-width: 16px;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.titlebar-close {
|
||||||
|
margin-left: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
|
}
|
||||||
|
|
||||||
|
.titlebar-close:hover {
|
||||||
|
background: #ef4444;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Body ── */
|
||||||
|
.approval-body {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0.6rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-agent {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent, #6366f1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-sep {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-id {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-count {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: var(--bg-hover);
|
||||||
|
padding: 0.1rem 0.35rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 2rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border: 2px solid var(--border-color);
|
||||||
|
border-top-color: var(--accent, #6366f1);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -340,7 +340,7 @@ onBeforeUnmount(() => {
|
|||||||
<!-- Content -->
|
<!-- Content -->
|
||||||
<div :class="['content-area', { 'selector-open': showSelector }, `nav-${scrollNavMode}`]">
|
<div :class="['content-area', { 'selector-open': showSelector }, `nav-${scrollNavMode}`]">
|
||||||
<AquaticBackground />
|
<AquaticBackground />
|
||||||
<div class="readability-overlay" :style="{ background: `rgba(0, 0, 0, ${overlayOpacity})` }" />
|
<div v-if="overlayOpacity > 0" class="readability-overlay" :style="{ background: `rgba(0, 0, 0, ${overlayOpacity})` }" />
|
||||||
|
|
||||||
<Transition name="terminal-loading">
|
<Transition name="terminal-loading">
|
||||||
<div v-if="transitioning" class="loading-overlay">
|
<div v-if="transitioning" class="loading-overlay">
|
||||||
|
|||||||
@@ -63,6 +63,11 @@ const router = createRouter({
|
|||||||
path: '/transcript-debug/:terminalIndex',
|
path: '/transcript-debug/:terminalIndex',
|
||||||
name: 'transcript-debug-terminal',
|
name: 'transcript-debug-terminal',
|
||||||
component: () => import('../pages/TranscriptDebugPage.vue')
|
component: () => import('../pages/TranscriptDebugPage.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/approval',
|
||||||
|
name: 'approval',
|
||||||
|
component: () => import('../pages/ApprovalPage.vue')
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|||||||
360
frontend/src/services/approvalNotifications.ts
Normal file
360
frontend/src/services/approvalNotifications.ts
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
/**
|
||||||
|
* Native OS notifications with action buttons for approval requests.
|
||||||
|
* Uses @tauri-apps/plugin-notification v2.
|
||||||
|
*
|
||||||
|
* - Permission approvals → Allow / Deny buttons
|
||||||
|
* - Plan approvals → Approve / Reject buttons
|
||||||
|
* - Questions → Open button (brings up approval window)
|
||||||
|
*
|
||||||
|
* ANDROID NOTES:
|
||||||
|
* The official JS wrapper (`sendNotification`, `isPermissionGranted`, `requestPermission`)
|
||||||
|
* all use `window.Notification` (Web API / polyfill). On Android WebView this polyfill is
|
||||||
|
* injected by init-iife.js but has timing issues and fire-and-forget invoke calls.
|
||||||
|
*
|
||||||
|
* We bypass the JS wrappers entirely and call `invoke()` directly for:
|
||||||
|
* - Permission: `checkPermissions` / `requestPermissions` (native Kotlin commands)
|
||||||
|
* - Send: `notify` (goes Rust → Kotlin show())
|
||||||
|
* - Channel: `create_channel` (Kotlin createChannel)
|
||||||
|
*
|
||||||
|
* See: https://github.com/tauri-apps/plugins-workspace/issues/2341
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { isTauri, isMobileTauri } from '@/lib/tauri'
|
||||||
|
import type { PendingApproval } from '@/stores/session-state'
|
||||||
|
|
||||||
|
/** Resolves when init is fully done (channel created, action types registered, listener set). */
|
||||||
|
let _readyPromise: Promise<boolean> | null = null
|
||||||
|
|
||||||
|
// ── Helpers ──
|
||||||
|
|
||||||
|
/** Hash a string to a stable 32-bit positive integer (for notification IDs). */
|
||||||
|
function hashRequestId(str: string): number {
|
||||||
|
let hash = 0
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0
|
||||||
|
}
|
||||||
|
return Math.abs(hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ApprovalKind = 'permission' | 'plan' | 'question'
|
||||||
|
|
||||||
|
function classifyApproval(approval: PendingApproval & { agent: string }): ApprovalKind {
|
||||||
|
if (approval.type === 'plan') return 'plan'
|
||||||
|
if (approval.toolName === 'AskUserQuestion') return 'question'
|
||||||
|
return 'permission'
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Public API ──
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Call once from App.vue on mount (guarded by isTauri).
|
||||||
|
* Registers action types, Android channel, and the onAction listener.
|
||||||
|
* Returns true if ready to send notifications.
|
||||||
|
*/
|
||||||
|
export function initApprovalNotifications(): Promise<boolean> {
|
||||||
|
if (_readyPromise) return _readyPromise
|
||||||
|
if (!isTauri) {
|
||||||
|
_readyPromise = Promise.resolve(false)
|
||||||
|
return _readyPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
_readyPromise = doInit()
|
||||||
|
return _readyPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doInit(): Promise<boolean> {
|
||||||
|
const isMobile = isMobileTauri()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { invoke } = await import('@tauri-apps/api/core')
|
||||||
|
const {
|
||||||
|
registerActionTypes,
|
||||||
|
onAction,
|
||||||
|
} = await import('@tauri-apps/plugin-notification')
|
||||||
|
|
||||||
|
// ── 1. Permission check & request ──
|
||||||
|
// Use native Kotlin commands directly instead of JS wrappers that depend on
|
||||||
|
// window.Notification polyfill (unreliable on Android WebView).
|
||||||
|
console.log('[ApprovalNotif] Step 1: checking permission...')
|
||||||
|
|
||||||
|
let granted = false
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
// Android path: use checkPermissions / requestPermissions (Kotlin @Command)
|
||||||
|
// These route JS → Rust (not found) → Kotlin via mobile plugin auto-routing
|
||||||
|
try {
|
||||||
|
const check = await invoke<{ permissionState: string }>('plugin:notification|check_permissions')
|
||||||
|
console.log('[ApprovalNotif] checkPermissions:', JSON.stringify(check))
|
||||||
|
granted = check?.permissionState === 'granted'
|
||||||
|
|
||||||
|
if (!granted) {
|
||||||
|
console.log('[ApprovalNotif] Requesting permission via requestPermissions...')
|
||||||
|
const req = await invoke<{ permissionState: string }>('plugin:notification|request_permissions')
|
||||||
|
console.log('[ApprovalNotif] requestPermissions result:', JSON.stringify(req))
|
||||||
|
granted = req?.permissionState === 'granted'
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[ApprovalNotif] Android mobile permission commands failed:', e)
|
||||||
|
// Fallback: try Rust-side is_permission_granted + request_permission
|
||||||
|
try {
|
||||||
|
const result = await invoke<boolean | null>('plugin:notification|is_permission_granted')
|
||||||
|
console.log('[ApprovalNotif] is_permission_granted fallback:', result)
|
||||||
|
granted = result === true
|
||||||
|
|
||||||
|
if (!granted) {
|
||||||
|
console.log('[ApprovalNotif] Requesting via request_permission (Rust)...')
|
||||||
|
const perm = await invoke<string>('plugin:notification|request_permission')
|
||||||
|
console.log('[ApprovalNotif] request_permission result:', perm)
|
||||||
|
granted = perm === 'granted'
|
||||||
|
}
|
||||||
|
} catch (e2) {
|
||||||
|
console.error('[ApprovalNotif] All permission checks failed:', e2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Desktop path: use the JS wrapper (window.Notification exists on desktop WebView)
|
||||||
|
try {
|
||||||
|
const { isPermissionGranted, requestPermission } = await import('@tauri-apps/plugin-notification')
|
||||||
|
granted = await isPermissionGranted()
|
||||||
|
console.log('[ApprovalNotif] Desktop isPermissionGranted:', granted)
|
||||||
|
if (!granted) {
|
||||||
|
const perm = await requestPermission()
|
||||||
|
granted = perm === 'granted'
|
||||||
|
console.log('[ApprovalNotif] Desktop requestPermission:', perm)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[ApprovalNotif] Desktop permission check failed:', e)
|
||||||
|
granted = true // Desktop usually grants by default
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!granted) {
|
||||||
|
console.warn('[ApprovalNotif] Notification permission denied')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
console.log('[ApprovalNotif] Permission granted')
|
||||||
|
|
||||||
|
// ── 2. Android channel (high importance = heads-up) ──
|
||||||
|
// On Android 8+, notifications REQUIRE a channel or they won't show.
|
||||||
|
// The plugin creates a "default" channel on load, but we create our own
|
||||||
|
// "approvals" channel with high importance for heads-up display.
|
||||||
|
console.log('[ApprovalNotif] Step 2: creating channel...')
|
||||||
|
try {
|
||||||
|
await invoke('plugin:notification|create_channel', {
|
||||||
|
id: 'approvals',
|
||||||
|
name: 'Approvals',
|
||||||
|
description: 'Approval requests from Claude Code hooks',
|
||||||
|
importance: 4, // High — raw int instead of enum to avoid import issues
|
||||||
|
visibility: 1, // Public
|
||||||
|
vibration: true,
|
||||||
|
lights: true,
|
||||||
|
})
|
||||||
|
console.log('[ApprovalNotif] Channel "approvals" created')
|
||||||
|
} catch (e) {
|
||||||
|
if (isMobile) {
|
||||||
|
console.error('[ApprovalNotif] createChannel failed on mobile:', e)
|
||||||
|
// Don't return false — the "default" channel exists as fallback
|
||||||
|
}
|
||||||
|
// On desktop, createChannel is not supported — that's fine
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 3. Register action types (button groups) ──
|
||||||
|
console.log('[ApprovalNotif] Step 3: registering action types...')
|
||||||
|
try {
|
||||||
|
await registerActionTypes([
|
||||||
|
{
|
||||||
|
id: 'permission-actions',
|
||||||
|
actions: [
|
||||||
|
{ id: 'allow', title: 'Allow' },
|
||||||
|
{ id: 'deny', title: 'Deny', destructive: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'plan-actions',
|
||||||
|
actions: [
|
||||||
|
{ id: 'approve', title: 'Approve' },
|
||||||
|
{ id: 'reject', title: 'Reject', destructive: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'question-actions',
|
||||||
|
actions: [
|
||||||
|
{ id: 'open', title: 'Open' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
console.log('[ApprovalNotif] Action types registered')
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[ApprovalNotif] registerActionTypes failed:', e)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 4. onAction listener — handle button clicks ──
|
||||||
|
await onAction(async (notification) => {
|
||||||
|
const extra = (notification as any).extra as Record<string, string> | undefined
|
||||||
|
const requestId = extra?.requestId
|
||||||
|
const type = extra?.type as ApprovalKind | undefined
|
||||||
|
const actionId = (notification as any).actionId as string | undefined
|
||||||
|
|
||||||
|
console.log('[ApprovalNotif] onAction:', { actionId, type, requestId })
|
||||||
|
|
||||||
|
if (!requestId) {
|
||||||
|
await openApprovalWindowFromNotification()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { apiFetch } = await import('@/lib/tauri')
|
||||||
|
|
||||||
|
if (type === 'permission') {
|
||||||
|
if (actionId === 'allow' || actionId === 'deny') {
|
||||||
|
await apiFetch('/api/hooks-approval/respond', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ requestId, decision: actionId }),
|
||||||
|
}).catch(e => console.error('[ApprovalNotif] respond failed:', e))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'plan') {
|
||||||
|
if (actionId === 'approve' || actionId === 'reject') {
|
||||||
|
await apiFetch('/api/hooks-approval/respond-plan', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ requestId, decision: actionId }),
|
||||||
|
}).catch(e => console.error('[ApprovalNotif] respond-plan failed:', e))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await openApprovalWindowFromNotification()
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('[ApprovalNotif] Initialized successfully')
|
||||||
|
return true
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[ApprovalNotif] Init failed:', e)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a native notification for a pending approval.
|
||||||
|
* Uses `invoke('plugin:notification|notify')` directly instead of the JS wrapper.
|
||||||
|
*/
|
||||||
|
export async function sendApprovalNotification(
|
||||||
|
approval: PendingApproval & { agent: string }
|
||||||
|
): Promise<void> {
|
||||||
|
if (!isTauri) return
|
||||||
|
|
||||||
|
const ready = _readyPromise ? await _readyPromise : false
|
||||||
|
if (!ready) {
|
||||||
|
console.warn('[ApprovalNotif] Not ready, skipping notification for', approval.requestId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { invoke } = await import('@tauri-apps/api/core')
|
||||||
|
const kind = classifyApproval(approval)
|
||||||
|
const id = hashRequestId(approval.requestId)
|
||||||
|
|
||||||
|
let title = 'Agent UI'
|
||||||
|
let body = ''
|
||||||
|
let actionTypeId = ''
|
||||||
|
|
||||||
|
switch (kind) {
|
||||||
|
case 'permission':
|
||||||
|
title = `Permission: ${approval.toolName || 'unknown tool'}`
|
||||||
|
body = truncate(summarizeToolInput(approval.toolInput), 200)
|
||||||
|
actionTypeId = 'permission-actions'
|
||||||
|
break
|
||||||
|
case 'plan':
|
||||||
|
title = 'Plan approval requested'
|
||||||
|
body = truncate(approval.lastAssistantText || 'Review the plan', 200)
|
||||||
|
actionTypeId = 'plan-actions'
|
||||||
|
break
|
||||||
|
case 'question':
|
||||||
|
title = 'Question from Claude'
|
||||||
|
body = truncate(extractQuestionText(approval.toolInput), 200)
|
||||||
|
actionTypeId = 'question-actions'
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
const isMobile = isMobileTauri()
|
||||||
|
|
||||||
|
await invoke('plugin:notification|notify', {
|
||||||
|
options: {
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
// On Android: use our custom channel. On desktop: ignored.
|
||||||
|
channelId: isMobile ? 'approvals' : undefined,
|
||||||
|
actionTypeId,
|
||||||
|
autoCancel: true,
|
||||||
|
extra: {
|
||||||
|
requestId: approval.requestId,
|
||||||
|
type: kind,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(`[ApprovalNotif] Sent id=${id} kind=${kind} req=${approval.requestId}`)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[ApprovalNotif] Failed to send:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cancel a notification when the approval is resolved from the UI. */
|
||||||
|
export async function cancelApprovalNotification(requestId: string): Promise<void> {
|
||||||
|
if (!isTauri) return
|
||||||
|
|
||||||
|
const ready = _readyPromise ? await _readyPromise : false
|
||||||
|
if (!ready) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { invoke } = await import('@tauri-apps/api/core')
|
||||||
|
const id = hashRequestId(requestId)
|
||||||
|
await invoke('plugin:notification|remove_active', {
|
||||||
|
notifications: [{ id }]
|
||||||
|
})
|
||||||
|
console.log(`[ApprovalNotif] Cancelled id=${id}`)
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[ApprovalNotif] Cancel failed:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Internal helpers ──
|
||||||
|
|
||||||
|
async function openApprovalWindowFromNotification(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { useApprovalWindow } = await import('@/composables/useApprovalWindow')
|
||||||
|
const { openApprovalWindow } = useApprovalWindow()
|
||||||
|
await openApprovalWindow()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[ApprovalNotif] Failed to open approval window:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeToolInput(input: unknown): string {
|
||||||
|
if (!input) return ''
|
||||||
|
if (typeof input === 'string') return input
|
||||||
|
const obj = input as Record<string, unknown>
|
||||||
|
if (obj.command && typeof obj.command === 'string') return obj.command
|
||||||
|
if (obj.file_path && typeof obj.file_path === 'string') return obj.file_path as string
|
||||||
|
return JSON.stringify(input).slice(0, 200)
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractQuestionText(input: unknown): string {
|
||||||
|
if (!input) return 'A question needs your answer'
|
||||||
|
const obj = input as Record<string, unknown>
|
||||||
|
if (Array.isArray(obj.questions) && obj.questions.length > 0) {
|
||||||
|
const q = obj.questions[0]
|
||||||
|
if (typeof q === 'object' && q && 'question' in q) return (q as any).question
|
||||||
|
}
|
||||||
|
if (typeof obj.question === 'string') return obj.question
|
||||||
|
return 'A question needs your answer'
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncate(str: string, max: number): string {
|
||||||
|
if (str.length <= max) return str
|
||||||
|
return str.slice(0, max - 1) + '\u2026'
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
"start:frontend": "cd frontend && bun run dev --host",
|
"start:frontend": "cd frontend && bun run dev --host",
|
||||||
"tauri": "npx --prefix frontend tauri",
|
"tauri": "npx --prefix frontend tauri",
|
||||||
"tauri:dev": "npx --prefix frontend tauri dev",
|
"tauri:dev": "npx --prefix frontend tauri dev",
|
||||||
"tauri:build": "npx --prefix frontend tauri build",
|
"tauri:build": "frontend/node_modules/.bin/tauri build",
|
||||||
"tauri:android:init": "npx --prefix frontend tauri android init",
|
"tauri:android:init": "npx --prefix frontend tauri android init",
|
||||||
"tauri:android:build": "npx --prefix frontend tauri android build",
|
"tauri:android:build": "npx --prefix frontend tauri android build",
|
||||||
"build:android:tauri": "cd src-tauri/gen/android && ./gradlew assembleRelease -x rustBuildArm64Release -x rustBuildArmRelease -x rustBuildX86Release -x rustBuildX86_64Release",
|
"build:android:tauri": "cd src-tauri/gen/android && ./gradlew assembleRelease -x rustBuildArm64Release -x rustBuildArmRelease -x rustBuildX86Release -x rustBuildX86_64Release",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ initDatabase()
|
|||||||
|
|
||||||
// Start HTTP API server
|
// Start HTTP API server
|
||||||
Bun.serve({
|
Bun.serve({
|
||||||
|
hostname: '0.0.0.0',
|
||||||
port: PORT_HTTP,
|
port: PORT_HTTP,
|
||||||
fetch: handleRequest
|
fetch: handleRequest
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -105,11 +105,14 @@ export function handleTranscriptDebugRaw(sessionId: string, url: URL): Response
|
|||||||
return errorResponse(`Session ${sessionId} not found`, 404)
|
return errorResponse(`Session ${sessionId} not found`, 404)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const stat = statSync(filePath)
|
||||||
const content = readFileSync(filePath, 'utf-8')
|
const content = readFileSync(filePath, 'utf-8')
|
||||||
return new Response(content, {
|
return new Response(content, {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'text/plain; charset=utf-8',
|
'Content-Type': 'text/plain; charset=utf-8',
|
||||||
|
'Content-Length': String(stat.size),
|
||||||
|
'X-File-Size': String(stat.size),
|
||||||
...corsHeaders
|
...corsHeaders
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export function getClients() {
|
|||||||
|
|
||||||
export function startSyncServer() {
|
export function startSyncServer() {
|
||||||
const server = Bun.serve({
|
const server = Bun.serve({
|
||||||
|
hostname: '0.0.0.0',
|
||||||
port: PORT_GIT,
|
port: PORT_GIT,
|
||||||
async fetch(req, server) {
|
async fetch(req, server) {
|
||||||
const url = new URL(req.url)
|
const url = new URL(req.url)
|
||||||
|
|||||||
41
src-tauri/Cargo.lock
generated
41
src-tauri/Cargo.lock
generated
@@ -18,6 +18,7 @@ dependencies = [
|
|||||||
"tauri-build",
|
"tauri-build",
|
||||||
"tauri-plugin-clipboard-manager",
|
"tauri-plugin-clipboard-manager",
|
||||||
"tauri-plugin-dialog",
|
"tauri-plugin-dialog",
|
||||||
|
"tauri-plugin-global-shortcut",
|
||||||
"tauri-plugin-http",
|
"tauri-plugin-http",
|
||||||
"tauri-plugin-notification",
|
"tauri-plugin-notification",
|
||||||
"tauri-plugin-store",
|
"tauri-plugin-store",
|
||||||
@@ -1487,6 +1488,24 @@ version = "0.3.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
|
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "global-hotkey"
|
||||||
|
version = "0.7.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b9247516746aa8e53411a0db9b62b0e24efbcf6a76e0ba73e5a91b512ddabed7"
|
||||||
|
dependencies = [
|
||||||
|
"crossbeam-channel",
|
||||||
|
"keyboard-types",
|
||||||
|
"objc2",
|
||||||
|
"objc2-app-kit",
|
||||||
|
"once_cell",
|
||||||
|
"serde",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"windows-sys 0.59.0",
|
||||||
|
"x11rb",
|
||||||
|
"xkeysym",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "gobject-sys"
|
name = "gobject-sys"
|
||||||
version = "0.18.0"
|
version = "0.18.0"
|
||||||
@@ -4097,6 +4116,7 @@ dependencies = [
|
|||||||
"gtk",
|
"gtk",
|
||||||
"heck 0.5.0",
|
"heck 0.5.0",
|
||||||
"http",
|
"http",
|
||||||
|
"image",
|
||||||
"jni",
|
"jni",
|
||||||
"libc",
|
"libc",
|
||||||
"log",
|
"log",
|
||||||
@@ -4266,6 +4286,21 @@ dependencies = [
|
|||||||
"url",
|
"url",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tauri-plugin-global-shortcut"
|
||||||
|
version = "2.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "424af23c7e88d05e4a1a6fc2c7be077912f8c76bd7900fd50aa2b7cbf5a2c405"
|
||||||
|
dependencies = [
|
||||||
|
"global-hotkey",
|
||||||
|
"log",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tauri",
|
||||||
|
"tauri-plugin",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-plugin-http"
|
name = "tauri-plugin-http"
|
||||||
version = "2.5.7"
|
version = "2.5.7"
|
||||||
@@ -6009,6 +6044,12 @@ version = "0.13.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
|
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "xkeysym"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "yoke"
|
name = "yoke"
|
||||||
version = "0.8.1"
|
version = "0.8.1"
|
||||||
|
|||||||
@@ -13,11 +13,12 @@ crate-type = ["staticlib", "cdylib", "rlib"]
|
|||||||
tauri-build = { version = "2", features = [] }
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tauri = { version = "2", features = [] }
|
tauri = { version = "2", features = ["tray-icon", "image-png"] }
|
||||||
tauri-plugin-http = "2"
|
tauri-plugin-http = "2"
|
||||||
tauri-plugin-store = "2"
|
tauri-plugin-store = "2"
|
||||||
tauri-plugin-notification = "2"
|
tauri-plugin-notification = "2"
|
||||||
tauri-plugin-clipboard-manager = "2"
|
tauri-plugin-clipboard-manager = "2"
|
||||||
tauri-plugin-dialog = "2"
|
tauri-plugin-dialog = "2"
|
||||||
|
tauri-plugin-global-shortcut = "2"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-utils/schema.json",
|
"$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-utils/schema.json",
|
||||||
"identifier": "default",
|
"identifier": "default",
|
||||||
"description": "Default permissions for Agent UI",
|
"description": "Default permissions for Agent UI",
|
||||||
"windows": ["main", "pip-terminal"],
|
"windows": ["main", "pip-terminal", "pip-terminal-1", "pip-terminal-2", "pip-terminal-3", "pip-terminal-4", "pip-terminal-5", "approval-window", "loading-window"],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"core:default",
|
"core:default",
|
||||||
{
|
{
|
||||||
@@ -28,9 +28,21 @@
|
|||||||
"core:window:allow-set-position",
|
"core:window:allow-set-position",
|
||||||
"core:window:allow-set-focus",
|
"core:window:allow-set-focus",
|
||||||
"core:window:allow-set-decorations",
|
"core:window:allow-set-decorations",
|
||||||
|
"core:window:allow-show",
|
||||||
|
"core:window:allow-hide",
|
||||||
|
"core:window:allow-inner-position",
|
||||||
|
"core:window:allow-inner-size",
|
||||||
|
"core:window:allow-outer-position",
|
||||||
|
"core:window:allow-outer-size",
|
||||||
"core:webview:default",
|
"core:webview:default",
|
||||||
"core:webview:allow-create-webview-window",
|
"core:webview:allow-create-webview-window",
|
||||||
"core:webview:allow-webview-close",
|
"core:webview:allow-webview-close",
|
||||||
"core:window:allow-destroy"
|
"core:window:allow-destroy",
|
||||||
|
"global-shortcut:default",
|
||||||
|
"global-shortcut:allow-register",
|
||||||
|
"global-shortcut:allow-unregister",
|
||||||
|
"global-shortcut:allow-is-registered",
|
||||||
|
"global-shortcut:allow-unregister-all",
|
||||||
|
"core:tray:default"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||||
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
<!-- AndroidTV support -->
|
<!-- AndroidTV support -->
|
||||||
<uses-feature android:name="android.software.leanback" android:required="false" />
|
<uses-feature android:name="android.software.leanback" android:required="false" />
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,46 @@
|
|||||||
|
use tauri::{
|
||||||
|
menu::{Menu, MenuItem},
|
||||||
|
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
|
||||||
|
Manager, WebviewWindowBuilder, WindowEvent,
|
||||||
|
};
|
||||||
|
use tauri_plugin_global_shortcut::{Code, GlobalShortcutExt, Modifiers, Shortcut, ShortcutState};
|
||||||
|
|
||||||
|
fn show_main_window(app: &tauri::AppHandle) {
|
||||||
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
|
let _ = window.show();
|
||||||
|
let _ = window.unminimize();
|
||||||
|
let _ = window.set_focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_pip_terminal(app: &tauri::AppHandle, idx: u8) {
|
||||||
|
let label = format!("pip-terminal-{}", idx);
|
||||||
|
|
||||||
|
// If PiP already exists, just focus it
|
||||||
|
if let Some(win) = app.get_webview_window(&label) {
|
||||||
|
let _ = win.set_focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new PiP window — the page itself handles terminal connection or showing "new session" modal
|
||||||
|
let url = format!("/transcript-debug/{}?pip=1", idx);
|
||||||
|
let x = 1520.0_f64; // sensible default, will be near right edge on 1920px screens
|
||||||
|
let y = 60.0 + (idx as f64 - 1.0) * 40.0;
|
||||||
|
|
||||||
|
let _ = WebviewWindowBuilder::new(
|
||||||
|
app,
|
||||||
|
&label,
|
||||||
|
tauri::WebviewUrl::App(url.into()),
|
||||||
|
)
|
||||||
|
.title(&format!("T{} - Agent UI", idx))
|
||||||
|
.inner_size(380.0, 620.0)
|
||||||
|
.position(x, y)
|
||||||
|
.decorations(false)
|
||||||
|
.resizable(true)
|
||||||
|
.focused(true)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
@@ -6,6 +49,96 @@ pub fn run() {
|
|||||||
.plugin(tauri_plugin_notification::init())
|
.plugin(tauri_plugin_notification::init())
|
||||||
.plugin(tauri_plugin_clipboard_manager::init())
|
.plugin(tauri_plugin_clipboard_manager::init())
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
|
.plugin(
|
||||||
|
tauri_plugin_global_shortcut::Builder::new()
|
||||||
|
.with_handler(|app, shortcut, event| {
|
||||||
|
if event.state != ShortcutState::Pressed {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mods = shortcut.mods;
|
||||||
|
let key = shortcut.key;
|
||||||
|
|
||||||
|
// Ctrl+Alt+E → show main window
|
||||||
|
if mods == Modifiers::CONTROL | Modifiers::ALT && key == Code::KeyE {
|
||||||
|
show_main_window(app);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl+1-5 → open PiP terminal
|
||||||
|
if mods == Modifiers::CONTROL {
|
||||||
|
match key {
|
||||||
|
Code::Digit1 => open_pip_terminal(app, 1),
|
||||||
|
Code::Digit2 => open_pip_terminal(app, 2),
|
||||||
|
Code::Digit3 => open_pip_terminal(app, 3),
|
||||||
|
Code::Digit4 => open_pip_terminal(app, 4),
|
||||||
|
Code::Digit5 => open_pip_terminal(app, 5),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.setup(|app| {
|
||||||
|
// Register global shortcuts (desktop only)
|
||||||
|
#[cfg(desktop)]
|
||||||
|
{
|
||||||
|
let ctrl_alt = Modifiers::CONTROL | Modifiers::ALT;
|
||||||
|
let ctrl = Modifiers::CONTROL;
|
||||||
|
let shortcuts = [
|
||||||
|
Shortcut::new(Some(ctrl_alt), Code::KeyE),
|
||||||
|
Shortcut::new(Some(ctrl), Code::Digit1),
|
||||||
|
Shortcut::new(Some(ctrl), Code::Digit2),
|
||||||
|
Shortcut::new(Some(ctrl), Code::Digit3),
|
||||||
|
Shortcut::new(Some(ctrl), Code::Digit4),
|
||||||
|
Shortcut::new(Some(ctrl), Code::Digit5),
|
||||||
|
];
|
||||||
|
for s in &shortcuts {
|
||||||
|
let _ = app.global_shortcut().register(*s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build system tray (desktop only)
|
||||||
|
#[cfg(desktop)]
|
||||||
|
{
|
||||||
|
let show_item = MenuItem::with_id(app, "show", "Show", true, None::<&str>)?;
|
||||||
|
let quit_item = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?;
|
||||||
|
let menu = Menu::with_items(app, &[&show_item, &quit_item])?;
|
||||||
|
|
||||||
|
TrayIconBuilder::new()
|
||||||
|
.icon(app.default_window_icon().unwrap().clone())
|
||||||
|
.menu(&menu)
|
||||||
|
.show_menu_on_left_click(false)
|
||||||
|
.on_menu_event(|app, event| match event.id.as_ref() {
|
||||||
|
"show" => show_main_window(app),
|
||||||
|
"quit" => app.exit(0),
|
||||||
|
_ => {}
|
||||||
|
})
|
||||||
|
.on_tray_icon_event(|tray, event| {
|
||||||
|
if let TrayIconEvent::Click {
|
||||||
|
button: MouseButton::Left,
|
||||||
|
button_state: MouseButtonState::Up,
|
||||||
|
..
|
||||||
|
} = event
|
||||||
|
{
|
||||||
|
show_main_window(tray.app_handle());
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.build(app)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.on_window_event(|window, event| {
|
||||||
|
// Hide main window to tray instead of closing (desktop only)
|
||||||
|
#[cfg(desktop)]
|
||||||
|
if let WindowEvent::CloseRequested { api, .. } = event {
|
||||||
|
if window.label() == "main" {
|
||||||
|
api.prevent_close();
|
||||||
|
let _ = window.hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user