fix(custom-widgets): restore bundled authoring

This commit is contained in:
Thomas Camlong committed 2026-07-20 16:48:00 +02:00
1 parent c39ddc4285
commit 4c69ad90df
16 files changed
+180 -58

No files matched your search

@@ -48,10 +48,14 @@ export function buildCustomWidgetAiPrompt(
currentConfig?: Partial<HomarrCustomWidgetV2> | Record<string, unknown> | null,
request?: string | null,
documentationUrl?: string | null,
embeddedSkill?: string,
) {
const sections = [AUTHORING_PROMPT];
if (documentationUrl) sections.push(`API documentation: ${redactUrl(documentationUrl)}`);
sections.push(`Requested widget: ${redactText(request?.trim() || "Describe the widget you want to create.")}`);
if (embeddedSkill) {
sections.push(`Use this embedded Homarr Custom Widget skill while authoring:\n\n${embeddedSkill}`);
}
if (rawResponse) sections.push(`Sample API response:\n\n\`\`\`json\n${redactResponse(rawResponse)}\n\`\`\``);
if (currentConfig) {
sections.push(`Current widget:\n\n\`\`\`json\n${JSON.stringify(redactValue(currentConfig), null, 2)}\n\`\`\``);
@@ -9,7 +9,7 @@ import { customJsxTablerIconNames } from "./tabler-icons";
export const CUSTOM_WIDGET_SKILL_VERSION = "2.0.0";
export const CUSTOM_WIDGET_SKILLS_SH_URL = "https://www.skills.sh/homarr-labs/homarr/homarr-custom-widget";
export const CUSTOM_WIDGET_SKILL_SOURCE_URL =
"https://github.com/homarr-labs/homarr/tree/v2/.agents/skills/homarr-custom-widget";
"https://github.com/homarr-labs/homarr/tree/dev/.agents/skills/homarr-custom-widget";
export const CUSTOM_WIDGET_SKILL_INSTALL_COMMAND =
"npx skills add https://github.com/homarr-labs/homarr --skill homarr-custom-widget";
@@ -1,9 +1,26 @@
import { describe, expect, test } from "vitest";
import { buildCustomWidgetAiPrompt, buildCustomWidgetFixPrompt, buildCustomWidgetMcpPrompt } from "../core/ai-prompt";
import { CUSTOM_WIDGET_SKILL_MD } from "../core/authoring-resources";
import { CUSTOM_WIDGET_STARTER } from "../core/examples";
describe("buildCustomWidgetAiPrompt", () => {
test("can embed the portable skill for standalone chat models", () => {
const prompt = buildCustomWidgetAiPrompt(
undefined,
undefined,
undefined,
"Create a status widget",
undefined,
CUSTOM_WIDGET_SKILL_MD,
);
expect(prompt).toContain("Use this embedded Homarr Custom Widget skill while authoring:");
expect(prompt).toContain("# Homarr Custom Widget");
expect(prompt).toContain("installed Mantine components can differ by Homarr release.");
expect(prompt.length).toBeLessThanOrEqual(8_000);
});
test("is concise and describes the one-widget v2 contract", () => {
const prompt = buildCustomWidgetAiPrompt(
undefined,
@@ -17,7 +17,7 @@ describe("custom widget Agent Skill", () => {
test("publishes repository-based skills.sh installation metadata", () => {
const skill = getCustomWidgetSkill();
expect(skill.skillsShUrl).toBe("https://www.skills.sh/homarr-labs/homarr/homarr-custom-widget");
expect(skill.sourceUrl).toContain("github.com/homarr-labs/homarr/tree/v2/.agents/skills/homarr-custom-widget");
expect(skill.sourceUrl).toBe("https://github.com/homarr-labs/homarr/tree/dev/.agents/skills/homarr-custom-widget");
expect(skill.installCommand).toBe(
"npx skills add https://github.com/homarr-labs/homarr --skill homarr-custom-widget",
);
@@ -7,7 +7,9 @@ import { json } from "@codemirror/lang-json";
import { linter } from "@codemirror/lint";
import type { EditorView as EditorViewType } from "@codemirror/view";
import { EditorView } from "@codemirror/view";
import { lazy, Suspense, useEffect, useMemo, useState } from "react";
import type { ComponentType } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import type { ReactCodeMirrorProps } from "@uiw/react-codemirror";
import { Badge, Collapse, Group, Input, Loader, Tabs, Text, useComputedColorScheme } from "@mantine/core";
import { IconCheck } from "@tabler/icons-react";
@@ -27,19 +29,26 @@ export type {
CustomWidgetSchemaReferenceData,
} from "./code-editor-types";
const LazyCodeMirror = lazy(() => import("@uiw/react-codemirror").then((module) => ({ default: module.default })));
const EMPTY_DIAGNOSTICS: NonNullable<CustomWidgetCodeEditorProps["diagnostics"]> = [];
export function CustomWidgetCodeEditor(props: CustomWidgetCodeEditorProps) {
const diagnostics = props.diagnostics ?? EMPTY_DIAGNOSTICS;
const [mounted, setMounted] = useState(false);
const [CodeMirror, setCodeMirror] = useState<ComponentType<ReactCodeMirrorProps> | null>(null);
const [copied, setCopied] = useState(false);
const [referenceOpened, setReferenceOpened] = useState(false);
const [cursor, setCursor] = useState({ line: 1, column: 1 });
const [editorView, setEditorView] = useState<EditorViewType | null>(null);
const [historyDepth, setHistoryDepth] = useState({ undo: 0, redo: 0 });
const colorScheme = useComputedColorScheme("light");
useEffect(() => setMounted(true), []);
useEffect(() => {
let cancelled = false;
void import("@uiw/react-codemirror").then((module) => {
if (!cancelled) setCodeMirror(() => module.default);
});
return () => {
cancelled = true;
};
}, []);
const extensions = useMemo(() => {
const diagnosticsExtension = linter((view) =>
diagnostics.map((diagnostic) => {
@@ -77,6 +86,11 @@ export function CustomWidgetCodeEditor(props: CustomWidgetCodeEditorProps) {
});
editorView.focus();
}, [editorView, props.revealKey, props.revealText]);
const handleCreateEditor = useCallback((view: EditorViewType) => {
// @uiw invokes this while constructing CodeMirror. Deferring the parent update avoids
// re-entering React's renderer during initialisation in Next.js and Docusaurus.
window.setTimeout(() => setEditorView(view), 0);
}, []);
const formattedValue = useMemo(() => formatCode(props.value, props.language), [props.language, props.value]);
const errorCount = diagnostics.filter(({ severity }) => severity === "error").length;
const warningCount = diagnostics.length - errorCount;
@@ -115,37 +129,36 @@ export function CustomWidgetCodeEditor(props: CustomWidgetCodeEditorProps) {
</Collapse>
)}
<div className={classes.viewport}>
{mounted ? (
<Suspense fallback={<EditorLoader />}>
<LazyCodeMirror
id={`${props.id}-root`}
value={props.value}
onChange={props.readOnly ? () => undefined : props.onChange}
placeholder={props.placeholder}
extensions={extensions}
theme={colorScheme}
height={props.height ?? (props.language === "jsx" ? "320px" : "220px")}
basicSetup={{
autocompletion: props.language !== "jsx" && !props.readOnly,
bracketMatching: true,
closeBrackets: !props.readOnly,
foldGutter: true,
history: !props.readOnly,
highlightActiveLine: !props.readOnly,
highlightActiveLineGutter: !props.readOnly,
indentOnInput: !props.readOnly,
lineNumbers: true,
}}
onCreateEditor={setEditorView}
onUpdate={(update) => {
if (!update.selectionSet && !update.docChanged) return;
const head = update.state.selection.main.head;
const line = update.state.doc.lineAt(head);
setCursor({ line: line.number, column: head - line.from + 1 });
setHistoryDepth({ undo: undoDepth(update.state), redo: redoDepth(update.state) });
}}
/>
</Suspense>
{CodeMirror ? (
<CodeMirror
id={`${props.id}-root`}
value={props.value}
onChange={props.readOnly ? () => undefined : props.onChange}
placeholder={props.placeholder}
extensions={extensions}
theme={colorScheme}
height={props.height ?? (props.language === "jsx" ? "320px" : "220px")}
basicSetup={{
autocompletion: props.language !== "jsx" && !props.readOnly,
bracketMatching: true,
closeBrackets: !props.readOnly,
foldGutter: true,
history: !props.readOnly,
highlightActiveLine: !props.readOnly,
highlightActiveLineGutter: !props.readOnly,
indentOnInput: !props.readOnly,
lineNumbers: true,
}}
onCreateEditor={handleCreateEditor}
onUpdate={(update) => {
if (!editorView) return;
if (!update.selectionSet && !update.docChanged) return;
const head = update.state.selection.main.head;
const line = update.state.doc.lineAt(head);
setCursor({ line: line.number, column: head - line.from + 1 });
setHistoryDepth({ undo: undoDepth(update.state), redo: redoDepth(update.state) });
}}
/>
) : (
<EditorLoader />
)}