- {currentView === "viewer" && (
+ {showPrint && (
}
disabled={exportDisabled}
@@ -48,7 +55,7 @@ export default function WorkbenchBarMobileActions({
{t("workbenchBar.print", "Print PDF")}
)}
- {!isCustomView && (
+ {showFileActions && (
)}
- {!isCustomView && saveAsIconName && (
+ {showFileActions && saveAsIconName && (
@@ -74,7 +81,7 @@ export default function WorkbenchBarMobileActions({
{t("workbenchBar.saveAs", "Save As")}
)}
- {!isCustomView && (
+ {showFileActions && (
<>
>
diff --git a/frontend/editor/src/core/components/tools/ToolPanel.css b/frontend/editor/src/core/components/tools/ToolPanel.css
index 427375db1a..3e3985103f 100644
--- a/frontend/editor/src/core/components/tools/ToolPanel.css
+++ b/frontend/editor/src/core/components/tools/ToolPanel.css
@@ -183,6 +183,17 @@
}
}
+/* In-panel tool filter. Aligned with .tool-picker__compact's inline padding so
+ the field lines up with the tool rows underneath it. */
+.tool-panel__search {
+ flex-shrink: 0;
+ padding: 0.5rem var(--mantine-spacing-sm) 0;
+}
+
+.tool-panel__search .search-input-container {
+ margin: 0;
+}
+
.tool-panel__compact-header-actions {
display: flex;
align-items: center;
diff --git a/frontend/editor/src/core/components/tools/ToolPanel.tsx b/frontend/editor/src/core/components/tools/ToolPanel.tsx
index 2d6006a8eb..d51ec9e0bf 100644
--- a/frontend/editor/src/core/components/tools/ToolPanel.tsx
+++ b/frontend/editor/src/core/components/tools/ToolPanel.tsx
@@ -4,6 +4,7 @@ import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
import ToolPicker from "@app/components/tools/ToolPicker";
import SearchResults from "@app/components/tools/SearchResults";
import ToolRenderer from "@app/components/tools/ToolRenderer";
+import ToolSearch from "@app/components/tools/toolPicker/ToolSearch";
import { ToolPanelViewerBar } from "@app/components/tools/ToolPanelViewerBar";
import { ToolId } from "@app/types/toolId";
@@ -20,6 +21,11 @@ interface ToolPanelProps {
onToolSelect?: (id: ToolId) => void;
/** Whether to render the compact (favourites + recommended only) view. */
compact?: boolean;
+ /**
+ * Render a tool filter at the head of the panel. Set where the workbench
+ * bar's super search is out of reach, so the list stays searchable in place.
+ */
+ showSearch?: boolean;
}
/** Tool list and renderer for the right rail; rail chrome lives in RightSidebar. */
@@ -28,24 +34,44 @@ export default function ToolPanel({
onShowAllTools,
onToolSelect,
compact: compactProp,
+ showSearch = false,
}: ToolPanelProps) {
const { t } = useTranslation();
const {
leftPanelView,
searchQuery,
+ setSearchQuery,
filteredTools,
+ toolRegistry,
selectedToolKey,
handleToolSelect,
setPreviewFile,
} = useToolWorkflow();
const selectTool = onToolSelect ?? handleToolSelect;
+ // Only offer the filter over the list itself; once a tool is open the panel
+ // belongs to that tool. Deriving the results branch from the same flag keeps
+ // the input and what it filters from drifting apart.
+ const panelSearch = showSearch && leftPanelView === "toolPicker";
+ const searching = searchQuery.trim().length > 0;
+
return (
<>
{/* Viewer mode tools — annotate, redact, form fill */}
- {allToolsView && searchQuery.trim().length > 0 ? (
+ {panelSearch && (
+
+
+
+ )}
+
+ {searching && (allToolsView || panelSearch) ? (
selectTool(id as ToolId)}
filteredTools={filteredTools}
- isSearching={Boolean(searchQuery && searchQuery.trim().length > 0)}
+ isSearching={searching}
compact={compactProp ?? !allToolsView}
onShowAllTools={onShowAllTools}
/>
From cc68ebc920a728fdb11a432326bff0ae98a86ce5 Mon Sep 17 00:00:00 2001
From: EthanHealy01
Date: Mon, 24 Aug 2026 14:38:17 +0100
Subject: [PATCH 2/2] review: address the smaller comments from the first pass
- The bell mounts nothing in a build with no notifications API, rather
than polling a nonexistent endpoint forever to show nothing: core
answers no through useNotificationsAvailable, a build that ships the
routes overrides it to say so.
- NotificationItem moves to its own file, taking noteFor and the action
button with it.
- BellIcon moves to the shared UI set.
- Time-anchored javadoc on FailureActionId restated as invariants, the
notification read limits get the comments they deserved, and the bell's
heavier comments trimmed to what the code cannot say. The workbench bar
comment stops describing behaviour that lives in Workbench.
---
.../proprietary/failure/FailureActionId.java | 7 +-
.../notification/NotificationController.java | 2 +
.../notifications/NotificationBell.test.tsx | 18 ++
.../notifications/NotificationBell.tsx | 275 +-----------------
.../notifications/NotificationItem.tsx | 251 ++++++++++++++++
.../useNotificationsAvailable.ts | 11 +
.../core/components/shared/WorkbenchBar.tsx | 3 +-
.../notifications => ui}/BellIcon.tsx | 4 +-
frontend/editor/src/core/ui/index.ts | 1 +
.../useNotificationsAvailable.ts | 7 +
10 files changed, 313 insertions(+), 266 deletions(-)
create mode 100644 frontend/editor/src/core/components/notifications/NotificationItem.tsx
create mode 100644 frontend/editor/src/core/components/notifications/useNotificationsAvailable.ts
rename frontend/editor/src/core/{components/notifications => ui}/BellIcon.tsx (68%)
create mode 100644 frontend/editor/src/proprietary/components/notifications/useNotificationsAvailable.ts
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/failure/FailureActionId.java b/app/proprietary/src/main/java/stirling/software/proprietary/failure/FailureActionId.java
index ad97fed8b2..8f60e9f4a0 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/failure/FailureActionId.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/failure/FailureActionId.java
@@ -10,12 +10,15 @@ import lombok.Getter;
@Getter
public enum FailureActionId {
- /** No kind offers this any more, but rows already {@code ACKNOWLEDGED} must stay closable. */
+ /**
+ * Kept in the vocabulary for as long as any persisted row is {@code ACKNOWLEDGED}: such rows
+ * must stay readable and closable whether or not any kind currently offers this.
+ */
ACKNOWLEDGE(Execution.SERVER, "Acknowledge"),
DISMISS(Execution.SERVER, "Dismiss"),
- /** Only the owner's client can resolve the id. */
+ /** Open the document behind the incident, in whichever client can resolve its id. */
VIEW_FILE(Execution.CLIENT, "View file"),
VIEW_IN_PROCESSOR(Execution.CLIENT, "View in processor");
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/notification/NotificationController.java b/app/proprietary/src/main/java/stirling/software/proprietary/notification/NotificationController.java
index de24a0842f..f2bf36a8dc 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/notification/NotificationController.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/notification/NotificationController.java
@@ -24,8 +24,10 @@ import lombok.RequiredArgsConstructor;
@Tag(name = "Notifications", description = "Things worth telling the caller about")
public class NotificationController {
+ /** How many notifications one read returns when the caller does not say: one panelful. */
private static final int DEFAULT_LIMIT = 20;
+ /** The most one read may return however large a limit the caller asks for. */
private static final int MAX_LIMIT = 100;
private final NotificationService notifications;
diff --git a/frontend/editor/src/core/components/notifications/NotificationBell.test.tsx b/frontend/editor/src/core/components/notifications/NotificationBell.test.tsx
index 94937a3b85..8753a5880b 100644
--- a/frontend/editor/src/core/components/notifications/NotificationBell.test.tsx
+++ b/frontend/editor/src/core/components/notifications/NotificationBell.test.tsx
@@ -29,6 +29,8 @@ vi.mock("@app/services/notifications", () => ({
// IndexedDB, which jsdom has none of. Answered here so availability is a fact of the test.
const h = vi.hoisted(() => ({
hasLocalFile: true,
+ // This build has the notifications API, except in the one test about the build that does not.
+ notificationsAvailable: true,
specs: {} as Record<
string,
{
@@ -43,6 +45,10 @@ vi.mock("@app/services/localFilePresence", () => ({
hasLocalFile: () => Promise.resolve(h.hasLocalFile),
}));
+vi.mock("@app/components/notifications/useNotificationsAvailable", () => ({
+ useNotificationsAvailable: () => h.notificationsAvailable,
+}));
+
// Core's own registry is empty, so without this there are no client actions to test.
vi.mock("@app/components/notifications/notificationActions", () => ({
useNotificationActions: () => h.specs,
@@ -119,9 +125,21 @@ describe("NotificationBell", () => {
window.localStorage.clear();
fetchNotifications.mockReset().mockResolvedValue([]);
h.hasLocalFile = true;
+ h.notificationsAvailable = true;
h.specs = {};
});
+ it("mounts nothing at all in a build with no notifications API", async () => {
+ // No bell and, above all, no poll: an OSS build must not sit on a timer collecting 404s.
+ h.notificationsAvailable = false;
+
+ render( );
+
+ await Promise.resolve();
+ expect(screen.queryByRole("button")).toBeNull();
+ expect(fetchNotifications).not.toHaveBeenCalled();
+ });
+
it("shows no badge when there is nothing to report", async () => {
render( );
diff --git a/frontend/editor/src/core/components/notifications/NotificationBell.tsx b/frontend/editor/src/core/components/notifications/NotificationBell.tsx
index 9cd4d1a2c9..f2ac1e0a3a 100644
--- a/frontend/editor/src/core/components/notifications/NotificationBell.tsx
+++ b/frontend/editor/src/core/components/notifications/NotificationBell.tsx
@@ -6,25 +6,13 @@ import {
useRef,
useState,
} from "react";
-import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
-import { Button } from "@app/ui";
-import { BellIcon } from "@app/components/notifications/BellIcon";
+import { BellIcon, Button } from "@app/ui";
import DividerWithText from "@app/components/shared/DividerWithText";
-import {
- isResolvableHere,
- useNotifications,
-} from "@app/hooks/useNotifications";
-import {
- useNotificationActions,
- type ClientActionRegistry,
- type NotificationActionContext,
-} from "@app/components/notifications/notificationActions";
-import type {
- AppNotification,
- NotificationActionOffer,
-} from "@app/services/notifications";
-import type { NotificationDocumentState } from "@app/hooks/useNotifications";
+import { useNotifications } from "@app/hooks/useNotifications";
+import { useNotificationActions } from "@app/components/notifications/notificationActions";
+import { NotificationItem } from "@app/components/notifications/NotificationItem";
+import { useNotificationsAvailable } from "@app/components/notifications/useNotificationsAvailable";
import "@app/components/notifications/NotificationBell.css";
/**
@@ -32,6 +20,14 @@ import "@app/components/notifications/NotificationBell.css";
* mean, so a new source or failure kind needs no change here. In core because both shells mount it.
*/
export function NotificationBell() {
+ // A build with no notifications API gets no bell at all, rather than one that polls a
+ // nonexistent endpoint forever to show nothing.
+ const available = useNotificationsAvailable();
+ if (!available) return null;
+ return ;
+}
+
+function MountedNotificationBell() {
const { t } = useTranslation();
const { notifications, unreadCount, documentStateFor, markAllSeen } =
useNotifications();
@@ -39,13 +35,9 @@ export function NotificationBell() {
const [open, setOpen] = useState(false);
const container = useRef(null);
const headingId = useId();
- /**
- * Where the new ones stop, frozen on open. An id rather than a count because opening marks
- * everything read, and because one arriving on a poll must land above the divider, not shift it.
- */
+ // Where the new ones stop, frozen when the panel opens (opening marks everything read).
const [firstSeenId, setFirstSeenId] = useState(null);
- // Fixed to the viewport: the workbench bar clips its overflow, so an absolutely positioned panel
- // would be cut off by its own toolbar.
+ // Viewport-fixed, because the workbench bar clips its own overflow.
const [anchor, setAnchor] = useState<{ top: number; right: number } | null>(
null,
);
@@ -178,240 +170,3 @@ export function NotificationBell() {
);
}
-
-/**
- * The server's reason wins, being about the failure rather than this browser. Otherwise only what we
- * actually looked up, so a row we never probed is never called absent.
- */
-function noteFor(
- notification: AppNotification,
- documentState: NotificationDocumentState,
- withheldReasonKey: string | null,
- t: TFunction,
-): string | null {
- if (withheldReasonKey)
- return t(withheldReasonKey, {
- defaultValue: t(
- "notifications.action.unavailable",
- "Not available for this notification.",
- ),
- });
- if (notification.ownership !== "MINE" || documentState.hasLocalFile)
- return null;
- if (!notification.fileId)
- return t(
- "notifications.noDocumentLinked",
- "This failure is not linked to a specific document, so there is nothing to open here.",
- );
- return isResolvableHere(notification)
- ? t(
- "notifications.notOnThisDevice",
- "This document is not on this device, so it cannot be opened here.",
- )
- : null;
-}
-
-interface NotificationItemProps {
- notification: AppNotification;
- unread: boolean;
- documentState: NotificationDocumentState;
- registry: ClientActionRegistry;
- onDismissPanel: () => void;
-}
-
-/** Its own component because the last attempt's message and its expanded state are per-row. */
-function NotificationItem({
- notification,
- unread,
- documentState,
- registry,
- onDismissPanel,
-}: NotificationItemProps) {
- const { t } = useTranslation();
- const [message, setMessage] = useState(null);
- const [busy, setBusy] = useState(null);
- const [expanded, setExpanded] = useState(false);
- const [copied, setCopied] = useState(false);
-
- const title = t(notification.titleKey, notification.defaultTitle);
- const context: NotificationActionContext = {
- notification,
- hasLocalFile: documentState.hasLocalFile,
- };
-
- // An id this build has never heard of is skipped rather than rendered unwired: the server ships
- // new kinds, and new actions, ahead of the clients that understand them.
- const usable = notification.actions.filter((offer) => {
- if (!offer.enabled) return false;
- const spec = registry[offer.id];
- return spec ? spec.available(context) : false;
- });
-
- // Only from an action this build would otherwise have rendered: a reason about one it cannot
- // perform anyway is not this row's explanation.
- const withheldReasonKey =
- notification.actions.find(
- (offer) =>
- !offer.enabled &&
- offer.disabledReasonKey !== null &&
- registry[offer.id] !== undefined,
- )?.disabledReasonKey ?? null;
-
- const labelOf = (offer: NotificationActionOffer) =>
- t(offer.labelKey, offer.defaultLabel);
-
- const run = async (offer: NotificationActionOffer) => {
- if (busy) return;
- setMessage(null);
-
- const spec = registry[offer.id];
- if (!spec) return;
-
- setBusy(offer.id);
- const outcome = await spec.run(context);
- setBusy(null);
- if (outcome && !outcome.ok) {
- setMessage(
- outcome.message ??
- t(
- "notifications.action.failed",
- "That did not work. Try again in a moment.",
- ),
- );
- return;
- }
-
- if (spec.closesPanel) onDismissPanel();
- };
-
- const copyDetail = async () => {
- if (!notification.detail) return;
- try {
- await navigator.clipboard.writeText(notification.detail);
- setCopied(true);
- } catch {
- // No clipboard permission, and the message is on screen and selectable anyway.
- }
- };
-
- const note = noteFor(notification, documentState, withheldReasonKey, t);
-
- return (
-
- {unread && (
-
- )}
- {title}
- {notification.occurrences > 1 && (
-
- {t("notifications.occurrences", {
- count: notification.occurrences,
- defaultValue: "{{count}} times",
- })}
-
- )}
-
- {notification.detail && (
- <>
-
- {notification.detail}
-
-
- void copyDetail()}
- >
- {copied
- ? t("notifications.detail.copied", "Copied")
- : t("notifications.detail.copy", "Copy error")}
-
- setExpanded((wasExpanded) => !wasExpanded)}
- >
- {expanded
- ? t("notifications.detail.less", "Show less")
- : t("notifications.detail.more", "Show full message")}
-
-
- >
- )}
-
- {note && {note} }
-
- {/* In the kind's declared order, the first leading. */}
- {usable.length > 0 && (
-
- {usable.map((offer, index) => (
- void run(offer)}
- />
- ))}
-
- )}
-
- {message && (
-
- {message}
-
- )}
-
- );
-}
-
-interface ActionButtonProps {
- variant: "primary" | "secondary";
- rowTitle: string;
- label: string;
- busy: boolean;
- onRun: () => void;
-}
-
-function ActionButton({
- variant,
- rowTitle,
- label,
- busy,
- onRun,
-}: ActionButtonProps) {
- return (
-
- {label}
-
- );
-}
diff --git a/frontend/editor/src/core/components/notifications/NotificationItem.tsx b/frontend/editor/src/core/components/notifications/NotificationItem.tsx
new file mode 100644
index 0000000000..b53ca7894c
--- /dev/null
+++ b/frontend/editor/src/core/components/notifications/NotificationItem.tsx
@@ -0,0 +1,251 @@
+import { useState } from "react";
+import type { TFunction } from "i18next";
+import { useTranslation } from "react-i18next";
+import { Button } from "@app/ui";
+import { isResolvableHere } from "@app/hooks/useNotifications";
+import type { NotificationDocumentState } from "@app/hooks/useNotifications";
+import type {
+ ClientActionRegistry,
+ NotificationActionContext,
+} from "@app/components/notifications/notificationActions";
+import type {
+ AppNotification,
+ NotificationActionOffer,
+} from "@app/services/notifications";
+
+/**
+ * The server's reason wins, being about the failure rather than this browser. Otherwise only what we
+ * actually looked up, so a row we never probed is never called absent.
+ */
+function noteFor(
+ notification: AppNotification,
+ documentState: NotificationDocumentState,
+ withheldReasonKey: string | null,
+ t: TFunction,
+): string | null {
+ if (withheldReasonKey)
+ return t(withheldReasonKey, {
+ defaultValue: t(
+ "notifications.action.unavailable",
+ "Not available for this notification.",
+ ),
+ });
+ if (notification.ownership !== "MINE" || documentState.hasLocalFile)
+ return null;
+ if (!notification.fileId)
+ return t(
+ "notifications.noDocumentLinked",
+ "This failure is not linked to a specific document, so there is nothing to open here.",
+ );
+ return isResolvableHere(notification)
+ ? t(
+ "notifications.notOnThisDevice",
+ "This document is not on this device, so it cannot be opened here.",
+ )
+ : null;
+}
+
+interface NotificationItemProps {
+ notification: AppNotification;
+ unread: boolean;
+ documentState: NotificationDocumentState;
+ registry: ClientActionRegistry;
+ onDismissPanel: () => void;
+}
+
+/** Its own component because the last attempt's message and its expanded state are per-row. */
+export function NotificationItem({
+ notification,
+ unread,
+ documentState,
+ registry,
+ onDismissPanel,
+}: NotificationItemProps) {
+ const { t } = useTranslation();
+ const [message, setMessage] = useState(null);
+ const [busy, setBusy] = useState(null);
+ const [expanded, setExpanded] = useState(false);
+ const [copied, setCopied] = useState(false);
+
+ const title = t(notification.titleKey, notification.defaultTitle);
+ const context: NotificationActionContext = {
+ notification,
+ hasLocalFile: documentState.hasLocalFile,
+ };
+
+ // An id this build has never heard of is skipped rather than rendered unwired: the server ships
+ // new kinds, and new actions, ahead of the clients that understand them.
+ const usable = notification.actions.filter((offer) => {
+ if (!offer.enabled) return false;
+ const spec = registry[offer.id];
+ return spec ? spec.available(context) : false;
+ });
+
+ // Only from an action this build would otherwise have rendered: a reason about one it cannot
+ // perform anyway is not this row's explanation.
+ const withheldReasonKey =
+ notification.actions.find(
+ (offer) =>
+ !offer.enabled &&
+ offer.disabledReasonKey !== null &&
+ registry[offer.id] !== undefined,
+ )?.disabledReasonKey ?? null;
+
+ const labelOf = (offer: NotificationActionOffer) =>
+ t(offer.labelKey, offer.defaultLabel);
+
+ const run = async (offer: NotificationActionOffer) => {
+ if (busy) return;
+ setMessage(null);
+
+ const spec = registry[offer.id];
+ if (!spec) return;
+
+ setBusy(offer.id);
+ const outcome = await spec.run(context);
+ setBusy(null);
+ if (outcome && !outcome.ok) {
+ setMessage(
+ outcome.message ??
+ t(
+ "notifications.action.failed",
+ "That did not work. Try again in a moment.",
+ ),
+ );
+ return;
+ }
+
+ if (spec.closesPanel) onDismissPanel();
+ };
+
+ const copyDetail = async () => {
+ if (!notification.detail) return;
+ try {
+ await navigator.clipboard.writeText(notification.detail);
+ setCopied(true);
+ } catch {
+ // No clipboard permission, and the message is on screen and selectable anyway.
+ }
+ };
+
+ const note = noteFor(notification, documentState, withheldReasonKey, t);
+
+ return (
+
+ {unread && (
+
+ )}
+ {title}
+ {notification.occurrences > 1 && (
+
+ {t("notifications.occurrences", {
+ count: notification.occurrences,
+ defaultValue: "{{count}} times",
+ })}
+
+ )}
+
+ {notification.detail && (
+ <>
+
+ {notification.detail}
+
+
+ void copyDetail()}
+ >
+ {copied
+ ? t("notifications.detail.copied", "Copied")
+ : t("notifications.detail.copy", "Copy error")}
+
+ setExpanded((wasExpanded) => !wasExpanded)}
+ >
+ {expanded
+ ? t("notifications.detail.less", "Show less")
+ : t("notifications.detail.more", "Show full message")}
+
+
+ >
+ )}
+
+ {note && {note} }
+
+ {/* In the kind's declared order, the first leading. */}
+ {usable.length > 0 && (
+
+ {usable.map((offer, index) => (
+ void run(offer)}
+ />
+ ))}
+
+ )}
+
+ {message && (
+
+ {message}
+
+ )}
+
+ );
+}
+
+interface ActionButtonProps {
+ variant: "primary" | "secondary";
+ rowTitle: string;
+ label: string;
+ busy: boolean;
+ onRun: () => void;
+}
+
+function ActionButton({
+ variant,
+ rowTitle,
+ label,
+ busy,
+ onRun,
+}: ActionButtonProps) {
+ return (
+
+ {label}
+
+ );
+}
diff --git a/frontend/editor/src/core/components/notifications/useNotificationsAvailable.ts b/frontend/editor/src/core/components/notifications/useNotificationsAvailable.ts
new file mode 100644
index 0000000000..1835a0541f
--- /dev/null
+++ b/frontend/editor/src/core/components/notifications/useNotificationsAvailable.ts
@@ -0,0 +1,11 @@
+/**
+ * Whether this build has a notifications API to read. When it does not, the bell must not
+ * mount at all: an unconditional mount would poll an endpoint that does not exist, leaving a
+ * permanent timer and a 404 in the network log for nothing it could ever show.
+ *
+ * Core has no failure registry and no notification routes, so the answer here is no; a build
+ * that ships them overrides this to say so.
+ */
+export function useNotificationsAvailable(): boolean {
+ return false;
+}
diff --git a/frontend/editor/src/core/components/shared/WorkbenchBar.tsx b/frontend/editor/src/core/components/shared/WorkbenchBar.tsx
index 28eccce112..a18b58661f 100644
--- a/frontend/editor/src/core/components/shared/WorkbenchBar.tsx
+++ b/frontend/editor/src/core/components/shared/WorkbenchBar.tsx
@@ -603,8 +603,7 @@ export default function WorkbenchBar({
enforcingProgress={enforcingProgress}
/>
)}
- {/* Last in the globals, so it is the rightmost control. Workbench floats it instead
- when the bar is down, so the bell is reachable whether or not a file is open. */}
+ {/* Last in the globals, so it is the rightmost control. */}
diff --git a/frontend/editor/src/core/components/notifications/BellIcon.tsx b/frontend/editor/src/core/ui/BellIcon.tsx
similarity index 68%
rename from frontend/editor/src/core/components/notifications/BellIcon.tsx
rename to frontend/editor/src/core/ui/BellIcon.tsx
index 3cbd55519c..87908c4af6 100644
--- a/frontend/editor/src/core/components/notifications/BellIcon.tsx
+++ b/frontend/editor/src/core/ui/BellIcon.tsx
@@ -1,6 +1,6 @@
/**
- * The bundled Material Symbols set only carries the filled variant, which reads as permanently
- * ringing. Mirrors the portal's own icon rather than importing it: core cannot reach into portal.
+ * An outline bell. The bundled Material Symbols set only carries the filled variant,
+ * which reads as permanently ringing.
*/
export function BellIcon({ size = 18 }: { size?: number }) {
return (
diff --git a/frontend/editor/src/core/ui/index.ts b/frontend/editor/src/core/ui/index.ts
index dcf0e9265f..c621319f0e 100644
--- a/frontend/editor/src/core/ui/index.ts
+++ b/frontend/editor/src/core/ui/index.ts
@@ -1,5 +1,6 @@
export * from "@app/ui/Button";
export * from "@app/ui/ActionIcon";
+export * from "@app/ui/BellIcon";
export * from "@app/ui/Logo";
export * from "@app/ui/FilePicker";
export * from "@app/ui/SegmentedControl";
diff --git a/frontend/editor/src/proprietary/components/notifications/useNotificationsAvailable.ts b/frontend/editor/src/proprietary/components/notifications/useNotificationsAvailable.ts
new file mode 100644
index 0000000000..2e21f0f33e
--- /dev/null
+++ b/frontend/editor/src/proprietary/components/notifications/useNotificationsAvailable.ts
@@ -0,0 +1,7 @@
+/**
+ * This build ships the failure registry and the notification routes, so the bell has
+ * something to read and may mount.
+ */
+export function useNotificationsAvailable(): boolean {
+ return true;
+}