Skip to content

Commit ee46794

Browse files
authored
feat: desktop notification when goose finishes a task (#8647)
Signed-off-by: Abhijay Jain <Abhijay007j@gmail.com>
1 parent 15cfd12 commit ee46794

6 files changed

Lines changed: 112 additions & 33 deletions

File tree

ui/desktop/src/components/settings/app/AppSettingsSection.tsx

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,14 @@ const i18n = defineMessages({
3232
},
3333
configGuide: { id: 'settings.notifications.configGuide', defaultMessage: 'Configuration guide' },
3434
openSettings: { id: 'settings.notifications.openSettings', defaultMessage: 'Open Settings' },
35+
taskNotifications: {
36+
id: 'settings.notifications.task.title',
37+
defaultMessage: 'Task completion notifications',
38+
},
39+
taskNotificationsDesc: {
40+
id: 'settings.notifications.task.description',
41+
defaultMessage: 'Notify when Goose finishes a task while the window is in the background',
42+
},
3543
menuBarIcon: { id: 'settings.menuBarIcon.title', defaultMessage: 'Menu bar icon' },
3644
menuBarIconDesc: {
3745
id: 'settings.menuBarIcon.description',
@@ -209,6 +217,7 @@ export default function AppSettingsSection({ scrollToSection }: AppSettingsSecti
209217
const [menuBarIconEnabled, setMenuBarIconEnabled] = useState(true);
210218
const [dockIconEnabled, setDockIconEnabled] = useState(true);
211219
const [wakelockEnabled, setWakelockEnabled] = useState(true);
220+
const [notificationsEnabled, setNotificationsEnabled] = useState(true);
212221
const [isMacOS, setIsMacOS] = useState(false);
213222
const [isDockSwitchDisabled, setIsDockSwitchDisabled] = useState(false);
214223
const [showNotificationModal, setShowNotificationModal] = useState(false);
@@ -258,6 +267,10 @@ export default function AppSettingsSection({ scrollToSection }: AppSettingsSecti
258267
setWakelockEnabled(enabled);
259268
});
260269

270+
window.electron.getSetting('enableNotifications').then((enabled) => {
271+
setNotificationsEnabled(enabled ?? true);
272+
});
273+
261274
if (isMacOS) {
262275
window.electron.getDockIconState().then((enabled) => {
263276
setDockIconEnabled(enabled);
@@ -316,6 +329,12 @@ export default function AppSettingsSection({ scrollToSection }: AppSettingsSecti
316329
}
317330
};
318331

332+
const handleNotificationsToggle = async (checked: boolean) => {
333+
setNotificationsEnabled(checked);
334+
await window.electron.setSetting('enableNotifications', checked);
335+
trackSettingToggled('task_notifications', checked);
336+
};
337+
319338
const handleShowPricingToggle = async (checked: boolean) => {
320339
setShowPricing(checked);
321340
await window.electron.setSetting('showPricing', checked);
@@ -371,6 +390,24 @@ export default function AppSettingsSection({ scrollToSection }: AppSettingsSecti
371390
</div>
372391
</div>
373392

393+
<div className="flex items-center justify-between">
394+
<div>
395+
<h3 className="text-text-primary text-xs">
396+
{intl.formatMessage(i18n.taskNotifications)}
397+
</h3>
398+
<p className="text-xs text-text-secondary max-w-md mt-[2px]">
399+
{intl.formatMessage(i18n.taskNotificationsDesc)}
400+
</p>
401+
</div>
402+
<div className="flex items-center">
403+
<Switch
404+
checked={notificationsEnabled}
405+
onCheckedChange={handleNotificationsToggle}
406+
variant="mono"
407+
/>
408+
</div>
409+
</div>
410+
374411
<div className="flex items-center justify-between">
375412
<div>
376413
<h3 className="text-text-primary text-xs">{intl.formatMessage(i18n.menuBarIcon)}</h3>

ui/desktop/src/hooks/useChatStream.ts

Lines changed: 44 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react';
2+
import { defineMessages, useIntl } from '../i18n';
23
import { v7 as uuidv7 } from 'uuid';
34
import { AppEvents } from '../constants/events';
45
import { ChatState } from '../types/chatState';
@@ -227,7 +228,7 @@ function createEventProcessor(
227228
dispatch: React.Dispatch<StreamAction>,
228229
onFinish: (error?: string) => void,
229230
sessionId: string,
230-
onReloadNeeded?: () => void,
231+
onReloadNeeded?: () => void
231232
) {
232233
let currentMessages = initialMessages;
233234
const reduceMotion = prefersReducedMotion();
@@ -343,11 +344,23 @@ function createEventProcessor(
343344
return processEvent;
344345
}
345346

347+
const i18n = defineMessages({
348+
notificationTitle: {
349+
id: 'chat.notification.taskComplete.title',
350+
defaultMessage: 'Goose finished the task.',
351+
},
352+
notificationBody: {
353+
id: 'chat.notification.taskComplete.body',
354+
defaultMessage: 'Click here to bring Goose back into focus.',
355+
},
356+
});
357+
346358
export function useChatStream({
347359
sessionId,
348360
onStreamFinish,
349361
onSessionLoaded,
350362
}: UseChatStreamProps): UseChatStreamReturn {
363+
const intl = useIntl();
351364
const [state, dispatch] = useReducer(streamReducer, initialState);
352365

353366
// Long-lived SSE connection for this session
@@ -358,7 +371,6 @@ export function useChatStream({
358371
const activeRequestSessionIdRef = useRef<string | null>(null);
359372
const activeAbortRef = useRef<AbortController | null>(null);
360373
const activeUnsubscribeRef = useRef<(() => void) | null>(null);
361-
const lastInteractionTimeRef = useRef<number>(Date.now());
362374
// When ActiveRequests fires before resumeAgent populates messages (cold mount),
363375
// defer the reattach until the session is loaded so the event processor has
364376
// the full conversation history. Events are buffered in the meantime.
@@ -399,12 +411,21 @@ export function useChatStream({
399411

400412
dispatch({ type: 'STREAM_FINISH', payload: error });
401413

402-
const timeSinceLastInteraction = Date.now() - lastInteractionTimeRef.current;
403-
if (!error && timeSinceLastInteraction > 60000) {
404-
window.electron.showNotification({
405-
title: 'goose finished the task.',
406-
body: 'Click here to expand.',
407-
});
414+
if (!error) {
415+
try {
416+
const [notificationsEnabled, anyWindowFocused] = await Promise.all([
417+
window.electron.getSetting('enableNotifications'),
418+
window.electron.isAnyWindowFocused(),
419+
]);
420+
if (notificationsEnabled === true && !anyWindowFocused) {
421+
window.electron.showNotification({
422+
title: intl.formatMessage(i18n.notificationTitle),
423+
body: intl.formatMessage(i18n.notificationBody),
424+
});
425+
}
426+
} catch (notifyError) {
427+
console.warn('Failed to show task completion notification:', notifyError);
428+
}
408429
}
409430

410431
const isNewSession = sessionId && sessionId.match(/^\d{8}_\d{6}$/);
@@ -444,7 +465,7 @@ export function useChatStream({
444465

445466
onStreamFinish();
446467
},
447-
[onStreamFinish, sessionId]
468+
[intl, onStreamFinish, sessionId]
448469
);
449470

450471
// Reload the full conversation from the server, e.g. after the SSE
@@ -453,14 +474,16 @@ export function useChatStream({
453474
getSession({
454475
path: { session_id: sessionId },
455476
throwOnError: true,
456-
}).then((response) => {
457-
const session = response.data as Session;
458-
if (session?.conversation) {
459-
dispatch({ type: 'SET_MESSAGES', payload: session.conversation });
460-
}
461-
}).catch((e) => {
462-
console.warn('Failed to reload conversation after buffer overflow:', e);
463-
});
477+
})
478+
.then((response) => {
479+
const session = response.data as Session;
480+
if (session?.conversation) {
481+
dispatch({ type: 'SET_MESSAGES', payload: session.conversation });
482+
}
483+
})
484+
.catch((e) => {
485+
console.warn('Failed to reload conversation after buffer overflow:', e);
486+
});
464487
}, [sessionId]);
465488

466489
// Perform the actual reattach: wire up an event processor and listener
@@ -479,7 +502,7 @@ export function useChatStream({
479502
dispatch,
480503
onFinish,
481504
sessionId,
482-
reloadConversation,
505+
reloadConversation
483506
);
484507

485508
// Replay any events that were buffered during cold-mount wait
@@ -523,7 +546,7 @@ export function useChatStream({
523546
});
524547
activeUnsubscribeRef.current = unsubscribe;
525548
},
526-
[sessionId, addListener, onFinish, reloadConversation],
549+
[sessionId, addListener, onFinish, reloadConversation]
527550
);
528551
doReattachRef.current = doReattach;
529552

@@ -582,7 +605,7 @@ export function useChatStream({
582605
targetSessionId: string,
583606
userMessage: Message,
584607
currentMessages: Message[],
585-
overrideConversation?: Message[],
608+
overrideConversation?: Message[]
586609
) => {
587610
const requestId = uuidv7();
588611
const abortController = new AbortController();
@@ -596,7 +619,7 @@ export function useChatStream({
596619
dispatch,
597620
onFinish,
598621
targetSessionId,
599-
reloadConversation,
622+
reloadConversation
600623
);
601624

602625
const unsubscribe = addListener(requestId, (event) => {
@@ -801,8 +824,6 @@ export function useChatStream({
801824
return;
802825
}
803826

804-
lastInteractionTimeRef.current = Date.now();
805-
806827
// Emit session-created event for first message in a new session
807828
if (!hasExistingMessages && hasNewMessage) {
808829
window.dispatchEvent(new CustomEvent(AppEvents.SESSION_CREATED));
@@ -876,8 +897,6 @@ export function useChatStream({
876897
return;
877898
}
878899

879-
lastInteractionTimeRef.current = Date.now();
880-
881900
const responseMessage = createElicitationResponseMessage(elicitationId, userData);
882901
const currentMessages = [...currentState.messages, responseMessage];
883902

@@ -961,7 +980,6 @@ export function useChatStream({
961980
activeRequestSessionIdRef.current = null;
962981

963982
dispatch({ type: 'SET_CHAT_STATE', payload: ChatState.Idle });
964-
lastInteractionTimeRef.current = Date.now();
965983
}, []);
966984

967985
const onMessageUpdate = useCallback(

ui/desktop/src/i18n/messages/en.json

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,12 @@
113113
"cardButtons.launch": {
114114
"defaultMessage": "Launch"
115115
},
116+
"chat.notification.taskComplete.body": {
117+
"defaultMessage": "Click here to bring Goose back into focus."
118+
},
119+
"chat.notification.taskComplete.title": {
120+
"defaultMessage": "Goose finished the task."
121+
},
116122
"chatInput.contextWindow": {
117123
"defaultMessage": "Context window"
118124
},
@@ -1449,7 +1455,7 @@
14491455
"defaultMessage": "Downloaded"
14501456
},
14511457
"huggingFaceModelSearch.downloading": {
1452-
"defaultMessage": "Downloading\u2026"
1458+
"defaultMessage": "Downloading"
14531459
},
14541460
"huggingFaceModelSearch.loadingVariants": {
14551461
"defaultMessage": "Loading variants..."
@@ -1845,7 +1851,7 @@
18451851
"defaultMessage": "Vision"
18461852
},
18471853
"localInferenceSettings.visionEncoderDownloading": {
1848-
"defaultMessage": "Vision encoder downloading\u2026"
1854+
"defaultMessage": "Vision encoder downloading"
18491855
},
18501856
"localInferenceSettings.visionEncoderNotDownloaded": {
18511857
"defaultMessage": "Vision encoder not downloaded"
@@ -4052,6 +4058,12 @@
40524058
"settings.notifications.openSettings": {
40534059
"defaultMessage": "Open Settings"
40544060
},
4061+
"settings.notifications.task.description": {
4062+
"defaultMessage": "Notify when Goose finishes a task while the window is in the background"
4063+
},
4064+
"settings.notifications.task.title": {
4065+
"defaultMessage": "Task completion notifications"
4066+
},
40554067
"settings.notifications.title": {
40564068
"defaultMessage": "Notifications"
40574069
},

ui/desktop/src/main.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -749,11 +749,14 @@ const createChat = async (app: App, options: CreateChatOptions = {}) => {
749749
// Nudge the user if mesh is their provider but isn't running.
750750
// Delay to let the renderer mount before sending the IPC event.
751751
setTimeout(() => {
752-
mesh.checkProviderRunning(goosedClient).then((ok) => {
753-
if (!ok && !mainWindow.isDestroyed()) {
754-
mainWindow.webContents.send('mesh-not-running');
755-
}
756-
}).catch(() => {});
752+
mesh
753+
.checkProviderRunning(goosedClient)
754+
.then((ok) => {
755+
if (!ok && !mainWindow.isDestroyed()) {
756+
mainWindow.webContents.send('mesh-not-running');
757+
}
758+
})
759+
.catch(() => {});
757760
}, 5000);
758761

759762
// Let windowStateKeeper manage the window
@@ -1359,6 +1362,7 @@ const validSettingKeys: Set<string> = new Set([
13591362
'showMenuBarIcon',
13601363
'showDockIcon',
13611364
'enableWakelock',
1365+
'enableNotifications',
13621366
'spellcheckEnabled',
13631367
'externalGoosed',
13641368
'globalShortcut',
@@ -1572,6 +1576,10 @@ ipcMain.handle('get-spellcheck-state', () => {
15721576
}
15731577
});
15741578

1579+
ipcMain.handle('is-any-window-focused', () => {
1580+
return BrowserWindow.getFocusedWindow() !== null;
1581+
});
1582+
15751583
// Add file/directory selection handler
15761584
ipcMain.handle('select-file-or-directory', async (_event, defaultPath?: string) => {
15771585
const dialogOptions: OpenDialogOptions = {

ui/desktop/src/preload.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ type ElectronAPI = {
147147
setSpellcheck: (enable: boolean) => Promise<boolean>;
148148
getSpellcheckState: () => Promise<boolean>;
149149
openNotificationsSettings: () => Promise<boolean>;
150+
isAnyWindowFocused: () => Promise<boolean>;
150151
onMouseBackButtonClicked: (callback: () => void) => void;
151152
offMouseBackButtonClicked: (callback: () => void) => void;
152153
on: (
@@ -267,6 +268,7 @@ const electronAPI: ElectronAPI = {
267268
setSpellcheck: (enable: boolean) => ipcRenderer.invoke('set-spellcheck', enable),
268269
getSpellcheckState: () => ipcRenderer.invoke('get-spellcheck-state'),
269270
openNotificationsSettings: () => ipcRenderer.invoke('open-notifications-settings'),
271+
isAnyWindowFocused: () => ipcRenderer.invoke('is-any-window-focused'),
270272
onMouseBackButtonClicked: (callback: () => void) => {
271273
// Wrapper that ignores the event parameter.
272274
const wrappedCallback = (_event: Electron.IpcRendererEvent) => callback();

ui/desktop/src/utils/settings.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ export interface Settings {
3333
showMenuBarIcon: boolean;
3434
showDockIcon: boolean;
3535
enableWakelock: boolean;
36+
enableNotifications: boolean;
3637
spellcheckEnabled: boolean;
3738
externalGoosed: ExternalGoosedConfig;
3839
globalShortcut?: string | null;
@@ -69,6 +70,7 @@ export const defaultSettings: Settings = {
6970
showMenuBarIcon: true,
7071
showDockIcon: true,
7172
enableWakelock: false,
73+
enableNotifications: true,
7274
spellcheckEnabled: true,
7375
keyboardShortcuts: defaultKeyboardShortcuts,
7476
externalGoosed: {

0 commit comments

Comments
 (0)