feat(FN-2587): switch chat render mode to per-message eye toggles

- Remove header-level markdown/plain render toggles from ChatView and QuickChatFAB
- Add per-assistant-message eye toggle state keyed by message id, including streaming responses
- Keep message eye controls visible and touch-friendly with updated assistant-message CSS behavior
- Update ChatView and QuickChatFAB tests to cover per-message render toggles and revised UI behavior
This commit is contained in:
Fusion
2026-04-26 09:48:00 -07:00
committed by gsxdsm
parent 50484c26ca
commit a692edf079
6 changed files with 214 additions and 338 deletions

View File

@@ -186,40 +186,6 @@
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;
@@ -264,7 +230,7 @@
}
.chat-message-render-toggle {
display: none;
display: inline-flex;
margin-left: auto;
width: calc(var(--space-md) * 3);
height: calc(var(--space-md) * 3);
@@ -276,6 +242,17 @@
border-color: transparent;
}
.chat-message--assistant .chat-message-render-toggle {
opacity: 0.6;
transition: opacity var(--transition-fast);
}
.chat-message--assistant:hover .chat-message-render-toggle,
.chat-message-render-toggle:focus-visible,
.chat-message-render-toggle.chat-message-render-toggle--plain {
opacity: 1;
}
.chat-message-render-toggle:hover {
color: var(--text);
background: color-mix(in srgb, var(--surface) 55%, transparent);
@@ -816,11 +793,7 @@
flex-wrap: wrap;
}
.chat-render-mode-toggle {
display: none;
}
.chat-message-render-toggle {
display: inline-flex;
.chat-message--assistant .chat-message-render-toggle {
opacity: 1;
}
}

View File

