Skip to content

feat: add OpenUI integration#8218

Open
vishxrad wants to merge 5 commits into
janhq:mainfrom
vishxrad:feat/openui-integration
Open

feat: add OpenUI integration#8218
vishxrad wants to merge 5 commits into
janhq:mainfrom
vishxrad:feat/openui-integration

Conversation

@vishxrad

Copy link
Copy Markdown

Summary

Adds an experimental OpenUI integration to Jan.

What Changed

  • Added OpenUI settings under Integrations.
  • Adds OpenUI prompt guidance automatically when OpenUI is enabled.
  • Renders assistant OpenUI Lang responses as interactive UI.
  • Routes OpenUI CTA/action clicks back into the active chat input.
  • Includes OpenUI form state in action messages sent back to the LLM.
  • Adds OpenUI logo to the settings page and integrations pane.
  • Adds tests for OpenUI CTA clicks and form submission payloads.

Validation

yarn workspace @janhq/web-app lint
yarn workspace @janhq/web-app exec vitest --run
yarn workspace @janhq/web-app build

All passed.

closes #8214

@qnixsynapse qnixsynapse self-requested a review May 28, 2026 13:51
@tokamak-pm

tokamak-pm Bot commented Jun 3, 2026

Copy link
Copy Markdown

Review: PR #8218 - feat: add OpenUI integration

Summary

This is a substantial feature PR that adds experimental OpenUI integration to Jan. When enabled, the assistant can describe UI elements in OpenUI Lang, and Jan renders them as interactive components (buttons, forms, etc.) instead of plain markdown. User interactions with these components are routed back into the conversation.

Architecture Overview

The implementation follows a clean layered approach:

  • Settings layer: New Zustand store (useOpenUISettings) persisted to localStorage, with a dedicated settings page at /settings/openui.
  • Detection layer: openui-detect.ts uses regex to identify OpenUI Lang in assistant responses (fenced code blocks or bare root = assignments).
  • Rendering layer: OpenUIResponse component wraps RenderMarkdown, lazy-loading OpenUIRenderedContent only when OpenUI content is detected. Falls back to markdown if parsing fails.
  • Action layer: openui-actions.ts dispatches user interactions as CustomEvents on window, which ChatInput listens for and auto-submits as new messages.
  • Prompt injection: custom-chat-transport.ts appends OpenUI system prompt guidance when the feature is enabled.

Detailed Findings

Strengths:

  1. Lazy loading -- The heavy OpenUI libraries (@openuidev/react-ui, @openuidev/react-lang) are code-split via React.lazy(). Users who never enable OpenUI pay no bundle cost.
  2. Graceful fallback -- If OpenUI parsing fails, the response falls back to standard markdown rendering. The Suspense boundary also shows markdown while the lazy chunk loads.
  3. Feature gating -- Disabled by default, so no impact on existing users.
  4. Test coverage -- Tests for CTA clicks, fallback to prompt, and form submission payloads in OpenUIRenderedContent.test.tsx. SettingsMenu test updated.
  5. Custom memo -- OpenUIResponse has a custom memo comparator to avoid unnecessary re-renders.

