Merge remote-tracking branch 'origin/feature/failure-notifications' into feature/policy-decrypt-retry

# Conflicts:
#	app/proprietary/src/main/java/stirling/software/proprietary/failure/FailureActionId.java
#	frontend/editor/src/core/components/notifications/NotificationBell.test.tsx
#	frontend/editor/src/core/components/notifications/NotificationBell.tsx
This commit is contained in:
EthanHealy01
2026-08-24 15:26:49 +01:00
14 changed files with 418 additions and 322 deletions
@@ -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");
@@ -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;
@@ -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(<NotificationBell />);
await Promise.resolve();
expect(screen.queryByRole("button")).toBeNull();
expect(fetchNotifications).not.toHaveBeenCalled();
});
it("shows no badge when there is nothing to report", async () => {
render(<NotificationBell />);
@@ -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 <MountedNotificationBell />;
}
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<HTMLDivElement>(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<string | null>(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() {
</div>
);
}
/** 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<string | null>(null);
const [busy, setBusy] = useState<string | null>(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 (
<li
className="notification-bell__item"
data-severity={notification.severity.toLowerCase()}
>
{unread && (
<span
className="notification-bell__dot"
aria-label={t("notifications.unread", "Unread")}
/>
)}
<span className="notification-bell__item-title">{title}</span>
{notification.occurrences > 1 && (
<span className="notification-bell__count">
{t("notifications.occurrences", {
count: notification.occurrences,
defaultValue: "{{count}} times",
})}
</span>
)}
{summary && <span className="notification-bell__detail">{summary}</span>}
{note && <span className="notification-bell__note">{note}</span>}
{/* 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 && (
<span className="notification-bell__actions">
<ActionButton
variant="primary"
rowTitle={title}
label={labelOf(primary)}
busy={busy === primary.id}
onRun={() => void run(primary)}
/>
{secondary && (
<ActionButton
variant="secondary"
rowTitle={title}
label={labelOf(secondary)}
busy={busy === secondary.id}
onRun={() => void run(secondary)}
/>
)}
{(overflow.length > 0 || notification.detail) && (
<Menu withinPortal position="bottom-end" shadow="md" width={180}>
<Menu.Target>
<Tooltip
label={t("notifications.action.more", "More options")}
withinPortal
>
<ActionIcon
variant="tertiary"
size="sm"
className="notification-bell__more"
aria-label={`${t("notifications.action.more", "More options")}: ${title}`}
>
<MoreIcon />
</ActionIcon>
</Tooltip>
</Menu.Target>
<Menu.Dropdown className="notification-bell__menu">
{overflow.map((offer) => (
<Menu.Item
key={offer.id}
disabled={busy === offer.id}
onClick={() => void run(offer)}
>
{labelOf(offer)}
</Menu.Item>
))}
{notification.detail && (
<Menu.Item
closeMenuOnClick={false}
onClick={() => void copyDetail()}
>
{copied
? t("notifications.action.copiedLog", "Copied")
: t("notifications.action.copyLog", "Copy log")}
</Menu.Item>
)}
</Menu.Dropdown>
</Menu>
)}
</span>
)}
{message && (
<span className="notification-bell__message" role="alert">
{message}
</span>
)}
</li>
);
}
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 (
<Button
variant={variant}
size="sm"
fontSize="xs"
className="notification-bell__cta"
disabled={busy}
// Every row's buttons read alike, so the label alone would not say which failure this acts on.
aria-label={`${label}: ${rowTitle}`}
onClick={onRun}
>
{label}
</Button>
);
}
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 (
<svg {...ICON_PROPS} strokeWidth={2.5}>
<circle cx="5" cy="12" r="0.5" />
<circle cx="12" cy="12" r="0.5" />
<circle cx="19" cy="12" r="0.5" />
</svg>
);
}
@@ -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<string | null>(null);
const [busy, setBusy] = useState<string | null>(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 (
<li
className="notification-bell__item"
data-severity={notification.severity.toLowerCase()}
>
{unread && (
<span
className="notification-bell__dot"
aria-label={t("notifications.unread", "Unread")}
/>
)}
<span className="notification-bell__item-title">{title}</span>
{notification.occurrences > 1 && (
<span className="notification-bell__count">
{t("notifications.occurrences", {
count: notification.occurrences,
defaultValue: "{{count}} times",
})}
</span>
)}
{summary && <span className="notification-bell__detail">{summary}</span>}
{note && <span className="notification-bell__note">{note}</span>}
{/* 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 && (
<span className="notification-bell__actions">
<ActionButton
variant="primary"
rowTitle={title}
label={labelOf(primary)}
busy={busy === primary.id}
onRun={() => void run(primary)}
/>
{secondary && (
<ActionButton
variant="secondary"
rowTitle={title}
label={labelOf(secondary)}
busy={busy === secondary.id}
onRun={() => void run(secondary)}
/>
)}
{(overflow.length > 0 || notification.detail) && (
<Menu withinPortal position="bottom-end" shadow="md" width={180}>
<Menu.Target>
<Tooltip
label={t("notifications.action.more", "More options")}
withinPortal
>
<ActionIcon
variant="tertiary"
size="sm"
className="notification-bell__more"
aria-label={`${t("notifications.action.more", "More options")}: ${title}`}
>
<MoreIcon />
</ActionIcon>
</Tooltip>
</Menu.Target>
<Menu.Dropdown className="notification-bell__menu">
{overflow.map((offer) => (
<Menu.Item
key={offer.id}
disabled={busy === offer.id}
onClick={() => void run(offer)}
>
{labelOf(offer)}
</Menu.Item>
))}
{notification.detail && (
<Menu.Item
closeMenuOnClick={false}
onClick={() => void copyDetail()}
>
{copied
? t("notifications.action.copiedLog", "Copied")
: t("notifications.action.copyLog", "Copy log")}
</Menu.Item>
)}
</Menu.Dropdown>
</Menu>
)}
</span>
)}
{message && (
<span className="notification-bell__message" role="alert">
{message}
</span>
)}
</li>
);
}
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 (
<Button
variant={variant}
size="sm"
fontSize="xs"
className="notification-bell__cta"
disabled={busy}
// Every row's buttons read alike, so the label alone would not say which failure this acts on.
aria-label={`${label}: ${rowTitle}`}
onClick={onRun}
>
{label}
</Button>
);
}
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 (
<svg {...ICON_PROPS} strokeWidth={2.5}>
<circle cx="5" cy="12" r="0.5" />
<circle cx="12" cy="12" r="0.5" />
<circle cx="19" cy="12" r="0.5" />
</svg>
);
}
@@ -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;
}
@@ -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. */}
<div className="workbench-bar-divider workbench-bar-globals-sep" />
<NotificationBell />
</div>
@@ -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 (
<Menu shadow="md" width={230} position="bottom-end">
@@ -39,7 +46,7 @@ export default function WorkbenchBarMobileActions({
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
{currentView === "viewer" && (
{showPrint && (
<Menu.Item
leftSection={<PrintIcon sx={{ fontSize: "1.1rem" }} />}
disabled={exportDisabled}
@@ -48,7 +55,7 @@ export default function WorkbenchBarMobileActions({
{t("workbenchBar.print", "Print PDF")}
</Menu.Item>
)}
{!isCustomView && (
{showFileActions && (
<Menu.Item
leftSection={
<LocalIcon
@@ -63,7 +70,7 @@ export default function WorkbenchBarMobileActions({
{downloadLabel}
</Menu.Item>
)}
{!isCustomView && saveAsIconName && (
{showFileActions && saveAsIconName && (
<Menu.Item
leftSection={
<LocalIcon icon={saveAsIconName} width="1.1rem" height="1.1rem" />
@@ -74,7 +81,7 @@ export default function WorkbenchBarMobileActions({
{t("workbenchBar.saveAs", "Save As")}
</Menu.Item>
)}
{!isCustomView && (
{showFileActions && (
<>
<Menu.Divider />
<Menu.Item
@@ -282,6 +282,9 @@ export default function RightSidebar() {
onShowAllTools={handleShowAllTools}
onToolSelect={handleToolSelectWithTransition}
compact={false}
/* Mobile keeps the workbench bar - and with it the super search -
on the other slide, so the list needs its own filter. */
showSearch={isMobile}
/>
</>
</div>
@@ -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;
@@ -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 */}
<ToolPanelViewerBar />
{allToolsView && searchQuery.trim().length > 0 ? (
{panelSearch && (
<div className="tool-panel__search">
<ToolSearch
value={searchQuery}
onChange={setSearchQuery}
toolRegistry={toolRegistry}
mode="filter"
/>
</div>
)}
{searching && (allToolsView || panelSearch) ? (
<div className="flex-1 flex flex-col overflow-y-auto">
<SearchResults
filteredTools={filteredTools}
@@ -59,7 +85,7 @@ export default function ToolPanel({
selectedToolKey={selectedToolKey}
onSelect={(id) => selectTool(id as ToolId)}
filteredTools={filteredTools}
isSearching={Boolean(searchQuery && searchQuery.trim().length > 0)}
isSearching={searching}
compact={compactProp ?? !allToolsView}
onShowAllTools={onShowAllTools}
/>
@@ -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 (
+1
View File
@@ -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";
@@ -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;
}