feat: Improve notifications with templates, summarization, and more (#317)
* feat: Successfully added new notification settings to Zustand * feat: Successfully implemented the Events subsection * feat: Successfully wired new notification settings * feat: Added notification template editor section to Notifications * feat: Added server-side template resolution and summarize * feat: Improve notifications with templates, summarization, and more * fix: session not populated in messages * fix notification template first-run defaults --------- Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
4194cd25d6
commit
fbf0ec50ee
@@ -7,6 +7,13 @@ import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||
|
||||
import { GridLoader } from '@/components/ui/grid-loader';
|
||||
|
||||
const DEFAULT_NOTIFICATION_TEMPLATES = {
|
||||
completion: { title: '{agent_name} is ready', message: '{model_name} completed the task' },
|
||||
error: { title: 'Tool error', message: '{last_message}' },
|
||||
question: { title: 'Input needed', message: '{last_message}' },
|
||||
subtask: { title: '{agent_name} is ready', message: '{model_name} completed the task' },
|
||||
} as const;
|
||||
|
||||
export const NotificationSettings: React.FC = () => {
|
||||
const isDesktop = React.useMemo(() => isDesktopShell(), []);
|
||||
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
|
||||
@@ -17,6 +24,22 @@ export const NotificationSettings: React.FC = () => {
|
||||
const setNotificationMode = useUIStore(state => state.setNotificationMode);
|
||||
const notifyOnSubtasks = useUIStore(state => state.notifyOnSubtasks);
|
||||
const setNotifyOnSubtasks = useUIStore(state => state.setNotifyOnSubtasks);
|
||||
const notifyOnCompletion = useUIStore(state => state.notifyOnCompletion);
|
||||
const setNotifyOnCompletion = useUIStore(state => state.setNotifyOnCompletion);
|
||||
const notifyOnError = useUIStore(state => state.notifyOnError);
|
||||
const setNotifyOnError = useUIStore(state => state.setNotifyOnError);
|
||||
const notifyOnQuestion = useUIStore(state => state.notifyOnQuestion);
|
||||
const setNotifyOnQuestion = useUIStore(state => state.setNotifyOnQuestion);
|
||||
const notificationTemplates = useUIStore(state => state.notificationTemplates);
|
||||
const setNotificationTemplates = useUIStore(state => state.setNotificationTemplates);
|
||||
const summarizeLastMessage = useUIStore(state => state.summarizeLastMessage);
|
||||
const setSummarizeLastMessage = useUIStore(state => state.setSummarizeLastMessage);
|
||||
const summaryThreshold = useUIStore(state => state.summaryThreshold);
|
||||
const setSummaryThreshold = useUIStore(state => state.setSummaryThreshold);
|
||||
const summaryLength = useUIStore(state => state.summaryLength);
|
||||
const setSummaryLength = useUIStore(state => state.setSummaryLength);
|
||||
const maxLastMessageLength = useUIStore(state => state.maxLastMessageLength);
|
||||
const setMaxLastMessageLength = useUIStore(state => state.setMaxLastMessageLength);
|
||||
|
||||
const [notificationPermission, setNotificationPermission] = React.useState<NotificationPermission>('default');
|
||||
const [pushSupported, setPushSupported] = React.useState(false);
|
||||
@@ -96,6 +119,20 @@ export const NotificationSettings: React.FC = () => {
|
||||
|
||||
const canShowNotifications = isDesktop || (isBrowser && typeof Notification !== 'undefined' && Notification.permission === 'granted');
|
||||
|
||||
const updateTemplate = (
|
||||
event: 'completion' | 'error' | 'question' | 'subtask',
|
||||
field: 'title' | 'message',
|
||||
value: string,
|
||||
) => {
|
||||
setNotificationTemplates({
|
||||
...notificationTemplates,
|
||||
[event]: {
|
||||
...notificationTemplates[event],
|
||||
[field]: value,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const base64UrlToUint8Array = (base64Url: string): Uint8Array<ArrayBuffer> => {
|
||||
const padding = '='.repeat((4 - (base64Url.length % 4)) % 4);
|
||||
const base64 = (base64Url + padding)
|
||||
@@ -404,24 +441,6 @@ export const NotificationSettings: React.FC = () => {
|
||||
</p>
|
||||
)}
|
||||
|
||||
{nativeNotificationsEnabled && canShowNotifications && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<span className="typography-ui text-foreground">
|
||||
Include subagent results
|
||||
</span>
|
||||
<p className="typography-micro text-muted-foreground">
|
||||
Also notify for child sessions started by the main one.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={notifyOnSubtasks}
|
||||
onCheckedChange={(checked) => setNotifyOnSubtasks(checked)}
|
||||
className="data-[state=checked]:bg-status-info"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{nativeNotificationsEnabled && canShowNotifications && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
@@ -434,12 +453,215 @@ export const NotificationSettings: React.FC = () => {
|
||||
</div>
|
||||
<Switch
|
||||
checked={notificationMode === 'always'}
|
||||
onCheckedChange={(checked) => setNotificationMode(checked ? 'always' : 'hidden-only')}
|
||||
onCheckedChange={(checked: boolean) => setNotificationMode(checked ? 'always' : 'hidden-only')}
|
||||
className="data-[state=checked]:bg-status-info"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{nativeNotificationsEnabled && canShowNotifications && (
|
||||
<div className="space-y-3 pt-2">
|
||||
<div className="space-y-0.5">
|
||||
<span className="typography-ui text-foreground font-medium">
|
||||
Events
|
||||
</span>
|
||||
<p className="typography-micro text-muted-foreground">
|
||||
Choose which events trigger notifications.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<span className="typography-ui text-foreground">Completion</span>
|
||||
<p className="typography-micro text-muted-foreground">Agent finished its task.</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={notifyOnCompletion}
|
||||
onCheckedChange={setNotifyOnCompletion}
|
||||
className="data-[state=checked]:bg-status-info"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<span className="typography-ui text-foreground">Errors</span>
|
||||
<p className="typography-micro text-muted-foreground">A tool call failed.</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={notifyOnError}
|
||||
onCheckedChange={setNotifyOnError}
|
||||
className="data-[state=checked]:bg-status-info"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<span className="typography-ui text-foreground">Questions</span>
|
||||
<p className="typography-micro text-muted-foreground">Agent is asking for input or permission.</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={notifyOnQuestion}
|
||||
onCheckedChange={setNotifyOnQuestion}
|
||||
className="data-[state=checked]:bg-status-info"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<span className="typography-ui text-foreground">Subagents</span>
|
||||
<p className="typography-micro text-muted-foreground">Also notify for child sessions started by the main one.</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={notifyOnSubtasks}
|
||||
onCheckedChange={(checked: boolean) => setNotifyOnSubtasks(checked)}
|
||||
className="data-[state=checked]:bg-status-info"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{nativeNotificationsEnabled && canShowNotifications && (
|
||||
<div className="space-y-4 pt-4">
|
||||
<div className="space-y-1">
|
||||
<h3 className="typography-ui-header font-semibold text-foreground">
|
||||
Customize content
|
||||
</h3>
|
||||
<p className="typography-micro text-muted-foreground">
|
||||
Use template variables: <code className="text-accent-foreground">{'{project_name}'}</code>{' '}
|
||||
<code className="text-accent-foreground">{'{worktree}'}</code>{' '}
|
||||
<code className="text-accent-foreground">{'{branch}'}</code>{' '}
|
||||
<code className="text-accent-foreground">{'{session_name}'}</code>{' '}
|
||||
<code className="text-accent-foreground">{'{agent_name}'}</code>{' '}
|
||||
<code className="text-accent-foreground">{'{last_message}'}</code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{(['completion', 'error', 'question', 'subtask'] as const).map((event) => (
|
||||
<div key={event} className="space-y-2">
|
||||
<span className="typography-ui text-foreground font-medium capitalize">{event}</span>
|
||||
<div className="space-y-1.5">
|
||||
<div>
|
||||
<label className="typography-micro text-muted-foreground block mb-1">Title</label>
|
||||
<input
|
||||
type="text"
|
||||
value={notificationTemplates[event].title}
|
||||
onChange={(e) => updateTemplate(event, 'title', e.target.value)}
|
||||
className="w-full rounded-md border border-border bg-background px-3 py-1.5 typography-ui text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-accent"
|
||||
placeholder={DEFAULT_NOTIFICATION_TEMPLATES[event].title}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="typography-micro text-muted-foreground block mb-1">Message</label>
|
||||
<input
|
||||
type="text"
|
||||
value={notificationTemplates[event].message}
|
||||
onChange={(e) => updateTemplate(event, 'message', e.target.value)}
|
||||
className="w-full rounded-md border border-border bg-background px-3 py-1.5 typography-ui text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-accent"
|
||||
placeholder={DEFAULT_NOTIFICATION_TEMPLATES[event].message}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{nativeNotificationsEnabled && canShowNotifications && (
|
||||
<div className="space-y-3 pt-4">
|
||||
<div className="space-y-1">
|
||||
<h3 className="typography-ui-header font-semibold text-foreground">
|
||||
Summarization
|
||||
</h3>
|
||||
<p className="typography-micro text-muted-foreground">
|
||||
Summarize long messages in notifications using AI.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<span className="typography-ui text-foreground">
|
||||
Summarize last message
|
||||
</span>
|
||||
<p className="typography-micro text-muted-foreground">
|
||||
Uses AI to shorten the {'{last_message}'} variable.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={summarizeLastMessage}
|
||||
onCheckedChange={setSummarizeLastMessage}
|
||||
className="data-[state=checked]:bg-status-info"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{summarizeLastMessage ? (
|
||||
<>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="typography-ui text-foreground">
|
||||
Summary threshold
|
||||
</label>
|
||||
<span className="typography-micro text-muted-foreground tabular-nums">{summaryThreshold} chars</span>
|
||||
</div>
|
||||
<p className="typography-micro text-muted-foreground">
|
||||
Messages longer than this will be summarized.
|
||||
</p>
|
||||
<input
|
||||
type="range"
|
||||
min={50}
|
||||
max={2000}
|
||||
step={50}
|
||||
value={summaryThreshold}
|
||||
onChange={(e) => setSummaryThreshold(Number(e.target.value))}
|
||||
className="w-full h-2 bg-muted rounded-lg appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-primary [&::-moz-range-thumb]:border-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="typography-ui text-foreground">
|
||||
Summary length
|
||||
</label>
|
||||
<span className="typography-micro text-muted-foreground tabular-nums">{summaryLength} chars</span>
|
||||
</div>
|
||||
<p className="typography-micro text-muted-foreground">
|
||||
Target length of the summary.
|
||||
</p>
|
||||
<input
|
||||
type="range"
|
||||
min={20}
|
||||
max={500}
|
||||
step={10}
|
||||
value={summaryLength}
|
||||
onChange={(e) => setSummaryLength(Number(e.target.value))}
|
||||
className="w-full h-2 bg-muted rounded-lg appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-primary [&::-moz-range-thumb]:border-0"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="typography-ui text-foreground">
|
||||
Max last message length
|
||||
</label>
|
||||
<span className="typography-micro text-muted-foreground tabular-nums">{maxLastMessageLength} chars</span>
|
||||
</div>
|
||||
<p className="typography-micro text-muted-foreground">
|
||||
Truncate {'{last_message}'} to this many characters.
|
||||
</p>
|
||||
<input
|
||||
type="range"
|
||||
min={50}
|
||||
max={1000}
|
||||
step={10}
|
||||
value={maxLastMessageLength}
|
||||
onChange={(e) => setMaxLastMessageLength(Number(e.target.value))}
|
||||
className="w-full h-2 bg-muted rounded-lg appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-primary [&::-moz-range-thumb]:border-0"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isBrowser && (
|
||||
<>
|
||||
{notificationPermission === 'denied' && (
|
||||
@@ -494,7 +716,7 @@ export const NotificationSettings: React.FC = () => {
|
||||
<Switch
|
||||
checked={pushSubscribed}
|
||||
disabled={pushBusy}
|
||||
onCheckedChange={(checked) => {
|
||||
onCheckedChange={(checked: boolean) => {
|
||||
if (checked) {
|
||||
void handleEnableBackgroundNotifications();
|
||||
} else {
|
||||
|
||||
@@ -1465,9 +1465,11 @@ export const useEventStream = () => {
|
||||
break;
|
||||
}
|
||||
|
||||
// Desktop local instance uses native notifications via sidecar stdout.
|
||||
// Avoid duplicating via UI runtime notifications.
|
||||
if (isDesktopLocalOriginActive()) {
|
||||
// When the sidecar stdout notification channel is active (production desktop builds),
|
||||
// skip this SSE notification to avoid duplicating the native notification already
|
||||
// shown by the Tauri process. In dev mode the stdout channel is not available,
|
||||
// so we fall through and let the UI handle it via Tauri IPC.
|
||||
if (isDesktopLocalOriginActive() && Boolean((props as { desktopStdoutActive?: unknown }).desktopStdoutActive)) {
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,19 @@ type AppearanceSlice = {
|
||||
nativeNotificationsEnabled: boolean;
|
||||
notificationMode: 'always' | 'hidden-only';
|
||||
notifyOnSubtasks: boolean;
|
||||
notifyOnCompletion: boolean;
|
||||
notifyOnError: boolean;
|
||||
notifyOnQuestion: boolean;
|
||||
notificationTemplates: {
|
||||
completion: { title: string; message: string };
|
||||
error: { title: string; message: string };
|
||||
question: { title: string; message: string };
|
||||
subtask: { title: string; message: string };
|
||||
};
|
||||
summarizeLastMessage: boolean;
|
||||
summaryThreshold: number;
|
||||
summaryLength: number;
|
||||
maxLastMessageLength: number;
|
||||
autoDeleteEnabled: boolean;
|
||||
autoDeleteAfterDays: number;
|
||||
toolCallExpansion: 'collapsed' | 'activity' | 'detailed';
|
||||
@@ -35,6 +48,14 @@ export const startAppearanceAutoSave = (): void => {
|
||||
nativeNotificationsEnabled: useUIStore.getState().nativeNotificationsEnabled,
|
||||
notificationMode: useUIStore.getState().notificationMode,
|
||||
notifyOnSubtasks: useUIStore.getState().notifyOnSubtasks,
|
||||
notifyOnCompletion: useUIStore.getState().notifyOnCompletion,
|
||||
notifyOnError: useUIStore.getState().notifyOnError,
|
||||
notifyOnQuestion: useUIStore.getState().notifyOnQuestion,
|
||||
notificationTemplates: useUIStore.getState().notificationTemplates,
|
||||
summarizeLastMessage: useUIStore.getState().summarizeLastMessage,
|
||||
summaryThreshold: useUIStore.getState().summaryThreshold,
|
||||
summaryLength: useUIStore.getState().summaryLength,
|
||||
maxLastMessageLength: useUIStore.getState().maxLastMessageLength,
|
||||
autoDeleteEnabled: useUIStore.getState().autoDeleteEnabled,
|
||||
autoDeleteAfterDays: useUIStore.getState().autoDeleteAfterDays,
|
||||
toolCallExpansion: useUIStore.getState().toolCallExpansion,
|
||||
@@ -74,6 +95,14 @@ export const startAppearanceAutoSave = (): void => {
|
||||
nativeNotificationsEnabled: state.nativeNotificationsEnabled,
|
||||
notificationMode: state.notificationMode,
|
||||
notifyOnSubtasks: state.notifyOnSubtasks,
|
||||
notifyOnCompletion: state.notifyOnCompletion,
|
||||
notifyOnError: state.notifyOnError,
|
||||
notifyOnQuestion: state.notifyOnQuestion,
|
||||
notificationTemplates: state.notificationTemplates,
|
||||
summarizeLastMessage: state.summarizeLastMessage,
|
||||
summaryThreshold: state.summaryThreshold,
|
||||
summaryLength: state.summaryLength,
|
||||
maxLastMessageLength: state.maxLastMessageLength,
|
||||
autoDeleteEnabled: state.autoDeleteEnabled,
|
||||
autoDeleteAfterDays: state.autoDeleteAfterDays,
|
||||
toolCallExpansion: state.toolCallExpansion,
|
||||
@@ -103,6 +132,30 @@ export const startAppearanceAutoSave = (): void => {
|
||||
if (current.notifyOnSubtasks !== previous.notifyOnSubtasks) {
|
||||
diff.notifyOnSubtasks = current.notifyOnSubtasks;
|
||||
}
|
||||
if (current.notifyOnCompletion !== previous.notifyOnCompletion) {
|
||||
diff.notifyOnCompletion = current.notifyOnCompletion;
|
||||
}
|
||||
if (current.notifyOnError !== previous.notifyOnError) {
|
||||
diff.notifyOnError = current.notifyOnError;
|
||||
}
|
||||
if (current.notifyOnQuestion !== previous.notifyOnQuestion) {
|
||||
diff.notifyOnQuestion = current.notifyOnQuestion;
|
||||
}
|
||||
if (JSON.stringify(current.notificationTemplates) !== JSON.stringify(previous.notificationTemplates)) {
|
||||
diff.notificationTemplates = current.notificationTemplates;
|
||||
}
|
||||
if (current.summarizeLastMessage !== previous.summarizeLastMessage) {
|
||||
diff.summarizeLastMessage = current.summarizeLastMessage;
|
||||
}
|
||||
if (current.summaryThreshold !== previous.summaryThreshold) {
|
||||
diff.summaryThreshold = current.summaryThreshold;
|
||||
}
|
||||
if (current.summaryLength !== previous.summaryLength) {
|
||||
diff.summaryLength = current.summaryLength;
|
||||
}
|
||||
if (current.maxLastMessageLength !== previous.maxLastMessageLength) {
|
||||
diff.maxLastMessageLength = current.maxLastMessageLength;
|
||||
}
|
||||
if (current.autoDeleteEnabled !== previous.autoDeleteEnabled) {
|
||||
diff.autoDeleteEnabled = current.autoDeleteEnabled;
|
||||
}
|
||||
|
||||
@@ -49,6 +49,26 @@ export type DesktopSettings = {
|
||||
nativeNotificationsEnabled?: boolean;
|
||||
notificationMode?: 'always' | 'hidden-only';
|
||||
notifyOnSubtasks?: boolean;
|
||||
|
||||
// Event toggles (which events trigger notifications)
|
||||
notifyOnCompletion?: boolean;
|
||||
notifyOnError?: boolean;
|
||||
notifyOnQuestion?: boolean;
|
||||
|
||||
// Per-event notification templates
|
||||
notificationTemplates?: {
|
||||
completion: { title: string; message: string };
|
||||
error: { title: string; message: string };
|
||||
question: { title: string; message: string };
|
||||
subtask: { title: string; message: string };
|
||||
};
|
||||
|
||||
// Summarization settings
|
||||
summarizeLastMessage?: boolean;
|
||||
summaryThreshold?: number;
|
||||
summaryLength?: number;
|
||||
maxLastMessageLength?: number;
|
||||
|
||||
usageAutoRefresh?: boolean;
|
||||
usageRefreshIntervalMs?: number;
|
||||
usageDisplayMode?: 'usage' | 'remaining';
|
||||
|
||||
@@ -245,6 +245,30 @@ const applyDesktopUiPreferences = (settings: DesktopSettings) => {
|
||||
if (typeof settings.notifyOnSubtasks === 'boolean' && settings.notifyOnSubtasks !== store.notifyOnSubtasks) {
|
||||
store.setNotifyOnSubtasks(settings.notifyOnSubtasks);
|
||||
}
|
||||
if (typeof settings.notifyOnCompletion === 'boolean' && settings.notifyOnCompletion !== store.notifyOnCompletion) {
|
||||
store.setNotifyOnCompletion(settings.notifyOnCompletion);
|
||||
}
|
||||
if (typeof settings.notifyOnError === 'boolean' && settings.notifyOnError !== store.notifyOnError) {
|
||||
store.setNotifyOnError(settings.notifyOnError);
|
||||
}
|
||||
if (typeof settings.notifyOnQuestion === 'boolean' && settings.notifyOnQuestion !== store.notifyOnQuestion) {
|
||||
store.setNotifyOnQuestion(settings.notifyOnQuestion);
|
||||
}
|
||||
if (settings.notificationTemplates && typeof settings.notificationTemplates === 'object') {
|
||||
store.setNotificationTemplates(settings.notificationTemplates);
|
||||
}
|
||||
if (typeof settings.summarizeLastMessage === 'boolean' && settings.summarizeLastMessage !== store.summarizeLastMessage) {
|
||||
store.setSummarizeLastMessage(settings.summarizeLastMessage);
|
||||
}
|
||||
if (typeof settings.summaryThreshold === 'number' && Number.isFinite(settings.summaryThreshold)) {
|
||||
store.setSummaryThreshold(settings.summaryThreshold);
|
||||
}
|
||||
if (typeof settings.summaryLength === 'number' && Number.isFinite(settings.summaryLength)) {
|
||||
store.setSummaryLength(settings.summaryLength);
|
||||
}
|
||||
if (typeof settings.maxLastMessageLength === 'number' && Number.isFinite(settings.maxLastMessageLength)) {
|
||||
store.setMaxLastMessageLength(settings.maxLastMessageLength);
|
||||
}
|
||||
if (typeof settings.toolCallExpansion === 'string'
|
||||
&& (settings.toolCallExpansion === 'collapsed' || settings.toolCallExpansion === 'activity' || settings.toolCallExpansion === 'detailed')) {
|
||||
if (settings.toolCallExpansion !== store.toolCallExpansion) {
|
||||
@@ -407,6 +431,50 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => {
|
||||
if (typeof candidate.notifyOnSubtasks === 'boolean') {
|
||||
result.notifyOnSubtasks = candidate.notifyOnSubtasks;
|
||||
}
|
||||
if (typeof candidate.notifyOnCompletion === 'boolean') {
|
||||
result.notifyOnCompletion = candidate.notifyOnCompletion;
|
||||
}
|
||||
if (typeof candidate.notifyOnError === 'boolean') {
|
||||
result.notifyOnError = candidate.notifyOnError;
|
||||
}
|
||||
if (typeof candidate.notifyOnQuestion === 'boolean') {
|
||||
result.notifyOnQuestion = candidate.notifyOnQuestion;
|
||||
}
|
||||
if (candidate.notificationTemplates && typeof candidate.notificationTemplates === 'object') {
|
||||
const templates = candidate.notificationTemplates as Record<string, unknown>;
|
||||
const validateTemplate = (key: string): { title: string; message: string } | undefined => {
|
||||
const value = templates[key];
|
||||
if (!value || typeof value !== 'object') return undefined;
|
||||
const obj = value as Record<string, unknown>;
|
||||
const title = typeof obj.title === 'string' ? obj.title : '';
|
||||
const message = typeof obj.message === 'string' ? obj.message : '';
|
||||
return { title, message };
|
||||
};
|
||||
const completion = validateTemplate('completion');
|
||||
const error = validateTemplate('error');
|
||||
const question = validateTemplate('question');
|
||||
const subtask = validateTemplate('subtask');
|
||||
if (completion || error || question || subtask) {
|
||||
result.notificationTemplates = {
|
||||
completion: completion ?? { title: 'Task Complete', message: 'Your task has finished.' },
|
||||
error: error ?? { title: 'Error Occurred', message: 'An error occurred while processing your task.' },
|
||||
question: question ?? { title: 'Input Needed', message: 'Please provide input to continue.' },
|
||||
subtask: subtask ?? { title: 'Subtask Complete', message: 'A subtask has finished.' },
|
||||
};
|
||||
}
|
||||
}
|
||||
if (typeof candidate.summarizeLastMessage === 'boolean') {
|
||||
result.summarizeLastMessage = candidate.summarizeLastMessage;
|
||||
}
|
||||
if (typeof candidate.summaryThreshold === 'number' && Number.isFinite(candidate.summaryThreshold)) {
|
||||
result.summaryThreshold = Math.max(0, Math.round(candidate.summaryThreshold));
|
||||
}
|
||||
if (typeof candidate.summaryLength === 'number' && Number.isFinite(candidate.summaryLength)) {
|
||||
result.summaryLength = Math.max(10, Math.round(candidate.summaryLength));
|
||||
}
|
||||
if (typeof candidate.maxLastMessageLength === 'number' && Number.isFinite(candidate.maxLastMessageLength)) {
|
||||
result.maxLastMessageLength = Math.max(10, Math.round(candidate.maxLastMessageLength));
|
||||
}
|
||||
if (typeof candidate.usageAutoRefresh === 'boolean') {
|
||||
result.usageAutoRefresh = candidate.usageAutoRefresh;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,41 @@ export type EventStreamStatus =
|
||||
| 'offline'
|
||||
| 'error';
|
||||
|
||||
const LEGACY_DEFAULT_NOTIFICATION_TEMPLATES = {
|
||||
completion: { title: '{agent_name} is ready', message: '{last_message}' },
|
||||
error: { title: 'Tool error', message: '{last_message}' },
|
||||
question: { title: '{agent_name} needs input', message: '{last_message}' },
|
||||
subtask: { title: 'Subtask complete', message: '{last_message}' },
|
||||
} as const;
|
||||
|
||||
const EMPTY_NOTIFICATION_TEMPLATES = {
|
||||
completion: { title: '', message: '' },
|
||||
error: { title: '', message: '' },
|
||||
question: { title: '', message: '' },
|
||||
subtask: { title: '', message: '' },
|
||||
} as const;
|
||||
|
||||
const isSameTemplateValue = (
|
||||
a: { title: string; message: string } | undefined,
|
||||
b: { title: string; message: string }
|
||||
) => {
|
||||
if (!a) return false;
|
||||
return a.title === b.title && a.message === b.message;
|
||||
};
|
||||
|
||||
const isLegacyDefaultTemplates = (value: unknown): boolean => {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return false;
|
||||
}
|
||||
const candidate = value as Record<string, { title: string; message: string } | undefined>;
|
||||
return (
|
||||
isSameTemplateValue(candidate.completion, LEGACY_DEFAULT_NOTIFICATION_TEMPLATES.completion)
|
||||
&& isSameTemplateValue(candidate.error, LEGACY_DEFAULT_NOTIFICATION_TEMPLATES.error)
|
||||
&& isSameTemplateValue(candidate.question, LEGACY_DEFAULT_NOTIFICATION_TEMPLATES.question)
|
||||
&& isSameTemplateValue(candidate.subtask, LEGACY_DEFAULT_NOTIFICATION_TEMPLATES.subtask)
|
||||
);
|
||||
};
|
||||
|
||||
interface UIStore {
|
||||
|
||||
theme: 'light' | 'dark' | 'system';
|
||||
@@ -79,6 +114,25 @@ interface UIStore {
|
||||
notificationMode: 'always' | 'hidden-only';
|
||||
notifyOnSubtasks: boolean;
|
||||
|
||||
// Event toggles (which events trigger notifications)
|
||||
notifyOnCompletion: boolean;
|
||||
notifyOnError: boolean;
|
||||
notifyOnQuestion: boolean;
|
||||
|
||||
// Per-event notification templates
|
||||
notificationTemplates: {
|
||||
completion: { title: string; message: string };
|
||||
error: { title: string; message: string };
|
||||
question: { title: string; message: string };
|
||||
subtask: { title: string; message: string };
|
||||
};
|
||||
|
||||
// Summarization settings
|
||||
summarizeLastMessage: boolean;
|
||||
summaryThreshold: number; // chars — messages longer than this get summarized
|
||||
summaryLength: number; // chars — target length for summary
|
||||
maxLastMessageLength: number; // chars — truncate {last_message} when summarization is off
|
||||
|
||||
showTerminalQuickKeysOnDesktop: boolean;
|
||||
persistChatDraft: boolean;
|
||||
isMobileSessionStatusBarCollapsed: boolean;
|
||||
@@ -147,6 +201,14 @@ interface UIStore {
|
||||
setNotificationMode: (mode: 'always' | 'hidden-only') => void;
|
||||
setShowTerminalQuickKeysOnDesktop: (value: boolean) => void;
|
||||
setNotifyOnSubtasks: (value: boolean) => void;
|
||||
setNotifyOnCompletion: (value: boolean) => void;
|
||||
setNotifyOnError: (value: boolean) => void;
|
||||
setNotifyOnQuestion: (value: boolean) => void;
|
||||
setNotificationTemplates: (templates: UIStore['notificationTemplates']) => void;
|
||||
setSummarizeLastMessage: (value: boolean) => void;
|
||||
setSummaryThreshold: (value: number) => void;
|
||||
setSummaryLength: (value: number) => void;
|
||||
setMaxLastMessageLength: (value: number) => void;
|
||||
setPersistChatDraft: (value: boolean) => void;
|
||||
setIsMobileSessionStatusBarCollapsed: (value: boolean) => void;
|
||||
openMultiRunLauncher: () => void;
|
||||
@@ -217,6 +279,23 @@ export const useUIStore = create<UIStore>()(
|
||||
notificationMode: 'hidden-only',
|
||||
notifyOnSubtasks: true,
|
||||
|
||||
// Event toggles (which events trigger notifications)
|
||||
notifyOnCompletion: true,
|
||||
notifyOnError: true,
|
||||
notifyOnQuestion: true,
|
||||
notificationTemplates: {
|
||||
completion: { ...EMPTY_NOTIFICATION_TEMPLATES.completion },
|
||||
error: { ...EMPTY_NOTIFICATION_TEMPLATES.error },
|
||||
question: { ...EMPTY_NOTIFICATION_TEMPLATES.question },
|
||||
subtask: { ...EMPTY_NOTIFICATION_TEMPLATES.subtask },
|
||||
},
|
||||
|
||||
// Summarization settings
|
||||
summarizeLastMessage: false,
|
||||
summaryThreshold: 200,
|
||||
summaryLength: 100,
|
||||
maxLastMessageLength: 250,
|
||||
|
||||
showTerminalQuickKeysOnDesktop: false,
|
||||
persistChatDraft: true,
|
||||
isMobileSessionStatusBarCollapsed: false,
|
||||
@@ -784,6 +863,14 @@ export const useUIStore = create<UIStore>()(
|
||||
set({ notifyOnSubtasks: value });
|
||||
},
|
||||
|
||||
setNotifyOnCompletion: (value) => { set({ notifyOnCompletion: value }); },
|
||||
setNotifyOnError: (value) => { set({ notifyOnError: value }); },
|
||||
setNotifyOnQuestion: (value) => { set({ notifyOnQuestion: value }); },
|
||||
setNotificationTemplates: (templates) => { set({ notificationTemplates: templates }); },
|
||||
setSummarizeLastMessage: (value) => { set({ summarizeLastMessage: value }); },
|
||||
setSummaryThreshold: (value) => { set({ summaryThreshold: value }); },
|
||||
setSummaryLength: (value) => { set({ summaryLength: value }); },
|
||||
setMaxLastMessageLength: (value) => { set({ maxLastMessageLength: value }); },
|
||||
setPersistChatDraft: (value) => {
|
||||
set({ persistChatDraft: value });
|
||||
},
|
||||
@@ -794,6 +881,25 @@ export const useUIStore = create<UIStore>()(
|
||||
{
|
||||
name: 'ui-store',
|
||||
storage: createJSONStorage(() => getSafeStorage()),
|
||||
version: 1,
|
||||
migrate: (persistedState, version) => {
|
||||
if (version >= 1 || !persistedState || typeof persistedState !== 'object') {
|
||||
return persistedState;
|
||||
}
|
||||
const state = persistedState as Record<string, unknown>;
|
||||
if (!isLegacyDefaultTemplates(state.notificationTemplates)) {
|
||||
return persistedState;
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
notificationTemplates: {
|
||||
completion: { ...EMPTY_NOTIFICATION_TEMPLATES.completion },
|
||||
error: { ...EMPTY_NOTIFICATION_TEMPLATES.error },
|
||||
question: { ...EMPTY_NOTIFICATION_TEMPLATES.question },
|
||||
subtask: { ...EMPTY_NOTIFICATION_TEMPLATES.subtask },
|
||||
},
|
||||
};
|
||||
},
|
||||
partialize: (state) => ({
|
||||
theme: state.theme,
|
||||
isSidebarOpen: state.isSidebarOpen,
|
||||
@@ -831,6 +937,14 @@ export const useUIStore = create<UIStore>()(
|
||||
notificationMode: state.notificationMode,
|
||||
showTerminalQuickKeysOnDesktop: state.showTerminalQuickKeysOnDesktop,
|
||||
notifyOnSubtasks: state.notifyOnSubtasks,
|
||||
notifyOnCompletion: state.notifyOnCompletion,
|
||||
notifyOnError: state.notifyOnError,
|
||||
notifyOnQuestion: state.notifyOnQuestion,
|
||||
notificationTemplates: state.notificationTemplates,
|
||||
summarizeLastMessage: state.summarizeLastMessage,
|
||||
summaryThreshold: state.summaryThreshold,
|
||||
summaryLength: state.summaryLength,
|
||||
maxLastMessageLength: state.maxLastMessageLength,
|
||||
persistChatDraft: state.persistChatDraft,
|
||||
isMobileSessionStatusBarCollapsed: state.isMobileSessionStatusBarCollapsed,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user