diff --git a/packages/ui/src/components/sections/openchamber/NotificationSettings.tsx b/packages/ui/src/components/sections/openchamber/NotificationSettings.tsx index 36e78072..fa6fd383 100644 --- a/packages/ui/src/components/sections/openchamber/NotificationSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/NotificationSettings.tsx @@ -127,7 +127,7 @@ export const NotificationSettings: React.FC = () => { } }; - const canShowNotifications = isDesktop || (isBrowser && typeof Notification !== 'undefined' && Notification.permission === 'granted'); + const canShowNotifications = isDesktop || isVSCode || (isBrowser && typeof Notification !== 'undefined' && Notification.permission === 'granted'); const updateTemplate = ( event: 'completion' | 'error' | 'question' | 'subtask', @@ -489,7 +489,7 @@ export const NotificationSettings: React.FC = () => { {isVSCode && (

- VS Code runtime handles notifications separately natively. + When enabled, notifications are delivered through VS Code native notifications.

)} diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx index 316a7bf0..9d93f438 100644 --- a/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx @@ -173,7 +173,7 @@ export const OpenChamberVisualSettings: React.FC const hasLayoutSettings = shouldShow('fontSize') || shouldShow('terminalFontSize') || shouldShow('spacing') || shouldShow('cornerRadius') || shouldShow('inputBarOffset'); const hasBehaviorSettings = shouldShow('toolOutput') || shouldShow('diffLayout') - || shouldShow('mobileStatusBar') + || (shouldShow('mobileStatusBar') && isMobile) || shouldShow('dotfiles') || shouldShow('reasoning') || shouldShow('queueMode') @@ -548,9 +548,9 @@ export const OpenChamberVisualSettings: React.FC )} - {(shouldShow('mobileStatusBar') || shouldShow('dotfiles') || shouldShow('queueMode') || shouldShow('persistDraft') || shouldShow('reasoning') || shouldShow('textJustificationActivity')) && ( + {((shouldShow('mobileStatusBar') && isMobile) || shouldShow('dotfiles') || shouldShow('queueMode') || shouldShow('persistDraft') || shouldShow('reasoning') || shouldShow('textJustificationActivity')) && (
- {shouldShow('mobileStatusBar') && ( + {shouldShow('mobileStatusBar') && isMobile && (
= ({ onClose, forceMobile return SETTINGS_PAGE_METADATA .filter((page) => page.slug !== 'home') .filter((page) => isPageAvailable(page, runtimeCtx)) + .filter((page) => !(runtimeCtx.isVSCode && page.slug === 'projects')) .filter((page) => !(isMobile && page.slug === 'shortcuts')); }, [runtimeCtx, isMobile]); diff --git a/packages/ui/src/hooks/useEventStream.ts b/packages/ui/src/hooks/useEventStream.ts index ca06cc2f..468b42fd 100644 --- a/packages/ui/src/hooks/useEventStream.ts +++ b/packages/ui/src/hooks/useEventStream.ts @@ -132,7 +132,6 @@ export const useEventStream = () => { } = useSessionStore(); const { checkConnection } = useConfigStore(); - const nativeNotificationsEnabled = useUIStore((state) => state.nativeNotificationsEnabled); const fallbackDirectory = useDirectoryStore((state) => state.currentDirectory); const activeSessionDirectory = React.useMemo(() => { @@ -378,6 +377,7 @@ export const useEventStream = () => { const questionToastShownRef = React.useRef>(new Set()); const notifiedMessagesRef = React.useRef>(new Set()); const notifiedQuestionsRef = React.useRef>(new Set()); + const serverNotificationEventSeenRef = React.useRef(false); const modeSwitchToastShownRef = React.useRef>(new Set()); const lastUserAgentSelectionRef = React.useRef>(new Map()); @@ -403,6 +403,50 @@ export const useEventStream = () => { >(() => Promise.resolve()); const scheduleReconnectRef = React.useRef<(hint?: string) => void>(() => {}); + const isNotificationContextHidden = React.useCallback((isVSCodeRuntime: boolean): boolean => { + if (visibilityStateRef.current === 'hidden') { + return true; + } + if (isVSCodeRuntime && typeof document !== 'undefined') { + return !document.hasFocus(); + } + return false; + }, []); + + const dispatchRuntimeNotification = React.useCallback((payload: { + title: string; + body?: string; + tag?: string; + requireHidden?: boolean; + }) => { + const runtimeAPIs = getRegisteredRuntimeAPIs(); + if (!runtimeAPIs?.notifications) { + return; + } + + const title = typeof payload.title === 'string' ? payload.title.trim() : ''; + if (!title) { + return; + } + + const settings = useUIStore.getState(); + if (!settings.nativeNotificationsEnabled) { + return; + } + + const isVSCodeRuntime = Boolean(runtimeAPIs.runtime?.isVSCode); + const shouldRequireHidden = Boolean(payload.requireHidden) || settings.notificationMode === 'hidden-only'; + if (shouldRequireHidden && !isNotificationContextHidden(isVSCodeRuntime)) { + return; + } + + void runtimeAPIs.notifications.notifyAgentCompletion({ + title, + body: typeof payload.body === 'string' ? payload.body : '', + tag: typeof payload.tag === 'string' ? payload.tag : undefined, + }); + }, [isNotificationContextHidden]); + const maybeBootstrapIfStale = React.useCallback( (reason: string) => { const now = Date.now(); @@ -1253,8 +1297,9 @@ export const useEventStream = () => { const finishCandidate = (message as { finish?: unknown }).finish; const finish = typeof finishCandidate === 'string' ? finishCandidate : null; const eventHasStopFinish = finish === 'stop'; + const eventHasErrorFinish = finish === 'error'; - if (!hasParts && !completedFromServer && !hasCompletedStatus && !eventHasStopFinish) break; + if (!hasParts && !completedFromServer && !hasCompletedStatus && !eventHasStopFinish && !eventHasErrorFinish) break; if ((messageExt as { role?: unknown }).role === 'assistant' && hasParts) { const hasQuestionTool = partsArray.some((part) => ( @@ -1277,6 +1322,44 @@ export const useEventStream = () => { updateMessageInfo(sessionId, messageId, message as unknown as Message); + const messageRole = typeof (message as { role?: unknown }).role === 'string' + ? (message as { role: string }).role + : null; + const runtimeAPIs = getRegisteredRuntimeAPIs(); + const shouldSynthesizeNotifications = Boolean(runtimeAPIs?.runtime?.isVSCode) && !serverNotificationEventSeenRef.current; + if (shouldSynthesizeNotifications && messageRole === 'assistant') { + const settings = useUIStore.getState(); + const sessionInfo = useSessionStore.getState().sessions.find((entry) => entry.id === sessionId); + const sessionTitle = typeof sessionInfo?.title === 'string' ? sessionInfo.title.trim() : ''; + + if (eventHasStopFinish && settings.notifyOnCompletion !== false) { + const isSubtask = Boolean(sessionInfo?.parentID); + if (!(settings.notifyOnSubtasks === false && isSubtask)) { + const notificationKey = `ready:${sessionId}:${messageId}`; + if (!notifiedMessagesRef.current.has(notificationKey)) { + notifiedMessagesRef.current.add(notificationKey); + dispatchRuntimeNotification({ + title: 'Agent is ready', + body: sessionTitle || 'Task completed', + tag: `ready-${sessionId}`, + }); + } + } + } + + if (eventHasErrorFinish && settings.notifyOnError !== false) { + const notificationKey = `error:${sessionId}:${messageId}`; + if (!notifiedMessagesRef.current.has(notificationKey)) { + notifiedMessagesRef.current.add(notificationKey); + dispatchRuntimeNotification({ + title: 'Tool error', + body: sessionTitle || 'An error occurred', + tag: `error-${sessionId}`, + }); + } + } + } + if (hasParts && (messageExt as { role?: unknown }).role !== 'user') { const storeState = useSessionStore.getState(); const existingMessages = storeState.messages.get(sessionId) || []; @@ -1445,6 +1528,25 @@ export const useEventStream = () => { addPermission(request); + const runtimeAPIs = getRegisteredRuntimeAPIs(); + if (runtimeAPIs?.runtime?.isVSCode && !serverNotificationEventSeenRef.current) { + const settings = useUIStore.getState(); + if (settings.notifyOnQuestion !== false) { + const notificationKey = `permission:${request.sessionID}:${request.id}`; + if (!notifiedQuestionsRef.current.has(notificationKey)) { + notifiedQuestionsRef.current.add(notificationKey); + const sessionTitle = + useSessionStore.getState().sessions.find((s) => s.id === request.sessionID)?.title || + 'Agent is waiting for your approval'; + dispatchRuntimeNotification({ + title: 'Permission required', + body: sessionTitle, + tag: `permission-${request.sessionID}:${request.id}`, + }); + } + } + } + // Notify if permission is for another session (common with child sessions). const toastKey = `${request.sessionID}:${request.id}`; if (!permissionToastShownRef.current.has(toastKey)) { @@ -1515,9 +1617,28 @@ export const useEventStream = () => { const request = props as unknown as QuestionRequest; addQuestion(request); + const runtimeAPIs = getRegisteredRuntimeAPIs(); + if (runtimeAPIs?.runtime?.isVSCode && !serverNotificationEventSeenRef.current) { + const settings = useUIStore.getState(); + if (settings.notifyOnQuestion !== false) { + const notificationKey = `question:${request.sessionID}:${request.id}`; + if (!notifiedQuestionsRef.current.has(notificationKey)) { + notifiedQuestionsRef.current.add(notificationKey); + const firstQuestion = Array.isArray(request.questions) ? request.questions[0] : undefined; + const questionHeader = typeof firstQuestion?.header === 'string' ? firstQuestion.header.trim() : ''; + const questionText = typeof firstQuestion?.question === 'string' ? firstQuestion.question.trim() : ''; + dispatchRuntimeNotification({ + title: questionHeader || 'Input needed', + body: questionText || 'Agent is waiting for your response', + tag: `question-${request.sessionID}:${request.id}`, + }); + } + } + } + const toastKey = `${request.sessionID}:${request.id}`; - // notifications are emitted server-side (see openchamber:notification) + // web/desktop use server-emitted notifications; VS Code may synthesize locally if (!questionToastShownRef.current.has(toastKey)) { setTimeout(() => { @@ -1586,15 +1707,12 @@ export const useEventStream = () => { } case 'openchamber:notification': { + serverNotificationEventSeenRef.current = true; const title = typeof (props as { title?: unknown }).title === 'string' ? (props as { title: string }).title : ''; const body = typeof (props as { body?: unknown }).body === 'string' ? (props as { body: string }).body : ''; const tag = typeof (props as { tag?: unknown }).tag === 'string' ? (props as { tag: string }).tag : undefined; const requireHidden = Boolean((props as { requireHidden?: unknown }).requireHidden); - if (requireHidden && visibilityStateRef.current !== 'hidden') { - break; - } - // 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, @@ -1603,14 +1721,7 @@ export const useEventStream = () => { break; } - if (!nativeNotificationsEnabled) { - break; - } - - const runtimeAPIs = getRegisteredRuntimeAPIs(); - if (runtimeAPIs?.notifications && title) { - void runtimeAPIs.notifications.notifyAgentCompletion({ title, body, tag }); - } + dispatchRuntimeNotification({ title, body, tag, requireHidden }); break; } @@ -1629,7 +1740,6 @@ export const useEventStream = () => { } }, [ currentSessionId, - nativeNotificationsEnabled, addStreamingPart, completeStreamingMessage, updateMessageInfo, @@ -1650,6 +1760,7 @@ export const useEventStream = () => { bootstrapState, effectiveDirectory, updateSessionStatus, + dispatchRuntimeNotification, ]); // --- Stable callback refs (Part A) --- @@ -2109,6 +2220,7 @@ export const useEventStream = () => { notifiedMessagesRef.current.clear(); // eslint-disable-next-line react-hooks/exhaustive-deps -- Intentionally accessing current ref value at cleanup time notifiedQuestionsRef.current.clear(); + serverNotificationEventSeenRef.current = false; pendingResumeRef.current = false; visibilityStateRef.current = resolveVisibilityState(); diff --git a/packages/vscode/src/bridge.ts b/packages/vscode/src/bridge.ts index 30ea1ed6..8ecb0c9b 100644 --- a/packages/vscode/src/bridge.ts +++ b/packages/vscode/src/bridge.ts @@ -79,6 +79,16 @@ type ApiProxyResponsePayload = { bodyBase64: string; }; +type NotificationBridgePayload = { + title?: string; + body?: string; + tag?: string; +}; + +type NotificationsNotifyRequestPayload = { + payload?: NotificationBridgePayload; +}; + interface FileEntry { name: string; path: string; @@ -2792,6 +2802,28 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo } } + case 'notifications:can-notify': { + return { id, type, success: true, data: true }; + } + + case 'notifications:notify': { + const request = (payload || {}) as NotificationsNotifyRequestPayload; + const notification = request.payload || {}; + const title = typeof notification.title === 'string' ? notification.title.trim() : ''; + const body = typeof notification.body === 'string' ? notification.body.trim() : ''; + + const message = title && body + ? `${title}: ${body}` + : title || body; + + if (!message) { + return { id, type, success: true, data: { shown: false } }; + } + + void vscode.window.showInformationMessage(message); + return { id, type, success: true, data: { shown: true } }; + } + // ============== Git Operations ============== case 'api:git/check': { diff --git a/packages/vscode/webview/api/index.ts b/packages/vscode/webview/api/index.ts index d76e3af8..97e38489 100644 --- a/packages/vscode/webview/api/index.ts +++ b/packages/vscode/webview/api/index.ts @@ -1,4 +1,4 @@ -import type { RuntimeAPIs, TerminalAPI, NotificationsAPI } from '@openchamber/ui/lib/api/types'; +import type { RuntimeAPIs, TerminalAPI } from '@openchamber/ui/lib/api/types'; import { createVSCodeFilesAPI } from './files'; import { createVSCodeSettingsAPI } from './settings'; import { createVSCodePermissionsAPI } from './permissions'; @@ -7,6 +7,7 @@ import { createVSCodeEditorAPI } from './editor'; import { createVSCodeGitAPI } from './git'; import { createVSCodeActionsAPI } from './vscode'; import { createVSCodeGitHubAPI } from './github'; +import { createVSCodeNotificationsAPI } from './notifications'; // Stub APIs return sensible defaults instead of throwing const createStubTerminalAPI = (): TerminalAPI => ({ @@ -17,11 +18,6 @@ const createStubTerminalAPI = (): TerminalAPI => ({ close: async () => {}, }); -const createStubNotificationsAPI = (): NotificationsAPI => ({ - notifyAgentCompletion: async () => true, - canNotify: () => true, -}); - export const createVSCodeAPIs = (): RuntimeAPIs => ({ runtime: { platform: 'vscode', isDesktop: false, isVSCode: true, label: 'VS Code Extension' }, terminal: createStubTerminalAPI(), @@ -29,7 +25,7 @@ export const createVSCodeAPIs = (): RuntimeAPIs => ({ files: createVSCodeFilesAPI(), settings: createVSCodeSettingsAPI(), permissions: createVSCodePermissionsAPI(), - notifications: createStubNotificationsAPI(), + notifications: createVSCodeNotificationsAPI(), github: createVSCodeGitHubAPI(), tools: createVSCodeToolsAPI(), editor: createVSCodeEditorAPI(), diff --git a/packages/vscode/webview/api/notifications.ts b/packages/vscode/webview/api/notifications.ts new file mode 100644 index 00000000..c0a89c0e --- /dev/null +++ b/packages/vscode/webview/api/notifications.ts @@ -0,0 +1,23 @@ +import type { NotificationPayload, NotificationsAPI } from '@openchamber/ui/lib/api/types'; +import { sendBridgeMessage } from './bridge'; + +type NotifyResponse = { shown?: boolean }; + +export const createVSCodeNotificationsAPI = (): NotificationsAPI => ({ + async notifyAgentCompletion(payload?: NotificationPayload): Promise { + try { + const response = await sendBridgeMessage('notifications:notify', { payload }); + return Boolean(response?.shown); + } catch { + return false; + } + }, + + async canNotify(): Promise { + try { + return await sendBridgeMessage('notifications:can-notify'); + } catch { + return false; + } + }, +});