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]
|
## [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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in New Issue
Block a user