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:
EthanHealy01
2026-06-04 18:26:19 +00:00
committed by GitHub
parent bd9ef0586b
commit 1698769928
14 changed files with 504 additions and 86 deletions
@@ -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(),
},
@@ -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;
}
}
@@ -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, unknown>) => string;
type TranslateFn = ReturnType<typeof useTranslation>["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 = (
<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 (
<div className={`chat-message chat-message-${role}`}>
<Paper className={`chat-bubble chat-bubble-${role}`} p="xs" radius="md">
<Text size="sm" style={{ whiteSpace: "pre-wrap" }}>
{content}
<div className="chat-message chat-message-assistant">
<div className="chat-bubble-assistant">
<Text size="sm" component="div">
{renderMarkdown(content)}
</Text>
{toolsUsed && toolsUsed.length > 0 && (
<ToolsUsedBlock
@@ -194,7 +240,8 @@ function ChatMessageBubble({
t={t}
/>
)}
</Paper>
{actions}
</div>
</div>
);
}
@@ -250,11 +297,12 @@ export function ChatPanel({ onBack, backLabel }: ChatPanelProps) {
<Menu.Target>
<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")}
>
<span className="chat-panel__agent-pill-icon">
<StirlingLogoOutline size={16} />
{isLoading && <span className="agent-status-dot" />}
</span>
<span className="chat-panel__agent-pill-label">
{t("agents.stirling_name", "Stirling")}
@@ -287,12 +335,13 @@ export function ChatPanel({ onBack, backLabel }: ChatPanelProps) {
</div>
<ScrollArea className="chat-panel-messages" viewportRef={scrollRef}>
<Stack gap="sm" p="sm">
<Stack gap="sm" px="md" py="sm">
{messages.map((msg) => (
<ChatMessageBubble
key={msg.id}
role={msg.role}
content={msg.content}
timestamp={msg.timestamp}
toolsUsed={msg.toolsUsed}
resolveToolName={resolveToolName}
t={t}
@@ -300,20 +349,14 @@ export function ChatPanel({ onBack, backLabel }: ChatPanelProps) {
))}
{isLoading && (
<div className="chat-message chat-message-assistant">
<Paper
className="chat-bubble chat-bubble-assistant"
p="xs"
radius="md"
>
<Group gap="xs" wrap="nowrap">
<Loader size="xs" type="dots" />
<Text size="sm" c="dimmed">
{progress
? formatProgress(progress, t, resolveToolName)
: t("chat.progress.thinking")}
</Text>
</Group>
</Paper>
<div className="chat-thinking">
<StirlingLogoAnimated size={20} />
<Text size="sm" c="dimmed">
{progress
? formatProgress(progress, t, resolveToolName)
: t("chat.progress.thinking")}
</Text>
</div>
</div>
)}
</Stack>