Skip to content

[UI-REWRITE] Render tool preview results - #55

Open
gandhipratik203 wants to merge 4 commits into
mainfrom
feat/6317-tool-result-rendering
Open

[UI-REWRITE] Render tool preview results#55
gandhipratik203 wants to merge 4 commits into
mainfrom
feat/6317-tool-result-rendering

Conversation

@gandhipratik203

@gandhipratik203 gandhipratik203 commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Summary

In simple terms: when you click Preview for a tool, PR 1 (#53) showed mostly raw JSON. This PR makes the result easier to read.

It adds UI support for:

  • text results
  • JSON results
  • image results, including SVG content returned as text
  • downloadable binary/PDF-style results
  • structured output
  • warning messages
  • error result badges
  • large result protection for content blocks, aggregate result size, structured output, and raw responses, with oversized content collapsed behind View all

Before / After

Before PR #55
=============

Tool details drawer
  |
  v
Try it tab
  |
  v
Preview response
  |
  +-- Preview 200
  +-- Resolved arguments
  +-- Raw preview response
        |
        v
      User reads JSON manually


After PR #55
============

Tool details drawer
  |
  v
Try it tab
  |
  v
Preview response
  |
  +-- Preview 200
  +-- Warnings
  +-- Tool result
  |     |
  |     +-- Text result
  |     +-- JSON result
  |     +-- Image preview
  |     +-- PDF / binary download
  |     +-- Error response badge
  |     `-- Large / aggregate result -> View all
  |
  +-- Structured output
  |     `-- Oversized output -> View all
  +-- Resolved arguments
  +-- Raw preview response
        |
        +-- Normal response opens by default
        `-- Oversized raw JSON -> View all

Context

Notes

  • Still mock-backed; no real backend preview endpoint is required for this PR.
  • The UI remains behind VITE_ENABLE_TOOL_PREVIEW inherited from PR 1.
  • Latest review fixes harden preview reruns, aggregate result limits, SVG text images, decoded binary sizes, empty data handling, raw/structured large guards, and i18n fallback text.

Tests

  • npm run test
  • npx vitest run --coverage --pool=threads
  • npx tsc --noEmit -p tsconfig.app.json
  • npm run e2e -- e2e/tools.spec.ts
  • npm run format:check
  • npm run lint
  • git diff --check

Manual verification

Manual test steps

Setup

git checkout feat/6317-tool-result-rendering
npm ci                 # if node_modules is missing
npm run generate       # if src/generated/ is missing

Save the mock script from the next collapsible at the repo root as tool-result-rendering-manual.mjs.

Two terminals:

# terminal A - dev server with the temporary tool-preview flag enabled
VITE_ENABLE_TOOL_PREVIEW=true npm run dev

# terminal B - opens the mocked browser
node tool-result-rendering-manual.mjs

Terminal B opens a Chrome for Testing window with /auth/session, /api/rbac/my/permissions, /api/tools, /api/gateways, and two /api/tools/preview/* responses mocked. Ctrl-C in terminal B to close. Do everything in that window, in the tab it opens.

Steps

1. Open More options for render-lab -> View details.
Expect: the details drawer opens with Try it selected.

2. Confirm both tool chips are visible: render_rich_result and render_error_large_result.

3. For render_rich_result, fill query with cloudflare and limit with 5, then click Preview.
Expect: Preview 200, Warnings, Tool result, Content block 1..5, Structured output, and Raw preview response.

4. Inspect the rich result.
Expect: text output, formatted JSON with "total": 2, an inline SVG image rendered from text content, PDF Open in new tab + Download raw, binary Download raw, warning text for approval_hook plus the mocked server default, and Raw preview response open by default.

5. Click the render_error_large_result tool chip.

6. Fill query with failure, then click Preview.
Expect: Preview 200 with an Error response badge.

7. Inspect the large result.
Expect: the large content block, large structured output, and oversized raw response are collapsed behind View all.

8. Click View all on the large content block.
Expect: END_OF_LARGE_RESULT appears.

9. Click View all on the large structured output.
Expect: END_OF_LARGE_STRUCTURED_OUTPUT appears.

10. Click View all in Raw preview response.
Expect: the raw JSON appears only after expansion.

Teardown

Ctrl-C both terminals. If :5173 is stuck:

lsof -ti:5173 | xargs kill
Mock script (tool-result-rendering-manual.mjs)

Save at the repo root. Requires @playwright/test, already a dev dependency; run npx playwright install chromium if the browser is missing.

// Manual UI testing for contextforge-web-ui#55 - tool preview result rendering.
//
//   VITE_ENABLE_TOOL_PREVIEW=true npm run dev  # terminal A, Vite on :5173
//   node tool-result-rendering-manual.mjs       # terminal B
//
// Ctrl-C in terminal B to close the headed browser.
//
// This mocks the backend endpoints needed by /app/tools, including two
// /api/tools/preview/* responses. It verifies frontend rendering only: text,
// JSON, image, PDF/download, structured output, warnings, error badges, and
// large-result collapse behavior.

import { chromium } from "@playwright/test";

const BASE = process.env.BASE_URL ?? "http://localhost:5173";
const HEADED = !process.env.HEADLESS;

const SVG_IMAGE = `
<svg xmlns="http://www.w3.org/2000/svg" width="480" height="220" viewBox="0 0 480 220">
  <rect width="480" height="220" fill="#f8fafc"/>
  <rect x="24" y="24" width="432" height="172" rx="12" fill="#ffffff" stroke="#94a3b8"/>
  <text x="48" y="78" font-family="Inter, Arial, sans-serif" font-size="26" font-weight="700" fill="#0f172a">Tool result image</text>
  <text x="48" y="122" font-family="Inter, Arial, sans-serif" font-size="18" fill="#475569">Rendered from SVG text content</text>
  <circle cx="388" cy="108" r="34" fill="#10b981"/>
  <path d="M374 108l10 10 22-26" fill="none" stroke="#ffffff" stroke-width="7" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
`.trim();

const USER = {
  email: "test@example.com",
  full_name: "Test User",
  is_admin: true,
  is_active: true,
  auth_provider: "local",
  email_verified: true,
  password_change_required: false,
};

function makeTool(id, overrides = {}) {
  return {
    id: `tool-${id}`,
    name: id,
    originalName: id,
    description: `Mocked ${id} preview result rendering`,
    originalDescription: `Mocked ${id} preview result rendering`,
    title: id,
    gatewayId: "gw-render-lab",
    gatewaySlug: "render-lab",
    customName: id,
    customNameSlug: id,
    enabled: true,
    reachable: true,
    deprecated: false,
    executionCount: 0,
    tags: [],
    integrationType: "mcp",
    requestType: "http",
    url: "https://render.example/mcp",
    headers: {},
    inputSchema: {
      type: "object",
      required: ["query"],
      properties: {
        query: { type: "string", description: "Preview query" },
        limit: { type: "integer", description: "Maximum rows to return" },
      },
    },
    annotations: { readOnlyHint: true },
    jsonpathFilter: null,
    auth: null,
    createdAt: "2026-04-10T10:00:00Z",
    updatedAt: "2026-04-10T10:00:00Z",
    ...overrides,
  };
}

const RICH_RESULT_TOOL = makeTool("render_rich_result", {
  description: "Returns text, JSON, image, PDF/download, warnings, and structured output.",
});

const ERROR_LARGE_TOOL = makeTool("render_error_large_result", {
  description: "Returns an error result plus large content hidden behind View all.",
  annotations: { readOnlyHint: true, destructiveHint: true },
  inputSchema: {
    type: "object",
    required: ["query"],
    properties: {
      query: { type: "string", description: "Error preview query" },
    },
  },
});

const GATEWAY_RESPONSE = {
  gateways: [
    {
      id: "gw-render-lab",
      name: "render-lab",
      url: "https://render.example/mcp",
      description: "Mocked MCP tools for manual result-rendering verification",
    },
  ],
  nextCursor: null,
};

function json(body, status = 200) {
  return {
    status,
    contentType: "application/json",
    body: JSON.stringify(body),
  };
}

function fallbackApiBody(pathname) {
  if (pathname.startsWith("/api/resources")) return [];
  if (pathname.startsWith("/api/prompts")) return [];
  if (pathname.startsWith("/api/servers")) return [];
  return {};
}

function interestingHeaders(headers) {
  return Object.fromEntries(
    Object.entries(headers).filter(([name]) =>
      ["authorization", "x-api-key", "x-tenant-id"].includes(name.toLowerCase()),
    ),
  );
}

function logPreviewRequest(label, request) {
  console.log(`\n${label} preview request body:`);
  console.log(JSON.stringify(request.postDataJSON(), null, 2));
  console.log(`${label} preview passthrough-ish headers:`);
  console.log(JSON.stringify(interestingHeaders(request.headers()), null, 2));
}

const browser = await chromium.launch({ headless: !HEADED });
const context = await browser.newContext({ viewport: { width: 1512, height: 950 } });
const page = await context.newPage();

page.on("console", (message) => {
  if (["error", "warning"].includes(message.type())) {
    console.log(`browser ${message.type()}: ${message.text()}`);
  }
});
page.on("pageerror", (error) => {
  console.log(`browser pageerror: ${error.message}`);
});

// Register broad API fallbacks first. Playwright evaluates the newest matching
// route first, so endpoint-specific mocks below must be registered after this.
await page.route("**/*", (route) => {
  const pathname = new URL(route.request().url()).pathname;
  if (pathname.startsWith("/api/")) return route.fulfill(json(fallbackApiBody(pathname)));
  return route.fallback();
});

await page.route("**/auth/session", (route) =>
  route.fulfill(
    json({
      authenticated: true,
      user: USER,
      csrfToken: "mock-csrf-token",
    }),
  ),
);

await page.route("**/api/rbac/my/permissions**", (route) => route.fulfill(json(["*"])));
await page.route("**/api/tools?*", (route) =>
  route.fulfill(json([RICH_RESULT_TOOL, ERROR_LARGE_TOOL])),
);
await page.route("**/api/gateways?*", (route) => route.fulfill(json(GATEWAY_RESPONSE)));

await page.route("**/api/tools/preview/render_rich_result", async (route) => {
  const request = route.request();
  const args = request.postDataJSON()?.arguments ?? {};
  logPreviewRequest("rich result", request);

  return route.fulfill(
    json({
      target: { kind: "local" },
      resolved_arguments: args,
      content: [
        {
          type: "text",
          text: "Found 2 matching issues for the preview query.",
          mimeType: "text/plain",
        },
        {
          type: "text",
          text: JSON.stringify({
            total: 2,
            items: [
              { id: 101, title: "Improve preview rendering" },
              { id: 102, title: "Add structured output panel" },
            ],
          }),
          mimeType: "application/json",
        },
        {
          type: "image",
          text: SVG_IMAGE,
          mimeType: "image/svg+xml",
        },
        {
          type: "resource",
          data: Buffer.from("%PDF-1.4\n% mocked preview PDF\n").toString("base64"),
          mimeType: "application/pdf",
        },
        {
          type: "blob",
          data: "AAECAwQFBgc=",
          mimeType: "application/octet-stream",
        },
      ],
      structured_output: {
        query: args.query ?? null,
        total: 2,
        ids: [101, 102],
      },
      annotations: { readOnlyHint: true },
      pre_hooks_run: [],
      warnings: [
        { code: "elicitation_skipped", hooks: ["approval_hook"] },
        { code: "schema_defaulted", message: "Limit defaulted on the server preview." },
      ],
    }),
  );
});

await page.route("**/api/tools/preview/render_error_large_result", async (route) => {
  const request = route.request();
  const args = request.postDataJSON()?.arguments ?? {};
  logPreviewRequest("error large result", request);

  return route.fulfill(
    json({
      target: { kind: "local" },
      resolved_arguments: args,
      content: [
        {
          type: "text",
          text: "The tool returned a handled error response.",
          mimeType: "text/plain",
        },
        {
          type: "text",
          text: `${"Large result line. ".repeat(17000)} END_OF_LARGE_RESULT`,
          mimeType: "text/plain",
        },
      ],
      structured_output: {
        error: "handled_error",
        retryable: false,
        details: `${"Large structured output line. ".repeat(7000)} END_OF_LARGE_STRUCTURED_OUTPUT`,
      },
      isError: true,
      annotations: { readOnlyHint: true, destructiveHint: true },
      pre_hooks_run: [],
      warnings: [],
    }),
  );
});

await page.addInitScript(() => {
  sessionStorage.setItem("mcpgateway_token", "mock-token-12345");
});

await page.goto(`${BASE}/app/tools`, { waitUntil: "networkidle" });

const cardCount = await page.getByRole("button", { name: "More options for render-lab" }).count();
console.log(`tools card: ${cardCount ? "ok" : "MISSING"}`);

if (!HEADED) {
  await browser.close();
} else {
  console.log(`
Browser open. Try:
  1. Open "More options for render-lab" -> "View details"
  2. Confirm "Try it" is selected and both tool chips are visible:
       render_rich_result
       render_error_large_result
  3. For render_rich_result, fill query="cloudflare" and limit="5", then click Preview
  4. Expect Preview 200, Warnings, Tool result, Content block 1..5, Structured output, and Raw preview response
  5. Confirm the result shows:
       text: "Found 2 matching issues..."
       formatted JSON with "total": 2
       an inline SVG image rendered from text content
       Open in new tab + Download raw for the PDF block
       Download raw for the octet-stream block
       warning text for approval_hook and the server default
       Raw preview response open by default
  6. Click render_error_large_result
  7. Fill query="failure", then click Preview
  8. Expect Preview 200 with an Error response badge
  9. Confirm the large content block, large structured output, and oversized raw response use View all
 10. Expand the large content block and confirm END_OF_LARGE_RESULT appears
 11. Expand the large structured output and confirm END_OF_LARGE_STRUCTURED_OUTPUT appears

Ctrl-C to close.
`);
  await new Promise(() => {});
}
Manual test results

Manual verification is mock-backed and can be re-run with the script above. Latest local automated verification was run against the current PR head after the review fixes.

# Step Expected Result
1 Open details for render-lab Drawer opens with Try it selected Pass
2 Inspect tool chips render_rich_result and render_error_large_result are visible Pass
3 Preview render_rich_result Preview 200, warnings, result blocks, structured output, and raw response render Pass
4 Inspect rich result blocks Text, JSON, SVG text image, PDF actions, binary download, warning text, and default-open raw response render Pass
5 Switch to render_error_large_result Form resets for the second mocked tool Pass
6 Preview error/large result Preview 200 and Error response badge render Pass
7 Inspect large result Large content, large structured output, and oversized raw response are collapsed behind View all Pass
8 Expand large content block END_OF_LARGE_RESULT appears Pass
9 Expand large structured output END_OF_LARGE_STRUCTURED_OUTPUT appears Pass

Observed terminal output includes tools card: ok and preview request logs for both mocked tools.

Scope of this verification: all backend responses are mocked. This covers frontend result rendering only: text, JSON, image/SVG text, PDF/download actions, structured output, warning messages, error-result badges, raw response behavior, and large-content collapse behavior. It does not verify a live backend preview endpoint.

Base automatically changed from feat/6316-tool-preview-drawer to main August 21, 2026 09:22
@gandhipratik203
gandhipratik203 force-pushed the feat/6317-tool-result-rendering branch from e4388d8 to 83d2190 Compare August 21, 2026 09:35
@gandhipratik203 gandhipratik203 self-assigned this Aug 21, 2026
@gandhipratik203
gandhipratik203 marked this pull request as ready for review August 21, 2026 10:16

@marekdano marekdano left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

High — silently breaks the feature

1. Stale expanded state leaks across Preview re-runs

src/components/tools/ToolResultRenderer.tsx:91

ToolResultBlock's expanded state (useState(!block.isLarge)) only initializes on mount, but blocks are keyed by ${block.type}-${block.mimeType}-${index} (line 54), and useToolPreview.run() never clears the previous result before a new one resolves — so the component never remounts between two Preview runs on the same tool.

Failure scenario: user expands a large block, then re-runs Preview with different args; if the new response's block at the same index has the same type/mimeType, it inherits the stale expanded=true and renders fully open immediately — silently bypassing the PR's own "large result protection" feature.

2. SVG images delivered via text render as raw markup

src/components/tools/ToolResultRenderer.tsx:145

isTextualMime (matches any mimetype containing "xml") is checked before the image/-prefix branch, so an image delivered via text (not base64 data) with mimeType: "image/svg+xml" renders as raw markup in a CodeBlock instead of an <img>. The PR's own manual-test script sidesteps this by always base64-encoding its SVG into data.


Medium — real correctness bugs, narrower blast radius

3. Duplicated ToolCodeLanguage type will mislabel content after rebase

src/components/tools/toolResultContent.ts:18,56

ToolCodeLanguage/codeLanguageForMime redefine a narrower duplicate of CodeBlockLanguage ("bash"|"json"|"tsx" vs. the real "bash"|"json"|"python"|"tsx"|"markdown"|"xml"|"text" already on origin/main), so XML content gets highlighted with the TSX grammar and plain text with the bash grammar once this branch merges, despite proper grammars already existing. (code-block.tsx isn't touched by this PR's diff — origin/main already gained markdown/xml/text support from an earlier merged PR; this branch just hasn't rebased onto that yet, so the type should reuse CodeBlockLanguage directly rather than redefining a narrower copy.)

4. Byte size inflated ~33% for binary/image blocks

src/components/tools/toolResultContent.ts:164

getBlockByteSize/getStringByteSize measures the byte length of the base64-encoded data string itself, not the decoded binary size, inflating displayed size and the isLarge (256KB) comparison by ~33% for every binary/image block.

5. Empty-string data mishandled

src/components/tools/toolResultContent.ts:79

getDataUrl uses a truthy check (if (block.data)) instead of !== undefined, so a block with explicit empty-string data: "" falls through to a confusing generic JSON-dump fallback instead of a valid empty data URL.


Low-medium — regressions / polish

6. Raw response now collapsed by default
src/components/tools/ToolPreviewResult.tsx

The raw preview response used to be an always-visible <section>; it's now a collapsible Accordion with no defaultValue, so it's hidden by default — inconsistent with the structured-output Accordion a few lines away in ToolResultRenderer.tsx, which explicitly sets defaultValue="structured-output" to stay open. Regresses the "still available for debugging" workflow the PR description claims to preserve.

7. Hardcoded English fallback string breaks i18n

src/components/tools/ToolPreviewResult.tsx:158

formatWarningHooks falls back to the hardcoded English literal "one or more hooks" instead of an i18n key, producing mixed-language warning text for es-ES/pt-BR users when a warning has no hook/hooks field.

@vishu-bh vishu-bh left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @gandhipratik203 for the changes.

It is well defined and approach is headed in right direction.

Please check these inline findings:


{blocks.map((block, index) => (
<ToolResultBlock
key={`${block.type}-${block.mimeType}-${index}`}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Block key only uses type, MIME, and index, so React reuses expanded state across preview reruns. A small result initializes expanded=true; a later huge same-slot result bypasses collapse and reaches Prism immediately. Include invocation/result identity in the key and add rerender regression tests.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in the latest push. Expansion state is now scoped to the preview response identity, and there is a rerun regression test for small-to-large results staying collapsed.

/>
))}

{hasStructuredOutput && (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Per-block limit does not protect aggregate content or structured_output. Many sub-limit blocks, or one huge structured output, are eagerly serialized/highlighted and can freeze the tab. Add total-byte and block-count limits, cap structured output, and serialize only after explicit expansion.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Added block-count and aggregate byte limits, plus large structured-output and raw-response guards so oversized content is only rendered after View all.

import { ToolResultRenderer } from "./ToolResultRenderer";
import { TOOL_RESULT_BLOCK_SIZE_LIMIT_BYTES } from "./toolResultContent";

describe("ToolResultRenderer", () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue verification requires MIME-focused tests plus Playwright JSON, text, image, error, large-block, warning, and empty-result cases. Current suite omits most paths and CI fails the global branch threshold. Add positive, negative, boundary, and rerun cases before closing the issue.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added focused MIME/content-helper and renderer coverage, plus extended the mocked Tools e2e preview path for text, JSON, SVG image, warnings, error badge, and large-result behavior. Coverage now passes locally.

data?: string;
raw: ToolResultContentBlock | string;
}): number {
if (input.text !== undefined) return getStringByteSize(input.text);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

data/blob fields contain base64, but Blob([input.data]) counts encoded characters. This produces wrong captions and thresholds. Decode validated base64—or use trusted response metadata—before computing binary size.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Binary sizes are now calculated from decoded base64/data URLs, and empty data: "" is handled explicitly. Added tests for both paths.

Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com>
Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com>
Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com>
@gandhipratik203
gandhipratik203 force-pushed the feat/6317-tool-result-rendering branch from 0bb7e26 to e7e4600 Compare August 21, 2026 12:27
Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com>
@gandhipratik203

Copy link
Copy Markdown
Contributor Author

Thanks for the detailed review.

I pushed fixes for the result-rendering issues:

  • reset block expansion state across preview reruns
  • added aggregate block limits and large structured/raw response guards
  • fixed SVG text content rendering as an image
  • reused the shared CodeBlockLanguage mapping
  • fixed decoded binary size calculation and empty data: "" handling
  • made raw response open by default for normal-sized responses
  • moved the fallback hook label into i18n
  • added focused unit coverage and extended the mocked Tools e2e preview path

@marekdano marekdano left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR looks good now!

LGTM 🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[UI-REWRITE]: Render tool preview content blocks and structured output

3 participants