From fde23989305c395d10f862a839305e071ba42c32 Mon Sep 17 00:00:00 2001 From: Roman Lutz Date: Sat, 9 May 2026 06:17:03 -0700 Subject: [PATCH] FIX Pretty-print structured JSON assistant responses in chat bubble Targets that emit structured JSON instead of natural-language text (e.g. PromptShieldTarget returning {"userPromptAnalysis":{...}}) were dumped into the chat bubble as a single line of compact text, giving the user no help reading the result and no visual hint that the response is structured rather than prose. This change adds a tryFormatJson helper in MessageList that detects object- or array-shaped assistant content and renders it pretty- printed (2-space indent) inside a
 with monospace font, scoped
height, and overflow auto so a large response cannot dominate the
chat. Plain text, malformed JSON, scalar JSON values (true/42/null),
streaming/loading content, and user-typed JSON are all left as-is.

Tests cover all of those cases plus a round-trip JSON.parse on the
formatted output to guard against accidental data corruption.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
 .../src/components/Chat/MessageList.styles.ts |  15 ++
 .../src/components/Chat/MessageList.test.tsx  | 146 ++++++++++++++++++
 frontend/src/components/Chat/MessageList.tsx  |  55 ++++++-
 3 files changed, 211 insertions(+), 5 deletions(-)

diff --git a/frontend/src/components/Chat/MessageList.styles.ts b/frontend/src/components/Chat/MessageList.styles.ts
index 7273fa1589..83611260e9 100644
--- a/frontend/src/components/Chat/MessageList.styles.ts
+++ b/frontend/src/components/Chat/MessageList.styles.ts
@@ -32,6 +32,21 @@ export const useMessageListStyles = makeStyles({
     whiteSpace: 'pre-wrap',
     wordBreak: 'break-word',
   },
+  messageJsonBlock: {
+    margin: 0,
+    padding: tokens.spacingHorizontalS,
+    backgroundColor: tokens.colorNeutralBackground1,
+    border: `1px solid ${tokens.colorNeutralStroke2}`,
+    borderRadius: tokens.borderRadiusMedium,
+    fontFamily: tokens.fontFamilyMonospace,
+    fontSize: tokens.fontSizeBase200,
+    lineHeight: tokens.lineHeightBase200,
+    whiteSpace: 'pre-wrap',
+    wordBreak: 'break-word',
+    overflowX: 'auto',
+    maxHeight: '400px',
+    overflowY: 'auto',
+  },
   messageFooter: {
     display: 'flex',
     justifyContent: 'space-between',
diff --git a/frontend/src/components/Chat/MessageList.test.tsx b/frontend/src/components/Chat/MessageList.test.tsx
index fd68e15ab1..612cda2960 100644
--- a/frontend/src/components/Chat/MessageList.test.tsx
+++ b/frontend/src/components/Chat/MessageList.test.tsx
@@ -87,6 +87,152 @@ describe("MessageList", () => {
     expect(screen.getByText("Assistant message test")).toBeInTheDocument();
   });
 
+  describe("structured JSON assistant responses", () => {
+    // Targets like PromptShieldTarget return structured JSON instead of
+    // natural-language text. Render these as pretty-printed JSON in a 
+    // so the user can actually read them.
+
+    it("renders JSON object responses as pretty-printed 
", () => {
+      const messages: Message[] = [
+        {
+          role: "assistant",
+          content: '{"userPromptAnalysis":{"attackDetected":false},"documentsAnalysis":[]}',
+          timestamp: new Date().toISOString(),
+        },
+      ];
+      render(
+        
+          
+        
+      );
+      const block = screen.getByTestId("message-json-0");
+      expect(block.tagName).toBe("PRE");
+      // Pretty-printed (2-space indent) and round-trips to the original payload.
+      const text = block.textContent ?? "";
+      expect(text).toContain('"userPromptAnalysis": {\n');
+      expect(text).toContain('"attackDetected": false');
+      expect(JSON.parse(text)).toEqual({
+        userPromptAnalysis: { attackDetected: false },
+        documentsAnalysis: [],
+      });
+    });
+
+    it("renders JSON array responses as pretty-printed 
", () => {
+      const messages: Message[] = [
+        {
+          role: "assistant",
+          content: '[{"label":"safe","score":0.97},{"label":"unsafe","score":0.03}]',
+          timestamp: new Date().toISOString(),
+        },
+      ];
+      render(
+        
+          
+        
+      );
+      const block = screen.getByTestId("message-json-0");
+      expect(block.tagName).toBe("PRE");
+      expect(JSON.parse(block.textContent ?? "")).toEqual([
+        { label: "safe", score: 0.97 },
+        { label: "unsafe", score: 0.03 },
+      ]);
+    });
+
+    it("does not reformat plain text assistant content", () => {
+      const messages: Message[] = [
+        {
+          role: "assistant",
+          content: "Hello there!",
+          timestamp: new Date().toISOString(),
+        },
+      ];
+      render(
+        
+          
+        
+      );
+      expect(screen.queryByTestId("message-json-0")).not.toBeInTheDocument();
+      expect(screen.getByText("Hello there!")).toBeInTheDocument();
+    });
+
+    it("does not reformat malformed JSON-shaped content", () => {
+      const messages: Message[] = [
+        {
+          role: "assistant",
+          content: "{not really json",
+          timestamp: new Date().toISOString(),
+        },
+      ];
+      render(
+        
+          
+        
+      );
+      expect(screen.queryByTestId("message-json-0")).not.toBeInTheDocument();
+      expect(screen.getByText("{not really json")).toBeInTheDocument();
+    });
+
+    it("does not reformat user messages even if they are JSON-shaped", () => {
+      // A user pasting JSON into the input shouldn't have it silently
+      // reformatted in their own bubble.
+      const messages: Message[] = [
+        {
+          role: "user",
+          content: '{"prompt":"hello"}',
+          timestamp: new Date().toISOString(),
+        },
+      ];
+      render(
+        
+          
+        
+      );
+      expect(screen.queryByTestId("message-json-0")).not.toBeInTheDocument();
+      expect(screen.getByText('{"prompt":"hello"}')).toBeInTheDocument();
+    });
+
+    it("does not reformat scalar JSON values", () => {
+      // "true", "42", '"hello"' are all valid JSON but rendering them as
+      // pretty-printed JSON gains nothing — keep them as plain text.
+      for (const scalar of ["true", "42", '"hello"', "null"]) {
+        const { unmount } = render(
+          
+            
+          
+        );
+        expect(screen.queryByTestId("message-json-0")).not.toBeInTheDocument();
+        unmount();
+      }
+    });
+
+    it("does not reformat content while a message is still loading", () => {
+      // Streaming responses pass through with isLoading=true; the
+      // intermediate text may temporarily look JSON-ish.
+      const messages: Message[] = [
+        {
+          role: "assistant",
+          content: '{"partial":',
+          isLoading: true,
+          timestamp: new Date().toISOString(),
+        },
+      ];
+      render(
+        
+          
+        
+      );
+      expect(screen.queryByTestId("message-json-0")).not.toBeInTheDocument();
+    });
+  });
+
   it("should handle messages with image attachments", () => {
     const messagesWithAttachments: Message[] = [
       {
diff --git a/frontend/src/components/Chat/MessageList.tsx b/frontend/src/components/Chat/MessageList.tsx
index 6e2a54086a..5f60b3373b 100644
--- a/frontend/src/components/Chat/MessageList.tsx
+++ b/frontend/src/components/Chat/MessageList.tsx
@@ -80,6 +80,30 @@ function MediaWithFallback({ type, src, className }: { type: 'video' | 'audio';
   return