From 1df372764fe9a21516df83e8aeacf32da6efc236 Mon Sep 17 00:00:00 2001 From: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:07:35 +0000 Subject: [PATCH 1/2] Mobile follow-ups to #7518: tool-list search, and drop the empty overflow menu (#7660) # Description of Changes Follow-up to #7518, picking up two mobile rough edges found while going over that branch. Two changes, one commit each. ## 1. Tool search back in the tool list (mobile) Tool search lives in the workbench bar's super search, which on mobile sits on the Workspace slide. So searching for a tool meant swiping off the tool list, typing, then swiping back. This puts a filter at the head of the tool panel on mobile. Reuses the existing `ToolSearch` component in `mode="filter"`, the same one the desktop fullscreen picker uses. Drives `setSearchQuery` on `ToolWorkflowContext`, so the query, filtering and grouped results are all existing paths. `ToolPanel` takes a new `showSearch` prop; `RightSidebar` passes `showSearch={isMobile}`. Desktop renders exactly as before. **To test:** - Open the editor at a phone-width viewport (under 1024px). - A "Search tools..." field should sit above Favourites / Recommended in the Tools pane. - Typing filters into grouped results. Clearing goes back to the compact list. - It hides once a tool is open, and comes back on the way out. - On desktop the field should not appear at all. ## 2. The mobile overflow menu opened with nothing in it `WorkbenchBarMobileActions` rendered its kebab trigger unconditionally. But every item inside is gated on `currentView === "viewer"` or `!isCustomView`. In a `custom:*` workbench both are false, so the dropdown was empty. `WorkbenchBarDesktopActions` renders nothing in that case, so this only showed on phones. Now returns `null` when neither group applies, with the two conditions named so the trigger and the items can't drift apart again. **To test:** - Phone-width viewport, load a PDF. - Open a tool with its own workbench view: Compare, Get Info report, Show JS, Validate Signature, Edit Table of Contents, or PDF Text Editor. - The kebab at the right of the workbench bar should be gone entirely, rather than opening an empty menu. - Back in the viewer or page editor it should still be there, with Print / Download / Save As / Close. --- .../WorkbenchBarMobileActions.tsx | 15 +++++++--- .../core/components/tools/RightSidebar.tsx | 3 ++ .../src/core/components/tools/ToolPanel.css | 11 +++++++ .../src/core/components/tools/ToolPanel.tsx | 30 +++++++++++++++++-- 4 files changed, 53 insertions(+), 6 deletions(-) diff --git a/frontend/editor/src/core/components/shared/workbenchBar/WorkbenchBarMobileActions.tsx b/frontend/editor/src/core/components/shared/workbenchBar/WorkbenchBarMobileActions.tsx index 6d1cf0efdf..7675c37e33 100644 --- a/frontend/editor/src/core/components/shared/workbenchBar/WorkbenchBarMobileActions.tsx +++ b/frontend/editor/src/core/components/shared/workbenchBar/WorkbenchBarMobileActions.tsx @@ -25,6 +25,13 @@ export default function WorkbenchBarMobileActions({ }: WorkbenchBarActionsProps) { const { t } = useTranslation(); const exportDisabled = actionsDisabled || policyEnforcing; + const showPrint = currentView === "viewer"; + const showFileActions = !isCustomView; + + // Custom workbench views own their content, so none of these apply. The + // desktop cluster renders nothing at all in that case; without this the + // trigger would still be there, opening an empty dropdown. + if (!showPrint && !showFileActions) return null; return ( @@ -39,7 +46,7 @@ export default function WorkbenchBarMobileActions({ - {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} - - - - - - - )} - - {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 ( - - ); -} 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} + + + + + + + )} + + {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 ( + + ); +} 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; +}