feat: add configurable web native notifications for assistant completion (#123)
This commit is contained in:
@@ -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<NotificationPermission>('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 (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="typography-ui-header font-semibold text-foreground">
|
||||||
|
Native Notifications
|
||||||
|
</h3>
|
||||||
|
<p className="typography-ui text-muted-foreground">
|
||||||
|
Show browser notifications when an assistant completes a task.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="typography-ui text-foreground">
|
||||||
|
Enable native notifications
|
||||||
|
</span>
|
||||||
|
<label className="relative inline-flex items-center cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={nativeNotificationsEnabled && canShowNotifications}
|
||||||
|
onChange={(e) => handleToggleChange(e.target.checked)}
|
||||||
|
className="sr-only peer"
|
||||||
|
/>
|
||||||
|
<div className="w-11 h-6 bg-neutral-200 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-primary/50 dark:peer-focus:ring-primary/50 rounded-full peer dark:bg-neutral-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-neutral-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-neutral-600 peer-checked:bg-primary" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{notificationPermission === 'denied' && (
|
||||||
|
<p className="typography-micro text-destructive">
|
||||||
|
Notification permission denied. Enable notifications in your browser settings.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{notificationPermission === 'granted' && !nativeNotificationsEnabled && (
|
||||||
|
<p className="typography-micro text-muted-foreground">
|
||||||
|
Notifications are enabled in your browser. Toggle the switch above to activate them.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -4,6 +4,7 @@ import { AboutSettings } from './AboutSettings';
|
|||||||
import { SessionRetentionSettings } from './SessionRetentionSettings';
|
import { SessionRetentionSettings } from './SessionRetentionSettings';
|
||||||
import { DefaultsSettings } from './DefaultsSettings';
|
import { DefaultsSettings } from './DefaultsSettings';
|
||||||
import { WorktreeSectionContent } from './WorktreeSectionContent';
|
import { WorktreeSectionContent } from './WorktreeSectionContent';
|
||||||
|
import { NotificationSettings } from './NotificationSettings';
|
||||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||||
import { useDeviceInfo } from '@/lib/device';
|
import { useDeviceInfo } from '@/lib/device';
|
||||||
import { isWebRuntime } from '@/lib/desktop';
|
import { isWebRuntime } from '@/lib/desktop';
|
||||||
@@ -53,6 +54,8 @@ export const OpenChamberPage: React.FC<OpenChamberPageProps> = ({ section }) =>
|
|||||||
return <SessionsSectionContent />;
|
return <SessionsSectionContent />;
|
||||||
case 'worktree':
|
case 'worktree':
|
||||||
return <WorktreeSectionContent />;
|
return <WorktreeSectionContent />;
|
||||||
|
case 'notifications':
|
||||||
|
return <NotificationSectionContent />;
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -90,3 +93,8 @@ const SessionsSectionContent: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Notifications section: Native browser notifications
|
||||||
|
const NotificationSectionContent: React.FC = () => {
|
||||||
|
return <NotificationSettings />;
|
||||||
|
};
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { isVSCodeRuntime, isWebRuntime } from '@/lib/desktop';
|
|||||||
import { AboutSettings } from './AboutSettings';
|
import { AboutSettings } from './AboutSettings';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
export type OpenChamberSection = 'visual' | 'chat' | 'sessions' | 'worktree';
|
export type OpenChamberSection = 'visual' | 'chat' | 'sessions' | 'worktree' | 'notifications';
|
||||||
|
|
||||||
interface OpenChamberSidebarProps {
|
interface OpenChamberSidebarProps {
|
||||||
selectedSection: OpenChamberSection;
|
selectedSection: OpenChamberSection;
|
||||||
@@ -39,6 +39,11 @@ const OPENCHAMBER_SECTION_GROUPS: SectionGroup[] = [
|
|||||||
label: 'Worktree',
|
label: 'Worktree',
|
||||||
items: ['Branch', 'Setup'],
|
items: ['Branch', 'Setup'],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'notifications',
|
||||||
|
label: 'Notifications',
|
||||||
|
items: ['Native'],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const OpenChamberSidebar: React.FC<OpenChamberSidebarProps> = ({
|
export const OpenChamberSidebar: React.FC<OpenChamberSidebarProps> = ({
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import { useProjectsStore } from '@/stores/useProjectsStore';
|
|||||||
import { streamDebugEnabled } from '@/stores/utils/streamDebug';
|
import { streamDebugEnabled } from '@/stores/utils/streamDebug';
|
||||||
import { handleTodoUpdatedEvent } from '@/stores/useTodoStore';
|
import { handleTodoUpdatedEvent } from '@/stores/useTodoStore';
|
||||||
import { useMcpStore } from '@/stores/useMcpStore';
|
import { useMcpStore } from '@/stores/useMcpStore';
|
||||||
|
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||||
|
import { isWebRuntime } from '@/lib/desktop';
|
||||||
|
|
||||||
interface EventData {
|
interface EventData {
|
||||||
type: string;
|
type: string;
|
||||||
@@ -85,6 +87,36 @@ const getMessageFromStore = (sessionId: string, messageId: string): { info: Mess
|
|||||||
return 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 = () => {
|
export const useEventStream = () => {
|
||||||
const {
|
const {
|
||||||
addStreamingPart,
|
addStreamingPart,
|
||||||
@@ -105,6 +137,7 @@ export const useEventStream = () => {
|
|||||||
} = useSessionStore();
|
} = useSessionStore();
|
||||||
|
|
||||||
const { checkConnection } = useConfigStore();
|
const { checkConnection } = useConfigStore();
|
||||||
|
const nativeNotificationsEnabled = useUIStore((state) => state.nativeNotificationsEnabled);
|
||||||
const fallbackDirectory = useDirectoryStore((state) => state.currentDirectory);
|
const fallbackDirectory = useDirectoryStore((state) => state.currentDirectory);
|
||||||
|
|
||||||
const activeSessionDirectory = React.useMemo(() => {
|
const activeSessionDirectory = React.useMemo(() => {
|
||||||
@@ -307,6 +340,7 @@ export const useEventStream = () => {
|
|||||||
const lastResyncAtRef = React.useRef(0);
|
const lastResyncAtRef = React.useRef(0);
|
||||||
const permissionToastShownRef = React.useRef<Set<string>>(new Set());
|
const permissionToastShownRef = React.useRef<Set<string>>(new Set());
|
||||||
const questionToastShownRef = React.useRef<Set<string>>(new Set());
|
const questionToastShownRef = React.useRef<Set<string>>(new Set());
|
||||||
|
const notifiedMessagesRef = React.useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
const resolveVisibilityState = React.useCallback((): 'visible' | 'hidden' => {
|
const resolveVisibilityState = React.useCallback((): 'visible' | 'hidden' => {
|
||||||
if (typeof document === 'undefined') return 'visible';
|
if (typeof document === 'undefined') return 'visible';
|
||||||
@@ -1014,6 +1048,26 @@ export const useEventStream = () => {
|
|||||||
|
|
||||||
completeStreamingMessage(sessionId, messageId);
|
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"
|
// For web/vscode: trigger cooldown only when assistant message has finish === "stop"
|
||||||
// to match desktop backend semantics.
|
// to match desktop backend semantics.
|
||||||
if (!isDesktopRuntimeRef.current) {
|
if (!isDesktopRuntimeRef.current) {
|
||||||
@@ -1657,6 +1711,7 @@ export const useEventStream = () => {
|
|||||||
cooldownTimers.forEach((timer) => clearTimeout(timer));
|
cooldownTimers.forEach((timer) => clearTimeout(timer));
|
||||||
cooldownTimers.clear();
|
cooldownTimers.clear();
|
||||||
messageCache.clear();
|
messageCache.clear();
|
||||||
|
notifiedMessagesRef.current.clear();
|
||||||
|
|
||||||
pendingResumeRef.current = false;
|
pendingResumeRef.current = false;
|
||||||
visibilityStateRef.current = resolveVisibilityState();
|
visibilityStateRef.current = resolveVisibilityState();
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ interface UIStore {
|
|||||||
diffFileLayout: Record<string, 'inline' | 'side-by-side'>;
|
diffFileLayout: Record<string, 'inline' | 'side-by-side'>;
|
||||||
diffWrapLines: boolean;
|
diffWrapLines: boolean;
|
||||||
isTimelineDialogOpen: boolean;
|
isTimelineDialogOpen: boolean;
|
||||||
|
nativeNotificationsEnabled: boolean;
|
||||||
|
|
||||||
setTheme: (theme: 'light' | 'dark' | 'system') => void;
|
setTheme: (theme: 'light' | 'dark' | 'system') => void;
|
||||||
toggleSidebar: () => void;
|
toggleSidebar: () => void;
|
||||||
@@ -95,6 +96,7 @@ interface UIStore {
|
|||||||
setDiffWrapLines: (wrap: boolean) => void;
|
setDiffWrapLines: (wrap: boolean) => void;
|
||||||
setMultiRunLauncherOpen: (open: boolean) => void;
|
setMultiRunLauncherOpen: (open: boolean) => void;
|
||||||
setTimelineDialogOpen: (open: boolean) => void;
|
setTimelineDialogOpen: (open: boolean) => void;
|
||||||
|
setNativeNotificationsEnabled: (value: boolean) => void;
|
||||||
openMultiRunLauncher: () => void;
|
openMultiRunLauncher: () => void;
|
||||||
openMultiRunLauncherWithPrompt: (prompt: string) => void;
|
openMultiRunLauncherWithPrompt: (prompt: string) => void;
|
||||||
}
|
}
|
||||||
@@ -138,6 +140,7 @@ export const useUIStore = create<UIStore>()(
|
|||||||
diffFileLayout: {},
|
diffFileLayout: {},
|
||||||
diffWrapLines: false,
|
diffWrapLines: false,
|
||||||
isTimelineDialogOpen: false,
|
isTimelineDialogOpen: false,
|
||||||
|
nativeNotificationsEnabled: false,
|
||||||
|
|
||||||
setTheme: (theme) => {
|
setTheme: (theme) => {
|
||||||
set({ theme });
|
set({ theme });
|
||||||
@@ -465,6 +468,10 @@ export const useUIStore = create<UIStore>()(
|
|||||||
setTimelineDialogOpen: (open) => {
|
setTimelineDialogOpen: (open) => {
|
||||||
set({ isTimelineDialogOpen: open });
|
set({ isTimelineDialogOpen: open });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setNativeNotificationsEnabled: (value) => {
|
||||||
|
set({ nativeNotificationsEnabled: value });
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: 'ui-store',
|
name: 'ui-store',
|
||||||
@@ -489,6 +496,7 @@ export const useUIStore = create<UIStore>()(
|
|||||||
recentModels: state.recentModels,
|
recentModels: state.recentModels,
|
||||||
diffLayoutPreference: state.diffLayoutPreference,
|
diffLayoutPreference: state.diffLayoutPreference,
|
||||||
diffWrapLines: state.diffWrapLines,
|
diffWrapLines: state.diffWrapLines,
|
||||||
|
nativeNotificationsEnabled: state.nativeNotificationsEnabled,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in New Issue
Block a user