Skip to content

feat(web): Import Skill modal + fix controller symlink crash#355

Merged
alchemistklk merged 6 commits intomainfrom
feat/import-skill-modal
Mar 23, 2026
Merged

feat(web): Import Skill modal + fix controller symlink crash#355
alchemistklk merged 6 commits intomainfrom
feat/import-skill-modal

Conversation

@alchemistklk
Copy link
Copy Markdown
Contributor

@alchemistklk alchemistklk commented Mar 23, 2026

Relates to #375

Summary

  • Import Skill Modal: Replaced the inline hidden file input + button on the Skills page with a polished modal dialog ported from the design-system. Two tabs: Upload Zip (wired to existing backend) and GitHub Link (placeholder/coming soon). Added Dialog UI component (Radix-based).
  • Bilingual i18n: Added 14 translation keys for EN/ZH-CN covering all modal strings.
  • Controller startup fix: Fixed EINVAL crash when fs.cp() copies runtime plugin directories containing node_modules/.bin symlinks. Symlinks are now filtered out during the recursive copy.

Test plan

  • pnpm typecheck passes
  • pnpm lint passes
  • pnpm dev — open Skills page, click Import button, verify modal opens
  • Verify Upload Zip tab: drag-and-drop zone accepts .zip files, import works
  • Verify GitHub Link tab shows disabled/coming-soon state
  • Verify success state shows checkmark and auto-closes
  • Switch locale to Chinese — verify all modal strings are translated
  • pnpm start — desktop app launches past "Starting local services" screen

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added Import Skill modal with ZIP upload, GitHub placeholder, import error/success states, and auto-close on success.
    • ZIP validation and auto-close controller for improved import flow.
  • UI/UX

    • New standardized dialog components for consistent modal styling and behavior.
  • Internationalization

    • Added English and Simplified Chinese copy for the import workflow.
  • Tests

    • New unit tests for import modal state utilities and runtime plugin file handling.

On process restart, lastWrittenContent is null, causing the first write()
to always hit disk even if the config file already has identical content.
This triggers an unnecessary OpenClaw reload on every controller restart.

Read the existing file to seed the in-memory cache on the first write()
call, so cold starts skip the write when content matches.
Replace the inline hidden file input + button with a polished modal
dialog ported from the design-system. Two tabs: Upload Zip (wired to
existing zip import backend) and GitHub Link (placeholder, coming soon).
Adds Dialog UI component (Radix-based) and i18n keys for EN/ZH-CN.

Also fixes controller startup crash (EINVAL) when copying runtime plugin
dirs containing node_modules/.bin symlinks by filtering out symlinks
during the recursive copy.
@alchemistklk
Copy link
Copy Markdown
Contributor Author

Code review

