Skip to content

feat(extensions): implement dynamic UI rendering for extension panels#1953

Merged
yujonglee merged 4 commits intomainfrom
devin/1764243917-extension-ui-rendering
Nov 27, 2025
Merged

feat(extensions): implement dynamic UI rendering for extension panels#1953
yujonglee merged 4 commits intomainfrom
devin/1764243917-extension-ui-rendering

Conversation

@yujonglee
Copy link
Contributor

@yujonglee yujonglee commented Nov 27, 2025

Summary

This PR implements the missing UI rendering part for extension panels, completing the work started in PRs #1938, #1945, and #1949. Previously, extension panels were registered but showed "UI not loaded" because there was no mechanism to actually load and render the extension's React components.

The solution uses a globals-based approach where:

  1. The extension build outputs IIFE format that expects React/UI dependencies as window globals
  2. The host app exposes these dependencies (__hypr_react, __hypr_jsx_runtime, __hypr_ui, etc.) before any extension loads
  3. When an extension tab opens, the script is fetched via convertFileSrc, injected into the DOM, and the exported component is registered

Key changes:

  • extensions/build.mjs: Changed from ESM to IIFE format with an esbuild plugin that resolves external deps to window globals
  • extension-globals.ts: New file that exposes React, ReactDOM, jsx-runtime, and @hypr/ui components as globals
  • registry.ts: Added loadExtensionUI() function that fetches, injects, and registers extension components
  • index.tsx: Updated TabContentExtension to show loading state and trigger dynamic loading
  • tauri.conf.json: Added $APPDATA/** to asset protocol scope to allow loading extension scripts from the app data directory

Updates since last revision

  • Build script refinements: Added clean, install commands with colored logging and better error handling. The install command copies extensions to the platform-specific app data directory for development.
  • Developer documentation: Added apps/web/content/docs/developers/8.extensions.mdx with comprehensive guide covering extension structure, manifest format, runtime scripts, panel UIs, available globals, and development workflow.
  • Updated package.json scripts: Added clean and install:dev scripts for easier development workflow.

Tested locally

The extension UI rendering was verified working on Linux:

Extension UI working

View original video (rec-1b9c32910eeb41b1bf7b7ba4de6566a8-edited.mp4)

Review & Testing Checklist for Human

  • Security review: Verify the $APPDATA/** asset protocol scope addition is acceptable - this allows the app to serve files from the app data directory
  • Test on macOS: The implementation was tested on Linux; verify it works on macOS with the correct extensions path
  • Review limited component exposure: Only Button and Card are exposed in extension-globals.ts; extensions using other @hypr/ui components will fail silently
  • Documentation accuracy: Review 8.extensions.mdx for correctness and completeness

Test Plan

  1. Run ONBOARDING=0 pnpm -F desktop tauri dev
  2. Build and install the hello-world extension:
    cd extensions
    pnpm install
    pnpm build:hello-world
    pnpm install:dev
  3. Click "Hello World" in the profile menu
  4. Verify the extension UI renders with the Card, buttons, and counter functionality

Notes

  • The script injection approach (document.head.appendChild(script)) is used instead of dynamic import because the extension scripts need access to the host app's React instance
  • Extensions that use @hypr/ui components beyond Button and Card will need those components added to extension-globals.ts
  • The install command in build.mjs handles macOS, Linux, and Windows paths but Windows was not tested

Link to Devin run: https://app.devin.ai/sessions/1fe16a177a9a48a48c1d09e80c93bdc1
Requested by: yujonglee (@yujonglee)

- Modify extension build to use IIFE format with globals instead of ESM
- Add esbuild plugin to resolve external dependencies to window globals
- Create extension-globals.ts to expose React, ReactDOM, jsx-runtime, and @hypr/ui components
- Initialize globals in main.tsx before app renders
- Implement loadExtensionUI function in registry.ts to dynamically load extension scripts
- Update TabContentExtension to show loading state and load extension UI on mount
- Use convertFileSrc to convert filesystem paths to URLs for script loading

Co-Authored-By: yujonglee <yujonglee.dev@gmail.com>
@devin-ai-integration
Copy link
Contributor

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR that start with 'DevinAI' or '@devin'.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment and CI monitoring

@netlify
Copy link

netlify bot commented Nov 27, 2025

Deploy Preview for hyprnote-storybook ready!

Name Link
🔨 Latest commit ec2413a
🔍 Latest deploy log https://app.netlify.com/projects/hyprnote-storybook/deploys/692842718e63a200081aac92
😎 Deploy Preview https://deploy-preview-1953--hyprnote-storybook.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@netlify
Copy link

netlify bot commented Nov 27, 2025

Deploy Preview for hyprnote ready!

Name Link
🔨 Latest commit ec2413a
🔍 Latest deploy log https://app.netlify.com/projects/hyprnote/deploys/6928426f0dfdba000818cc0f
😎 Deploy Preview https://deploy-preview-1953--hyprnote.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Nov 27, 2025

📝 Walkthrough

Walkthrough

Adds runtime extension UI loading: app initializes extension globals, panels request UI via a new loadExtensionUI flow that fetches and executes IIFE extension bundles (which set __hypr_panel_exports), registers default exports as components, and manages per-panel load state and errors.

Changes

Cohort / File(s) Summary
Extension panel UI
apps/desktop/src/components/main/body/extensions/index.tsx
Adds loadState (idle/loading/loaded/error), forceUpdate, and a useEffect-driven async load that calls loadExtensionUI(tab.extensionId); shows LoaderIcon while loading and renders loaded component or error/fallback.
Extension registry loader
apps/desktop/src/components/main/body/extensions/registry.ts
Adds loadExtensionUI(extensionId): Promise<boolean> and loadingExtensions Set; validates entry_path, fetches & injects IIFE via convertFileSrc, captures/restores __hypr_panel_exports, registers default export with registerExtensionComponent, and returns success/failure with logging.
Extension global shims
apps/desktop/src/extension-globals.ts
New module exporting initExtensionGlobals() and augmenting window with __hypr_react, __hypr_react_dom, __hypr_jsx_runtime, __hypr_ui, and __hypr_utils; pre-populates __hypr_ui entries for core UI.
App bootstrap
apps/desktop/src/main.tsx
Imports and invokes initExtensionGlobals() during startup (after initWindowsPlugin()), ensuring globals exist before extension scripts run.
Extension bundler
extensions/build.mjs
Switches esbuild output to IIFE with global __hypr_panel_exports, adds hypr-externals plugin mapping core deps to window.__hypr_*, and emits per-panel IIFE bundles consumable by runtime loader.
Tauri asset scope
apps/desktop/src-tauri/tauri.conf.json
Expands security.assetProtocol scope to include $APPDATA/** in addition to "**/*".
Docs & scripts
apps/web/content/docs/developers/8.extensions.mdx, extensions/package.json
Adds extension developer docs and updates build/clean/install scripts to new CLI subcommands.

