diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/AiWorkflowRequest.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/AiWorkflowRequest.java index e574f84b8..ad4c994c0 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/AiWorkflowRequest.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/AiWorkflowRequest.java @@ -6,7 +6,6 @@ import java.util.List; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; import lombok.Data; @@ -14,9 +13,8 @@ import lombok.Data; @Schema(description = "Run an AI workflow") public class AiWorkflowRequest { - @NotNull @Schema(description = "The input PDF files") - private List fileInputs; + private List fileInputs = new ArrayList<>(); @NotBlank @Schema(description = "The user message to orchestrate", example = "Summarise these documents") diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/AiWorkflowService.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/AiWorkflowService.java index 8f49694d8..95f8afcea 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/service/AiWorkflowService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/AiWorkflowService.java @@ -186,10 +186,7 @@ public class AiWorkflowService { WorkflowTurnRequest initialRequest = new WorkflowTurnRequest(); initialRequest.setUserMessage(request.getUserMessage().trim()); initialRequest.setFiles(files); - initialRequest.setConversationHistory( - request.getConversationHistory() == null - ? new ArrayList<>() - : new ArrayList<>(request.getConversationHistory())); + initialRequest.setConversationHistory(new ArrayList<>(request.getConversationHistory())); initialRequest.setEnabledEndpoints(endpointResolver.getEnabledEndpointUrls()); listener.onProgress(AiWorkflowProgressEvent.of(AiWorkflowPhase.ANALYZING)); @@ -232,6 +229,12 @@ public class AiWorkflowService { WorkflowTurnRequest request, ProgressListener listener) throws IOException { + if (filesById.isEmpty()) { + return new WorkflowState.Terminal( + cannotContinue( + "No files were uploaded. Please add a PDF to the workbench first.")); + } + if (!request.getArtifacts().isEmpty()) { return new WorkflowState.Terminal( cannotContinue("AI engine requested content extraction more than once.")); diff --git a/frontend/editor/public/locales/en-GB/translation.toml b/frontend/editor/public/locales/en-GB/translation.toml index 94b09b446..d2aa0300d 100644 --- a/frontend/editor/public/locales/en-GB/translation.toml +++ b/frontend/editor/public/locales/en-GB/translation.toml @@ -1526,6 +1526,7 @@ section_title = "Agents" show_less = "Show less" start_chat = "Start chatting" stirling_description = "Your general-purpose PDF assistant" +stirling_running = "Running..." stirling_full_name = "Stirling General Agent" stirling_long_description = "General purpose PDF assistant that can run tools, create PDFs and extract insights from your documents." stirling_name = "Stirling" @@ -2715,6 +2716,12 @@ title = "Change Permissions" [changePermissions.tooltip.warning] text = "To make these permissions unchangeable, use the Add Password tool to set an owner password." +[chat] +resize = "Resize chat panel" + +[chat.actions] +copy = "Copy message" + [chat.header] agentMenu = "Stirling agent options" clearChat = "Clear chat" diff --git a/frontend/editor/src/core/components/agents/AgentsPanel.tsx b/frontend/editor/src/core/components/agents/AgentsPanel.tsx index 282b4639b..d5aeee493 100644 --- a/frontend/editor/src/core/components/agents/AgentsPanel.tsx +++ b/frontend/editor/src/core/components/agents/AgentsPanel.tsx @@ -13,10 +13,8 @@ export function useAgentsEnabled(): boolean { } /** - * Whether the agent chat overlay is currently open. Core builds have no chat, + * Whether the agent chat panel 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; @@ -35,14 +33,6 @@ 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). diff --git a/frontend/editor/src/core/components/chat/ChatContext.tsx b/frontend/editor/src/core/components/chat/ChatContext.tsx new file mode 100644 index 000000000..3ef7276fb --- /dev/null +++ b/frontend/editor/src/core/components/chat/ChatContext.tsx @@ -0,0 +1,18 @@ +/** + * Core stub for the chat context. + * The real implementation lives in proprietary/components/chat/ChatContext.tsx + * and shadows this via the @app/* alias cascade in proprietary builds. + */ + +export function useChat() { + return { + messages: [] as never[], + isOpen: false, + isLoading: false, + progress: null, + toggleOpen: () => {}, + setOpen: (_open: boolean) => {}, + sendMessage: async (_content: string) => {}, + clearChat: () => {}, + }; +} diff --git a/frontend/editor/src/core/components/chat/ChatPanel.tsx b/frontend/editor/src/core/components/chat/ChatPanel.tsx new file mode 100644 index 000000000..0d85c23fc --- /dev/null +++ b/frontend/editor/src/core/components/chat/ChatPanel.tsx @@ -0,0 +1,14 @@ +/** + * Core stub for the chat panel. + * The real implementation lives in proprietary/components/chat/ChatPanel.tsx + * and shadows this via the @app/* alias cascade in proprietary builds. + */ + +export interface ChatPanelProps { + onBack: () => void; + backLabel: string; +} + +export function ChatPanel(_props: ChatPanelProps) { + return null; +} diff --git a/frontend/editor/src/core/components/tools/RightSidebar.tsx b/frontend/editor/src/core/components/tools/RightSidebar.tsx index 5e5b7e0fc..b5a5a22b0 100644 --- a/frontend/editor/src/core/components/tools/RightSidebar.tsx +++ b/frontend/editor/src/core/components/tools/RightSidebar.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from "react"; +import { useMemo, useState, useRef, useCallback } from "react"; import { ActionIcon } from "@mantine/core"; import { useTranslation } from "react-i18next"; import { useRainbowThemeContext } from "@app/components/shared/RainbowThemeProvider"; @@ -9,11 +9,12 @@ 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 { useChat } from "@app/components/chat/ChatContext"; +import { ChatPanel } from "@app/components/chat/ChatPanel"; import { useFavoriteToolItems } from "@app/hooks/tools/useFavoriteToolItems"; import { useToolSections } from "@app/hooks/useToolSections"; import type { SubcategoryGroup } from "@app/hooks/useToolSections"; @@ -32,6 +33,10 @@ import { import { useToolPanelGeometry } from "@app/hooks/tools/useToolPanelGeometry"; import "@app/components/tools/ToolPanel.css"; +const DEFAULT_CHAT_WIDTH_PX = 18.5 * 16; // 18.5rem in px +const MIN_CHAT_WIDTH_PX = 240; +const MAX_CHAT_WIDTH_PX = 720; + /** * Right-side rail wrapping the tool panel. * @@ -86,6 +91,57 @@ export default function RightSidebar() { const [allToolsView, setAllToolsView] = useState(false); + const { isOpen: isChatOpen, setOpen: setChatOpen } = useChat(); + const [chatWidthPx, setChatWidthPx] = useState(DEFAULT_CHAT_WIDTH_PX); + const [isChatDragging, setIsChatDragging] = useState(false); + const chatDragState = useRef<{ startX: number; startWidth: number } | null>( + null, + ); + + const handleChatClose = useCallback(() => { + withViewTransition(() => setChatOpen(false)); + setChatWidthPx(DEFAULT_CHAT_WIDTH_PX); + }, [setChatOpen]); + + const handleResizeChatPointerDown = useCallback( + (e: React.PointerEvent) => { + e.preventDefault(); + chatDragState.current = { startX: e.clientX, startWidth: chatWidthPx }; + setIsChatDragging(true); + document.body.style.cursor = "col-resize"; + document.body.style.userSelect = "none"; + + const onMove = (ev: PointerEvent) => { + if (!chatDragState.current) return; + const delta = chatDragState.current.startX - ev.clientX; + setChatWidthPx( + Math.max( + MIN_CHAT_WIDTH_PX, + Math.min( + MAX_CHAT_WIDTH_PX, + chatDragState.current.startWidth + delta, + ), + ), + ); + }; + + const cleanup = () => { + chatDragState.current = null; + setIsChatDragging(false); + document.body.style.removeProperty("cursor"); + document.body.style.removeProperty("user-select"); + window.removeEventListener("pointermove", onMove); + window.removeEventListener("pointerup", cleanup); + window.removeEventListener("pointercancel", cleanup); + }; + + window.addEventListener("pointermove", onMove); + window.addEventListener("pointerup", cleanup); + window.addEventListener("pointercancel", cleanup); + }, + [chatWidthPx], + ); + const handleShowAllTools = () => { withViewTransition(() => setAllToolsView(true)); }; @@ -147,6 +203,7 @@ export default function RightSidebar() { const computedWidth = () => { if (isMobile) return "100%"; + if (isChatOpen) return `${chatWidthPx}px`; if (!isPanelVisible) return "3.5rem"; return "18.5rem"; }; @@ -182,15 +239,39 @@ export default function RightSidebar() { ref={toolPanelRef} data-sidebar="tool-panel" data-tour={fullscreenExpanded ? undefined : "tool-panel"} - className={`tool-panel flex flex-col ${fullscreenExpanded ? "tool-panel--fullscreen-active" : "overflow-hidden"} bg-[var(--bg-toolbar)] border-l border-[var(--border-subtle)] transition-all duration-300 ease-out ${ + className={`tool-panel flex flex-col ${fullscreenExpanded ? "tool-panel--fullscreen-active" : isChatOpen ? "" : "overflow-hidden"} bg-[var(--bg-toolbar)] border-l border-[var(--border-subtle)] transition-all duration-300 ease-out ${ isRainbowMode ? rainbowStyles.rainbowPaper : "" } ${isMobile ? "h-full border-r-0" : "h-screen"} ${fullscreenExpanded ? "tool-panel--fullscreen" : ""}`} style={{ width: computedWidth(), padding: "0", + ...(isChatDragging ? { transition: "none" } : {}), }} > - {!fullscreenExpanded && !isPanelVisible && !isMobile && ( + {!fullscreenExpanded && isChatOpen && ( +
+
+ +
+ )} + + {!fullscreenExpanded && !isChatOpen && !isPanelVisible && !isMobile && (
)} - {!fullscreenExpanded && isPanelVisible && ( + {!fullscreenExpanded && !isChatOpen && isPanelVisible && (
-
)} diff --git a/frontend/editor/src/core/utils/timeUtils.ts b/frontend/editor/src/core/utils/timeUtils.ts new file mode 100644 index 000000000..7bca31100 --- /dev/null +++ b/frontend/editor/src/core/utils/timeUtils.ts @@ -0,0 +1,10 @@ +export function formatRelativeTime(timestamp: number): string { + const diff = Date.now() - timestamp; + const mins = Math.floor(diff / 60_000); + if (mins < 1) return "just now"; + if (mins < 60) return `${mins}m ago`; + const hours = Math.floor(mins / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + return `${days}d ago`; +} diff --git a/frontend/editor/src/proprietary/components/agents/AgentsPanel.css b/frontend/editor/src/proprietary/components/agents/AgentsPanel.css index 7a531012e..864c9527d 100644 --- a/frontend/editor/src/proprietary/components/agents/AgentsPanel.css +++ b/frontend/editor/src/proprietary/components/agents/AgentsPanel.css @@ -1,15 +1,49 @@ /* ─── Chat takeover ─────────────────────────────────────────────────────── */ .agents-takeover { - position: absolute; - inset: 0; + position: fixed; + right: 0; + top: 0; + bottom: 0; display: flex; flex-direction: column; background: var(--bg-toolbar); - z-index: 5; + border-left: 1px solid var(--border-subtle); view-transition-name: agents-rail; } +.agents-takeover__resize-handle { + position: absolute; + left: -3px; + top: 0; + bottom: 0; + width: 7px; + cursor: col-resize; + z-index: 10; + touch-action: none; +} + +.agents-takeover__resize-handle::after { + content: ""; + position: absolute; + left: 3px; + top: 0; + bottom: 0; + width: 1px; + background: transparent; + transition: + background 150ms ease, + width 150ms ease, + left 150ms ease; +} + +.agents-takeover__resize-handle:hover::after { + left: 2px; + width: 3px; + background: var(--mantine-color-blue-4); + opacity: 0.6; +} + /* ─── Sidebar agents section ─────────────────────────────────────────────── */ .agents-section { @@ -67,6 +101,7 @@ justify-content: center; color: var(--mantine-color-blue-filled); flex-shrink: 0; + position: relative; } /* Coming-soon agent rows */ @@ -205,12 +240,61 @@ color: var(--mantine-color-blue-filled); cursor: pointer; transition: background 120ms ease-out; + position: relative; } .agents-collapsed-btn:hover { background: var(--mantine-color-default-hover); } +/* ─── Running / in-progress status dot ──────────────────────────────────── */ + +.agent-status-dot { + position: absolute; + bottom: 0; + right: 0; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--mantine-color-blue-5); + border: 1.5px solid var(--mantine-color-body); + animation: agent-dot-pulse 2.4s ease-in-out infinite; + pointer-events: none; +} + +@keyframes agent-dot-pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.45; + } +} + +/* Blue outline on hero Stirling button when agent is running */ +.agent-button.agent-button--running { + border-color: color-mix( + in srgb, + var(--mantine-color-blue-5) 60%, + var(--border-subtle) + ); +} + +[data-mantine-color-scheme="dark"] .agent-button.agent-button--running { + border-color: color-mix( + in srgb, + var(--mantine-color-blue-4) 55%, + var(--border-subtle) + ); +} + +@media (prefers-reduced-motion: reduce) { + .agent-status-dot { + animation: none; + } +} + /* ─── Fullscreen hero card ───────────────────────────────────────────────── */ /* diff --git a/frontend/editor/src/proprietary/components/agents/AgentsPanel.tsx b/frontend/editor/src/proprietary/components/agents/AgentsPanel.tsx index adfbff3bb..8799596d9 100644 --- a/frontend/editor/src/proprietary/components/agents/AgentsPanel.tsx +++ b/frontend/editor/src/proprietary/components/agents/AgentsPanel.tsx @@ -10,7 +10,6 @@ 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"; @@ -77,7 +76,7 @@ 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 { isOpen, setOpen, isLoading } = useChat(); const enabled = useAgentsEnabled(); const [showAll, setShowAll] = useState(false); @@ -92,23 +91,26 @@ export function AgentsSection() { {/* Main Stirling agent — real and clickable */} withViewTransition(() => setOpen(true))} aria-label={t("agents.stirling_name", "Stirling")} > + {isLoading && } {t("agents.stirling_name", "Stirling")} - {t( - "agents.stirling_description", - "Your general-purpose PDF assistant", - )} + {isLoading + ? t("agents.stirling_running", "Running...") + : t( + "agents.stirling_description", + "Your general-purpose PDF assistant", + )} @@ -171,7 +173,7 @@ export function AgentsSection() { /** Icon-only agent button in the collapsed (minimised) right rail. */ export function AgentsCollapsedButton({ onExpand }: { onExpand: () => void }) { const { t } = useTranslation(); - const { setOpen } = useChat(); + const { setOpen, isLoading } = useChat(); const enabled = useAgentsEnabled(); if (!enabled) return null; @@ -189,6 +191,7 @@ export function AgentsCollapsedButton({ onExpand }: { onExpand: () => void }) { className="agents-collapsed-btn" > + {isLoading && } ); @@ -270,21 +273,3 @@ export function AgentsFullscreenSection() { ); } - -/** 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/StirlingLogoAnimated.tsx b/frontend/editor/src/proprietary/components/agents/StirlingLogoAnimated.tsx new file mode 100644 index 000000000..85cac7464 --- /dev/null +++ b/frontend/editor/src/proprietary/components/agents/StirlingLogoAnimated.tsx @@ -0,0 +1,23 @@ +import "@app/components/chat/ChatPanel.css"; + +export function StirlingLogoAnimated({ 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 index 074d3d029..6cd473eda 100644 --- a/frontend/editor/src/proprietary/components/chat/ChatContext.tsx +++ b/frontend/editor/src/proprietary/components/chat/ChatContext.tsx @@ -19,9 +19,14 @@ import { } from "@app/types/fileContext"; import type { ToolOperation } from "@app/types/file"; +export enum ChatRole { + USER = "user", + ASSISTANT = "assistant", +} + export interface ChatMessage { id: string; - role: "user" | "assistant"; + role: ChatRole; content: string; timestamp: number; /** @@ -437,7 +442,7 @@ export function ChatProvider({ children }: { children: ReactNode }) { const userMessage: ChatMessage = { id: generateId(), - role: "user", + role: ChatRole.USER, content, timestamp: Date.now(), }; @@ -513,7 +518,7 @@ export function ChatProvider({ children }: { children: ReactNode }) { type: "ADD_MESSAGE", message: { id: generateId(), - role: "assistant", + role: ChatRole.ASSISTANT, content: replyContent, timestamp: Date.now(), toolsUsed: toolsUsed.length > 0 ? toolsUsed : undefined, @@ -526,7 +531,7 @@ export function ChatProvider({ children }: { children: ReactNode }) { type: "ADD_MESSAGE", message: { id: generateId(), - role: "assistant", + role: ChatRole.ASSISTANT, content: "The file was processed but I couldn't download it.", timestamp: Date.now(), @@ -542,7 +547,7 @@ export function ChatProvider({ children }: { children: ReactNode }) { type: "ADD_MESSAGE", message: { id: generateId(), - role: "assistant", + role: ChatRole.ASSISTANT, content: data.message || "Something went wrong.", timestamp: Date.now(), }, @@ -568,7 +573,7 @@ export function ChatProvider({ children }: { children: ReactNode }) { type: "ADD_MESSAGE", message: { id: generateId(), - role: "assistant", + role: ChatRole.ASSISTANT, content, timestamp: Date.now(), }, diff --git a/frontend/editor/src/proprietary/components/chat/ChatPanel.css b/frontend/editor/src/proprietary/components/chat/ChatPanel.css index 665b7a8c2..0176172ce 100644 --- a/frontend/editor/src/proprietary/components/chat/ChatPanel.css +++ b/frontend/editor/src/proprietary/components/chat/ChatPanel.css @@ -61,6 +61,23 @@ background: var(--mantine-color-blue-light); color: var(--mantine-color-blue-filled); flex-shrink: 0; + position: relative; +} + +.chat-panel__agent-pill--loading { + border-color: color-mix( + in srgb, + var(--mantine-color-blue-5) 60%, + var(--border-subtle) + ); +} + +[data-mantine-color-scheme="dark"] .chat-panel__agent-pill--loading { + border-color: color-mix( + in srgb, + var(--mantine-color-blue-4) 55%, + var(--border-subtle) + ); } /* Dark-mode: let the pill blend into the rail. Just a thin border, no fill — @@ -284,15 +301,75 @@ .chat-message-user { justify-content: flex-end; + margin: 0.2rem 0; +} + +.chat-message-user__inner { + display: flex; + flex-direction: column; + align-items: flex-end; + max-width: 85%; } .chat-message-assistant { justify-content: flex-start; } -/* Bubble styling */ +/* Hover action row — copy, like, timestamp */ +.chat-message-actions { + display: flex; + align-items: center; + gap: 0.15rem; + margin-top: 0.3rem; + opacity: 0; + transition: opacity 120ms ease-out; + pointer-events: none; +} + +.chat-message:hover .chat-message-actions, +.chat-message-actions:focus-within { + opacity: 1; + pointer-events: auto; +} + +.chat-message-action-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.5rem; + height: 1.5rem; + padding: 0; + border: none; + border-radius: 0.375rem; + background: transparent; + color: var(--text-muted); + cursor: pointer; + transition: + background 100ms ease-out, + color 100ms ease-out; +} + +.chat-message-action-btn:hover { + background: var(--mantine-color-default-hover); + color: var(--mantine-color-text); +} + +.chat-message-action-btn--active { + color: var(--mantine-color-blue-filled); +} + +.chat-message-timestamp { + font-size: 0.7rem; + color: var(--text-muted); + padding: 0 0.25rem; + white-space: nowrap; +} + +/* Bubble styling — user only */ + .chat-bubble { - max-width: 85%; + width: fit-content; + max-width: 100%; } .chat-bubble-user { @@ -304,6 +381,87 @@ color: white !important; } +/* Assistant messages: no bubble, free-flowing with side padding */ .chat-bubble-assistant { - background: var(--mantine-color-default-hover) !important; + width: 100%; + padding: 0 0.5rem; + font-size: var(--mantine-font-size-sm); +} + +.chat-bubble-assistant p { + margin: 0 0 0.5em; +} + +.chat-bubble-assistant p:last-child { + margin-bottom: 0; +} + +/* Thinking indicator */ +.chat-thinking { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.125rem 0.5rem; + color: var(--mantine-color-blue-filled); +} + +/* ─── Stirling logo thinking animation ─────────────────────────────────── */ + +.stirling-thinking__path-right { + animation: stirling-orbit-right 2s ease-in-out infinite; +} + +.stirling-thinking__path-left { + animation: stirling-orbit-left 2s ease-in-out infinite; +} + +@keyframes stirling-orbit-right { + 0%, + 100% { + transform: translate(0, 0); + opacity: 0.5; + } + 25% { + transform: translate(-1px, -5px); + opacity: 0.45; + } + 50% { + transform: translate(-6px, 0); + opacity: 0.9; + } + 75% { + transform: translate(-1px, 5px); + opacity: 0.55; + } +} + +@keyframes stirling-orbit-left { + 0%, + 100% { + transform: translate(0, 0); + opacity: 1; + } + 25% { + transform: translate(1px, 5px); + opacity: 0.85; + } + 50% { + transform: translate(6px, 0); + opacity: 0.5; + } + 75% { + transform: translate(1px, -5px); + opacity: 0.85; + } +} + +@media (prefers-reduced-motion: reduce) { + .stirling-thinking__path-right { + animation: none; + opacity: 0.5; + } + .stirling-thinking__path-left { + animation: none; + opacity: 1; + } } diff --git a/frontend/editor/src/proprietary/components/chat/ChatPanel.tsx b/frontend/editor/src/proprietary/components/chat/ChatPanel.tsx index 6cb617b04..2e1d00e8e 100644 --- a/frontend/editor/src/proprietary/components/chat/ChatPanel.tsx +++ b/frontend/editor/src/proprietary/components/chat/ChatPanel.tsx @@ -5,6 +5,7 @@ import { useState, type KeyboardEvent, } from "react"; +import { renderMarkdown } from "@app/components/viewer/nonpdf/MarkdownRenderer"; import { useTranslation } from "react-i18next"; import { ActionIcon, @@ -12,7 +13,6 @@ import { Collapse, Group, List, - Loader, Menu, Paper, ScrollArea, @@ -23,6 +23,7 @@ import { } from "@mantine/core"; import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward"; import CloseIcon from "@mui/icons-material/Close"; +import ContentCopyIcon from "@mui/icons-material/ContentCopy"; import DeleteSweepIcon from "@mui/icons-material/DeleteSweep"; import ExpandLessIcon from "@mui/icons-material/ExpandLess"; import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; @@ -30,16 +31,19 @@ import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; import { useChat, AiWorkflowPhase, + ChatRole, isKnownEngineProgressDetail, type AiWorkflowProgress, type AnyEngineProgressDetail, } from "@app/components/chat/ChatContext"; +import { formatRelativeTime } from "@app/utils/timeUtils"; import { useTranslatedToolCatalog } from "@app/data/useTranslatedToolRegistry"; import { StirlingLogoOutline } from "@app/components/agents/StirlingLogoOutline"; +import { StirlingLogoAnimated } from "@app/components/agents/StirlingLogoAnimated"; import { ChatQuickActions } from "@app/components/chat/ChatQuickActions"; import "@app/components/chat/ChatPanel.css"; -type TranslateFn = (key: string, options?: Record) => string; +type TranslateFn = ReturnType["t"]; /** Resolver mapping a tool endpoint path to its translated display name. */ type ToolNameResolver = (endpoint: string) => string | null; @@ -171,21 +175,63 @@ function ToolsUsedBlock({ function ChatMessageBubble({ role, content, + timestamp, toolsUsed, resolveToolName, t, }: { - role: "user" | "assistant"; + role: ChatRole; content: string; + timestamp: number; toolsUsed?: string[]; resolveToolName: ToolNameResolver; t: TranslateFn; }) { + const [copied, setCopied] = useState(false); + + function handleCopy() { + void navigator.clipboard.writeText(content).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }); + } + + const actions = ( +
+ + + {formatRelativeTime(timestamp)} + +
+ ); + + if (role === ChatRole.USER) { + return ( +
+
+ + + {content} + + + {actions} +
+
+ ); + } + return ( -
- - - {content} +
+
+ + {renderMarkdown(content)} {toolsUsed && toolsUsed.length > 0 && ( )} - + {actions} +
); } @@ -250,11 +297,12 @@ export function ChatPanel({ onBack, backLabel }: ChatPanelProps) {
- + {messages.map((msg) => ( - - - - - {progress - ? formatProgress(progress, t, resolveToolName) - : t("chat.progress.thinking")} - - - +
+ + + {progress + ? formatProgress(progress, t, resolveToolName) + : t("chat.progress.thinking")} + +
)}