)}
- {!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")}
+
+
)}