feat: desktop notification when goose finishes a task (#8647)

Signed-off-by: Abhijay Jain <Abhijay007j@gmail.com>
This commit is contained in:
Abhijay Jain
2026-04-22 23:26:49 +05:30
committed by GitHub
parent 15cfd127ca
commit ee46794b18
6 changed files with 112 additions and 33 deletions
@@ -32,6 +32,14 @@ const i18n = defineMessages({
}, },
configGuide: { id: 'settings.notifications.configGuide', defaultMessage: 'Configuration guide' }, configGuide: { id: 'settings.notifications.configGuide', defaultMessage: 'Configuration guide' },
openSettings: { id: 'settings.notifications.openSettings', defaultMessage: 'Open Settings' }, 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' }, menuBarIcon: { id: 'settings.menuBarIcon.title', defaultMessage: 'Menu bar icon' },
menuBarIconDesc: { menuBarIconDesc: {
id: 'settings.menuBarIcon.description', id: 'settings.menuBarIcon.description',
@@ -209,6 +217,7 @@ export default function AppSettingsSection({ scrollToSection }: AppSettingsSecti
const [menuBarIconEnabled, setMenuBarIconEnabled] = useState(true); const [menuBarIconEnabled, setMenuBarIconEnabled] = useState(true);
const [dockIconEnabled, setDockIconEnabled] = useState(true); const [dockIconEnabled, setDockIconEnabled] = useState(true);
const [wakelockEnabled, setWakelockEnabled] = useState(true); const [wakelockEnabled, setWakelockEnabled] = useState(true);
const [notificationsEnabled, setNotificationsEnabled] = useState(true);
const [isMacOS, setIsMacOS] = useState(false); const [isMacOS, setIsMacOS] = useState(false);
const [isDockSwitchDisabled, setIsDockSwitchDisabled] = useState(false); const [isDockSwitchDisabled, setIsDockSwitchDisabled] = useState(false);
const [showNotificationModal, setShowNotificationModal] = useState(false); const [showNotificationModal, setShowNotificationModal] = useState(false);
@@ -258,6 +267,10 @@ export default function AppSettingsSection({ scrollToSection }: AppSettingsSecti
setWakelockEnabled(enabled); setWakelockEnabled(enabled);
}); });
window.electron.getSetting('enableNotifications').then((enabled) => {
setNotificationsEnabled(enabled ?? true);
});
if (isMacOS) { if (isMacOS) {
window.electron.getDockIconState().then((enabled) => { window.electron.getDockIconState().then((enabled) => {
setDockIconEnabled(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) => { const handleShowPricingToggle = async (checked: boolean) => {
setShowPricing(checked); setShowPricing(checked);
await window.electron.setSetting('showPricing', checked); await window.electron.setSetting('showPricing', checked);
@@ -371,6 +390,24 @@ export default function AppSettingsSection({ scrollToSection }: AppSettingsSecti
</div> </div>
</div> </div>
<div className="flex items-center justify-between">
<div>
<h3 className="text-text-primary text-xs">
{intl.formatMessage(i18n.taskNotifications)}
</h3>
<p className="text-xs text-text-secondary max-w-md mt-[2px]">
{intl.formatMessage(i18n.taskNotificationsDesc)}
</p>
</div>
<div className="flex items-center">
<Switch
checked={notificationsEnabled}
onCheckedChange={handleNotificationsToggle}
variant="mono"
/>
</div>
</div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h3 className="text-text-primary text-xs">{intl.formatMessage(i18n.menuBarIcon)}</h3> <h3 className="text-text-primary text-xs">{intl.formatMessage(i18n.menuBarIcon)}</h3>
+44 -26
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react'; import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react';
import { defineMessages, useIntl } from '../i18n';
import { v7 as uuidv7 } from 'uuid'; import { v7 as uuidv7 } from 'uuid';
import { AppEvents } from '../constants/events'; import { AppEvents } from '../constants/events';
import { ChatState } from '../types/chatState'; import { ChatState } from '../types/chatState';
@@ -227,7 +228,7 @@ function createEventProcessor(
dispatch: React.Dispatch<StreamAction>, dispatch: React.Dispatch<StreamAction>,
onFinish: (error?: string) => void, onFinish: (error?: string) => void,
sessionId: string, sessionId: string,
onReloadNeeded?: () => void, onReloadNeeded?: () => void
) { ) {
let currentMessages = initialMessages; let currentMessages = initialMessages;
const reduceMotion = prefersReducedMotion(); const reduceMotion = prefersReducedMotion();
@@ -343,11 +344,23 @@ function createEventProcessor(
return processEvent; 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({ export function useChatStream({
sessionId, sessionId,
onStreamFinish, onStreamFinish,
onSessionLoaded, onSessionLoaded,
}: UseChatStreamProps): UseChatStreamReturn { }: UseChatStreamProps): UseChatStreamReturn {
const intl = useIntl();
const [state, dispatch] = useReducer(streamReducer, initialState); const [state, dispatch] = useReducer(streamReducer, initialState);
// Long-lived SSE connection for this session // Long-lived SSE connection for this session
@@ -358,7 +371,6 @@ export function useChatStream({
const activeRequestSessionIdRef = useRef<string | null>(null); const activeRequestSessionIdRef = useRef<string | null>(null);
const activeAbortRef = useRef<AbortController | null>(null); const activeAbortRef = useRef<AbortController | null>(null);
const activeUnsubscribeRef = useRef<(() => void) | null>(null); const activeUnsubscribeRef = useRef<(() => void) | null>(null);
const lastInteractionTimeRef = useRef<number>(Date.now());
// When ActiveRequests fires before resumeAgent populates messages (cold mount), // When ActiveRequests fires before resumeAgent populates messages (cold mount),
// defer the reattach until the session is loaded so the event processor has // defer the reattach until the session is loaded so the event processor has
// the full conversation history. Events are buffered in the meantime. // the full conversation history. Events are buffered in the meantime.
@@ -399,12 +411,21 @@ export function useChatStream({
dispatch({ type: 'STREAM_FINISH', payload: error }); dispatch({ type: 'STREAM_FINISH', payload: error });
const timeSinceLastInteraction = Date.now() - lastInteractionTimeRef.current; if (!error) {
if (!error && timeSinceLastInteraction > 60000) { try {
window.electron.showNotification({ const [notificationsEnabled, anyWindowFocused] = await Promise.all([
title: 'goose finished the task.', window.electron.getSetting('enableNotifications'),
body: 'Click here to expand.', 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}$/); const isNewSession = sessionId && sessionId.match(/^\d{8}_\d{6}$/);
@@ -444,7 +465,7 @@ export function useChatStream({
onStreamFinish(); onStreamFinish();
}, },
[onStreamFinish, sessionId] [intl, onStreamFinish, sessionId]
); );
// Reload the full conversation from the server, e.g. after the SSE // Reload the full conversation from the server, e.g. after the SSE
@@ -453,14 +474,16 @@ export function useChatStream({
getSession({ getSession({
path: { session_id: sessionId }, path: { session_id: sessionId },
throwOnError: true, throwOnError: true,
}).then((response) => { })
const session = response.data as Session; .then((response) => {
if (session?.conversation) { const session = response.data as Session;
dispatch({ type: 'SET_MESSAGES', payload: session.conversation }); if (session?.conversation) {
} dispatch({ type: 'SET_MESSAGES', payload: session.conversation });
}).catch((e) => { }
console.warn('Failed to reload conversation after buffer overflow:', e); })
}); .catch((e) => {
console.warn('Failed to reload conversation after buffer overflow:', e);
});
}, [sessionId]); }, [sessionId]);
// Perform the actual reattach: wire up an event processor and listener // Perform the actual reattach: wire up an event processor and listener
@@ -479,7 +502,7 @@ export function useChatStream({
dispatch, dispatch,
onFinish, onFinish,
sessionId, sessionId,
reloadConversation, reloadConversation
); );
// Replay any events that were buffered during cold-mount wait // Replay any events that were buffered during cold-mount wait
@@ -523,7 +546,7 @@ export function useChatStream({
}); });
activeUnsubscribeRef.current = unsubscribe; activeUnsubscribeRef.current = unsubscribe;
}, },
[sessionId, addListener, onFinish, reloadConversation], [sessionId, addListener, onFinish, reloadConversation]
); );
doReattachRef.current = doReattach; doReattachRef.current = doReattach;
@@ -582,7 +605,7 @@ export function useChatStream({
targetSessionId: string, targetSessionId: string,
userMessage: Message, userMessage: Message,
currentMessages: Message[], currentMessages: Message[],
overrideConversation?: Message[], overrideConversation?: Message[]
) => { ) => {
const requestId = uuidv7(); const requestId = uuidv7();
const abortController = new AbortController(); const abortController = new AbortController();
@@ -596,7 +619,7 @@ export function useChatStream({
dispatch, dispatch,
onFinish, onFinish,
targetSessionId, targetSessionId,
reloadConversation, reloadConversation
); );
const unsubscribe = addListener(requestId, (event) => { const unsubscribe = addListener(requestId, (event) => {
@@ -801,8 +824,6 @@ export function useChatStream({
return; return;
} }
lastInteractionTimeRef.current = Date.now();
// Emit session-created event for first message in a new session // Emit session-created event for first message in a new session
if (!hasExistingMessages && hasNewMessage) { if (!hasExistingMessages && hasNewMessage) {
window.dispatchEvent(new CustomEvent(AppEvents.SESSION_CREATED)); window.dispatchEvent(new CustomEvent(AppEvents.SESSION_CREATED));
@@ -876,8 +897,6 @@ export function useChatStream({
return; return;
} }
lastInteractionTimeRef.current = Date.now();
const responseMessage = createElicitationResponseMessage(elicitationId, userData); const responseMessage = createElicitationResponseMessage(elicitationId, userData);
const currentMessages = [...currentState.messages, responseMessage]; const currentMessages = [...currentState.messages, responseMessage];
@@ -961,7 +980,6 @@ export function useChatStream({
activeRequestSessionIdRef.current = null; activeRequestSessionIdRef.current = null;
dispatch({ type: 'SET_CHAT_STATE', payload: ChatState.Idle }); dispatch({ type: 'SET_CHAT_STATE', payload: ChatState.Idle });
lastInteractionTimeRef.current = Date.now();
}, []); }, []);
const onMessageUpdate = useCallback( const onMessageUpdate = useCallback(
+14 -2
View File
@@ -113,6 +113,12 @@
"cardButtons.launch": { "cardButtons.launch": {
"defaultMessage": "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": { "chatInput.contextWindow": {
"defaultMessage": "Context window" "defaultMessage": "Context window"
}, },
@@ -1449,7 +1455,7 @@
"defaultMessage": "Downloaded" "defaultMessage": "Downloaded"
}, },
"huggingFaceModelSearch.downloading": { "huggingFaceModelSearch.downloading": {
"defaultMessage": "Downloading\u2026" "defaultMessage": "Downloading"
}, },
"huggingFaceModelSearch.loadingVariants": { "huggingFaceModelSearch.loadingVariants": {
"defaultMessage": "Loading variants..." "defaultMessage": "Loading variants..."
@@ -1845,7 +1851,7 @@
"defaultMessage": "Vision" "defaultMessage": "Vision"
}, },
"localInferenceSettings.visionEncoderDownloading": { "localInferenceSettings.visionEncoderDownloading": {
"defaultMessage": "Vision encoder downloading\u2026" "defaultMessage": "Vision encoder downloading"
}, },
"localInferenceSettings.visionEncoderNotDownloaded": { "localInferenceSettings.visionEncoderNotDownloaded": {
"defaultMessage": "Vision encoder not downloaded" "defaultMessage": "Vision encoder not downloaded"
@@ -4052,6 +4058,12 @@
"settings.notifications.openSettings": { "settings.notifications.openSettings": {
"defaultMessage": "Open Settings" "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": { "settings.notifications.title": {
"defaultMessage": "Notifications" "defaultMessage": "Notifications"
}, },
+13 -5
View File
@@ -749,11 +749,14 @@ const createChat = async (app: App, options: CreateChatOptions = {}) => {
// Nudge the user if mesh is their provider but isn't running. // Nudge the user if mesh is their provider but isn't running.
// Delay to let the renderer mount before sending the IPC event. // Delay to let the renderer mount before sending the IPC event.
setTimeout(() => { setTimeout(() => {
mesh.checkProviderRunning(goosedClient).then((ok) => { mesh
if (!ok && !mainWindow.isDestroyed()) { .checkProviderRunning(goosedClient)
mainWindow.webContents.send('mesh-not-running'); .then((ok) => {
} if (!ok && !mainWindow.isDestroyed()) {
}).catch(() => {}); mainWindow.webContents.send('mesh-not-running');
}
})
.catch(() => {});
}, 5000); }, 5000);
// Let windowStateKeeper manage the window // Let windowStateKeeper manage the window
@@ -1359,6 +1362,7 @@ const validSettingKeys: Set<string> = new Set([
'showMenuBarIcon', 'showMenuBarIcon',
'showDockIcon', 'showDockIcon',
'enableWakelock', 'enableWakelock',
'enableNotifications',
'spellcheckEnabled', 'spellcheckEnabled',
'externalGoosed', 'externalGoosed',
'globalShortcut', '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 // Add file/directory selection handler
ipcMain.handle('select-file-or-directory', async (_event, defaultPath?: string) => { ipcMain.handle('select-file-or-directory', async (_event, defaultPath?: string) => {
const dialogOptions: OpenDialogOptions = { const dialogOptions: OpenDialogOptions = {
+2
View File
@@ -147,6 +147,7 @@ type ElectronAPI = {
setSpellcheck: (enable: boolean) => Promise<boolean>; setSpellcheck: (enable: boolean) => Promise<boolean>;
getSpellcheckState: () => Promise<boolean>; getSpellcheckState: () => Promise<boolean>;
openNotificationsSettings: () => Promise<boolean>; openNotificationsSettings: () => Promise<boolean>;
isAnyWindowFocused: () => Promise<boolean>;
onMouseBackButtonClicked: (callback: () => void) => void; onMouseBackButtonClicked: (callback: () => void) => void;
offMouseBackButtonClicked: (callback: () => void) => void; offMouseBackButtonClicked: (callback: () => void) => void;
on: ( on: (
@@ -267,6 +268,7 @@ const electronAPI: ElectronAPI = {
setSpellcheck: (enable: boolean) => ipcRenderer.invoke('set-spellcheck', enable), setSpellcheck: (enable: boolean) => ipcRenderer.invoke('set-spellcheck', enable),
getSpellcheckState: () => ipcRenderer.invoke('get-spellcheck-state'), getSpellcheckState: () => ipcRenderer.invoke('get-spellcheck-state'),
openNotificationsSettings: () => ipcRenderer.invoke('open-notifications-settings'), openNotificationsSettings: () => ipcRenderer.invoke('open-notifications-settings'),
isAnyWindowFocused: () => ipcRenderer.invoke('is-any-window-focused'),
onMouseBackButtonClicked: (callback: () => void) => { onMouseBackButtonClicked: (callback: () => void) => {
// Wrapper that ignores the event parameter. // Wrapper that ignores the event parameter.
const wrappedCallback = (_event: Electron.IpcRendererEvent) => callback(); const wrappedCallback = (_event: Electron.IpcRendererEvent) => callback();
+2
View File
@@ -33,6 +33,7 @@ export interface Settings {
showMenuBarIcon: boolean; showMenuBarIcon: boolean;
showDockIcon: boolean; showDockIcon: boolean;
enableWakelock: boolean; enableWakelock: boolean;
enableNotifications: boolean;
spellcheckEnabled: boolean; spellcheckEnabled: boolean;
externalGoosed: ExternalGoosedConfig; externalGoosed: ExternalGoosedConfig;
globalShortcut?: string | null; globalShortcut?: string | null;
@@ -69,6 +70,7 @@ export const defaultSettings: Settings = {
showMenuBarIcon: true, showMenuBarIcon: true,
showDockIcon: true, showDockIcon: true,
enableWakelock: false, enableWakelock: false,
enableNotifications: true,
spellcheckEnabled: true, spellcheckEnabled: true,
keyboardShortcuts: defaultKeyboardShortcuts, keyboardShortcuts: defaultKeyboardShortcuts,
externalGoosed: { externalGoosed: {