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]
- 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
- Skills: added ClawdHub integration as built-in market for skills.
@@ -23,7 +23,6 @@ import {
formatEditOutput,
detectLanguageFromOutput,
formatInputForDisplay,
hasLspDiagnostics,
} from '../toolRenderers';
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(
<DiffPreview diff={diffContent} syntaxTheme={syntaxTheme} input={input} />,
{ className: 'p-1' }
@@ -1,11 +1,14 @@
import React from 'react';
import { useUIStore } from '@/stores/useUIStore';
import { isWebRuntime } from '@/lib/desktop';
import { Switch } from '@/components/ui/switch';
import { toast } from 'sonner';
export const NotificationSettings: React.FC = () => {
const nativeNotificationsEnabled = useUIStore(state => state.nativeNotificationsEnabled);
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');
@@ -59,17 +62,31 @@ export const NotificationSettings: React.FC = () => {
<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>
<Switch
checked={nativeNotificationsEnabled && canShowNotifications}
onCheckedChange={handleToggleChange}
className="data-[state=checked]:bg-status-info"
/>
</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' && (
<p className="typography-micro text-destructive">
Notification permission denied. Enable notifications in your browser settings.
@@ -16,6 +16,7 @@ interface SectionGroup {
id: OpenChamberSection;
label: string;
items: string[];
webOnly?: boolean;
}
const OPENCHAMBER_SECTION_GROUPS: SectionGroup[] = [
@@ -43,6 +44,7 @@ const OPENCHAMBER_SECTION_GROUPS: SectionGroup[] = [
id: 'notifications',
label: 'Notifications',
items: ['Native'],
webOnly: true,
},
];
@@ -59,12 +61,17 @@ export const OpenChamberSidebar: React.FC<OpenChamberSidebarProps> = ({
});
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
const isWeb = React.useMemo(() => isWebRuntime(), []);
React.useEffect(() => {
if (typeof window === 'undefined') return;
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
// VS Code: bg-background (same as page content)
// Web/mobile: bg-sidebar
@@ -77,7 +84,7 @@ export const OpenChamberSidebar: React.FC<OpenChamberSidebarProps> = ({
return (
<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">
{OPENCHAMBER_SECTION_GROUPS.map((group) => {
{visibleSections.map((group) => {
const isSelected = selectedSection === group.id;
return (
<div
+16 -10
View File
@@ -138,6 +138,7 @@ export const useEventStream = () => {
const { checkConnection } = useConfigStore();
const nativeNotificationsEnabled = useUIStore((state) => state.nativeNotificationsEnabled);
const notificationMode = useUIStore((state) => state.notificationMode);
const fallbackDirectory = useDirectoryStore((state) => state.currentDirectory);
const activeSessionDirectory = React.useMemo(() => {
@@ -1050,21 +1051,25 @@ export const useEventStream = () => {
// Only notify when entire message is finished (finish === 'stop')
if (finish === 'stop' && isWebRuntime() && nativeNotificationsEnabled) {
const notifiedMessages = notifiedMessagesRef.current;
const shouldNotify = notificationMode === 'always' || visibilityStateRef.current === 'hidden';
if (!notifiedMessages.has(messageId)) {
notifiedMessages.add(messageId);
if (shouldNotify) {
const notifiedMessages = notifiedMessagesRef.current;
const runtimeAPIs = getRegisteredRuntimeAPIs();
if (!notifiedMessages.has(messageId)) {
notifiedMessages.add(messageId);
if (runtimeAPIs?.notifications) {
const rawMode = (messageExt as { mode?: string }).mode || 'agent';
const rawModel = (messageExt as { modelID?: string }).modelID || 'assistant';
const runtimeAPIs = getRegisteredRuntimeAPIs();
const title = `${rawMode.charAt(0).toUpperCase() + rawMode.slice(1)} agent is ready`;
const body = `${formatModelID(rawModel)} completed the task`;
if (runtimeAPIs?.notifications) {
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,
nativeNotificationsEnabled,
notificationMode,
addStreamingPart,
completeStreamingMessage,
updateMessageInfo,
+8
View File
@@ -54,6 +54,7 @@ interface UIStore {
diffWrapLines: boolean;
isTimelineDialogOpen: boolean;
nativeNotificationsEnabled: boolean;
notificationMode: 'always' | 'hidden-only';
setTheme: (theme: 'light' | 'dark' | 'system') => void;
toggleSidebar: () => void;
@@ -97,6 +98,7 @@ interface UIStore {
setMultiRunLauncherOpen: (open: boolean) => void;
setTimelineDialogOpen: (open: boolean) => void;
setNativeNotificationsEnabled: (value: boolean) => void;
setNotificationMode: (mode: 'always' | 'hidden-only') => void;
openMultiRunLauncher: () => void;
openMultiRunLauncherWithPrompt: (prompt: string) => void;
}
@@ -141,6 +143,7 @@ export const useUIStore = create<UIStore>()(
diffWrapLines: false,
isTimelineDialogOpen: false,
nativeNotificationsEnabled: false,
notificationMode: 'hidden-only',
setTheme: (theme) => {
set({ theme });
@@ -472,6 +475,10 @@ export const useUIStore = create<UIStore>()(
setNativeNotificationsEnabled: (value) => {
set({ nativeNotificationsEnabled: value });
},
setNotificationMode: (mode) => {
set({ notificationMode: mode });
},
}),
{
name: 'ui-store',
@@ -497,6 +504,7 @@ export const useUIStore = create<UIStore>()(
diffLayoutPreference: state.diffLayoutPreference,
diffWrapLines: state.diffWrapLines,
nativeNotificationsEnabled: state.nativeNotificationsEnabled,
notificationMode: state.notificationMode,
})
}
),