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}
+
+
+
+
+
+ {#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 = [];
}