diff --git a/frontend/editor/src/core/components/filesPage/FilesPage.css b/frontend/editor/src/core/components/filesPage/FilesPage.css
index aa9d8248aa..6d4070edb6 100644
--- a/frontend/editor/src/core/components/filesPage/FilesPage.css
+++ b/frontend/editor/src/core/components/filesPage/FilesPage.css
@@ -215,6 +215,17 @@
.files-page-toolbar-actions .mantine-Button-label {
display: none;
}
+/* Exception: the bulk-actions trigger IS its label. Collapsed to an icon it
+ would read as a bare chevron with nothing to say what it opens. */
+.files-page-toolbar-actions
+ .files-page-toolbar-bulk-trigger
+ .mantine-Button-label {
+ display: inline;
+}
+.files-page-toolbar-actions .files-page-toolbar-bulk-trigger {
+ padding-left: 0.75rem;
+ padding-right: 0.5rem;
+}
/* Pin the view toggle: never let it clip off the right. flex-shrink:0
keeps its width fixed; the rest of the row shrinks around it. */
.files-page-toolbar-actions .mantine-SegmentedControl-root {
@@ -476,6 +487,23 @@
gap: 0.4rem;
}
+/* Narrow cards: let the values wrap as whole units onto their own lines rather
+ than breaking mid-value ("239.26 / KB") around a stranded separator. */
+@media (max-width: 64rem) {
+ .files-page-card-meta {
+ flex-wrap: wrap;
+ align-items: baseline;
+ column-gap: 0.4rem;
+ row-gap: 0.05rem;
+ }
+ .files-page-card-meta > span {
+ white-space: nowrap;
+ }
+ .files-page-card-meta-sep {
+ display: none;
+ }
+}
+
/* Parent-folder breadcrumb shown on cards/rows during recursive search so
the user can tell which folder each hit lives in without navigating. */
.files-page-card-path {
@@ -1295,30 +1323,38 @@
sits next to the Upload button without breaking the action row. */
display: none;
}
-@media (max-width: 900px) {
+@media (max-width: 1024px) {
.files-page-toolbar {
/* nowrap so "7 items" + "Select all" sit on the same row as the
filter dropdowns and view-toggle instead of stacking on three
- separate lines. Per-child min-width:0 lets them shrink as needed.
- Used to only kick in at ≤640px which left a broken zone where
- both side panels were hidden but the toolbar was still wrapping
- to multiple rows. */
+ separate lines. Runs to the app's mobile breakpoint: capping it at
+ 900px left 901-1024px wrapping to two rows, which is the band the
+ mobile layout actually renders in.
+
+ Scrolls rather than clips. With a selection active the bulk-action
+ strip cannot fit any phone width, and `overflow-x: hidden` put those
+ buttons permanently out of reach behind the edge. */
flex-wrap: nowrap;
gap: 0.35rem;
padding: 0.35rem 0.5rem;
min-height: auto;
- overflow-x: hidden;
+ overflow-x: auto;
+ scrollbar-width: none;
+ }
+ .files-page-toolbar::-webkit-scrollbar {
+ display: none;
}
.files-page-toolbar-info {
- /* Was `flex-basis: 100%` which forced a row break. Let it share
- the row, shrink hard if needed, and ellipsize so the count line
- collapses gracefully (was overlapping the bulk-action buttons
- at ~400px because no truncation rule existed). */
- flex: 0 1 auto;
+ /* The toolbar's only status text. Pinned, because against nowrap
+ siblings it lost every shrink round and rendered as "3 i". */
+ flex-shrink: 0;
min-width: 0;
white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
+ }
+ /* Filter and sort collapse to icon triggers here (see FilesToolbar*Menu);
+ they are the whole control, so they never shrink. */
+ .files-page-toolbar-icon-btn {
+ flex-shrink: 0;
}
.files-page-toolbar-actions {
flex-wrap: nowrap;
@@ -1350,41 +1386,48 @@
navigation, so the in-header Home/Apps/Close trio is duplicated
and the first to go. Same for "Upload" - the user can use the
centre drop overlay. */
-@media (max-width: 640px) {
- /* Drop the 3-column grid on phones; flex-wrap lets the search slip onto
- * its own row when chrome is too cramped to share. */
+/* ── Mobile + tablet chrome (≤1024px = useIsMobile) ──────────────────
+ The desktop header is a 3-column grid whose middle track can grow to
+ 40rem. Below ~1024px that track eats the row: the breadcrumb column
+ collapsed to ~36px (wrapping "All files" to two lines) and the action
+ column overflowed, pushing Upload off the right edge. One flex row
+ instead - breadcrumb and actions keep their intrinsic width and the
+ search takes whatever is left. Ends at the app's mobile breakpoint so
+ it matches the layout HomePage is already rendering. */
+@media (max-width: 1024px) {
.files-page-header {
display: flex;
- flex-wrap: wrap;
+ flex-wrap: nowrap;
+ align-items: center;
gap: 0.4rem;
- padding: 0 0.4rem;
+ padding: 0.25rem 0.4rem;
overflow-x: hidden;
}
- .files-page-header [data-mobile-hide="true"] {
- display: none !important;
+ .files-page-header-search {
+ flex: 1 1 auto;
+ min-width: 0;
+ justify-content: flex-start;
}
- .files-page-header [data-desktop-hide="true"] {
- display: inline-flex !important;
- }
- /* Mobile-hide for sub-toolbar create buttons. */
- .files-page-toolbar [data-mobile-hide="true"] {
- display: none !important;
+ /* Undo the fixed 24rem basis so the pill tracks the row's spare width. */
+ .files-page-header-search .super-search {
+ flex: 1 1 auto;
+ width: 100%;
+ max-width: none;
}
.files-page-header-actions {
+ flex: 0 0 auto;
margin-left: auto;
- gap: 0.3rem;
+ gap: 0.25rem;
flex-wrap: nowrap;
}
.files-page-breadcrumbs {
+ flex: 0 1 auto;
font-size: 0.85rem;
flex-wrap: nowrap;
overflow-x: auto;
min-width: 0;
}
- /* Upload becomes an icon-only square button on mobile so the action
- row stops getting clipped. Scoped to `.files-page-header-actions`
- so the Back button at the header level keeps its visible "Back"
- label (Back has no other on-screen indicator that it's about leaving). */
+ /* Icon-only actions: the labels are what pushed Upload past the edge. */
.files-page-header-actions .mantine-Button-root {
padding-left: 0.55rem;
padding-right: 0.55rem;
@@ -1395,6 +1438,9 @@
.files-page-header-actions .mantine-Button-label {
display: none;
}
+}
+
+@media (max-width: 640px) {
/* Grid: single column on very narrow phones; two columns from ~440px */
.files-page-grid {
grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr));
diff --git a/frontend/editor/src/core/components/filesPage/FilesToolbarBulkMenu.tsx b/frontend/editor/src/core/components/filesPage/FilesToolbarBulkMenu.tsx
new file mode 100644
index 0000000000..3e344ac7b0
--- /dev/null
+++ b/frontend/editor/src/core/components/filesPage/FilesToolbarBulkMenu.tsx
@@ -0,0 +1,104 @@
+import { Menu } from "@mantine/core";
+import { useTranslation } from "react-i18next";
+import CloudUploadIcon from "@mui/icons-material/CloudUpload";
+import DeleteIcon from "@mui/icons-material/Delete";
+import DriveFileMoveIcon from "@mui/icons-material/DriveFileMove";
+import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
+import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
+import OpenInNewIcon from "@mui/icons-material/OpenInNew";
+
+import { Button } from "@app/ui/Button";
+
+interface FilesToolbarBulkMenuProps {
+ selectedCount: number;
+ onAddToWorkspace: () => void;
+ /** Local-only files in the selection; omit when there are none to upload. */
+ onSaveToServer?: () => void;
+ /** Set when storage is off - the item stays listed but disabled. */
+ saveToServerDisabledReason?: string;
+ onShowDetails?: () => void;
+ onMove: () => void;
+ onRemove: () => void;
+}
+
+/**
+ * Bulk actions behind one trigger. The full strip is five buttons wide, which
+ * no phone can hold alongside the count and the clear control, so rather than
+ * letting the row scroll them off the edge they collapse into a menu where
+ * every action keeps its label.
+ */
+export function FilesToolbarBulkMenu({
+ selectedCount,
+ onAddToWorkspace,
+ onSaveToServer,
+ saveToServerDisabledReason,
+ onShowDetails,
+ onMove,
+ onRemove,
+}: FilesToolbarBulkMenuProps) {
+ const { t } = useTranslation();
+
+ const addLabel =
+ selectedCount === 1
+ ? t("filesPage.addToWorkspace", "Add to workspace")
+ : t("filesPage.addToWorkspaceCount", "Add {{count}} to workspace", {
+ count: selectedCount,
+ });
+
+ return (
+
+ );
+}
+
+export default FilesToolbarBulkMenu;
diff --git a/frontend/editor/src/core/components/filesPage/FilesToolbarCount.tsx b/frontend/editor/src/core/components/filesPage/FilesToolbarCount.tsx
new file mode 100644
index 0000000000..d808638d63
--- /dev/null
+++ b/frontend/editor/src/core/components/filesPage/FilesToolbarCount.tsx
@@ -0,0 +1,41 @@
+import { useTranslation } from "react-i18next";
+
+interface FilesToolbarCountProps {
+ loading: boolean;
+ totalCount: number;
+ selectedCount: number;
+ /**
+ * Selection-bar mode: report only the selection. A phone spends the room on
+ * the actions rather than on "3 items · 3 selected".
+ */
+ selectionOnly: boolean;
+}
+
+/** Status text at the head of the files toolbar. */
+export function FilesToolbarCount({
+ loading,
+ totalCount,
+ selectedCount,
+ selectionOnly,
+}: FilesToolbarCountProps) {
+ const { t } = useTranslation();
+
+ const selected = t("filesPage.selectedCount", "{{count}} selected", {
+ count: selectedCount,
+ });
+
+ if (selectionOnly) {
+ return {selected};
+ }
+
+ return (
+
+ {loading
+ ? t("filesPage.loading", "Loading…")
+ : t("filesPage.summary", "{{count}} items", { count: totalCount })}
+ {selectedCount > 0 && · {selected}}
+
+ );
+}
+
+export default FilesToolbarCount;
diff --git a/frontend/editor/src/core/components/filesPage/FilesToolbarFilterMenu.tsx b/frontend/editor/src/core/components/filesPage/FilesToolbarFilterMenu.tsx
new file mode 100644
index 0000000000..81d84daf67
--- /dev/null
+++ b/frontend/editor/src/core/components/filesPage/FilesToolbarFilterMenu.tsx
@@ -0,0 +1,149 @@
+import { MultiSelect, Popover, Select, Stack, TextInput } from "@mantine/core";
+import { useTranslation } from "react-i18next";
+import CloseIcon from "@mui/icons-material/Close";
+import SearchIcon from "@mui/icons-material/Search";
+import TuneIcon from "@mui/icons-material/Tune";
+
+import { ActionIcon } from "@app/ui/ActionIcon";
+import { Button } from "@app/ui/Button";
+import { Tooltip } from "@app/components/shared/Tooltip";
+import type { FilesPageOriginFilter } from "@app/contexts/FilesPageContext";
+
+interface FilesToolbarFilterMenuProps {
+ originFilter: FilesPageOriginFilter;
+ onOriginChange: (value: FilesPageOriginFilter) => void;
+ availableTypes: string[];
+ typeFilter: string[];
+ onTypeChange: (value: string[]) => void;
+ search: string;
+ onSearchChange: (value: string) => void;
+}
+
+/**
+ * Source, type and name filters collapsed behind one icon. Side by side these
+ * three need ~480px, so on narrow viewports they were each truncated to
+ * unreadable stubs ("All sour"). In the popover they get their full width back,
+ * and a dot on the trigger keeps an active filter discoverable while hidden.
+ */
+export function FilesToolbarFilterMenu({
+ originFilter,
+ onOriginChange,
+ availableTypes,
+ typeFilter,
+ onTypeChange,
+ search,
+ onSearchChange,
+}: FilesToolbarFilterMenuProps) {
+ const { t } = useTranslation();
+
+ const activeCount =
+ (originFilter !== "all" ? 1 : 0) +
+ (typeFilter.length > 0 ? 1 : 0) +
+ (search.trim() !== "" ? 1 : 0);
+ const label = t("filesPage.filters.label", "Filters");
+
+ const clearAll = () => {
+ onOriginChange("all");
+ onTypeChange([]);
+ onSearchChange("");
+ };
+
+ return (
+
+
+
);
diff --git a/frontend/editor/src/core/components/shared/config/SettingsMobileBackButton.stories.tsx b/frontend/editor/src/core/components/shared/config/SettingsMobileBackButton.stories.tsx
new file mode 100644
index 0000000000..4cde2b2b90
--- /dev/null
+++ b/frontend/editor/src/core/components/shared/config/SettingsMobileBackButton.stories.tsx
@@ -0,0 +1,17 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { SettingsMobileBackButton } from "@app/components/shared/config/SettingsMobileBackButton";
+
+const meta = {
+ title: "Shared/Config/SettingsMobileBackButton",
+ component: SettingsMobileBackButton,
+ parameters: { layout: "padded" },
+ args: { show: true, onClick: () => {} },
+} satisfies Meta;
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {};
+
+export const Hidden: Story = {
+ args: { show: false },
+};
diff --git a/frontend/editor/src/core/components/shared/config/SettingsMobileBackButton.tsx b/frontend/editor/src/core/components/shared/config/SettingsMobileBackButton.tsx
new file mode 100644
index 0000000000..db21592609
--- /dev/null
+++ b/frontend/editor/src/core/components/shared/config/SettingsMobileBackButton.tsx
@@ -0,0 +1,31 @@
+import { useTranslation } from "react-i18next";
+import ArrowBackRoundedIcon from "@mui/icons-material/ArrowBackRounded";
+import { ActionIcon } from "@app/ui/ActionIcon";
+
+interface SettingsMobileBackButtonProps {
+ /** Only the mobile two-pane layout has a nav pane to go back to. */
+ show: boolean;
+ onClick: () => void;
+}
+
+/** Returns the settings modal from a section back to the section list. */
+export function SettingsMobileBackButton({
+ show,
+ onClick,
+}: SettingsMobileBackButtonProps) {
+ const { t } = useTranslation();
+
+ if (!show) return null;
+
+ return (
+
+
+
+ );
+}
+
+export default SettingsMobileBackButton;
diff --git a/frontend/editor/src/core/components/shared/config/SettingsMobileNavHeader.stories.tsx b/frontend/editor/src/core/components/shared/config/SettingsMobileNavHeader.stories.tsx
new file mode 100644
index 0000000000..a4ade4801b
--- /dev/null
+++ b/frontend/editor/src/core/components/shared/config/SettingsMobileNavHeader.stories.tsx
@@ -0,0 +1,23 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { SettingsMobileNavHeader } from "@app/components/shared/config/SettingsMobileNavHeader";
+import "@app/components/shared/AppConfigModal.css";
+
+const meta = {
+ title: "Shared/Config/SettingsMobileNavHeader",
+ component: SettingsMobileNavHeader,
+ parameters: { layout: "padded" },
+ args: {
+ show: true,
+ onClose: () => {},
+ background: "var(--c-bg-raised)",
+ borderColor: "var(--c-border-subtle)",
+ },
+} satisfies Meta;
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {};
+
+export const Hidden: Story = {
+ args: { show: false },
+};
diff --git a/frontend/editor/src/core/components/shared/config/SettingsMobileNavHeader.tsx b/frontend/editor/src/core/components/shared/config/SettingsMobileNavHeader.tsx
new file mode 100644
index 0000000000..bfeea13861
--- /dev/null
+++ b/frontend/editor/src/core/components/shared/config/SettingsMobileNavHeader.tsx
@@ -0,0 +1,44 @@
+import { Text } from "@mantine/core";
+import { useTranslation } from "react-i18next";
+import { ActionIcon } from "@app/ui/ActionIcon";
+import LocalIcon from "@app/components/shared/LocalIcon";
+
+interface SettingsMobileNavHeaderProps {
+ /** Mobile shows nav and content as separate panes, so the nav needs its own header. */
+ show: boolean;
+ onClose: () => void;
+ background: string;
+ borderColor: string;
+}
+
+/** Header for the settings nav pane: the modal title plus a close button. */
+export function SettingsMobileNavHeader({
+ show,
+ onClose,
+ background,
+ borderColor,
+}: SettingsMobileNavHeaderProps) {
+ const { t } = useTranslation();
+
+ if (!show) return null;
+
+ return (
+
+
+ {t("settings.title", "Settings")}
+
+
+
+
+
+ );
+}
+
+export default SettingsMobileNavHeader;
diff --git a/frontend/editor/src/core/components/shared/config/SettingsNavChevron.stories.tsx b/frontend/editor/src/core/components/shared/config/SettingsNavChevron.stories.tsx
new file mode 100644
index 0000000000..f9ede21f49
--- /dev/null
+++ b/frontend/editor/src/core/components/shared/config/SettingsNavChevron.stories.tsx
@@ -0,0 +1,18 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { SettingsNavChevron } from "@app/components/shared/config/SettingsNavChevron";
+import "@app/components/shared/AppConfigModal.css";
+
+const meta = {
+ title: "Shared/Config/SettingsNavChevron",
+ component: SettingsNavChevron,
+ parameters: { layout: "padded" },
+ args: { show: true },
+} satisfies Meta;
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {};
+
+export const Hidden: Story = {
+ args: { show: false },
+};
diff --git a/frontend/editor/src/core/components/shared/config/SettingsNavChevron.tsx b/frontend/editor/src/core/components/shared/config/SettingsNavChevron.tsx
new file mode 100644
index 0000000000..c14e7933f5
--- /dev/null
+++ b/frontend/editor/src/core/components/shared/config/SettingsNavChevron.tsx
@@ -0,0 +1,20 @@
+import ChevronRightRoundedIcon from "@mui/icons-material/ChevronRightRounded";
+
+interface SettingsNavChevronProps {
+ /** Mobile nav items drill into a second pane, so they get an affordance. */
+ show: boolean;
+}
+
+/** Drill-in affordance on a settings nav item. */
+export function SettingsNavChevron({ show }: SettingsNavChevronProps) {
+ if (!show) return null;
+
+ return (
+
+ );
+}
+
+export default SettingsNavChevron;
diff --git a/frontend/editor/src/core/components/shared/config/configNavSections.tsx b/frontend/editor/src/core/components/shared/config/configNavSections.tsx
index b4a8748434..1fd16de4d4 100644
--- a/frontend/editor/src/core/components/shared/config/configNavSections.tsx
+++ b/frontend/editor/src/core/components/shared/config/configNavSections.tsx
@@ -44,7 +44,7 @@ export const useConfigNavSections = (
key: "general",
label: t("settings.general.title", "General"),
icon: "settings-rounded",
- component: ,
+ component: ,
},
{
key: "hotkeys",
@@ -112,7 +112,7 @@ export const createConfigNavSections = (
key: "general",
label: "General",
icon: "settings-rounded",
- component: ,
+ component: ,
},
{
key: "hotkeys",
diff --git a/frontend/editor/src/core/components/shared/superSearch/SuperSearch.css b/frontend/editor/src/core/components/shared/superSearch/SuperSearch.css
index a815d88fca..67d724eb7a 100644
--- a/frontend/editor/src/core/components/shared/superSearch/SuperSearch.css
+++ b/frontend/editor/src/core/components/shared/superSearch/SuperSearch.css
@@ -36,8 +36,11 @@
border-color: var(--c-primary);
}
-/* Keyboard-shortcut hint pinned to the right of the input. */
+/* Keyboard-shortcut hint pinned to the right of the input. Hidden by default
+ and opted back in below, so the two rules that must agree - showing the chip
+ and reserving room for it - are keyed on one condition and cannot drift. */
.super-search-kbd {
+ display: none;
position: absolute;
right: 0.5rem;
top: 50%;
@@ -54,6 +57,15 @@
letter-spacing: 0.06em;
}
+/* Only where the shortcut is pressable and the pill has room for it. Below the
+ mobile breakpoint the chip used to sit over the input - it has no padding of
+ its own - so the placeholder ran underneath it instead of ellipsising. */
+@media (min-width: 64.0625rem) and (not (pointer: coarse)) {
+ .super-search-kbd {
+ display: inline-block;
+ }
+}
+
/* Portalled to ; coords are set inline from the input's viewport rect. */
.super-search-dropdown {
position: fixed;
diff --git a/frontend/editor/src/core/components/shared/superSearch/SuperSearch.tsx b/frontend/editor/src/core/components/shared/superSearch/SuperSearch.tsx
index 6afcdfff82..9b5cf22190 100644
--- a/frontend/editor/src/core/components/shared/superSearch/SuperSearch.tsx
+++ b/frontend/editor/src/core/components/shared/superSearch/SuperSearch.tsx
@@ -14,6 +14,7 @@ import { Chip } from "@app/ui/Chip";
import { TextInput } from "@app/components/shared/TextInput";
import LocalIcon from "@app/components/shared/LocalIcon";
import { isMacLike } from "@app/utils/hotkeys";
+import { useIsMobile } from "@app/hooks/useIsMobile";
import {
useSuperSearch,
SuperSearchResult,
@@ -373,6 +374,7 @@ export default function SuperSearch({
}
};
+ const isMobile = useIsMobile();
const shortcutHint = useMemo(() => (isMacLike() ? "⌘K" : "Ctrl+K"), []);
const toggleScope = useCallback(
@@ -627,7 +629,11 @@ export default function SuperSearch({
ref={inputRef}
value={query}
onChange={setQuery}
- placeholder={t("superSearch.placeholder", "Search Stirling")}
+ placeholder={
+ isMobile
+ ? t("superSearch.placeholderShort", "Search")
+ : t("superSearch.placeholder", "Search Stirling")
+ }
icon={
}
diff --git a/frontend/editor/src/core/components/shared/workbenchBar/WorkbenchBarDesktopActions.tsx b/frontend/editor/src/core/components/shared/workbenchBar/WorkbenchBarDesktopActions.tsx
new file mode 100644
index 0000000000..e2070d6711
--- /dev/null
+++ b/frontend/editor/src/core/components/shared/workbenchBar/WorkbenchBarDesktopActions.tsx
@@ -0,0 +1,121 @@
+import React from "react";
+import { useTranslation } from "react-i18next";
+import CloseIcon from "@mui/icons-material/Close";
+import PrintIcon from "@mui/icons-material/Print";
+import { ActionIcon } from "@app/ui/ActionIcon";
+import LocalIcon from "@app/components/shared/LocalIcon";
+import {
+ PolicyEnforcingTooltip,
+ renderWithTooltip,
+} from "@app/components/shared/workbenchBar/workbenchBarTooltip";
+import { WorkbenchBarActionsProps } from "@app/components/shared/workbenchBar/types";
+
+interface WorkbenchBarDesktopActionsProps extends WorkbenchBarActionsProps {
+ /** Percentage through the enforcing policy run, when it reports steps. */
+ enforcingProgress?: number;
+}
+
+/**
+ * Desktop version of the workbench bar's global actions: print / export /
+ * save-as sit as icons, with close split off behind a separator.
+ */
+export default function WorkbenchBarDesktopActions({
+ currentView,
+ isCustomView,
+ actionsDisabled,
+ policyEnforcing,
+ downloadLabel,
+ downloadIconName,
+ saveAsIconName,
+ onPrint,
+ onExport,
+ onClose,
+ enforcingProgress,
+}: WorkbenchBarDesktopActionsProps) {
+ const { t } = useTranslation();
+ const exportDisabled = actionsDisabled || policyEnforcing;
+ const closeLabel =
+ currentView === "fileEditor"
+ ? t("workbenchBar.closeAll", "Close All")
+ : t("workbenchBar.closePdf", "Close PDF");
+
+ // Policy enforcement replaces the plain label with a "why is this blocked" card.
+ const tooltipFor = (label: string): React.ReactNode =>
+ policyEnforcing ? (
+
+ ) : (
+ label
+ );
+
+ return (
+ <>
+ {currentView === "viewer" &&
+ renderWithTooltip(
+
+
+ ,
+ tooltipFor(t("workbenchBar.print", "Print PDF")),
+ )}
+
+ {/* Download (file-level action — not relevant in custom views) */}
+ {!isCustomView &&
+ renderWithTooltip(
+ onExport()}
+ disabled={exportDisabled}
+ aria-label={downloadLabel}
+ >
+
+ ,
+ tooltipFor(downloadLabel),
+ )}
+
+ {!isCustomView &&
+ saveAsIconName &&
+ renderWithTooltip(
+ onExport(true)}
+ disabled={exportDisabled}
+ aria-label={t("workbenchBar.saveAs", "Save As")}
+ >
+
+ ,
+ tooltipFor(t("workbenchBar.saveAs", "Save As")),
+ )}
+
+ {/* Separator: export group | close */}
+ {!isCustomView && (
+
+ )}
+
+ {/* Close (context-aware: close all / close viewer file / close page editor) */}
+ {!isCustomView &&
+ renderWithTooltip(
+
+
+ ,
+ closeLabel,
+ )}
+ >
+ );
+}
diff --git a/frontend/editor/src/core/components/shared/workbenchBar/WorkbenchBarMobileActions.tsx b/frontend/editor/src/core/components/shared/workbenchBar/WorkbenchBarMobileActions.tsx
new file mode 100644
index 0000000000..7675c37e33
--- /dev/null
+++ b/frontend/editor/src/core/components/shared/workbenchBar/WorkbenchBarMobileActions.tsx
@@ -0,0 +1,101 @@
+import { Menu } from "@mantine/core";
+import { useTranslation } from "react-i18next";
+import CloseIcon from "@mui/icons-material/Close";
+import MoreVertIcon from "@mui/icons-material/MoreVert";
+import PrintIcon from "@mui/icons-material/Print";
+import { ActionIcon } from "@app/ui/ActionIcon";
+import LocalIcon from "@app/components/shared/LocalIcon";
+import { WorkbenchBarActionsProps } from "@app/components/shared/workbenchBar/types";
+
+/**
+ * Mobile version of the workbench bar's global actions: the icon row won't fit
+ * on a phone, so print / export / save-as / close collapse into one overflow menu.
+ */
+export default function WorkbenchBarMobileActions({
+ currentView,
+ isCustomView,
+ actionsDisabled,
+ policyEnforcing,
+ downloadLabel,
+ downloadIconName,
+ saveAsIconName,
+ onPrint,
+ onExport,
+ onClose,
+}: 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 (
+
+ );
+}
diff --git a/frontend/editor/src/core/components/shared/workbenchBar/WorkbenchBarToolbarHandle.tsx b/frontend/editor/src/core/components/shared/workbenchBar/WorkbenchBarToolbarHandle.tsx
new file mode 100644
index 0000000000..0b971bc991
--- /dev/null
+++ b/frontend/editor/src/core/components/shared/workbenchBar/WorkbenchBarToolbarHandle.tsx
@@ -0,0 +1,67 @@
+import { useTranslation } from "react-i18next";
+import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
+import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp";
+import { Button } from "@app/ui/Button";
+
+interface WorkbenchBarToolbarHandleProps {
+ isMobile: boolean;
+ /** Mobile only: whether the tool row is showing every tool rather than scrolling. */
+ expanded: boolean;
+ onToggleExpanded: () => void;
+ /** Desktop viewer only: retracts the whole tool row. Omit to render no handle. */
+ onRetract?: () => void;
+}
+
+/**
+ * Handle pinned to the right of the tool row. On mobile it expands the row from
+ * a single scrolling line to a wrapped grid; on the desktop viewer it retracts
+ * the row entirely.
+ */
+export default function WorkbenchBarToolbarHandle({
+ isMobile,
+ expanded,
+ onToggleExpanded,
+ onRetract,
+}: WorkbenchBarToolbarHandleProps) {
+ const { t } = useTranslation();
+
+ if (isMobile) {
+ return (
+
+ ) : (
+
+ )
+ }
+ />
+ );
+ }
+
+ if (!onRetract) return null;
+
+ return (
+ }
+ />
+ );
+}
diff --git a/frontend/editor/src/core/components/shared/workbenchBar/types.ts b/frontend/editor/src/core/components/shared/workbenchBar/types.ts
new file mode 100644
index 0000000000..7ec30c5cec
--- /dev/null
+++ b/frontend/editor/src/core/components/shared/workbenchBar/types.ts
@@ -0,0 +1,20 @@
+import { WorkbenchType } from "@app/types/workbench";
+
+/** Shared shape for the workbench bar's file-level global actions (print,
+ * export, save-as, close) so the mobile and desktop clusters stay in step. */
+export interface WorkbenchBarActionsProps {
+ currentView: WorkbenchType;
+ /** Custom workbench views own their content, so file actions don't apply. */
+ isCustomView: boolean;
+ /** No files to act on, or the bar is globally locked out. */
+ actionsDisabled: boolean;
+ /** A policy run is enforcing on a file the export would touch. */
+ policyEnforcing: boolean;
+ /** Context-aware label for the download/export action. */
+ downloadLabel: string;
+ downloadIconName: string;
+ saveAsIconName?: string;
+ onPrint: () => void;
+ onExport: (forceNewFile?: boolean) => void;
+ onClose: () => void;
+}
diff --git a/frontend/editor/src/core/components/shared/workbenchBar/workbenchBarTooltip.tsx b/frontend/editor/src/core/components/shared/workbenchBar/workbenchBarTooltip.tsx
new file mode 100644
index 0000000000..e7a0b482ee
--- /dev/null
+++ b/frontend/editor/src/core/components/shared/workbenchBar/workbenchBarTooltip.tsx
@@ -0,0 +1,66 @@
+import React from "react";
+import { Group, Loader, Progress, Stack, Text } from "@mantine/core";
+import { useTranslation } from "react-i18next";
+import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined";
+import { Tooltip } from "@app/components/shared/Tooltip";
+
+/** Wrap a bar control in the bar's standard tooltip, or pass it through when
+ * the caller has no tooltip to show. */
+export function renderWithTooltip(
+ node: React.ReactNode,
+ tooltip: React.ReactNode | undefined,
+) {
+ if (!tooltip) return node;
+ return (
+
+
{node}
+
+ );
+}
+
+interface PolicyEnforcingTooltipProps {
+ /** The blocked action's label, e.g. "Print PDF". */
+ action: string;
+ /** Percentage through the enforcing run, when the run reports steps. */
+ progress?: number;
+}
+
+/** Tooltip body explaining that a file action is blocked by a policy run. */
+export function PolicyEnforcingTooltip({
+ action,
+ progress,
+}: PolicyEnforcingTooltipProps) {
+ const { t } = useTranslation();
+ return (
+
+
+
+
+ {t(
+ "policy.blockingAction",
+ "{{action}} blocked while enforcing policy, please wait",
+ { action },
+ )}
+
+
+ {progress != null ? (
+
+ ) : (
+
+ )}
+
+ );
+}
diff --git a/frontend/editor/src/core/components/tools/RightSidebar.tsx b/frontend/editor/src/core/components/tools/RightSidebar.tsx
index d6cd86868b..a4bdbced7e 100644
--- a/frontend/editor/src/core/components/tools/RightSidebar.tsx
+++ b/frontend/editor/src/core/components/tools/RightSidebar.tsx
@@ -237,7 +237,10 @@ export default function RightSidebar() {
: t("toolPanel.goBack", "Go back")
}
/>
- ) : (
+ ) : showCloseButton || !isMobile ? (
+ /* Without a back button this header is just the collapse control,
+ which has no meaning on mobile - the slider switches panes. Drop
+ it there so the tool list starts under the tabs. */
diff --git a/frontend/editor/src/core/components/tools/ToolPanel.css b/frontend/editor/src/core/components/tools/ToolPanel.css
index 0e93c414d8..3e3985103f 100644
--- a/frontend/editor/src/core/components/tools/ToolPanel.css
+++ b/frontend/editor/src/core/components/tools/ToolPanel.css
@@ -169,6 +169,31 @@
color: var(--c-text);
}
+/* On mobile the "Tools" tab sits directly above this panel, so the heading
+ just repeats it. The header stays for its actions, but shrinks to fit them -
+ at the desktop 52px it would read as an empty band once the title is gone. */
+@media (max-width: 64rem) {
+ .tool-panel__compact-title {
+ display: none;
+ }
+ .tool-panel__compact-header {
+ min-height: 44px;
+ padding-top: 0.25rem;
+ padding-bottom: 0.25rem;
+ }
+}
+
+/* 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 && (
+
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/tools/automate/AutomationCreation.tsx b/frontend/editor/src/core/components/tools/automate/AutomationCreation.tsx
index 65c50a3481..0e4926adbe 100644
--- a/frontend/editor/src/core/components/tools/automate/AutomationCreation.tsx
+++ b/frontend/editor/src/core/components/tools/automate/AutomationCreation.tsx
@@ -82,7 +82,7 @@ export default function AutomationCreation({
setConfigModalOpen(true);
};
- const handleToolConfigSave = (parameters: Record) => {
+ const handleToolConfigSave = (parameters: Record) => {
if (configuraingToolIndex >= 0) {
updateTool(configuraingToolIndex, {
configured: true,
diff --git a/frontend/editor/src/core/components/tools/automate/AutomationEntry.tsx b/frontend/editor/src/core/components/tools/automate/AutomationEntry.tsx
index 8ffbfeda81..a4a7c1d2ca 100644
--- a/frontend/editor/src/core/components/tools/automate/AutomationEntry.tsx
+++ b/frontend/editor/src/core/components/tools/automate/AutomationEntry.tsx
@@ -19,7 +19,7 @@ interface AutomationEntryProps {
/** Optional description for tooltip */
description?: string;
/** MUI Icon component for the badge */
- badgeIcon?: React.ComponentType;
+ badgeIcon?: React.ComponentType;
/** Array of tool operation names in the workflow */
operations: string[];
/** Click handler */
diff --git a/frontend/editor/src/core/components/tools/automate/AutomationRun.tsx b/frontend/editor/src/core/components/tools/automate/AutomationRun.tsx
index 2e3e5f1539..675372c12b 100644
--- a/frontend/editor/src/core/components/tools/automate/AutomationRun.tsx
+++ b/frontend/editor/src/core/components/tools/automate/AutomationRun.tsx
@@ -9,11 +9,12 @@ import { useToolRegistry } from "@app/contexts/ToolRegistryContext";
import { AutomationConfig, ExecutionStep } from "@app/types/automation";
import { EXECUTION_STATUS } from "@app/constants/automation";
import { useResourceCleanup } from "@app/utils/resourceManager";
+import type { useAutomateOperation } from "@app/hooks/tools/automate/useAutomateOperation";
interface AutomationRunProps {
automation: AutomationConfig;
onComplete: () => void;
- automateOperation?: any; // TODO: Type this properly when available
+ automateOperation?: ReturnType;
}
export default function AutomationRun({
@@ -34,13 +35,13 @@ export default function AutomationRun({
// Use the operation hook's loading state
const isExecuting = automateOperation?.isLoading || false;
const hasResults =
- automateOperation?.files.length > 0 ||
+ (automateOperation?.files.length ?? 0) > 0 ||
automateOperation?.downloadUrl !== null;
// Initialize execution steps from automation
useEffect(() => {
if (automation?.operations) {
- const steps = automation.operations.map((op: any, index: number) => {
+ const steps = automation.operations.map((op, index) => {
const tool = toolRegistry[op.operation as keyof typeof toolRegistry];
return {
id: `${op.operation}-${index}`,
@@ -125,7 +126,7 @@ export default function AutomationRun({
// Mark all as completed and reset current step
setCurrentStepIndex(-1);
console.log(`✅ Automation completed successfully`);
- } catch (error: any) {
+ } catch (error: unknown) {
console.error("Automation execution failed:", error);
setCurrentStepIndex(-1);
}
diff --git a/frontend/editor/src/core/components/tools/automate/ToolSelector.tsx b/frontend/editor/src/core/components/tools/automate/ToolSelector.tsx
index ad7bb33a6e..2dd482863d 100644
--- a/frontend/editor/src/core/components/tools/automate/ToolSelector.tsx
+++ b/frontend/editor/src/core/components/tools/automate/ToolSelector.tsx
@@ -4,6 +4,7 @@ import { Stack, Text, ScrollArea } from "@mantine/core";
import {
ToolRegistryEntry,
ToolRegistry,
+ SubcategoryId,
getToolSupportsAutomate,
} from "@app/data/toolsTaxonomy";
import { useToolSections } from "@app/hooks/useToolSections";
@@ -81,9 +82,7 @@ export default function ToolSelector({
}, [filteredTools]);
// Use the same tool sections logic as the main ToolPicker
- const { sections, searchGroups } = useToolSections(
- transformedFilteredTools as any /* FIX ME */,
- );
+ const { sections, searchGroups } = useToolSections(transformedFilteredTools);
// Determine what to display: search results or organized sections
const isSearching = searchTerm.trim().length > 0;
@@ -98,7 +97,9 @@ export default function ToolSelector({
return [
{
name: "Tools",
- subcategoryId: "all" as any,
+ // Synthetic "all tools" group used only as a fallback when the
+ // taxonomy produces no sections; "all" is not a real SubcategoryId.
+ subcategoryId: "all" as unknown as SubcategoryId,
tools: baseFilteredTools.map(([key, tool]) => ({ id: key, tool })),
},
];
@@ -125,7 +126,7 @@ export default function ToolSelector({
displayGroups.map((subcategory) =>
renderToolButtons(
t,
- subcategory as any,
+ subcategory,
null,
handleToolSelect,
!isSearching,
diff --git a/frontend/editor/src/core/components/viewer/EmbedPdfViewer.tsx b/frontend/editor/src/core/components/viewer/EmbedPdfViewer.tsx
index 8444eb3a18..def28b7493 100644
--- a/frontend/editor/src/core/components/viewer/EmbedPdfViewer.tsx
+++ b/frontend/editor/src/core/components/viewer/EmbedPdfViewer.tsx
@@ -13,6 +13,7 @@ import {
useFileActions,
} from "@app/contexts/FileContext";
import { useFileWithUrl } from "@app/hooks/useFileWithUrl";
+import { ZoomMode } from "@embedpdf/plugin-zoom/react";
import { useViewer } from "@app/contexts/ViewerContext";
import { LocalEmbedPDF } from "@app/components/viewer/LocalEmbedPDF";
import { PdfViewerToolbar } from "@app/components/viewer/PdfViewerToolbar";
@@ -430,7 +431,7 @@ const EmbedPdfViewerContent = ({
return;
case "0":
event.preventDefault();
- zoomActions.requestZoom("fit-width");
+ zoomActions.requestZoom(ZoomMode.FitWidth);
return;
}
}
diff --git a/frontend/editor/src/core/components/viewer/NonPdfViewer.tsx b/frontend/editor/src/core/components/viewer/NonPdfViewer.tsx
index bd2704963a..47d442ef3d 100644
--- a/frontend/editor/src/core/components/viewer/NonPdfViewer.tsx
+++ b/frontend/editor/src/core/components/viewer/NonPdfViewer.tsx
@@ -1,4 +1,4 @@
-import { useCallback, useMemo } from "react";
+import React, { useCallback, useMemo } from "react";
import { Box, Center, Stack, Text } from "@mantine/core";
import { Button } from "@app/ui/Button";
import ArticleIcon from "@mui/icons-material/Article";
@@ -99,12 +99,17 @@ export function NonPdfViewer({ file }: NonPdfViewerProps) {
return (
({
copyToClipboard: () => selection.copyToClipboard(),
- getSelectedText: () => selection.getSelectedText(),
getFormattedSelection: () => selection.getFormattedSelection(),
selectAll: async (totalPages: number) => {
const docId = activeDocumentId;
diff --git a/frontend/editor/src/core/components/viewer/nonpdf/HtmlViewer.tsx b/frontend/editor/src/core/components/viewer/nonpdf/HtmlViewer.tsx
index ee9016b926..329e0cdfd8 100644
--- a/frontend/editor/src/core/components/viewer/nonpdf/HtmlViewer.tsx
+++ b/frontend/editor/src/core/components/viewer/nonpdf/HtmlViewer.tsx
@@ -1,7 +1,9 @@
import { useEffect, useState } from "react";
-import { Box, Paper, Text } from "@mantine/core";
+import { Box, Center, Group, Paper, Stack, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
+import { Button } from "@app/ui/Button";
+import { useIsMobile } from "@app/hooks/useIsMobile";
import { formatFileSize } from "@app/utils/fileUtils";
interface HtmlViewerProps {
@@ -10,37 +12,79 @@ interface HtmlViewerProps {
export function HtmlViewer({ file }: HtmlViewerProps) {
const { t } = useTranslation();
+ const isMobile = useIsMobile();
const [objectUrl, setObjectUrl] = useState(null);
+ // Phones render a desktop-width document into ~400px, which reads as a blank
+ // column, so the iframe is opt-in there. Derived rather than seeded into
+ // state because useIsMobile resolves after first paint.
+ const [optedIn, setOptedIn] = useState(false);
+ const showPreview = !isMobile || optedIn;
useEffect(() => {
+ if (!showPreview) return;
const url = URL.createObjectURL(file);
setObjectUrl(url);
return () => URL.revokeObjectURL(url);
- }, [file]);
+ }, [file, showPreview]);
return (
-
- {t("viewer.nonPdf.htmlPreviewWarning", {
- size: formatFileSize(file.size),
- })}
-
+
+
+ {t("viewer.nonPdf.htmlPreviewWarning", {
+ size: formatFileSize(file.size),
+ })}
+
+ {/* Opting in used to be one-way: the only way back was closing and
+ reopening the file. */}
+ {isMobile && optedIn && (
+
+ )}
+
- {objectUrl && (
-
+ {showPreview ? (
+ objectUrl && (
+
+ )
+ ) : (
+
+
+
+ {t(
+ "viewer.nonPdf.htmlPreviewMobileHidden",
+ "HTML pages are laid out for desktop widths, so the preview is off by default here.",
+ )}
+
+
+
+