From 18cd2baec0ae10d7ff193ee2f4a4586bed5c9f3f Mon Sep 17 00:00:00 2001 From: EthanHealy01 Date: Wed, 27 May 2026 11:14:19 +0100 Subject: [PATCH] feat: add Agents UI to proprietary right sidebar Adds agents panel to the right sidebar, gated behind aiEngineEnabled. Includes RightSidebar refactor, FullscreenToolPanel, core stubs, and proprietary AgentsPanel with Stirling hero CTA and coming-soon agents. --- .gitignore | 6 +- Taskfile.yml | 2 +- .../controller/api/misc/ConfigController.java | 3 + .../public/locales/en-GB/translation.toml | 46 ++ .../core/components/agents/AgentsPanel.tsx | 53 ++ .../components/tools/FullscreenToolPanel.tsx | 102 +++ .../tools/FullscreenToolSurface.tsx | 40 +- .../core/components/tools/RightSidebar.tsx | 333 ++++++++++ .../src/core/components/tools/ToolPanel.css | 213 +++++- .../src/core/components/tools/ToolPanel.tsx | 340 ++-------- .../src/core/components/tools/ToolPicker.tsx | 66 +- .../tools/toolPicker/ToolButton.tsx | 19 +- .../tools/toolPicker/ToolPicker.css | 43 ++ .../toolWorkflow/toolWorkflowState.ts | 8 +- .../core/hooks/tools/useToolPanelGeometry.ts | 51 +- frontend/editor/src/core/pages/HomePage.tsx | 6 +- frontend/editor/src/core/styles/theme.css | 14 + .../src/core/tests/stubbed/navigation.spec.ts | 4 +- .../tests/stubbed/tool-pages-common.spec.ts | 4 +- frontend/editor/src/core/types/appConfig.ts | 1 + .../editor/src/core/utils/viewTransition.ts | 26 + .../proprietary/components/AppProviders.tsx | 3 +- .../components/agents/AgentsPanel.css | 399 +++++++++++ .../components/agents/AgentsPanel.tsx | 290 ++++++++ .../components/agents/StirlingLogoOutline.tsx | 21 + .../components/chat/ChatContext.tsx | 572 ++++++++++++++++ .../proprietary/components/chat/ChatPanel.css | 309 +++++++++ .../proprietary/components/chat/ChatPanel.tsx | 359 ++++++++++ .../components/chat/ChatQuickActions.tsx | 303 +++++++++ .../data/useProprietaryToolRegistry.tsx | 5 +- .../proprietary/types/proprietaryToolId.ts | 6 +- .../prototypes/components/AppProviders.tsx | 20 - .../prototypes/components/chat/ChatPanel.css | 77 --- .../components/home/HomePageExtensions.tsx | 5 - .../src/assets/material-symbols-icons.d.ts | 16 + .../src/assets/material-symbols-icons.json | 622 ++++++++++++++++++ 36 files changed, 3929 insertions(+), 458 deletions(-) create mode 100644 frontend/editor/src/core/components/agents/AgentsPanel.tsx create mode 100644 frontend/editor/src/core/components/tools/FullscreenToolPanel.tsx create mode 100644 frontend/editor/src/core/components/tools/RightSidebar.tsx create mode 100644 frontend/editor/src/core/utils/viewTransition.ts create mode 100644 frontend/editor/src/proprietary/components/agents/AgentsPanel.css create mode 100644 frontend/editor/src/proprietary/components/agents/AgentsPanel.tsx create mode 100644 frontend/editor/src/proprietary/components/agents/StirlingLogoOutline.tsx create mode 100644 frontend/editor/src/proprietary/components/chat/ChatContext.tsx create mode 100644 frontend/editor/src/proprietary/components/chat/ChatPanel.css create mode 100644 frontend/editor/src/proprietary/components/chat/ChatPanel.tsx create mode 100644 frontend/editor/src/proprietary/components/chat/ChatQuickActions.tsx delete mode 100644 frontend/editor/src/prototypes/components/AppProviders.tsx delete mode 100644 frontend/editor/src/prototypes/components/chat/ChatPanel.css delete mode 100644 frontend/editor/src/prototypes/components/home/HomePageExtensions.tsx create mode 100644 frontend/src/assets/material-symbols-icons.d.ts create mode 100644 frontend/src/assets/material-symbols-icons.json diff --git a/.gitignore b/.gitignore index 40be551559..bb19e3113c 100644 --- a/.gitignore +++ b/.gitignore @@ -55,6 +55,10 @@ app/core/src/main/resources/static/pdfjs/ app/core/src/main/resources/static/vendor/ # Note: Keep backend-managed files like fonts/, css/, js/, pdfjs/, etc. +# Tauri build artifacts +frontend/src-tauri/target/ +frontend/editor/src-tauri/target/ + # Gradle .gradle .gradle-home @@ -157,7 +161,7 @@ app/proprietary/build common/build proprietary/build stirling-pdf/build -frontend/editor/src-tauri/provisioner/target +frontend/src-tauri/provisioner/target # Byte-compiled / optimized / DLL files __pycache__/ diff --git a/Taskfile.yml b/Taskfile.yml index 7edf1e9b41..e2f2825f62 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -74,7 +74,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 1ea0ddd0aa..0a5709befe 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 @@ -277,6 +277,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/frontend/editor/public/locales/en-GB/translation.toml b/frontend/editor/public/locales/en-GB/translation.toml index 5ae0cfc435..7236d05905 100644 --- a/frontend/editor/public/locales/en-GB/translation.toml +++ b/frontend/editor/public/locales/en-GB/translation.toml @@ -2672,6 +2672,47 @@ 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" + +[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..." @@ -7910,6 +7951,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 0000000000..282b4639b3 --- /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 0000000000..68a0bb3a0f --- /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 264f00c35c..e1ad45d46a 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 0000000000..5e5b7e0fcd --- /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 0e52f93abd..592c837715 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 e431a24a00..2d6006a8eb 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 7fb01f89e2..96f826f49f 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 a0c01c6449..fbea209842 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 54551d8105..338afb095a 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 bb7193d3e8..2c55315ccb 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/useToolPanelGeometry.ts b/frontend/editor/src/core/hooks/tools/useToolPanelGeometry.ts index 818057b996..41a2b2f761 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 1a32ebe770..2af7506b1b 100644 --- a/frontend/editor/src/core/pages/HomePage.tsx +++ b/frontend/editor/src/core/pages/HomePage.tsx @@ -18,7 +18,7 @@ import { useViewer } from "@app/contexts/ViewerContext"; import { useLocation } from "react-router-dom"; import AppsIcon from "@mui/icons-material/AppsRounded"; -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"; @@ -290,7 +290,7 @@ export default function HomePage() { aria-label={t("home.mobile.toolsSlide", "Tool selection panel")} >
- +
setConfigModalOpen(true)} /> - {!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 250bff4474..ae2e4b4e6d 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/types/appConfig.ts b/frontend/editor/src/core/types/appConfig.ts index e72dc8e448..6a99d61f39 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/utils/viewTransition.ts b/frontend/editor/src/core/utils/viewTransition.ts new file mode 100644 index 0000000000..049c3e7673 --- /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/proprietary/components/AppProviders.tsx b/frontend/editor/src/proprietary/components/AppProviders.tsx index db10725f2a..06b5ddb692 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 0000000000..7a531012eb --- /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 0000000000..d71c54a076 --- /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; + name: string; + description: string; + Icon: ElementType; +} + +const COMING_SOON_AGENTS: ComingSoonAgent[] = [ + { + id: "data-extraction", + name: "Data Extraction", + description: "Extract tables & structured data", + Icon: TableChartRoundedIcon, + }, + { + id: "doc-summary", + name: "Summariser", + description: "Summarise long documents", + Icon: SummarizeRoundedIcon, + }, + { + id: "auto-redaction", + name: "Auto Redaction", + description: "Redact PII automatically", + Icon: VisibilityOffRoundedIcon, + }, + { + id: "compliance", + name: "Compliance Check", + description: "Audit documents for compliance", + Icon: GavelRoundedIcon, + }, + { + id: "form-filler", + name: "Form Filler", + description: "Fill PDF forms intelligently", + Icon: AssignmentRoundedIcon, + }, + { + id: "pdf-to-markdown", + name: "PDF to Markdown", + description: "Convert PDFs to clean Markdown", + 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, name, description, Icon }) => ( + + + + + + + + + {name} + + + {description} + + + + + + ))} +
+ + {!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, name, description, 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 0000000000..c52b40df24 --- /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 0000000000..e38cd8c6a8 --- /dev/null +++ b/frontend/editor/src/proprietary/components/chat/ChatContext.tsx @@ -0,0 +1,572 @@ +import { + createContext, + useContext, + useReducer, + useCallback, + useRef, + type ReactNode, +} from "react"; +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 }; + } +} + +function formatWorkflowResponse(data: AiWorkflowResponse): string { + switch (data.outcome) { + case "answer": + case "completed": + return data.answer ?? data.summary ?? "Done."; + case "need_clarification": + return data.question ?? "Could you clarify your request?"; + case "cannot_do": + return data.reason ?? "I'm unable to do that."; + case "not_found": + return data.reason ?? "I couldn't find the requested information."; + case "unsupported_capability": + return ( + data.message ?? + `Unsupported capability: ${data.capability ?? "unknown"}` + ); + case "cannot_continue": + return data.reason ?? "Something went wrong and I can't 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 ?? `Processing (${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 = ""; + + 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"); + } + } +} + +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 [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: crypto.randomUUID(), + 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) { + throw new Error(`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); + dispatch({ + type: "ADD_MESSAGE", + message: { + id: crypto.randomUUID(), + 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: crypto.randomUUID(), + 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: crypto.randomUUID(), + 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; + dispatch({ type: "SET_PROGRESS", progress: null }); + dispatch({ + type: "ADD_MESSAGE", + message: { + id: crypto.randomUUID(), + role: "assistant", + content: + "Failed to get a response. The AI engine may not be available yet.", + 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 0000000000..665b7a8c28 --- /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 0000000000..6cb617b045 --- /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)} + /> + )} + +
+