feat(FN-2503): merge fusion/fn-2503

This commit is contained in:
gsxdsm
2026-04-25 12:20:35 -07:00
parent 8c7f03246c
commit 944f2dedda
6 changed files with 527 additions and 8 deletions

View File

@@ -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;
}
}

View File

@@ -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…"}

View File

@@ -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 {

View File

@@ -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…"}

View File

@@ -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);
});

View File

@@ -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} />);