Skip to content

feat: add sandboxed inline HTML widgets to chat transcript#106

Open
Stealinglight wants to merge 1 commit into
grp06:mainfrom
Stealinglight:feat/inline-widgets
Open

feat: add sandboxed inline HTML widgets to chat transcript#106
Stealinglight wants to merge 1 commit into
grp06:mainfrom
Stealinglight:feat/inline-widgets

Conversation

@Stealinglight

Copy link
Copy Markdown

feat: add sandboxed inline HTML widgets to chat transcript

Summary

Adds support for <widget title="...">...</widget> tags in assistant
responses. Each widget renders as a sandboxed <iframe srcDoc> inline
with the surrounding markdown — enabling Chart.js visualizations,
Tailwind-styled status cards, color-coded tables, and simple
interactive tools to appear directly in chat. Also accepts <mcwidget>
as an alias for compatibility with agents trained on that prefix.

The change is browser-only. No new gateway methods, no new API routes,
no edits to control-plane / intent / runtime code paths. Theme-aware
via Studio's existing CSS variables; live theme broadcast via
postMessage so widgets re-theme on dark/light toggle without remounting
their Chart.js instances.

Design decisions

  • Parser pre-splits widget tags out of the raw markdown string before
    ReactMarkdown sees it.
    Avoids depending on rehype-raw, which would
    enable arbitrary HTML passthrough for the entire markdown pipeline
    and entangle iframe key stability with markdown reconciliation. The
    parser is pure TypeScript with zero new runtime deps, exhaustively
    tested for edge cases (unclosed tags during streaming, nested tags,
    malformed attributes, curly-quote normalization).
  • Sandbox attribute is the literal constant "allow-scripts allow-popups".
    Hard-coded module constant; never templated. ESLint rule
    (no-restricted-syntax) bans the literal string "allow-same-origin"
    anywhere in the src/ tree to prevent accidental sandbox-escape via
    the WHATWG-documented document-reload pattern.
  • Content-hash widget keys (FNV-1a 32-bit) keep the iframe DOM node
    stable across SSE chunks during streaming. Avoids the
    Date.now()/Math.random() pattern that would remount on every
    render — Chart.js restart, scroll jumps, listener leaks.
  • Live theme broadcast via postMessage with a single
    MutationObserver on document.documentElement class changes.
    Iframes update :root style vars in place; no srcDoc rebuild on
    toggle, so chart animations and JS state survive theme changes.
  • Single global window.message listener (ref-counted registry
    keyed by widgetId). Validates event.source === iframe.contentWindow
    on every message — opaque-origin srcdoc iframes make event.origin
    meaningless, so source-identity is the trust boundary. Heights are
    clamped to [60, 600] on receive (parent side), independent of any
    widget-side clamping.
  • Per-widget React error boundary with a "View source" disclosure
    fallback. A single bad widget cannot crash the surrounding transcript;
    the user sees the raw HTML the agent emitted in a collapsed <details>
    block.
  • WIDGET_PROMPT auto-inject into TOOLS.md at compose time (not
    written to disk). Agents get widget capability by default; turning
    it off in a future release is a single-line revert with no
    per-agent migration. Idempotent on a literal [WIDGETS] sentinel
    to avoid double-injection.

Testing

  • npm run lint — passes (2 pre-existing baseline errors in
    AgentChatPanel.tsx:707 and GatewayConnectScreen.tsx:90,
    authored upstream; this PR introduces zero new lint errors)
  • npm run typecheck — passes (exit 0)
  • npm run test — 909/909 vitest tests pass
  • npm run e2e — 6/6 Playwright tests pass
    (tests/e2e/widget-replay-parity.spec.ts)
  • @axe-core/playwright baseline: zero new accessibility violations
    vs a no-widgets baseline page
  • Manual perf readout (synthetic transcript with 10 widgets):
    LCP 1.3s, CLS 0.27 (high, see Out of Scope), TBT moderate. The
    CLS is iframe-mount layout shift; v1.x mitigation via min-height
    reservation or CSS height transition.

Files changed

New widget code under src/features/agents/components/widgets/:

  • parseWidgetSegments.ts — pure streaming-safe parser
  • buildWidgetSrcDoc.ts — pure iframe srcDoc assembler
  • widgetTheme.ts — theme snapshot + Studio-token mapping
  • widgetPromptSnippet.tsWIDGET_PROMPT agent guidance
  • widgetConstants.ts — sandbox literal, height clamps, etc.
  • widgetMessageRegistry.ts — single global postMessage listener
  • widgetThemeBroadcast.ts — MutationObserver theme propagation
  • InlineWidget.tsx — memoized iframe component
  • InlineWidgetErrorBoundary.tsx — per-widget React error boundary
  • AssistantMarkdownContent.tsx — wrapper integrating with markdown

