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