diff --git a/packages/ui/src/sync/sync-context.tsx b/packages/ui/src/sync/sync-context.tsx index d35273ca..8f69e3f6 100644 --- a/packages/ui/src/sync/sync-context.tsx +++ b/packages/ui/src/sync/sync-context.tsx @@ -6,6 +6,7 @@ import type { StoreApi } from "zustand" import { useStore } from "zustand" import type { OpencodeClient } from "@opencode-ai/sdk/v2/client" import { createEventPipeline } from "./event-pipeline" +import { isVSCodeRuntime } from "@/lib/desktop" import { reduceGlobalEvent, applyGlobalProject, applyDirectoryEvent } from "./event-reducer" import { useGlobalSyncStore, type GlobalSyncStore } from "./global-sync-store" import { ChildStoreManager, type DirectoryStore } from "./child-store" @@ -308,6 +309,15 @@ type EventRoutingIndex = { sessionMessageIdsById: Map> } +const SHOULD_DISPATCH_VSCODE_NOTIFICATIONS = isVSCodeRuntime() + +const dispatchVSCodeRuntimeNotificationEvent = (directory: string, payload: Event) => { + if (!SHOULD_DISPATCH_VSCODE_NOTIFICATIONS || typeof window === "undefined") return + window.dispatchEvent(new CustomEvent("openchamber:vscode-notification-event", { + detail: { directory, payload }, + })) +} + const createEventRoutingIndex = (): EventRoutingIndex => ({ sessionDirectoryById: new Map(), messageSessionById: new Map(), @@ -1432,6 +1442,7 @@ export function SyncProvider(props: { return resolveDirectoryFromRoutingIndex(routingIndex, directory, payload, childStores) }, onEvent: (directory, payload) => { + dispatchVSCodeRuntimeNotificationEvent(directory, payload) if (payload.type === "installation.update-available") { const version = typeof (payload.properties as { version?: unknown })?.version === "string" ? (payload.properties as { version: string }).version diff --git a/packages/vscode/src/AgentManagerPanelProvider.ts b/packages/vscode/src/AgentManagerPanelProvider.ts index 657b4875..965a727d 100644 --- a/packages/vscode/src/AgentManagerPanelProvider.ts +++ b/packages/vscode/src/AgentManagerPanelProvider.ts @@ -137,6 +137,18 @@ export class AgentManagerPanelProvider { }); } + public notifyWindowFocusChanged(focused: boolean): void { + if (!this._panel) { + return; + } + + this._panel.webview.postMessage({ + type: 'command', + command: 'windowFocusChanged', + payload: { focused }, + }); + } + private _sendCachedState() { if (!this._panel) { return; @@ -147,6 +159,7 @@ export class AgentManagerPanelProvider { status: this._cachedStatus, error: this._cachedError, }); + this.notifyWindowFocusChanged(vscode.window.state.focused); } private _buildSseHeaders(extra?: Record): Record { diff --git a/packages/vscode/src/ChatViewProvider.ts b/packages/vscode/src/ChatViewProvider.ts index 7e155942..5770d2f1 100644 --- a/packages/vscode/src/ChatViewProvider.ts +++ b/packages/vscode/src/ChatViewProvider.ts @@ -275,6 +275,18 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { }); } + public notifyWindowFocusChanged(focused: boolean): void { + if (!this._view) { + return; + } + + this._view.webview.postMessage({ + type: 'command', + command: 'windowFocusChanged', + payload: { focused }, + }); + } + // Message delivery confirmation private _confirmMessage(messageId: string) { this._pendingMessages.delete(messageId); @@ -354,6 +366,7 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { status: this._cachedStatus, error: this._cachedError, }); + this.notifyWindowFocusChanged(vscode.window.state.focused); } private _scheduleBroadcast(): void { diff --git a/packages/vscode/src/SessionEditorPanelProvider.ts b/packages/vscode/src/SessionEditorPanelProvider.ts index e2e8dad3..476ffcac 100644 --- a/packages/vscode/src/SessionEditorPanelProvider.ts +++ b/packages/vscode/src/SessionEditorPanelProvider.ts @@ -149,12 +149,27 @@ export class SessionEditorPanelProvider { } } + public notifyWindowFocusChanged(focused: boolean): void { + for (const entry of this._panels.values()) { + entry.panel.webview.postMessage({ + type: 'command', + command: 'windowFocusChanged', + payload: { focused }, + }); + } + } + private _sendCachedStateToPanel(entry: SessionPanelState) { entry.panel.webview.postMessage({ type: 'connectionStatus', status: this._cachedStatus, error: this._cachedError, }); + entry.panel.webview.postMessage({ + type: 'command', + command: 'windowFocusChanged', + payload: { focused: vscode.window.state.focused }, + }); } private _disposePanel(sessionId: string) { diff --git a/packages/vscode/src/extension.ts b/packages/vscode/src/extension.ts index 9c903d98..bbae2aab 100644 --- a/packages/vscode/src/extension.ts +++ b/packages/vscode/src/extension.ts @@ -197,6 +197,14 @@ export async function activate(context: vscode.ExtensionContext) { }) ); + context.subscriptions.push( + vscode.window.onDidChangeWindowState((state) => { + chatViewProvider?.notifyWindowFocusChanged(state.focused); + sessionEditorProvider?.notifyWindowFocusChanged(state.focused); + agentManagerProvider?.notifyWindowFocusChanged(state.focused); + }) + ); + context.subscriptions.push( vscode.commands.registerCommand('openchamber.openAgentManager', () => { agentManagerProvider?.createOrShow(); diff --git a/packages/vscode/src/sessionActivityWatcher.ts b/packages/vscode/src/sessionActivityWatcher.ts index 723a32ae..3c0a2c42 100644 --- a/packages/vscode/src/sessionActivityWatcher.ts +++ b/packages/vscode/src/sessionActivityWatcher.ts @@ -26,6 +26,19 @@ const clearGlobalEventWatcherRetry = (): void => { globalEventWatcherRetryTimer = null; }; +const unwrapGlobalEventPayload = (eventData: unknown): Record | null => { + if (!eventData || typeof eventData !== 'object') { + return null; + } + + const record = eventData as { payload?: unknown }; + if (record.payload && typeof record.payload === 'object') { + return record.payload as Record; + } + + return eventData as Record; +}; + const reconcileSessionActivityFromStatus = async (manager: OpenCodeManager): Promise => { const baseUrl = manager.getApiUrl(); if (!baseUrl) { @@ -61,7 +74,6 @@ const reconcileSessionActivityFromStatus = async (manager: OpenCodeManager): Pro const setSessionActivityPhase = (sessionId: string, phase: ActivityPhase): void => { if (!sessionId) return; - // Cancel existing cooldown timer const existingTimer = sessionActivityCooldowns.get(sessionId); if (existingTimer) { clearTimeout(existingTimer); @@ -69,36 +81,30 @@ const setSessionActivityPhase = (sessionId: string, phase: ActivityPhase): void } const current = sessionActivityPhases.get(sessionId); - if (current?.phase === phase) return; // No change + if (current?.phase === phase) return; sessionActivityPhases.set(sessionId, { phase, updatedAt: Date.now() }); - // Notify webview if available - if (chatViewProvider) { - chatViewProvider.postMessage({ - type: 'openchamber:session-activity', - properties: { - sessionId, - phase, - }, - }); - } + chatViewProvider?.postMessage({ + type: 'openchamber:session-activity', + properties: { + sessionId, + phase, + }, + }); - // Schedule transition from cooldown to idle if (phase === 'cooldown') { const timer = setTimeout(() => { const now = sessionActivityPhases.get(sessionId); if (now?.phase === 'cooldown') { sessionActivityPhases.set(sessionId, { phase: 'idle', updatedAt: Date.now() }); - if (chatViewProvider) { - chatViewProvider.postMessage({ - type: 'openchamber:session-activity', - properties: { - sessionId, - phase: 'idle', - }, - }); - } + chatViewProvider?.postMessage({ + type: 'openchamber:session-activity', + properties: { + sessionId, + phase: 'idle', + }, + }); } sessionActivityCooldowns.delete(sessionId); }, SESSION_COOLDOWN_DURATION_MS); @@ -230,22 +236,19 @@ export const startGlobalEventWatcher = async ( const result = await client.global.event({ signal, sseMaxRetryAttempts: 0, - onSseEvent: (event) => { - const payload = event.data; - if (!payload || typeof payload !== 'object') { - return; - } - const activity = deriveSessionActivity(payload as Record); - if (activity) { - setSessionActivityPhase(activity.sessionId, activity.phase); - } - }, }); console.log('[VSCode:Activity] connected'); - for await (const _ of result.stream) { - void _; + for await (const event of result.stream) { + const payload = unwrapGlobalEventPayload((event as { payload?: unknown }).payload ?? event); + if (payload) { + const activity = deriveSessionActivity(payload); + if (activity) { + setSessionActivityPhase(activity.sessionId, activity.phase); + } + } + if (signal.aborted) { break; } @@ -279,7 +282,6 @@ export const stopGlobalEventWatcher = (): void => { globalEventWatcherAbortController = null; chatViewProvider = null; - // Clear all cooldown timers for (const timer of sessionActivityCooldowns.values()) { clearTimeout(timer); } diff --git a/packages/vscode/webview/api/notifications.ts b/packages/vscode/webview/api/notifications.ts index c0a89c0e..353300b5 100644 --- a/packages/vscode/webview/api/notifications.ts +++ b/packages/vscode/webview/api/notifications.ts @@ -1,23 +1,40 @@ import type { NotificationPayload, NotificationsAPI } from '@openchamber/ui/lib/api/types'; -import { sendBridgeMessage } from './bridge'; -type NotifyResponse = { shown?: boolean }; +const showWebviewNotification = async (payload?: NotificationPayload): Promise => { + if (typeof Notification === 'undefined') { + return false; + } + + if (Notification.permission === 'default') { + const permission = await Notification.requestPermission(); + if (permission !== 'granted') { + return false; + } + } + + if (Notification.permission !== 'granted') { + return false; + } + + const title = typeof payload?.title === 'string' && payload.title.trim().length > 0 + ? payload.title.trim() + : 'OpenChamber'; + const body = typeof payload?.body === 'string' ? payload.body : ''; + + new Notification(title, { body }); + return true; +}; export const createVSCodeNotificationsAPI = (): NotificationsAPI => ({ async notifyAgentCompletion(payload?: NotificationPayload): Promise { try { - const response = await sendBridgeMessage('notifications:notify', { payload }); - return Boolean(response?.shown); + return await showWebviewNotification(payload); } catch { return false; } }, async canNotify(): Promise { - try { - return await sendBridgeMessage('notifications:can-notify'); - } catch { - return false; - } + return typeof Notification !== 'undefined' && Notification.permission !== 'denied'; }, }); diff --git a/packages/vscode/webview/main.tsx b/packages/vscode/webview/main.tsx index 7be6ee43..b0ccd8f4 100644 --- a/packages/vscode/webview/main.tsx +++ b/packages/vscode/webview/main.tsx @@ -36,6 +36,7 @@ declare global { __OPENCHAMBER_CONNECTION__?: { status: ConnectionStatus; error?: string; cliAvailable?: boolean }; __OPENCHAMBER_HOME__?: string; __OPENCHAMBER_PANEL_TYPE__?: PanelType; + __OPENCHAMBER_VSCODE_WINDOW_FOCUSED__?: boolean; } } @@ -435,6 +436,19 @@ const handleLocalApiRequest = async (url: URL, init?: RequestInit) => { }); } + if (normalizedPathname === '/api/notifications/auto-accept' && method === 'POST') { + const bodyText = await extractBodyText(url, init, method); + const body = bodyText + ? JSON.parse(bodyText) as { sessionId?: unknown; enabled?: unknown } + : {}; + const result = await sendBridgeMessage<{ success?: boolean }>('api:notifications/auto-accept', body) + .catch(() => ({ success: false })); + return new Response(JSON.stringify(result), { + status: result?.success === false ? 400 : 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + if (/^\/api\/sessions\/[^/]+\/message-sent$/.test(normalizedPathname) && method === 'POST') { const sessionId = normalizedPathname.split('/')[3] || ''; return new Response( @@ -527,7 +541,7 @@ const handleLocalApiRequest = async (url: URL, init?: RequestInit) => { }); } - if ((pathname === '/api/tts/speak' || pathname === '/api/tts/say/speak' || pathname === '/api/text/summarize') && method === 'POST') { + if ((pathname === '/api/tts/speak' || pathname === '/api/tts/say/speak') && method === 'POST') { return new Response(JSON.stringify({ error: 'TTS endpoints are not available in VS Code runtime' }), { status: 501, headers: { 'Content-Type': 'application/json' }, @@ -1228,6 +1242,352 @@ onCommand('showSettings', () => { window.dispatchEvent(new CustomEvent('openchamber:navigate', { detail: { view: 'settings' } })); }); +const showOpenChamberNotification = (payload: { title?: unknown; body?: unknown; sessionId?: unknown; requireHidden?: unknown } | undefined) => { + if (typeof Notification === 'undefined') { + return false; + } + + const show = () => { + const isVSCodeWindowFocused = window.__OPENCHAMBER_VSCODE_WINDOW_FOCUSED__ ?? document.hasFocus(); + if (payload?.requireHidden === true && isVSCodeWindowFocused) { + return false; + } + if (Notification.permission !== 'granted') { + return false; + } + + const title = typeof payload?.title === 'string' && payload.title.trim().length > 0 + ? payload.title.trim() + : 'OpenChamber'; + const body = typeof payload?.body === 'string' ? payload.body : ''; + const sessionId = typeof payload?.sessionId === 'string' && payload.sessionId.trim().length > 0 + ? payload.sessionId.trim() + : ''; + + const notification = new Notification(title, { body }); + notification.onclick = () => { + if (sessionId) { + import('@/sync/session-ui-store').then(({ useSessionUIStore }) => { + useSessionUIStore.getState().setCurrentSession(sessionId); + }); + } + window.dispatchEvent(new CustomEvent('openchamber:navigate', { detail: { view: 'chat' } })); + }; + return true; + }; + + if (Notification.permission === 'default') { + void Notification.requestPermission().then((permission) => { + if (permission === 'granted') { + show(); + } + }); + return true; + } + + return show(); +}; + +onCommand('showNotification', (payload) => { + showOpenChamberNotification(payload as { title?: unknown; body?: unknown; sessionId?: unknown; requireHidden?: unknown } | undefined); +}); + +onCommand('windowFocusChanged', (payload) => { + if (typeof payload === 'object' && payload && typeof (payload as { focused?: unknown }).focused === 'boolean') { + window.__OPENCHAMBER_VSCODE_WINDOW_FOCUSED__ = (payload as { focused: boolean }).focused; + } +}); + +const readyNotificationCooldowns = new Map(); +const READY_NOTIFICATION_COOLDOWN_MS = 5000; +const DEFAULT_NOTIFICATION_MESSAGE_MAX_LENGTH = 250; +let notificationSettingsSyncPromise: Promise | null = null; + +const getPayloadString = (value: unknown): string => typeof value === 'string' ? value.trim() : ''; + +const normalizeNotificationPlainText = (text: string): string => text + .replace(/```[\s\S]*?```/g, ' ') + .replace(/`([^`]*)`/g, '$1') + .replace(/^[\t ]*[-*+]\s+/gm, '') + .replace(/^#{1,6}\s+/gm, '') + .replace(/\*\*(.*?)\*\*/g, '$1') + .replace(/__(.*?)__/g, '$1') + .replace(/\*(.*?)\*/g, '$1') + .replace(/_(.*?)_/g, '$1') + .replace(/\[(.*?)\]\((.*?)\)/g, '$1') + .replace(/\s*\n\s*/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + +const truncateNotificationText = (text: string, maxLength: number): string => ( + text.length <= maxLength ? text : `${text.slice(0, maxLength)}...` +); + +const resolvePositiveNotificationNumber = (value: unknown, fallback: number): number => ( + typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : fallback +); + +const ensureNotificationSettingsSynced = async () => { + if (!notificationSettingsSyncPromise) { + notificationSettingsSyncPromise = import('@/lib/persistence') + .then(({ syncDesktopSettings }) => syncDesktopSettings()) + .catch((error) => { + console.warn('[OpenChamber] Failed to sync notification settings:', error); + }); + } + await notificationSettingsSyncPromise; +}; + +const prepareNotificationLastMessage = ( + message: string, + settings: { maxLastMessageLength: number }, +): string => { + const maxLength = resolvePositiveNotificationNumber(settings.maxLastMessageLength, DEFAULT_NOTIFICATION_MESSAGE_MAX_LENGTH); + return truncateNotificationText(normalizeNotificationPlainText(message), maxLength); +}; + +const resolveTemplate = (template: string, variables: Record): string => ( + template.replace(/\{(\w+)\}/g, (_match, key: string) => variables[key] ?? '') +); + +const shouldApplyTemplateMessage = (template: string, resolved: string, variables: Record) => { + if (!resolved) return false; + if (template.includes('{last_message}')) { + return variables.last_message.trim().length > 0; + } + return true; +}; + +const formatNotificationLabel = (raw: string, fallback: string): string => { + if (!raw) return fallback; + return raw.split(/[-_\s]+/).filter(Boolean).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(' '); +}; + +const extractNotificationTextFromParts = (parts: unknown): string => { + if (!Array.isArray(parts)) return ''; + return parts + .map((part) => { + if (!part || typeof part !== 'object') return ''; + const entry = part as { type?: unknown; text?: unknown; content?: unknown }; + if (entry.type === 'text' || typeof entry.text === 'string' || typeof entry.content === 'string') { + return typeof entry.text === 'string' ? entry.text : typeof entry.content === 'string' ? entry.content : ''; + } + return ''; + }) + .filter(Boolean) + .join('\n') + .trim(); +}; + +const extractNotificationLastMessage = (payload: Record): string => { + const properties = (payload.properties ?? payload) as Record; + const info = properties.info as Record | undefined; + if (!info) return ''; + return extractNotificationTextFromParts(info.parts ?? properties.parts) || extractNotificationTextFromParts(info.content); +}; + +const fetchLastAssistantMessageText = async (sessionId: string, messageId?: string): Promise => { + if (!sessionId) return ''; + + try { + const response = await fetch(`/api/session/${encodeURIComponent(sessionId)}/message?limit=5`, { + method: 'GET', + headers: { Accept: 'application/json' }, + signal: AbortSignal.timeout(3000), + }); + if (!response.ok) return ''; + + const messages = await response.json().catch(() => null) as unknown; + if (!Array.isArray(messages)) return ''; + + let target = messageId + ? messages.find((message) => { + const info = message && typeof message === 'object' + ? (message as { info?: { id?: unknown; role?: unknown } }).info + : undefined; + return info?.id === messageId && info?.role === 'assistant'; + }) + : null; + + if (!target) { + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index]; + const info = message && typeof message === 'object' + ? (message as { info?: { role?: unknown; finish?: unknown } }).info + : undefined; + if (info?.role === 'assistant' && info?.finish === 'stop') { + target = message; + break; + } + } + } + + if (!target || typeof target !== 'object') return ''; + const message = target as { parts?: unknown; content?: unknown; info?: { parts?: unknown; content?: unknown } }; + return extractNotificationTextFromParts(message.parts ?? message.info?.parts) + || extractNotificationTextFromParts(message.content ?? message.info?.content); + } catch { + return ''; + } +}; + +const getNotificationTemplate = ( + settings: { notificationTemplates?: Record }, + key: 'completion' | 'error' | 'question', + fallback: { title: string; message: string }, +) => { + const candidate = settings.notificationTemplates?.[key]; + return { + title: typeof candidate?.title === 'string' ? candidate.title : fallback.title, + message: typeof candidate?.message === 'string' ? candidate.message : fallback.message, + }; +}; + +const buildNotificationVariables = (payload: Record, sessionId: string, lastMessage: string): Record => { + const properties = (payload.properties ?? payload) as Record; + const info = properties.info as Record | undefined; + const pathInfo = info?.path as { root?: unknown; cwd?: unknown } | undefined; + const worktree = getPayloadString(pathInfo?.root ?? pathInfo?.cwd); + const modelId = getPayloadString(info?.modelID ?? info?.modelId ?? (info?.model as { modelID?: unknown } | undefined)?.modelID); + return { + project_name: worktree.split(/[\\/]/).filter(Boolean).pop() || '', + worktree, + branch: '', + session_name: getPayloadString(properties.sessionTitle ?? (properties.session as { title?: unknown } | undefined)?.title ?? info?.sessionTitle), + agent_name: formatNotificationLabel(getPayloadString(info?.agent ?? info?.mode), 'Agent'), + model_name: formatNotificationLabel(modelId, 'Assistant'), + last_message: lastMessage, + session_id: sessionId, + }; +}; + +const getNotificationSessionId = (payload: Record): string => { + const properties = (payload.properties ?? payload) as Record; + const info = properties.info as Record | undefined; + return getPayloadString(info?.sessionID ?? info?.sessionId ?? properties.sessionID ?? properties.sessionId ?? properties.session); +}; + +window.addEventListener('openchamber:vscode-notification-event', (event) => { + const detail = (event as CustomEvent<{ payload?: unknown }>).detail; + const payload = detail?.payload; + if (!payload || typeof payload !== 'object') { + return; + } + + const record = payload as Record; + const type = getPayloadString(record.type); + const properties = (record.properties ?? record) as Record; + const info = properties.info as Record | undefined; + const sessionId = getNotificationSessionId(record); + if (!sessionId) { + return; + } + + Promise.all([ + import('@/stores/useUIStore'), + import('@/stores/permissionStore'), + ]).then(async ([{ useUIStore }, { usePermissionStore }]) => { + const localSettings = useUIStore.getState(); + await ensureNotificationSettingsSynced(); + const syncedSettings = useUIStore.getState(); + const settings = { + ...syncedSettings, + nativeNotificationsEnabled: localSettings.nativeNotificationsEnabled, + notificationMode: localSettings.notificationMode, + notifyOnCompletion: localSettings.notifyOnCompletion, + notifyOnError: localSettings.notifyOnError, + notifyOnQuestion: localSettings.notifyOnQuestion, + notificationTemplates: localSettings.notificationTemplates, + summarizeLastMessage: localSettings.summarizeLastMessage, + summaryThreshold: localSettings.summaryThreshold, + summaryLength: localSettings.summaryLength, + maxLastMessageLength: localSettings.maxLastMessageLength, + }; + if (!settings.nativeNotificationsEnabled) { + return; + } + const requireHidden = settings.notificationMode !== 'always'; + const messageId = getPayloadString(info?.id); + const rawLastMessage = extractNotificationLastMessage(record) || await fetchLastAssistantMessageText(sessionId, messageId); + const lastMessage = prepareNotificationLastMessage( + rawLastMessage, + settings, + ); + const variables = buildNotificationVariables(record, sessionId, lastMessage); + + if (type === 'message.updated' && getPayloadString(info?.role) === 'assistant') { + const finish = getPayloadString(info?.finish); + if (finish === 'stop') { + if (!settings.notifyOnCompletion) return; + const now = Date.now(); + const lastAt = readyNotificationCooldowns.get(sessionId) ?? 0; + if (now - lastAt < READY_NOTIFICATION_COOLDOWN_MS) return; + readyNotificationCooldowns.set(sessionId, now); + const template = getNotificationTemplate(settings, 'completion', { title: '{agent_name} is ready', message: '{model_name} completed the task' }); + const title = resolveTemplate(template.title, variables) || 'Agent is ready'; + const body = resolveTemplate(template.message, variables); + showOpenChamberNotification({ + title, + body: shouldApplyTemplateMessage(template.message, body, variables) ? body : `${variables.model_name} completed the task`, + sessionId, + requireHidden, + }); + return; + } + + if (finish === 'error') { + if (!settings.notifyOnError) return; + const template = getNotificationTemplate(settings, 'error', { title: 'Tool error', message: '{last_message}' }); + const title = resolveTemplate(template.title, variables) || 'Tool error'; + const body = resolveTemplate(template.message, variables); + showOpenChamberNotification({ + title, + body: shouldApplyTemplateMessage(template.message, body, variables) ? body : 'An error occurred', + sessionId, + requireHidden, + }); + } + } + + if (type === 'question.asked') { + if (!settings.notifyOnQuestion) return; + const questions = Array.isArray(properties.questions) ? properties.questions : []; + const firstQuestion = questions[0] as Record | undefined; + const header = getPayloadString(firstQuestion?.header); + const questionText = getPayloadString(firstQuestion?.question); + const questionVariables = { ...variables, last_message: questionText || header }; + const template = getNotificationTemplate(settings, 'question', { title: 'Input needed', message: '{last_message}' }); + const title = resolveTemplate(template.title, questionVariables) || (/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 = resolveTemplate(template.message, questionVariables); + showOpenChamberNotification({ + title, + body: shouldApplyTemplateMessage(template.message, body, questionVariables) ? body : questionText || 'Agent is waiting for your response', + sessionId, + requireHidden, + }); + return; + } + + if (type === 'permission.asked') { + if (!settings.notifyOnQuestion) return; + if (usePermissionStore.getState().isSessionAutoAccepting(sessionId)) return; + const permission = getPayloadString(properties.permission); + const sessionTitle = getPayloadString(properties.sessionTitle); + const fallbackMessage = sessionTitle || permission || 'Agent is waiting for your approval'; + const permissionVariables = { ...variables, last_message: fallbackMessage }; + const template = getNotificationTemplate(settings, 'question', { title: 'Permission required', message: '{last_message}' }); + const title = resolveTemplate(template.title, permissionVariables) || 'Permission required'; + const body = resolveTemplate(template.message, permissionVariables); + showOpenChamberNotification({ + title, + body: shouldApplyTemplateMessage(template.message, body, permissionVariables) ? body : fallbackMessage, + sessionId, + requireHidden, + }); + } + }); +}); + // Listen for settings sync command from extension (broadcast to all VS Code webviews) onCommand('settingsSynced', () => { import('@openchamber/ui/lib/persistence').then(({ syncDesktopSettings }) => {