mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +02:00
Improvements to agent chat markdown rendering. (#6507)
### To test
- Ask the agent to “list all the things you can do and put them in a
markdown table”. I know we’re explicitly asking it for markdown, but I
don’t want to update the system prompt to ask it to make tables when
necessary because it’ll probably turn everything into a table, not sure
though, we can test in future.
- Notice how the loading is different
- Notice how the user chat is in a bubble but the agent chat is flat
(super standard design practice in AI tools, and looks much better when
the agent outputs mardown, expecially tables and needs room to do so)
- Ask it to do something different, then close the chat, and see that
the agent is marked as running and has a green outline and a green dot.
- Play around with resizing the chat to make it bigger/smaller
Open to any and all criticisms on any of the design choices, and of
course the usual, code etc.
Resizing
<img width="1572" height="812" alt="Screenshot 2026-06-01 at 2 47 53 PM"
src="https://github.com/user-attachments/assets/ec0ac1d0-01da-4025-bf7e-eea4eb544181"
/>
Loading (cool animation not visible through screenshot obviously)
<img width="559" height="141" alt="Screenshot 2026-06-01 at 2 53 41 PM"
src="https://github.com/user-attachments/assets/99f0b1f5-1719-4d78-8947-21b142293052"
/>
Removed bubbles for agent chat (maybe controversial, let me know) and
markdown now renders properly again
<img width="654" height="1060" alt="Screenshot 2026-06-01 at 2 55 01 PM"
src="https://github.com/user-attachments/assets/445f0889-a632-4751-9a16-f80ae388c632"
/>
This commit is contained in:
+1
-3
@@ -6,7 +6,6 @@ import java.util.List;
|
|||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
|
||||||
import jakarta.validation.constraints.NotBlank;
|
import jakarta.validation.constraints.NotBlank;
|
||||||
import jakarta.validation.constraints.NotNull;
|
|
||||||
|
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
@@ -14,9 +13,8 @@ import lombok.Data;
|
|||||||
@Schema(description = "Run an AI workflow")
|
@Schema(description = "Run an AI workflow")
|
||||||
public class AiWorkflowRequest {
|
public class AiWorkflowRequest {
|
||||||
|
|
||||||
@NotNull
|
|
||||||
@Schema(description = "The input PDF files")
|
@Schema(description = "The input PDF files")
|
||||||
private List<AiWorkflowFileInput> fileInputs;
|
private List<AiWorkflowFileInput> fileInputs = new ArrayList<>();
|
||||||
|
|
||||||
@NotBlank
|
@NotBlank
|
||||||
@Schema(description = "The user message to orchestrate", example = "Summarise these documents")
|
@Schema(description = "The user message to orchestrate", example = "Summarise these documents")
|
||||||
|
|||||||
+7
-4
@@ -186,10 +186,7 @@ public class AiWorkflowService {
|
|||||||
WorkflowTurnRequest initialRequest = new WorkflowTurnRequest();
|
WorkflowTurnRequest initialRequest = new WorkflowTurnRequest();
|
||||||
initialRequest.setUserMessage(request.getUserMessage().trim());
|
initialRequest.setUserMessage(request.getUserMessage().trim());
|
||||||
initialRequest.setFiles(files);
|
initialRequest.setFiles(files);
|
||||||
initialRequest.setConversationHistory(
|
initialRequest.setConversationHistory(new ArrayList<>(request.getConversationHistory()));
|
||||||
request.getConversationHistory() == null
|
|
||||||
? new ArrayList<>()
|
|
||||||
: new ArrayList<>(request.getConversationHistory()));
|
|
||||||
initialRequest.setEnabledEndpoints(endpointResolver.getEnabledEndpointUrls());
|
initialRequest.setEnabledEndpoints(endpointResolver.getEnabledEndpointUrls());
|
||||||
|
|
||||||
listener.onProgress(AiWorkflowProgressEvent.of(AiWorkflowPhase.ANALYZING));
|
listener.onProgress(AiWorkflowProgressEvent.of(AiWorkflowPhase.ANALYZING));
|
||||||
@@ -232,6 +229,12 @@ public class AiWorkflowService {
|
|||||||
WorkflowTurnRequest request,
|
WorkflowTurnRequest request,
|
||||||
ProgressListener listener)
|
ProgressListener listener)
|
||||||
throws IOException {
|
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()) {
|
if (!request.getArtifacts().isEmpty()) {
|
||||||
return new WorkflowState.Terminal(
|
return new WorkflowState.Terminal(
|
||||||
cannotContinue("AI engine requested content extraction more than once."));
|
cannotContinue("AI engine requested content extraction more than once."));
|
||||||
|
|||||||
@@ -1526,6 +1526,7 @@ section_title = "Agents"
|
|||||||
show_less = "Show less"
|
show_less = "Show less"
|
||||||
start_chat = "Start chatting"
|
start_chat = "Start chatting"
|
||||||
stirling_description = "Your general-purpose PDF assistant"
|
stirling_description = "Your general-purpose PDF assistant"
|
||||||
|
stirling_running = "Running..."
|
||||||
stirling_full_name = "Stirling General Agent"
|
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_long_description = "General purpose PDF assistant that can run tools, create PDFs and extract insights from your documents."
|
||||||
stirling_name = "Stirling"
|
stirling_name = "Stirling"
|
||||||
@@ -2715,6 +2716,12 @@ title = "Change Permissions"
|
|||||||
[changePermissions.tooltip.warning]
|
[changePermissions.tooltip.warning]
|
||||||
text = "To make these permissions unchangeable, use the Add Password tool to set an owner password."
|
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]
|
[chat.header]
|
||||||
agentMenu = "Stirling agent options"
|
agentMenu = "Stirling agent options"
|
||||||
clearChat = "Clear chat"
|
clearChat = "Clear chat"
|
||||||
|
|||||||
@@ -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.
|
* 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 {
|
export function useAgentChatOpen(): boolean {
|
||||||
return false;
|
return false;
|
||||||
@@ -35,14 +33,6 @@ export function AgentsCollapsedButton(_props: { onExpand: () => void }) {
|
|||||||
return null;
|
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
|
* Agents card rendered inside the fullscreen tool picker. Matches the visual
|
||||||
* language of the fullscreen category cards (gradient border, title, items).
|
* language of the fullscreen category cards (gradient border, title, items).
|
||||||
|
|||||||
@@ -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: () => {},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState, useRef, useCallback } from "react";
|
||||||
import { ActionIcon } from "@mantine/core";
|
import { ActionIcon } from "@mantine/core";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useRainbowThemeContext } from "@app/components/shared/RainbowThemeProvider";
|
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 ToolPanel from "@app/components/tools/ToolPanel";
|
||||||
import ToolSearch from "@app/components/tools/toolPicker/ToolSearch";
|
import ToolSearch from "@app/components/tools/toolPicker/ToolSearch";
|
||||||
import {
|
import {
|
||||||
AgentsChatOverlay,
|
|
||||||
AgentsCollapsedButton,
|
AgentsCollapsedButton,
|
||||||
AgentsSection,
|
AgentsSection,
|
||||||
useAgentsEnabled,
|
useAgentsEnabled,
|
||||||
} from "@app/components/agents/AgentsPanel";
|
} 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 { useFavoriteToolItems } from "@app/hooks/tools/useFavoriteToolItems";
|
||||||
import { useToolSections } from "@app/hooks/useToolSections";
|
import { useToolSections } from "@app/hooks/useToolSections";
|
||||||
import type { SubcategoryGroup } from "@app/hooks/useToolSections";
|
import type { SubcategoryGroup } from "@app/hooks/useToolSections";
|
||||||
@@ -32,6 +33,10 @@ import {
|
|||||||
import { useToolPanelGeometry } from "@app/hooks/tools/useToolPanelGeometry";
|
import { useToolPanelGeometry } from "@app/hooks/tools/useToolPanelGeometry";
|
||||||
import "@app/components/tools/ToolPanel.css";
|
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.
|
* Right-side rail wrapping the tool panel.
|
||||||
*
|
*
|
||||||
@@ -86,6 +91,57 @@ export default function RightSidebar() {
|
|||||||
|
|
||||||
const [allToolsView, setAllToolsView] = useState(false);
|
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<HTMLDivElement>) => {
|
||||||
|
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 = () => {
|
const handleShowAllTools = () => {
|
||||||
withViewTransition(() => setAllToolsView(true));
|
withViewTransition(() => setAllToolsView(true));
|
||||||
};
|
};
|
||||||
@@ -147,6 +203,7 @@ export default function RightSidebar() {
|
|||||||
|
|
||||||
const computedWidth = () => {
|
const computedWidth = () => {
|
||||||
if (isMobile) return "100%";
|
if (isMobile) return "100%";
|
||||||
|
if (isChatOpen) return `${chatWidthPx}px`;
|
||||||
if (!isPanelVisible) return "3.5rem";
|
if (!isPanelVisible) return "3.5rem";
|
||||||
return "18.5rem";
|
return "18.5rem";
|
||||||
};
|
};
|
||||||
@@ -182,15 +239,39 @@ export default function RightSidebar() {
|
|||||||
ref={toolPanelRef}
|
ref={toolPanelRef}
|
||||||
data-sidebar="tool-panel"
|
data-sidebar="tool-panel"
|
||||||
data-tour={fullscreenExpanded ? undefined : "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 : ""
|
isRainbowMode ? rainbowStyles.rainbowPaper : ""
|
||||||
} ${isMobile ? "h-full border-r-0" : "h-screen"} ${fullscreenExpanded ? "tool-panel--fullscreen" : ""}`}
|
} ${isMobile ? "h-full border-r-0" : "h-screen"} ${fullscreenExpanded ? "tool-panel--fullscreen" : ""}`}
|
||||||
style={{
|
style={{
|
||||||
width: computedWidth(),
|
width: computedWidth(),
|
||||||
padding: "0",
|
padding: "0",
|
||||||
|
...(isChatDragging ? { transition: "none" } : {}),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{!fullscreenExpanded && !isPanelVisible && !isMobile && (
|
{!fullscreenExpanded && isChatOpen && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: "100%",
|
||||||
|
width: "100%",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="agents-takeover__resize-handle"
|
||||||
|
onPointerDown={handleResizeChatPointerDown}
|
||||||
|
role="separator"
|
||||||
|
aria-label={t("chat.resize", "Resize chat panel")}
|
||||||
|
aria-orientation="vertical"
|
||||||
|
/>
|
||||||
|
<ChatPanel
|
||||||
|
onBack={handleChatClose}
|
||||||
|
backLabel={t("agents.back_to_tools", "Back to tools")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!fullscreenExpanded && !isChatOpen && !isPanelVisible && !isMobile && (
|
||||||
<div className="tool-panel__collapsed-strip">
|
<div className="tool-panel__collapsed-strip">
|
||||||
<div className="tool-panel__collapsed-top">
|
<div className="tool-panel__collapsed-top">
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
@@ -234,7 +315,7 @@ export default function RightSidebar() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!fullscreenExpanded && isPanelVisible && (
|
{!fullscreenExpanded && !isChatOpen && isPanelVisible && (
|
||||||
<div
|
<div
|
||||||
/* Fixed width matches the expanded panel width so the inner content is
|
/* Fixed width matches the expanded panel width so the inner content is
|
||||||
laid out at its final size from the moment it mounts. The outer
|
laid out at its final size from the moment it mounts. The outer
|
||||||
@@ -323,7 +404,6 @@ export default function RightSidebar() {
|
|||||||
onToolSelect={handleToolSelectWithTransition}
|
onToolSelect={handleToolSelectWithTransition}
|
||||||
compact={agentsEnabled && !allToolsView}
|
compact={agentsEnabled && !allToolsView}
|
||||||
/>
|
/>
|
||||||
<AgentsChatOverlay />
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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`;
|
||||||
|
}
|
||||||
@@ -1,15 +1,49 @@
|
|||||||
/* ─── Chat takeover ─────────────────────────────────────────────────────── */
|
/* ─── Chat takeover ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
.agents-takeover {
|
.agents-takeover {
|
||||||
position: absolute;
|
position: fixed;
|
||||||
inset: 0;
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
background: var(--bg-toolbar);
|
background: var(--bg-toolbar);
|
||||||
z-index: 5;
|
border-left: 1px solid var(--border-subtle);
|
||||||
view-transition-name: agents-rail;
|
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 ─────────────────────────────────────────────── */
|
/* ─── Sidebar agents section ─────────────────────────────────────────────── */
|
||||||
|
|
||||||
.agents-section {
|
.agents-section {
|
||||||
@@ -67,6 +101,7 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
color: var(--mantine-color-blue-filled);
|
color: var(--mantine-color-blue-filled);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Coming-soon agent rows */
|
/* Coming-soon agent rows */
|
||||||
@@ -205,12 +240,61 @@
|
|||||||
color: var(--mantine-color-blue-filled);
|
color: var(--mantine-color-blue-filled);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 120ms ease-out;
|
transition: background 120ms ease-out;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.agents-collapsed-btn:hover {
|
.agents-collapsed-btn:hover {
|
||||||
background: var(--mantine-color-default-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 ───────────────────────────────────────────────── */
|
/* ─── Fullscreen hero card ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import CodeRoundedIcon from "@mui/icons-material/CodeRounded";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||||
import { useChat } from "@app/components/chat/ChatContext";
|
import { useChat } from "@app/components/chat/ChatContext";
|
||||||
import { ChatPanel } from "@app/components/chat/ChatPanel";
|
|
||||||
import { Tooltip as AppTooltip } from "@app/components/shared/Tooltip";
|
import { Tooltip as AppTooltip } from "@app/components/shared/Tooltip";
|
||||||
import { StirlingLogoOutline } from "@app/components/agents/StirlingLogoOutline";
|
import { StirlingLogoOutline } from "@app/components/agents/StirlingLogoOutline";
|
||||||
import { withViewTransition } from "@app/utils/viewTransition";
|
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. */
|
/** Sidebar agents section — Stirling as hero CTA, coming-soon agents below. */
|
||||||
export function AgentsSection() {
|
export function AgentsSection() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { isOpen, setOpen } = useChat();
|
const { isOpen, setOpen, isLoading } = useChat();
|
||||||
const enabled = useAgentsEnabled();
|
const enabled = useAgentsEnabled();
|
||||||
const [showAll, setShowAll] = useState(false);
|
const [showAll, setShowAll] = useState(false);
|
||||||
|
|
||||||
@@ -92,23 +91,26 @@ export function AgentsSection() {
|
|||||||
<Box className="agents-section" w="100%">
|
<Box className="agents-section" w="100%">
|
||||||
{/* Main Stirling agent — real and clickable */}
|
{/* Main Stirling agent — real and clickable */}
|
||||||
<UnstyledButton
|
<UnstyledButton
|
||||||
className="agent-button agent-button--hero"
|
className={`agent-button agent-button--hero${isLoading ? " agent-button--running" : ""}`}
|
||||||
onClick={() => withViewTransition(() => setOpen(true))}
|
onClick={() => withViewTransition(() => setOpen(true))}
|
||||||
aria-label={t("agents.stirling_name", "Stirling")}
|
aria-label={t("agents.stirling_name", "Stirling")}
|
||||||
>
|
>
|
||||||
<Group gap="sm" wrap="nowrap" align="center">
|
<Group gap="sm" wrap="nowrap" align="center">
|
||||||
<Box className="agent-button__logo">
|
<Box className="agent-button__logo">
|
||||||
<StirlingLogoOutline size={28} />
|
<StirlingLogoOutline size={28} />
|
||||||
|
{isLoading && <span className="agent-status-dot" />}
|
||||||
</Box>
|
</Box>
|
||||||
<Box style={{ minWidth: 0, flex: 1 }}>
|
<Box style={{ minWidth: 0, flex: 1 }}>
|
||||||
<Text size="sm" fw={600} truncate>
|
<Text size="sm" fw={600} truncate>
|
||||||
{t("agents.stirling_name", "Stirling")}
|
{t("agents.stirling_name", "Stirling")}
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="xs" c="dimmed" truncate>
|
<Text size="xs" c="dimmed" truncate>
|
||||||
{t(
|
{isLoading
|
||||||
"agents.stirling_description",
|
? t("agents.stirling_running", "Running...")
|
||||||
"Your general-purpose PDF assistant",
|
: t(
|
||||||
)}
|
"agents.stirling_description",
|
||||||
|
"Your general-purpose PDF assistant",
|
||||||
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -171,7 +173,7 @@ export function AgentsSection() {
|
|||||||
/** Icon-only agent button in the collapsed (minimised) right rail. */
|
/** Icon-only agent button in the collapsed (minimised) right rail. */
|
||||||
export function AgentsCollapsedButton({ onExpand }: { onExpand: () => void }) {
|
export function AgentsCollapsedButton({ onExpand }: { onExpand: () => void }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { setOpen } = useChat();
|
const { setOpen, isLoading } = useChat();
|
||||||
const enabled = useAgentsEnabled();
|
const enabled = useAgentsEnabled();
|
||||||
|
|
||||||
if (!enabled) return null;
|
if (!enabled) return null;
|
||||||
@@ -189,6 +191,7 @@ export function AgentsCollapsedButton({ onExpand }: { onExpand: () => void }) {
|
|||||||
className="agents-collapsed-btn"
|
className="agents-collapsed-btn"
|
||||||
>
|
>
|
||||||
<StirlingLogoOutline size={22} />
|
<StirlingLogoOutline size={22} />
|
||||||
|
{isLoading && <span className="agent-status-dot" />}
|
||||||
</UnstyledButton>
|
</UnstyledButton>
|
||||||
</AppTooltip>
|
</AppTooltip>
|
||||||
);
|
);
|
||||||
@@ -270,21 +273,3 @@ export function AgentsFullscreenSection() {
|
|||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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 (
|
|
||||||
<Box className="agents-takeover">
|
|
||||||
<ChatPanel
|
|
||||||
onBack={() => withViewTransition(() => setOpen(false))}
|
|
||||||
backLabel={t("agents.back_to_tools", "Back to tools")}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import "@app/components/chat/ChatPanel.css";
|
||||||
|
|
||||||
|
export function StirlingLogoAnimated({ size = 20 }: { size?: number }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 192 192"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
className="stirling-thinking__path-right"
|
||||||
|
d="M68.48 102.4 L184.73 6.45 L184.73 96.05 L68.48 192 Z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
className="stirling-thinking__path-left"
|
||||||
|
d="M7.26 95.83 L123.37 0 L123.37 89.5 L7.26 185.33 Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -19,9 +19,14 @@ import {
|
|||||||
} from "@app/types/fileContext";
|
} from "@app/types/fileContext";
|
||||||
import type { ToolOperation } from "@app/types/file";
|
import type { ToolOperation } from "@app/types/file";
|
||||||
|
|
||||||
|
export enum ChatRole {
|
||||||
|
USER = "user",
|
||||||
|
ASSISTANT = "assistant",
|
||||||
|
}
|
||||||
|
|
||||||
export interface ChatMessage {
|
export interface ChatMessage {
|
||||||
id: string;
|
id: string;
|
||||||
role: "user" | "assistant";
|
role: ChatRole;
|
||||||
content: string;
|
content: string;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
/**
|
/**
|
||||||
@@ -437,7 +442,7 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
const userMessage: ChatMessage = {
|
const userMessage: ChatMessage = {
|
||||||
id: generateId(),
|
id: generateId(),
|
||||||
role: "user",
|
role: ChatRole.USER,
|
||||||
content,
|
content,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
};
|
};
|
||||||
@@ -513,7 +518,7 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
|||||||
type: "ADD_MESSAGE",
|
type: "ADD_MESSAGE",
|
||||||
message: {
|
message: {
|
||||||
id: generateId(),
|
id: generateId(),
|
||||||
role: "assistant",
|
role: ChatRole.ASSISTANT,
|
||||||
content: replyContent,
|
content: replyContent,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
toolsUsed: toolsUsed.length > 0 ? toolsUsed : undefined,
|
toolsUsed: toolsUsed.length > 0 ? toolsUsed : undefined,
|
||||||
@@ -526,7 +531,7 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
|||||||
type: "ADD_MESSAGE",
|
type: "ADD_MESSAGE",
|
||||||
message: {
|
message: {
|
||||||
id: generateId(),
|
id: generateId(),
|
||||||
role: "assistant",
|
role: ChatRole.ASSISTANT,
|
||||||
content:
|
content:
|
||||||
"The file was processed but I couldn't download it.",
|
"The file was processed but I couldn't download it.",
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
@@ -542,7 +547,7 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
|||||||
type: "ADD_MESSAGE",
|
type: "ADD_MESSAGE",
|
||||||
message: {
|
message: {
|
||||||
id: generateId(),
|
id: generateId(),
|
||||||
role: "assistant",
|
role: ChatRole.ASSISTANT,
|
||||||
content: data.message || "Something went wrong.",
|
content: data.message || "Something went wrong.",
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
},
|
},
|
||||||
@@ -568,7 +573,7 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
|||||||
type: "ADD_MESSAGE",
|
type: "ADD_MESSAGE",
|
||||||
message: {
|
message: {
|
||||||
id: generateId(),
|
id: generateId(),
|
||||||
role: "assistant",
|
role: ChatRole.ASSISTANT,
|
||||||
content,
|
content,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -61,6 +61,23 @@
|
|||||||
background: var(--mantine-color-blue-light);
|
background: var(--mantine-color-blue-light);
|
||||||
color: var(--mantine-color-blue-filled);
|
color: var(--mantine-color-blue-filled);
|
||||||
flex-shrink: 0;
|
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 —
|
/* Dark-mode: let the pill blend into the rail. Just a thin border, no fill —
|
||||||
@@ -284,15 +301,75 @@
|
|||||||
|
|
||||||
.chat-message-user {
|
.chat-message-user {
|
||||||
justify-content: flex-end;
|
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 {
|
.chat-message-assistant {
|
||||||
justify-content: flex-start;
|
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 {
|
.chat-bubble {
|
||||||
max-width: 85%;
|
width: fit-content;
|
||||||
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-bubble-user {
|
.chat-bubble-user {
|
||||||
@@ -304,6 +381,87 @@
|
|||||||
color: white !important;
|
color: white !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Assistant messages: no bubble, free-flowing with side padding */
|
||||||
.chat-bubble-assistant {
|
.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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
type KeyboardEvent,
|
type KeyboardEvent,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
import { renderMarkdown } from "@app/components/viewer/nonpdf/MarkdownRenderer";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
@@ -12,7 +13,6 @@ import {
|
|||||||
Collapse,
|
Collapse,
|
||||||
Group,
|
Group,
|
||||||
List,
|
List,
|
||||||
Loader,
|
|
||||||
Menu,
|
Menu,
|
||||||
Paper,
|
Paper,
|
||||||
ScrollArea,
|
ScrollArea,
|
||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward";
|
import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward";
|
||||||
import CloseIcon from "@mui/icons-material/Close";
|
import CloseIcon from "@mui/icons-material/Close";
|
||||||
|
import ContentCopyIcon from "@mui/icons-material/ContentCopy";
|
||||||
import DeleteSweepIcon from "@mui/icons-material/DeleteSweep";
|
import DeleteSweepIcon from "@mui/icons-material/DeleteSweep";
|
||||||
import ExpandLessIcon from "@mui/icons-material/ExpandLess";
|
import ExpandLessIcon from "@mui/icons-material/ExpandLess";
|
||||||
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||||
@@ -30,16 +31,19 @@ import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
|
|||||||
import {
|
import {
|
||||||
useChat,
|
useChat,
|
||||||
AiWorkflowPhase,
|
AiWorkflowPhase,
|
||||||
|
ChatRole,
|
||||||
isKnownEngineProgressDetail,
|
isKnownEngineProgressDetail,
|
||||||
type AiWorkflowProgress,
|
type AiWorkflowProgress,
|
||||||
type AnyEngineProgressDetail,
|
type AnyEngineProgressDetail,
|
||||||
} from "@app/components/chat/ChatContext";
|
} from "@app/components/chat/ChatContext";
|
||||||
|
import { formatRelativeTime } from "@app/utils/timeUtils";
|
||||||
import { useTranslatedToolCatalog } from "@app/data/useTranslatedToolRegistry";
|
import { useTranslatedToolCatalog } from "@app/data/useTranslatedToolRegistry";
|
||||||
import { StirlingLogoOutline } from "@app/components/agents/StirlingLogoOutline";
|
import { StirlingLogoOutline } from "@app/components/agents/StirlingLogoOutline";
|
||||||
|
import { StirlingLogoAnimated } from "@app/components/agents/StirlingLogoAnimated";
|
||||||
import { ChatQuickActions } from "@app/components/chat/ChatQuickActions";
|
import { ChatQuickActions } from "@app/components/chat/ChatQuickActions";
|
||||||
import "@app/components/chat/ChatPanel.css";
|
import "@app/components/chat/ChatPanel.css";
|
||||||
|
|
||||||
type TranslateFn = (key: string, options?: Record<string, unknown>) => string;
|
type TranslateFn = ReturnType<typeof useTranslation>["t"];
|
||||||
|
|
||||||
/** Resolver mapping a tool endpoint path to its translated display name. */
|
/** Resolver mapping a tool endpoint path to its translated display name. */
|
||||||
type ToolNameResolver = (endpoint: string) => string | null;
|
type ToolNameResolver = (endpoint: string) => string | null;
|
||||||
@@ -171,21 +175,63 @@ function ToolsUsedBlock({
|
|||||||
function ChatMessageBubble({
|
function ChatMessageBubble({
|
||||||
role,
|
role,
|
||||||
content,
|
content,
|
||||||
|
timestamp,
|
||||||
toolsUsed,
|
toolsUsed,
|
||||||
resolveToolName,
|
resolveToolName,
|
||||||
t,
|
t,
|
||||||
}: {
|
}: {
|
||||||
role: "user" | "assistant";
|
role: ChatRole;
|
||||||
content: string;
|
content: string;
|
||||||
|
timestamp: number;
|
||||||
toolsUsed?: string[];
|
toolsUsed?: string[];
|
||||||
resolveToolName: ToolNameResolver;
|
resolveToolName: ToolNameResolver;
|
||||||
t: TranslateFn;
|
t: TranslateFn;
|
||||||
}) {
|
}) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
function handleCopy() {
|
||||||
|
void navigator.clipboard.writeText(content).then(() => {
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 1500);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const actions = (
|
||||||
|
<div className="chat-message-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`chat-message-action-btn${copied ? " chat-message-action-btn--active" : ""}`}
|
||||||
|
onClick={handleCopy}
|
||||||
|
title={t("chat.actions.copy", "Copy message")}
|
||||||
|
>
|
||||||
|
<ContentCopyIcon sx={{ fontSize: 13 }} />
|
||||||
|
</button>
|
||||||
|
<span className="chat-message-timestamp">
|
||||||
|
{formatRelativeTime(timestamp)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (role === ChatRole.USER) {
|
||||||
|
return (
|
||||||
|
<div className="chat-message chat-message-user">
|
||||||
|
<div className="chat-message-user__inner">
|
||||||
|
<Paper className="chat-bubble chat-bubble-user" p="xs" radius="md">
|
||||||
|
<Text size="sm" style={{ whiteSpace: "pre-wrap" }}>
|
||||||
|
{content}
|
||||||
|
</Text>
|
||||||
|
</Paper>
|
||||||
|
{actions}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`chat-message chat-message-${role}`}>
|
<div className="chat-message chat-message-assistant">
|
||||||
<Paper className={`chat-bubble chat-bubble-${role}`} p="xs" radius="md">
|
<div className="chat-bubble-assistant">
|
||||||
<Text size="sm" style={{ whiteSpace: "pre-wrap" }}>
|
<Text size="sm" component="div">
|
||||||
{content}
|
{renderMarkdown(content)}
|
||||||
</Text>
|
</Text>
|
||||||
{toolsUsed && toolsUsed.length > 0 && (
|
{toolsUsed && toolsUsed.length > 0 && (
|
||||||
<ToolsUsedBlock
|
<ToolsUsedBlock
|
||||||
@@ -194,7 +240,8 @@ function ChatMessageBubble({
|
|||||||
t={t}
|
t={t}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Paper>
|
{actions}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -250,11 +297,12 @@ export function ChatPanel({ onBack, backLabel }: ChatPanelProps) {
|
|||||||
<Menu.Target>
|
<Menu.Target>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="chat-panel__agent-pill"
|
className={`chat-panel__agent-pill${isLoading ? " chat-panel__agent-pill--loading" : ""}`}
|
||||||
aria-label={t("chat.header.agentMenu", "Stirling agent options")}
|
aria-label={t("chat.header.agentMenu", "Stirling agent options")}
|
||||||
>
|
>
|
||||||
<span className="chat-panel__agent-pill-icon">
|
<span className="chat-panel__agent-pill-icon">
|
||||||
<StirlingLogoOutline size={16} />
|
<StirlingLogoOutline size={16} />
|
||||||
|
{isLoading && <span className="agent-status-dot" />}
|
||||||
</span>
|
</span>
|
||||||
<span className="chat-panel__agent-pill-label">
|
<span className="chat-panel__agent-pill-label">
|
||||||
{t("agents.stirling_name", "Stirling")}
|
{t("agents.stirling_name", "Stirling")}
|
||||||
@@ -287,12 +335,13 @@ export function ChatPanel({ onBack, backLabel }: ChatPanelProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ScrollArea className="chat-panel-messages" viewportRef={scrollRef}>
|
<ScrollArea className="chat-panel-messages" viewportRef={scrollRef}>
|
||||||
<Stack gap="sm" p="sm">
|
<Stack gap="sm" px="md" py="sm">
|
||||||
{messages.map((msg) => (
|
{messages.map((msg) => (
|
||||||
<ChatMessageBubble
|
<ChatMessageBubble
|
||||||
key={msg.id}
|
key={msg.id}
|
||||||
role={msg.role}
|
role={msg.role}
|
||||||
content={msg.content}
|
content={msg.content}
|
||||||
|
timestamp={msg.timestamp}
|
||||||
toolsUsed={msg.toolsUsed}
|
toolsUsed={msg.toolsUsed}
|
||||||
resolveToolName={resolveToolName}
|
resolveToolName={resolveToolName}
|
||||||
t={t}
|
t={t}
|
||||||
@@ -300,20 +349,14 @@ export function ChatPanel({ onBack, backLabel }: ChatPanelProps) {
|
|||||||
))}
|
))}
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="chat-message chat-message-assistant">
|
<div className="chat-message chat-message-assistant">
|
||||||
<Paper
|
<div className="chat-thinking">
|
||||||
className="chat-bubble chat-bubble-assistant"
|
<StirlingLogoAnimated size={20} />
|
||||||
p="xs"
|
<Text size="sm" c="dimmed">
|
||||||
radius="md"
|
{progress
|
||||||
>
|
? formatProgress(progress, t, resolveToolName)
|
||||||
<Group gap="xs" wrap="nowrap">
|
: t("chat.progress.thinking")}
|
||||||
<Loader size="xs" type="dots" />
|
</Text>
|
||||||
<Text size="sm" c="dimmed">
|
</div>
|
||||||
{progress
|
|
||||||
? formatProgress(progress, t, resolveToolName)
|
|
||||||
: t("chat.progress.thinking")}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
</Paper>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
Reference in New Issue
Block a user