feat: add Agents UI to proprietary right sidebar (#6454)

Update UI to include agents

Run `task dev:all` to test
This commit is contained in:
EthanHealy01
2026-05-28 17:26:23 +00:00
committed by GitHub
parent 398617391b
commit 763595a5a3
47 changed files with 3394 additions and 538 deletions
@@ -0,0 +1,53 @@
/**
* Core stubs for the right-rail Agents UI.
*
* The real implementations live in {@code proprietary/components/agents/AgentsPanel.tsx}
* and shadow these stubs via the {@code @app/*} alias cascade when the proprietary
* build is active. Core builds render nothing, so the right rail collapses to the
* tool list unchanged.
*/
/** Whether the right rail should reserve space for agents UI. False in core. */
export function useAgentsEnabled(): boolean {
return false;
}
/**
* Whether the agent chat overlay is currently open. Core builds have no chat,
* so this always returns false. Proprietary builds bridge to the ChatContext.
* Used by {@code RightSidebar} so the fullscreen tool picker can yield to the
* chat overlay just like it yields to a selected tool.
*/
export function useAgentChatOpen(): boolean {
return false;
}
/** Inline "Agents" section rendered above the tool list in {@code ToolPicker}. */
export function AgentsSection() {
return null;
}
/**
* Icon-only agent button rendered in the collapsed (minimised) right rail.
* Returns null in core; proprietary renders the Stirling agent shortcut.
*/
export function AgentsCollapsedButton(_props: { onExpand: () => void }) {
return null;
}
/**
* Full-rail chat overlay rendered inside {@code ToolPanel}. Covers the panel
* (including the search bar) when an agent conversation is active.
*/
export function AgentsChatOverlay() {
return null;
}
/**
* Agents card rendered inside the fullscreen tool picker. Matches the visual
* language of the fullscreen category cards (gradient border, title, items).
* Returns null in core; proprietary renders the Stirling agent.
*/
export function AgentsFullscreenSection() {
return null;
}
@@ -0,0 +1,102 @@
import { useEffect, useMemo } from "react";
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
import { useIsMobile } from "@app/hooks/useIsMobile";
import { usePreferences } from "@app/contexts/PreferencesContext";
import { useWorkbenchBar } from "@app/contexts/WorkbenchBarContext";
import {
AgentsFullscreenSection,
useAgentChatOpen,
useAgentsEnabled,
} from "@app/components/agents/AgentsPanel";
import FullscreenToolSurface from "@app/components/tools/FullscreenToolSurface";
import { ToolId } from "@app/types/toolId";
import type { ToolPanelGeometry } from "@app/hooks/tools/useToolPanelGeometry";
/** Derives whether the fullscreen tool picker is currently expanded. */
export function useIsFullscreenExpanded(): boolean {
const { toolPanelMode, leftPanelView, readerMode } = useToolWorkflow();
const isMobile = useIsMobile();
const agentChatOpen = useAgentChatOpen();
return (
toolPanelMode === "fullscreen" &&
leftPanelView === "toolPicker" &&
!isMobile &&
!readerMode &&
!agentChatOpen
);
}
interface FullscreenToolPanelProps {
geometry: ToolPanelGeometry | null;
}
/**
* Self-contained fullscreen tool picker. Renders null when inactive, and takes
* over the right rail (via FullscreenToolSurface) when fullscreen mode is on.
* Geometry is computed by the parent (RightSidebar) so its useLayoutEffect runs
* after the ref div is committed, ensuring toolPanelRef.current is always set.
*/
export function FullscreenToolPanel({ geometry }: FullscreenToolPanelProps) {
const {
toolPanelMode,
setToolPanelMode,
leftPanelView,
readerMode,
searchQuery,
setSearchQuery,
filteredTools,
toolRegistry,
selectedToolKey,
handleToolSelect,
} = useToolWorkflow();
const isMobile = useIsMobile();
const agentsEnabled = useAgentsEnabled();
const agentChatOpen = useAgentChatOpen();
const { setAllButtonsDisabled } = useWorkbenchBar();
const { preferences, updatePreference } = usePreferences();
const fullscreenExpanded =
toolPanelMode === "fullscreen" &&
leftPanelView === "toolPicker" &&
!isMobile &&
!readerMode &&
!agentChatOpen;
useEffect(() => {
setAllButtonsDisabled(fullscreenExpanded);
}, [fullscreenExpanded, setAllButtonsDisabled]);
const matchedTextMap = useMemo(() => {
const map = new Map<string, string>();
filteredTools.forEach(({ item: [id], matchedText }) => {
if (matchedText) map.set(id, matchedText);
});
return map;
}, [filteredTools]);
if (!fullscreenExpanded) return null;
return (
<FullscreenToolSurface
searchQuery={searchQuery}
toolRegistry={toolRegistry}
filteredTools={filteredTools}
selectedToolKey={selectedToolKey}
showDescriptions={preferences.showLegacyToolDescriptions}
matchedTextMap={matchedTextMap}
onSearchChange={setSearchQuery}
onSelect={(id: ToolId) => handleToolSelect(id)}
onToggleDescriptions={() =>
updatePreference(
"showLegacyToolDescriptions",
!preferences.showLegacyToolDescriptions,
)
}
onExitFullscreenMode={() => setToolPanelMode("sidebar")}
geometry={geometry}
agentsSlot={
agentsEnabled && !searchQuery ? <AgentsFullscreenSection /> : null
}
/>
);
}
@@ -1,4 +1,5 @@
import { useRef } from "react";
import { createPortal } from "react-dom";
import { ScrollArea, Switch } from "@mantine/core";
import { useTranslation } from "react-i18next";
import ToolSearch from "@app/components/tools/toolPicker/ToolSearch";
@@ -6,8 +7,6 @@ import FullscreenToolList from "@app/components/tools/FullscreenToolList";
import { ToolRegistryEntry } from "@app/data/toolsTaxonomy";
import { ToolId } from "@app/types/toolId";
import { useFocusTrap } from "@app/hooks/useFocusTrap";
import { LogoIcon } from "@app/components/shared/LogoIcon";
import { Wordmark } from "@app/components/shared/Wordmark";
import "@app/components/tools/ToolPanel.css";
import { ToolPanelGeometry } from "@app/hooks/tools/useToolPanelGeometry";
@@ -26,6 +25,8 @@ interface FullscreenToolSurfaceProps {
onToggleDescriptions: () => void;
onExitFullscreenMode: () => void;
geometry: ToolPanelGeometry | null;
/** Optional agents block rendered above the tool list. */
agentsSlot?: React.ReactNode;
}
const FullscreenToolSurface = ({
@@ -40,6 +41,7 @@ const FullscreenToolSurface = ({
onToggleDescriptions,
onExitFullscreenMode: _onExitFullscreenMode,
geometry,
agentsSlot,
}: FullscreenToolSurfaceProps) => {
const { t } = useTranslation();
const surfaceRef = useRef<HTMLDivElement>(null);
@@ -47,18 +49,16 @@ const FullscreenToolSurface = ({
// Enable focus trap when surface is active
useFocusTrap(surfaceRef, true);
const brandAltText = t("home.mobile.brandAlt", "Stirling PDF logo");
if (!geometry) return null;
const style = geometry
? {
left: `${geometry.left}px`,
top: `${geometry.top}px`,
width: `${geometry.width}px`,
height: `${geometry.height}px`,
}
: undefined;
const style = {
left: `${geometry.left}px`,
top: `${geometry.top}px`,
width: `${geometry.width}px`,
height: `${geometry.height}px`,
};
return (
const surface = (
<div
className="tool-panel__fullscreen-surface"
style={style}
@@ -70,16 +70,6 @@ const FullscreenToolSurface = ({
data-tour="tool-panel"
>
<div ref={surfaceRef} className="tool-panel__fullscreen-surface-inner">
<header className="tool-panel__fullscreen-header">
<div className="tool-panel__fullscreen-brand">
<LogoIcon className="tool-panel__fullscreen-brand-icon" />
<Wordmark
alt={brandAltText}
className="tool-panel__fullscreen-brand-text"
/>
</div>
</header>
<div className="tool-panel__fullscreen-controls">
<ToolSearch
value={searchQuery}
@@ -102,6 +92,9 @@ const FullscreenToolSurface = ({
className="tool-panel__fullscreen-scroll"
offsetScrollbars
>
{agentsSlot && (
<div className="tool-panel__fullscreen-agents">{agentsSlot}</div>
)}
<FullscreenToolList
filteredTools={filteredTools}
searchQuery={searchQuery}
@@ -115,6 +108,9 @@ const FullscreenToolSurface = ({
</div>
</div>
);
if (typeof document === "undefined") return surface;
return createPortal(surface, document.body);
};
export default FullscreenToolSurface;
@@ -0,0 +1,333 @@
import { useMemo, useState } from "react";
import { ActionIcon } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useRainbowThemeContext } from "@app/components/shared/RainbowThemeProvider";
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
import { useSidebarContext } from "@app/contexts/SidebarContext";
import rainbowStyles from "@app/styles/rainbow.module.css";
import { useIsMobile } from "@app/hooks/useIsMobile";
import ToolPanel from "@app/components/tools/ToolPanel";
import ToolSearch from "@app/components/tools/toolPicker/ToolSearch";
import {
AgentsChatOverlay,
AgentsCollapsedButton,
AgentsSection,
useAgentsEnabled,
} from "@app/components/agents/AgentsPanel";
import { useFavoriteToolItems } from "@app/hooks/tools/useFavoriteToolItems";
import { useToolSections } from "@app/hooks/useToolSections";
import type { SubcategoryGroup } from "@app/hooks/useToolSections";
import { ToolIcon } from "@app/components/shared/ToolIcon";
import { Tooltip as AppTooltip } from "@app/components/shared/Tooltip";
import { withViewTransition } from "@app/utils/viewTransition";
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
import CloseIcon from "@mui/icons-material/Close";
import { ToolId } from "@app/types/toolId";
import type { ToolRegistryEntry } from "@app/data/toolsTaxonomy";
import {
FullscreenToolPanel,
useIsFullscreenExpanded,
} from "@app/components/tools/FullscreenToolPanel";
import { useToolPanelGeometry } from "@app/hooks/tools/useToolPanelGeometry";
import "@app/components/tools/ToolPanel.css";
/**
* Right-side rail wrapping the tool panel.
*
* Owns the rail-level concerns: collapse/expand chrome, the AGENTS top label,
* the collapsed strip (agent button + favourite/recommended icon shortcuts),
* and the agents chat overlay. Fullscreen takeover lives in FullscreenToolPanel.
*/
export default function RightSidebar() {
const { t } = useTranslation();
const { isRainbowMode } = useRainbowThemeContext();
const { sidebarRefs } = useSidebarContext();
const { toolPanelRef, quickAccessRef } = sidebarRefs;
const isMobile = useIsMobile();
const {
leftPanelView,
isPanelVisible,
searchQuery,
filteredTools,
toolRegistry,
setSearchQuery,
selectedToolKey,
handleToolSelect,
handleBackToTools,
setLeftPanelView,
setReaderMode,
setSidebarsVisible,
sidebarsVisible,
readerMode,
favoriteTools,
} = useToolWorkflow();
const agentsEnabled = useAgentsEnabled();
const fullscreenExpanded = useIsFullscreenExpanded();
const fullscreenGeometry = useToolPanelGeometry({
enabled: fullscreenExpanded,
toolPanelRef,
quickAccessRef,
});
const handleExpand = () => {
withViewTransition(() => {
if (readerMode) setReaderMode(false);
if (leftPanelView === "hidden") setLeftPanelView("toolPicker");
if (!sidebarsVisible) setSidebarsVisible(true);
});
};
const handleCollapse = () => {
withViewTransition(() => setLeftPanelView("hidden"));
};
const [allToolsView, setAllToolsView] = useState(false);
const handleShowAllTools = () => {
withViewTransition(() => setAllToolsView(true));
};
const handleBackToDefault = () => {
withViewTransition(() => {
setAllToolsView(false);
setSearchQuery("");
});
};
// The header shows [back] [search] when we have somewhere to go back to —
// i.e. the user is in a specific tool, or already in the all-tools/search view.
const inToolView = leftPanelView !== "toolPicker";
// Show X (close) button only when there's somewhere to go back to.
const showCloseButton = inToolView || allToolsView;
// Show search input whenever there's a close button, or when agents are off and
// we're in the default tool-picker view (search filters the full list inline).
const showHeaderSearch =
showCloseButton || (!agentsEnabled && leftPanelView === "toolPicker");
const handleHeaderBack = () => {
if (inToolView) {
withViewTransition(() => handleBackToTools());
} else {
handleBackToDefault();
}
};
const handleToolSelectWithTransition = (id: ToolId) => {
withViewTransition(() => handleToolSelect(id));
};
// Typing in the header search while inside a tool exits the tool and lifts the
// panel into the all-tools view so the user immediately sees search results.
const handleHeaderSearchChange = (value: string) => {
if (inToolView) {
withViewTransition(() => {
handleBackToTools();
setAllToolsView(true);
setSearchQuery(value);
});
return;
}
setSearchQuery(value);
};
const activeTool: ToolRegistryEntry | null =
inToolView && selectedToolKey
? (toolRegistry[selectedToolKey as ToolId] ?? null)
: null;
// Agents header + section is hidden when:
// - the AI engine is off, or
// - the user is in the all-tools view, or
// - a specific tool is being rendered (leftPanelView ≠ "toolPicker").
const showAgents =
agentsEnabled && !allToolsView && leftPanelView === "toolPicker";
const computedWidth = () => {
if (isMobile) return "100%";
if (!isPanelVisible) return "3.5rem";
return "18.5rem";
};
// Collapsed rail: show favourites + recommended tools as icons.
const favoriteToolItems = useFavoriteToolItems(favoriteTools, toolRegistry);
const { sections: collapsedSections } = useToolSections(filteredTools);
const collapsedQuickSection = useMemo(
() => collapsedSections.find((s) => s.key === "quick"),
[collapsedSections],
);
const collapsedRecommendedItems = useMemo(() => {
if (!collapsedQuickSection) return [];
const items: Array<{ id: ToolId; tool: ToolRegistryEntry }> = [];
collapsedQuickSection.subcategories.forEach((sc: SubcategoryGroup) =>
sc.tools.forEach((entry) =>
items.push({ id: entry.id as ToolId, tool: entry.tool }),
),
);
return items;
}, [collapsedQuickSection]);
const collapsedRailItems = useMemo(() => {
const map = new Map<ToolId, ToolRegistryEntry>();
favoriteToolItems.forEach(({ id, tool }) => map.set(id, tool));
collapsedRecommendedItems.forEach(({ id, tool }) => {
if (!map.has(id)) map.set(id, tool);
});
return Array.from(map, ([id, tool]) => ({ id, tool }));
}, [favoriteToolItems, collapsedRecommendedItems]);
return (
<div
ref={toolPanelRef}
data-sidebar="tool-panel"
data-tour={fullscreenExpanded ? undefined : "tool-panel"}
className={`tool-panel flex flex-col ${fullscreenExpanded ? "tool-panel--fullscreen-active" : "overflow-hidden"} bg-[var(--bg-toolbar)] border-l border-[var(--border-subtle)] transition-all duration-300 ease-out ${
isRainbowMode ? rainbowStyles.rainbowPaper : ""
} ${isMobile ? "h-full border-r-0" : "h-screen"} ${fullscreenExpanded ? "tool-panel--fullscreen" : ""}`}
style={{
width: computedWidth(),
padding: "0",
}}
>
{!fullscreenExpanded && !isPanelVisible && !isMobile && (
<div className="tool-panel__collapsed-strip">
<div className="tool-panel__collapsed-top">
<ActionIcon
variant="outline"
color="gray.4"
radius="xl"
size="md"
className="tool-panel__expand-btn"
onClick={handleExpand}
aria-label={t("toolPanel.expand", "Expand panel")}
>
<ChevronLeftIcon sx={{ fontSize: "1.1rem" }} />
</ActionIcon>
<AgentsCollapsedButton onExpand={handleExpand} />
</div>
<div className="tool-panel__collapsed-divider" />
<div className="tool-panel__collapsed-tools">
{collapsedRailItems.map(({ id, tool }) => (
<AppTooltip
key={id}
content={tool.name}
position="left"
arrow
delay={300}
>
<button
type="button"
className="tool-panel__collapsed-tool-btn"
data-selected={selectedToolKey === id}
onClick={() => {
handleExpand();
handleToolSelectWithTransition(id);
}}
aria-label={tool.name}
>
<ToolIcon icon={tool.icon} marginRight="0" />
</button>
</AppTooltip>
))}
</div>
</div>
)}
{!fullscreenExpanded && isPanelVisible && (
<div
/* Fixed width matches the expanded panel width so the inner content is
laid out at its final size from the moment it mounts. The outer
.tool-panel clips it (overflow-hidden) while it animates from the
collapsed 3.5rem width — text/icons stay put and just come into view
instead of jiggling as space becomes available. */
style={{
opacity: 1,
transition: "opacity 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94)",
height: "100%",
width: isMobile ? "100%" : "18.5rem",
flexShrink: 0,
display: "flex",
flexDirection: "column",
}}
>
<div className="tool-panel__compact-header">
{activeTool ? (
<div
className="tool-panel__active-tool-pill"
aria-label={activeTool.name}
>
<span className="tool-panel__active-tool-pill-icon">
<ToolIcon
icon={activeTool.icon}
marginRight="0"
color="var(--mantine-color-blue-filled)"
/>
</span>
<span className="tool-panel__active-tool-pill-label">
{activeTool.name}
</span>
</div>
) : showHeaderSearch ? (
<div className="tool-panel__compact-header-search">
<ToolSearch
value={searchQuery}
onChange={handleHeaderSearchChange}
toolRegistry={toolRegistry}
mode="filter"
autoFocus={allToolsView && !inToolView}
/>
</div>
) : (
showAgents && (
<span className="tool-panel__section-label">
{t("agents.section_title", "Agents")}
</span>
)
)}
{showCloseButton ? (
<ActionIcon
variant="subtle"
color="gray"
radius="xl"
size="md"
onClick={handleHeaderBack}
aria-label={
inToolView
? t("toolPanel.backToAllTools", "Back to all tools")
: t("toolPanel.goBack", "Go back")
}
className="tool-panel__expand-btn"
>
<CloseIcon sx={{ fontSize: "1.1rem" }} />
</ActionIcon>
) : (
<ActionIcon
variant="outline"
radius="xl"
size="md"
onClick={handleCollapse}
aria-label={t("toolPanel.collapse", "Collapse panel")}
className="tool-panel__expand-btn"
>
<ChevronRightIcon sx={{ fontSize: "1.1rem" }} />
</ActionIcon>
)}
</div>
{showAgents && <AgentsSection />}
<ToolPanel
allToolsView={allToolsView}
onShowAllTools={handleShowAllTools}
onToolSelect={handleToolSelectWithTransition}
compact={agentsEnabled && !allToolsView}
/>
<AgentsChatOverlay />
</div>
)}
<FullscreenToolPanel geometry={fullscreenGeometry} />
</div>
);
}
@@ -129,17 +129,70 @@
transition:
width 0.3s ease,
max-width 0.3s ease;
view-transition-name: tool-rail;
}
.tool-panel__collapsed-strip {
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
align-items: stretch;
padding-top: 10px;
gap: 8px;
height: 100%;
width: 100%;
min-height: 0;
}
.tool-panel__collapsed-top {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 0 0 10px;
}
.tool-panel__collapsed-divider {
height: 1px;
background: var(--border-subtle);
margin: 0 0.5rem 8px;
}
.tool-panel__collapsed-tools {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 0 0 12px;
overflow-y: auto;
overflow-x: hidden;
flex: 1 1 auto;
min-height: 0;
}
.tool-panel__collapsed-tool-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
border: 1px solid transparent;
border-radius: 0.5rem;
background: transparent;
color: var(--tools-text-and-icon-color);
cursor: pointer;
transition:
background 120ms ease-out,
border-color 120ms ease-out;
padding: 0;
flex-shrink: 0;
}
.tool-panel__collapsed-tool-btn:hover {
background: var(--mantine-color-default-hover);
}
.tool-panel__collapsed-tool-btn[data-selected="true"] {
background: var(--mantine-color-default-hover);
border-color: var(--mantine-color-default-border);
}
.tool-panel__expand-btn {
@@ -174,6 +227,11 @@
flex-shrink: 0;
}
.tool-panel__back-bar {
flex-shrink: 0;
border-bottom: 1px solid var(--border-subtle) !important;
}
.tool-panel--fullscreen-active {
overflow: visible !important;
}
@@ -191,6 +249,128 @@
flex: 1 1 auto;
}
.tool-panel__compact-header {
display: flex;
align-items: center;
gap: 0.5rem;
min-height: 52px;
padding: 0.5rem 0.75rem;
box-sizing: border-box;
}
.tool-panel__compact-header .tool-panel__expand-btn {
margin-left: auto;
}
.tool-panel__compact-header-search {
flex: 1 1 auto;
min-width: 0;
}
.tool-panel__compact-header-search .search-input-container {
width: 100%;
}
.tool-panel__active-tool-pill {
display: inline-flex;
align-items: center;
gap: 0.5rem;
flex: 1 1 auto;
min-width: 0;
padding: 0.35rem 0.75rem 0.35rem 0.4rem;
border: 1px solid var(--border-subtle);
border-radius: 9999px;
background: var(--mantine-color-body);
}
.tool-panel__active-tool-pill-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
border-radius: 9999px;
background: var(--mantine-color-blue-light);
color: var(--mantine-color-blue-filled);
flex-shrink: 0;
}
[data-mantine-color-scheme="dark"] .tool-panel__active-tool-pill-icon {
background: color-mix(
in srgb,
var(--mantine-color-blue-filled) 18%,
transparent
);
color: var(--mantine-color-blue-3, var(--mantine-color-blue-filled));
}
/* The inner .tool-button-icon wrapper carries its own transform/margin; reset
them so the icon glyph sits dead-centre in the circular background. */
.tool-panel__active-tool-pill-icon .tool-button-icon {
margin: 0 !important;
transform: none !important;
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 1;
}
.tool-panel__active-tool-pill-icon svg {
font-size: 1rem;
width: 1rem;
height: 1rem;
}
.tool-panel__active-tool-pill-label {
font-size: 0.9rem;
font-weight: 600;
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--text-primary);
}
::view-transition-old(tool-rail) {
animation: vt-rail-fade-out 180ms ease-out forwards;
}
::view-transition-new(tool-rail) {
animation: vt-rail-fade-in 220ms ease-in forwards;
}
@keyframes vt-rail-fade-out {
to {
opacity: 0;
}
}
@keyframes vt-rail-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
::view-transition-old(tool-rail),
::view-transition-new(tool-rail) {
animation-duration: 1ms !important;
}
}
.tool-panel__section-label {
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted);
line-height: 1;
}
.tool-panel__mode-toggle {
transition: transform 0.2s ease;
}
@@ -652,6 +832,33 @@
gap: 0.75rem;
}
/* Agents card lives in its own column above the tool grid. It uses the same
.tool-panel__fullscreen-group base styles but with a colourful gradient
border so it stands out as a distinct surface (and doesn't blend into the
neighbouring category cards). */
.tool-panel__fullscreen-agents {
padding: 1.5rem 1.75rem 0;
}
.tool-panel__fullscreen-group--agents {
position: relative;
background:
linear-gradient(var(--fullscreen-bg-group), var(--fullscreen-bg-group))
padding-box,
linear-gradient(
135deg,
var(--mantine-color-blue-6) 0%,
var(--mantine-color-violet-6) 45%,
var(--mantine-color-pink-6) 100%
)
border-box;
border: 1.5px solid transparent;
}
.tool-panel__fullscreen-section-icon--agents {
color: var(--mantine-color-blue-6);
}
@keyframes tool-panel-fullscreen-slide-in {
from {
transform: translateX(6%) scaleX(0.85);
@@ -1,303 +1,85 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useRainbowThemeContext } from "@app/components/shared/RainbowThemeProvider";
import {
useToolWorkflow,
useToolWorkflowActions,
} from "@app/contexts/ToolWorkflowContext";
import { usePreferences } from "@app/contexts/PreferencesContext";
import { ScrollArea } from "@mantine/core";
import { useTranslation } from "react-i18next";
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 { useSidebarContext } from "@app/contexts/SidebarContext";
import rainbowStyles from "@app/styles/rainbow.module.css";
import { ActionIcon, Button, ScrollArea } from "@mantine/core";
import { ToolId } from "@app/types/toolId";
import { useIsMobile } from "@app/hooks/useIsMobile";
import { useTranslation } from "react-i18next";
import FullscreenToolSurface from "@app/components/tools/FullscreenToolSurface";
import { ToolPanelViewerBar } from "@app/components/tools/ToolPanelViewerBar";
import { useToolPanelGeometry } from "@app/hooks/tools/useToolPanelGeometry";
import { useWorkbenchBar } from "@app/contexts/WorkbenchBarContext";
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import SearchIcon from "@mui/icons-material/Search";
import "@app/components/tools/ToolPanel.css";
import { ToolId } from "@app/types/toolId";
// No props needed - component uses context
interface ToolPanelProps {
/** Whether to expand into the full categorised tools view (with search). */
allToolsView: boolean;
/** Trigger to enter the all-tools view (from the "View all tools" button). */
onShowAllTools: () => void;
/**
* Tool-selection handler injected by {@code RightSidebar} so the click can
* be wrapped in a View Transition (the tool card morphs into the header
* pill). Falls back to the workflow context's handler when not provided.
*/
onToolSelect?: (id: ToolId) => void;
/** Whether to render the compact (favourites + recommended only) view. */
compact?: boolean;
}
export default function ToolPanel() {
/** Tool list and renderer for the right rail; rail chrome lives in RightSidebar. */
export default function ToolPanel({
allToolsView,
onShowAllTools,
onToolSelect,
compact: compactProp,
}: ToolPanelProps) {
const { t } = useTranslation();
const { isRainbowMode } = useRainbowThemeContext();
const { sidebarRefs } = useSidebarContext();
const { toolPanelRef, quickAccessRef } = sidebarRefs;
const isMobile = useIsMobile();
const {
leftPanelView,
isPanelVisible,
searchQuery,
filteredTools,
toolRegistry,
setSearchQuery,
selectedToolKey,
toolPanelMode,
sidebarsVisible,
readerMode,
} = useToolWorkflow();
const {
handleToolSelect,
handleBackToTools,
setPreviewFile,
setToolPanelMode,
setLeftPanelView,
setReaderMode,
setSidebarsVisible,
} = useToolWorkflowActions();
const { setAllButtonsDisabled } = useWorkbenchBar();
const { preferences, updatePreference } = usePreferences();
const isFullscreenMode = toolPanelMode === "fullscreen";
const toolPickerVisible = !readerMode;
const fullscreenExpanded =
isFullscreenMode &&
leftPanelView === "toolPicker" &&
!isMobile &&
toolPickerVisible;
// Disable workbench bar buttons when fullscreen mode is active
useEffect(() => {
setAllButtonsDisabled(fullscreenExpanded);
}, [fullscreenExpanded, setAllButtonsDisabled]);
const fullscreenGeometry = useToolPanelGeometry({
enabled: fullscreenExpanded,
toolPanelRef,
quickAccessRef,
});
const handleExpand = () => {
if (readerMode) setReaderMode(false);
if (leftPanelView === "hidden") setLeftPanelView("toolPicker");
if (!sidebarsVisible) setSidebarsVisible(true);
};
const handleCollapse = () => {
setLeftPanelView("hidden");
};
const [focusSearch, setFocusSearch] = useState(false);
const focusSearchOnNextOpen = useRef(false);
const handleExpandAndSearch = () => {
focusSearchOnNextOpen.current = true;
handleExpand();
};
// Once the panel becomes visible, consume the focus-search request
useEffect(() => {
if (isPanelVisible && focusSearchOnNextOpen.current) {
focusSearchOnNextOpen.current = false;
setFocusSearch(true);
// Reset after one render so autoFocus doesn't re-fire on subsequent renders
const id = setTimeout(() => setFocusSearch(false), 100);
return () => clearTimeout(id);
}
}, [isPanelVisible]);
const computedWidth = () => {
if (isMobile) {
return "100%";
}
if (!isPanelVisible) {
return "3.5rem";
}
return "18.5rem";
};
const handleSelect = useCallback(
(id: string) => handleToolSelect(id as ToolId),
[handleToolSelect],
);
const matchedTextMap = useMemo(() => {
const map = new Map<string, string>();
filteredTools.forEach(({ item: [id], matchedText }) => {
if (matchedText) {
map.set(id, matchedText);
}
});
return map;
}, [filteredTools]);
} = useToolWorkflow();
const selectTool = onToolSelect ?? handleToolSelect;
return (
<div
ref={toolPanelRef}
data-sidebar="tool-panel"
data-tour={fullscreenExpanded ? undefined : "tool-panel"}
className={`tool-panel flex flex-col ${fullscreenExpanded ? "tool-panel--fullscreen-active" : "overflow-hidden"} bg-[var(--bg-toolbar)] border-l border-[var(--border-subtle)] transition-all duration-300 ease-out ${
isRainbowMode ? rainbowStyles.rainbowPaper : ""
} ${isMobile ? "h-full border-r-0" : "h-screen"} ${fullscreenExpanded ? "tool-panel--fullscreen" : ""}`}
style={{
width: computedWidth(),
padding: "0",
}}
>
{!fullscreenExpanded && !isPanelVisible && !isMobile && (
<div className="tool-panel__collapsed-strip">
<ActionIcon
variant="outline"
color="gray.4"
radius="xl"
size="md"
className="tool-panel__expand-btn"
onClick={handleExpand}
aria-label={t("toolPanel.expand", "Expand panel")}
>
<ChevronLeftIcon sx={{ fontSize: "1.1rem" }} />
</ActionIcon>
<ActionIcon
variant="subtle"
color="gray"
radius="md"
size="md"
className="tool-panel__collapsed-search-btn"
onClick={handleExpandAndSearch}
aria-label={t("toolPanel.search", "Search tools")}
style={{ marginTop: "8px" }}
>
<SearchIcon sx={{ fontSize: "1.25rem" }} />
</ActionIcon>
<>
{/* Viewer mode tools — annotate, redact, form fill */}
<ToolPanelViewerBar />
{allToolsView && searchQuery.trim().length > 0 ? (
<div className="flex-1 flex flex-col overflow-y-auto">
<SearchResults
filteredTools={filteredTools}
onSelect={(id) => selectTool(id as ToolId)}
searchQuery={searchQuery}
/>
</div>
)}
{!fullscreenExpanded && isPanelVisible && (
<div
style={{
opacity: 1,
transition: "opacity 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94)",
height: "100%",
display: "flex",
flexDirection: "column",
}}
>
{/* Viewer mode tools — annotate, redact, form fill */}
<ToolPanelViewerBar />
<div
className="tool-panel__search-row"
style={{
backgroundColor: "transparent",
borderBottom: "1px solid var(--border-subtle)",
}}
>
<ToolSearch
value={searchQuery}
onChange={setSearchQuery}
toolRegistry={toolRegistry}
mode="filter"
autoFocus={focusSearch}
/>
<ActionIcon
variant="outline"
radius="xl"
size="md"
onClick={handleCollapse}
aria-label={t("toolPanel.collapse", "Collapse panel")}
className="tool-panel__expand-btn"
style={{ flexShrink: 0 }}
>
<ChevronRightIcon sx={{ fontSize: "1.1rem" }} />
</ActionIcon>
</div>
{searchQuery.trim().length > 0 ? (
<div className="flex-1 flex flex-col overflow-y-auto">
<SearchResults
filteredTools={filteredTools}
onSelect={handleSelect}
searchQuery={searchQuery}
/>
</div>
) : leftPanelView === "toolPicker" ? (
<div className="flex-1 flex flex-col overflow-auto">
<ToolPicker
) : leftPanelView === "toolPicker" ? (
<div className="flex-1 flex flex-col overflow-auto">
<ToolPicker
selectedToolKey={selectedToolKey}
onSelect={(id) => selectTool(id as ToolId)}
filteredTools={filteredTools}
isSearching={Boolean(searchQuery && searchQuery.trim().length > 0)}
compact={compactProp ?? !allToolsView}
onShowAllTools={onShowAllTools}
/>
</div>
) : (
<div className="flex-1 min-h-0 overflow-hidden">
<ScrollArea h="100%">
{selectedToolKey ? (
<ToolRenderer
selectedToolKey={selectedToolKey}
onSelect={handleSelect}
filteredTools={filteredTools}
isSearching={Boolean(
searchQuery && searchQuery.trim().length > 0,
)}
onPreviewFile={setPreviewFile}
/>
</div>
) : (
<div className="flex-1 flex flex-col overflow-hidden">
<div
style={{
borderBottom: "1px solid var(--border-subtle)",
flexShrink: 0,
}}
>
<Button
variant="light"
color="blue"
size="sm"
fullWidth
radius={0}
leftSection={<ArrowBackIcon sx={{ fontSize: "0.9rem" }} />}
onClick={handleBackToTools}
aria-label={t("toolPanel.backToTools", "Back to tools")}
styles={{ root: { justifyContent: "flex-start" } }}
>
{t("toolPanel.backToTools", "Back to tools")}
</Button>
) : (
<div className="tool-panel__placeholder">
{t("toolPanel.placeholder", "Choose a tool to get started")}
</div>
<div className="flex-1 min-h-0 overflow-hidden">
<ScrollArea h="100%">
{selectedToolKey ? (
<ToolRenderer
selectedToolKey={selectedToolKey}
onPreviewFile={setPreviewFile}
/>
) : (
<div className="tool-panel__placeholder">
{t(
"toolPanel.placeholder",
"Choose a tool to get started",
)}
</div>
)}
</ScrollArea>
</div>
</div>
)}
)}
</ScrollArea>
</div>
)}
{fullscreenExpanded && (
<FullscreenToolSurface
searchQuery={searchQuery}
toolRegistry={toolRegistry}
filteredTools={filteredTools}
selectedToolKey={selectedToolKey}
showDescriptions={preferences.showLegacyToolDescriptions}
matchedTextMap={matchedTextMap}
onSearchChange={setSearchQuery}
onSelect={(id: ToolId) => handleToolSelect(id)}
onToggleDescriptions={() =>
updatePreference(
"showLegacyToolDescriptions",
!preferences.showLegacyToolDescriptions,
)
}
onExitFullscreenMode={() => setToolPanelMode("sidebar")}
geometry={fullscreenGeometry}
/>
)}
</div>
</>
);
}
@@ -1,5 +1,5 @@
import React, { memo, useMemo, useRef } from "react";
import { Box, Stack } from "@mantine/core";
import React, { useMemo, useRef } from "react";
import { Box, Button, Stack } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { ToolRegistryEntry } from "@app/data/toolsTaxonomy";
import "@app/components/tools/toolPicker/ToolPicker.css";
@@ -22,6 +22,10 @@ interface ToolPickerProps {
matchedText?: string;
}>;
isSearching?: boolean;
/** Compact "resting" view: favourites + recommended only, with a button to expand. */
compact?: boolean;
/** Called when the user clicks "View all tools" in compact mode. */
onShowAllTools?: () => void;
}
const EMPTY_FILTERED_TOOLS: ToolPickerProps["filteredTools"] = [];
@@ -57,6 +61,8 @@ const ToolPicker = ({
onSelect,
filteredTools,
isSearching = false,
compact = false,
onShowAllTools,
}: ToolPickerProps) => {
const { t } = useTranslation();
@@ -118,9 +124,60 @@ const ToolPicker = ({
)
)}
</Stack>
) : compact ? (
/* Resting state: flat list of pinned + recommended only. */
<Box className="tool-picker__compact">
<div style={HEADER_TEXT_STYLE}>
{t("toolPanel.toolsHeader", "Tools")}
</div>
{favoriteToolItems.length === 0 && recommendedItems.length === 0 ? (
<NoToolsFound />
) : (
<div className="tool-picker__compact-list">
{favoriteToolItems.map(({ id, tool }) => (
<ToolButton
key={`fav-${id}`}
id={id}
tool={tool}
isSelected={selectedToolKey === id}
onSelect={onSelect}
hasStars
showDescription
/>
))}
{recommendedItems
.filter(
({ id }) => !favoriteToolItems.some((fav) => fav.id === id),
)
.map(({ id, tool }) => (
<ToolButton
key={`rec-${id}`}
id={id as ToolId}
tool={tool}
isSelected={selectedToolKey === id}
onSelect={onSelect}
hasStars
showDescription
/>
))}
</div>
)}
{onShowAllTools && (
<Button
variant="subtle"
size="sm"
fullWidth
onClick={onShowAllTools}
className="tool-picker__view-all"
aria-label={t("toolPanel.viewAllTools", "View all tools")}
>
{t("toolPanel.viewAllTools", "View all tools")}
</Button>
)}
</Box>
) : (
<>
{/* Flat list: favorites and recommended first, then all subcategories */}
{/* All-tools view: favourites + recommended + all subcategories. */}
<Stack p="sm" gap="xs">
{favoriteToolItems.length > 0 && (
<Box w="100%">
@@ -182,7 +239,6 @@ const ToolPicker = ({
{!quickSection && !allSection && <NoToolsFound />}
{/* bottom spacer to allow scrolling past the last row */}
<div aria-hidden style={{ height: 200 }} />
</>
)}
@@ -192,4 +248,4 @@ const ToolPicker = ({
);
};
export default memo(ToolPicker);
export default ToolPicker;
@@ -32,6 +32,7 @@ interface ToolButtonProps {
disableNavigation?: boolean;
matchedSynonym?: string;
hasStars?: boolean;
showDescription?: boolean;
/** Called when an unavailable tool is clicked; if provided, overrides the default no-op */
onUnavailableClick?: () => void;
}
@@ -44,6 +45,7 @@ const ToolButton: React.FC<ToolButtonProps> = ({
disableNavigation = false,
matchedSynonym,
hasStars = false,
showDescription = false,
onUnavailableClick,
}) => {
const { t } = useTranslation();
@@ -183,6 +185,14 @@ const ToolButton: React.FC<ToolButtonProps> = ({
)}
{usesCloud && !visuallyUnavailable && <CloudBadge />}
</div>
{showDescription && tool.description && (
<span
className="tool-button__description"
style={{ opacity: visuallyUnavailable ? 0.25 : 1 }}
>
{tool.description}
</span>
)}
{matchedSynonym && (
<span
style={{
@@ -205,8 +215,8 @@ const ToolButton: React.FC<ToolButtonProps> = ({
handleUnlessSpecialClick(e, () => handleClick(id));
};
const selectedStyles = isSelected
? { backgroundColor: "#EAEAEA", color: "var(--tools-text-and-icon-color)" }
const selectedBg = isSelected
? { backgroundColor: "var(--tool-button-selected-bg)" }
: {};
const buttonElement = navProps ? (
@@ -227,7 +237,7 @@ const ToolButton: React.FC<ToolButtonProps> = ({
borderRadius: 0,
color: "var(--tools-text-and-icon-color)",
overflow: "visible",
...selectedStyles,
...selectedBg,
},
label: { overflow: "visible" },
}}
@@ -254,7 +264,7 @@ const ToolButton: React.FC<ToolButtonProps> = ({
borderRadius: 0,
color: "var(--tools-text-and-icon-color)",
overflow: "visible",
...selectedStyles,
...selectedBg,
},
label: { overflow: "visible" },
}}
@@ -279,7 +289,6 @@ const ToolButton: React.FC<ToolButtonProps> = ({
color: "var(--tools-text-and-icon-color)",
cursor: visuallyUnavailable ? "not-allowed" : undefined,
overflow: "visible",
...selectedStyles,
},
label: { overflow: "visible" },
}}
@@ -55,6 +55,15 @@
flex: 1 1 auto;
}
/* Selected tool highlight — theme-aware via CSS variable */
:root {
--tool-button-selected-bg: var(--mantine-color-gray-2);
}
[data-mantine-color-scheme="dark"] {
--tool-button-selected-bg: var(--mantine-color-dark-4);
}
/* Compact tool buttons */
.tool-button {
font-size: 0.875rem;
@@ -96,3 +105,37 @@
margin-top: 0.5rem;
margin-bottom: 0.5rem;
}
.tool-button__description {
font-size: 0.75rem;
color: var(--mantine-color-dimmed);
line-height: 1.35;
margin-top: 0.15rem;
white-space: normal;
text-align: left;
}
/* Resting / compact tool list — taller rows, room for description. */
.tool-picker__compact {
padding: 0.5rem var(--mantine-spacing-sm) var(--mantine-spacing-sm);
}
.tool-picker__compact .tool-button {
padding-top: 0.65rem;
padding-bottom: 0.65rem;
height: auto;
}
.tool-picker__compact .tool-button .mantine-Button-label {
align-items: center;
}
.tool-picker__compact-list {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.tool-picker__view-all {
margin-top: 0.75rem;
}
@@ -1,8 +1,6 @@
import { PageEditorFunctions } from "@app/types/pageEditor";
import {
type ToolPanelMode,
DEFAULT_TOOL_PANEL_MODE,
} from "@app/constants/toolPanel";
import { type ToolPanelMode } from "@app/constants/toolPanel";
import { preferencesService } from "@app/services/preferencesService";
export interface ToolWorkflowState {
// UI State
@@ -43,7 +41,7 @@ export const baseState: Omit<ToolWorkflowState, "toolPanelMode"> = {
export const createInitialState = (): ToolWorkflowState => ({
...baseState,
toolPanelMode: DEFAULT_TOOL_PANEL_MODE,
toolPanelMode: preferencesService.getPreference("defaultToolPanelMode"),
});
export function toolWorkflowReducer(
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { generateId } from "@app/utils/generateId";
import {
signatureStorageService,
type StorageType,
@@ -28,16 +29,6 @@ export type AddSignatureResult =
const isSupportedEnvironment = () =>
typeof window !== "undefined" && typeof window.localStorage !== "undefined";
const generateId = () => {
if (
typeof crypto !== "undefined" &&
typeof crypto.randomUUID === "function"
) {
return crypto.randomUUID();
}
return `sig_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
};
export const useSavedSignatures = () => {
const [savedSignatures, setSavedSignatures] = useState<SavedSignature[]>([]);
const [storageType, setStorageType] = useState<StorageType | null>(null);
@@ -13,6 +13,31 @@ interface UseToolPanelGeometryOptions {
quickAccessRef: RefObject<HTMLDivElement | null>;
}
function computeGeometry(
panelEl: HTMLDivElement,
quickAccessRef: RefObject<HTMLDivElement | null>,
): ToolPanelGeometry {
const rect = panelEl.getBoundingClientRect();
const isRTL =
typeof document !== "undefined" && document.documentElement.dir === "rtl";
let width: number;
let left: number;
if (isRTL) {
// RTL: panel is on the left, expands rightward
width = Math.max(360, window.innerWidth - rect.right);
left = rect.right;
} else {
// LTR: panel is on the right, expands leftward to the file sidebar
const quickAccessRect = quickAccessRef.current?.getBoundingClientRect();
const leftOffset = quickAccessRect ? quickAccessRect.right : 0;
width = Math.max(360, rect.right - leftOffset);
left = leftOffset;
}
const height = Math.max(rect.height, window.innerHeight - rect.top);
return { left, top: rect.top, width, height };
}
export function useToolPanelGeometry({
enabled,
toolPanelRef,
@@ -36,31 +61,7 @@ export function useToolPanelGeometry({
let rafId: number | null = null;
const computeAndSetGeometry = () => {
const rect = panelEl.getBoundingClientRect();
const isRTL =
typeof document !== "undefined" &&
document.documentElement.dir === "rtl";
let width: number;
let left: number;
if (isRTL) {
// RTL: panel is on the left, expands rightward
width = Math.max(360, window.innerWidth - rect.right);
left = rect.right;
} else {
// LTR: panel is on the right, expands leftward to the file sidebar
const quickAccessRect = quickAccessRef.current?.getBoundingClientRect();
const leftOffset = quickAccessRect ? quickAccessRect.right : 0;
width = Math.max(360, rect.right - leftOffset);
left = leftOffset;
}
const height = Math.max(rect.height, window.innerHeight - rect.top);
setGeometry({
left,
top: rect.top,
width,
height,
});
setGeometry(computeGeometry(panelEl, quickAccessRef));
};
const scheduleUpdate = () => {
+3 -3
View File
@@ -20,7 +20,7 @@ import AppsIcon from "@mui/icons-material/AppsRounded";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import CreateNewFolderIcon from "@mui/icons-material/CreateNewFolder";
import ToolPanel from "@app/components/tools/ToolPanel";
import RightSidebar from "@app/components/tools/RightSidebar";
import Workbench from "@app/components/layout/Workbench";
import FileSidebar from "@app/components/shared/FileSidebar";
import FileManager from "@app/components/FileManager";
@@ -380,7 +380,7 @@ export default function HomePage() {
)}
>
<div className="mobile-slide-content">
<ToolPanel />
<RightSidebar />
</div>
</div>
<div
@@ -510,7 +510,7 @@ export default function HomePage() {
/>
<FolderTreePanel active={navigationState.workbench === "myFiles"} />
<Workbench />
{!hideToolPanel && <ToolPanel />}
{!hideToolPanel && <RightSidebar />}
<FileManager selectedTool={selectedTool} />
<AppConfigModal
opened={configModalOpen}
+14
View File
@@ -1099,3 +1099,17 @@
animation-duration: 160ms;
animation-timing-function: cubic-bezier(0.2, 0, 0.2, 1);
}
/* Mantine animates a button's background-color and its text/icon colour on
* separate transitions, so when a filled button flips state (selected ↔
* unselected, enabled ↔ disabled) you briefly see e.g. a blue background
* with the old dark-grey label still showing through. Drop the colour
* tween — the swap is instant, but transforms/shadows/borders/opacity keep
* animating for hover and press feedback. */
.mantine-Button-root,
.mantine-ActionIcon-root,
.mantine-SegmentedControl-control,
.mantine-SegmentedControl-label,
.mantine-SegmentedControl-indicator {
transition-property: transform, box-shadow, border-color, opacity !important;
}
@@ -31,9 +31,9 @@ test.describe("Navigation", () => {
await page.locator('a[href="/merge"]').first().click();
await expect(page).toHaveURL(/\/merge/);
// In the redesigned UI, "Back to tools" button replaces the old "Tools" link
// In the redesigned UI, "Back to all tools" button replaces the old "Tools" link
await page
.getByRole("button", { name: /Back to tools/i })
.getByRole("button", { name: /Back to all tools/i })
.first()
.click();
await expect(page).toHaveURL("/");
@@ -36,10 +36,10 @@ test.describe("4. PDF Tool Pages - Common Patterns", () => {
await page.goto("/compress");
await page.waitForLoadState("domcontentloaded");
// Step 2: Click the "Back to tools" button in ToolPanel to go back to /.
// Step 2: Click the "Back to all tools" button in ToolPanel to go back to /.
// In the redesigned UI this replaces the old "Tools" sidebar link.
const homeLink = page
.getByRole("button", { name: /Back to tools/i })
.getByRole("button", { name: /Back to all tools/i })
.first();
await homeLink.click();
+2 -4
View File
@@ -1,4 +1,5 @@
import { useEffect, useState, useContext, useCallback, useRef } from "react";
import { generateId } from "@app/utils/generateId";
import { useTranslation } from "react-i18next";
import { createToolFlow } from "@app/components/tools/shared/createToolFlow";
@@ -578,10 +579,7 @@ const Annotate = (_props: BaseToolProps) => {
const OFFSET = 20;
const pasted: Record<string, unknown> = { ...annotation };
// Assign a new id so EmbedPDF tracks the copy and delete works on it
pasted.id =
typeof crypto !== "undefined" && crypto.randomUUID
? crypto.randomUUID()
: `paste-${Date.now()}-${Math.random().toString(36).slice(2)}`;
pasted.id = generateId();
delete pasted.uid;
// Remove appearance stream reference — the copy needs its own rendering
delete pasted.appearanceModes;
@@ -58,6 +58,7 @@ export interface AppConfig {
timestampDefaultTsaUrl?: string;
timestampCustomTsaUrls?: string[];
timestampTsaPresets?: { label: string; url: string }[];
aiEngineEnabled?: boolean;
}
export type AppConfigBootstrapMode = "blocking" | "non-blocking";
+2 -11
View File
@@ -4,6 +4,7 @@
import { PageOperation } from "@app/types/pageEditor";
import { FileId, BaseFileMetadata } from "@app/types/file";
import { generateId } from "@app/utils/generateId";
// Re-export FileId for convenience
export type { FileId };
@@ -58,18 +59,8 @@ export interface FileContextNormalizedFiles {
byId: Record<FileId, StirlingFileStub>;
}
// Helper functions - UUID-based primary keys (zero collisions, synchronous)
export function createFileId(): FileId {
// Use crypto.randomUUID for authoritative primary key
if (typeof window !== "undefined" && window.crypto?.randomUUID) {
return window.crypto.randomUUID() as FileId;
}
// Fallback for environments without randomUUID
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) {
const r = (Math.random() * 16) | 0;
const v = c == "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
}) as FileId;
return generateId() as FileId;
}
// Generate quick deduplication key from file metadata
+3 -11
View File
@@ -5,6 +5,8 @@
* and may reference a parent folder; a `null` parent means the root.
*/
import { generateId } from "@app/utils/generateId";
declare const folderTag: unique symbol;
export type FolderId = string & { readonly [folderTag]: "FolderId" };
@@ -81,17 +83,7 @@ export function parseFolderId(value: unknown): FolderId {
}
export function createFolderId(): FolderId {
if (typeof window !== "undefined" && window.crypto?.randomUUID) {
return window.crypto.randomUUID() as FolderId;
}
// Math.random fallback is non-cryptographic but acceptable here - these ids are
// not used as security tokens, only as opaque local handles. The brand is the
// contract; the entropy is best-effort.
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) {
const r = (Math.random() * 16) | 0;
const v = c == "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
}) as FolderId;
return generateId() as FolderId;
}
export function pickFolderColor(seed: string): FolderPaletteColor {
@@ -3,6 +3,8 @@
* Generates and persists a unique UUID in localStorage for WAU tracking
*/
import { generateId } from "@app/utils/generateId";
const BROWSER_ID_KEY = "stirling_browser_id";
/**
@@ -28,19 +30,6 @@ export function getBrowserId(): string {
}
}
/**
* Generates a UUID v4
*/
function generateUUID(): string {
// Use crypto.randomUUID if available (modern browsers)
if (typeof crypto !== "undefined" && crypto.randomUUID) {
return crypto.randomUUID();
}
// Fallback to manual UUID generation
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
return generateId();
}
@@ -1,3 +1,5 @@
import { generateId } from "@app/utils/generateId";
export interface BookmarkPayload {
title: string;
pageNumber: number;
@@ -12,13 +14,7 @@ export interface BookmarkNode {
expanded: boolean;
}
const createBookmarkId = () => {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID();
}
return `bookmark-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
};
const createBookmarkId = () => generateId();
export const createBookmarkNode = (
bookmark?: Partial<BookmarkNode>,
@@ -0,0 +1,12 @@
export function generateId(): string {
if (
typeof crypto !== "undefined" &&
typeof crypto.randomUUID === "function"
) {
return crypto.randomUUID();
}
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
return (c === "x" ? r : (r & 0x3) | 0x8).toString(16);
});
}
@@ -0,0 +1,26 @@
import { flushSync } from "react-dom";
type ViewTransitionDoc = Document & {
startViewTransition?: (cb: () => void) => { finished: Promise<void> };
};
/**
* Run a state update inside a View Transition so the browser cross-fades
* (and morphs any elements sharing a {@code view-transition-name}) between
* the before/after DOMs.
*
* Falls back to a plain synchronous update when the API is unavailable
* (Firefox <130, JSDOM, motion-reduced preference).
*/
export function withViewTransition(update: () => void): Promise<void> {
if (typeof document === "undefined") {
update();
return Promise.resolve();
}
const doc = document as ViewTransitionDoc;
if (doc.startViewTransition) {
return doc.startViewTransition(() => flushSync(update)).finished;
}
update();
return Promise.resolve();
}