feat(FN-2503): merge fusion/fn-2503
This commit is contained in:
@@ -186,6 +186,40 @@
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.chat-render-mode-toggle {
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.chat-render-mode-btn {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-pill);
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast), color var(--transition-fast), border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.chat-render-mode-btn:hover {
|
||||
color: var(--text);
|
||||
border-color: color-mix(in srgb, var(--todo) 40%, var(--border));
|
||||
}
|
||||
|
||||
.chat-render-mode-btn--active {
|
||||
background: color-mix(in srgb, var(--todo) 18%, transparent);
|
||||
color: var(--text);
|
||||
border-color: color-mix(in srgb, var(--todo) 50%, var(--border));
|
||||
}
|
||||
|
||||
.chat-render-mode-btn:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring-strong);
|
||||
}
|
||||
|
||||
/* Messages */
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
@@ -244,6 +278,72 @@
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.chat-message-content--plain {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.chat-message-content--markdown {
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.chat-message-content--markdown > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.chat-message-content--markdown > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.chat-message-content--markdown p,
|
||||
.chat-message-content--markdown ul,
|
||||
.chat-message-content--markdown ol,
|
||||
.chat-message-content--markdown blockquote,
|
||||
.chat-message-content--markdown pre,
|
||||
.chat-message-content--markdown table {
|
||||
margin: 0 0 var(--space-sm);
|
||||
}
|
||||
|
||||
.chat-message-content--markdown ul,
|
||||
.chat-message-content--markdown ol {
|
||||
padding-left: var(--space-lg);
|
||||
}
|
||||
|
||||
.chat-message-content--markdown code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.chat-message-content--markdown :not(pre) > code {
|
||||
padding: 0 var(--space-xs);
|
||||
border-radius: var(--radius-sm);
|
||||
background: color-mix(in srgb, var(--surface) 55%, transparent);
|
||||
}
|
||||
|
||||
.chat-markdown-pre {
|
||||
margin: 0 0 var(--space-sm);
|
||||
padding: var(--space-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
background: color-mix(in srgb, var(--surface) 65%, transparent);
|
||||
overflow-x: auto;
|
||||
white-space: pre;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.chat-markdown-table {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.chat-markdown-table th,
|
||||
.chat-markdown-table td {
|
||||
border: 1px solid color-mix(in srgb, var(--border) 85%, transparent);
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
}
|
||||
|
||||
.chat-message-time {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
@@ -684,4 +784,18 @@
|
||||
justify-content: center;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.chat-thread-header {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.chat-render-mode-toggle {
|
||||
width: 100%;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.chat-render-mode-btn {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
// ChatView.css is imported eagerly from App.tsx to avoid a flash of
|
||||
// unstyled content when the lazy chunk loads. Do not re-import here.
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import type { Components } from "react-markdown";
|
||||
import {
|
||||
MessageSquare,
|
||||
Send,
|
||||
@@ -216,6 +219,19 @@ function renderToolCalls(toolCalls?: ToolCallInfo[]): ReactNode {
|
||||
);
|
||||
}
|
||||
|
||||
const chatMarkdownComponents: Components = {
|
||||
pre: ({ children, ...props }) => (
|
||||
<pre {...props} className="chat-markdown-pre">
|
||||
{children}
|
||||
</pre>
|
||||
),
|
||||
table: ({ children, ...props }) => (
|
||||
<table {...props} className="chat-markdown-table">
|
||||
{children}
|
||||
</table>
|
||||
),
|
||||
};
|
||||
|
||||
/**
|
||||
* Constant agent ID for the built-in fn agent.
|
||||
* The chat system always uses createFnAgent with CHAT_SYSTEM_PROMPT regardless
|
||||
@@ -464,6 +480,7 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
|
||||
const [mentionPopupVisible, setMentionPopupVisible] = useState(false);
|
||||
const [mentionHighlightIndex, setMentionHighlightIndex] = useState(0);
|
||||
const [mentionStartPos, setMentionStartPos] = useState(-1);
|
||||
const [renderAssistantMarkdown, setRenderAssistantMarkdown] = useState(true);
|
||||
|
||||
// File mention state and hook
|
||||
const [, setFileMentionPopupVisible] = useState(false);
|
||||
@@ -1014,6 +1031,23 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
|
||||
? `${pendingMessage.slice(0, 50)}…`
|
||||
: pendingMessage;
|
||||
|
||||
const renderAssistantContent = useCallback(
|
||||
(content: string) => {
|
||||
if (!renderAssistantMarkdown) {
|
||||
return <div className="chat-message-content chat-message-content--plain">{content}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="chat-message-content chat-message-content--markdown">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={chatMarkdownComponents}>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[renderAssistantMarkdown],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="chat-view">
|
||||
{/* Sidebar */}
|
||||
@@ -1169,6 +1203,30 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
|
||||
<Bot size={16} />
|
||||
<span className="chat-thread-header-title">{threadHeaderTitle}</span>
|
||||
{showThreadHeaderModelTag && <span className="chat-model-tag">{activeModelTag}</span>}
|
||||
{activeSession && (
|
||||
<div className="chat-render-mode-toggle" role="group" aria-label="Assistant response render mode">
|
||||
<button
|
||||
type="button"
|
||||
className={`chat-render-mode-btn${renderAssistantMarkdown ? " chat-render-mode-btn--active" : ""}`}
|
||||
aria-pressed={renderAssistantMarkdown}
|
||||
aria-label="Render assistant responses as markdown"
|
||||
data-testid="chat-render-mode-markdown"
|
||||
onClick={() => setRenderAssistantMarkdown(true)}
|
||||
>
|
||||
Markdown
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`chat-render-mode-btn${!renderAssistantMarkdown ? " chat-render-mode-btn--active" : ""}`}
|
||||
aria-pressed={!renderAssistantMarkdown}
|
||||
aria-label="Render assistant responses as plain text"
|
||||
data-testid="chat-render-mode-plain"
|
||||
onClick={() => setRenderAssistantMarkdown(false)}
|
||||
>
|
||||
Plain
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1197,7 +1255,9 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
|
||||
{showAssistantModelTag && <span className="chat-model-tag">{activeModelTag}</span>}
|
||||
</div>
|
||||
)}
|
||||
<div className="chat-message-content">{renderMessageContent(message.content)}</div>
|
||||
{message.role === "assistant"
|
||||
? renderAssistantContent(message.content)
|
||||
: <div className="chat-message-content">{renderMessageContent(message.content)}</div>}
|
||||
{renderToolCalls(message.toolCalls)}
|
||||
{message.thinkingOutput && (
|
||||
<details className="chat-message-thinking">
|
||||
@@ -1216,7 +1276,7 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
|
||||
{showAssistantModelTag && <span className="chat-model-tag">{activeModelTag}</span>}
|
||||
</div>
|
||||
{streamingText ? (
|
||||
<div className="chat-message-content">{renderMessageContent(streamingText)}</div>
|
||||
renderAssistantContent(streamingText)
|
||||
) : (
|
||||
<div className="chat-message-content chat-message-content--waiting">
|
||||
{streamingThinking ? "Thinking…" : "Connecting…"}
|
||||
|
||||
@@ -83,6 +83,40 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.quick-chat-render-mode-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.quick-chat-render-mode-btn {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-pill);
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast), color var(--transition-fast), border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.quick-chat-render-mode-btn:hover {
|
||||
color: var(--text);
|
||||
border-color: color-mix(in srgb, var(--todo) 40%, var(--border));
|
||||
}
|
||||
|
||||
.quick-chat-render-mode-btn--active {
|
||||
color: var(--text);
|
||||
background: color-mix(in srgb, var(--todo) 18%, transparent);
|
||||
border-color: color-mix(in srgb, var(--todo) 50%, var(--border));
|
||||
}
|
||||
|
||||
.quick-chat-render-mode-btn:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring-strong);
|
||||
}
|
||||
|
||||
.quick-chat-mode-toggle {
|
||||
display: flex;
|
||||
gap: var(--space-xs);
|
||||
@@ -168,6 +202,72 @@
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.quick-chat-message-content--plain {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.quick-chat-message-content--markdown {
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.quick-chat-message-content--markdown > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.quick-chat-message-content--markdown > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.quick-chat-message-content--markdown p,
|
||||
.quick-chat-message-content--markdown ul,
|
||||
.quick-chat-message-content--markdown ol,
|
||||
.quick-chat-message-content--markdown blockquote,
|
||||
.quick-chat-message-content--markdown pre,
|
||||
.quick-chat-message-content--markdown table {
|
||||
margin: 0 0 var(--space-sm);
|
||||
}
|
||||
|
||||
.quick-chat-message-content--markdown ul,
|
||||
.quick-chat-message-content--markdown ol {
|
||||
padding-left: var(--space-lg);
|
||||
}
|
||||
|
||||
.quick-chat-message-content--markdown code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.quick-chat-message-content--markdown :not(pre) > code {
|
||||
padding: 0 var(--space-xs);
|
||||
border-radius: var(--radius-sm);
|
||||
background: color-mix(in srgb, var(--surface) 55%, transparent);
|
||||
}
|
||||
|
||||
.quick-chat-markdown-pre {
|
||||
margin: 0 0 var(--space-sm);
|
||||
padding: var(--space-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
background: color-mix(in srgb, var(--surface) 65%, transparent);
|
||||
overflow-x: auto;
|
||||
white-space: pre;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.quick-chat-markdown-table {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.quick-chat-markdown-table th,
|
||||
.quick-chat-markdown-table td {
|
||||
border: 1px solid color-mix(in srgb, var(--border) 85%, transparent);
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
}
|
||||
|
||||
.quick-chat-panel-message--sent {
|
||||
align-self: flex-end;
|
||||
background: color-mix(in srgb, var(--todo) 20%, transparent);
|
||||
@@ -247,6 +347,25 @@
|
||||
/* Reserve safe-area space so the input clears the iOS home bar */
|
||||
padding-bottom: calc(10px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
.quick-chat-panel-header {
|
||||
flex-wrap: wrap;
|
||||
row-gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.quick-chat-panel-header-actions {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.quick-chat-render-mode-toggle {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.quick-chat-render-mode-btn {
|
||||
flex: 1;
|
||||
min-height: 36px;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-message-thinking-content {
|
||||
|
||||
@@ -8,6 +8,9 @@ import {
|
||||
type KeyboardEvent as ReactKeyboardEvent,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import type { Components } from "react-markdown";
|
||||
import { MessageSquare, Send, Square, Wrench, X } from "lucide-react";
|
||||
import { fetchModels, type Agent, type ModelInfo } from "../api";
|
||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||
@@ -204,6 +207,19 @@ function renderToolCalls(toolCalls?: ToolCallInfo[], compact = false): ReactNode
|
||||
);
|
||||
}
|
||||
|
||||
const quickChatMarkdownComponents: Components = {
|
||||
pre: ({ children, ...props }) => (
|
||||
<pre {...props} className="quick-chat-markdown-pre">
|
||||
{children}
|
||||
</pre>
|
||||
),
|
||||
table: ({ children, ...props }) => (
|
||||
<table {...props} className="quick-chat-markdown-table">
|
||||
{children}
|
||||
</table>
|
||||
),
|
||||
};
|
||||
|
||||
function getMentionTriggerMatch(
|
||||
value: string,
|
||||
cursorPos: number,
|
||||
@@ -436,6 +452,7 @@ export function QuickChatFAB({
|
||||
const [mentionPopupVisible, setMentionPopupVisible] = useState(false);
|
||||
const [mentionHighlightIndex, setMentionHighlightIndex] = useState(0);
|
||||
const [mentionStartPos, setMentionStartPos] = useState(-1);
|
||||
const [renderAssistantMarkdown, setRenderAssistantMarkdown] = useState(true);
|
||||
|
||||
// File mention state and hook
|
||||
const [, setFileMentionPopupVisible] = useState(false);
|
||||
@@ -876,6 +893,23 @@ export function QuickChatFAB({
|
||||
[mentionAgentsByName],
|
||||
);
|
||||
|
||||
const renderAssistantMessageContent = useCallback(
|
||||
(content: string) => {
|
||||
if (!renderAssistantMarkdown) {
|
||||
return <div className="quick-chat-message-content quick-chat-message-content--plain">{content}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="quick-chat-message-content quick-chat-message-content--markdown">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={quickChatMarkdownComponents}>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[renderAssistantMarkdown],
|
||||
);
|
||||
|
||||
const handleInputKeyDown = useCallback(
|
||||
(event: ReactKeyboardEvent<HTMLInputElement>) => {
|
||||
mentionCursorPosRef.current = event.currentTarget.selectionStart ?? mentionCursorPosRef.current;
|
||||
@@ -998,6 +1032,28 @@ export function QuickChatFAB({
|
||||
)}
|
||||
</div>
|
||||
<div className="quick-chat-panel-header-actions">
|
||||
<div className="quick-chat-render-mode-toggle" role="group" aria-label="Assistant response render mode">
|
||||
<button
|
||||
type="button"
|
||||
className={`quick-chat-render-mode-btn${renderAssistantMarkdown ? " quick-chat-render-mode-btn--active" : ""}`}
|
||||
data-testid="quick-chat-render-mode-markdown"
|
||||
aria-pressed={renderAssistantMarkdown}
|
||||
aria-label="Render assistant responses as markdown"
|
||||
onClick={() => setRenderAssistantMarkdown(true)}
|
||||
>
|
||||
Markdown
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`quick-chat-render-mode-btn${!renderAssistantMarkdown ? " quick-chat-render-mode-btn--active" : ""}`}
|
||||
data-testid="quick-chat-render-mode-plain"
|
||||
aria-pressed={!renderAssistantMarkdown}
|
||||
aria-label="Render assistant responses as plain text"
|
||||
onClick={() => setRenderAssistantMarkdown(false)}
|
||||
>
|
||||
Plain
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
@@ -1098,7 +1154,9 @@ export function QuickChatFAB({
|
||||
className={`quick-chat-panel-message ${isSent ? "quick-chat-panel-message--sent" : "quick-chat-panel-message--received"}`}
|
||||
data-testid={`quick-chat-message-${message.id}`}
|
||||
>
|
||||
<p>{renderMessageContent(message.content)}</p>
|
||||
{isSent
|
||||
? <p>{renderMessageContent(message.content)}</p>
|
||||
: renderAssistantMessageContent(message.content)}
|
||||
{renderToolCalls(message.toolCalls, true)}
|
||||
</div>
|
||||
);
|
||||
@@ -1110,7 +1168,7 @@ export function QuickChatFAB({
|
||||
data-testid="quick-chat-streaming-message"
|
||||
>
|
||||
{streamingText ? (
|
||||
<p data-testid="quick-chat-streaming-text">{renderMessageContent(streamingText)}</p>
|
||||
<div data-testid="quick-chat-streaming-text">{renderAssistantMessageContent(streamingText)}</div>
|
||||
) : (
|
||||
<p className="quick-chat-panel-waiting" data-testid="quick-chat-waiting">
|
||||
{streamingThinking ? "Thinking…" : "Connecting…"}
|
||||
|
||||
@@ -396,6 +396,81 @@ describe("ChatView", () => {
|
||||
expect(screen.getByText("Hi there!")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows markdown/plain toggle in thread header for active sessions", () => {
|
||||
setupMockChat({
|
||||
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", updatedAt: "2026-04-08T00:00:00.000Z" },
|
||||
messages: [{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "Hello", createdAt: "2026-04-08T00:00:00.000Z" }],
|
||||
});
|
||||
|
||||
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
expect(screen.getByTestId("chat-render-mode-markdown")).toHaveAttribute("aria-pressed", "true");
|
||||
expect(screen.getByTestId("chat-render-mode-plain")).toHaveAttribute("aria-pressed", "false");
|
||||
});
|
||||
|
||||
it("renders assistant messages as markdown by default and raw text in plain mode", async () => {
|
||||
setupMockChat({
|
||||
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", updatedAt: "2026-04-08T00:00:00.000Z" },
|
||||
messages: [
|
||||
{
|
||||
id: "msg-002",
|
||||
sessionId: "session-001",
|
||||
role: "assistant",
|
||||
content: "**Bold**\n\n- item",
|
||||
createdAt: "2026-04-08T00:01:00.000Z",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
const assistantBubble = screen.getByTestId("chat-message-msg-002");
|
||||
expect(within(assistantBubble).getByText("Bold", { selector: "strong" })).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByTestId("chat-render-mode-plain"));
|
||||
|
||||
expect(within(assistantBubble).getByText(/\*\*Bold\*\*/)).toBeInTheDocument();
|
||||
expect(within(assistantBubble).queryByText("Bold", { selector: "strong" })).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps user message rendering unchanged when toggling assistant render mode", async () => {
|
||||
setupMockChat({
|
||||
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", updatedAt: "2026-04-08T00:00:00.000Z" },
|
||||
messages: [
|
||||
{ id: "msg-001", sessionId: "session-001", role: "user", content: "**User** plain", createdAt: "2026-04-08T00:00:00.000Z" },
|
||||
],
|
||||
});
|
||||
|
||||
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
const userBubble = screen.getByTestId("chat-message-msg-001");
|
||||
expect(within(userBubble).getByText(/\*\*User\*\* plain/)).toBeInTheDocument();
|
||||
expect(within(userBubble).queryByText("User", { selector: "strong" })).toBeNull();
|
||||
|
||||
await userEvent.click(screen.getByTestId("chat-render-mode-plain"));
|
||||
|
||||
expect(within(userBubble).getByText(/\*\*User\*\* plain/)).toBeInTheDocument();
|
||||
expect(within(userBubble).queryByText("User", { selector: "strong" })).toBeNull();
|
||||
});
|
||||
|
||||
it("applies markdown/plain mode to streaming assistant text", async () => {
|
||||
setupMockChat({
|
||||
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", updatedAt: "2026-04-08T00:00:00.000Z" },
|
||||
messages: [{ id: "msg-001", sessionId: "session-001", role: "user", content: "Hello", createdAt: "2026-04-08T00:00:00.000Z" }],
|
||||
isStreaming: true,
|
||||
streamingText: "**Live** stream",
|
||||
});
|
||||
|
||||
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
const streamingBubble = document.querySelector(".chat-message--streaming") as HTMLElement;
|
||||
expect(within(streamingBubble).getByText("Live", { selector: "strong" })).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByTestId("chat-render-mode-plain"));
|
||||
|
||||
expect(within(streamingBubble).getByText(/\*\*Live\*\* stream/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders tool calls from persisted messages", () => {
|
||||
setupMockChat({
|
||||
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Tool Chat", updatedAt: "2026-04-08T00:00:00.000Z" },
|
||||
@@ -699,7 +774,7 @@ describe("ChatView", () => {
|
||||
expect(screen.queryByTestId("agent-mention-popup")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders known @mentions as highlighted chips", async () => {
|
||||
it("renders assistant mentions as plain text in markdown mode", async () => {
|
||||
setupMockChat({
|
||||
activeSession: activeSessionFixture,
|
||||
messages: [
|
||||
@@ -716,9 +791,10 @@ describe("ChatView", () => {
|
||||
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("@Alpha")).toHaveClass("chat-mention-chip");
|
||||
expect(screen.getByText(/Talk to @Alpha and @Unknown next\./)).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText(/@Unknown/)).not.toHaveClass("chat-mention-chip");
|
||||
expect(screen.queryByText("@Alpha", { selector: ".chat-mention-chip" })).toBeNull();
|
||||
expect(screen.queryByText("@Unknown", { selector: ".chat-mention-chip" })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1017,7 +1093,8 @@ describe("ChatView", () => {
|
||||
|
||||
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
const details = screen.getByText("Here's my response").parentElement?.querySelector("details");
|
||||
const message = screen.getByTestId("chat-message-msg-001");
|
||||
const details = message.querySelector("details");
|
||||
expect(details).toBeInTheDocument();
|
||||
expect(details).toHaveProperty("open", false);
|
||||
});
|
||||
|
||||
@@ -196,6 +196,97 @@ describe("QuickChatFAB", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("shows markdown/plain render toggle in quick chat header", async () => {
|
||||
render(<QuickChatFAB addToast={addToast} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId("quick-chat-fab"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("quick-chat-render-mode-markdown")).toHaveAttribute("aria-pressed", "true");
|
||||
expect(screen.getByTestId("quick-chat-render-mode-plain")).toHaveAttribute("aria-pressed", "false");
|
||||
});
|
||||
});
|
||||
|
||||
it("renders persisted assistant messages as markdown and switches to raw plain text", async () => {
|
||||
mockFetchChatMessages.mockResolvedValueOnce({
|
||||
messages: [
|
||||
{
|
||||
id: "msg-001",
|
||||
sessionId: "session-001",
|
||||
role: "assistant",
|
||||
content: "**Bold**\n\n- item",
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<QuickChatFAB addToast={addToast} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId("quick-chat-fab"));
|
||||
|
||||
const assistantBubble = await screen.findByTestId("quick-chat-message-msg-001");
|
||||
expect(within(assistantBubble).getByText("Bold", { selector: "strong" })).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByTestId("quick-chat-render-mode-plain"));
|
||||
|
||||
expect(within(assistantBubble).getByText(/\*\*Bold\*\*/)).toBeInTheDocument();
|
||||
expect(within(assistantBubble).queryByText("Bold", { selector: "strong" })).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps user message rendering unchanged when switching assistant render mode", async () => {
|
||||
mockFetchChatMessages.mockResolvedValueOnce({
|
||||
messages: [
|
||||
{
|
||||
id: "msg-001",
|
||||
sessionId: "session-001",
|
||||
role: "user",
|
||||
content: "**User** plain",
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<QuickChatFAB addToast={addToast} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId("quick-chat-fab"));
|
||||
|
||||
const userBubble = await screen.findByTestId("quick-chat-message-msg-001");
|
||||
expect(within(userBubble).getByText(/\*\*User\*\* plain/)).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByTestId("quick-chat-render-mode-plain"));
|
||||
|
||||
expect(within(userBubble).getByText(/\*\*User\*\* plain/)).toBeInTheDocument();
|
||||
expect(within(userBubble).queryByText("User", { selector: "strong" })).toBeNull();
|
||||
});
|
||||
|
||||
it("applies markdown/plain mode to streaming assistant text", async () => {
|
||||
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
|
||||
handlers.onText?.("**Live** stream");
|
||||
return {
|
||||
close: vi.fn(),
|
||||
isConnected: vi.fn(() => true),
|
||||
};
|
||||
});
|
||||
|
||||
render(<QuickChatFAB addToast={addToast} projectId="proj-123" />);
|
||||
|
||||
fireEvent.click(screen.getByTestId("quick-chat-fab"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchResumeChatSession).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const input = await screen.findByTestId("quick-chat-input");
|
||||
fireEvent.change(input, { target: { value: "Show stream" } });
|
||||
fireEvent.click(screen.getByTestId("quick-chat-send"));
|
||||
|
||||
const streamingText = await screen.findByTestId("quick-chat-streaming-text");
|
||||
expect(within(streamingText).getByText("Live", { selector: "strong" })).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByTestId("quick-chat-render-mode-plain"));
|
||||
expect(within(streamingText).getByText(/\*\*Live\*\* stream/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the model dropdown when panel is open in model mode", async () => {
|
||||
render(<QuickChatFAB addToast={addToast} />);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user