Sequence Diagram

sequenceDiagram
    participant Panel as Extension Panel (index.tsx)
    participant Registry as Extension Registry (registry.ts)
    participant Fetch as Tauri/File Fetch
    participant Window as Window Globals
    participant Extension as Extension IIFE Bundle

    Panel->>Registry: request component for extensionId
    Registry->>Registry: is component registered?
    alt not registered
        Registry-->>Panel: indicate loading
        Panel->>Panel: set loadState="loading"
        Registry->>Fetch: fetch entry_path via convertFileSrc
        Fetch->>Extension: load/execute IIFE script
        Extension->>Window: read globals (__hypr_react, __hypr_ui, etc.)
        Extension->>Window: set __hypr_panel_exports (bundle exports)
        Registry->>Window: read __hypr_panel_exports
        Registry->>Registry: extract default export
        Registry->>Registry: registerExtensionComponent(extensionId, component)
        Registry-->>Panel: resolve true
        Panel->>Panel: set loadState="loaded" and render component
    else registered
        Registry-->>Panel: return component immediately
        Panel->>Panel: render component
    end
    alt load error
        Panel->>Panel: set loadState="error" and show fallback message
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

  • Inspect convertFileSrc usage and path handling for Tauri.
  • Verify __hypr_panel_exports capture/restore to avoid races/leaks with concurrent loads.
  • Confirm loadingExtensions cleanup in all paths (success/error/finally).
  • Validate initExtensionGlobals() contents match names expected by built bundles and hypr-externals mapping.
  • Review esbuild IIFE output and plugin to ensure runtime global names align with injected globals.

Possibly related PRs

