diff --git a/v2/src-tauri/src/commands/notifications.rs b/v2/src-tauri/src/commands/notifications.rs new file mode 100644 index 0000000..0e7173f --- /dev/null +++ b/v2/src-tauri/src/commands/notifications.rs @@ -0,0 +1,8 @@ +// Notification commands — desktop notification via notify-rust + +use crate::notifications; + +#[tauri::command] +pub fn notify_desktop(title: String, body: String, urgency: String) -> Result<(), String> { + notifications::send_desktop_notification(&title, &body, &urgency) +} diff --git a/v2/src-tauri/src/notifications.rs b/v2/src-tauri/src/notifications.rs new file mode 100644 index 0000000..9f9b4e6 --- /dev/null +++ b/v2/src-tauri/src/notifications.rs @@ -0,0 +1,31 @@ +// Desktop notification support via notify-rust + +use notify_rust::{Notification, Urgency}; + +/// Send an OS desktop notification. +/// Fails gracefully if the notification daemon is unavailable. +pub fn send_desktop_notification( + title: &str, + body: &str, + urgency: &str, +) -> Result<(), String> { + let urgency_level = match urgency { + "critical" => Urgency::Critical, + "low" => Urgency::Low, + _ => Urgency::Normal, + }; + + match Notification::new() + .summary(title) + .body(body) + .appname("BTerminal") + .urgency(urgency_level) + .show() + { + Ok(_) => Ok(()), + Err(e) => { + tracing::warn!("Desktop notification failed (daemon unavailable?): {e}"); + Ok(()) // Graceful — don't propagate to frontend + } + } +} diff --git a/v2/src/lib/adapters/notifications-bridge.ts b/v2/src/lib/adapters/notifications-bridge.ts new file mode 100644 index 0000000..8b5609e --- /dev/null +++ b/v2/src/lib/adapters/notifications-bridge.ts @@ -0,0 +1,19 @@ +// Notifications bridge — wraps Tauri desktop notification command + +import { invoke } from '@tauri-apps/api/core'; + +export type NotificationUrgency = 'low' | 'normal' | 'critical'; + +/** + * Send an OS desktop notification via notify-rust. + * Fire-and-forget: errors are swallowed (notification daemon may not be running). + */ +export function sendDesktopNotification( + title: string, + body: string, + urgency: NotificationUrgency = 'normal', +): void { + invoke('notify_desktop', { title, body, urgency }).catch(() => { + // Swallow IPC errors — notifications must never break the app + }); +} diff --git a/v2/src/lib/components/Notifications/NotificationCenter.svelte b/v2/src/lib/components/Notifications/NotificationCenter.svelte new file mode 100644 index 0000000..ace5ee3 --- /dev/null +++ b/v2/src/lib/components/Notifications/NotificationCenter.svelte @@ -0,0 +1,300 @@ + + + + +
+ + + {#if open} + +
+
+
+ Notifications +
+ {#if unreadCount > 0} + + {/if} + {#if history.length > 0} + + {/if} +
+
+
+ {#if history.length === 0} +
No notifications
+ {:else} + {#each [...history].reverse() as item (item.id)} + + {/each} + {/if} +
+
+ {/if} +
+ + diff --git a/v2/src/lib/components/StatusBar/StatusBar.svelte b/v2/src/lib/components/StatusBar/StatusBar.svelte index ae409a6..fd33dd6 100644 --- a/v2/src/lib/components/StatusBar/StatusBar.svelte +++ b/v2/src/lib/components/StatusBar/StatusBar.svelte @@ -3,6 +3,10 @@ import { getActiveGroup, getEnabledProjects, setActiveProject } from '../../stores/workspace.svelte'; import { getHealthAggregates, getAttentionQueue, type ProjectHealth } from '../../stores/health.svelte'; import { getTotalConflictCount } from '../../stores/conflicts.svelte'; + import { onMount } from 'svelte'; + import { checkForUpdates, installUpdate, type UpdateInfo } from '../../utils/updater'; + import { message as dialogMessage, confirm } from '@tauri-apps/plugin-dialog'; + import NotificationCenter from '../Notifications/NotificationCenter.svelte'; let agentSessions = $derived(getAgentSessions()); let activeGroup = $derived(getActiveGroup()); @@ -19,6 +23,35 @@ let totalConflicts = $derived(getTotalConflictCount()); let showAttention = $state(false); + // Auto-update state + let updateInfo = $state(null); + let installing = $state(false); + + onMount(() => { + // Check for updates 10s after startup + const timer = setTimeout(async () => { + const info = await checkForUpdates(); + if (info.available) updateInfo = info; + }, 10_000); + return () => clearTimeout(timer); + }); + + async function handleUpdateClick() { + if (!updateInfo) return; + const notes = updateInfo.notes + ? `Release notes:\n\n${updateInfo.notes}\n\nInstall and restart?` + : `Install v${updateInfo.version} and restart?`; + const confirmed = await confirm(notes, { title: `Update available: v${updateInfo.version}`, kind: 'info' }); + if (confirmed) { + installing = true; + try { + await installUpdate(); + } catch { + installing = false; + } + } + } + function projectName(projectId: string): string { return enabledProjects.find(p => p.id === projectId)?.name ?? projectId.slice(0, 8); } @@ -105,6 +138,23 @@ ${totalCost.toFixed(4)} {/if} + + + {#if updateInfo?.available} + + + {/if} BTerminal v3 @@ -244,6 +294,32 @@ .cost { color: var(--ctp-yellow); } .version { color: var(--ctp-overlay0); } + /* Update badge */ + .update-btn { + background: color-mix(in srgb, var(--ctp-green) 15%, transparent); + border: 1px solid var(--ctp-green); + border-radius: 0.25rem; + color: var(--ctp-green); + font: inherit; + font-size: 0.625rem; + font-weight: 600; + padding: 0 0.375rem; + cursor: pointer; + display: flex; + align-items: center; + gap: 0.25rem; + line-height: 1.25rem; + } + + .update-btn:hover { + background: color-mix(in srgb, var(--ctp-green) 25%, transparent); + } + + .update-btn:disabled { + opacity: 0.5; + cursor: default; + } + /* Attention panel dropdown */ .attention-panel { position: absolute; diff --git a/v2/src/lib/stores/notifications.svelte.ts b/v2/src/lib/stores/notifications.svelte.ts index e8c9364..8206890 100644 --- a/v2/src/lib/stores/notifications.svelte.ts +++ b/v2/src/lib/stores/notifications.svelte.ts @@ -1,30 +1,60 @@ -// Notification store — ephemeral toast messages +// Notification store — ephemeral toasts + persistent notification history -export type NotificationType = 'info' | 'success' | 'warning' | 'error'; +import { sendDesktopNotification } from '../adapters/notifications-bridge'; -export interface Notification { +// --- Toast types (existing) --- + +export type ToastType = 'info' | 'success' | 'warning' | 'error'; + +export interface Toast { id: string; - type: NotificationType; + type: ToastType; message: string; timestamp: number; } -let notifications = $state([]); +// --- Notification history types (new) --- + +export type NotificationType = + | 'agent_complete' + | 'agent_error' + | 'task_review' + | 'wake_event' + | 'conflict' + | 'system'; + +export interface HistoryNotification { + id: string; + title: string; + body: string; + type: NotificationType; + timestamp: number; + read: boolean; + projectId?: string; +} + +// --- State --- + +let toasts = $state([]); +let notificationHistory = $state([]); const MAX_TOASTS = 5; const TOAST_DURATION_MS = 4000; +const MAX_HISTORY = 100; -export function getNotifications(): Notification[] { - return notifications; +// --- Toast API (preserved from original) --- + +export function getNotifications(): Toast[] { + return toasts; } -export function notify(type: NotificationType, message: string): string { +export function notify(type: ToastType, message: string): string { const id = crypto.randomUUID(); - notifications.push({ id, type, message, timestamp: Date.now() }); + toasts.push({ id, type, message, timestamp: Date.now() }); // Cap visible toasts - if (notifications.length > MAX_TOASTS) { - notifications = notifications.slice(-MAX_TOASTS); + if (toasts.length > MAX_TOASTS) { + toasts = toasts.slice(-MAX_TOASTS); } // Auto-dismiss @@ -34,5 +64,89 @@ export function notify(type: NotificationType, message: string): string { } export function dismissNotification(id: string): void { - notifications = notifications.filter(n => n.id !== id); + toasts = toasts.filter(n => n.id !== id); +} + +// --- Notification History API (new) --- + +/** Map NotificationType to a toast type for the ephemeral toast */ +function notificationTypeToToast(type: NotificationType): ToastType { + switch (type) { + case 'agent_complete': return 'success'; + case 'agent_error': return 'error'; + case 'task_review': return 'info'; + case 'wake_event': return 'info'; + case 'conflict': return 'warning'; + case 'system': return 'info'; + } +} + +/** Map NotificationType to OS notification urgency */ +function notificationUrgency(type: NotificationType): 'low' | 'normal' | 'critical' { + switch (type) { + case 'agent_error': return 'critical'; + case 'conflict': return 'normal'; + case 'system': return 'normal'; + default: return 'low'; + } +} + +/** + * Add a notification to history, show a toast, and send an OS desktop notification. + */ +export function addNotification( + title: string, + body: string, + type: NotificationType, + projectId?: string, +): string { + const id = crypto.randomUUID(); + + // Add to history + notificationHistory.push({ + id, + title, + body, + type, + timestamp: Date.now(), + read: false, + projectId, + }); + + // Cap history + if (notificationHistory.length > MAX_HISTORY) { + notificationHistory = notificationHistory.slice(-MAX_HISTORY); + } + + // Show ephemeral toast + const toastType = notificationTypeToToast(type); + notify(toastType, `${title}: ${body}`); + + // Send OS desktop notification (fire-and-forget) + sendDesktopNotification(title, body, notificationUrgency(type)); + + return id; +} + +export function getNotificationHistory(): HistoryNotification[] { + return notificationHistory; +} + +export function getUnreadCount(): number { + return notificationHistory.filter(n => !n.read).length; +} + +export function markRead(id: string): void { + const entry = notificationHistory.find(n => n.id === id); + if (entry) entry.read = true; +} + +export function markAllRead(): void { + for (const entry of notificationHistory) { + entry.read = true; + } +} + +export function clearHistory(): void { + notificationHistory = []; }