feat(FN-2570): add mobile per-message chat render toggles

- Track plain-text render mode per assistant message (including streaming output) in ChatView state
- Add inline mobile-only Eye/EyeOff toggle buttons on assistant bubbles to switch markdown vs plain text per message
- Hide the header markdown/plain toggle on mobile and polish inline toggle sizing, focus, and hover styles in ChatView.css
- Extend ChatView tests with mobile viewport coverage for toggle rendering, per-message isolation, round-trip toggling, and mobile CSS contract checks
This commit is contained in:
Fusion
2026-04-25 20:14:10 -07:00
committed by gsxdsm
parent 26f9c74d8f
commit c644daca77
3 changed files with 232 additions and 35 deletions

View File

@@ -263,6 +263,33 @@
color: var(--text-secondary);
}
.chat-message-render-toggle {
display: none;
margin-left: auto;
width: calc(var(--space-md) * 3);
height: calc(var(--space-md) * 3);
min-width: calc(var(--space-md) * 3);
min-height: calc(var(--space-md) * 3);
padding: 0;
color: var(--text-muted);
background: transparent;
border-color: transparent;
}
.chat-message-render-toggle:hover {
color: var(--text);
background: color-mix(in srgb, var(--surface) 55%, transparent);
}
.chat-message-render-toggle:focus-visible {
outline: none;
box-shadow: var(--focus-ring-strong);
}
.chat-message-render-toggle--plain {
color: var(--text);
}
.chat-model-tag {
font-size: 11px;
padding: 1px 6px;
@@ -790,12 +817,10 @@
}
.chat-render-mode-toggle {
width: 100%;
margin-left: 0;
display: none;
}
.chat-render-mode-btn {
flex: 1;
text-align: center;
.chat-message-render-toggle {
display: inline-flex;
}
}

View File

@@ -15,6 +15,8 @@ import {
Bot,
Square,
Wrench,
Eye,
EyeOff,
} from "lucide-react";
import { useChat, type ToolCallInfo } from "../hooks/useChat";
import { useViewportMode } from "./Header";
@@ -481,6 +483,7 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
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);
@@ -1031,9 +1034,22 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
? `${pendingMessage.slice(0, 50)}`
: pendingMessage;
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 renderAssistantContent = useCallback(
(content: string) => {
if (!renderAssistantMarkdown) {
(content: string, forcePlain = false) => {
const showPlainText = isMobile ? forcePlain : forcePlain || !renderAssistantMarkdown;
if (showPlainText) {
return <div className="chat-message-content chat-message-content--plain">{content}</div>;
}
@@ -1045,7 +1061,7 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
</div>
);
},
[renderAssistantMarkdown],
[isMobile, renderAssistantMarkdown],
);
return (
@@ -1242,41 +1258,68 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
</div>
) : (
<>
{messages.map((message) => (
<div
key={message.id}
className={`chat-message chat-message--${message.role}`}
data-testid={`chat-message-${message.id}`}
>
{message.role === "assistant" && (
<div className="chat-message-avatar">
<Bot size={14} />
<span>{agentName}</span>
{showAssistantModelTag && <span className="chat-model-tag">{activeModelTag}</span>}
</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">
<summary>Thinking</summary>
<pre className="chat-message-thinking-content">{message.thinkingOutput}</pre>
</details>
)}
<div className="chat-message-time">{formatRelativeTime(message.createdAt)}</div>
</div>
))}
{messages.map((message) => {
const isAssistantMessage = message.role === "assistant";
const forcePlain = plainTextMessageIds.has(message.id);
return (
<div
key={message.id}
className={`chat-message chat-message--${message.role}`}
data-testid={`chat-message-${message.id}`}
>
{isAssistantMessage && (
<div className="chat-message-avatar">
<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>
)}
</div>
)}
{isAssistantMessage
? renderAssistantContent(message.content, forcePlain)
: <div className="chat-message-content">{renderMessageContent(message.content)}</div>}
{renderToolCalls(message.toolCalls)}
{message.thinkingOutput && (
<details className="chat-message-thinking">
<summary>Thinking</summary>
<pre className="chat-message-thinking-content">{message.thinkingOutput}</pre>
</details>
)}
<div className="chat-message-time">{formatRelativeTime(message.createdAt)}</div>
</div>
);
})}
{isStreaming && (
<div className="chat-message chat-message--assistant chat-message--streaming">
<div className="chat-message-avatar">
<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>
)}
</div>
{streamingText ? (
renderAssistantContent(streamingText)
renderAssistantContent(streamingText, plainTextMessageIds.has("__streaming__"))
) : (
<div className="chat-message-content chat-message-content--waiting">
{streamingThinking ? "Thinking…" : "Connecting…"}

View File

@@ -37,6 +37,8 @@ vi.mock("lucide-react", async (importOriginal) => {
ChevronLeft: ({ "data-testid": testId, ...props }: any) => <svg data-testid={testId || "icon-chevron-left"} {...props} />,
Bot: ({ "data-testid": testId, ...props }: any) => <svg data-testid={testId || "icon-bot"} {...props} />,
Square: ({ "data-testid": testId, ...props }: any) => <svg data-testid={testId || "icon-square"} {...props} />,
Eye: ({ "data-testid": testId, ...props }: any) => <svg data-testid={testId || "icon-eye"} {...props} />,
EyeOff: ({ "data-testid": testId, ...props }: any) => <svg data-testid={testId || "icon-eye-off"} {...props} />,
};
});
@@ -138,6 +140,31 @@ function setupMockChat(overrides: Partial<typeof defaultChatState> = {}) {
mockUseChat.mockReturnValue(state as any);
}
function ensureMatchMedia() {
if (!window.matchMedia) {
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn(),
});
}
}
function mockViewportMode(mode: "mobile" | "desktop") {
ensureMatchMedia();
const isMobile = mode === "mobile";
Object.defineProperty(window, "innerWidth", { value: isMobile ? 375 : 1280, configurable: true });
return vi.spyOn(window, "matchMedia").mockImplementation((query: string) => ({
matches: isMobile && query === "(max-width: 768px)",
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
}
beforeEach(() => {
vi.clearAllMocks();
mockFetchDiscoveredSkills.mockResolvedValue([]);
@@ -471,6 +498,100 @@ describe("ChatView", () => {
expect(within(streamingBubble).getByText(/\*\*Live\*\* stream/)).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();
}
});
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" },
@@ -2028,4 +2149,12 @@ describe("ChatView mobile CSS contract", () => {
expect(mobileRuleContains(".chat-sidebar-footer-btn", "flex: 1")).toBe(true);
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);
});
});