feat: add notification mode settings and enhance notification handling
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user