Tests:

  • tests/unit/*.test.ts — 10 new vitest files
  • tests/e2e/widget-replay-parity.spec.ts — Playwright e2e
  • tests/e2e/helpers/widgetTranscriptStub.ts — test harness
  • tests/eslint-rules/sandbox-allow-same-origin.fixture.tsx — ESLint rule fixture
  • tests/setup.ts — ResizeObserver shim (jsdom)

Modified for integration:

  • src/features/agents/components/AgentChatPanel.tsx — two
    ReactMarkdown call sites in the assistant render path swapped
    for <AssistantMarkdownContent />. Tool/thinking/user content
    paths unchanged.
  • src/lib/agents/personalityBuilder.tsWIDGET_PROMPT
    auto-injected into TOOLS.md at the existing
    serializePersonalityFiles boundary, idempotent on a [WIDGETS]
    sentinel.

Modified for tooling:

  • eslint.config.mjsno-restricted-syntax rule banning
    "allow-same-origin" in src/
  • vitest.config.ts — coverage scope for parseWidgetSegments
  • package.json / package-lock.json — adds two devDependencies
    only: @axe-core/playwright (e2e a11y baseline) and
    @vitest/coverage-v8 (parser branch-coverage gate)

New shipped doc:

  • docs/widget-csp.md — minimum CSP directives required if Studio
    ever ships a Content-Security-Policy header. Documents the
    script-src / frame-src / img-src requirements for the
    Tailwind v4 + Chart.js CDN pattern used inside srcdoc.

Out of scope (deliberately deferred)

  • allow-same-origin sandbox token — banned at the lint level;
    WHATWG-documented sandbox-escape pattern.
  • allow-forms, allow-top-navigation — banned for similar reasons.
  • Two-way widget→agent RPC (postMessage protocol beyond
    height-reporting) — opens a security-review surface; v2 design.
  • Side-panel "artifacts"-style render — wrong product shape; widgets
    are defined by being inline in the transcript.
  • window.claude.complete()-style agent callbacks inside widgets —
    out of scope for the v1 inline-render contract.
  • Web-vitals CI gate — perf budgets documented in this PR
    description; CI gating is v1.x once min-height reservation
    mitigates the CLS hot-spot.
  • Prompt-eval harness for agent malformed-tag rate — useful
    follow-up; not blocking v1.

Gateway behaviour changed: no

This PR is browser-only. Zero changes to:

  • src/lib/controlplane/**
  • src/lib/gateway/GatewayClient.ts
  • server/**
  • /api/gateway/*, /api/intents/*, /api/runtime/*

The Studio reducer, SSE pipeline, SQLite outbox, and all gateway
adapter code are untouched. git diff main...HEAD --name-only
shows only widget code, tests, two integration-point files, and
tooling config.

Copilot AI review requested due to automatic review settings May 19, 2026 07:25

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@qodo-code-review

Copy link
Copy Markdown

Review Summary by Qodo

Add sandboxed inline HTML widgets to chat transcripts with theme-aware rendering and streaming safety

✨ Enhancement 🧪 Tests 📝 Documentation

Grey Divider

Walkthroughs

Description
• **Adds sandboxed inline HTML widget support to chat transcripts** via <widget> and <mcwidget>
  tags, enabling Chart.js visualizations, Tailwind-styled components, and interactive tools to render
  directly in assistant responses as deterministic iframes
• **Pure TypeScript parser** (parseWidgetSegments) with streaming-safe truncation, FNV-1a content
  hashing for stable widget IDs, and exhaustive edge-case handling (unclosed tags, nested widgets,
  malformed attributes)
• **Sandboxed iframe component** (InlineWidget) with hard-coded "allow-scripts allow-popups"
  sandbox attribute, source-identity postMessage validation, height clamping [60–600px], and
  per-widget error boundary with "View source" fallback
• **Theme-aware rendering** via 9 CSS variables (light/dark fallbacks), live theme broadcast using
  MutationObserver on document.documentElement class changes, and in-place iframe style updates
  without srcDoc rebuild
• **Widget capability auto-injection** into TOOLS.md at compose time (not persisted) with idempotent
  sentinel-based detection; agents get widget support by default
• **Comprehensive test coverage** including 334-line parser unit tests (95%+ branch coverage), React
  component tests, e2e Playwright tests validating sandbox enforcement and replay parity, and
  regression tests ensuring widget-less markdown renders identically
• **Security hardening** via ESLint rule banning allow-same-origin sandbox token, source-identity
  validation on all postMessages, and height clamping on both widget and parent sides
• **Documentation** including agent capability prompt (~280 tokens) with worked Chart.js example,
  CSP guidance, and design decision rationale
Diagram
flowchart LR
  A["Assistant Response Text"] -->|parseWidgetSegments| B["Markdown + Widget Segments"]
  B -->|AssistantMarkdownContent| C["ReactMarkdown + InlineWidget"]
  C -->|InlineWidget| D["Sandboxed iframe srcDoc"]
  D -->|buildWidgetSrcDoc| E["HTML with Theme Vars + CDN URLs"]
  F["Theme Changes"] -->|MutationObserver| G["widgetThemeBroadcast"]
  G -->|postMessage| D
  D -->|height:update| H["widgetMessageRegistry"]
  H -->|clamp + rate-limit| I["Parent Height Update"]
  J["TOOLS.md Compose"] -->|composeToolsContentWithWidgetPrompt| K["WIDGET_PROMPT Injected"]
Loading

Grey Divider

File Changes

1. tests/unit/parseWidgetSegments.test.ts 🧪 Tests +334/-0

Widget parser unit tests with streaming safety validation

• Comprehensive test suite for the parseWidgetSegments parser with 334 lines covering 95%+ branch
 coverage
• Tests widget tag parsing (both <widget> and <mcwidget> prefixes), markdown interleaving, and
 edge cases
• Validates streaming-safe behavior with unclosed tags, nested widgets, and malformed attributes
• Covers curly-quote normalization, content-hash stability, and error recovery paths

tests/unit/parseWidgetSegments.test.ts


2. tests/e2e/widget-replay-parity.spec.ts 🧪 Tests +276/-0

E2E tests for widget sandbox enforcement and replay parity

• End-to-end Playwright tests validating iframe sandbox attributes and srcDoc byte-equality across
 live/replay renders
• Asserts literal sandbox token "allow-scripts allow-popups" and presence of 9 theme CSS variables
• Validates forged postMessage rejection and accessibility baseline (zero new violations vs
 no-widgets baseline)
• Deterministic widget harness using FNV-1a hashing for reproducible widget IDs

tests/e2e/widget-replay-parity.spec.ts


3. src/features/agents/components/widgets/parseWidgetSegments.ts ✨ Enhancement +251/-0

Streaming-safe widget tag parser with FNV-1a hashing

• Pure TypeScript parser converting raw assistant strings into ordered MessageSegment arrays
 (markdown | widget)
• Implements streaming-safe truncation on unmatched open tags and outer-wins nested widget semantics
• Exports fnv1a 32-bit hash for deterministic content-based widget IDs and canonical regex
 patterns
• Normalizes curly quotes in widget tag attributes with console warnings; rejects malformed tags as
 literal markdown

src/features/agents/components/widgets/parseWidgetSegments.ts


View more (27)
4. tests/unit/inlineWidget.test.ts 🧪 Tests +259/-0

InlineWidget React component unit tests

• React component tests for InlineWidget covering sandbox discipline, iframe DOM identity, and
 height updates
• Validates source-identity postMessage validation (rejects foreign sources) and height clamping to
 [60, 600]px
• Tests error boundary integration, title bar affordances, and listener cleanup across 50
 mount/unmount cycles
• Confirms iframe node stability across re-renders with identical props

tests/unit/inlineWidget.test.ts


5. tests/unit/widgetMessageRegistry.test.ts 🧪 Tests +194/-0

Widget message registry and height update validation tests

• Tests for the global window.message listener registry managing widget height updates
• Validates source validation (event.source === iframe.contentWindow), height clamping, and rate
 limiting
• Confirms lazy listener installation/removal and idempotent re-registration (StrictMode-safe)
• Tests registry cleanup after 50 mount/unmount cycles and rejection of foreign/malformed messages

tests/unit/widgetMessageRegistry.test.ts


6. tests/unit/personalityBuilder.test.ts 🧪 Tests +139/-1

Widget prompt injection tests for personality builder

• Adds tests for composeToolsContentWithWidgetPrompt helper validating idempotent widget prompt
 injection
• Tests TOOLS.md composition with widget capability appended, sentinel detection, and newline
 normalization
• Validates that serializePersonalityFiles emits TOOLS.md with widget prompt while leaving other
 files unchanged
• Confirms parsePersonalityFiles reads stored content verbatim without widget prompt (storage
 stays clean)

tests/unit/personalityBuilder.test.ts


7. src/features/agents/components/widgets/buildWidgetSrcDoc.ts ✨ Enhancement +150/-0

Pure widget srcDoc builder with theme variables and CDN URLs

• Pure HTML document builder for widget iframes with deterministic output (no randomness, no DOM
 reads)
• Exports hard-coded WIDGET_SANDBOX constant ("allow-scripts allow-popups") and pinned CDN URLs
 for Tailwind v4 and Chart.js 4.5.1
• Injects 9 widget-side CSS variables in deterministic order, height-reporter postMessage script,
 and theme-update listener bootstrap
• Wraps theme bootstrap in try/catch to prevent bad messages from breaking widgets

src/features/agents/components/widgets/buildWidgetSrcDoc.ts


8. src/features/agents/components/widgets/widgetMessageRegistry.ts ✨ Enhancement +137/-0

Global widget message listener with source validation

• Module-level singleton managing global window.message listener for widget height updates
• Implements 7-step validation chain: message type, widget existence, source identity, parent check,
 finite height, clamping, and rate limiting
• Lazy-installs listener on first register; removes on last unregister (prevents listener leaks)
• Re-registration replaces prior entry (StrictMode-safe); exports test-only reset and snapshot
 helpers

src/features/agents/components/widgets/widgetMessageRegistry.ts


9. src/features/agents/components/widgets/widgetTheme.ts ✨ Enhancement +130/-0

Widget theme variable mapping and DOM-aware snapshot reader

• Defines WidgetCssVarName union type and ThemeVars record for 9 widget-side CSS variables
• Exports frozen MAPPED_CSS_VARS table mapping widget vars to Studio tokens with light/dark
 fallbacks (sourced from DISC-03 audit)
• Implements readThemeSnapshot() to resolve live theme from DOM with fallback support for SSR/test
 environments
• Theme detection via document.documentElement.dark class; uses getComputedStyle for Studio
 token resolution

src/features/agents/components/widgets/widgetTheme.ts


10. tests/unit/assistantMarkdownContent-regression.test.ts 🧪 Tests +122/-0

Markdown content regression tests for widget-less messages

• Regression tests ensuring widget-less markdown renders identically to pre-swap baseline via
 AssistantMarkdownContent
• Validates byte-identical HTML output for both static and streaming cases with zero widget tags
• Confirms generating-widget indicator does not render on widget-less messages (streaming or
 finalized)
• Exercises the same ReactMarkdown + remarkGfm pipeline as the legacy code path

tests/unit/assistantMarkdownContent-regression.test.ts


11. tests/unit/assistantMarkdownContent-replay-parity.test.ts 🧪 Tests +110/-0

Widget replay parity tests for AssistantMarkdownContent

• Tests SSE replay parity at the React component level for widget-containing messages
• Validates byte-identical DOM output across live and replay renders with identical text input
• Confirms iframe sandbox and srcDoc attributes remain identical between renders
• Validates stable widgetId derivation (content-hash) across multiple renders of the same widget
 HTML

tests/unit/assistantMarkdownContent-replay-parity.test.ts


12. tests/unit/widgetThemeBroadcast.test.ts 🧪 Tests +114/-0

Widget theme broadcast MutationObserver tests

• Tests for the MutationObserver-based theme broadcast system sending theme:update postMessages to
 widgets
• Validates lazy observer installation on first subscribe and disconnection on last unsubscribe
• Tests broadcast delivery to all subscribed widgets, error isolation (one widget failure doesn't
 block others), and re-subscription replacement
• Confirms rate-limit window cleanup and no-op unsubscribe of unknown widget IDs

tests/unit/widgetThemeBroadcast.test.ts


13. tests/unit/buildWidgetSrcDoc.test.ts 🧪 Tests +102/-0

Widget srcDoc builder unit tests with CDN validation

• Tests for buildWidgetSrcDoc pure function validating sandbox token, CDN URLs, and theme variable
 injection
• Confirms Tailwind v4 jsdelivr URL and Chart.js 4.5.1 pinned version (never @latest) are embedded
• Validates all 9 widget-side CSS variables appear in deterministic order and height-reporter script
 includes widgetId
• Tests byte-identical output for identical inputs (purity contract)

tests/unit/buildWidgetSrcDoc.test.ts


14. src/lib/agents/personalityBuilder.ts ✨ Enhancement +55/-1

Widget prompt injection into personality compose pipeline

• Adds composeToolsContentWithWidgetPrompt helper for idempotent widget prompt injection into
 TOOLS.md at compose time
• Implements sentinel-based idempotency using the unique opening sentence of WIDGET_PROMPT to
 prevent double-injection
• Normalizes trailing newlines before appending prompt with canonical \n\n separator
• Integrates into serializePersonalityFiles so all agents get widget capability by default; stored
 TOOLS.md stays clean

src/lib/agents/personalityBuilder.ts


15. tests/unit/inlineWidgetErrorBoundary.test.ts 🧪 Tests +95/-0

Widget error boundary tests with source disclosure fallback

• Tests for InlineWidgetErrorBoundary React error boundary catching widget render failures
• Validates fallback UI displays title (or "Widget" if empty) with "failed to render" message
• Confirms raw HTML renders inside a <details> "View source" disclosure block for debugging
• Tests error logging with widgetId and title context; validates boundary does not break on child
 exceptions

tests/unit/inlineWidgetErrorBoundary.test.ts


16. tests/e2e/helpers/widgetTranscriptStub.ts 🧪 Tests +65/-0

Deterministic widget transcript fixture for e2e tests

• Deterministic Playwright fixture builder generating assistant message text with N widget blocks
• Each widget references all 9 mapped Studio CSS theme variables for srcDoc validation
• Produces byte-identical output for the same widgetCount (no randomness, no Date.now)
• Used by e2e tests to assert SSE-replay parity and theme variable presence

tests/e2e/helpers/widgetTranscriptStub.ts


17. src/features/agents/components/widgets/widgetPromptSnippet.ts 📝 Documentation +65/-0

Comprehensive widget capability prompt for agents

• Exports WIDGET_PROMPT constant (~280 tokens) with comprehensive agent capability documentation
• Covers tag syntax, when-to-use/when-NOT-to-use lists, all 9 theme variables, height postMessage
 protocol, and hard rules
• Includes worked Chart.js example with concrete <script> body demonstrating real-world usage
• Injected into TOOLS.md at compose time (not stored) so agents get widget capability by default

src/features/agents/components/widgets/widgetPromptSnippet.ts


18. src/features/agents/components/widgets/widgetThemeBroadcast.ts ✨ Enhancement +102/-0

Theme broadcast via MutationObserver and postMessage

• Module-level singleton managing single MutationObserver on document.documentElement for theme
 changes
• Broadcasts { type: "theme:update", vars } postMessages to all registered widget contentWindows
• Lazy-installs observer on first subscribe; disconnects on last unsubscribe (zero overhead when no
 widgets)
• Swallows per-widget broadcast errors so one closed iframe doesn't block others; exports test-only
 helpers

src/features/agents/components/widgets/widgetThemeBroadcast.ts


19. tests/unit/buildWidgetSrcDoc.theme-bootstrap.test.ts 🧪 Tests +92/-0

Widget theme bootstrap script tests

• Tests for the theme-update listener bootstrap script embedded in widget srcDoc
• Validates listener for theme:update messages and in-place CSS variable updates via setProperty
• Confirms all 9 widget-side var names are included in the bootstrap allowlist
• Tests try/catch wrapping and byte-identical output across repeated calls (purity)

tests/unit/buildWidgetSrcDoc.theme-bootstrap.test.ts


20. tests/setup.ts Configuration +18/-0

ResizeObserver polyfill for test environment

• Adds ensureResizeObserver shim for test environments where ResizeObserver is not available
• Implements minimal shim with no-op observe, unobserve, and disconnect methods
• Prevents test failures when widget srcDoc code attempts to use ResizeObserver for height reporting

tests/setup.ts


21. src/features/agents/components/widgets/widgetConstants.ts ⚙️ Configuration changes +29/-0

Widget subsystem constants module

• Introduces module-level numeric and string constants for the widget subsystem
• Defines height bounds (MIN_HEIGHT, MAX_HEIGHT), rate-limiting thresholds, and fallback values
• All constants use const-assertion suffix per project conventions
• References design decisions from CONTEXT.md (D-11, D-14, D-17, D-19)

src/features/agents/components/widgets/widgetConstants.ts


22. vitest.config.ts 🧪 Tests +21/-0

Test coverage configuration for widget parser

• Adds coverage configuration targeting parseWidgetSegments.ts with v8 provider
• Sets branch coverage threshold at 80% (achievable floor), lines/statements at 90%, functions at
 100%
• Documents 10 unreachable defensive branches as dead-code safety nets
• Enables coverage reporting on test failure

vitest.config.ts


23. src/features/agents/components/widgets/InlineWidget.tsx ✨ Enhancement +189/-0

Sandboxed iframe widget component implementation

• Implements sandboxed iframe component for rendering agent-generated HTML inline
• Manages iframe lifecycle including height registration, theme subscription, and collapse state
• Provides "open in new tab" functionality with blob URL revocation after timeout
• Wrapped in error boundary and memoized with custom comparator on (title, html, id)

src/features/agents/components/widgets/InlineWidget.tsx


24. src/features/agents/components/widgets/AssistantMarkdownContent.tsx ✨ Enhancement +141/-0

Markdown and widget segment renderer wrapper

• Bridges existing ReactMarkdown pipeline with new InlineWidget component
• Parses input text into markdown and widget segments using parseWidgetSegments
• Renders "Generating widget…" indicator when streaming with unclosed widget tags
• Applies streaming-glow class only to last markdown segment per design decision D-06

src/features/agents/components/widgets/AssistantMarkdownContent.tsx


25. docs/widget-csp.md 📝 Documentation +125/-0

Content Security Policy guidance for widgets

• Provides forward-looking CSP guidance for widget feature deployment
• Documents required directives (script-src, frame-src, img-src) and optional ones
 (connect-src, style-src)
• Explains sandbox boundary and why certain CSP patterns must be avoided
• Includes verification steps via Playwright e2e tests

docs/widget-csp.md


26. src/features/agents/components/widgets/InlineWidgetErrorBoundary.tsx Error handling +83/-0

Per-widget React error boundary with fallback

• Class component error boundary catching render errors from InlineWidget and buildWidgetSrcDoc
• Renders fallback UI with title bar and collapsible "View source" showing raw HTML
• Logs errors via console.error with widget ID and title context
• Prevents single widget failure from crashing surrounding transcript

src/features/agents/components/widgets/InlineWidgetErrorBoundary.tsx


27. eslint.config.mjs Security +30/-0

ESLint sandbox escape prevention rule

• Adds SEC-01 ESLint rule banning allow-same-origin sandbox token from TypeScript/TSX files
• Targets both string literals and template elements to prevent sandbox-escape vulnerability
• Excludes tests/eslint-rules/ directory from default lint to preserve regression fixture
• References CONTEXT.md D-24..D-27 security decisions

eslint.config.mjs


28. package.json Dependencies +2/-0

Development dependencies for testing and coverage

• Adds @axe-core/playwright dev dependency for accessibility testing
• Adds @vitest/coverage-v8 dev dependency for code coverage reporting

package.json


29. src/features/agents/components/AgentChatPanel.tsx ✨ Enhancement +3/-8

Integrate widget-aware markdown content renderer

• Replaces inline ReactMarkdown rendering with new AssistantMarkdownContent wrapper
• Passes isStreaming flag to wrapper for streaming indicator logic
• Simplifies both streaming and non-streaming branches by delegating to wrapper component
• Imports new AssistantMarkdownContent component

src/features/agents/components/AgentChatPanel.tsx


30. tests/eslint-rules/sandbox-allow-same-origin.fixture.tsx 🧪 Tests +16/-0

SEC-01 ESLint rule regression test fixture

• Intentional regression test fixture containing forbidden allow-same-origin token
• Excluded from default lint via globalIgnores; run with --no-ignore to verify SEC-01 rule fires
• Documents expected behavior and verification command in header comments

tests/eslint-rules/sandbox-allow-same-origin.fixture.tsx


Grey Divider

ⓘ You are approaching your monthly quota for Qodo. Upgrade your plan

Qodo Logo

@qodo-code-review

qodo-code-review Bot commented May 19, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (3) 📘 Rule violations (1)

Grey Divider


Action required

1. widget-csp.md recommends unsafe-inline 📘 Rule violation ⛨ Security
Description
The new CSP guidance instructs adding permissive directives like script-src 'unsafe-inline' and
img-src *, which is unsafe and not appropriate as generic open-source repository guidance because
it encourages weakening CSP protections broadly.
Code

docs/widget-csp.md[R26-60]

+### `script-src 'unsafe-inline' https://cdn.jsdelivr.net`
+
+Widgets execute inline `<script>` tags inside their srcDoc. Browsers
+treat `about:srcdoc` document CSP inheritance unevenly — Chrome and
+Firefox have historically differed on which directives flow into the
+srcDoc context. To stay compatible, the parent page's `script-src`
+must allow inline scripts AND the jsdelivr CDN that hosts the widget
+runtime libraries.
+
+- `'unsafe-inline'` — required for `<script>` blocks in widget HTML.
+  The sandbox prevents these scripts from reaching parent-origin
+  resources, so `'unsafe-inline'` here does **not** weaken Studio's
+  own attack surface.
+- `https://cdn.jsdelivr.net` — origin for the pinned Tailwind v4
+  browser CDN (`@tailwindcss/browser@4`) and Chart.js
+  (`[email protected]/dist/chart.umd.min.js`). Both are loaded inside
+  the iframe at widget-mount time.
+
+### `frame-src 'self'`
+
+Studio embeds widgets via `<iframe srcdoc>`, which navigates to the
+synthetic URL `about:srcdoc`. Browsers consistently allow `srcdoc`
+iframes when `frame-src` permits the embedding origin (`'self'`).
+Adding `'self'` is sufficient — do **not** add `data:` or `about:`
+schemes; they don't apply to `srcdoc` and add unrelated attack
+surface.
+
+### `img-src *`
+
+Widget HTML may reference user-supplied or agent-generated image URLs
+from arbitrary origins (chart screenshots, status icons, external
+assets). Restricting `img-src` would block legitimate widget content.
+Images load from the iframe context only, where the sandbox prevents
+them from reading parent storage; allowing `*` here is consistent with
+the sandbox boundary.
Evidence
PR Compliance ID 2 prohibits unsafe operational guidance in repository documentation. The added CSP
document explicitly instructs allowing script-src 'unsafe-inline' and img-src *, which are
permissive CSP configurations that weaken security guidance for open-source users.

AGENTS.md
docs/widget-csp.md[26-60]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`docs/widget-csp.md` currently presents permissive CSP directives (e.g., `script-src 'unsafe-inline'` and `img-src *`) as required guidance. This is risky and not suitable as generic open-source documentation because it encourages broadly weakening CSP on the parent page.

## Issue Context
The compliance checklist requires repository instructions/documentation to remain generic and safe for open source audiences. CSP guidance should avoid prescribing insecure defaults; if exceptions are necessary, they should be framed as last-resort with safer alternatives (nonces/hashes, tighter allowlists, widget-only isolation) and clear security warnings.

## Fix Focus Areas
- docs/widget-csp.md[26-60]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. WidgetId collision breaks updates 🐞 Bug ≡ Correctness
Description
parseWidgetSegments derives widgetId solely from widget HTML, so two widgets with identical bodies
in the same message will share the same id. InlineWidget uses this id to key the global height/theme
registries, so the later widget overwrites the earlier entry and the first iframe stops receiving
height/theme updates.
Code

src/features/agents/components/widgets/parseWidgetSegments.ts[R211-216]

+    segments.push({
+      kind: "widget",
+      title: span.title,
+      html: span.html,
+      widgetId: fnv1a(span.html).toString(16),
+    });
Evidence
The code shows widgetId is computed only from widget HTML and then reused as the InlineWidget id.
Both the message registry and theme broadcast store entries in Maps keyed by that id, so duplicates
overwrite earlier widgets and prevent updates from routing to the overwritten iframe.

src/features/agents/components/widgets/parseWidgetSegments.ts[190-224]
src/features/agents/components/widgets/AssistantMarkdownContent.tsx[104-110]
src/features/agents/components/widgets/InlineWidget.tsx[75-95]
src/features/agents/components/widgets/widgetMessageRegistry.ts[35-113]
src/features/agents/components/widgets/widgetThemeBroadcast.ts[16-69]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`parseWidgetSegments` sets `widgetId` to `fnv1a(span.html).toString(16)`, which is not unique per widget instance. When two widgets have identical `html` in one assistant message, they get the same `widgetId`, and `widgetMessageRegistry` / `widgetThemeBroadcast` (both `Map`s keyed by `widgetId`) will overwrite the earlier widget’s entry, breaking height updates and theme broadcasts for the first widget.

## Issue Context
- `AssistantMarkdownContent` passes `segment.widgetId` as the `id` prop to `<InlineWidget />`.
- `<InlineWidget />` registers height/theme subscriptions keyed by `id`.
- The registries use `Map.set(widgetId, ...)` which replaces previous entries.

## Fix Focus Areas
- src/features/agents/components/widgets/parseWidgetSegments.ts[190-224]
- src/features/agents/components/widgets/AssistantMarkdownContent.tsx[82-112]
- src/features/agents/components/widgets/widgetMessageRegistry.ts[35-113]
- src/features/agents/components/widgets/widgetThemeBroadcast.ts[16-75]

## Implementation notes
- Change `widgetId` to be deterministic **and unique per occurrence** within the parsed message, e.g.:
 - Maintain a `let widgetOrdinal = 0;` inside `parseInner`, increment per emitted widget, and set `widgetId` to `${fnv1a(span.html).toString(16)}-${widgetOrdinal}`; or
 - Incorporate the open-tag byte index (`openStart`) into the id (`${hash}-${openStart}`), which is deterministic for a given message.
- Update affected tests that currently assert two identical HTML bodies produce identical `widgetId` (they should now be different IDs but still stable across renders).
- If you still want a “content hash” for iframe DOM stability, keep it as a separate field (e.g. `contentHash`) and use the unique instance id only for registry routing.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

3. Prompt height guidance wrong 🐞 Bug ⚙ Maintainability
Description
WIDGET_PROMPT tells agents to send iframe:height messages without a widgetId, but the parent
widgetMessageRegistry only accepts height messages that include a string widgetId. This makes the
injected prompt misleading and encourages widget JS that will be ignored by the parent.
Code

src/features/agents/components/widgets/widgetPromptSnippet.ts[R27-30]

+Height protocol — call this on load and on body resize:
+parent.postMessage({type:'iframe:height', height: document.documentElement.scrollHeight}, '*')
+The parent clamps height to [60, 600]px.
+
Evidence
The prompt snippet explicitly omits widgetId in its documented height postMessage. The registry’s
type guard rejects messages without a string widgetId, while the injected height reporter includes
widgetId, demonstrating the mismatch is in the prompt guidance.

src/features/agents/components/widgets/widgetPromptSnippet.ts[24-30]
src/features/agents/components/widgets/widgetMessageRegistry.ts[38-44]
src/features/agents/components/widgets/buildWidgetSrcDoc.ts[57-65]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The injected `WIDGET_PROMPT` documents a height postMessage format that omits `widgetId`, but `widgetMessageRegistry` requires `widgetId` to route/validate messages. This mismatch will confuse agents/users and leads to non-functional “manual resize” snippets.

## Issue Context
- The real implementation already injects its own height reporter script (which does include widgetId), so the prompt’s “call this on load/resize” guidance is not only mismatched but also largely unnecessary.

## Fix Focus Areas
- src/features/agents/components/widgets/widgetPromptSnippet.ts[24-30]
- src/features/agents/components/widgets/widgetMessageRegistry.ts[38-44]
- src/features/agents/components/widgets/buildWidgetSrcDoc.ts[57-66]

## Suggested fix
Pick one and make the prompt match reality:
1) **Recommended:** Update the prompt to say height reporting is automatic (via injected script + ResizeObserver) and remove the manual `parent.postMessage(...)` instruction entirely.
2) If you want to support manual calls from widget code, expose a readable widget id inside the iframe (e.g. set `window.__WIDGET_ID` or `document.body.dataset.widgetId` in `buildWidgetSrcDoc`) and update the prompt to include it in the message shape.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Advisory comments

4. SEC-01 fixture not CI-run 🐞 Bug ⚙ Maintainability
Description
The SEC-01 regression fixture is excluded from default linting via globalIgnores, and there is no
automated step that lints it with --no-ignore. This means weakening/removing SEC-01 can pass CI
even though the fixture is intended to fail when the rule regresses.
Code

eslint.config.mjs[R50-55]

+    // SEC-01 ESLint fixture path is intentionally ignored at default lint
+    // time — its file violates the rule so we have a regression test. Run
+    // `npm run lint -- <fixture path> --no-ignore` to confirm the rule
+    // fires; default lint excludes it so CI stays green.
+    "tests/eslint-rules/**",
  ]),
Evidence
eslint.config.mjs explicitly ignores the fixture directory, and the fixture file itself documents
that it’s excluded from default lint runs, implying it only runs when invoked manually with
--no-ignore.

eslint.config.mjs[40-56]
tests/eslint-rules/sandbox-allow-same-origin.fixture.tsx[2-12]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The repo includes a SEC-01 regression fixture containing the forbidden sandbox token, but eslint ignores `tests/eslint-rules/**` by default. Without an additional CI/test step that runs eslint against that fixture with `--no-ignore`, the fixture doesn’t actually guard against the SEC-01 rule being removed or weakened.

## Issue Context
The SEC-01 rule itself still protects production TS/TSX code during normal lint runs; this is specifically about ensuring the fixture functions as an automated regression signal.

## Fix Focus Areas
- eslint.config.mjs[40-56]
- tests/eslint-rules/sandbox-allow-same-origin.fixture.tsx[2-16]

## Suggested fix
Add an automated assertion, e.g.:
- Add an npm script (e.g. `lint:sec01`) that runs:
 `eslint --no-ignore tests/eslint-rules/sandbox-allow-same-origin.fixture.tsx`
 and wire it into CI.
- Or add a small vitest that uses ESLint’s Node API to lint the fixture and assert it produces the SEC-01 error message.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

ⓘ You are approaching your monthly quota for Qodo. Upgrade your plan

Qodo Logo

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 38b33abe66

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

<div className="agent-markdown text-foreground">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{rewritten}</ReactMarkdown>
</div>
<AssistantMarkdownContent text={contentText} isStreaming={true} />

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Route streaming widgets through the widget renderer

This new streaming renderer is still only reached after the legacy MEDIA: checks above. When an assistant streams <widget title="...">... without a MEDIA: line, the branch returns the raw whitespace div instead, so widget parsing and the “Generating widget…” indicator never run until the message stops streaming; route streaming assistant text through this component whenever widget tags are present, not only when media markdown is detected.

Useful? React with 👍 / 👎.

Comment on lines +168 to +176
{!collapsed ? (
<iframe
ref={iframeRef}
title={titleText}
sandbox={WIDGET_SANDBOX}
srcDoc={srcDoc}
className={IFRAME_CLASSES}
style={{ height: `${height}px` }}
/>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep iframe registrations valid after expanding

When the user collapses a widget, this conditional unmounts the iframe, but the height/theme effects that registered its contentWindow only depend on id/srcDoc and are not re-run just because the ref points to a new iframe after expanding. The registry therefore keeps the removed iframe's window, so postMessages from the newly expanded iframe fail the source check and height/theme updates stop working for that widget.

Useful? React with 👍 / 👎.

kind: "widget",
title: span.title,
html: span.html,
widgetId: fnv1a(span.html).toString(16),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Make widget IDs unique per occurrence

Hashing only span.html gives two widgets with identical HTML the same widgetId in the same transcript. Because both the height registry and theme broadcaster are keyed by widgetId, the later widget replaces the earlier entry; height messages from the first iframe then fail the event.source check and it stays at the default height. Include an occurrence/position component while preserving stability across re-renders.

Useful? React with 👍 / 👎.

Comment thread docs/widget-csp.md
Comment on lines +26 to +60
### `script-src 'unsafe-inline' https://cdn.jsdelivr.net`

Widgets execute inline `<script>` tags inside their srcDoc. Browsers
treat `about:srcdoc` document CSP inheritance unevenly — Chrome and
Firefox have historically differed on which directives flow into the
srcDoc context. To stay compatible, the parent page's `script-src`
must allow inline scripts AND the jsdelivr CDN that hosts the widget
runtime libraries.

- `'unsafe-inline'` — required for `<script>` blocks in widget HTML.
The sandbox prevents these scripts from reaching parent-origin
resources, so `'unsafe-inline'` here does **not** weaken Studio's
own attack surface.
- `https://cdn.jsdelivr.net` — origin for the pinned Tailwind v4
browser CDN (`@tailwindcss/browser@4`) and Chart.js
(`[email protected]/dist/chart.umd.min.js`). Both are loaded inside
the iframe at widget-mount time.

### `frame-src 'self'`

Studio embeds widgets via `<iframe srcdoc>`, which navigates to the
synthetic URL `about:srcdoc`. Browsers consistently allow `srcdoc`
iframes when `frame-src` permits the embedding origin (`'self'`).
Adding `'self'` is sufficient — do **not** add `data:` or `about:`
schemes; they don't apply to `srcdoc` and add unrelated attack
surface.

### `img-src *`

Widget HTML may reference user-supplied or agent-generated image URLs
from arbitrary origins (chart screenshots, status icons, external
assets). Restricting `img-src` would block legitimate widget content.
Images load from the iframe context only, where the sandbox prevents
them from reading parent storage; allowing `*` here is consistent with
the sandbox boundary.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Action required

1. widget-csp.md recommends unsafe-inline 📘 Rule violation ⛨ Security

The new CSP guidance instructs adding permissive directives like script-src 'unsafe-inline' and
img-src *, which is unsafe and not appropriate as generic open-source repository guidance because
it encourages weakening CSP protections broadly.
Agent Prompt
## Issue description
`docs/widget-csp.md` currently presents permissive CSP directives (e.g., `script-src 'unsafe-inline'` and `img-src *`) as required guidance. This is risky and not suitable as generic open-source documentation because it encourages broadly weakening CSP on the parent page.

## Issue Context
The compliance checklist requires repository instructions/documentation to remain generic and safe for open source audiences. CSP guidance should avoid prescribing insecure defaults; if exceptions are necessary, they should be framed as last-resort with safer alternatives (nonces/hashes, tighter allowlists, widget-only isolation) and clear security warnings.

## Fix Focus Areas
- docs/widget-csp.md[26-60]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +211 to +216
segments.push({
kind: "widget",
title: span.title,
html: span.html,
widgetId: fnv1a(span.html).toString(16),
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Action required

2. Widgetid collision breaks updates 🐞 Bug ≡ Correctness

parseWidgetSegments derives widgetId solely from widget HTML, so two widgets with identical bodies
in the same message will share the same id. InlineWidget uses this id to key the global height/theme
registries, so the later widget overwrites the earlier entry and the first iframe stops receiving
height/theme updates.
Agent Prompt
## Issue description
`parseWidgetSegments` sets `widgetId` to `fnv1a(span.html).toString(16)`, which is not unique per widget instance. When two widgets have identical `html` in one assistant message, they get the same `widgetId`, and `widgetMessageRegistry` / `widgetThemeBroadcast` (both `Map`s keyed by `widgetId`) will overwrite the earlier widget’s entry, breaking height updates and theme broadcasts for the first widget.

## Issue Context
- `AssistantMarkdownContent` passes `segment.widgetId` as the `id` prop to `<InlineWidget />`.
- `<InlineWidget />` registers height/theme subscriptions keyed by `id`.
- The registries use `Map.set(widgetId, ...)` which replaces previous entries.

## Fix Focus Areas
- src/features/agents/components/widgets/parseWidgetSegments.ts[190-224]
- src/features/agents/components/widgets/AssistantMarkdownContent.tsx[82-112]
- src/features/agents/components/widgets/widgetMessageRegistry.ts[35-113]
- src/features/agents/components/widgets/widgetThemeBroadcast.ts[16-75]

## Implementation notes
- Change `widgetId` to be deterministic **and unique per occurrence** within the parsed message, e.g.:
  - Maintain a `let widgetOrdinal = 0;` inside `parseInner`, increment per emitted widget, and set `widgetId` to `${fnv1a(span.html).toString(16)}-${widgetOrdinal}`; or
  - Incorporate the open-tag byte index (`openStart`) into the id (`${hash}-${openStart}`), which is deterministic for a given message.
- Update affected tests that currently assert two identical HTML bodies produce identical `widgetId` (they should now be different IDs but still stable across renders).
- If you still want a “content hash” for iframe DOM stability, keep it as a separate field (e.g. `contentHash`) and use the unique instance id only for registry routing.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

## Summary

Adds support for `<widget title="...">...</widget>` tags in assistant
responses. Each widget renders as a sandboxed `<iframe srcDoc>` inline
with the surrounding markdown — enabling Chart.js visualizations,
Tailwind-styled status cards, color-coded tables, and simple
interactive tools to appear directly in chat. Also accepts `<mcwidget>`
as an alias for compatibility with agents trained on that prefix.

The change is browser-only. No new gateway methods, no new API routes,
no edits to control-plane / intent / runtime code paths. Theme-aware
via Studio's existing CSS variables; live theme broadcast via
postMessage so widgets re-theme on dark/light toggle without remounting
their Chart.js instances.

## Design decisions

- **Parser pre-splits widget tags out of the raw markdown string before
  ReactMarkdown sees it.** Avoids depending on `rehype-raw`, which would
  enable arbitrary HTML passthrough for the entire markdown pipeline
  and entangle iframe key stability with markdown reconciliation. The
  parser is pure TypeScript with zero new runtime deps, exhaustively
  tested for edge cases (unclosed tags during streaming, nested tags,
  malformed attributes, curly-quote normalization).
- **Sandbox attribute is the literal constant `"allow-scripts allow-popups"`.**
  Hard-coded module constant; never templated. ESLint rule
  (`no-restricted-syntax`) bans the literal string `"allow-same-origin"`
  anywhere in the `src/` tree to prevent accidental sandbox-escape via
  the WHATWG-documented document-reload pattern.
- **Content-hash widget keys (FNV-1a 32-bit)** keep the iframe DOM node
  stable across SSE chunks during streaming. Avoids the
  `Date.now()`/`Math.random()` pattern that would remount on every
  render — Chart.js restart, scroll jumps, listener leaks.
- **Live theme broadcast via postMessage** with a single
  MutationObserver on `document.documentElement` class changes.
  Iframes update `:root` style vars in place; no srcDoc rebuild on
  toggle, so chart animations and JS state survive theme changes.
- **Single global `window.message` listener** (ref-counted registry
  keyed by widgetId). Validates `event.source === iframe.contentWindow`
  on every message — opaque-origin srcdoc iframes make `event.origin`
  meaningless, so source-identity is the trust boundary. Heights are
  clamped to `[60, 600]` on receive (parent side), independent of any
  widget-side clamping.
- **Per-widget React error boundary** with a "View source" disclosure
  fallback. A single bad widget cannot crash the surrounding transcript;
  the user sees the raw HTML the agent emitted in a collapsed `<details>`
  block.
- **`WIDGET_PROMPT` auto-inject into TOOLS.md at compose time** (not
  written to disk). Agents get widget capability by default; turning
  it off in a future release is a single-line revert with no
  per-agent migration. Idempotent on a literal `[WIDGETS]` sentinel
  to avoid double-injection.

## Testing

- `npm run lint` — passes (2 pre-existing baseline errors in
  `AgentChatPanel.tsx:707` and `GatewayConnectScreen.tsx:90`,
  authored upstream; this PR introduces zero new lint errors)
- `npm run typecheck` — passes (exit 0)
- `npm run test` — 909/909 vitest tests pass
- `npm run e2e` — 6/6 Playwright tests pass
  (`tests/e2e/widget-replay-parity.spec.ts`)
- `@axe-core/playwright` baseline: zero new accessibility violations
  vs a no-widgets baseline page
- Manual perf readout (synthetic transcript with 10 widgets):
  LCP 1.3s, CLS 0.27 (high, see Out of Scope), TBT moderate. The
  CLS is iframe-mount layout shift; v1.x mitigation via min-height
  reservation or CSS height transition.

## Files changed

New widget code under `src/features/agents/components/widgets/`:
- `parseWidgetSegments.ts` — pure streaming-safe parser
- `buildWidgetSrcDoc.ts` — pure iframe srcDoc assembler
- `widgetTheme.ts` — theme snapshot + Studio-token mapping
- `widgetPromptSnippet.ts` — `WIDGET_PROMPT` agent guidance
- `widgetConstants.ts` — sandbox literal, height clamps, etc.
- `widgetMessageRegistry.ts` — single global postMessage listener
- `widgetThemeBroadcast.ts` — MutationObserver theme propagation
- `InlineWidget.tsx` — memoized iframe component
- `InlineWidgetErrorBoundary.tsx` — per-widget React error boundary
- `AssistantMarkdownContent.tsx` — wrapper integrating with markdown

Tests:
- `tests/unit/*.test.ts` — 10 new vitest files
- `tests/e2e/widget-replay-parity.spec.ts` — Playwright e2e
- `tests/e2e/helpers/widgetTranscriptStub.ts` — test harness
- `tests/eslint-rules/sandbox-allow-same-origin.fixture.tsx` — ESLint rule fixture
- `tests/setup.ts` — ResizeObserver shim (jsdom)

Modified for integration:
- `src/features/agents/components/AgentChatPanel.tsx` — two
  ReactMarkdown call sites in the assistant render path swapped
  for `<AssistantMarkdownContent />`. Tool/thinking/user content
  paths unchanged.
- `src/lib/agents/personalityBuilder.ts` — `WIDGET_PROMPT`
  auto-injected into `TOOLS.md` at the existing
  `serializePersonalityFiles` boundary, idempotent on a `[WIDGETS]`
  sentinel.

Modified for tooling:
- `eslint.config.mjs` — `no-restricted-syntax` rule banning
  `"allow-same-origin"` in `src/`
- `vitest.config.ts` — coverage scope for `parseWidgetSegments`
- `package.json` / `package-lock.json` — adds two devDependencies
  only: `@axe-core/playwright` (e2e a11y baseline) and
  `@vitest/coverage-v8` (parser branch-coverage gate)

New shipped doc:
- `docs/widget-csp.md` — minimum CSP directives required if Studio
  ever ships a Content-Security-Policy header. Documents the
  `script-src` / `frame-src` / `img-src` requirements for the
  Tailwind v4 + Chart.js CDN pattern used inside srcdoc.

## Out of scope (deliberately deferred)

- `allow-same-origin` sandbox token — banned at the lint level;
  WHATWG-documented sandbox-escape pattern.
- `allow-forms`, `allow-top-navigation` — banned for similar reasons.
- Two-way widget→agent RPC (postMessage protocol beyond
  height-reporting) — opens a security-review surface; v2 design.
- Side-panel "artifacts"-style render — wrong product shape; widgets
  are defined by being inline in the transcript.
- `window.claude.complete()`-style agent callbacks inside widgets —
  out of scope for the v1 inline-render contract.
- Web-vitals CI gate — perf budgets documented in this PR
  description; CI gating is v1.x once min-height reservation
  mitigates the CLS hot-spot.
- Prompt-eval harness for agent malformed-tag rate — useful
  follow-up; not blocking v1.

## Gateway behaviour changed: no

This PR is browser-only. Zero changes to:
- `src/lib/controlplane/**`
- `src/lib/gateway/GatewayClient.ts`
- `server/**`
- `/api/gateway/*`, `/api/intents/*`, `/api/runtime/*`

The Studio reducer, SSE pipeline, SQLite outbox, and all gateway
adapter code are untouched. `git diff main...HEAD --name-only`
shows only widget code, tests, two integration-point files, and
tooling config.
@Stealinglight Stealinglight force-pushed the feat/inline-widgets branch from 38b33ab to e785a49 Compare May 19, 2026 20:12
@Stealinglight

Copy link
Copy Markdown
Author

Thanks for the review. Pushed e785a49 addressing all four findings:

1. CSP doc tone — added a "Scope" callout near the top of docs/widget-csp.md clarifying that the permissive directives apply inside the iframe srcdoc context (where the sandbox is the trust boundary), not as generic CSP guidance. Added a "Stricter alternatives" section at the end discussing nonce/hash CSP as future-stricter options that would require widget-side coordination.

2. widgetId collisionparseWidgetSegments now combines the FNV-1a content hash with the open-tag byte index (${hash}-${openStart}). Two widgets with identical HTML at different positions get distinct widgetIds, so widgetMessageRegistry and widgetThemeBroadcast (Map-keyed by widgetId) no longer overwrite each other's entries. Per-occurrence uniqueness without sacrificing replay determinism — openStart is stable for any given input text. The existing test that asserted identical-HTML → identical-widgetId was inverted to assert distinct stable IDs across re-parses.

3. Height protocol mismatch — the auto-injected reporter in buildWidgetSrcDoc already sends correctly-formed messages with widgetId. The agent-facing prompt was telling agents to write redundant manual code that the registry would silently drop. Replaced the manual instruction with "Height: automatic. Studio re-measures the widget on load and on every body resize... Do not write your own height postMessage code; it will be ignored." Also removed the manual parent.postMessage call from the worked Chart.js example.

4. SEC-01 fixture not CI-run — added tests/unit/sec01-eslint-rule.test.ts that uses ESLint's Node API with ignore: false to lint the fixture file regardless of globalIgnores, and asserts the rule fires with severity 2. Native to the existing vitest suite; no new npm script or CI plumbing required. Removing or weakening SEC-01 now fails npm run test.

Verification: npm run typecheck exit 0, npm run test 910/910 passing (was 909, +1 SEC-01 test), npm run lint shows the same 2 pre-existing baseline errors and 0 new errors. Diff scope unchanged — no forbidden paths added.

@Stealinglight

Copy link
Copy Markdown
Author

/agentic_review

Re-review request: PR head is now e785a49 which addresses all 4 findings from the prior review (see previous comment for the per-finding breakdown). Specifically: finding #1 (CSP doc tone) was addressed by adding a Scope callout and Stricter Alternatives section to docs/widget-csp.md.

@qodo-code-review

Copy link
Copy Markdown

Qodo reviews are paused for this user.

Troubleshooting steps vary by plan Learn more →

On a Teams plan?
Reviews resume once this user has a paid seat and their Git account is linked in Qodo.
Link Git account →

Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center?
These require an Enterprise plan - Contact us
Contact us →

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants