From e09559f711463fcee32991ef17ecd1d10d2dfdc7 Mon Sep 17 00:00:00 2001 From: vio1ator Date: Sun, 11 Jan 2026 21:21:13 +0800 Subject: [PATCH] feat: add configurable web native notifications for assistant completion (#123) --- .../openchamber/NotificationSettings.tsx | 87 +++++++++++++++++++ .../sections/openchamber/OpenChamberPage.tsx | 8 ++ .../openchamber/OpenChamberSidebar.tsx | 7 +- packages/ui/src/hooks/useEventStream.ts | 57 +++++++++++- packages/ui/src/stores/useUIStore.ts | 8 ++ 5 files changed, 165 insertions(+), 2 deletions(-) create mode 100644 packages/ui/src/components/sections/openchamber/NotificationSettings.tsx diff --git a/packages/ui/src/components/sections/openchamber/NotificationSettings.tsx b/packages/ui/src/components/sections/openchamber/NotificationSettings.tsx new file mode 100644 index 00000000..2b0b9b34 --- /dev/null +++ b/packages/ui/src/components/sections/openchamber/NotificationSettings.tsx @@ -0,0 +1,87 @@ +import React from 'react'; +import { useUIStore } from '@/stores/useUIStore'; +import { isWebRuntime } from '@/lib/desktop'; +import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; +import { toast } from 'sonner'; + +export const NotificationSettings: React.FC = () => { + const nativeNotificationsEnabled = useUIStore(state => state.nativeNotificationsEnabled); + const setNativeNotificationsEnabled = useUIStore(state => state.setNativeNotificationsEnabled); + + const [notificationPermission, setNotificationPermission] = React.useState('default'); + + React.useEffect(() => { + if (typeof Notification !== 'undefined') { + setNotificationPermission(Notification.permission); + } + }, []); + + const handleToggleChange = async (checked: boolean) => { + if (checked && typeof Notification !== 'undefined' && Notification.permission === 'default') { + try { + const permission = await Notification.requestPermission(); + setNotificationPermission(permission); + if (permission === 'granted') { + setNativeNotificationsEnabled(true); + } else { + toast.error('Notification permission denied', { + description: 'Please enable notifications in your browser settings.', + }); + } + } catch (error) { + console.error('Failed to request notification permission:', error); + toast.error('Failed to request notification permission'); + } + } else if (checked && notificationPermission === 'granted') { + setNativeNotificationsEnabled(true); + } else { + setNativeNotificationsEnabled(false); + } + }; + + const canShowNotifications = typeof Notification !== 'undefined' && Notification.permission === 'granted'; + + if (!isWebRuntime()) { + return null; + } + + return ( +
+
+

+ Native Notifications +

+

+ Show browser notifications when an assistant completes a task. +

+
+ +
+ + Enable native notifications + +
+ ); +}; diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx index 882404ff..56b7542a 100644 --- a/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx @@ -4,6 +4,7 @@ import { AboutSettings } from './AboutSettings'; import { SessionRetentionSettings } from './SessionRetentionSettings'; import { DefaultsSettings } from './DefaultsSettings'; import { WorktreeSectionContent } from './WorktreeSectionContent'; +import { NotificationSettings } from './NotificationSettings'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { useDeviceInfo } from '@/lib/device'; import { isWebRuntime } from '@/lib/desktop'; @@ -53,6 +54,8 @@ export const OpenChamberPage: React.FC = ({ section }) => return ; case 'worktree': return ; + case 'notifications': + return ; default: return null; } @@ -90,3 +93,8 @@ const SessionsSectionContent: React.FC = () => {
); }; + +// Notifications section: Native browser notifications +const NotificationSectionContent: React.FC = () => { + return ; +}; diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberSidebar.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberSidebar.tsx index 1e47b200..fec6b035 100644 --- a/packages/ui/src/components/sections/openchamber/OpenChamberSidebar.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenChamberSidebar.tsx @@ -5,7 +5,7 @@ import { isVSCodeRuntime, isWebRuntime } from '@/lib/desktop'; import { AboutSettings } from './AboutSettings'; import { cn } from '@/lib/utils'; -export type OpenChamberSection = 'visual' | 'chat' | 'sessions' | 'worktree'; +export type OpenChamberSection = 'visual' | 'chat' | 'sessions' | 'worktree' | 'notifications'; interface OpenChamberSidebarProps { selectedSection: OpenChamberSection; @@ -39,6 +39,11 @@ const OPENCHAMBER_SECTION_GROUPS: SectionGroup[] = [ label: 'Worktree', items: ['Branch', 'Setup'], }, + { + id: 'notifications', + label: 'Notifications', + items: ['Native'], + }, ]; export const OpenChamberSidebar: React.FC = ({ diff --git a/packages/ui/src/hooks/useEventStream.ts b/packages/ui/src/hooks/useEventStream.ts index 58dce569..29194631 100644 --- a/packages/ui/src/hooks/useEventStream.ts +++ b/packages/ui/src/hooks/useEventStream.ts @@ -12,6 +12,8 @@ import { useProjectsStore } from '@/stores/useProjectsStore'; import { streamDebugEnabled } from '@/stores/utils/streamDebug'; import { handleTodoUpdatedEvent } from '@/stores/useTodoStore'; import { useMcpStore } from '@/stores/useMcpStore'; +import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; +import { isWebRuntime } from '@/lib/desktop'; interface EventData { type: string; @@ -80,11 +82,41 @@ const getMessageFromStore = (sessionId: string, messageId: string): { info: Mess const storeState = useSessionStore.getState(); const sessionMessages = storeState.messages.get(sessionId) || []; const message = sessionMessages.find(m => m.info.id === messageId) || null; - + messageCache.set(cacheKey, { sessionId, message }); return message; }; +const formatModelID = (raw: string): string => { + if (!raw) { + return 'Assistant'; + } + + const tokens: string[] = raw.split(/[-_]/); + const result: string[] = []; + let i = 0; + + while (i < tokens.length) { + const current = tokens[i]; + + if (/^\d+$/.test(current)) { + if (i + 1 < tokens.length && /^\d+$/.test(tokens[i + 1])) { + const combined = `${current}.${tokens[i + 1]}`; + result.push(combined); + i += 2; + continue; + } + } + + result.push(current); + i += 1; + } + + return result + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' '); +}; + export const useEventStream = () => { const { addStreamingPart, @@ -105,6 +137,7 @@ export const useEventStream = () => { } = useSessionStore(); const { checkConnection } = useConfigStore(); + const nativeNotificationsEnabled = useUIStore((state) => state.nativeNotificationsEnabled); const fallbackDirectory = useDirectoryStore((state) => state.currentDirectory); const activeSessionDirectory = React.useMemo(() => { @@ -307,6 +340,7 @@ export const useEventStream = () => { const lastResyncAtRef = React.useRef(0); const permissionToastShownRef = React.useRef>(new Set()); const questionToastShownRef = React.useRef>(new Set()); + const notifiedMessagesRef = React.useRef>(new Set()); const resolveVisibilityState = React.useCallback((): 'visible' | 'hidden' => { if (typeof document === 'undefined') return 'visible'; @@ -1014,6 +1048,26 @@ export const useEventStream = () => { completeStreamingMessage(sessionId, messageId); + if (isWebRuntime() && nativeNotificationsEnabled) { + const notifiedMessages = notifiedMessagesRef.current; + + if (!notifiedMessages.has(messageId)) { + notifiedMessages.add(messageId); + + const runtimeAPIs = getRegisteredRuntimeAPIs(); + + if (runtimeAPIs?.notifications) { + const rawMode = (messageExt as { mode?: string }).mode || 'agent'; + const rawModel = (messageExt as { modelID?: string }).modelID || 'assistant'; + + const title = `${rawMode.charAt(0).toUpperCase() + rawMode.slice(1)} agent is ready`; + const body = `${formatModelID(rawModel)} completed the task`; + + void runtimeAPIs.notifications.notifyAgentCompletion({ title, body, tag: messageId }); + } + } + } + // For web/vscode: trigger cooldown only when assistant message has finish === "stop" // to match desktop backend semantics. if (!isDesktopRuntimeRef.current) { @@ -1657,6 +1711,7 @@ export const useEventStream = () => { cooldownTimers.forEach((timer) => clearTimeout(timer)); cooldownTimers.clear(); messageCache.clear(); + notifiedMessagesRef.current.clear(); pendingResumeRef.current = false; visibilityStateRef.current = resolveVisibilityState(); diff --git a/packages/ui/src/stores/useUIStore.ts b/packages/ui/src/stores/useUIStore.ts index 4417fd23..bdc0ba1d 100644 --- a/packages/ui/src/stores/useUIStore.ts +++ b/packages/ui/src/stores/useUIStore.ts @@ -53,6 +53,7 @@ interface UIStore { diffFileLayout: Record; diffWrapLines: boolean; isTimelineDialogOpen: boolean; + nativeNotificationsEnabled: boolean; setTheme: (theme: 'light' | 'dark' | 'system') => void; toggleSidebar: () => void; @@ -95,6 +96,7 @@ interface UIStore { setDiffWrapLines: (wrap: boolean) => void; setMultiRunLauncherOpen: (open: boolean) => void; setTimelineDialogOpen: (open: boolean) => void; + setNativeNotificationsEnabled: (value: boolean) => void; openMultiRunLauncher: () => void; openMultiRunLauncherWithPrompt: (prompt: string) => void; } @@ -138,6 +140,7 @@ export const useUIStore = create()( diffFileLayout: {}, diffWrapLines: false, isTimelineDialogOpen: false, + nativeNotificationsEnabled: false, setTheme: (theme) => { set({ theme }); @@ -465,6 +468,10 @@ export const useUIStore = create()( setTimelineDialogOpen: (open) => { set({ isTimelineDialogOpen: open }); }, + + setNativeNotificationsEnabled: (value) => { + set({ nativeNotificationsEnabled: value }); + }, }), { name: 'ui-store', @@ -489,6 +496,7 @@ export const useUIStore = create()( recentModels: state.recentModels, diffLayoutPreference: state.diffLayoutPreference, diffWrapLines: state.diffWrapLines, + nativeNotificationsEnabled: state.nativeNotificationsEnabled, }) } ),