Suggested reviewers

  • yujonglee

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The PR title accurately describes the main change: implementing dynamic UI rendering for extension panels, which is the core objective of this changeset.
Description check ✅ Passed The PR description is comprehensive and directly related to the changeset, explaining the problem, solution approach, key changes, testing, and review considerations.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch devin/1764243917-extension-ui-rendering

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (2)
apps/desktop/src/extension-globals.ts (1)

19-28: Keep __hypr_ui keys aligned with the build-time externals mapping

The keys:

window.__hypr_ui = {
  "components/ui/button": Button,
  "components/ui/card": Card,
};

must stay in lockstep with the subpaths computed in extensions/build.mjs (e.g. @hypr/ui/components/ui/button"components/ui/button"). If new UI modules are exposed to extensions, they will need matching entries here; otherwise imports will resolve to undefined at runtime.

It may be worth centralizing these subpath strings into a small shared constant module used by both the build script and this initializer to avoid subtle mismatches.

extensions/build.mjs (1)

48-50: IIFE/globalName wiring matches runtime usage; tighten externals handling to avoid silent failures

The switch to:

format: "iife",
globalName: "__hypr_panel_exports",

correctly lines up with the runtime code that reads window.__hypr_panel_exports in registry.ts. The externals plugin also matches the globals initialized in extension-globals.ts, which is good.

Two things to consider tightening:

  1. Silent stubbing for unknown hypr-global paths
return { contents: "module.exports = {}", loader: "js" };

This will happily succeed the build but give extensions an empty module at runtime if:

  • A new @hypr/ui or @hypr/utils path is added but not wired into window.__hypr_ui / window.__hypr_utils, or
  • An import is misspelled.

It’s usually preferable for those to be hard build failures. For example, you could throw here or at least console.error and fail the build so issues surface early.

  1. @hypr/utils subpaths always mapped to root
if (args.path.startsWith("@hypr/utils")) {
  return {
    contents: "module.exports = window.__hypr_utils",
    loader: "js",
  };
}

This assumes extensions will only ever import the root @hypr/utils. If someone imports a subpath (e.g. @hypr/utils/foo), they’ll still get the root module, which may be confusing.

If subpath imports are not intended, you might restrict the resolve filter to ^@hypr/utils$ and let other paths fail, or add explicit handling similar to @hypr/ui.

Both changes would make extension builds fail fast instead of producing subtle runtime issues.

Also applies to: 53-124

📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between d53f5f5 and b7fa813.

📒 Files selected for processing (5)
  • apps/desktop/src/components/main/body/extensions/index.tsx (4 hunks)
  • apps/desktop/src/components/main/body/extensions/registry.ts (3 hunks)
  • apps/desktop/src/extension-globals.ts (1 hunks)
  • apps/desktop/src/main.tsx (2 hunks)
  • extensions/build.mjs (1 hunks)
🧰 Additional context used
📓 Path-based instructions (2)
**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.{ts,tsx}: Avoid creating a bunch of types/interfaces if they are not shared. Especially for function props, just inline them instead.
Never do manual state management for form/mutation. Use useForm (from tanstack-form) and useQuery/useMutation (from tanstack-query) instead for 99% of cases. Avoid patterns like setError.
If there are many classNames with conditional logic, use cn (import from @hypr/utils). It is similar to clsx. Always pass an array and split by logical grouping.
Use motion/react instead of framer-motion.

Files:

  • apps/desktop/src/main.tsx
  • apps/desktop/src/components/main/body/extensions/registry.ts
  • apps/desktop/src/components/main/body/extensions/index.tsx
  • apps/desktop/src/extension-globals.ts
**/*.ts

📄 CodeRabbit inference engine (CLAUDE.md)

**/*.ts: Agent implementations should use TypeScript and follow the established architectural patterns defined in the agent framework
Agent communication should use defined message protocols and interfaces

Files:

  • apps/desktop/src/components/main/body/extensions/registry.ts
  • apps/desktop/src/extension-globals.ts
