diff --git a/ui/desktop/src/components/settings/app/AppSettingsSection.tsx b/ui/desktop/src/components/settings/app/AppSettingsSection.tsx index 0e62945826..bc3f5e8126 100644 --- a/ui/desktop/src/components/settings/app/AppSettingsSection.tsx +++ b/ui/desktop/src/components/settings/app/AppSettingsSection.tsx @@ -32,6 +32,14 @@ const i18n = defineMessages({ }, configGuide: { id: 'settings.notifications.configGuide', defaultMessage: 'Configuration guide' }, openSettings: { id: 'settings.notifications.openSettings', defaultMessage: 'Open Settings' }, + taskNotifications: { + id: 'settings.notifications.task.title', + defaultMessage: 'Task completion notifications', + }, + taskNotificationsDesc: { + id: 'settings.notifications.task.description', + defaultMessage: 'Notify when Goose finishes a task while the window is in the background', + }, menuBarIcon: { id: 'settings.menuBarIcon.title', defaultMessage: 'Menu bar icon' }, menuBarIconDesc: { id: 'settings.menuBarIcon.description', @@ -209,6 +217,7 @@ export default function AppSettingsSection({ scrollToSection }: AppSettingsSecti const [menuBarIconEnabled, setMenuBarIconEnabled] = useState(true); const [dockIconEnabled, setDockIconEnabled] = useState(true); const [wakelockEnabled, setWakelockEnabled] = useState(true); + const [notificationsEnabled, setNotificationsEnabled] = useState(true); const [isMacOS, setIsMacOS] = useState(false); const [isDockSwitchDisabled, setIsDockSwitchDisabled] = useState(false); const [showNotificationModal, setShowNotificationModal] = useState(false); @@ -258,6 +267,10 @@ export default function AppSettingsSection({ scrollToSection }: AppSettingsSecti setWakelockEnabled(enabled); }); + window.electron.getSetting('enableNotifications').then((enabled) => { + setNotificationsEnabled(enabled ?? true); + }); + if (isMacOS) { window.electron.getDockIconState().then((enabled) => { setDockIconEnabled(enabled); @@ -316,6 +329,12 @@ export default function AppSettingsSection({ scrollToSection }: AppSettingsSecti } }; + const handleNotificationsToggle = async (checked: boolean) => { + setNotificationsEnabled(checked); + await window.electron.setSetting('enableNotifications', checked); + trackSettingToggled('task_notifications', checked); + }; + const handleShowPricingToggle = async (checked: boolean) => { setShowPricing(checked); await window.electron.setSetting('showPricing', checked); @@ -371,6 +390,24 @@ export default function AppSettingsSection({ scrollToSection }: AppSettingsSecti +
+
+

+ {intl.formatMessage(i18n.taskNotifications)} +

+

+ {intl.formatMessage(i18n.taskNotificationsDesc)} +

+
+
+ +
+
+

{intl.formatMessage(i18n.menuBarIcon)}

diff --git a/ui/desktop/src/hooks/useChatStream.ts b/ui/desktop/src/hooks/useChatStream.ts index e2eb68b3fc..7d84b6253b 100644 --- a/ui/desktop/src/hooks/useChatStream.ts +++ b/ui/desktop/src/hooks/useChatStream.ts @@ -1,4 +1,5 @@ import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react'; +import { defineMessages, useIntl } from '../i18n'; import { v7 as uuidv7 } from 'uuid'; import { AppEvents } from '../constants/events'; import { ChatState } from '../types/chatState'; @@ -227,7 +228,7 @@ function createEventProcessor( dispatch: React.Dispatch, onFinish: (error?: string) => void, sessionId: string, - onReloadNeeded?: () => void, + onReloadNeeded?: () => void ) { let currentMessages = initialMessages; const reduceMotion = prefersReducedMotion(); @@ -343,11 +344,23 @@ function createEventProcessor( return processEvent; } +const i18n = defineMessages({ + notificationTitle: { + id: 'chat.notification.taskComplete.title', + defaultMessage: 'Goose finished the task.', + }, + notificationBody: { + id: 'chat.notification.taskComplete.body', + defaultMessage: 'Click here to bring Goose back into focus.', + }, +}); + export function useChatStream({ sessionId, onStreamFinish, onSessionLoaded, }: UseChatStreamProps): UseChatStreamReturn { + const intl = useIntl(); const [state, dispatch] = useReducer(streamReducer, initialState); // Long-lived SSE connection for this session @@ -358,7 +371,6 @@ export function useChatStream({ const activeRequestSessionIdRef = useRef(null); const activeAbortRef = useRef(null); const activeUnsubscribeRef = useRef<(() => void) | null>(null); - const lastInteractionTimeRef = useRef(Date.now()); // When ActiveRequests fires before resumeAgent populates messages (cold mount), // defer the reattach until the session is loaded so the event processor has // the full conversation history. Events are buffered in the meantime. @@ -399,12 +411,21 @@ export function useChatStream({ dispatch({ type: 'STREAM_FINISH', payload: error }); - const timeSinceLastInteraction = Date.now() - lastInteractionTimeRef.current; - if (!error && timeSinceLastInteraction > 60000) { - window.electron.showNotification({ - title: 'goose finished the task.', - body: 'Click here to expand.', - }); + if (!error) { + try { + const [notificationsEnabled, anyWindowFocused] = await Promise.all([ + window.electron.getSetting('enableNotifications'), + window.electron.isAnyWindowFocused(), + ]); + if (notificationsEnabled === true && !anyWindowFocused) { + window.electron.showNotification({ + title: intl.formatMessage(i18n.notificationTitle), + body: intl.formatMessage(i18n.notificationBody), + }); + } + } catch (notifyError) { + console.warn('Failed to show task completion notification:', notifyError); + } } const isNewSession = sessionId && sessionId.match(/^\d{8}_\d{6}$/); @@ -444,7 +465,7 @@ export function useChatStream({ onStreamFinish(); }, - [onStreamFinish, sessionId] + [intl, onStreamFinish, sessionId] ); // Reload the full conversation from the server, e.g. after the SSE @@ -453,14 +474,16 @@ export function useChatStream({ getSession({ path: { session_id: sessionId }, throwOnError: true, - }).then((response) => { - const session = response.data as Session; - if (session?.conversation) { - dispatch({ type: 'SET_MESSAGES', payload: session.conversation }); - } - }).catch((e) => { - console.warn('Failed to reload conversation after buffer overflow:', e); - }); + }) + .then((response) => { + const session = response.data as Session; + if (session?.conversation) { + dispatch({ type: 'SET_MESSAGES', payload: session.conversation }); + } + }) + .catch((e) => { + console.warn('Failed to reload conversation after buffer overflow:', e); + }); }, [sessionId]); // Perform the actual reattach: wire up an event processor and listener @@ -479,7 +502,7 @@ export function useChatStream({ dispatch, onFinish, sessionId, - reloadConversation, + reloadConversation ); // Replay any events that were buffered during cold-mount wait @@ -523,7 +546,7 @@ export function useChatStream({ }); activeUnsubscribeRef.current = unsubscribe; }, - [sessionId, addListener, onFinish, reloadConversation], + [sessionId, addListener, onFinish, reloadConversation] ); doReattachRef.current = doReattach; @@ -582,7 +605,7 @@ export function useChatStream({ targetSessionId: string, userMessage: Message, currentMessages: Message[], - overrideConversation?: Message[], + overrideConversation?: Message[] ) => { const requestId = uuidv7(); const abortController = new AbortController(); @@ -596,7 +619,7 @@ export function useChatStream({ dispatch, onFinish, targetSessionId, - reloadConversation, + reloadConversation ); const unsubscribe = addListener(requestId, (event) => { @@ -801,8 +824,6 @@ export function useChatStream({ return; } - lastInteractionTimeRef.current = Date.now(); - // Emit session-created event for first message in a new session if (!hasExistingMessages && hasNewMessage) { window.dispatchEvent(new CustomEvent(AppEvents.SESSION_CREATED)); @@ -876,8 +897,6 @@ export function useChatStream({ return; } - lastInteractionTimeRef.current = Date.now(); - const responseMessage = createElicitationResponseMessage(elicitationId, userData); const currentMessages = [...currentState.messages, responseMessage]; @@ -961,7 +980,6 @@ export function useChatStream({ activeRequestSessionIdRef.current = null; dispatch({ type: 'SET_CHAT_STATE', payload: ChatState.Idle }); - lastInteractionTimeRef.current = Date.now(); }, []); const onMessageUpdate = useCallback( diff --git a/ui/desktop/src/i18n/messages/en.json b/ui/desktop/src/i18n/messages/en.json index 74e433d204..c7f53f9a4b 100644 --- a/ui/desktop/src/i18n/messages/en.json +++ b/ui/desktop/src/i18n/messages/en.json @@ -113,6 +113,12 @@ "cardButtons.launch": { "defaultMessage": "Launch" }, + "chat.notification.taskComplete.body": { + "defaultMessage": "Click here to bring Goose back into focus." + }, + "chat.notification.taskComplete.title": { + "defaultMessage": "Goose finished the task." + }, "chatInput.contextWindow": { "defaultMessage": "Context window" }, @@ -1449,7 +1455,7 @@ "defaultMessage": "Downloaded" }, "huggingFaceModelSearch.downloading": { - "defaultMessage": "Downloading\u2026" + "defaultMessage": "Downloading…" }, "huggingFaceModelSearch.loadingVariants": { "defaultMessage": "Loading variants..." @@ -1845,7 +1851,7 @@ "defaultMessage": "Vision" }, "localInferenceSettings.visionEncoderDownloading": { - "defaultMessage": "Vision encoder downloading\u2026" + "defaultMessage": "Vision encoder downloading…" }, "localInferenceSettings.visionEncoderNotDownloaded": { "defaultMessage": "Vision encoder not downloaded" @@ -4052,6 +4058,12 @@ "settings.notifications.openSettings": { "defaultMessage": "Open Settings" }, + "settings.notifications.task.description": { + "defaultMessage": "Notify when Goose finishes a task while the window is in the background" + }, + "settings.notifications.task.title": { + "defaultMessage": "Task completion notifications" + }, "settings.notifications.title": { "defaultMessage": "Notifications" }, diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts index 821ae79783..28332b8c2c 100644 --- a/ui/desktop/src/main.ts +++ b/ui/desktop/src/main.ts @@ -749,11 +749,14 @@ const createChat = async (app: App, options: CreateChatOptions = {}) => { // Nudge the user if mesh is their provider but isn't running. // Delay to let the renderer mount before sending the IPC event. setTimeout(() => { - mesh.checkProviderRunning(goosedClient).then((ok) => { - if (!ok && !mainWindow.isDestroyed()) { - mainWindow.webContents.send('mesh-not-running'); - } - }).catch(() => {}); + mesh + .checkProviderRunning(goosedClient) + .then((ok) => { + if (!ok && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('mesh-not-running'); + } + }) + .catch(() => {}); }, 5000); // Let windowStateKeeper manage the window @@ -1359,6 +1362,7 @@ const validSettingKeys: Set = new Set([ 'showMenuBarIcon', 'showDockIcon', 'enableWakelock', + 'enableNotifications', 'spellcheckEnabled', 'externalGoosed', 'globalShortcut', @@ -1572,6 +1576,10 @@ ipcMain.handle('get-spellcheck-state', () => { } }); +ipcMain.handle('is-any-window-focused', () => { + return BrowserWindow.getFocusedWindow() !== null; +}); + // Add file/directory selection handler ipcMain.handle('select-file-or-directory', async (_event, defaultPath?: string) => { const dialogOptions: OpenDialogOptions = { diff --git a/ui/desktop/src/preload.ts b/ui/desktop/src/preload.ts index 97334ef88b..10e1ae0c34 100644 --- a/ui/desktop/src/preload.ts +++ b/ui/desktop/src/preload.ts @@ -147,6 +147,7 @@ type ElectronAPI = { setSpellcheck: (enable: boolean) => Promise; getSpellcheckState: () => Promise; openNotificationsSettings: () => Promise; + isAnyWindowFocused: () => Promise; onMouseBackButtonClicked: (callback: () => void) => void; offMouseBackButtonClicked: (callback: () => void) => void; on: ( @@ -267,6 +268,7 @@ const electronAPI: ElectronAPI = { setSpellcheck: (enable: boolean) => ipcRenderer.invoke('set-spellcheck', enable), getSpellcheckState: () => ipcRenderer.invoke('get-spellcheck-state'), openNotificationsSettings: () => ipcRenderer.invoke('open-notifications-settings'), + isAnyWindowFocused: () => ipcRenderer.invoke('is-any-window-focused'), onMouseBackButtonClicked: (callback: () => void) => { // Wrapper that ignores the event parameter. const wrappedCallback = (_event: Electron.IpcRendererEvent) => callback(); diff --git a/ui/desktop/src/utils/settings.ts b/ui/desktop/src/utils/settings.ts index 6e491b9c35..4afefd5a53 100644 --- a/ui/desktop/src/utils/settings.ts +++ b/ui/desktop/src/utils/settings.ts @@ -33,6 +33,7 @@ export interface Settings { showMenuBarIcon: boolean; showDockIcon: boolean; enableWakelock: boolean; + enableNotifications: boolean; spellcheckEnabled: boolean; externalGoosed: ExternalGoosedConfig; globalShortcut?: string | null; @@ -69,6 +70,7 @@ export const defaultSettings: Settings = { showMenuBarIcon: true, showDockIcon: true, enableWakelock: false, + enableNotifications: true, spellcheckEnabled: true, keyboardShortcuts: defaultKeyboardShortcuts, externalGoosed: {