Found 2 issues:

  1. Symlink filter is too broad: the filter skips all symlinks in the plugin directory tree, but only node_modules/.bin symlinks cause the EINVAL crash. A more targeted filter (e.g. skipping only .bin directories, or using dereference: true + .bin exclusion as PR feat(desktop): allocate runtime ports dynamically #331 previously did) would avoid silently dropping useful symlinks elsewhere in the plugin tree.

this.env.runtimePluginTemplatesDir,
entry.name,
);
const targetDir = path.join(this.env.openclawExtensionsDir, entry.name);
await cp(sourceDir, targetDir, {
recursive: true,
force: true,
filter: async (src) => !(await isSymlink(src)),
});

  1. No test coverage for the OpenClawRuntimePluginWriter symlink fix. The parallel OpenClawConfigWriter has tests (openclaw-config-writer.test.ts), but there is no test file for the plugin writer at all. This is a crash-bug fix that should have a test verifying that directories containing node_modules/.bin symlinks copy without EINVAL, and that regular files are preserved. CLAUDE.md requires 80% test coverage and TDD workflow.

} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
return;
}
throw err;
}
for (const entry of entries) {
if (!entry.isDirectory()) {
continue;
}
const sourceDir = path.join(
this.env.runtimePluginTemplatesDir,
entry.name,
);
const targetDir = path.join(this.env.openclawExtensionsDir, entry.name);
await cp(sourceDir, targetDir, {
recursive: true,
force: true,
filter: async (src) => !(await isSymlink(src)),
});

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

…lter

Keep main's more targeted approach (dereference: true + skip .bin dirs)
instead of the broader all-symlink filter. Both fix the same EINVAL
crash but main's approach is more specific and preserves useful symlinks.
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Mar 23, 2026

📝 Walkthrough

Walkthrough

This PR adds a modal-based skill import flow (ZIP and GitHub tab), a Radix dialog wrapper, i18n entries for import UI (en and zh-CN), state utilities and tests for the modal logic, and a controller test validating plugin-template symlink handling.

Changes

Cohort / File(s) Summary
Skill Import Modal & State
apps/web/src/components/skills/import-skill-modal.tsx, apps/web/src/components/skills/import-skill-modal-state.ts
Added ImportSkillModal component (tabbed ZIP/GitHub UI, drag/drop, file input, mutation handling, auto-close controller) and helper utilities (getSelectedZipFile, createAutoCloseController).
Skills Page Integration
apps/web/src/pages/skills.tsx
Replaced inline file-input flow with modal trigger state; now opens ImportSkillModal instead of handling file selection directly.
Dialog UI Wrapper
apps/web/src/components/ui/dialog.tsx
New Radix dialog wrapper and composed subcomponents (Overlay, Content, Header, Footer, Title, Description, Body) with consistent styling and built-in close affordance.
Internationalization
apps/web/src/i18n/locales/en.ts, apps/web/src/i18n/locales/zh-CN.ts
Added i18n keys/strings for skill import UI, upload hints, GitHub tab copy, and status messages in English and Simplified Chinese.
Unit Tests (modal state)
apps/web/tests/import-skill-modal-state.test.ts
New Vitest tests for getSelectedZipFile and createAutoCloseController, including fake-timer scenarios and input validation cases.
Unit Tests (plugin writer)
apps/controller/tests/openclaw-runtime-plugin-writer.test.ts
New Vitest suite validating file-copy semantics for plugin templates: ensures .bin symlink entries are omitted while non-.bin symlinks are materialized and contents preserved.

Sequence Diagram

sequenceDiagram
    actor User
    participant SkillsPage
    participant ImportSkillModal
    participant useImportSkill
    participant Backend

    User->>SkillsPage: Click "Import"
    SkillsPage->>ImportSkillModal: Open modal
    User->>ImportSkillModal: Select/drag ZIP
    ImportSkillModal->>ImportSkillModal: Validate ZIP (getSelectedZipFile)
    User->>ImportSkillModal: Click Import
    ImportSkillModal->>useImportSkill: mutateAsync(selectedFile)
    useImportSkill->>Backend: Upload & process ZIP
    alt Success
        Backend-->>useImportSkill: Success
        useImportSkill-->>ImportSkillModal: resolve
        ImportSkillModal->>ImportSkillModal: Show success, schedule auto-close (1200ms)
        ImportSkillModal->>SkillsPage: Trigger close
    else Error
        Backend-->>useImportSkill: Error
        useImportSkill-->>ImportSkillModal: reject
        ImportSkillModal->>ImportSkillModal: Show error
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 I hopped in with a ZIP in paw,
A modal opened — click, then awe,
Dialogs tidy, messages clear,
Tests make symlinks disappear (or stay near),
Hooray — a tiny rabbit cheer! ✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ 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%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the two main changes: adding an Import Skill modal UI and fixing a controller symlink crash.
Description check ✅ Passed The description follows the template structure with What, Why, How, and Affected areas sections. However, the Test plan uses incomplete checkboxes instead of marking items as completed or removed.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/import-skill-modal

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

Copy link
Copy Markdown

@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: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/controller/tests/openclaw-runtime-plugin-writer.test.ts`:
- Around line 71-97: The test expects non-.bin symlinks to be preserved but
OpenClawRuntimePluginWriter.ensurePlugins() calls cp() with dereference: true
which materializes symlinks; either change the writer to preserve symlinks by
setting dereference: false in the cp() invocation inside
OpenClawRuntimePluginWriter.ensurePlugins(), or update the test to expect a
materialized directory (remove the isSymbolicLink() assertion and assert
directory contents instead) so behavior and test align.

In `@apps/web/src/components/skills/import-skill-modal.tsx`:
- Around line 67-79: When a new file is chosen or dropped, the previous import
mutation error must be cleared; update handleFileChange and handleDrop to call
the import mutation's reset/clear method (e.g., importSkillMutation.reset() or
importSkillMutation.resetError()) immediately after
setSelectedFile(getSelectedZipFile(file)) so any prior error state is dismissed
when a new file is selected/dropped; keep the existing e.target.value = "" reset
in handleFileChange.
- Around line 18-21: The import './import-skill-modal-state' is unresolved
causing TS2307; either add the missing module exporting the helpers or
remove/inline the imports. Fix by creating a module that exports
createAutoCloseController and getSelectedZipFile (matching those exact function
names) or update the import to the correct path where those functions live;
alternatively inline implementations of createAutoCloseController and
getSelectedZipFile directly into import-skill-modal.tsx ensuring their
signatures match existing usage in the component.
- Around line 196-197: Replace the hardcoded fallback string "Import failed"
used with importMutation.error?.message ?? "Import failed" inside the
ImportSkillModal component so it uses the i18n translation key (e.g., call your
translation helper for "skills.importFailed") instead of plain English; also add
the new "skills.importFailed" entry to both locale files (with appropriate
translations) so the fallback is localized across languages.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 1c6c3484-0476-4190-a432-063ce87f2204

📥 Commits

Reviewing files that changed from the base of the PR and between 699859a and 09e522b.

📒 Files selected for processing (6)
  • apps/controller/tests/openclaw-runtime-plugin-writer.test.ts
  • apps/web/src/components/skills/import-skill-modal.tsx
  • apps/web/src/components/ui/dialog.tsx
  • apps/web/src/i18n/locales/en.ts
  • apps/web/src/i18n/locales/zh-CN.ts
  • apps/web/src/pages/skills.tsx

Comment thread apps/controller/tests/openclaw-runtime-plugin-writer.test.ts Outdated
Comment thread apps/web/src/components/skills/import-skill-modal.tsx
Comment thread apps/web/src/components/skills/import-skill-modal.tsx
Comment thread apps/web/src/components/skills/import-skill-modal.tsx
The modal state helpers (getSelectedZipFile, createAutoCloseController)
were extracted but not committed, breaking the build with TS2307.
The test expected non-.bin symlinks to be preserved as symlinks, but
the implementation uses dereference: true which materializes them into
real directories. Update the test to assert directory materialization
instead of symlink preservation.
Copy link
Copy Markdown

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

🧹 Nitpick comments (1)
apps/web/tests/import-skill-modal-state.test.ts (1)

7-17: Consider locking in edge cases for ZIP selection.

Coverage is good; adding uppercase extension and nullish-input cases would better pin the intended contract.

✅ Suggested test additions
 describe("getSelectedZipFile", () => {
   it("keeps valid zip files", () => {
     const file = { name: "skill.zip" };

     expect(getSelectedZipFile(file)).toBe(file);
   });

+  it("keeps valid zip files with uppercase extension", () => {
+    const file = { name: "skill.ZIP" };
+    expect(getSelectedZipFile(file)).toBe(file);
+  });
+
+  it("returns null for nullish input", () => {
+    expect(getSelectedZipFile(null)).toBeNull();
+    expect(getSelectedZipFile(undefined)).toBeNull();
+  });
+
   it("clears the selection for invalid files", () => {
     expect(getSelectedZipFile({ name: "skill.txt" })).toBeNull();
   });
 });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/tests/import-skill-modal-state.test.ts` around lines 7 - 17, Add
tests to strengthen getSelectedZipFile's contract by asserting it accepts ZIP
extensions case-insensitively and handles null/undefined inputs: add a test that
passes a file object with name "SKILL.ZIP" (and/or mixed case) and expects the
same file returned, and add tests that call getSelectedZipFile(null) and
getSelectedZipFile(undefined) (or omit the argument) and expect null; update or
add these cases near the existing describe("getSelectedZipFile") block to ensure
uppercase extension and nullish-input edge cases are covered.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@apps/web/tests/import-skill-modal-state.test.ts`:
- Around line 7-17: Add tests to strengthen getSelectedZipFile's contract by
asserting it accepts ZIP extensions case-insensitively and handles
null/undefined inputs: add a test that passes a file object with name
"SKILL.ZIP" (and/or mixed case) and expects the same file returned, and add
tests that call getSelectedZipFile(null) and getSelectedZipFile(undefined) (or
omit the argument) and expect null; update or add these cases near the existing
describe("getSelectedZipFile") block to ensure uppercase extension and
nullish-input edge cases are covered.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: c4c88cc7-a177-4baa-93da-8609de5d20d1

📥 Commits

Reviewing files that changed from the base of the PR and between 09e522b and 6076ff2.

📒 Files selected for processing (3)
  • apps/controller/tests/openclaw-runtime-plugin-writer.test.ts
  • apps/web/src/components/skills/import-skill-modal-state.ts
  • apps/web/tests/import-skill-modal-state.test.ts
✅ Files skipped from review due to trivial changes (1)
  • apps/controller/tests/openclaw-runtime-plugin-writer.test.ts

@alchemistklk
Copy link
Copy Markdown
Contributor Author

/cr

@slack-code-review-channel
Copy link
Copy Markdown

✅ CR topic created in Feishu topic group Refly CR.

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.

2 participants