diff --git a/.taskfiles/backend.yml b/.taskfiles/backend.yml index 472258fdb..2a3adffeb 100644 --- a/.taskfiles/backend.yml +++ b/.taskfiles/backend.yml @@ -22,12 +22,13 @@ tasks: vars: PORT: '{{.PORT | default "8080"}}' AIENGINE_URL: '{{.AIENGINE_URL | default ""}}' + AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS | default "120"}}' env: SERVER_PORT: '{{.PORT}}' cmds: - - cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED=true {{end}}cmd /c ".\gradlew.bat :stirling-pdf:bootRun"' + - cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED=true AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}cmd /c ".\gradlew.bat :stirling-pdf:bootRun"' platforms: [windows] - - cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED=true {{end}}./gradlew :stirling-pdf:bootRun' + - cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED=true AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}./gradlew :stirling-pdf:bootRun' platforms: [linux, darwin] dev:bundled: diff --git a/Taskfile.yml b/Taskfile.yml index 5b40ac4fe..dc4d5130b 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -91,7 +91,7 @@ tasks: vars: PORT: '{{.BACKEND_PORT}}' AIENGINE_URL: 'http://localhost:{{.ENGINE_PORT}}' - - task: frontend:dev:prototypes + - task: frontend:dev vars: PORT: '{{.FRONTEND_PORT}}' BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}' diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java index 924b41df5..383d05e0e 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java @@ -307,6 +307,9 @@ public class ConfigController { // Premium/Enterprise settings configData.put("premiumEnabled", applicationProperties.getPremium().isEnabled()); + // AI Engine settings + configData.put("aiEngineEnabled", applicationProperties.getAiEngine().isEnabled()); + // Timestamp TSA settings — single source of truth for presets + admin URLs ApplicationProperties.Security.Timestamp tsConfig = applicationProperties.getSecurity().getTimestamp(); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/AiWorkflowRequest.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/AiWorkflowRequest.java index da327177c..e574f84b8 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/AiWorkflowRequest.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/AiWorkflowRequest.java @@ -11,7 +11,7 @@ import jakarta.validation.constraints.NotNull; import lombok.Data; @Data -@Schema(description = "Run an AI workflow against one or more PDF files") +@Schema(description = "Run an AI workflow") public class AiWorkflowRequest { @NotNull diff --git a/frontend/editor/public/locales/en-GB/translation.toml b/frontend/editor/public/locales/en-GB/translation.toml index 097fb9a5e..1a79197c5 100644 --- a/frontend/editor/public/locales/en-GB/translation.toml +++ b/frontend/editor/public/locales/en-GB/translation.toml @@ -2684,6 +2684,59 @@ title = "Change Permissions" [changePermissions.tooltip.warning] text = "To make these permissions unchangeable, use the Add Password tool to set an owner password." +[agents] +section_title = "Agents" +fullscreen_title = "Stirling Agents" +stirling_name = "Stirling" +stirling_full_name = "Stirling General Agent" +stirling_tooltip = "Stirling agent" +stirling_description = "Your general-purpose PDF assistant" +stirling_long_description = "General purpose PDF assistant that can run tools, create PDFs and extract insights from your documents." +back_to_tools = "Back to tools" +coming_soon = "Coming soon" +view_all = "View all agents" +show_less = "Show less" +start_chat = "Start chatting" +data_extraction_name = "Data Extraction" +data_extraction_description = "Extract tables & structured data" +doc_summary_name = "Summariser" +doc_summary_description = "Summarise long documents" +auto_redaction_name = "Auto Redaction" +auto_redaction_description = "Redact PII automatically" +compliance_name = "Compliance Check" +compliance_description = "Audit documents for compliance" +form_filler_name = "Form Filler" +form_filler_description = "Fill PDF forms intelligently" +pdf_to_markdown_name = "PDF to Markdown" +pdf_to_markdown_description = "Convert PDFs to clean Markdown" + +[chat.header] +settings = "Agent settings" +agentMenu = "Stirling agent options" +clearChat = "Clear chat" + +[chat.input] +placeholder = "What do you want to do?" +send = "Send message" +attach = "Attach files" + +[chat.quickActions] +heading = "Get started" +openFromComputer = "Open from computer" +browseYourFiles = "Browse your files" +rotateOne = "Rotate this document" +rotateMany = "Rotate these documents" +compressOne = "Compress this document" +compressMany = "Compress these documents" +mergeMany = "Merge these {{count}} documents into 1" +splitOne = "Split this document" +convertOne = "Convert this document to PDF" +convertMany = "Convert these documents to PDF" +fileSummary_one = "1 file in workbench ({{types}})" +fileSummary_other = "{{count}} files in workbench ({{types}})" +moreFiles = "+{{count}} more" +removeFile = "Remove {{name}}" + [chat.progress] analyzing = "Analysing your request..." calling_engine = "AI is thinking..." @@ -2699,6 +2752,15 @@ whole_doc_read_done = "Finished reading the document..." whole_doc_read_started = "Reading the document..." whole_doc_slice_done = "Reading the document... ({{percent}}% complete)" +[chat.responses] +done = "Done." +need_clarification = "Could you clarify your request?" +cannot_do = "I'm unable to do that." +not_found = "I couldn't find the requested information." +unsupported_capability = "Unsupported capability: {{capability}}" +cannot_continue = "Something went wrong and I can't continue." +processing = "Processing ({{outcome}})..." + [chat.toolsUsed] summary = "Ran {{count}} tools" summary_one = "Ran 1 tool" @@ -8072,6 +8134,11 @@ expand = "Expand panel" placeholder = "Choose a tool to get started" premiumFeature = "Premium feature:" search = "Search tools" +toolsHeader = "Tools" +viewAllTools = "View all tools" +backToDefault = "Back" +backToAllTools = "Back to all tools" +goBack = "Go back" [toolPanel.fullscreen] comingSoon = "Coming soon:" diff --git a/frontend/editor/src/core/components/agents/AgentsPanel.tsx b/frontend/editor/src/core/components/agents/AgentsPanel.tsx new file mode 100644 index 000000000..282b4639b --- /dev/null +++ b/frontend/editor/src/core/components/agents/AgentsPanel.tsx @@ -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; +} diff --git a/frontend/editor/src/core/components/tools/FullscreenToolPanel.tsx b/frontend/editor/src/core/components/tools/FullscreenToolPanel.tsx new file mode 100644 index 000000000..68a0bb3a0 --- /dev/null +++ b/frontend/editor/src/core/components/tools/FullscreenToolPanel.tsx @@ -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(); + filteredTools.forEach(({ item: [id], matchedText }) => { + if (matchedText) map.set(id, matchedText); + }); + return map; + }, [filteredTools]); + + if (!fullscreenExpanded) return null; + + return ( + handleToolSelect(id)} + onToggleDescriptions={() => + updatePreference( + "showLegacyToolDescriptions", + !preferences.showLegacyToolDescriptions, + ) + } + onExitFullscreenMode={() => setToolPanelMode("sidebar")} + geometry={geometry} + agentsSlot={ + agentsEnabled && !searchQuery ? : null + } + /> + ); +} diff --git a/frontend/editor/src/core/components/tools/FullscreenToolSurface.tsx b/frontend/editor/src/core/components/tools/FullscreenToolSurface.tsx index 264f00c35..e1ad45d46 100644 --- a/frontend/editor/src/core/components/tools/FullscreenToolSurface.tsx +++ b/frontend/editor/src/core/components/tools/FullscreenToolSurface.tsx @@ -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(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 = (
-
-
- - -
-
-
+ {agentsSlot && ( +
{agentsSlot}
+ )}
); + + if (typeof document === "undefined") return surface; + return createPortal(surface, document.body); }; export default FullscreenToolSurface; diff --git a/frontend/editor/src/core/components/tools/RightSidebar.tsx b/frontend/editor/src/core/components/tools/RightSidebar.tsx new file mode 100644 index 000000000..5e5b7e0fc --- /dev/null +++ b/frontend/editor/src/core/components/tools/RightSidebar.tsx @@ -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(); + 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 ( +
+ {!fullscreenExpanded && !isPanelVisible && !isMobile && ( +
+
+ + + + +
+
+
+ {collapsedRailItems.map(({ id, tool }) => ( + + + + ))} +
+
+ )} + + {!fullscreenExpanded && isPanelVisible && ( +
+
+ {activeTool ? ( +
+ + + + + {activeTool.name} + +
+ ) : showHeaderSearch ? ( +
+ +
+ ) : ( + showAgents && ( + + {t("agents.section_title", "Agents")} + + ) + )} + {showCloseButton ? ( + + + + ) : ( + + + + )} +
+ + {showAgents && } + + + +
+ )} + + +
+ ); +} diff --git a/frontend/editor/src/core/components/tools/ToolPanel.css b/frontend/editor/src/core/components/tools/ToolPanel.css index 0e52f93ab..592c83771 100644 --- a/frontend/editor/src/core/components/tools/ToolPanel.css +++ b/frontend/editor/src/core/components/tools/ToolPanel.css @@ -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); diff --git a/frontend/editor/src/core/components/tools/ToolPanel.tsx b/frontend/editor/src/core/components/tools/ToolPanel.tsx index e431a24a0..2d6006a8e 100644 --- a/frontend/editor/src/core/components/tools/ToolPanel.tsx +++ b/frontend/editor/src/core/components/tools/ToolPanel.tsx @@ -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(); - filteredTools.forEach(({ item: [id], matchedText }) => { - if (matchedText) { - map.set(id, matchedText); - } - }); - return map; - }, [filteredTools]); + } = useToolWorkflow(); + const selectTool = onToolSelect ?? handleToolSelect; return ( -
- {!fullscreenExpanded && !isPanelVisible && !isMobile && ( -
- - - - - - + <> + {/* Viewer mode tools — annotate, redact, form fill */} + + + {allToolsView && searchQuery.trim().length > 0 ? ( +
+ selectTool(id as ToolId)} + searchQuery={searchQuery} + />
- )} - - {!fullscreenExpanded && isPanelVisible && ( -
- {/* Viewer mode tools — annotate, redact, form fill */} - - -
- - - - -
- - {searchQuery.trim().length > 0 ? ( -
- -
- ) : leftPanelView === "toolPicker" ? ( -
- + selectTool(id as ToolId)} + filteredTools={filteredTools} + isSearching={Boolean(searchQuery && searchQuery.trim().length > 0)} + compact={compactProp ?? !allToolsView} + onShowAllTools={onShowAllTools} + /> +
+ ) : ( +
+ + {selectedToolKey ? ( + 0, - )} + onPreviewFile={setPreviewFile} /> -
- ) : ( -
-
- + ) : ( +
+ {t("toolPanel.placeholder", "Choose a tool to get started")}
-
- - {selectedToolKey ? ( - - ) : ( -
- {t( - "toolPanel.placeholder", - "Choose a tool to get started", - )} -
- )} -
-
-
- )} + )} +
)} - - {fullscreenExpanded && ( - handleToolSelect(id)} - onToggleDescriptions={() => - updatePreference( - "showLegacyToolDescriptions", - !preferences.showLegacyToolDescriptions, - ) - } - onExitFullscreenMode={() => setToolPanelMode("sidebar")} - geometry={fullscreenGeometry} - /> - )} -
+ ); } diff --git a/frontend/editor/src/core/components/tools/ToolPicker.tsx b/frontend/editor/src/core/components/tools/ToolPicker.tsx index 7fb01f89e..96f826f49 100644 --- a/frontend/editor/src/core/components/tools/ToolPicker.tsx +++ b/frontend/editor/src/core/components/tools/ToolPicker.tsx @@ -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 = ({ ) )} + ) : compact ? ( + /* Resting state: flat list of pinned + recommended only. */ + +
+ {t("toolPanel.toolsHeader", "Tools")} +
+ {favoriteToolItems.length === 0 && recommendedItems.length === 0 ? ( + + ) : ( +
+ {favoriteToolItems.map(({ id, tool }) => ( + + ))} + {recommendedItems + .filter( + ({ id }) => !favoriteToolItems.some((fav) => fav.id === id), + ) + .map(({ id, tool }) => ( + + ))} +
+ )} + {onShowAllTools && ( + + )} +
) : ( <> - {/* Flat list: favorites and recommended first, then all subcategories */} + {/* All-tools view: favourites + recommended + all subcategories. */} {favoriteToolItems.length > 0 && ( @@ -182,7 +239,6 @@ const ToolPicker = ({ {!quickSection && !allSection && } - {/* bottom spacer to allow scrolling past the last row */}
)} @@ -192,4 +248,4 @@ const ToolPicker = ({ ); }; -export default memo(ToolPicker); +export default ToolPicker; diff --git a/frontend/editor/src/core/components/tools/toolPicker/ToolButton.tsx b/frontend/editor/src/core/components/tools/toolPicker/ToolButton.tsx index a0c01c644..fbea20984 100644 --- a/frontend/editor/src/core/components/tools/toolPicker/ToolButton.tsx +++ b/frontend/editor/src/core/components/tools/toolPicker/ToolButton.tsx @@ -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 = ({ disableNavigation = false, matchedSynonym, hasStars = false, + showDescription = false, onUnavailableClick, }) => { const { t } = useTranslation(); @@ -183,6 +185,14 @@ const ToolButton: React.FC = ({ )} {usesCloud && !visuallyUnavailable && }
+ {showDescription && tool.description && ( + + {tool.description} + + )} {matchedSynonym && ( = ({ 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 = ({ borderRadius: 0, color: "var(--tools-text-and-icon-color)", overflow: "visible", - ...selectedStyles, + ...selectedBg, }, label: { overflow: "visible" }, }} @@ -254,7 +264,7 @@ const ToolButton: React.FC = ({ borderRadius: 0, color: "var(--tools-text-and-icon-color)", overflow: "visible", - ...selectedStyles, + ...selectedBg, }, label: { overflow: "visible" }, }} @@ -279,7 +289,6 @@ const ToolButton: React.FC = ({ color: "var(--tools-text-and-icon-color)", cursor: visuallyUnavailable ? "not-allowed" : undefined, overflow: "visible", - ...selectedStyles, }, label: { overflow: "visible" }, }} diff --git a/frontend/editor/src/core/components/tools/toolPicker/ToolPicker.css b/frontend/editor/src/core/components/tools/toolPicker/ToolPicker.css index 54551d810..338afb095 100644 --- a/frontend/editor/src/core/components/tools/toolPicker/ToolPicker.css +++ b/frontend/editor/src/core/components/tools/toolPicker/ToolPicker.css @@ -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; +} diff --git a/frontend/editor/src/core/contexts/toolWorkflow/toolWorkflowState.ts b/frontend/editor/src/core/contexts/toolWorkflow/toolWorkflowState.ts index bb7193d3e..2c55315cc 100644 --- a/frontend/editor/src/core/contexts/toolWorkflow/toolWorkflowState.ts +++ b/frontend/editor/src/core/contexts/toolWorkflow/toolWorkflowState.ts @@ -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 = { export const createInitialState = (): ToolWorkflowState => ({ ...baseState, - toolPanelMode: DEFAULT_TOOL_PANEL_MODE, + toolPanelMode: preferencesService.getPreference("defaultToolPanelMode"), }); export function toolWorkflowReducer( diff --git a/frontend/editor/src/core/hooks/tools/sign/useSavedSignatures.ts b/frontend/editor/src/core/hooks/tools/sign/useSavedSignatures.ts index b14f8579f..c4ef0b1e9 100644 --- a/frontend/editor/src/core/hooks/tools/sign/useSavedSignatures.ts +++ b/frontend/editor/src/core/hooks/tools/sign/useSavedSignatures.ts @@ -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([]); const [storageType, setStorageType] = useState(null); diff --git a/frontend/editor/src/core/hooks/tools/useToolPanelGeometry.ts b/frontend/editor/src/core/hooks/tools/useToolPanelGeometry.ts index 818057b99..41a2b2f76 100644 --- a/frontend/editor/src/core/hooks/tools/useToolPanelGeometry.ts +++ b/frontend/editor/src/core/hooks/tools/useToolPanelGeometry.ts @@ -13,6 +13,31 @@ interface UseToolPanelGeometryOptions { quickAccessRef: RefObject; } +function computeGeometry( + panelEl: HTMLDivElement, + quickAccessRef: RefObject, +): 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 = () => { diff --git a/frontend/editor/src/core/pages/HomePage.tsx b/frontend/editor/src/core/pages/HomePage.tsx index 43a31b41f..a589ed43b 100644 --- a/frontend/editor/src/core/pages/HomePage.tsx +++ b/frontend/editor/src/core/pages/HomePage.tsx @@ -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() { )} >
- +
- {!hideToolPanel && } + {!hideToolPanel && } { 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("/"); diff --git a/frontend/editor/src/core/tests/stubbed/tool-pages-common.spec.ts b/frontend/editor/src/core/tests/stubbed/tool-pages-common.spec.ts index 250bff447..ae2e4b4e6 100644 --- a/frontend/editor/src/core/tests/stubbed/tool-pages-common.spec.ts +++ b/frontend/editor/src/core/tests/stubbed/tool-pages-common.spec.ts @@ -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(); diff --git a/frontend/editor/src/core/tools/Annotate.tsx b/frontend/editor/src/core/tools/Annotate.tsx index 563f7db6e..473b739c6 100644 --- a/frontend/editor/src/core/tools/Annotate.tsx +++ b/frontend/editor/src/core/tools/Annotate.tsx @@ -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 = { ...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; diff --git a/frontend/editor/src/core/types/appConfig.ts b/frontend/editor/src/core/types/appConfig.ts index e72dc8e44..6a99d61f3 100644 --- a/frontend/editor/src/core/types/appConfig.ts +++ b/frontend/editor/src/core/types/appConfig.ts @@ -58,6 +58,7 @@ export interface AppConfig { timestampDefaultTsaUrl?: string; timestampCustomTsaUrls?: string[]; timestampTsaPresets?: { label: string; url: string }[]; + aiEngineEnabled?: boolean; } export type AppConfigBootstrapMode = "blocking" | "non-blocking"; diff --git a/frontend/editor/src/core/types/fileContext.ts b/frontend/editor/src/core/types/fileContext.ts index c65b0485a..3e9ba4283 100644 --- a/frontend/editor/src/core/types/fileContext.ts +++ b/frontend/editor/src/core/types/fileContext.ts @@ -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; } -// 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 diff --git a/frontend/editor/src/core/types/folder.ts b/frontend/editor/src/core/types/folder.ts index 12a59a4f7..dbc0710fc 100644 --- a/frontend/editor/src/core/types/folder.ts +++ b/frontend/editor/src/core/types/folder.ts @@ -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 { diff --git a/frontend/editor/src/core/utils/browserIdentifier.ts b/frontend/editor/src/core/utils/browserIdentifier.ts index 703766a10..df8ee0211 100644 --- a/frontend/editor/src/core/utils/browserIdentifier.ts +++ b/frontend/editor/src/core/utils/browserIdentifier.ts @@ -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(); } diff --git a/frontend/editor/src/core/utils/editTableOfContents.ts b/frontend/editor/src/core/utils/editTableOfContents.ts index bb6119ae3..57bb42b06 100644 --- a/frontend/editor/src/core/utils/editTableOfContents.ts +++ b/frontend/editor/src/core/utils/editTableOfContents.ts @@ -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, diff --git a/frontend/editor/src/core/utils/generateId.ts b/frontend/editor/src/core/utils/generateId.ts new file mode 100644 index 000000000..283883b33 --- /dev/null +++ b/frontend/editor/src/core/utils/generateId.ts @@ -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); + }); +} diff --git a/frontend/editor/src/core/utils/viewTransition.ts b/frontend/editor/src/core/utils/viewTransition.ts new file mode 100644 index 000000000..049c3e767 --- /dev/null +++ b/frontend/editor/src/core/utils/viewTransition.ts @@ -0,0 +1,26 @@ +import { flushSync } from "react-dom"; + +type ViewTransitionDoc = Document & { + startViewTransition?: (cb: () => void) => { finished: Promise }; +}; + +/** + * 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 { + 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(); +} diff --git a/frontend/editor/src/desktop/services/apiClientSetup.ts b/frontend/editor/src/desktop/services/apiClientSetup.ts index 3b97154fe..790d8feef 100644 --- a/frontend/editor/src/desktop/services/apiClientSetup.ts +++ b/frontend/editor/src/desktop/services/apiClientSetup.ts @@ -1,6 +1,11 @@ import type { AxiosInstance, InternalAxiosRequestConfig } from "axios"; import { alert } from "@app/components/toast"; -import { setupApiInterceptors as coreSetup } from "@core/services/apiClientSetup"; +import { + setupApiInterceptors as coreSetup, + getAuthHeaders, +} from "@core/services/apiClientSetup"; + +export { getAuthHeaders }; import { tauriBackendService } from "@app/services/tauriBackendService"; import { createBackendNotReadyError } from "@app/constants/backendErrors"; import { operationRouter } from "@app/services/operationRouter"; diff --git a/frontend/editor/src/desktop/services/authService.ts b/frontend/editor/src/desktop/services/authService.ts index 9073c9d15..6f5d3214f 100644 --- a/frontend/editor/src/desktop/services/authService.ts +++ b/frontend/editor/src/desktop/services/authService.ts @@ -1,4 +1,5 @@ import { invoke, isTauri } from "@tauri-apps/api/core"; +import { generateId } from "@app/utils/generateId"; import { listen } from "@tauri-apps/api/event"; import { open as shellOpen } from "@tauri-apps/plugin-shell"; import { connectionModeService } from "@app/services/connectionModeService"; @@ -1008,7 +1009,7 @@ export class AuthService { serverUrl: string, ): Promise { // Generate and store nonce for CSRF protection - const nonce = crypto.randomUUID(); + const nonce = generateId(); sessionStorage.setItem("oauth_nonce", nonce); console.log( "[Desktop AuthService] Generated OAuth nonce for CSRF protection", diff --git a/frontend/editor/src/desktop/services/nativePrintService.ts b/frontend/editor/src/desktop/services/nativePrintService.ts index f94135334..43979b726 100644 --- a/frontend/editor/src/desktop/services/nativePrintService.ts +++ b/frontend/editor/src/desktop/services/nativePrintService.ts @@ -1,4 +1,5 @@ import { invoke } from "@tauri-apps/api/core"; +import { generateId } from "@app/utils/generateId"; function sanitizeFileName(fileName: string) { const cleaned = fileName.replace(/[^A-Za-z0-9._-]+/g, "_"); @@ -40,7 +41,7 @@ export async function printPdfNatively( const tempPath = await join( await tempDir(), - `stirling-print-${crypto.randomUUID()}-${sanitizeFileName(fileName)}`, + `stirling-print-${generateId()}-${sanitizeFileName(fileName)}`, ); await writeFile(tempPath, new Uint8Array(await source.arrayBuffer())); diff --git a/frontend/editor/src/proprietary/components/AppProviders.tsx b/frontend/editor/src/proprietary/components/AppProviders.tsx index db10725f2..06b5ddb69 100644 --- a/frontend/editor/src/proprietary/components/AppProviders.tsx +++ b/frontend/editor/src/proprietary/components/AppProviders.tsx @@ -8,6 +8,7 @@ import { CheckoutProvider } from "@app/contexts/CheckoutContext"; import { UpgradeBannerInitializer } from "@app/components/shared/UpgradeBannerInitializer"; import { ServerExperienceProvider } from "@app/contexts/ServerExperienceContext"; import { UpdateSeatsProvider } from "@app/contexts/UpdateSeatsContext"; +import { ChatProvider } from "@app/components/chat/ChatContext"; export function AppProviders({ children, @@ -25,7 +26,7 @@ export function AppProviders({ - {children} + {children} diff --git a/frontend/editor/src/proprietary/components/agents/AgentsPanel.css b/frontend/editor/src/proprietary/components/agents/AgentsPanel.css new file mode 100644 index 000000000..7a531012e --- /dev/null +++ b/frontend/editor/src/proprietary/components/agents/AgentsPanel.css @@ -0,0 +1,399 @@ +/* ─── Chat takeover ─────────────────────────────────────────────────────── */ + +.agents-takeover { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + background: var(--bg-toolbar); + z-index: 5; + view-transition-name: agents-rail; +} + +/* ─── Sidebar agents section ─────────────────────────────────────────────── */ + +.agents-section { + padding: 0.5rem 0.75rem 0.875rem; + border-bottom: 1px solid var(--border-subtle); + background: var(--bg-toolbar); + flex-shrink: 0; + view-transition-name: agents-rail; +} + +/* Hero button — real Stirling agent */ +.agent-button { + display: block; + width: 100%; + padding: 0.75rem; + border: 1px solid + color-mix(in srgb, var(--mantine-color-blue-5) 40%, var(--border-subtle)); + border-radius: 0.625rem; + background: linear-gradient( + 135deg, + color-mix( + in srgb, + var(--mantine-color-blue-6) 6%, + var(--mantine-color-body) + ) + 0%, + var(--mantine-color-body) 100% + ); + transition: + background 150ms ease-out, + border-color 150ms ease-out; +} + +.agent-button:hover { + border-color: color-mix( + in srgb, + var(--mantine-color-blue-5) 65%, + var(--border-subtle) + ); + background: linear-gradient( + 135deg, + color-mix( + in srgb, + var(--mantine-color-blue-6) 12%, + var(--mantine-color-default-hover) + ) + 0%, + var(--mantine-color-default-hover) 100% + ); +} + +.agent-button__logo { + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--mantine-color-blue-filled); + flex-shrink: 0; +} + +/* Coming-soon agent rows */ +.agents-sidebar-list { + display: flex; + flex-direction: column; + gap: 0.375rem; + margin-top: 0.375rem; +} + +.agent-button--coming-soon { + display: block; + width: 100%; + padding: 0.75rem; + border: 1px solid var(--border-subtle); + border-radius: 0.625rem; + background: var(--mantine-color-body); + cursor: not-allowed !important; + opacity: 0.65; + transition: + opacity 180ms ease-out, + transform 180ms ease-out, + border-color 120ms ease-out; +} + +@starting-style { + .agent-button--coming-soon { + opacity: 0; + transform: translateY(5px); + } +} + +.agent-button--coming-soon:hover { + opacity: 0.85; + border-color: color-mix( + in srgb, + var(--mantine-color-gray-4) 60%, + var(--border-subtle) + ); + background: var(--mantine-color-default-hover); +} + +.agent-button__icon-plain { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.75rem; + height: 1.75rem; + color: var(--mantine-color-dimmed); + flex-shrink: 0; +} + +.agents-view-all { + all: unset; + display: block; + width: 100%; + margin-top: 0.375rem; + padding: 0.25rem 0.625rem; + font-size: 0.75rem; + font-weight: 500; + color: var(--mantine-color-dimmed); + text-align: center; + cursor: pointer; + opacity: 0.7; + border-radius: 0.375rem; + transition: opacity 120ms ease-out; + box-sizing: border-box; +} + +.agents-view-all:hover { + opacity: 1; +} + +/* Dark mode — sidebar */ +[data-mantine-color-scheme="dark"] .agent-button--coming-soon { + background: transparent; + border-color: color-mix( + in srgb, + var(--mantine-color-dark-4) 60%, + transparent + ); +} + +[data-mantine-color-scheme="dark"] .agent-button--coming-soon:hover { + border-color: var(--mantine-color-dark-3); + background: var(--mantine-color-dark-6); +} + +[data-mantine-color-scheme="dark"] .agent-button { + border-color: color-mix( + in srgb, + var(--mantine-color-blue-7) 55%, + var(--border-subtle) + ); + background: linear-gradient( + 135deg, + color-mix(in srgb, var(--mantine-color-blue-9) 35%, transparent) 0%, + transparent 100% + ); +} + +[data-mantine-color-scheme="dark"] .agent-button:hover { + border-color: color-mix( + in srgb, + var(--mantine-color-blue-5) 60%, + var(--border-subtle) + ); + background: linear-gradient( + 135deg, + color-mix( + in srgb, + var(--mantine-color-blue-8) 45%, + rgba(255, 255, 255, 0.04) + ) + 0%, + rgba(255, 255, 255, 0.04) 100% + ); +} + +[data-mantine-color-scheme="dark"] .agent-button__logo { + color: var(--mantine-color-blue-3, var(--mantine-color-blue-filled)); +} + +/* ─── Collapsed-rail agent button ────────────────────────────────────────── */ + +.agents-collapsed-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + padding: 0; + border: none; + border-radius: 0.5rem; + background: transparent; + color: var(--mantine-color-blue-filled); + cursor: pointer; + transition: background 120ms ease-out; +} + +.agents-collapsed-btn:hover { + background: var(--mantine-color-default-hover); +} + +/* ─── Fullscreen hero card ───────────────────────────────────────────────── */ + +/* + * .tool-panel__fullscreen-group--agents gives the gradient border via the + * padding-box / border-box trick. We only override the padding-box with a + * very light tint so the card doesn't read as a colourful surface. + */ +.agents-hero { + padding: 1.25rem 1.5rem 1.5rem; + border-radius: 1rem; + background: + linear-gradient( + 125deg, + color-mix(in srgb, var(--mantine-color-blue-1) 35%, white) 0%, + color-mix(in srgb, var(--mantine-color-violet-1) 45%, white) 50%, + color-mix(in srgb, var(--mantine-color-pink-0) 35%, white) 100% + ) + 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; +} + +/* Body: left CTA + right grid */ +.agents-hero__body { + display: flex; + gap: 2rem; + align-items: flex-start; +} + +/* ── Left CTA — plain content, only the button inside is interactive ── */ +.agents-hero__cta { + display: flex; + flex-direction: column; + gap: 0; + flex: 0 0 18rem; + box-sizing: border-box; + padding: 0.25rem 0; +} + +.agents-hero__cta-logo { + color: var(--mantine-color-blue-filled); + margin-bottom: 0.625rem; +} + +.agents-hero__cta-headline { + font-size: 1.375rem; +} + +.agents-hero__cta-btn { + all: unset; + margin-top: 1.25rem; + display: inline-flex; + align-items: center; + padding: 0.4375rem 1rem; + border-radius: 0.5rem; + background: var(--mantine-color-blue-6); + color: white; + font-size: 0.8125rem; + font-weight: 600; + line-height: 1; + width: fit-content; + cursor: pointer; + transition: background 0.15s ease; +} + +.agents-hero__cta-btn:hover { + background: var(--mantine-color-blue-7); +} + +.agents-hero__cta-btn:focus-visible { + outline: 2px solid var(--mantine-color-blue-5); + outline-offset: 3px; + border-radius: 0.5rem; +} + +/* ── Right 2×3 grid — sizes to content, pushed to the right ── */ +.agents-hero__grid { + flex: 0 0 auto; + margin-left: auto; + display: grid; + /* auto columns: each column = widest item in that column */ + grid-template-columns: repeat(2, auto); + gap: 0.625rem; + align-content: start; + align-self: start; +} + +/* Solid white cards — no opacity so the white is opaque */ +.agents-hero__grid-item { + all: unset; + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 0.875rem; + border: 1px solid rgba(0, 0, 0, 0.07); + border-radius: 0.75rem; + background: var(--mantine-color-body); + cursor: not-allowed; + box-sizing: border-box; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); +} + +.agents-hero__grid-icon { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: var(--mantine-color-blue-5); +} + +.agents-hero__grid-body { + min-width: 0; + flex: 1; +} + +/* ── Dark mode — fullscreen ── */ +[data-mantine-color-scheme="dark"] .agents-hero { + background: + linear-gradient( + 125deg, + color-mix(in srgb, var(--mantine-color-blue-9) 55%, #1a1b2e) 0%, + color-mix(in srgb, var(--mantine-color-violet-9) 45%, #1a1b2e) 50%, + color-mix(in srgb, var(--mantine-color-pink-9) 40%, #1a1b2e) 100% + ) + 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; +} + +[data-mantine-color-scheme="dark"] .agents-hero__cta-logo { + color: var(--mantine-color-blue-3); +} + +[data-mantine-color-scheme="dark"] .agents-hero__grid-item { + background: var(--fullscreen-bg-item); + border-color: var(--fullscreen-border-subtle-70); + box-shadow: none; +} + +[data-mantine-color-scheme="dark"] .agents-hero__grid-icon { + color: var(--mantine-color-blue-3); +} + +/* ─── agents-rail view transition — slide up ─────────────────────────────── */ + +::view-transition-old(agents-rail) { + animation: vt-agents-out 150ms ease-out forwards; +} + +::view-transition-new(agents-rail) { + animation: vt-agents-in 260ms cubic-bezier(0.22, 0.61, 0.36, 1) forwards; +} + +@keyframes vt-agents-out { + to { + opacity: 0; + } +} + +@keyframes vt-agents-in { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@media (prefers-reduced-motion: reduce) { + ::view-transition-old(agents-rail), + ::view-transition-new(agents-rail) { + animation-duration: 1ms !important; + } +} diff --git a/frontend/editor/src/proprietary/components/agents/AgentsPanel.tsx b/frontend/editor/src/proprietary/components/agents/AgentsPanel.tsx new file mode 100644 index 000000000..adfbff3bb --- /dev/null +++ b/frontend/editor/src/proprietary/components/agents/AgentsPanel.tsx @@ -0,0 +1,290 @@ +import { useState } from "react"; +import type { ElementType } from "react"; +import { Box, Group, Text, UnstyledButton } from "@mantine/core"; +import TableChartRoundedIcon from "@mui/icons-material/TableChartRounded"; +import SummarizeRoundedIcon from "@mui/icons-material/SummarizeRounded"; +import VisibilityOffRoundedIcon from "@mui/icons-material/VisibilityOffRounded"; +import GavelRoundedIcon from "@mui/icons-material/GavelRounded"; +import AssignmentRoundedIcon from "@mui/icons-material/AssignmentRounded"; +import CodeRoundedIcon from "@mui/icons-material/CodeRounded"; +import { useTranslation } from "react-i18next"; +import { useAppConfig } from "@app/contexts/AppConfigContext"; +import { useChat } from "@app/components/chat/ChatContext"; +import { ChatPanel } from "@app/components/chat/ChatPanel"; +import { Tooltip as AppTooltip } from "@app/components/shared/Tooltip"; +import { StirlingLogoOutline } from "@app/components/agents/StirlingLogoOutline"; +import { withViewTransition } from "@app/utils/viewTransition"; +import "@app/components/agents/AgentsPanel.css"; + +interface ComingSoonAgent { + id: string; + nameKey: string; + descriptionKey: string; + Icon: ElementType; +} + +const COMING_SOON_AGENTS: ComingSoonAgent[] = [ + { + id: "data-extraction", + nameKey: "agents.data_extraction_name", + descriptionKey: "agents.data_extraction_description", + Icon: TableChartRoundedIcon, + }, + { + id: "doc-summary", + nameKey: "agents.doc_summary_name", + descriptionKey: "agents.doc_summary_description", + Icon: SummarizeRoundedIcon, + }, + { + id: "auto-redaction", + nameKey: "agents.auto_redaction_name", + descriptionKey: "agents.auto_redaction_description", + Icon: VisibilityOffRoundedIcon, + }, + { + id: "compliance", + nameKey: "agents.compliance_name", + descriptionKey: "agents.compliance_description", + Icon: GavelRoundedIcon, + }, + { + id: "form-filler", + nameKey: "agents.form_filler_name", + descriptionKey: "agents.form_filler_description", + Icon: AssignmentRoundedIcon, + }, + { + id: "pdf-to-markdown", + nameKey: "agents.pdf_to_markdown_name", + descriptionKey: "agents.pdf_to_markdown_description", + Icon: CodeRoundedIcon, + }, +]; + +export function useAgentsEnabled(): boolean { + const { config } = useAppConfig(); + return Boolean(config?.aiEngineEnabled); +} + +export function useAgentChatOpen(): boolean { + const { isOpen } = useChat(); + return isOpen; +} + +const PREVIEW_COUNT = 3; + +/** Sidebar agents section — Stirling as hero CTA, coming-soon agents below. */ +export function AgentsSection() { + const { t } = useTranslation(); + const { isOpen, setOpen } = useChat(); + const enabled = useAgentsEnabled(); + const [showAll, setShowAll] = useState(false); + + if (!enabled || isOpen) return null; + + const comingSoonLabel = t("agents.coming_soon", "Coming soon"); + const visibleAgents = showAll + ? COMING_SOON_AGENTS + : COMING_SOON_AGENTS.slice(0, PREVIEW_COUNT); + + return ( + + {/* Main Stirling agent — real and clickable */} + withViewTransition(() => setOpen(true))} + aria-label={t("agents.stirling_name", "Stirling")} + > + + + + + + + {t("agents.stirling_name", "Stirling")} + + + {t( + "agents.stirling_description", + "Your general-purpose PDF assistant", + )} + + + + + + {/* Coming-soon agents */} +
+ {visibleAgents.map(({ id, nameKey, descriptionKey, Icon }) => ( + + + + + + + + + {t(nameKey)} + + + {t(descriptionKey)} + + + + + + ))} +
+ + {!showAll ? ( + + ) : ( + + )} +
+ ); +} + +/** Icon-only agent button in the collapsed (minimised) right rail. */ +export function AgentsCollapsedButton({ onExpand }: { onExpand: () => void }) { + const { t } = useTranslation(); + const { setOpen } = useChat(); + const enabled = useAgentsEnabled(); + + if (!enabled) return null; + + const label = t("agents.stirling_tooltip", "Stirling agent"); + + return ( + + { + onExpand(); + setOpen(true); + }} + aria-label={label} + className="agents-collapsed-btn" + > + + + + ); +} + +/** + * Fullscreen hero card — Stirling CTA on the left, 2×3 coming-soon grid on + * the right. Matches the gradient border of the other fullscreen category cards. + */ +export function AgentsFullscreenSection() { + const { t } = useTranslation(); + const { isOpen, setOpen } = useChat(); + const enabled = useAgentsEnabled(); + + if (!enabled || isOpen) return null; + + const comingSoonLabel = t("agents.coming_soon", "Coming soon"); + + return ( +
+
+ {/* Left: Stirling content — only the button is interactive */} +
+
+ +
+ + {t("agents.stirling_full_name", "Stirling General Agent")} + + + {t( + "agents.stirling_long_description", + "General purpose PDF assistant that can run tools, create PDFs and extract insights from your documents.", + )} + + +
+ + {/* Right: 2×3 grid of coming-soon agents */} +
+ {COMING_SOON_AGENTS.map(({ id, nameKey, descriptionKey, Icon }) => ( + + + + ))} +
+
+
+ ); +} + +/** Full-rail chat overlay rendered inside ToolPanel. */ +export function AgentsChatOverlay() { + const { t } = useTranslation(); + const { isOpen, setOpen } = useChat(); + const enabled = useAgentsEnabled(); + + if (!enabled || !isOpen) return null; + + return ( + + withViewTransition(() => setOpen(false))} + backLabel={t("agents.back_to_tools", "Back to tools")} + /> + + ); +} diff --git a/frontend/editor/src/proprietary/components/agents/StirlingLogoOutline.tsx b/frontend/editor/src/proprietary/components/agents/StirlingLogoOutline.tsx new file mode 100644 index 000000000..c52b40df2 --- /dev/null +++ b/frontend/editor/src/proprietary/components/agents/StirlingLogoOutline.tsx @@ -0,0 +1,21 @@ +/** + * A temp stirling logo, may change in future. + */ +export function StirlingLogoOutline({ size = 20 }: { size?: number }) { + return ( + + ); +} diff --git a/frontend/editor/src/proprietary/components/chat/ChatContext.tsx b/frontend/editor/src/proprietary/components/chat/ChatContext.tsx new file mode 100644 index 000000000..074d3d029 --- /dev/null +++ b/frontend/editor/src/proprietary/components/chat/ChatContext.tsx @@ -0,0 +1,610 @@ +import { + createContext, + useContext, + useReducer, + useCallback, + useRef, + type ReactNode, +} from "react"; +import { useTranslation } from "react-i18next"; +import { generateId } from "@app/utils/generateId"; +import { useAllFiles, useFileActions } from "@app/contexts/FileContext"; +import apiClient from "@app/services/apiClient"; +import { getAuthHeaders } from "@app/services/apiClientSetup"; +import { createChildStub } from "@app/contexts/file/fileActions"; +import { + createNewStirlingFileStub, + createStirlingFile, + type StirlingFileStub, +} from "@app/types/fileContext"; +import type { ToolOperation } from "@app/types/file"; + +export interface ChatMessage { + id: string; + role: "user" | "assistant"; + content: string; + timestamp: number; + /** + * Tool endpoint paths executed during this assistant turn (e.g. + * {@code /api/v1/general/rotate-pdf}). Populated for assistant messages when the workflow + * ran one or more tools, in execution order. Undefined for user messages and for assistant + * turns that answered without running any tool. + */ + toolsUsed?: string[]; +} + +export enum AiWorkflowPhase { + ANALYZING = "analyzing", + CALLING_ENGINE = "calling_engine", + EXTRACTING_CONTENT = "extracting_content", + EXECUTING_TOOL = "executing_tool", + PROCESSING = "processing", + ENGINE_PROGRESS = "engine_progress", +} + +/** + * Engine-side progress detail for ENGINE_PROGRESS events. Mirrors the Python + * {@code ProgressEvent} discriminated union (engine/src/stirling/contracts/progress.py) + * and the Java {@code AiEngineProgressDetail} sealed interface; the {@code phase} + * string is the discriminator. Field names are camelCase because the engine + * serialises by alias. + */ +export interface WholeDocReadStartedDetail { + phase: "whole_doc_read_started"; + question: string; + pages: number; + slices: number; +} + +export interface WholeDocSliceDoneDetail { + phase: "whole_doc_slice_done"; + completed: number; + total: number; + /** Page-range label, e.g. "pages=1-5". */ + pages: string; + durationMs: number; + excerpts: number; + facts: number; +} + +export interface WholeDocCompressionRoundDetail { + phase: "whole_doc_compression_round"; + roundNumber: number; + notesIn: number; + groups: number; +} + +export interface WholeDocReadDoneDetail { + phase: "whole_doc_read_done"; + completed: number; + slices: number; + durationSeconds: number; +} + +export type EngineProgressDetail = + | WholeDocReadStartedDetail + | WholeDocSliceDoneDetail + | WholeDocCompressionRoundDetail + | WholeDocReadDoneDetail; + +/** + * What we actually carry across the wire boundary: a known typed variant, or a + * forward-compat shape with just the discriminator string. The "unknown" arm + * exists so a new engine-side phase rolling out before a frontend update keeps + * rendering the generic processing message instead of crashing the union. + */ +export interface UnknownEngineProgressDetail { + phase: string; +} + +export type AnyEngineProgressDetail = + | EngineProgressDetail + | UnknownEngineProgressDetail; + +const KNOWN_ENGINE_PHASES = new Set([ + "whole_doc_read_started", + "whole_doc_slice_done", + "whole_doc_compression_round", + "whole_doc_read_done", +]); + +export function isKnownEngineProgressDetail( + detail: AnyEngineProgressDetail, +): detail is EngineProgressDetail { + return KNOWN_ENGINE_PHASES.has(detail.phase); +} + +export interface AiWorkflowProgress { + phase: AiWorkflowPhase; + /** Tool endpoint path currently executing, for EXECUTING_TOOL events. */ + tool?: string; + /** 1-based step index, for EXECUTING_TOOL events. */ + stepIndex?: number; + /** Total number of plan steps, for EXECUTING_TOOL events. */ + stepCount?: number; + /** + * Engine-side event payload, for ENGINE_PROGRESS events. Typed sub-phase + * record (e.g. {@link WholeDocSliceDoneDetail}) the UI can render in detail. + */ + engineDetail?: AnyEngineProgressDetail; +} + +type AiWorkflowOutcome = + | "answer" + | "not_found" + | "need_content" + | "plan" + | "need_clarification" + | "cannot_do" + | "tool_call" + | "completed" + | "unsupported_capability" + | "cannot_continue"; + +interface AiWorkflowResultFile { + /** Stirling file ID — download with /api/v1/general/files/{fileId}. */ + fileId: string; + fileName: string; + contentType: string; +} + +interface AiWorkflowResponse { + outcome: AiWorkflowOutcome; + answer?: string; + summary?: string; + rationale?: string; + reason?: string; + question?: string; + capability?: string; + message?: string; + evidence?: Array<{ pageNumber: number; text: string }>; + steps?: Array>; + /** Every file produced by the workflow (empty if the outcome has no files). */ + resultFiles?: AiWorkflowResultFile[]; + // Legacy single-file fields — mirror the first entry of resultFiles. + fileId?: string; + fileName?: string; + contentType?: string; +} + +interface ChatState { + messages: ChatMessage[]; + isOpen: boolean; + isLoading: boolean; + progress: AiWorkflowProgress | null; +} + +type ChatAction = + | { type: "ADD_MESSAGE"; message: ChatMessage } + | { type: "SET_LOADING"; loading: boolean } + | { type: "SET_PROGRESS"; progress: AiWorkflowProgress | null } + | { type: "TOGGLE_OPEN" } + | { type: "SET_OPEN"; open: boolean } + | { type: "CLEAR" }; + +function chatReducer(state: ChatState, action: ChatAction): ChatState { + switch (action.type) { + case "ADD_MESSAGE": + return { ...state, messages: [...state.messages, action.message] }; + case "SET_LOADING": + return { ...state, isLoading: action.loading }; + case "SET_PROGRESS": + return { ...state, progress: action.progress }; + case "TOGGLE_OPEN": + return { ...state, isOpen: !state.isOpen }; + case "SET_OPEN": + return { ...state, isOpen: action.open }; + case "CLEAR": + return { ...state, messages: [], isLoading: false, progress: null }; + } +} + +type TranslateFn = ReturnType["t"]; + +function formatWorkflowResponse( + data: AiWorkflowResponse, + t: TranslateFn, +): string { + switch (data.outcome) { + case "answer": + case "completed": + return data.answer ?? data.summary ?? t("chat.responses.done"); + case "need_clarification": + return data.question ?? t("chat.responses.need_clarification"); + case "cannot_do": + return data.reason ?? t("chat.responses.cannot_do"); + case "not_found": + return data.reason ?? t("chat.responses.not_found"); + case "unsupported_capability": + return ( + data.message ?? + t("chat.responses.unsupported_capability", { + capability: data.capability ?? "unknown", + }) + ); + case "cannot_continue": + return data.reason ?? t("chat.responses.cannot_continue"); + case "plan": + return data.rationale + ? `${data.rationale}\n\n${(data.steps ?? []).map((s, i) => `${i + 1}. ${JSON.stringify(s)}`).join("\n")}` + : JSON.stringify(data.steps, null, 2); + case "need_content": + case "tool_call": + return ( + data.rationale ?? + data.summary ?? + t("chat.responses.processing", { outcome: data.outcome }) + ); + default: + return ( + data.answer ?? data.summary ?? data.message ?? JSON.stringify(data) + ); + } +} + +/** + * Parses an SSE text stream and invokes callbacks for each named event. + */ +interface ProgressEvent { + phase: string; + timestamp: number; + tool?: string; + stepIndex?: number; + stepCount?: number; + engineDetail?: AnyEngineProgressDetail; +} + +async function consumeSSEStream( + response: Response, + handlers: { + onProgress: (data: ProgressEvent) => void; + onResult: (data: AiWorkflowResponse) => void; + onError: (data: { message: string }) => void; + }, +) { + if (!response.body) { + throw new Error("Response body is null"); + } + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + let currentEvent = ""; + + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + + // SSE frames are separated by double newlines + let boundary = buffer.indexOf("\n\n"); + while (boundary !== -1) { + const frame = buffer.slice(0, boundary); + buffer = buffer.slice(boundary + 2); + + let dataPayload = ""; + for (const line of frame.split("\n")) { + if (line.startsWith("event:")) { + currentEvent = line.slice(6).trim(); + } else if (line.startsWith("data:")) { + dataPayload += line.slice(5); + } + } + + if (dataPayload) { + try { + const parsed = JSON.parse(dataPayload); + if (currentEvent === "progress") { + handlers.onProgress(parsed); + } else if (currentEvent === "result") { + handlers.onResult(parsed); + } else if (currentEvent === "error") { + handlers.onError(parsed); + } + } catch { + // Skip malformed JSON frames + } + } + currentEvent = ""; + boundary = buffer.indexOf("\n\n"); + } + } + } finally { + reader.releaseLock(); + } +} + +interface ChatContextValue { + messages: ChatMessage[]; + isOpen: boolean; + isLoading: boolean; + progress: AiWorkflowProgress | null; + toggleOpen: () => void; + setOpen: (open: boolean) => void; + sendMessage: (content: string) => Promise; + /** Abort any in-flight request and reset the chat to an empty conversation. */ + clearChat: () => void; +} + +const ChatContext = createContext(null); + +const initialState: ChatState = { + messages: [], + isOpen: false, + isLoading: false, + progress: null, +}; + +export function ChatProvider({ children }: { children: ReactNode }) { + const { t } = useTranslation(); + const [state, dispatch] = useReducer(chatReducer, initialState); + const { files: activeFiles, fileStubs: activeFileStubs } = useAllFiles(); + const { actions: fileActions } = useFileActions(); + const abortRef = useRef(null); + const messagesRef = useRef(state.messages); + messagesRef.current = state.messages; + + // Download a File from the Stirling files endpoint. + const downloadFile = useCallback( + async (descriptor: AiWorkflowResultFile): Promise => { + const response = await apiClient.get( + `/api/v1/general/files/${descriptor.fileId}`, + { responseType: "blob" }, + ); + return new File([response.data], descriptor.fileName, { + type: descriptor.contentType ?? response.data.type, + }); + }, + [], + ); + + // Import the files produced by an AI workflow result into FileContext. + // + // If the workflow produced the same number of outputs as inputs, map each output to its + // corresponding input as a new version in the same chain. Otherwise (merge, split, etc.) + // add the outputs as new root files. + const importResultFile = useCallback( + async ( + result: AiWorkflowResponse, + sourceStubs: StirlingFileStub[], + ): Promise => { + const descriptors = result.resultFiles?.length + ? result.resultFiles + : result.fileId && result.fileName && result.contentType + ? [ + { + fileId: result.fileId, + fileName: result.fileName, + contentType: result.contentType, + } satisfies AiWorkflowResultFile, + ] + : []; + if (descriptors.length === 0) return; + + const files = await Promise.all(descriptors.map(downloadFile)); + + const operation: ToolOperation = { + toolId: "ai-workflow", + timestamp: Date.now(), + }; + const isVersionMapping = + sourceStubs.length > 0 && files.length === sourceStubs.length; + const stubs = files.map((file, i) => + isVersionMapping + ? createChildStub(sourceStubs[i], operation, file) + : createNewStirlingFileStub(file), + ); + const stirlingFiles = files.map((file, i) => + createStirlingFile(file, stubs[i].id), + ); + + if (sourceStubs.length > 0) { + // Always consume the inputs so merge/split inputs are removed from the workbench. + // For 1:1 operations (rotate, compress) the outputs carry the version chain; for + // merge/split they're fresh roots. + await fileActions.consumeFiles( + sourceStubs.map((s) => s.id), + stirlingFiles, + stubs, + ); + } else { + // No inputs were provided (unlikely for completed workflows, but handle it safely). + await fileActions.addFiles(files, { selectFiles: true }); + } + }, + [fileActions, downloadFile], + ); + + const toggleOpen = useCallback(() => dispatch({ type: "TOGGLE_OPEN" }), []); + const setOpen = useCallback( + (open: boolean) => dispatch({ type: "SET_OPEN", open }), + [], + ); + const clearChat = useCallback(() => { + abortRef.current?.abort(); + abortRef.current = null; + dispatch({ type: "CLEAR" }); + }, []); + + const sendMessage = useCallback( + async (content: string) => { + // Abort any in-flight request + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; + + const priorMessages = messagesRef.current; + + const userMessage: ChatMessage = { + id: generateId(), + role: "user", + content, + timestamp: Date.now(), + }; + dispatch({ type: "ADD_MESSAGE", message: userMessage }); + dispatch({ type: "SET_LOADING", loading: true }); + dispatch({ type: "SET_PROGRESS", progress: null }); + + try { + const formData = new FormData(); + formData.append("userMessage", content); + activeFiles.forEach((file, i) => { + formData.append(`fileInputs[${i}].fileInput`, file); + }); + priorMessages.forEach((message, i) => { + formData.append(`conversationHistory[${i}].role`, message.role); + formData.append(`conversationHistory[${i}].content`, message.content); + }); + + const response = await fetch("/api/v1/ai/orchestrate/stream", { + method: "POST", + body: formData, + headers: getAuthHeaders(), + credentials: "include", + signal: controller.signal, + }); + + if (!response.ok) { + let detail: string | undefined; + try { + const body = await response.json(); + detail = + body?.message ?? + body?.detail ?? + body?.error ?? + (Array.isArray(body?.errors) + ? body.errors[0]?.message + : undefined); + } catch { + // non-JSON body — ignore + } + throw new Error( + detail ?? `AI engine request failed: ${response.status}`, + ); + } + + let receivedResult = false; + const toolsUsed: string[] = []; + + await consumeSSEStream(response, { + onProgress: (data) => { + if ( + data.phase === AiWorkflowPhase.EXECUTING_TOOL && + typeof data.tool === "string" + ) { + toolsUsed.push(data.tool); + } + dispatch({ + type: "SET_PROGRESS", + progress: { + phase: data.phase as AiWorkflowPhase, + tool: data.tool, + stepIndex: data.stepIndex, + stepCount: data.stepCount, + engineDetail: data.engineDetail, + }, + }); + }, + onResult: (data) => { + receivedResult = true; + dispatch({ type: "SET_PROGRESS", progress: null }); + const replyContent = formatWorkflowResponse(data, t); + dispatch({ + type: "ADD_MESSAGE", + message: { + id: generateId(), + role: "assistant", + content: replyContent, + timestamp: Date.now(), + toolsUsed: toolsUsed.length > 0 ? toolsUsed : undefined, + }, + }); + if (data.fileId || data.resultFiles?.length) { + importResultFile(data, activeFileStubs).catch((err) => { + console.error("Failed to import AI result file", err); + dispatch({ + type: "ADD_MESSAGE", + message: { + id: generateId(), + role: "assistant", + content: + "The file was processed but I couldn't download it.", + timestamp: Date.now(), + }, + }); + }); + } + }, + onError: (data) => { + receivedResult = true; + dispatch({ type: "SET_PROGRESS", progress: null }); + dispatch({ + type: "ADD_MESSAGE", + message: { + id: generateId(), + role: "assistant", + content: data.message || "Something went wrong.", + timestamp: Date.now(), + }, + }); + }, + }); + + if (!receivedResult) { + throw new Error("Stream ended without a result"); + } + } catch (e) { + if ((e as Error).name === "AbortError") return; + const err = e as Error; + const isEngineError = + err.message.startsWith("AI engine request failed:") || + err.message === "Stream ended without a result"; + const content = isEngineError + ? "Failed to get a response. The AI engine may not be available yet." + : (err.message ?? + "Failed to get a response. The AI engine may not be available yet."); + dispatch({ type: "SET_PROGRESS", progress: null }); + dispatch({ + type: "ADD_MESSAGE", + message: { + id: generateId(), + role: "assistant", + content, + timestamp: Date.now(), + }, + }); + } finally { + dispatch({ type: "SET_LOADING", loading: false }); + if (abortRef.current === controller) { + abortRef.current = null; + } + } + }, + [activeFiles, activeFileStubs, importResultFile], + ); + + return ( + + {children} + + ); +} + +export function useChat(): ChatContextValue { + const context = useContext(ChatContext); + if (!context) { + throw new Error("useChat must be used within a ChatProvider"); + } + return context; +} diff --git a/frontend/editor/src/proprietary/components/chat/ChatPanel.css b/frontend/editor/src/proprietary/components/chat/ChatPanel.css new file mode 100644 index 000000000..665b7a8c2 --- /dev/null +++ b/frontend/editor/src/proprietary/components/chat/ChatPanel.css @@ -0,0 +1,309 @@ +.chat-panel { + display: flex; + flex-direction: column; + /* Match the right-rail toolbar background so dark-mode doesn't show a + lighter card behind the chat, and so the agent-card → pill morph + doesn't flash a different colour mid-transition. */ + background: var(--bg-toolbar); +} + +.chat-panel--embedded { + height: 100%; + width: 100%; +} + +/* Top header: agent pill + close. */ +.chat-panel__header { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 1rem 1rem 0.75rem; + flex-shrink: 0; +} + +.chat-panel__agent-pill { + display: inline-flex; + align-items: center; + gap: 0.5rem; + flex: 1; + min-width: 0; + padding: 0.4rem 0.75rem 0.4rem 0.4rem; + border: 1px solid var(--border-subtle); + border-radius: 9999px; + background: var(--mantine-color-body); + cursor: pointer; + transition: + background 120ms ease-out, + border-color 120ms ease-out; + /* Pair with .agent-button to morph between the card and the pill. */ + view-transition-name: stirling-agent; +} + +/* Match the active-tool morph timing for a consistent rail feel. */ +::view-transition-group(stirling-agent), +::view-transition-old(stirling-agent), +::view-transition-new(stirling-agent) { + animation-duration: 220ms; + animation-timing-function: cubic-bezier(0.32, 0.72, 0, 1); +} + +.chat-panel__agent-pill:hover { + background: var(--mantine-color-default-hover); +} + +.chat-panel__agent-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; +} + +/* Dark-mode: let the pill blend into the rail. Just a thin border, no fill — + so it doesn't read as a clashing lighter card on the dark toolbar. */ +[data-mantine-color-scheme="dark"] .chat-panel__agent-pill { + background: transparent; + border-color: var(--border-subtle); +} + +[data-mantine-color-scheme="dark"] .chat-panel__agent-pill:hover { + background: rgba(255, 255, 255, 0.04); +} + +[data-mantine-color-scheme="dark"] .chat-panel__agent-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)); +} + +/* Same treatment for the input pill and quick-action cards. */ +[data-mantine-color-scheme="dark"] .chat-panel-input { + background: transparent; + box-shadow: + 0 0 0 1px var(--border-subtle), + 0 6px 16px rgba(0, 0, 0, 0.25); +} + +[data-mantine-color-scheme="dark"] .chat-panel-input:focus-within { + box-shadow: + 0 0 0 1px color-mix(in srgb, var(--mantine-color-blue-3) 40%, transparent), + 0 8px 22px color-mix(in srgb, var(--mantine-color-blue-6) 18%, transparent); +} + +[data-mantine-color-scheme="dark"] .chat-quick-action { + background: transparent; +} + +[data-mantine-color-scheme="dark"] .chat-quick-action:hover { + background: rgba(255, 255, 255, 0.04); + border-color: var(--border-subtle); +} + +.chat-panel__agent-pill-label { + font-size: 0.95rem; + font-weight: 600; + flex: 1; + text-align: left; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.chat-panel-messages { + flex: 1; + min-height: 0; +} + +/* Quick action cards above the input. */ +.chat-panel__quick-actions { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 0.75rem 0.75rem 0.25rem; + flex-shrink: 0; +} + +.chat-panel__quick-actions-label { + font-size: 0.95rem; + font-weight: 600; + margin-top: 0.25rem; + margin-bottom: 0.1rem; +} + +/* File pills shown above the quick actions when files are loaded. */ +.chat-file-pills { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + align-items: center; +} + +.chat-file-pill { + display: inline-flex; + align-items: center; + gap: 0.3rem; + max-width: 11rem; + padding: 0.2rem 0.35rem 0.2rem 0.45rem; + border: 1px solid var(--mantine-color-blue-light); + border-radius: 9999px; + background: var(--mantine-color-blue-light); + color: var(--mantine-color-blue-filled); + font-size: 0.8rem; + font-weight: 500; + line-height: 1.1; +} + +.chat-file-pill__icon { + flex-shrink: 0; +} + +.chat-file-pill__label { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.chat-file-pill__remove { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1rem; + height: 1rem; + padding: 0; + border: none; + border-radius: 9999px; + background: transparent; + color: inherit; + cursor: pointer; + opacity: 0.7; + transition: + background 120ms ease-out, + opacity 120ms ease-out; +} + +.chat-file-pill__remove:hover { + background: var(--mantine-color-default-hover); + opacity: 1; +} + +.chat-file-pill--more { + background: transparent; + border: 1px dashed var(--border-subtle); + color: var(--text-muted); + cursor: pointer; + padding: 0.2rem 0.55rem; +} + +.chat-file-pill--more:hover { + background: var(--mantine-color-default-hover); +} + +.chat-quick-action { + display: block; + width: 100%; + padding: 0.6rem 0.75rem; + border: 1px solid var(--border-subtle); + border-radius: 0.65rem; + background: var(--mantine-color-body); + transition: + background 120ms ease-out, + border-color 120ms ease-out; +} + +.chat-quick-action:hover { + background: var(--mantine-color-default-hover); + border-color: var(--mantine-color-default-border); +} + +.chat-quick-action__icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + border-radius: 0.5rem; + background: var(--mantine-color-blue-light); + color: var(--mantine-color-blue-filled); + flex-shrink: 0; +} + +/* Input area: textarea on top row, action icons on bottom row. */ +.chat-panel-input { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 0.85rem 1rem 0.75rem; + margin: 0.75rem 0.75rem 1rem; + border: none; + border-radius: 1.1rem; + background: var(--mantine-color-body); + flex-shrink: 0; + box-shadow: + 0 0 0 1px rgba(0, 0, 0, 0.04), + 0 4px 14px rgba(15, 23, 42, 0.08); + transition: box-shadow 160ms ease-out; +} + +.chat-panel-input:focus-within { + box-shadow: + 0 0 0 1px color-mix(in srgb, var(--mantine-color-blue-6) 20%, transparent), + 0 6px 18px color-mix(in srgb, var(--mantine-color-blue-6) 12%, transparent); +} + +/* Kill the Mantine Textarea's own border/outline — the wrapper owns the chrome. */ +.chat-panel-input__field, +.chat-panel-input__field:focus, +.chat-panel-input__field:focus-visible { + font-size: 0.95rem; + padding: 0.15rem 0 !important; + background: transparent !important; + border: none !important; + outline: none !important; + box-shadow: none !important; +} + +.chat-panel-input__actions { + display: flex; + align-items: center; + justify-content: space-between; +} + +/* Message layout */ +.chat-message { + display: flex; +} + +.chat-message-user { + justify-content: flex-end; +} + +.chat-message-assistant { + justify-content: flex-start; +} + +/* Bubble styling */ +.chat-bubble { + max-width: 85%; +} + +.chat-bubble-user { + background: var(--mantine-color-blue-filled) !important; + color: white !important; +} + +.chat-bubble-user * { + color: white !important; +} + +.chat-bubble-assistant { + background: var(--mantine-color-default-hover) !important; +} diff --git a/frontend/editor/src/proprietary/components/chat/ChatPanel.tsx b/frontend/editor/src/proprietary/components/chat/ChatPanel.tsx new file mode 100644 index 000000000..6cb617b04 --- /dev/null +++ b/frontend/editor/src/proprietary/components/chat/ChatPanel.tsx @@ -0,0 +1,359 @@ +import { + useMemo, + useRef, + useEffect, + useState, + type KeyboardEvent, +} from "react"; +import { useTranslation } from "react-i18next"; +import { + ActionIcon, + Box, + Collapse, + Group, + List, + Loader, + Menu, + Paper, + ScrollArea, + Stack, + Text, + Textarea, + UnstyledButton, +} from "@mantine/core"; +import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward"; +import CloseIcon from "@mui/icons-material/Close"; +import DeleteSweepIcon from "@mui/icons-material/DeleteSweep"; +import ExpandLessIcon from "@mui/icons-material/ExpandLess"; +import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; +import { + useChat, + AiWorkflowPhase, + isKnownEngineProgressDetail, + type AiWorkflowProgress, + type AnyEngineProgressDetail, +} from "@app/components/chat/ChatContext"; +import { useTranslatedToolCatalog } from "@app/data/useTranslatedToolRegistry"; +import { StirlingLogoOutline } from "@app/components/agents/StirlingLogoOutline"; +import { ChatQuickActions } from "@app/components/chat/ChatQuickActions"; +import "@app/components/chat/ChatPanel.css"; + +type TranslateFn = (key: string, options?: Record) => string; + +/** Resolver mapping a tool endpoint path to its translated display name. */ +type ToolNameResolver = (endpoint: string) => string | null; + +/** + * Look up a tool's translated name from the tool catalog. The catalog's {@code operationConfig} + * exposes the full API endpoint path for each tool, so we key the lookup on the exact path that + * arrives in SSE progress events — no string parsing. + */ +function useToolNameResolver(): ToolNameResolver { + const { allTools } = useTranslatedToolCatalog(); + return useMemo(() => { + const nameByEndpoint = new Map(); + Object.values(allTools).forEach((tool) => { + const endpoint = tool.operationConfig?.endpoint; + if (typeof endpoint === "string") { + nameByEndpoint.set(endpoint, tool.name); + } + }); + return (endpoint: string) => nameByEndpoint.get(endpoint) ?? null; + }, [allTools]); +} + +function formatProgress( + progress: AiWorkflowProgress, + t: TranslateFn, + resolveToolName: ToolNameResolver, +): string { + if (progress.phase === AiWorkflowPhase.EXECUTING_TOOL && progress.tool) { + const tool = resolveToolName(progress.tool); + const hasSteps = + progress.stepIndex != null && + progress.stepCount != null && + progress.stepCount > 1; + if (tool) { + return hasSteps + ? t("chat.progress.executing_tool_step", { + tool, + step: progress.stepIndex, + total: progress.stepCount, + }) + : t("chat.progress.executing_tool_single", { tool }); + } + return hasSteps + ? t("chat.progress.executing_tool_generic_step", { + step: progress.stepIndex, + total: progress.stepCount, + }) + : t("chat.progress.executing_tool_generic"); + } + if (progress.phase === AiWorkflowPhase.ENGINE_PROGRESS) { + return formatEngineProgress(progress.engineDetail, t); + } + return t(`chat.progress.${progress.phase}`); +} + +function formatEngineProgress( + detail: AnyEngineProgressDetail | undefined, + t: TranslateFn, +): string { + if (!detail || !isKnownEngineProgressDetail(detail)) { + return t("chat.progress.processing"); + } + switch (detail.phase) { + case "whole_doc_read_started": + return t("chat.progress.whole_doc_read_started"); + case "whole_doc_slice_done": { + const percent = + detail.total > 0 + ? Math.round((detail.completed / detail.total) * 100) + : 0; + return t("chat.progress.whole_doc_slice_done", { percent }); + } + case "whole_doc_compression_round": + return t("chat.progress.whole_doc_compression_round"); + case "whole_doc_read_done": + return t("chat.progress.whole_doc_read_done"); + } +} + +function ToolsUsedBlock({ + tools, + resolveToolName, + t, +}: { + tools: string[]; + resolveToolName: ToolNameResolver; + t: TranslateFn; +}) { + const [expanded, setExpanded] = useState(false); + const names = tools.map( + (endpoint) => resolveToolName(endpoint) ?? t("chat.toolsUsed.unknownTool"), + ); + const label = t("chat.toolsUsed.summary", { count: tools.length }); + return ( + + setExpanded((v) => !v)} + aria-expanded={expanded} + > + + {expanded ? ( + + ) : ( + + )} + + {label} + + + + + + {names.map((name, i) => ( + {name} + ))} + + + + ); +} + +function ChatMessageBubble({ + role, + content, + toolsUsed, + resolveToolName, + t, +}: { + role: "user" | "assistant"; + content: string; + toolsUsed?: string[]; + resolveToolName: ToolNameResolver; + t: TranslateFn; +}) { + return ( +
+ + + {content} + + {toolsUsed && toolsUsed.length > 0 && ( + + )} + +
+ ); +} + +export interface ChatPanelProps { + /** Called when the user closes the chat to return to the tool list. */ + onBack: () => void; + /** Accessible label for the close button. */ + backLabel: string; +} + +export function ChatPanel({ onBack, backLabel }: ChatPanelProps) { + const { t } = useTranslation(); + const { messages, isLoading, progress, sendMessage, clearChat } = useChat(); + const resolveToolName = useToolNameResolver(); + const [input, setInput] = useState(""); + const scrollRef = useRef(null); + const inputRef = useRef(null); + + useEffect(() => { + if (scrollRef.current) { + scrollRef.current.scrollTo({ + top: scrollRef.current.scrollHeight, + behavior: "smooth", + }); + } + }, [messages]); + + useEffect(() => { + inputRef.current?.focus(); + }, []); + + const handleSend = (override?: string) => { + const text = (override ?? input).trim(); + if (!text || isLoading) return; + setInput(""); + sendMessage(text); + }; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }; + + const showQuickActions = messages.length === 0 && !isLoading; + + return ( + +
+ + + + + + } + onClick={clearChat} + disabled={messages.length === 0 && !isLoading} + > + {t("chat.header.clearChat", "Clear chat")} + + + + + + +
+ + + + {messages.map((msg) => ( + + ))} + {isLoading && ( +
+ + + + + {progress + ? formatProgress(progress, t, resolveToolName) + : t("chat.progress.thinking")} + + + +
+ )} +
+
+ + {showQuickActions && ( + handleSend(text)} + /> + )} + +
+