Skip to content

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

Open
gandhipratik203 wants to merge 1 commit into
mainfrom
feat/6317-tool-result-rendering
Open

[UI-REWRITE] Render tool preview results#55
gandhipratik203 wants to merge 1 commit 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
  • downloadable binary/PDF-style results
  • structured output
  • warning messages
  • error result badges
  • large result protection, so huge content is 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 result -> View all
  |
  +-- Structured output
  +-- Resolved arguments
  +-- Raw preview response
        |
        v
      Still available for debugging

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.

Tests

  • npm run test
  • 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 image, PDF Open in new tab + Download raw, binary Download raw, and warning text for approval_hook plus the mocked server 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 content block.
Expect: Large content hidden (...) and a View all button.

8. Click View all.
Expect: END_OF_LARGE_RESULT appears.

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 a mocked image content block</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 a large text block 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",
          data: Buffer.from(SVG_IMAGE).toString("base64"),
          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,
      },
      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 image
       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
  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 block says "Large content hidden (...)" and has a View all button
 10. Click View all and confirm END_OF_LARGE_RESULT appears

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

Run against feat/6317-tool-result-rendering at 83d2190, rebased on main at ff32f35.

# 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, image, PDF actions, binary download, and warning text 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 block Large block is collapsed behind View all Pass
8 Click View all END_OF_LARGE_RESULT 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, PDF/download actions, structured output, warning messages, error-result badges, 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
Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com>
@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.

/>
))}

{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.

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.

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.

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