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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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__"))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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…"}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user