diff --git a/src/usb/alerts/AlreadyInUse.test.tsx b/src/usb/alerts/AlreadyInUse.test.tsx
new file mode 100644
index 00000000..91a78807
--- /dev/null
+++ b/src/usb/alerts/AlreadyInUse.test.tsx
@@ -0,0 +1,19 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2025-2026 The Pybricks Authors
+
+import { Toast } from '@blueprintjs/core';
+import { act } from '@testing-library/react';
+import React from 'react';
+import { testRender } from '../../../test';
+import { alreadyInUse } from './AlreadyInUse';
+
+it('should dismiss when close is clicked', async () => {
+ const callback = jest.fn();
+ const toast = alreadyInUse(callback, undefined as never);
+
+ const [user, message] = testRender();
+
+ await act(() => user.click(message.getByRole('button', { name: /close/i })));
+
+ expect(callback).toHaveBeenCalledWith('dismiss');
+});
diff --git a/src/usb/alerts/AlreadyInUse.tsx b/src/usb/alerts/AlreadyInUse.tsx
new file mode 100644
index 00000000..834b48bb
--- /dev/null
+++ b/src/usb/alerts/AlreadyInUse.tsx
@@ -0,0 +1,24 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2025-2026 The Pybricks Authors
+
+import { Intent } from '@blueprintjs/core';
+import { Error } from '@blueprintjs/icons';
+import React from 'react';
+import type { CreateToast } from '../../toasterTypes';
+import { useI18n } from './i18n';
+
+const AlreadyInUse: React.FunctionComponent = () => {
+ const i18n = useI18n();
+ return (
+ <>
+
{i18n.translate('alreadyInUse.message')}
+ >
+ );
+};
+
+export const alreadyInUse: CreateToast = (onAction) => ({
+ message: ,
+ icon: ,
+ intent: Intent.DANGER,
+ onDismiss: () => onAction('dismiss'),
+});
diff --git a/src/usb/alerts/index.ts b/src/usb/alerts/index.ts
index c8f30b51..f6d5c0fa 100644
--- a/src/usb/alerts/index.ts
+++ b/src/usb/alerts/index.ts
@@ -1,12 +1,14 @@
// SPDX-License-Identifier: MIT
-// Copyright (c) 2025 The Pybricks Authors
+// Copyright (c) 2025-2026 The Pybricks Authors
+import { alreadyInUse } from './AlreadyInUse';
import { newPybricksProfile } from './NewPybricksProfile';
import { noWebUsb } from './NoWebUsb';
import { oldFirmware } from './OldFirmware';
// gathers all of the alert creation functions for passing up to the top level
export default {
+ alreadyInUse,
newPybricksProfile,
noWebUsb,
oldFirmware,
diff --git a/src/usb/alerts/translations/en.json b/src/usb/alerts/translations/en.json
index 93732567..2f0019a2 100644
--- a/src/usb/alerts/translations/en.json
+++ b/src/usb/alerts/translations/en.json
@@ -4,6 +4,9 @@
"suggestion": "Use a supported browser such as Google Chrome or Microsoft Edge.",
"action": "More Info"
},
+ "alreadyInUse": {
+ "message": "This hub is already in use by another application."
+ },
"oldFirmware": {
"message": "A new firmware version is available for this hub. Please install the latest version to use all new features.",
"flashFirmware": {
diff --git a/src/usb/sagas.ts b/src/usb/sagas.ts
index eb9a1c96..85f38a59 100644
--- a/src/usb/sagas.ts
+++ b/src/usb/sagas.ts
@@ -205,7 +205,17 @@ function* handleUsbConnectPybricks(hotPlugDevice?: USBDevice): Generator {
maybe(usbDevice.claimInterface(iface.interfaceNumber)),
);
if (claimErr) {
- // TODO: show error message to user here
+ // Only show error to the user if they initiated the connection.
+ if (hotPlugDevice === undefined) {
+ if (claimErr.name === 'NetworkError') {
+ yield* put(alertsShowAlert('usb', 'alreadyInUse'));
+ } else {
+ yield* put(
+ alertsShowAlert('alerts', 'unexpectedError', { error: claimErr }),
+ );
+ }
+ }
+
console.error('Failed to claim USB interface:', claimErr);
yield* put(usbDidFailToConnectPybricks());
yield* cleanup();