diff --git a/packages/ui/src/components/sections/openchamber/NotificationSettings.tsx b/packages/ui/src/components/sections/openchamber/NotificationSettings.tsx
index 2db97524..e1c89f69 100644
--- a/packages/ui/src/components/sections/openchamber/NotificationSettings.tsx
+++ b/packages/ui/src/components/sections/openchamber/NotificationSettings.tsx
@@ -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('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 => {
const padding = '='.repeat((4 - (base64Url.length % 4)) % 4);
const base64 = (base64Url + padding)
@@ -404,24 +441,6 @@ export const NotificationSettings: React.FC = () => {
)}
- {nativeNotificationsEnabled && canShowNotifications && (
-
-
-
- Include subagent results
-
-
- Also notify for child sessions started by the main one.
-
-
-
setNotifyOnSubtasks(checked)}
- className="data-[state=checked]:bg-status-info"
- />
-
- )}
-
{nativeNotificationsEnabled && canShowNotifications && (
@@ -434,12 +453,215 @@ export const NotificationSettings: React.FC = () => {
setNotificationMode(checked ? 'always' : 'hidden-only')}
+ onCheckedChange={(checked: boolean) => setNotificationMode(checked ? 'always' : 'hidden-only')}
className="data-[state=checked]:bg-status-info"
/>
)}
+ {nativeNotificationsEnabled && canShowNotifications && (
+
+
+
+ Events
+
+
+ Choose which events trigger notifications.
+
+
+
+
+
+
Completion
+
Agent finished its task.
+
+
+
+
+
+
+
Errors
+
A tool call failed.
+
+
+
+
+
+
+
Questions
+
Agent is asking for input or permission.
+
+
+
+
+
+
+
Subagents
+
Also notify for child sessions started by the main one.
+
+
setNotifyOnSubtasks(checked)}
+ className="data-[state=checked]:bg-status-info"
+ />
+
+
+ )}
+
+ {nativeNotificationsEnabled && canShowNotifications && (
+
+
+
+ Customize content
+
+
+ Use template variables: {'{project_name}'}{' '}
+ {'{worktree}'}{' '}
+ {'{branch}'}{' '}
+ {'{session_name}'}{' '}
+ {'{agent_name}'}{' '}
+ {'{last_message}'}
+
+
+
+ {(['completion', 'error', 'question', 'subtask'] as const).map((event) => (
+
+ ))}
+
+ )}
+
+ {nativeNotificationsEnabled && canShowNotifications && (
+
+
+
+ Summarization
+
+
+ Summarize long messages in notifications using AI.
+
+
+
+
+
+
+ Summarize last message
+
+
+ Uses AI to shorten the {'{last_message}'} variable.
+
+
+
+
+
+ {summarizeLastMessage ? (
+ <>
+
+
+
+ {summaryThreshold} chars
+
+
+ Messages longer than this will be summarized.
+
+
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"
+ />
+
+
+
+
+
+ {summaryLength} chars
+
+
+ Target length of the summary.
+
+
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"
+ />
+
+ >
+ ) : (
+
+
+
+ {maxLastMessageLength} chars
+
+
+ Truncate {'{last_message}'} to this many characters.
+
+
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"
+ />
+
+ )}
+
+ )}
+
{isBrowser && (
<>
{notificationPermission === 'denied' && (
@@ -494,7 +716,7 @@ export const NotificationSettings: React.FC = () => {
{
+ onCheckedChange={(checked: boolean) => {
if (checked) {
void handleEnableBackgroundNotifications();
} else {
diff --git a/packages/ui/src/hooks/useEventStream.ts b/packages/ui/src/hooks/useEventStream.ts
index fa70121b..90c43ba5 100644
--- a/packages/ui/src/hooks/useEventStream.ts
+++ b/packages/ui/src/hooks/useEventStream.ts
@@ -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;
}
diff --git a/packages/ui/src/lib/appearanceAutoSave.ts b/packages/ui/src/lib/appearanceAutoSave.ts
index 1de5adf5..cc518c82 100644
--- a/packages/ui/src/lib/appearanceAutoSave.ts
+++ b/packages/ui/src/lib/appearanceAutoSave.ts
@@ -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;
}
diff --git a/packages/ui/src/lib/desktop.ts b/packages/ui/src/lib/desktop.ts
index d0901ab3..9a28f3bc 100644
--- a/packages/ui/src/lib/desktop.ts
+++ b/packages/ui/src/lib/desktop.ts
@@ -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';
diff --git a/packages/ui/src/lib/persistence.ts b/packages/ui/src/lib/persistence.ts
index f7a813e1..230b104b 100644
--- a/packages/ui/src/lib/persistence.ts
+++ b/packages/ui/src/lib/persistence.ts
@@ -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;
+ 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;
+ 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;
}
diff --git a/packages/ui/src/stores/useUIStore.ts b/packages/ui/src/stores/useUIStore.ts
index 0d255c7e..72fad371 100644
--- a/packages/ui/src/stores/useUIStore.ts
+++ b/packages/ui/src/stores/useUIStore.ts
@@ -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;
+ 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()(
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()(
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()(
{
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;
+ 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()(
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,
})
diff --git a/packages/web/server/index.js b/packages/web/server/index.js
index 4b9f7876..d8de2d65 100644
--- a/packages/web/server/index.js
+++ b/packages/web/server/index.js
@@ -555,6 +555,358 @@ const createTimeoutSignal = (timeoutMs) => {
};
};
+/** Humanize a project label: replace dashes/underscores with spaces, title-case each word. Mirrors the UI's formatProjectLabel. */
+const formatProjectLabel = (label) => {
+ if (!label || typeof label !== 'string') return '';
+ return label
+ .replace(/[-_]/g, ' ')
+ .replace(/\b\w/g, (char) => char.toUpperCase());
+};
+
+const resolveNotificationTemplate = (template, variables) => {
+ if (!template || typeof template !== 'string') return '';
+ return template.replace(/\{(\w+)\}/g, (_match, key) => {
+ const value = variables[key];
+ if (value === undefined || value === null) return '';
+ return String(value);
+ });
+};
+
+const summarizeText = async (text, targetLength) => {
+ if (!text || typeof text !== 'string' || text.trim().length === 0) return text;
+
+ try {
+ const prompt = `Summarize the following text in approximately ${targetLength} characters. Be concise and capture the key point. Output ONLY the summary text, nothing else.\n\nText:\n${text}`;
+
+ const completionTimeout = createTimeoutSignal(15000);
+ let response;
+ try {
+ response = await fetch('https://opencode.ai/zen/v1/responses', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ model: 'gpt-5-nano',
+ input: [{ role: 'user', content: prompt }],
+ max_output_tokens: 1000,
+ stream: false,
+ reasoning: { effort: 'low' },
+ }),
+ signal: completionTimeout.signal,
+ });
+ } finally {
+ completionTimeout.cleanup();
+ }
+
+ if (!response.ok) return text;
+
+ const data = await response.json();
+ const summary = data?.output?.find((item) => item?.type === 'message')
+ ?.content?.find((item) => item?.type === 'output_text')?.text?.trim();
+
+ return summary || text;
+ } catch {
+ return text;
+ }
+};
+
+const NOTIFICATION_BODY_MAX_CHARS = 1000;
+
+/**
+ * Extract text from parts array (used when parts are available inline or fetched from API).
+ */
+const extractTextFromParts = (parts, maxLength = NOTIFICATION_BODY_MAX_CHARS) => {
+ if (!Array.isArray(parts) || parts.length === 0) return '';
+
+ const textParts = parts
+ .filter((p) => p && (p.type === 'text' || typeof p.text === 'string' || typeof p.content === 'string'))
+ .map((p) => p.text || p.content || '')
+ .filter(Boolean);
+
+ let text = textParts.length > 0 ? textParts.join('\n').trim() : '';
+
+ // Truncate to prevent oversized notification payloads
+ if (maxLength > 0 && text.length > maxLength) {
+ text = text.slice(0, maxLength);
+ }
+
+ return text;
+};
+
+/**
+ * Try to extract message text from the payload itself (fast path).
+ * Note: message.updated events from the OpenCode SSE stream typically do NOT include
+ * parts inline — parts are sent via separate message.part.updated events. This function
+ * is a fast path for the rare case where parts are included.
+ */
+const extractLastMessageText = (payload, maxLength = NOTIFICATION_BODY_MAX_CHARS) => {
+ const info = payload?.properties?.info;
+ if (!info) return '';
+
+ // Try inline parts on info or on properties
+ const parts = info.parts || payload?.properties?.parts;
+ const text = extractTextFromParts(parts, maxLength);
+ if (text) return text;
+
+ // Fallback: try content array (legacy)
+ const content = info.content;
+ if (Array.isArray(content)) {
+ const textContent = content
+ .filter((c) => c && (c.type === 'text' || typeof c.text === 'string'))
+ .map((c) => c.text || '')
+ .filter(Boolean);
+ if (textContent.length > 0) {
+ let result = textContent.join('\n').trim();
+ if (maxLength > 0 && result.length > maxLength) {
+ result = result.slice(0, maxLength);
+ }
+ return result;
+ }
+ }
+
+ return '';
+};
+
+/**
+ * Fetch the last assistant message text from the OpenCode API.
+ * This is needed because message.updated events don't include parts;
+ * we must fetch them separately via the session messages endpoint.
+ */
+const fetchLastAssistantMessageText = async (sessionId, messageId, maxLength = NOTIFICATION_BODY_MAX_CHARS) => {
+ if (!sessionId) return '';
+
+ try {
+ // Fetch last few messages to find the one that triggered the notification
+ const url = buildOpenCodeUrl(`/session/${encodeURIComponent(sessionId)}/message`, '');
+ const response = await fetch(`${url}?limit=5`, {
+ method: 'GET',
+ headers: { Accept: 'application/json' },
+ signal: AbortSignal.timeout(3000),
+ });
+
+ if (!response.ok) return '';
+
+ const messages = await response.json().catch(() => null);
+ if (!Array.isArray(messages)) return '';
+
+ // Find the specific message by ID, or fall back to the last assistant message
+ let target = null;
+ if (messageId) {
+ target = messages.find((m) => m?.info?.id === messageId && m?.info?.role === 'assistant');
+ }
+ if (!target) {
+ // Find the last assistant message with finish === 'stop'
+ for (let i = messages.length - 1; i >= 0; i--) {
+ const m = messages[i];
+ if (m?.info?.role === 'assistant' && m?.info?.finish === 'stop') {
+ target = m;
+ break;
+ }
+ }
+ }
+
+ if (!target || !Array.isArray(target.parts)) return '';
+
+ return extractTextFromParts(target.parts, maxLength);
+ } catch {
+ return '';
+ }
+};
+
+/**
+ * In-memory cache of session titles populated from SSE session.updated / session.created events.
+ * This is the preferred source for session titles since it is populated passively and doesn't
+ * require a separate API call.
+ */
+const sessionTitleCache = new Map();
+
+const cacheSessionTitle = (sessionId, title) => {
+ if (typeof sessionId === 'string' && sessionId.length > 0 &&
+ typeof title === 'string' && title.length > 0) {
+ sessionTitleCache.set(sessionId, title);
+ }
+};
+
+const getCachedSessionTitle = (sessionId) => {
+ return sessionTitleCache.get(sessionId) ?? null;
+};
+
+/**
+ * Extract and cache session title from session.updated / session.created SSE events.
+ * Called by the global event watcher to passively maintain the title cache.
+ */
+const maybeCacheSessionInfoFromEvent = (payload) => {
+ if (!payload || typeof payload !== 'object') return;
+ const type = payload.type;
+ if (type !== 'session.updated' && type !== 'session.created') return;
+ const info = payload.properties?.info;
+ if (!info || typeof info !== 'object') return;
+ const sessionId = info.id;
+ const title = info.title;
+ cacheSessionTitle(sessionId, title);
+};
+
+/**
+ * Fetch session metadata (title, directory) from the OpenCode API.
+ * Cached for 60s per session to avoid repeated API calls.
+ */
+const sessionInfoCache = new Map();
+const SESSION_INFO_CACHE_TTL_MS = 60 * 1000;
+
+const fetchSessionInfo = async (sessionId) => {
+ if (!sessionId) return null;
+
+ const cached = sessionInfoCache.get(sessionId);
+ if (cached && Date.now() - cached.at < SESSION_INFO_CACHE_TTL_MS) {
+ return cached.data;
+ }
+
+ try {
+ const url = buildOpenCodeUrl(`/session/${encodeURIComponent(sessionId)}`, '');
+ const response = await fetch(url, {
+ method: 'GET',
+ headers: { Accept: 'application/json' },
+ signal: AbortSignal.timeout(2000),
+ });
+ if (!response.ok) {
+ console.warn(`[Notification] fetchSessionInfo: ${response.status} for session ${sessionId}`);
+ return null;
+ }
+ const data = await response.json().catch(() => null);
+ if (data && typeof data === 'object') {
+ sessionInfoCache.set(sessionId, { data, at: Date.now() });
+ return data;
+ }
+ return null;
+ } catch (err) {
+ console.warn(`[Notification] fetchSessionInfo failed for ${sessionId}:`, err?.message || err);
+ return null;
+ }
+};
+
+const buildTemplateVariables = async (payload, sessionId) => {
+ const info = payload?.properties?.info || {};
+
+ // Session title — try inline payload, then SSE cache, then API fetch
+ let sessionTitle = payload?.properties?.sessionTitle ||
+ payload?.properties?.session?.title ||
+ (typeof info.sessionTitle === 'string' ? info.sessionTitle : '') ||
+ '';
+
+ // Try the SSE-populated session title cache (filled from session.updated / session.created events)
+ if (!sessionTitle && sessionId) {
+ const cached = getCachedSessionTitle(sessionId);
+ if (cached) {
+ sessionTitle = cached;
+ }
+ }
+
+ // Last resort: fetch session info from the API
+ let sessionInfo = null;
+ if (!sessionTitle && sessionId) {
+ sessionInfo = await fetchSessionInfo(sessionId);
+ if (sessionInfo && typeof sessionInfo.title === 'string') {
+ sessionTitle = sessionInfo.title;
+ // Populate the SSE cache so future notifications don't need an API call
+ cacheSessionTitle(sessionId, sessionTitle);
+ }
+ }
+
+ // Agent name from mode or agent field (v2 has both mode and agent)
+ const agentName = (() => {
+ const mode = typeof info.agent === 'string' && info.agent.trim().length > 0
+ ? info.agent.trim()
+ : (typeof info.mode === 'string' ? info.mode.trim() : '');
+ if (!mode) return 'Agent';
+ return mode.split(/[-_\s]+/).filter(Boolean)
+ .map((t) => t.charAt(0).toUpperCase() + t.slice(1)).join(' ');
+ })();
+
+ // Model name — v2 has modelID directly on info, v1 user messages nest it under info.model.modelID
+ const modelName = (() => {
+ const raw = typeof info.modelID === 'string' ? info.modelID.trim()
+ : (typeof info.model?.modelID === 'string' ? info.model.modelID.trim() : '');
+ if (!raw) return 'Assistant';
+ return raw.split(/[-_]+/).filter(Boolean)
+ .map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join(' ');
+ })();
+
+ // Project name, branch, worktree — derived from multiple sources with fallbacks
+ let projectName = '';
+ let branch = '';
+ let worktreeDir = '';
+
+ // 1. Primary source: the message payload's path (always accurate for the session)
+ const infoPath = info.path;
+ if (typeof infoPath?.root === 'string' && infoPath.root.length > 0) {
+ worktreeDir = infoPath.root;
+ } else if (typeof infoPath?.cwd === 'string' && infoPath.cwd.length > 0) {
+ worktreeDir = infoPath.cwd;
+ }
+
+ // 2. Look up the user-facing project label from stored settings
+ try {
+ const settings = await readSettingsFromDisk();
+ const projects = Array.isArray(settings.projects) ? settings.projects : [];
+
+ if (worktreeDir) {
+ // Match the session directory against stored projects to find the label
+ const normalizedDir = worktreeDir.replace(/\/+$/, '');
+ const matchedProject = projects.find((p) => {
+ if (!p || typeof p.path !== 'string') return false;
+ return p.path.replace(/\/+$/, '') === normalizedDir;
+ });
+ if (matchedProject && typeof matchedProject.label === 'string' && matchedProject.label.trim().length > 0) {
+ projectName = matchedProject.label.trim();
+ } else {
+ // No label stored — derive from directory name
+ projectName = normalizedDir.split('/').filter(Boolean).pop() || '';
+ }
+ } else {
+ // No directory from payload — fall back to active project
+ const activeId = typeof settings.activeProjectId === 'string' ? settings.activeProjectId : '';
+ const activeProject = activeId ? projects.find((p) => p && p.id === activeId) : projects[0];
+ if (activeProject) {
+ projectName = typeof activeProject.label === 'string' && activeProject.label.trim().length > 0
+ ? activeProject.label.trim()
+ : typeof activeProject.path === 'string'
+ ? activeProject.path.split('/').pop() || ''
+ : '';
+ worktreeDir = typeof activeProject.path === 'string' ? activeProject.path : '';
+ }
+ }
+ } catch {
+ // Settings read failed — derive from directory if available
+ if (worktreeDir && !projectName) {
+ projectName = worktreeDir.split('/').filter(Boolean).pop() || '';
+ }
+ }
+
+ // 3. Get branch from git
+ if (worktreeDir) {
+ try {
+ const { simpleGit } = await import('simple-git');
+ const git = simpleGit(worktreeDir);
+ branch = await Promise.race([
+ git.revparse(['--abbrev-ref', 'HEAD']),
+ new Promise((_, reject) => setTimeout(() => reject(new Error('git timeout')), 3000)),
+ ]).catch(() => '');
+ } catch {
+ // ignore — git may not be available
+ }
+ }
+
+ return {
+ project_name: formatProjectLabel(projectName),
+ worktree: worktreeDir,
+ branch: typeof branch === 'string' ? branch.trim() : '',
+ session_name: sessionTitle,
+ agent_name: agentName,
+ model_name: modelName,
+ last_message: '', // Populated by caller
+ session_id: sessionId || '',
+ };
+};
+
const stripJsonMarkdownWrapper = (value) => {
if (typeof value !== 'string') {
return '';
@@ -1009,6 +1361,30 @@ const sanitizeSettingsUpdate = (payload) => {
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') {
+ result.notificationTemplates = candidate.notificationTemplates;
+ }
+ 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;
}
@@ -1504,15 +1880,73 @@ const migrateSettingsFromLegacyCollapsedProjects = async (current) => {
return { settings: next, changed: true };
};
+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' },
+};
+
+const ensureNotificationTemplateShape = (templates) => {
+ const input = templates && typeof templates === 'object' ? templates : {};
+ let changed = false;
+ const next = {};
+
+ for (const event of Object.keys(DEFAULT_NOTIFICATION_TEMPLATES)) {
+ const currentEntry = input[event];
+ const base = DEFAULT_NOTIFICATION_TEMPLATES[event];
+ const currentTitle = typeof currentEntry?.title === 'string' ? currentEntry.title : base.title;
+ const currentMessage = typeof currentEntry?.message === 'string' ? currentEntry.message : base.message;
+ if (!currentEntry || typeof currentEntry.title !== 'string' || typeof currentEntry.message !== 'string') {
+ changed = true;
+ }
+ next[event] = { title: currentTitle, message: currentMessage };
+ }
+
+ return { templates: next, changed };
+};
+
+const migrateSettingsNotificationDefaults = async (current) => {
+ const settings = current && typeof current === 'object' ? current : {};
+ let changed = false;
+ const next = { ...settings };
+
+ if (typeof settings.notifyOnSubtasks !== 'boolean') {
+ next.notifyOnSubtasks = true;
+ changed = true;
+ }
+ if (typeof settings.notifyOnCompletion !== 'boolean') {
+ next.notifyOnCompletion = true;
+ changed = true;
+ }
+ if (typeof settings.notifyOnError !== 'boolean') {
+ next.notifyOnError = true;
+ changed = true;
+ }
+ if (typeof settings.notifyOnQuestion !== 'boolean') {
+ next.notifyOnQuestion = true;
+ changed = true;
+ }
+
+ const { templates, changed: templatesChanged } = ensureNotificationTemplateShape(settings.notificationTemplates);
+ if (templatesChanged || !settings.notificationTemplates || typeof settings.notificationTemplates !== 'object') {
+ next.notificationTemplates = templates;
+ changed = true;
+ }
+
+ return { settings: changed ? next : settings, changed };
+};
+
const readSettingsFromDiskMigrated = async () => {
const current = await readSettingsFromDisk();
const migration1 = await migrateSettingsFromLegacyLastDirectory(current);
const migration2 = await migrateSettingsFromLegacyThemePreferences(migration1.settings);
const migration3 = await migrateSettingsFromLegacyCollapsedProjects(migration2.settings);
- if (migration1.changed || migration2.changed || migration3.changed) {
- await writeSettingsToDisk(migration3.settings);
+ const migration4 = await migrateSettingsNotificationDefaults(migration3.settings);
+ if (migration1.changed || migration2.changed || migration3.changed || migration4.changed) {
+ await writeSettingsToDisk(migration4.settings);
}
- return migration3.settings;
+ return migration4.settings;
};
const getOrCreateVapidKeys = async () => {
@@ -2859,6 +3293,8 @@ const startGlobalEventWatcher = async () => {
buffer = buffer.slice(separatorIndex + 2);
separatorIndex = buffer.indexOf('\n\n');
const payload = parseSseDataPayload(block);
+ // Cache session titles from session.updated/session.created events
+ maybeCacheSessionInfoFromEvent(payload);
void maybeSendPushForTrigger(payload);
// Track session activity independently of UI (mirrors Tauri desktop behavior)
const transitions = deriveSessionActivityTransitions(payload);
@@ -3160,7 +3596,13 @@ function broadcastUiNotification(payload) {
try {
writeSseEvent(res, {
type: 'openchamber:notification',
- properties: payload,
+ properties: {
+ ...payload,
+ // Tell the UI whether the sidecar stdout notification channel is active.
+ // When true, the desktop UI should skip this SSE notification to avoid duplicates.
+ // When false (e.g. tauri dev), the UI must handle this SSE notification itself.
+ desktopStdoutActive: ENV_DESKTOP_NOTIFY,
+ },
});
} catch {
// ignore
@@ -3383,6 +3825,11 @@ const maybeSendPushForTrigger = async (payload) => {
}
}
+ // Check if completion notifications are enabled
+ if (settings.notifyOnCompletion === false) {
+ return;
+ }
+
const now = Date.now();
const lastAt = lastReadyNotificationAt.get(sessionId) ?? 0;
if (now - lastAt < PUSH_READY_COOLDOWN_MS) {
@@ -3390,11 +3837,49 @@ const maybeSendPushForTrigger = async (payload) => {
}
lastReadyNotificationAt.set(sessionId, now);
- const title = `${formatMode(info?.mode)} agent is ready`;
- const body = `${formatModelId(info?.modelID)} completed the task`;
+ // Resolve templates with fallback to legacy hardcoded values
+ let title = `${formatMode(info?.mode)} agent is ready`;
+ let body = `${formatModelId(info?.modelID)} completed the task`;
+
+ try {
+ const templates = settings.notificationTemplates || {};
+ const isSubtask = await fetchSessionParentId(sessionId);
+ const completionTemplate = isSubtask && settings.notifyOnSubtasks !== false
+ ? (templates.subtask || templates.completion || { title: '{agent_name} is ready', message: '{model_name} completed the task' })
+ : (templates.completion || { title: '{agent_name} is ready', message: '{model_name} completed the task' });
+
+ const variables = await buildTemplateVariables(payload, sessionId);
+
+ // Try fast-path (inline parts) first, then fetch from API
+ const messageId = info?.id;
+ let lastMessage = extractLastMessageText(payload);
+ if (!lastMessage) {
+ lastMessage = await fetchLastAssistantMessageText(sessionId, messageId);
+ }
+
+ // Summarize if enabled and above threshold, otherwise truncate to maxLastMessageLength
+ if (settings.summarizeLastMessage && lastMessage.length > (settings.summaryThreshold || 200)) {
+ lastMessage = await summarizeText(lastMessage, settings.summaryLength || 100);
+ } else {
+ const maxLen = typeof settings.maxLastMessageLength === 'number' && settings.maxLastMessageLength > 0
+ ? settings.maxLastMessageLength
+ : 250;
+ if (lastMessage.length > maxLen) {
+ lastMessage = lastMessage.slice(0, maxLen) + '...';
+ }
+ }
+ variables.last_message = lastMessage;
+
+ const resolvedTitle = resolveNotificationTemplate(completionTemplate.title, variables);
+ const resolvedBody = resolveNotificationTemplate(completionTemplate.message, variables);
+ if (resolvedTitle) title = resolvedTitle;
+ if (resolvedBody) body = resolvedBody;
+ } catch (err) {
+ console.warn('[Notification] Template resolution failed, using defaults:', err?.message || err);
+ }
if (settings.nativeNotificationsEnabled) {
- const payload = {
+ const notificationPayload = {
title,
body,
tag: `ready-${sessionId}`,
@@ -3402,8 +3887,8 @@ const maybeSendPushForTrigger = async (payload) => {
sessionId,
requireHidden: settings.notificationMode !== 'always',
};
- emitDesktopNotification(payload);
- broadcastUiNotification(payload);
+ emitDesktopNotification(notificationPayload);
+ broadcastUiNotification(notificationPayload);
}
await sendPushToAllUiSessions(
@@ -3421,6 +3906,74 @@ const maybeSendPushForTrigger = async (payload) => {
);
}
+ // Check for error finish
+ if (info?.role === 'assistant' && info?.finish === 'error' && sessionId) {
+ const settings = await readSettingsFromDisk();
+ if (settings.notifyOnError === false) return;
+
+ let title = 'Tool error';
+ let body = 'An error occurred';
+
+ try {
+ const variables = await buildTemplateVariables(payload, sessionId);
+
+ // Try fast-path (inline parts) first, then fetch from API
+ const errorMessageId = info?.id;
+ let lastMessage = extractLastMessageText(payload);
+ if (!lastMessage) {
+ lastMessage = await fetchLastAssistantMessageText(sessionId, errorMessageId);
+ }
+
+ // Summarize if enabled and above threshold, otherwise truncate to maxLastMessageLength
+ if (settings.summarizeLastMessage && lastMessage.length > (settings.summaryThreshold || 200)) {
+ lastMessage = await summarizeText(lastMessage, settings.summaryLength || 100);
+ } else {
+ const maxLen = typeof settings.maxLastMessageLength === 'number' && settings.maxLastMessageLength > 0
+ ? settings.maxLastMessageLength
+ : 250;
+ if (lastMessage.length > maxLen) {
+ lastMessage = lastMessage.slice(0, maxLen) + '...';
+ }
+ }
+ variables.last_message = lastMessage;
+
+ const errorTemplate = (settings.notificationTemplates || {}).error || { title: 'Tool error', message: '{last_message}' };
+ const resolvedTitle = resolveNotificationTemplate(errorTemplate.title, variables);
+ const resolvedBody = resolveNotificationTemplate(errorTemplate.message, variables);
+ if (resolvedTitle) title = resolvedTitle;
+ if (resolvedBody) body = resolvedBody;
+ } catch (err) {
+ console.warn('[Notification] Error template resolution failed, using defaults:', err?.message || err);
+ }
+
+ if (settings.nativeNotificationsEnabled) {
+ const notificationPayload = {
+ title,
+ body,
+ tag: `error-${sessionId}`,
+ kind: 'error',
+ sessionId,
+ requireHidden: settings.notificationMode !== 'always',
+ };
+ emitDesktopNotification(notificationPayload);
+ broadcastUiNotification(notificationPayload);
+ }
+
+ await sendPushToAllUiSessions(
+ {
+ title,
+ body,
+ tag: `error-${sessionId}`,
+ data: {
+ url: buildSessionDeepLinkUrl(sessionId),
+ sessionId,
+ type: 'error',
+ }
+ },
+ { requireNoSse: true }
+ );
+ }
+
return;
}
@@ -3431,24 +3984,51 @@ const maybeSendPushForTrigger = async (payload) => {
clearTimeout(existingTimer);
}
- const timer = setTimeout(() => {
+ const timer = setTimeout(async () => {
pushQuestionDebounceTimers.delete(sessionId);
- void readSettingsFromDisk().then((settings) => {
- if (!settings.nativeNotificationsEnabled) {
- return;
- }
+ const settings = await readSettingsFromDisk();
- const firstQuestion = payload.properties?.questions?.[0];
- const header = typeof firstQuestion?.header === 'string' ? firstQuestion.header.trim() : '';
- const questionText = typeof firstQuestion?.question === 'string' ? firstQuestion.question.trim() : '';
- const title = /plan\s*mode/i.test(header)
- ? 'Switch to plan mode'
- : /build\s*agent/i.test(header)
- ? 'Switch to build mode'
- : header || 'Input needed';
- const body = questionText || 'Agent is waiting for your response';
+ // Check if question notifications are enabled
+ if (settings.notifyOnQuestion === false) {
+ return;
+ }
+ if (!settings.nativeNotificationsEnabled) {
+ // Still send push even if native notifications are disabled
+ }
+
+ const firstQuestion = payload.properties?.questions?.[0];
+ const header = typeof firstQuestion?.header === 'string' ? firstQuestion.header.trim() : '';
+ const questionText = typeof firstQuestion?.question === 'string' ? firstQuestion.question.trim() : '';
+
+ // Legacy fallback title
+ let title = /plan\s*mode/i.test(header)
+ ? 'Switch to plan mode'
+ : /build\s*agent/i.test(header)
+ ? 'Switch to build mode'
+ : header || 'Input needed';
+ let body = questionText || 'Agent is waiting for your response';
+
+ try {
+ // Build template variables
+ const variables = await buildTemplateVariables(payload, sessionId);
+ variables.last_message = questionText || header || '';
+
+ // Get question template
+ const templates = settings.notificationTemplates || {};
+ const questionTemplate = templates.question || { title: 'Input needed', message: '{last_message}' };
+
+ // Resolve templates with fallback to legacy behavior
+ const resolvedTitle = resolveNotificationTemplate(questionTemplate.title, variables);
+ const resolvedBody = resolveNotificationTemplate(questionTemplate.message, variables);
+ if (resolvedTitle) title = resolvedTitle;
+ if (resolvedBody) body = resolvedBody;
+ } catch (err) {
+ console.warn('[Notification] Question template resolution failed, using defaults:', err?.message || err);
+ }
+
+ if (settings.nativeNotificationsEnabled) {
emitDesktopNotification({
kind: 'question',
title,
@@ -3466,17 +4046,7 @@ const maybeSendPushForTrigger = async (payload) => {
sessionId,
requireHidden: settings.notificationMode !== 'always',
});
- });
-
- const firstQuestion = payload.properties?.questions?.[0];
- const header = typeof firstQuestion?.header === 'string' ? firstQuestion.header.trim() : '';
- const questionText = typeof firstQuestion?.question === 'string' ? firstQuestion.question.trim() : '';
- const title = /plan\s*mode/i.test(header)
- ? 'Switch to plan mode'
- : /build\s*agent/i.test(header)
- ? 'Switch to build mode'
- : header || 'Input needed';
- const body = questionText || 'Agent is waiting for your response';
+ }
void sendPushToAllUiSessions(
{
@@ -3510,19 +4080,47 @@ const maybeSendPushForTrigger = async (payload) => {
clearTimeout(existingTimer);
}
- const timer = setTimeout(() => {
+ const timer = setTimeout(async () => {
pushPermissionDebounceTimers.delete(sessionId);
- void readSettingsFromDisk().then((settings) => {
- if (!settings.nativeNotificationsEnabled) {
- return;
- }
+ const settings = await readSettingsFromDisk();
- const title = 'Permission required';
- const sessionTitle = payload.properties?.sessionTitle;
- const body = typeof sessionTitle === 'string' && sessionTitle.trim().length > 0
- ? sessionTitle.trim()
- : 'Agent is waiting for your approval';
+ // Permission requests use the question event toggle (since permission requests are a type of "agent needs input")
+ if (settings.notifyOnQuestion === false) {
+ return;
+ }
+ if (!settings.nativeNotificationsEnabled) {
+ // Still send push even if native notifications are disabled
+ }
+
+ const sessionTitle = payload.properties?.sessionTitle;
+ const permissionText = typeof permission === 'string' && permission.length > 0 ? permission : '';
+ const fallbackMessage = typeof sessionTitle === 'string' && sessionTitle.trim().length > 0
+ ? sessionTitle.trim()
+ : permissionText || 'Agent is waiting for your approval';
+
+ let title = 'Permission required';
+ let body = fallbackMessage;
+
+ try {
+ // Build template variables
+ const variables = await buildTemplateVariables(payload, sessionId);
+ variables.last_message = fallbackMessage;
+
+ // Get question template (permission uses question template since it's an input request)
+ const templates = settings.notificationTemplates || {};
+ const questionTemplate = templates.question || { title: 'Permission required', message: '{last_message}' };
+
+ // Resolve templates with fallback to legacy behavior
+ const resolvedTitle = resolveNotificationTemplate(questionTemplate.title, variables);
+ const resolvedBody = resolveNotificationTemplate(questionTemplate.message, variables);
+ if (resolvedTitle) title = resolvedTitle;
+ if (resolvedBody) body = resolvedBody;
+ } catch (err) {
+ console.warn('[Notification] Permission template resolution failed, using defaults:', err?.message || err);
+ }
+
+ if (settings.nativeNotificationsEnabled) {
emitDesktopNotification({
kind: 'permission',
title,
@@ -3540,7 +4138,7 @@ const maybeSendPushForTrigger = async (payload) => {
sessionId,
requireHidden: settings.notificationMode !== 'always',
});
- });
+ }
if (requestKey) {
notifiedPermissionRequests.add(requestKey);
@@ -3548,8 +4146,8 @@ const maybeSendPushForTrigger = async (payload) => {
void sendPushToAllUiSessions(
{
- title: 'Permission required',
- body: typeof permission === 'string' && permission.length > 0 ? permission : 'Agent requested permission',
+ title,
+ body,
tag: `permission-${sessionId}`,
data: {
url: buildSessionDeepLinkUrl(sessionId),
@@ -4691,7 +5289,7 @@ async function main(options = {}) {
let targetUrl;
try {
targetUrl = new URL(buildOpenCodeUrl('/global/event', ''));
- } catch (error) {
+ } catch {
return res.status(503).json({ error: 'OpenCode service unavailable' });
}
@@ -4761,6 +5359,8 @@ async function main(options = {}) {
`);
const payload = parseSseDataPayload(block);
+ // Cache session titles from session.updated/session.created events (global stream)
+ maybeCacheSessionInfoFromEvent(payload);
const transitions = deriveSessionActivityTransitions(payload);
if (transitions && transitions.length > 0) {
for (const activity of transitions) {
@@ -4815,7 +5415,7 @@ async function main(options = {}) {
let targetUrl;
try {
targetUrl = new URL(buildOpenCodeUrl('/event', ''));
- } catch (error) {
+ } catch {
return res.status(503).json({ error: 'OpenCode service unavailable' });
}
@@ -4887,6 +5487,8 @@ async function main(options = {}) {
`);
const payload = parseSseDataPayload(block);
+ // Cache session titles from session.updated/session.created events (per-session stream)
+ maybeCacheSessionInfoFromEvent(payload);
const transitions = deriveSessionActivityTransitions(payload);
if (transitions && transitions.length > 0) {
for (const activity of transitions) {