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 dfebf14891..e1ccd182c9 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,7 +10,10 @@ 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"), @@ -21,7 +24,7 @@ public enum FailureActionId { /** Ask the owner for the password, unlock the document in their client, then retry. */ DECRYPT_AND_RETRY(Execution.CLIENT, "Decrypt and retry"), - /** 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 df49ce6b4b..de6312d521 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 @@ -30,8 +30,10 @@ import stirling.software.proprietary.failure.FailureActionException; @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 6ffdca9c20..0b7b3ceb7e 100644 --- a/frontend/editor/src/core/components/notifications/NotificationBell.test.tsx +++ b/frontend/editor/src/core/components/notifications/NotificationBell.test.tsx @@ -40,6 +40,8 @@ vi.mock("@app/services/notifications", () => ({ const h = vi.hoisted(() => ({ hasLocalFile: true, retryPayload: { operation: "removePassword" } as unknown, + // This build has the notifications API, except in the one test about the build that does not. + notificationsAvailable: true, specs: {} as Record< string, { @@ -56,6 +58,10 @@ vi.mock("@app/services/notificationRetry", () => ({ loadRetryPayload: () => Promise.resolve(h.retryPayload), })); +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, @@ -150,9 +156,21 @@ describe("NotificationBell", () => { fetchNotifications.mockReset().mockResolvedValue([]); h.hasLocalFile = true; h.retryPayload = { operation: "removePassword" }; + 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 6947b90d00..0a7502a981 100644 --- a/frontend/editor/src/core/components/notifications/NotificationBell.tsx +++ b/frontend/editor/src/core/components/notifications/NotificationBell.tsx @@ -6,30 +6,17 @@ import { useRef, useState, } from "react"; -import type { TFunction } from "i18next"; import { useTranslation } from "react-i18next"; -import { Menu, Tooltip } from "@mantine/core"; -import { Button } from "@app/ui"; -import { ActionIcon } from "@app/ui/ActionIcon"; -import { BellIcon } from "@app/components/notifications/BellIcon"; +import { BellIcon, Button } from "@app/ui"; import DividerWithText from "@app/components/shared/DividerWithText"; import EncryptedPdfUnlockModal from "@app/components/shared/EncryptedPdfUnlockModal"; +import { useNotifications } from "@app/hooks/useNotifications"; +import { useNotificationActions } from "@app/components/notifications/notificationActions"; import { - isResolvableHere, - useNotifications, -} from "@app/hooks/useNotifications"; -import { - useNotificationActions, - type ClientActionRegistry, - type ClientActionSpec, - type NotificationActionContext, -} from "@app/components/notifications/notificationActions"; -import { promoteActions } from "@app/components/notifications/notificationActionSlots"; -import type { - AppNotification, - NotificationActionOffer, -} from "@app/services/notifications"; -import type { NotificationDocumentState } from "@app/hooks/useNotifications"; + NotificationItem, + type PasswordPrompt, +} from "@app/components/notifications/NotificationItem"; +import { useNotificationsAvailable } from "@app/components/notifications/useNotificationsAvailable"; import "@app/components/notifications/NotificationBell.css"; /** @@ -37,6 +24,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, refresh } = useNotifications(); @@ -44,13 +39,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, ); @@ -248,287 +239,3 @@ export function NotificationBell() { ); } - -/** An action that asked for a password, with everything running it needs. */ -interface PasswordPrompt { - offer: NotificationActionOffer; - spec: ClientActionSpec; - context: NotificationActionContext; - /** The row's title, so the prompt can say which failure it is unlocking for. */ - rowTitle: string; -} - -/** - * 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. - */ -/** The kind's own sentence, sharing the portal's copy. Empty for a kind this build has none for. */ -function summaryKeyOf(titleKey: string): string { - return titleKey.replace(/\.title$/, ".description"); -} - -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 it cannot be opened or retried here.", - ); - return isResolvableHere(notification) - ? t( - "notifications.notOnThisDevice", - "This document is not on this device, so it cannot be opened or retried here.", - ) - : null; -} - -interface NotificationItemProps { - notification: AppNotification; - unread: boolean; - documentState: NotificationDocumentState; - registry: ClientActionRegistry; - onDismissPanel: () => void; - /** Hand a password-collecting action to the panel, which owns the prompt. */ - onRequestPassword: (prompt: PasswordPrompt) => void; -} - -/** Its own component because the last attempt's message and the copy state are per-row. */ -function NotificationItem({ - notification, - unread, - documentState, - registry, - onDismissPanel, - onRequestPassword, -}: NotificationItemProps) { - const { t } = useTranslation(); - const [message, setMessage] = useState(null); - const [busy, setBusy] = useState(null); - const [copied, setCopied] = useState(false); - - const title = t(notification.titleKey, notification.defaultTitle); - const context: NotificationActionContext = { - notification, - hasLocalFile: documentState.hasLocalFile, - retryPayload: documentState.retryPayload, - }; - - const { primary, secondary, overflow, withheldReasonKey } = promoteActions( - notification.actions, - (offer) => { - const spec = registry[offer.id]; - // An id this build has never heard of: skipped, not rendered unwired. The server ships new kinds - // and their actions ahead of the clients that understand them. - if (!spec) return false; - return spec.available(context); - }, - ); - - 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; - // A password action is handed to the panel, which prompts for it and runs it from there. - if (spec.needsPassword) { - onRequestPassword({ offer, spec, context, rowTitle: title }); - 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); - const summary = t(summaryKeyOf(notification.titleKey), { defaultValue: "" }); - - return ( -
  • - {unread && ( - - )} - {title} - {notification.occurrences > 1 && ( - - {t("notifications.occurrences", { - count: notification.occurrences, - defaultValue: "{{count}} times", - })} - - )} - - {summary && {summary}} - - {note && {note}} - - {/* Two buttons at most, then a menu: the row's own answer, one runner-up, and the rest tucked - out of the way so a row of near-equal buttons never competes for the click. */} - {primary && ( - - void run(primary)} - /> - {secondary && ( - void run(secondary)} - /> - )} - {(overflow.length > 0 || notification.detail) && ( - - - - - - - - - - {overflow.map((offer) => ( - void run(offer)} - > - {labelOf(offer)} - - ))} - {notification.detail && ( - void copyDetail()} - > - {copied - ? t("notifications.action.copiedLog", "Copied") - : t("notifications.action.copyLog", "Copy log")} - - )} - - - )} - - )} - - {message && ( - - {message} - - )} -
  • - ); -} - -interface ActionButtonProps { - /** Solid for the row's answer, outlined for its runner-up, ghost for the rest. */ - variant: "primary" | "secondary" | "tertiary"; - rowTitle: string; - label: string; - busy: boolean; - onRun: () => void; -} - -function ActionButton({ - variant, - rowTitle, - label, - busy, - onRun, -}: ActionButtonProps) { - return ( - - ); -} - -const ICON_PROPS = { - width: 14, - height: 14, - viewBox: "0 0 24 24", - fill: "none", - stroke: "currentColor", - strokeWidth: 2, - strokeLinecap: "round" as const, - strokeLinejoin: "round" as const, - "aria-hidden": true, -}; - -function MoreIcon() { - 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..3652f78462 --- /dev/null +++ b/frontend/editor/src/core/components/notifications/NotificationItem.tsx @@ -0,0 +1,301 @@ +import { useState } from "react"; +import type { TFunction } from "i18next"; +import { useTranslation } from "react-i18next"; +import { Menu, Tooltip } from "@mantine/core"; +import { ActionIcon, Button } from "@app/ui"; +import { isResolvableHere } from "@app/hooks/useNotifications"; +import type { NotificationDocumentState } from "@app/hooks/useNotifications"; +import type { + ClientActionRegistry, + ClientActionSpec, + NotificationActionContext, +} from "@app/components/notifications/notificationActions"; +import { promoteActions } from "@app/components/notifications/notificationActionSlots"; +import type { + AppNotification, + NotificationActionOffer, +} from "@app/services/notifications"; + +/** An action that asked for a password, with everything running it needs. */ +export interface PasswordPrompt { + offer: NotificationActionOffer; + spec: ClientActionSpec; + context: NotificationActionContext; + /** The row's title, so the prompt can say which failure it is unlocking for. */ + rowTitle: string; +} + +/** The kind's own sentence, sharing the portal's copy. Empty for a kind this build has none for. */ +function summaryKeyOf(titleKey: string): string { + return titleKey.replace(/\.title$/, ".description"); +} + +/** + * 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 it cannot be opened or retried here.", + ); + return isResolvableHere(notification) + ? t( + "notifications.notOnThisDevice", + "This document is not on this device, so it cannot be opened or retried here.", + ) + : null; +} + +interface NotificationItemProps { + notification: AppNotification; + unread: boolean; + documentState: NotificationDocumentState; + registry: ClientActionRegistry; + onDismissPanel: () => void; + /** Hand a password-collecting action to the panel, which owns the prompt. */ + onRequestPassword: (prompt: PasswordPrompt) => void; +} + +/** Its own component because the last attempt's message and the copy state are per-row. */ +export function NotificationItem({ + notification, + unread, + documentState, + registry, + onDismissPanel, + onRequestPassword, +}: NotificationItemProps) { + const { t } = useTranslation(); + const [message, setMessage] = useState(null); + const [busy, setBusy] = useState(null); + const [copied, setCopied] = useState(false); + + const title = t(notification.titleKey, notification.defaultTitle); + const context: NotificationActionContext = { + notification, + hasLocalFile: documentState.hasLocalFile, + retryPayload: documentState.retryPayload, + }; + + const { primary, secondary, overflow, withheldReasonKey } = promoteActions( + notification.actions, + (offer) => { + const spec = registry[offer.id]; + // An id this build has never heard of: skipped, not rendered unwired. The server ships new kinds + // and their actions ahead of the clients that understand them. + if (!spec) return false; + return spec.available(context); + }, + ); + + 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; + // A password action is handed to the panel, which prompts for it and runs it from there. + if (spec.needsPassword) { + onRequestPassword({ offer, spec, context, rowTitle: title }); + 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); + const summary = t(summaryKeyOf(notification.titleKey), { defaultValue: "" }); + + return ( +
  • + {unread && ( + + )} + {title} + {notification.occurrences > 1 && ( + + {t("notifications.occurrences", { + count: notification.occurrences, + defaultValue: "{{count}} times", + })} + + )} + + {summary && {summary}} + + {note && {note}} + + {/* Two buttons at most, then a menu: the row's own answer, one runner-up, and the rest tucked + out of the way so a row of near-equal buttons never competes for the click. */} + {primary && ( + + void run(primary)} + /> + {secondary && ( + void run(secondary)} + /> + )} + {(overflow.length > 0 || notification.detail) && ( + + + + + + + + + + {overflow.map((offer) => ( + void run(offer)} + > + {labelOf(offer)} + + ))} + {notification.detail && ( + void copyDetail()} + > + {copied + ? t("notifications.action.copiedLog", "Copied") + : t("notifications.action.copyLog", "Copy log")} + + )} + + + )} + + )} + + {message && ( + + {message} + + )} +
  • + ); +} + +interface ActionButtonProps { + /** Solid for the row's answer, outlined for its runner-up, ghost for the rest. */ + variant: "primary" | "secondary" | "tertiary"; + rowTitle: string; + label: string; + busy: boolean; + onRun: () => void; +} + +function ActionButton({ + variant, + rowTitle, + label, + busy, + onRun, +}: ActionButtonProps) { + return ( + + ); +} + +const ICON_PROPS = { + width: 14, + height: 14, + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + strokeWidth: 2, + strokeLinecap: "round" as const, + strokeLinejoin: "round" as const, + "aria-hidden": true, +}; + +function MoreIcon() { + 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/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} /> 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; +}