Concerns:

  1. Security -- No sanitization of rendered UI. The OpenUI renderer trusts the model output. A malicious or confused model could generate UI that triggers unintended actions. The dispatchOpenUIChatAction sends whatever the model produced back into the chat. While this is an "experimental" feature, consider:

    • What happens if the model generates a form that auto-submits on render?
    • Is there any XSS surface in the OpenUI renderer itself?
    • The window.open(url, '_blank', 'noopener,noreferrer') for OpenUrl actions is good, but the URL comes from model output unsanitized -- could a javascript: URL slip through?
  2. System prompt always appended, not opt-in per thread. When OpenUI is enabled globally, every chat gets the OpenUI system prompt appended. This increases token usage and may confuse models that are not good at generating OpenUI Lang. Consider making this per-thread or per-assistant, or at minimum noting this trade-off in the settings description.

  3. handleSendMessageRef pattern in ChatInput. The ref is updated on every render (the useEffect has no dependency array). While this is a known React pattern for "latest ref," it is somewhat fragile. A comment explaining why would help maintainability.

  4. Detection regex may false-positive. The extractOpenUIResponse function triggers on any content with root = ... followed by a capitalized function call. This could match legitimate markdown code blocks explaining assignment syntax. The fenced code block detection is more reliable. Consider tightening the bare detection or requiring both root = and a known OpenUI component name.

  5. New dependencies are heavyweight. The PR adds @openuidev/react-ui which pulls in recharts, react-day-picker, date-fns, react-syntax-highlighter (a second copy -- Jan already has one), lodash (full, not lodash-es), victory-vendor, and many Radix primitives. Even though they are lazy-loaded, this significantly increases the overall bundle size. The yarn.lock diff is substantial (800+ lines of new dependencies).

  6. MessageItem.tsx -- Global replacement of RenderMarkdown with OpenUIResponse. This means every assistant message now goes through the OpenUI detection path (regex test) even when OpenUI is disabled. The useOpenUISettings check short-circuits early, but the Zustand selector still runs per message. This is probably fine performance-wise, but worth noting.

  7. No i18n for the OpenUI settings page. All strings on the settings page ("Enable OpenUI rendering", "Component library", "About OpenUI", etc.) are hardcoded in English. Other settings pages use t() for translations. This should be consistent.

  8. The routeTree.gen.ts changes appear to be auto-generated (by TanStack Router codegen). Verify this file is regenerated correctly in CI, not manually edited.

Missing

  • No e2e/integration tests for the full flow (enable OpenUI -> send message -> get OpenUI response -> interact with rendered component -> verify follow-up message).
  • No documentation for users about what OpenUI is and how to use it.
  • The merge_commit_sha is null and no reviewers have approved yet, suggesting CI may not have run successfully. The PR says validation passed locally.

Verdict

This is a well-architected feature with good code separation and lazy loading. However, it introduces a large dependency footprint and has security considerations around unsanitized model-generated UI that need attention before merging. The lack of i18n on the settings page is inconsistent with the rest of the codebase.

Recommendation: fix needed

Priority action items:

  1. Validate that the OpenUI renderer sanitizes URLs (prevent javascript: protocol) and does not allow arbitrary HTML/script injection.
  2. Add i18n strings for the OpenUI settings page.
  3. Consider the bundle size impact of the @openuidev/react-ui dependency tree (recharts, lodash, date-fns, etc.) and whether lighter alternatives exist.
  4. Add a comment explaining the handleSendMessageRef pattern in ChatInput.

@vishxrad vishxrad force-pushed the feat/openui-integration branch from 7908d7f to 1985c8f Compare June 4, 2026 11:22
@vishxrad

vishxrad commented Jun 4, 2026

Copy link
Copy Markdown
Author

Thanks for the review, I will refactor the pr and push the changes soon.

@vishxrad vishxrad force-pushed the feat/openui-integration branch 2 times, most recently from 80f761b to 143da66 Compare June 5, 2026 22:06
@tokamak-pm

tokamak-pm Bot commented Jun 6, 2026

Copy link
Copy Markdown

Re-review: PR #8218 - feat: add OpenUI integration

Updated review after the author's changes (commit 143da66 on 2026-06-05). Comparing against findings from the previous review on 2026-06-03.

What improved since the last review

