Skip to content

Commit 065ab7b

Browse files
xingyaowwopenhands-agentamanape
authored andcommitted
feat: add sound and browser notifications for agent state changes (All-Hands-AI#6530)
Co-authored-by: openhands <[email protected]> Co-authored-by: amanape <[email protected]>
1 parent 57a1768 commit 065ab7b

File tree

16 files changed

+557
-7
lines changed

16 files changed

+557
-7
lines changed

frontend/__tests__/components/feedback-actions.test.tsx

+7-5
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import { render, screen, within } from "@testing-library/react";
1+
import { screen, within } from "@testing-library/react";
22
import userEvent from "@testing-library/user-event";
33
import { afterEach, describe, expect, it, vi } from "vitest";
4+
import { renderWithProviders } from "test-utils";
45
import { TrajectoryActions } from "#/components/features/trajectory/trajectory-actions";
56

67
describe("TrajectoryActions", () => {
@@ -14,7 +15,7 @@ describe("TrajectoryActions", () => {
1415
});
1516

1617
it("should render correctly", () => {
17-
render(
18+
renderWithProviders(
1819
<TrajectoryActions
1920
onPositiveFeedback={onPositiveFeedback}
2021
onNegativeFeedback={onNegativeFeedback}
@@ -25,10 +26,11 @@ describe("TrajectoryActions", () => {
2526
const actions = screen.getByTestId("feedback-actions");
2627
within(actions).getByTestId("positive-feedback");
2728
within(actions).getByTestId("negative-feedback");
29+
within(actions).getByTestId("export-trajectory");
2830
});
2931

3032
it("should call onPositiveFeedback when positive feedback is clicked", async () => {
31-
render(
33+
renderWithProviders(
3234
<TrajectoryActions
3335
onPositiveFeedback={onPositiveFeedback}
3436
onNegativeFeedback={onNegativeFeedback}
@@ -43,7 +45,7 @@ describe("TrajectoryActions", () => {
4345
});
4446

4547
it("should call onNegativeFeedback when negative feedback is clicked", async () => {
46-
render(
48+
renderWithProviders(
4749
<TrajectoryActions
4850
onPositiveFeedback={onPositiveFeedback}
4951
onNegativeFeedback={onNegativeFeedback}
@@ -58,7 +60,7 @@ describe("TrajectoryActions", () => {
5860
});
5961

6062
it("should call onExportTrajectory when negative feedback is clicked", async () => {
61-
render(
63+
renderWithProviders(
6264
<TrajectoryActions
6365
onPositiveFeedback={onPositiveFeedback}
6466
onNegativeFeedback={onNegativeFeedback}

frontend/src/assets/notification.mp3

11.4 KB
Binary file not shown.

frontend/src/components/features/controls/agent-status-bar.tsx

+37-1
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,21 @@ import {
99
useWsClient,
1010
WsClientProviderStatus,
1111
} from "#/context/ws-client-provider";
12+
import { useNotification } from "#/hooks/useNotification";
13+
import { browserTab } from "#/utils/browser-tab";
14+
15+
const notificationStates = [
16+
AgentState.AWAITING_USER_INPUT,
17+
AgentState.FINISHED,
18+
AgentState.AWAITING_USER_CONFIRMATION,
19+
];
1220

1321
export function AgentStatusBar() {
1422
const { t, i18n } = useTranslation();
1523
const { curAgentState } = useSelector((state: RootState) => state.agent);
1624
const { curStatusMessage } = useSelector((state: RootState) => state.status);
1725
const { status } = useWsClient();
26+
const { notify } = useNotification();
1827

1928
const [statusMessage, setStatusMessage] = React.useState<string>("");
2029

@@ -45,13 +54,40 @@ export function AgentStatusBar() {
4554
updateStatusMessage();
4655
}, [curStatusMessage.id]);
4756

57+
// Handle window focus/blur
58+
React.useEffect(() => {
59+
if (typeof window === "undefined") return undefined;
60+
61+
const handleFocus = () => {
62+
browserTab.stopNotification();
63+
};
64+
65+
window.addEventListener("focus", handleFocus);
66+
return () => {
67+
window.removeEventListener("focus", handleFocus);
68+
browserTab.stopNotification();
69+
};
70+
}, []);
71+
4872
React.useEffect(() => {
4973
if (status === WsClientProviderStatus.DISCONNECTED) {
5074
setStatusMessage("Connecting...");
5175
} else {
5276
setStatusMessage(AGENT_STATUS_MAP[curAgentState].message);
77+
if (notificationStates.includes(curAgentState)) {
78+
const message = t(AGENT_STATUS_MAP[curAgentState].message);
79+
notify(t(AGENT_STATUS_MAP[curAgentState].message), {
80+
body: t(`Agent state changed to ${curAgentState}`),
81+
playSound: true,
82+
});
83+
84+
// Update browser tab if window exists and is not focused
85+
if (typeof document !== "undefined" && !document.hasFocus()) {
86+
browserTab.startNotification(message);
87+
}
88+
}
5389
}
54-
}, [curAgentState]);
90+
}, [curAgentState, notify, t]);
5591

5692
return (
5793
<div className="flex flex-col items-center">

frontend/src/components/features/trajectory/trajectory-actions.tsx

+6
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { useTranslation } from "react-i18next";
12
import ThumbsUpIcon from "#/icons/thumbs-up.svg?react";
23
import ThumbDownIcon from "#/icons/thumbs-down.svg?react";
34
import ExportIcon from "#/icons/export.svg?react";
@@ -14,22 +15,27 @@ export function TrajectoryActions({
1415
onNegativeFeedback,
1516
onExportTrajectory,
1617
}: TrajectoryActionsProps) {
18+
const { t } = useTranslation();
19+
1720
return (
1821
<div data-testid="feedback-actions" className="flex gap-1">
1922
<TrajectoryActionButton
2023
testId="positive-feedback"
2124
onClick={onPositiveFeedback}
2225
icon={<ThumbsUpIcon width={15} height={15} />}
26+
tooltip={t("BUTTON$MARK_HELPFUL")}
2327
/>
2428
<TrajectoryActionButton
2529
testId="negative-feedback"
2630
onClick={onNegativeFeedback}
2731
icon={<ThumbDownIcon width={15} height={15} />}
32+
tooltip={t("BUTTON$MARK_NOT_HELPFUL")}
2833
/>
2934
<TrajectoryActionButton
3035
testId="export-trajectory"
3136
onClick={onExportTrajectory}
3237
icon={<ExportIcon width={15} height={15} />}
38+
tooltip={t("BUTTON$EXPORT_CONVERSATION")}
3339
/>
3440
</div>
3541
);

frontend/src/components/shared/buttons/trajectory-action-button.tsx

+15-1
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
1+
import { Tooltip } from "@heroui/react";
2+
13
interface TrajectoryActionButtonProps {
24
testId?: string;
35
onClick: () => void;
46
icon: React.ReactNode;
7+
tooltip?: string;
58
}
69

710
export function TrajectoryActionButton({
811
testId,
912
onClick,
1013
icon,
14+
tooltip,
1115
}: TrajectoryActionButtonProps) {
12-
return (
16+
const button = (
1317
<button
1418
type="button"
1519
data-testid={testId}
@@ -19,4 +23,14 @@ export function TrajectoryActionButton({
1923
{icon}
2024
</button>
2125
);
26+
27+
if (tooltip) {
28+
return (
29+
<Tooltip content={tooltip} closeDelay={100}>
30+
{button}
31+
</Tooltip>
32+
);
33+
}
34+
35+
return button;
2236
}

frontend/src/hooks/mutation/use-save-settings.ts

+1
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ const saveSettingsMutationFn = async (settings: Partial<PostSettings>) => {
2020
github_token: settings.github_token,
2121
unset_github_token: settings.unset_github_token,
2222
enable_default_condenser: settings.ENABLE_DEFAULT_CONDENSER,
23+
enable_sound_notifications: settings.ENABLE_SOUND_NOTIFICATIONS,
2324
user_consents_to_analytics: settings.user_consents_to_analytics,
2425
};
2526

frontend/src/hooks/query/use-settings.ts

+1
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ const getSettingsQueryFn = async () => {
2020
REMOTE_RUNTIME_RESOURCE_FACTOR: apiSettings.remote_runtime_resource_factor,
2121
GITHUB_TOKEN_IS_SET: apiSettings.github_token_is_set,
2222
ENABLE_DEFAULT_CONDENSER: apiSettings.enable_default_condenser,
23+
ENABLE_SOUND_NOTIFICATIONS: apiSettings.enable_sound_notifications,
2324
USER_CONSENTS_TO_ANALYTICS: apiSettings.user_consents_to_analytics,
2425
};
2526
};

frontend/src/hooks/useNotification.ts

+56
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { useCallback, useRef } from "react";
2+
import notificationSound from "../assets/notification.mp3";
3+
import { useCurrentSettings } from "../context/settings-context";
4+
5+
export const useNotification = () => {
6+
const { settings } = useCurrentSettings();
7+
const audioRef = useRef<HTMLAudioElement | undefined>(undefined);
8+
9+
// Initialize audio only in browser environment
10+
if (typeof window !== "undefined" && !audioRef.current) {
11+
audioRef.current = new Audio(notificationSound);
12+
audioRef.current.volume = 0.5;
13+
}
14+
15+
const notify = useCallback(
16+
async (
17+
title: string,
18+
options?: NotificationOptions & { playSound?: boolean },
19+
): Promise<Notification | undefined> => {
20+
if (typeof window === "undefined") return undefined;
21+
22+
// Only play sound if:
23+
// 1. Explicitly requested via playSound option
24+
// 2. Sound notifications are enabled in settings
25+
// 3. Audio is available
26+
// 4. Not a settings-related notification
27+
if (
28+
options?.playSound === true && // Must be explicitly true
29+
settings?.ENABLE_SOUND_NOTIFICATIONS &&
30+
audioRef.current &&
31+
!title.includes("BUTTON$") // Don't play for button/settings actions
32+
) {
33+
// Reset and play sound
34+
audioRef.current.currentTime = 0;
35+
audioRef.current.play().catch(() => {
36+
// Ignore autoplay errors
37+
});
38+
}
39+
40+
if (Notification.permission === "default") {
41+
await Notification.requestPermission();
42+
}
43+
44+
if (Notification.permission === "granted") {
45+
// Remove playSound from options before passing to Notification
46+
const { playSound, ...notificationOptions } = options || {};
47+
return new Notification(title, notificationOptions);
48+
}
49+
50+
return undefined;
51+
},
52+
[settings?.ENABLE_SOUND_NOTIFICATIONS],
53+
);
54+
55+
return { notify };
56+
};

0 commit comments

Comments
 (0)