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) => ( +
+ {event} +
+
+ + 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} + /> +
+
+ + 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} + /> +
+
+
+ ))} +
+ )} + + {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) {