The author addressed several of the original concerns:

  1. URL sanitization -- FIXED. openui-url.ts now validates all model-generated URLs via getSafeOpenUIUrl(), allowing only http: and https: protocols. javascript:, data:, file:, and relative URLs are blocked. There are dedicated tests in openui-url.test.ts confirming this. The OpenUIRenderedContent.test.tsx tests also verify that unsafe URLs are blocked and safe URLs pass through.

  2. i18n -- FIXED. All strings on the OpenUI settings page now use t() with proper keys in settings:openui.* and common:openui. All 17 locale files (ca, cs, de-DE, en, es, fr, hi, id, it, ja, ko, pl, pt-BR, ru, vn, zh-CN, zh-TW) have been updated with translated strings.

  3. Detection tightening -- IMPROVED. The openui-detect.ts detection now validates against a known OPENUI_COMPONENT_NAMES list instead of matching any capitalized identifier. Bare root = content must start at the beginning of the trimmed string (via ROOT_AT_START_RE), reducing false positives from prose containing root = ... mid-paragraph. Tests confirm this rejects unknown component names and prose-embedded examples.

  4. Documentation -- ADDED. docs/src/pages/docs/desktop/integrations/openui.mdx provides user-facing documentation covering setup, rendering behavior, safety, and custom libraries.

Remaining concerns

1. handleSendMessageRef pattern still lacks a comment.
In ChatInput.tsx, the ref is updated via useEffect with no dependency array. This is a valid "latest ref" pattern, but it is unusual enough that a brief comment explaining why would help future maintainers:

// Keep ref in sync with latest closure so the OpenUI event listener
// (registered once with []) always calls the current handleSendMessage.
const handleSendMessageRef = useRef(handleSendMessage)
useEffect(() => { handleSendMessageRef.current = handleSendMessage })

2. System prompt is appended to every chat when enabled globally.
This was noted in the previous review and has not changed. When OpenUI is enabled, every chat gets the OpenUI system prompt (~1,600 tokens for Chat mode, ~5,000 for Standard). The settings description now documents the token cost, which is an improvement, but it is still applied globally rather than per-thread or per-assistant. For a first "experimental" release this is acceptable, but I would suggest adding a TODO or issue to make this configurable per thread/assistant in a follow-up.

3. Bundle size from @openuidev/react-ui dependency tree.
The standard library mode pulls in recharts, react-day-picker, date-fns, react-syntax-highlighter (a second copy -- Jan already uses one), lodash (full, not lodash-es), and many Radix primitives. The yarn.lock adds ~390 lines of new dependencies. The chat mode is lighter (only @openuidev/react-lang + @openuidev/react-headless). The lazy loading via React.lazy() mitigates the impact for users who never enable the Standard library, but:

  • Users who enable Standard will pay the full bundle download cost
  • Consider whether @openuidev/react-ui could be made an optional/peer dependency rather than a direct dependency, so users who only want Chat mode do not download the Standard bundle at all

4. No XSS sanitization within the OpenUI renderer itself.
URL sanitization was addressed, but the broader question remains: can a model craft OpenUI Lang that injects arbitrary HTML or scripts into the rendered output? This depends on the @openuidev/react-lang Renderer component's internal sanitization. If the Renderer uses React's JSX (which escapes strings by default), the risk is low. But if any component uses dangerouslySetInnerHTML or equivalent, model-generated content could be a vector. This should be verified by auditing the upstream @openuidev/react-lang Renderer, or at minimum documented as a known limitation.

5. useEffect without dependency array runs on every render.
The handleSendMessageRef update effect (line ~473 in ChatInput.tsx) has no dependency array, meaning it runs after every render. While this is intentional (it keeps the ref current), it is worth noting that this adds a small amount of overhead per render in a component that re-renders frequently. A more explicit alternative would be useLayoutEffect with [handleSendMessage] as deps, but this is minor and the current approach works correctly.

6. Zustand peer dependency override in .yarnrc.yml.
The PR adds packageExtensions to widen the Zustand peer dependency range for @openuidev/react-headless and @openuidev/react-ui from ^4.5.5 to ^4.5.5 || ^5.0.0. The comment says this is needed until upstream publishes the same range. This is a clean workaround, but the team should track when upstream updates so this override can be removed.

Test coverage assessment

The PR includes solid test coverage:

  • openui-detect.test.ts -- Detection logic with positive and negative cases
  • openui-url.test.ts -- URL validation for safe and unsafe protocols
  • openui.test.ts -- System prompt injection with enabled/disabled/undefined cases, and token count assertion
  • OpenUIRenderedContent.test.tsx -- CTA clicks, prompt fallback, form submission payloads with form state, and URL safety (both allowed and blocked)
  • ChatInput.test.tsx -- OpenUI action dispatch through the active input listener
  • SettingsMenu.test.tsx -- Menu item presence