@@ -482,7 +482,6 @@ 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);
const [plainTextMessageIds, setPlainTextMessageIds] = useState<Set<string>>(() => new Set());
// File mention state and hook
@@ -1048,7 +1047,7 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
const renderAssistantContent = useCallback(
(content: string, forcePlain = false) => {
const showPlainText = isMobile ? forcePlain : forcePlain || !renderAssistantMarkdown;
const showPlainText = forcePlain;
if (showPlainText) {
return <div className="chat-message-content chat-message-content--plain">{content}</div>;
}
@@ -1061,7 +1060,7 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
</div>
);
},
[isMobile, renderAssistantMarkdown],
[],
);
return (
@@ -1219,30 +1218,7 @@ 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>
)}
@@ -1273,17 +1249,15 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
<Bot size={14} />
<span>{agentName}</span>
{showAssistantModelTag && <span className="chat-model-tag">{activeModelTag}</span>}
{isMobile && (
<button
type="button"
className={`btn btn-icon chat-message-render-toggle${forcePlain ? " chat-message-render-toggle--plain" : ""}`}
data-testid="chat-message-render-toggle"
aria-label={forcePlain ? "Show rendered markdown" : "Show plain text"}
onClick={() => toggleMessageRenderMode(message.id)}
>
{forcePlain ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
)}
<button
type="button"
className={`btn btn-icon chat-message-render-toggle${forcePlain ? " chat-message-render-toggle--plain" : ""}`}
data-testid="chat-message-render-toggle"
aria-label={forcePlain ? "Show rendered markdown" : "Show plain text"}
onClick={() => toggleMessageRenderMode(message.id)}
>
{forcePlain ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
</div>
)}
{isAssistantMessage
@@ -1306,17 +1280,15 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
<Bot size={14} />
<span>{agentName}</span>
{showAssistantModelTag && <span className="chat-model-tag">{activeModelTag}</span>}
{isMobile && (
<button
type="button"
className={`btn btn-icon chat-message-render-toggle${plainTextMessageIds.has("__streaming__") ? " chat-message-render-toggle--plain" : ""}`}
data-testid="chat-message-render-toggle"
aria-label={plainTextMessageIds.has("__streaming__") ? "Show rendered markdown" : "Show plain text"}
onClick={() => toggleMessageRenderMode("__streaming__")}
>
{plainTextMessageIds.has("__streaming__") ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
)}
<button
type="button"
className={`btn btn-icon chat-message-render-toggle${plainTextMessageIds.has("__streaming__") ? " chat-message-render-toggle--plain" : ""}`}
data-testid="chat-message-render-toggle"
aria-label={plainTextMessageIds.has("__streaming__") ? "Show rendered markdown" : "Show plain text"}
onClick={() => toggleMessageRenderMode("__streaming__")}
>
{plainTextMessageIds.has("__streaming__") ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
</div>
{streamingText ? (
renderAssistantContent(streamingText, plainTextMessageIds.has("__streaming__"))

View File

@@ -83,40 +83,6 @@
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);
@@ -275,10 +241,45 @@
}
.quick-chat-panel-message--received {
position: relative;
align-self: flex-start;
background: var(--card);
}
.quick-chat-message-render-toggle {
position: absolute;
top: var(--space-xs);
right: var(--space-xs);
width: 20px;
height: 20px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity var(--transition-fast);
color: var(--text-tertiary);
background: transparent;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
}
.quick-chat-panel-message--received:hover .quick-chat-message-render-toggle,
.quick-chat-message-render-toggle:focus-visible {
opacity: 1;
}
.quick-chat-message-render-toggle--plain {
opacity: 1;
color: var(--accent);
}
.quick-chat-message-render-toggle:hover {
background: var(--surface-hover, color-mix(in srgb, var(--surface) 70%, transparent));
color: var(--text);
}
.quick-chat-panel-input {
display: flex;
align-items: center;
@@ -358,14 +359,6 @@
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

@@ -11,7 +11,7 @@ import {
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 { Eye, EyeOff, MessageSquare, Send, Square, Wrench, X } from "lucide-react";
import { fetchModels, type Agent, type ModelInfo } from "../api";
import { CustomModelDropdown } from "./CustomModelDropdown";
import { AgentMentionPopup } from "./AgentMentionPopup";
@@ -452,7 +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);
const [plainTextMessageIds, setPlainTextMessageIds] = useState<Set<string>>(() => new Set());
// File mention state and hook
const [, setFileMentionPopupVisible] = useState(false);
@@ -893,9 +893,21 @@ export function QuickChatFAB({
[mentionAgentsByName],
);
const toggleMessageRenderMode = useCallback((messageId: string) => {
setPlainTextMessageIds((current) => {
const next = new Set(current);
if (next.has(messageId)) {
next.delete(messageId);
} else {
next.add(messageId);
}
return next;
});
}, []);
const renderAssistantMessageContent = useCallback(
(content: string) => {
if (!renderAssistantMarkdown) {
(content: string, forcePlain = false) => {
if (forcePlain) {
return <div className="quick-chat-message-content quick-chat-message-content--plain">{content}</div>;
}
@@ -907,7 +919,7 @@ export function QuickChatFAB({
</div>
);
},
[renderAssistantMarkdown],
[],
);
const handleInputKeyDown = useCallback(
@@ -1032,28 +1044,7 @@ 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"
@@ -1148,6 +1139,7 @@ export function QuickChatFAB({
<>
{messages.map((message: ChatMessageInfo) => {
const isSent = message.role === "user";
const forcePlain = !isSent && plainTextMessageIds.has(message.id);
return (
<div
key={message.id}
@@ -1156,7 +1148,20 @@ export function QuickChatFAB({
>
{isSent
? <p>{renderMessageContent(message.content)}</p>
: renderAssistantMessageContent(message.content)}
: (
<>
{renderAssistantMessageContent(message.content, forcePlain)}
<button
type="button"
className={`btn btn-icon quick-chat-message-render-toggle${forcePlain ? " quick-chat-message-render-toggle--plain" : ""}`}
data-testid="quick-chat-message-render-toggle"
aria-label={forcePlain ? "Show rendered markdown" : "Show plain text"}
onClick={() => toggleMessageRenderMode(message.id)}
>
{forcePlain ? <EyeOff size={12} /> : <Eye size={12} />}
</button>
</>
)}
{renderToolCalls(message.toolCalls, true)}
</div>
);
@@ -1168,7 +1173,20 @@ export function QuickChatFAB({
data-testid="quick-chat-streaming-message"
>
{streamingText ? (
<div data-testid="quick-chat-streaming-text">{renderAssistantMessageContent(streamingText)}</div>
<>
<div data-testid="quick-chat-streaming-text">
{renderAssistantMessageContent(streamingText, plainTextMessageIds.has("__streaming__"))}
</div>
<button
type="button"
className={`btn btn-icon quick-chat-message-render-toggle${plainTextMessageIds.has("__streaming__") ? " quick-chat-message-render-toggle--plain" : ""}`}
data-testid="quick-chat-message-render-toggle"
aria-label={plainTextMessageIds.has("__streaming__") ? "Show rendered markdown" : "Show plain text"}
onClick={() => toggleMessageRenderMode("__streaming__")}
>
{plainTextMessageIds.has("__streaming__") ? <EyeOff size={12} /> : <Eye size={12} />}
</button>
</>
) : (
<p className="quick-chat-panel-waiting" data-testid="quick-chat-waiting">
{streamingThinking ? "Thinking…" : "Connecting…"}

View File

@@ -423,7 +423,7 @@ describe("ChatView", () => {
expect(screen.getByText("Hi there!")).toBeInTheDocument();
});
it("shows markdown/plain toggle in thread header for active sessions", () => {
it("does not render markdown/plain toggle controls in the thread header", () => {
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" }],
@@ -431,165 +431,65 @@ describe("ChatView", () => {
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");
expect(screen.queryByTestId("chat-render-mode-markdown")).not.toBeInTheDocument();
expect(screen.queryByTestId("chat-render-mode-plain")).not.toBeInTheDocument();
});
it("renders assistant messages as markdown by default and raw text in plain mode", async () => {
it("renders per-message eye toggles for assistant bubbles on desktop and isolates toggles by message", 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",
},
{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "**First** item", createdAt: "2026-04-08T00:00:00.000Z" },
{ id: "msg-002", sessionId: "session-001", role: "assistant", content: "**Second** 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();
const firstBubble = screen.getByTestId("chat-message-msg-001");
const secondBubble = screen.getByTestId("chat-message-msg-002");
const [firstToggle, secondToggle] = screen.getAllByTestId("chat-message-render-toggle");
await userEvent.click(screen.getByTestId("chat-render-mode-plain"));
expect(firstToggle).toBeInTheDocument();
expect(secondToggle).toBeInTheDocument();
expect(within(firstBubble).getByText("First", { selector: "strong" })).toBeInTheDocument();
expect(within(secondBubble).getByText("Second", { selector: "strong" })).toBeInTheDocument();
expect(within(assistantBubble).getByText(/\*\*Bold\*\*/)).toBeInTheDocument();
expect(within(assistantBubble).queryByText("Bold", { selector: "strong" })).toBeNull();
await userEvent.click(firstToggle);
expect(within(firstBubble).getByText(/\*\*First\*\* item/)).toBeInTheDocument();
expect(within(firstBubble).queryByText("First", { selector: "strong" })).toBeNull();
expect(within(secondBubble).getByText("Second", { selector: "strong" })).toBeInTheDocument();
await userEvent.click(firstToggle);
expect(within(firstBubble).getByText("First", { selector: "strong" })).toBeInTheDocument();
});
it("keeps user message rendering unchanged when toggling assistant render mode", async () => {
it("uses a dedicated streaming toggle sentinel without affecting persisted assistant messages", 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" }],
messages: [{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "**Persisted**", createdAt: "2026-04-08T00:00:00.000Z" }],
isStreaming: true,
streamingText: "**Live** stream",
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const persistedBubble = screen.getByTestId("chat-message-msg-001");
const streamingBubble = document.querySelector(".chat-message--streaming") as HTMLElement;
expect(within(streamingBubble).getByText("Live", { selector: "strong" })).toBeInTheDocument();
const [persistedToggle, streamingToggle] = screen.getAllByTestId("chat-message-render-toggle");
await userEvent.click(screen.getByTestId("chat-render-mode-plain"));
expect(within(streamingBubble).getByText("Live", { selector: "strong" })).toBeInTheDocument();
expect(within(persistedBubble).getByText("Persisted", { selector: "strong" })).toBeInTheDocument();
await userEvent.click(streamingToggle);
expect(within(streamingBubble).getByText(/\*\*Live\*\* stream/)).toBeInTheDocument();
});
expect(within(persistedBubble).getByText("Persisted", { selector: "strong" })).toBeInTheDocument();
it("renders inline per-message render toggles for assistant bubbles on mobile", () => {
const restoreMatchMedia = mockViewportMode("mobile");
try {
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: "**First** item", createdAt: "2026-04-08T00:00:00.000Z" },
{ id: "msg-002", sessionId: "session-001", role: "assistant", content: "**Second** item", createdAt: "2026-04-08T00:01:00.000Z" },
],
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
expect(screen.getAllByTestId("chat-message-render-toggle")).toHaveLength(2);
} finally {
restoreMatchMedia();
}
});
it("toggles plain text for one mobile assistant message without affecting others", async () => {
const restoreMatchMedia = mockViewportMode("mobile");
try {
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: "**First** item", createdAt: "2026-04-08T00:00:00.000Z" },
{ id: "msg-002", sessionId: "session-001", role: "assistant", content: "**Second** item", createdAt: "2026-04-08T00:01:00.000Z" },
],
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const firstBubble = screen.getByTestId("chat-message-msg-001");
const secondBubble = screen.getByTestId("chat-message-msg-002");
const [firstToggle] = screen.getAllByTestId("chat-message-render-toggle");
expect(within(firstBubble).getByText("First", { selector: "strong" })).toBeInTheDocument();
expect(within(secondBubble).getByText("Second", { selector: "strong" })).toBeInTheDocument();
await userEvent.click(firstToggle);
expect(within(firstBubble).getByText(/\*\*First\*\* item/)).toBeInTheDocument();
expect(within(firstBubble).queryByText("First", { selector: "strong" })).toBeNull();
expect(within(secondBubble).getByText("Second", { selector: "strong" })).toBeInTheDocument();
} finally {
restoreMatchMedia();
}
});
it("toggles mobile assistant message back to markdown when clicked again", async () => {
const restoreMatchMedia = mockViewportMode("mobile");
try {
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: "**First** item", createdAt: "2026-04-08T00:00:00.000Z" },
],
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const bubble = screen.getByTestId("chat-message-msg-001");
const toggle = screen.getByTestId("chat-message-render-toggle");
await userEvent.click(toggle);
expect(within(bubble).getByText(/\*\*First\*\* item/)).toBeInTheDocument();
await userEvent.click(toggle);
expect(within(bubble).getByText("First", { selector: "strong" })).toBeInTheDocument();
} finally {
restoreMatchMedia();
}
});
it("hides the header markdown/plain toggle controls on mobile via CSS", () => {
const restoreMatchMedia = mockViewportMode("mobile");
try {
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: "**First** item", createdAt: "2026-04-08T00:00:00.000Z" },
],
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
expect(screen.queryByTestId("chat-render-mode-markdown")).toBeInTheDocument();
expect(screen.queryByTestId("chat-render-mode-plain")).toBeInTheDocument();
expect(document.querySelector(".chat-render-mode-toggle")).toBeInTheDocument();
} finally {
restoreMatchMedia();
}
await userEvent.click(persistedToggle);
expect(within(persistedBubble).getByText(/\*\*Persisted\*\*/)).toBeInTheDocument();
expect(within(streamingBubble).getByText(/\*\*Live\*\* stream/)).toBeInTheDocument();
});
it("renders tool calls from persisted messages", () => {
@@ -2150,11 +2050,7 @@ describe("ChatView mobile CSS contract", () => {
expect(mobileRuleContains(".chat-sidebar-footer-btn", "justify-content: center")).toBe(true);
});
it("mobile hides thread header markdown/plain toggle container", () => {
expect(mobileRuleContains(".chat-render-mode-toggle", "display: none")).toBe(true);
});
it("mobile shows inline assistant render toggle button", () => {
expect(mobileRuleContains(".chat-message-render-toggle", "display: inline-flex")).toBe(true);
it("mobile does not override assistant render toggle visibility", () => {
expect(mobileRuleNotContains(".chat-message-render-toggle", "display: inline-flex")).toBe(true);
});
});

View File

@@ -196,53 +196,43 @@ describe("QuickChatFAB", () => {
});
});
it("shows markdown/plain render toggle in quick chat header", async () => {
it("does not render 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");
expect(screen.getByTestId("quick-chat-panel")).toBeDefined();
});
expect(screen.queryByTestId("quick-chat-render-mode-markdown")).not.toBeInTheDocument();
expect(screen.queryByTestId("quick-chat-render-mode-plain")).not.toBeInTheDocument();
});
it("renders persisted assistant messages as markdown and switches to raw plain text", async () => {
it("shows eye toggles only on received messages and toggles per message", 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",
id: "msg-user",
sessionId: "session-001",
role: "user",
content: "**User** plain",
createdAt: new Date().toISOString(),
},
{
id: "msg-assistant-1",
sessionId: "session-001",
role: "assistant",
content: "**Bold** one",
createdAt: new Date().toISOString(),
},
{
id: "msg-assistant-2",
sessionId: "session-001",
role: "assistant",
content: "**Bold** two",
createdAt: new Date().toISOString(),
},
],
});
@@ -250,16 +240,41 @@ describe("QuickChatFAB", () => {
fireEvent.click(screen.getByTestId("quick-chat-fab"));
const userBubble = await screen.findByTestId("quick-chat-message-msg-001");
expect(within(userBubble).getByText(/\*\*User\*\* plain/)).toBeInTheDocument();
const userBubble = await screen.findByTestId("quick-chat-message-msg-user");
const firstAssistantBubble = await screen.findByTestId("quick-chat-message-msg-assistant-1");
const secondAssistantBubble = await screen.findByTestId("quick-chat-message-msg-assistant-2");
fireEvent.click(screen.getByTestId("quick-chat-render-mode-plain"));
expect(within(userBubble).queryByTestId("quick-chat-message-render-toggle")).toBeNull();
const toggles = screen.getAllByTestId("quick-chat-message-render-toggle");
expect(toggles).toHaveLength(2);
expect(within(firstAssistantBubble).getByText("Bold", { selector: "strong" })).toBeInTheDocument();
expect(within(secondAssistantBubble).getByText("Bold", { selector: "strong" })).toBeInTheDocument();
fireEvent.click(toggles[0]);
expect(within(firstAssistantBubble).getByText(/\*\*Bold\*\* one/)).toBeInTheDocument();
expect(within(firstAssistantBubble).queryByText("Bold", { selector: "strong" })).toBeNull();
expect(within(secondAssistantBubble).getByText("Bold", { selector: "strong" })).toBeInTheDocument();
fireEvent.click(toggles[0]);
expect(within(firstAssistantBubble).getByText("Bold", { selector: "strong" })).toBeInTheDocument();
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 () => {
it("uses the streaming sentinel toggle without affecting persisted received messages", async () => {
mockFetchChatMessages.mockResolvedValueOnce({
messages: [
{
id: "msg-assistant",
sessionId: "session-001",
role: "assistant",
content: "**Persisted** message",
createdAt: new Date().toISOString(),
},
],
});
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
handlers.onText?.("**Live** stream");
return {
@@ -281,9 +296,18 @@ describe("QuickChatFAB", () => {
fireEvent.click(screen.getByTestId("quick-chat-send"));
const streamingText = await screen.findByTestId("quick-chat-streaming-text");
expect(within(streamingText).getByText("Live", { selector: "strong" })).toBeInTheDocument();
const persistedBubble = await screen.findByTestId("quick-chat-message-msg-assistant");
const [persistedToggle, streamingToggle] = screen.getAllByTestId("quick-chat-message-render-toggle");
fireEvent.click(screen.getByTestId("quick-chat-render-mode-plain"));
expect(within(streamingText).getByText("Live", { selector: "strong" })).toBeInTheDocument();
expect(within(persistedBubble).getByText("Persisted", { selector: "strong" })).toBeInTheDocument();
fireEvent.click(streamingToggle);
expect(within(streamingText).getByText(/\*\*Live\*\* stream/)).toBeInTheDocument();
expect(within(persistedBubble).getByText("Persisted", { selector: "strong" })).toBeInTheDocument();
fireEvent.click(persistedToggle);
expect(within(persistedBubble).getByText(/\*\*Persisted\*\* message/)).toBeInTheDocument();
expect(within(streamingText).getByText(/\*\*Live\*\* stream/)).toBeInTheDocument();
});