feat: add notification mode settings and enhance notification handling

This commit is contained in:
Bohdan Triapitsyn
2026-01-14 01:03:26 +02:00
parent 0d43725802
commit b84c17e0e2
6 changed files with 66 additions and 22 deletions
+7
View File
@@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file.
## [Unreleased] ## [Unreleased]
- Settings: consolidated Git settings and added opencode zen model selection for commit generation (thanks to @nelsonpires).
- Web Notifications: added configurable native web notifications for assistant completion (thanks to @vio1ator).
- Chat: sidebar sessions are now automatically sorted by last updated date (thanks to @vio1ator).
- UI: todo lists and status indicators now hide automatically when all tasks are completed (thanks to @vio1ator).
- Reliability: improved project state preservation on validation failures (thanks to @vio1ator) and refined server health monitoring.
- Stability: added graceful shutdown handling for the server process (thanks to @vio1ator).
## [1.4.7] - 2026-01-10 ## [1.4.7] - 2026-01-10
- Skills: added ClawdHub integration as built-in market for skills. - Skills: added ClawdHub integration as built-in market for skills.
@@ -23,7 +23,6 @@ import {
formatEditOutput, formatEditOutput,
detectLanguageFromOutput, detectLanguageFromOutput,
formatInputForDisplay, formatInputForDisplay,
hasLspDiagnostics,
} from '../toolRenderers'; } from '../toolRenderers';
type ToolStateWithMetadata = ToolStateUnion & { metadata?: Record<string, unknown>; input?: Record<string, unknown>; output?: string; error?: string; time?: { start: number; end?: number } }; type ToolStateWithMetadata = ToolStateUnion & { metadata?: Record<string, unknown>; input?: Record<string, unknown>; output?: string; error?: string; time?: { start: number; end?: number } };
@@ -756,7 +755,7 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = ({
); );
} }
if ((part.tool === 'edit' || part.tool === 'multiedit') && ((!hasStringOutput && diffContent) || (outputString.trim().length === 0 || hasLspDiagnostics(outputString))) && diffContent) { if ((part.tool === 'edit' || part.tool === 'multiedit') && diffContent) {
return renderScrollableBlock( return renderScrollableBlock(
<DiffPreview diff={diffContent} syntaxTheme={syntaxTheme} input={input} />, <DiffPreview diff={diffContent} syntaxTheme={syntaxTheme} input={input} />,
{ className: 'p-1' } { className: 'p-1' }
@@ -1,11 +1,14 @@
import React from 'react'; import React from 'react';
import { useUIStore } from '@/stores/useUIStore'; import { useUIStore } from '@/stores/useUIStore';
import { isWebRuntime } from '@/lib/desktop'; import { isWebRuntime } from '@/lib/desktop';
import { Switch } from '@/components/ui/switch';
import { toast } from 'sonner'; import { toast } from 'sonner';
export const NotificationSettings: React.FC = () => { export const NotificationSettings: React.FC = () => {
const nativeNotificationsEnabled = useUIStore(state => state.nativeNotificationsEnabled); const nativeNotificationsEnabled = useUIStore(state => state.nativeNotificationsEnabled);
const setNativeNotificationsEnabled = useUIStore(state => state.setNativeNotificationsEnabled); const setNativeNotificationsEnabled = useUIStore(state => state.setNativeNotificationsEnabled);
const notificationMode = useUIStore(state => state.notificationMode);
const setNotificationMode = useUIStore(state => state.setNotificationMode);
const [notificationPermission, setNotificationPermission] = React.useState<NotificationPermission>('default'); const [notificationPermission, setNotificationPermission] = React.useState<NotificationPermission>('default');
@@ -59,17 +62,31 @@ export const NotificationSettings: React.FC = () => {
<span className="typography-ui text-foreground"> <span className="typography-ui text-foreground">
Enable native notifications Enable native notifications
</span> </span>
<label className="relative inline-flex items-center cursor-pointer"> <Switch
<input checked={nativeNotificationsEnabled && canShowNotifications}
type="checkbox" onCheckedChange={handleToggleChange}
checked={nativeNotificationsEnabled && canShowNotifications} className="data-[state=checked]:bg-status-info"
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> </div>
{nativeNotificationsEnabled && canShowNotifications && (
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<span className="typography-ui text-foreground">
Always notify
</span>
<p className="typography-micro text-muted-foreground">
When off, only notifies if the window is out of focus.
</p>
</div>
<Switch
checked={notificationMode === 'always'}
onCheckedChange={(checked) => setNotificationMode(checked ? 'always' : 'hidden-only')}
className="data-[state=checked]:bg-status-info"
/>
</div>
)}
{notificationPermission === 'denied' && ( {notificationPermission === 'denied' && (
<p className="typography-micro text-destructive"> <p className="typography-micro text-destructive">
Notification permission denied. Enable notifications in your browser settings. Notification permission denied. Enable notifications in your browser settings.
@@ -16,6 +16,7 @@ interface SectionGroup {
id: OpenChamberSection; id: OpenChamberSection;
label: string; label: string;
items: string[]; items: string[];
webOnly?: boolean;
} }
const OPENCHAMBER_SECTION_GROUPS: SectionGroup[] = [ const OPENCHAMBER_SECTION_GROUPS: SectionGroup[] = [
@@ -43,6 +44,7 @@ const OPENCHAMBER_SECTION_GROUPS: SectionGroup[] = [
id: 'notifications', id: 'notifications',
label: 'Notifications', label: 'Notifications',
items: ['Native'], items: ['Native'],
webOnly: true,
}, },
]; ];
@@ -59,12 +61,17 @@ export const OpenChamberSidebar: React.FC<OpenChamberSidebarProps> = ({
}); });
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []); const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
const isWeb = React.useMemo(() => isWebRuntime(), []);
React.useEffect(() => { React.useEffect(() => {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
setIsDesktopRuntime(typeof window.opencodeDesktop !== 'undefined'); setIsDesktopRuntime(typeof window.opencodeDesktop !== 'undefined');
}, []); }, []);
const visibleSections = React.useMemo(() => {
return OPENCHAMBER_SECTION_GROUPS.filter((group) => !group.webOnly || isWeb);
}, [isWeb]);
// Desktop app: transparent for blur effect // Desktop app: transparent for blur effect
// VS Code: bg-background (same as page content) // VS Code: bg-background (same as page content)
// Web/mobile: bg-sidebar // Web/mobile: bg-sidebar
@@ -77,7 +84,7 @@ export const OpenChamberSidebar: React.FC<OpenChamberSidebarProps> = ({
return ( return (
<div className={cn('flex h-full flex-col', bgClass)}> <div className={cn('flex h-full flex-col', bgClass)}>
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="space-y-1 px-3 py-2 overflow-x-hidden"> <ScrollableOverlay outerClassName="flex-1 min-h-0" className="space-y-1 px-3 py-2 overflow-x-hidden">
{OPENCHAMBER_SECTION_GROUPS.map((group) => { {visibleSections.map((group) => {
const isSelected = selectedSection === group.id; const isSelected = selectedSection === group.id;
return ( return (
<div <div
+16 -10
View File
@@ -138,6 +138,7 @@ export const useEventStream = () => {
const { checkConnection } = useConfigStore(); const { checkConnection } = useConfigStore();
const nativeNotificationsEnabled = useUIStore((state) => state.nativeNotificationsEnabled); const nativeNotificationsEnabled = useUIStore((state) => state.nativeNotificationsEnabled);
const notificationMode = useUIStore((state) => state.notificationMode);
const fallbackDirectory = useDirectoryStore((state) => state.currentDirectory); const fallbackDirectory = useDirectoryStore((state) => state.currentDirectory);
const activeSessionDirectory = React.useMemo(() => { const activeSessionDirectory = React.useMemo(() => {
@@ -1050,21 +1051,25 @@ export const useEventStream = () => {
// Only notify when entire message is finished (finish === 'stop') // Only notify when entire message is finished (finish === 'stop')
if (finish === 'stop' && isWebRuntime() && nativeNotificationsEnabled) { if (finish === 'stop' && isWebRuntime() && nativeNotificationsEnabled) {
const notifiedMessages = notifiedMessagesRef.current; const shouldNotify = notificationMode === 'always' || visibilityStateRef.current === 'hidden';
if (!notifiedMessages.has(messageId)) { if (shouldNotify) {
notifiedMessages.add(messageId); const notifiedMessages = notifiedMessagesRef.current;
const runtimeAPIs = getRegisteredRuntimeAPIs(); if (!notifiedMessages.has(messageId)) {
notifiedMessages.add(messageId);
if (runtimeAPIs?.notifications) { const runtimeAPIs = getRegisteredRuntimeAPIs();
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`; if (runtimeAPIs?.notifications) {
const body = `${formatModelID(rawModel)} completed the task`; const rawMode = (messageExt as { mode?: string }).mode || 'agent';
const rawModel = (messageExt as { modelID?: string }).modelID || 'assistant';
void runtimeAPIs.notifications.notifyAgentCompletion({ title, body, tag: messageId }); 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 });
}
} }
} }
} }
@@ -1290,6 +1295,7 @@ export const useEventStream = () => {
}, [ }, [
currentSessionId, currentSessionId,
nativeNotificationsEnabled, nativeNotificationsEnabled,
notificationMode,
addStreamingPart, addStreamingPart,
completeStreamingMessage, completeStreamingMessage,
updateMessageInfo, updateMessageInfo,
+8
View File
@@ -54,6 +54,7 @@ interface UIStore {
diffWrapLines: boolean; diffWrapLines: boolean;
isTimelineDialogOpen: boolean; isTimelineDialogOpen: boolean;
nativeNotificationsEnabled: boolean; nativeNotificationsEnabled: boolean;
notificationMode: 'always' | 'hidden-only';
setTheme: (theme: 'light' | 'dark' | 'system') => void; setTheme: (theme: 'light' | 'dark' | 'system') => void;
toggleSidebar: () => void; toggleSidebar: () => void;
@@ -97,6 +98,7 @@ interface UIStore {
setMultiRunLauncherOpen: (open: boolean) => void; setMultiRunLauncherOpen: (open: boolean) => void;
setTimelineDialogOpen: (open: boolean) => void; setTimelineDialogOpen: (open: boolean) => void;
setNativeNotificationsEnabled: (value: boolean) => void; setNativeNotificationsEnabled: (value: boolean) => void;
setNotificationMode: (mode: 'always' | 'hidden-only') => void;
openMultiRunLauncher: () => void; openMultiRunLauncher: () => void;
openMultiRunLauncherWithPrompt: (prompt: string) => void; openMultiRunLauncherWithPrompt: (prompt: string) => void;
} }
@@ -141,6 +143,7 @@ export const useUIStore = create<UIStore>()(
diffWrapLines: false, diffWrapLines: false,
isTimelineDialogOpen: false, isTimelineDialogOpen: false,
nativeNotificationsEnabled: false, nativeNotificationsEnabled: false,
notificationMode: 'hidden-only',
setTheme: (theme) => { setTheme: (theme) => {
set({ theme }); set({ theme });
@@ -472,6 +475,10 @@ export const useUIStore = create<UIStore>()(
setNativeNotificationsEnabled: (value) => { setNativeNotificationsEnabled: (value) => {
set({ nativeNotificationsEnabled: value }); set({ nativeNotificationsEnabled: value });
}, },
setNotificationMode: (mode) => {
set({ notificationMode: mode });
},
}), }),
{ {
name: 'ui-store', name: 'ui-store',
@@ -497,6 +504,7 @@ export const useUIStore = create<UIStore>()(
diffLayoutPreference: state.diffLayoutPreference, diffLayoutPreference: state.diffLayoutPreference,
diffWrapLines: state.diffWrapLines, diffWrapLines: state.diffWrapLines,
nativeNotificationsEnabled: state.nativeNotificationsEnabled, nativeNotificationsEnabled: state.nativeNotificationsEnabled,
notificationMode: state.notificationMode,
}) })
} }
), ),