Missing: No e2e tests for the full enable-to-interact flow, but this is understandable for an experimental feature.

Architecture

The layered approach is clean and well-separated:

  • Settings (useOpenUISettings Zustand store) -- persistence, feature flag
  • Detection (openui-detect.ts) -- content identification
  • Prompt injection (openui.ts via custom-chat-transport.ts) -- system prompt augmentation
  • Rendering (OpenUIResponse -> OpenUIRenderedContent -> OpenUILibraryRenderedContent) -- lazy-loaded rendering pipeline with markdown fallback
  • Actions (openui-actions.ts) -- CustomEvent dispatch from UI interactions back to chat
  • URL safety (openui-url.ts) -- protocol allowlisting

The two library modes (Chat vs Standard) are well-isolated: Chat uses the locally defined janOpenUIChatLibrary (437 lines of custom components using Jan's design system), while Standard lazy-loads the upstream @openuidev/react-ui bundle.

Verdict

The author has addressed the most critical issues from the first review (URL sanitization, i18n, detection accuracy, documentation). The remaining items are either minor (missing comment on ref pattern), known trade-offs (bundle size, global prompt injection), or require upstream verification (XSS in Renderer).

Recommendation: improve needed

Minimum before merge:

  1. Add a brief comment on the handleSendMessageRef pattern in ChatInput.tsx explaining why it has no dependency array.
  2. Verify (or document as a known limitation) that the @openuidev/react-lang Renderer does not allow arbitrary HTML injection from model output.

Nice-to-have for follow-up:
3. Track per-thread/per-assistant OpenUI toggle as a future enhancement.
4. Consider making @openuidev/react-ui an optional dependency.
5. Track removal of the .yarnrc.yml peer dependency override once upstream updates.

@vishxrad vishxrad force-pushed the feat/openui-integration branch from 143da66 to 41d305b Compare June 8, 2026 07:40
@vishxrad

vishxrad commented Jun 8, 2026

Copy link
Copy Markdown
Author

Updated the PR to make OpenUI opt-in per chat instead of global.

The OpenUI prompt is still included on each model request where OpenUI is enabled, because the model needs those instructions in-context to reliably emit OpenUI Lang. However, it is now scoped to the active chat only:

  • Added a per-chat OpenUI toggle in the chat composer.
  • OpenUI is disabled by default for existing chats.
  • New chats transfer the temporary OpenUI toggle state to the created thread ID.
  • Prompt injection now checks whether OpenUI is enabled for that specific thread before appending the OpenUI system prompt.
  • OpenUI rendering also checks the active thread’s OpenUI state, so responses are only parsed/rendered as OpenUI in enabled chats.
  • The OpenUI settings page now only controls the component library choice and explains how to enable OpenUI per chat.
  • Updated the docs to clarify that OpenUI is enabled per chat, and token cost applies only to requests in enabled chats.

Validation run locally:

  • TypeScript passed.
  • Web ESLint passed.
  • Focused OpenUI/ChatInput tests passed: 9 files, 83 tests.

@tokamak-pm

tokamak-pm Bot commented Jun 9, 2026

Copy link
Copy Markdown

Follow-up Review (new commit since Jun 6)

Reviewing commit: 41d305b — OpenUI is now a per-chat toggle instead of a global setting.

What Changed

The author addressed the highest-priority concern from the previous review: OpenUI is no longer a global toggle.

  • A per-chat OpenUI toggle button has been added to the chat composer, disabled by default.
  • The useOpenUISettings Zustand store now tracks enabledThreads: Record<string, boolean> with setEnabled, toggleEnabled, transferThread, removeThread, and clearAllThreads.
  • transferThread correctly migrates temporary-chat OpenUI state to the real thread ID when a new thread is created.
  • removeThread is called from every thread-deletion path in useThreads.ts (single delete, batch delete, clear all), keeping the persisted map from growing unboundedly.
  • Prompt injection in custom-chat-transport.ts now checks openUISettings.isEnabled(threadId) before appending the system prompt, so token overhead only applies to opted-in chats.
  • New tests added: useOpenUISettings.test.ts (5 cases) and OpenUIResponse.test.tsx (2 cases).

This directly resolves concern #2 from the last review.

Remaining Items

1. handleSendMessageRef still has no explanatory comment (flagged previously as minimum-before-merge). The ref+effect pattern in ChatInput.tsx (~lines 203-207) needs a single-line comment explaining why the effect has no dependency array. Trivial fix.

2. XSS audit of @openuidev/react-lang Renderer (not addressed). For an experimental feature, this is acceptable as a documented known limitation, but it should be documented somewhere (docs page, code comment, or linked issue).

3. enabledThreads map may accumulate stale entries across sessions if threads are deleted outside useThreads (e.g., directly from app data folder). Low severity — worth a TODO comment.

What Looks Good

  • transferThread implementation is clean and handles the undefined edge case correctly
  • clearAllThreads placement in the "delete all" path is correct
  • OpenUIResponse memoization is correct — enabled is read via Zustand selector inside the component
  • Test coverage for the new store and component is solid

Recommendation: improve needed (minor)

Required before merge:

  1. Add a one-line comment to the handleSendMessageRef / useEffect block in ChatInput.tsx explaining the ref pattern.

Nice-to-have:
2. Document that OpenUI Lang safety depends on @openuidev/react-lang's internal sanitization.
3. Add a TODO in useOpenUISettings.ts about potential stale enabledThreads entries.

vishxrad and others added 3 commits June 10, 2026 20:54
- Update docs to describe enabling OpenUI via the Tools menu switch
  instead of the removed composer button
- Document that HTML-safety relies on the upstream @openuidev/react-lang
  renderer's internal sanitization
- Remove orphaned noToolsAvailable key from all 17 locales
- Point the settings-page enable description at the per-chat Tools menu
  toggle in all 17 locales

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@tokamak-pm

tokamak-pm Bot commented Jun 11, 2026

Copy link
Copy Markdown

Follow-up Review (new commits detected since last review)

Reviewing commits 25a3291, 36e16b5, 0fe1ef8 pushed on 2026-06-10, after the previous review on 2026-06-09.


What Changed Since Last Review

Three new commits were pushed on June 10 -- all follow-on polish to the per-chat activation refactor reviewed on June 9:

  1. refactor(openui): move activation into tools menu (25a3291) -- The per-chat OpenUI toggle is moved out of a standalone composer button and into the existing Tools dropdown (DropdownToolsAvailable). The tools button now becomes the single entry point for both MCP tool toggles and the OpenUI switch. The button gains an active-state style (primary tint + filled variant) when OpenUI is on, giving clear visual feedback without adding a second icon to the toolbar.

  2. fix(openui): clean up tools menu trigger (36e16b5) -- Follow-up cleanup of the refactor above: removes a stray console.log from DropdownToolsAvailable.tsx, tidies formatting in ChatInput.tsx, and simplifies the conditional rendering of the MCP tools component from a ternary to a short-circuit (&&).

  3. fix(openui): align docs and locales with tools-menu activation (0fe1ef8) -- Updates the docs page and all 17 locale files to describe enabling OpenUI via the Tools menu switch (instead of the previously removed dedicated button), documents that HTML safety relies on @openuidev/react-lang's internal sanitization, and removes the now-orphaned noToolsAvailable locale key from every locale.


Previous Required Items -- Status

Item Status
Add comment to handleSendMessageRef / useEffect pattern in ChatInput.tsx DONE -- Comment // Keep the once-registered OpenUI listener pointed at the latest send closure. is present in the diff
Document that HTML safety depends on @openuidev/react-lang sanitization DONE -- The docs page now explicitly states: "Jan does not add its own sanitization layer, so this guarantee depends on the internal sanitization of the upstream @openuidev/react-lang renderer."

Both minimum-before-merge items from the last review have been addressed.


Review of New Changes

UX improvement: Consolidating the OpenUI toggle into the Tools menu is a cleaner design -- fewer icons in the composer bar, one consistent place for per-chat tool options. The active-state styling (bg-primary/10, text-primary on the button icon) is a good affordance.

DropdownToolsAvailable refactor is clean:

  • The old noToolsAvailable empty-state branch is removed, and the dropdown is now always rendered (it always shows the OpenUI toggle at minimum). This is correct -- the dropdown is never empty anymore.
  • showMCPTools prop correctly gates MCP tool entries without affecting the OpenUI section.
  • The useMemo wrapping of the tools filter is a good addition.
  • The console.log removal is welcome.

Locale cleanup: Removing noToolsAvailable from all 17 locale files is correct hygiene since the key is no longer used. The new settings:openui.description key shown inline in the Tools menu gives users context about what OpenUI is without leaving the chat.

OpenUIResponse memoization: The custom memo comparator in OpenUIResponse.tsx compares only the props passed from outside, but enabled is read via a Zustand selector inside the component. This is correct -- Zustand subscriptions bypass memo, so toggling OpenUI on/off in the Tools menu will still trigger a re-render.

Minor observation -- DropdownToolsAvailable always shown now: Previously, the tools button was only rendered when !effectiveAgentMode && selectedModel?.capabilities?.includes('tools') && hasActiveMCPServers. Now the DropdownToolsAvailable wrapper (and its trigger button) is always visible regardless of agent mode or model capabilities, because OpenUI is independent of those conditions. This is correct behavior, but reviewers should verify the button does not appear awkwardly in agent mode or for models that do not support tools. Worth a quick manual smoke test.


Remaining Nice-to-Haves (not blocking)

  1. Per-thread stale enabledThreads entries -- still possible if threads are deleted outside useThreads (e.g., direct data-folder manipulation). The TODO comment was suggested in the previous review but not yet added. Non-blocking for an experimental feature.
  2. @openuidev/react-ui bundle size -- unchanged trade-off from previous reviews.
  3. Removal of .yarnrc.yml peer extension once upstream widens its Zustand peer range -- unchanged, should be tracked as a follow-up.

Recommendation: can merge

All minimum-before-merge items from the previous reviews have been addressed:

  • URL sanitization (getSafeOpenUIUrl) checked
  • i18n for all settings strings across 17 locales checked
  • Per-chat (not global) OpenUI toggle checked
  • handleSendMessageRef comment explaining the ref pattern checked
  • HTML safety documentation in user-facing docs checked

The refactor to move OpenUI activation into the Tools menu is a sound UX decision. The code is clean, tests pass locally per the author, and the PR is well-scoped. The remaining items are appropriate for follow-up issues rather than blocking this merge.

Suggested before merging: a quick manual smoke test confirming the tools button renders sensibly in agent mode, since its visibility condition changed (it is now always shown rather than gated on MCP availability).

@qnixsynapse qnixsynapse 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.

This seems like adding an entire new third party library. Need to decide on whether we want to include it. I am moving it to 0.8.5 release.

@qnixsynapse qnixsynapse added this to the v0.8.5 milestone Jun 17, 2026
@tokamak-pm

tokamak-pm Bot commented Jun 20, 2026

Copy link
Copy Markdown

Follow-up — branch update noted

Thanks for keeping the branch up to date with main.

Picking up from the previous review: the outstanding item is a manual smoke test confirming the tools/OpenUI button renders correctly in agent mode — specifically that the DropdownToolsAvailable button appears and the OpenUI toggle works when agent mode is active, since the MCP availability gate was removed from the button's render condition. Could you confirm you've tested that path, or share a screenshot?

No other blocking issues from the diff. Once that's confirmed, this looks ready.

Recommendation: improve needed (pending confirmation of manual smoke test)

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

Labels

None yet

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

idea: Add optional OpenUI integration for interactive assistant responses

2 participants