🧠 Learnings (1)
📚 Learning: 2025-11-24T16:32:19.706Z
Learnt from: CR
Repo: fastrepl/hyprnote PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-11-24T16:32:19.706Z
Learning: Applies to **/*.{ts,tsx} : Use `motion/react` instead of `framer-motion`.

Applied to files:

  • apps/desktop/src/components/main/body/extensions/index.tsx
🧬 Code graph analysis (3)
apps/desktop/src/main.tsx (1)
apps/desktop/src/extension-globals.ts (1)
  • initExtensionGlobals (19-29)
apps/desktop/src/components/main/body/extensions/registry.ts (2)
apps/desktop/src/types/extensions.d.ts (1)
  • ExtensionViewProps (3-6)
extensions/hello-world/ui.tsx (1)
  • ExtensionViewProps (13-16)
apps/desktop/src/components/main/body/extensions/index.tsx (2)
apps/desktop/src/components/main/body/extensions/registry.ts (3)
  • getExtensionComponent (27-34)
  • getPanelInfoByExtensionId (71-76)
  • loadExtensionUI (112-174)
apps/desktop/src/components/main/body/index.tsx (1)
  • StandardTabWrapper (349-368)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (7)
  • GitHub Check: Redirect rules - hyprnote
  • GitHub Check: Header rules - hyprnote
  • GitHub Check: Pages changed - hyprnote
  • GitHub Check: fmt
  • GitHub Check: ci (linux, depot-ubuntu-22.04-8)
  • GitHub Check: ci (linux, depot-ubuntu-24.04-8)
  • GitHub Check: ci (macos, depot-macos-14)
🔇 Additional comments (3)
apps/desktop/src/extension-globals.ts (1)

9-16: Clarify lifecycle/optionality of window.__hypr_* globals

These properties are declared as required on Window, but only become available after initExtensionGlobals() runs. For tests or any code that might touch them before bootstrap, this can be surprising.

Consider either:

  • Marking them as optional (__hypr_react?: typeof React, etc.) and narrowing at call sites, or
  • Adding a brief doc comment on initExtensionGlobals() stating it must run before any extension script executes.

This helps avoid accidental early access in future refactors.

apps/desktop/src/main.tsx (1)

20-21: Bootstrap ordering for extension globals looks reasonable

Calling initExtensionGlobals() at startup, right after initWindowsPlugin(), ensures the window.__hypr_* globals are ready before any extension UI tries to load, and it runs only once per webview.

Just make sure no extension-related code can run before main.tsx executes (e.g., alternative entry points) so those globals are always initialized first.

Also applies to: 89-91

apps/desktop/src/components/main/body/extensions/index.tsx (1)

1-3: Imports and motion/react usage look aligned with guidelines

Using motion/react and cn here is consistent with the repo’s conventions and the documented preference over framer-motion. The registry imports also neatly centralize extension loading concerns instead of duplicating logic in the UI layer.

Also applies to: 17-21

devin-ai-integration bot and others added 3 commits November 27, 2025 12:12
…ading

Co-Authored-By: yujonglee <yujonglee.dev@gmail.com>
Co-Authored-By: yujonglee <yujonglee.dev@gmail.com>
- Add clean, install commands to build script
- Add colored logging and better error handling
- Add extensions.mdx documentation for developers
- Update package.json with new scripts

Co-Authored-By: yujonglee <yujonglee.dev@gmail.com>
@argos-ci
Copy link

argos-ci bot commented Nov 27, 2025

The latest updates on your projects. Learn more about Argos notifications ↗︎

Build Status Details Updated (UTC)
web (Inspect) ⚠️ Changes detected (Review) 3 changed Nov 27, 2025, 12:24 PM

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

🧹 Nitpick comments (2)
extensions/build.mjs (2)

243-285: Install helpers work; consider propagating failures and scoping copied files

The install flow correctly validates extension.json, mirrors the extension into the computed app-data extensions dir, and logs the final target path. Two optional refinements:

  • installAll() ignores the boolean result from installExtension, so a missing/broken extension still yields exit code 0. Mirroring the buildAll() pattern (track a success flag and exit 1 on any failure) would make failures more visible in CI/scripts.
  • copyRecursive copies the entire extension directory, including node_modules and source files. For dev this is acceptable, but you may want to consider excluding obvious heavy directories (e.g., node_modules) or restricting to extension.json, dist/, and asset folders if that’s sufficient for runtime.

6-8: CLI argument parsing and command handling are good; legacy mode could be a bit stricter

Parsing process.argv.slice(2) into command/extensionName, handling build|clean|install|help, and exiting with 1 on build failures is all well-structured, and the main().catch guard gives you a clean failure mode.

One minor UX edge case: in the default branch, any non-flag first arg is treated as a legacy “extension name” (e.g., node build.mjs my-ext). That preserves backwards compatibility, but also means a typo like node build.mjs biuld tries to build a non-existent extension instead of immediately flagging an unknown command. You might optionally gate this legacy path on the extension actually existing (or on an explicit flag) and otherwise print usage.

Also applies to: 287-341

📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 86a07f6 and ec2413a.

📒 Files selected for processing (3)
  • apps/web/content/docs/developers/8.extensions.mdx (1 hunks)
  • extensions/build.mjs (2 hunks)
  • extensions/package.json (1 hunks)
✅ Files skipped from review due to trivial changes (1)
  • apps/web/content/docs/developers/8.extensions.mdx
🧰 Additional context used
🧬 Code graph analysis (1)
extensions/build.mjs (1)
apps/desktop/src/components/main/body/extensions/registry.ts (1)
  • getExtensionsDir (59-65)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (8)
  • GitHub Check: Redirect rules - hyprnote
  • GitHub Check: Header rules - hyprnote
  • GitHub Check: Pages changed - hyprnote
  • GitHub Check: ci (linux, depot-ubuntu-24.04-8)
  • GitHub Check: ci (linux, depot-ubuntu-22.04-8)
  • GitHub Check: fmt
  • GitHub Check: ci (macos, depot-macos-14)
  • GitHub Check: ci (macos, macos-14)
🔇 Additional comments (6)
extensions/package.json (1)

7-10: Scripts correctly wired to the new multi-command CLI

build, build:hello-world, clean, and install:dev all delegate cleanly to build.mjs with the expected subcommands, matching the CLI usage in build.mjs. No issues from a tooling or ergonomics standpoint.

extensions/build.mjs (5)

1-4: Importing os for platform-specific extension paths is appropriate

Using os.platform()/os.homedir() alongside path and fs to compute the extensions directory is the right choice for this Node-based CLI; nothing to fix here.


201-224: Extension discovery and aggregated build result behavior look correct

getExtensionDirs() sensibly filters to non-dot, non-node_modules directories that contain an extension.json, and buildAll() aggregates their build results into a single success flag used by the CLI for exit codes. This is a clean and predictable setup.


226-241: Clean helpers are straightforward and safe

cleanExtension only removes <name>/dist when present and logs clearly; cleanAll fans this out over discovered extensions. No correctness or UX issues here.


9-55: I'll help you verify the concerns raised in the review comment. Let me search the codebase to understand the scope of the issue and check for potential sync problems between the CLI and runtime.
<function_calls>


#!/bin/bash

Search for app ID and extensions dir references

echo "=== Searching for com.hyprnote.dev ==="
rg -n "com.hyprnote.dev" -C 2

echo ""
echo "=== Searching for getExtensionsDir ==="
rg -n "getExtensionsDir" -C 2

echo ""
echo "=== Searching for extensions path patterns ==="
rg -n ".local.*share|AppData.*Roaming|Library.*Application Support" -C 2 | head -50


</function_calls>


57-199: globals contract is correctly aligned; fallback behavior remains a latent fragility

The @hypr/ui subpath extraction in the plugin correctly derives "components/ui/button" and "components/ui/card" from imports, which precisely match the keys defined in window.__hypr_ui in apps/desktop/src/extension-globals.ts. The example extension (extensions/hello-world/ui.tsx) imports only from these two components, confirming current alignment.

However, the original review concern stands: the onLoad fallback returns an empty object for any unrecognized @hypr/ui subpath (e.g., if an extension tries to import @hypr/ui/components/ui/accordion before it's exported in extension-globals). This masks what should be a build-time failure and defers the error to runtime. While currently not manifesting (only button and card are exported and used), this pattern creates technical debt as more UI components are added.

The subpath extraction logic and globals initialization are sound for the present state, but consider adding explicit validation or warnings in the plugin's onLoad handler when accessing undefined keys in window.__hypr_ui to catch integration mismatches earlier.

@yujonglee yujonglee merged commit 02d5673 into main Nov 27, 2025
16 of 17 checks passed
@yujonglee yujonglee deleted the devin/1764243917-extension-ui-rendering branch November 27, 2025 13:13
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.

1 participant