feat: Improve notifications with templates, summarization, and more (#317)
* feat: Successfully added new notification settings to Zustand * feat: Successfully implemented the Events subsection * feat: Successfully wired new notification settings * feat: Added notification template editor section to Notifications * feat: Added server-side template resolution and summarize * feat: Improve notifications with templates, summarization, and more * fix: session not populated in messages * fix notification template first-run defaults --------- Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
4194cd25d6
commit
fbf0ec50ee
+651
-49
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user