fix(custom-widgets): simplify error sharing
This commit is contained in:
10 files changed
+65
-115
No files matched your search
@@ -31,4 +31,4 @@ Create one widget at a time. Repeat the workflow when the user asks for several
|
||||
- Include responsive loading, empty, error, and success states.
|
||||
- Keep actions explicit, permission-scoped, confirmed when destructive, and followed by targeted invalidation.
|
||||
|
||||
Read [references/schema.md](references/schema.md), [references/runtime.md](references/runtime.md), and [references/security.md](references/security.md) before offline authoring. When connected, prefer live resources because installed Mantine components can differ by Homarr release.
|
||||
When connected through MCP, use the live schema, component, example, and skill resources. If live resources are unavailable, follow this document and the authoring prompt; do not assume access to repository-relative files.
|
||||
@@ -4,7 +4,7 @@ import { CanonicalCustomWidgetExample } from "@site/src/components/custom-widget
|
||||
|
||||
The desktop workbench keeps code and preview side by side. Mobile uses Configure and Preview tabs. Save and preview actions stay available while the form scrolls.
|
||||
|
||||
CodeMirror edits requests, options, and JSX. The editor supplies installed Mantine components, request/source IDs, option names, temporary input names, runtime components, and icons. The request toolbar jumps to a declaration and atomically renames its invalidation, dynamic-option, and JSX references. Diagnostics are copyable and secret-free.
|
||||
CodeMirror edits requests, options, and JSX. The editor supplies installed Mantine components, request/source IDs, option names, temporary input names, runtime components, and icons. The request toolbar jumps to a declaration and atomically renames its invalidation, dynamic-option, and JSX references. Save validation errors appear as selectable plain text with a direct **Copy errors** action.
|
||||
|
||||
The **Schema** action beside Undo, Redo, and Format expands the generated JSON Schema plus minimal and full examples. These references are generated from the same validators used by imports, MCP, Workshop, and saved definitions.
|
||||
|
||||
@@ -25,7 +25,7 @@ Enter a short request and optional API documentation URL, then select **Copy AI
|
||||
1. A fenced `json` block with one complete widget and `template: "__HOMARR_TEMPLATE__"`.
|
||||
2. A fenced `jsx` block with the readable template.
|
||||
|
||||
Use **Paste AI response** to fill the workbench. Enter credentials yourself, test, and preview. If validation or a request fails, select **Copy diagnostics for AI** and paste the correction back into Homarr.
|
||||
Use **Paste AI response** to fill the workbench. Enter credentials yourself, test, and preview. If saving fails, select or copy the validation errors shown by Homarr and give that plain text to the model. Homarr does not generate a separate fix prompt or assume the model can access local skill files.
|
||||
|
||||
The prompt is under 8,000 characters. It includes the installed Mantine version and capability rules but not a complete prop list, MCP workflow, server URL, or credentials.
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Card, Group, Stack, Text, TextInput, Textarea } from "@mantine/core";
|
||||
import { IconCopy, IconSparkles } from "@tabler/icons-react";
|
||||
import { IconSparkles } from "@tabler/icons-react";
|
||||
|
||||
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
@@ -15,7 +15,6 @@ interface AiCardProps {
|
||||
documentationUrl: string;
|
||||
onDocumentationUrlChange(value: string): void;
|
||||
onPaste(): void;
|
||||
onCopyDiagnostics(): void;
|
||||
}
|
||||
|
||||
export function CustomWidgetAiCard(props: AiCardProps) {
|
||||
@@ -48,9 +47,6 @@ export function CustomWidgetAiCard(props: AiCardProps) {
|
||||
<Button type="button" variant="light" leftSection={<IconSparkles size={16} />} onClick={props.onPaste}>
|
||||
{t("paste")}
|
||||
</Button>
|
||||
<Button type="button" variant="subtle" leftSection={<IconCopy size={16} />} onClick={props.onCopyDiagnostics}>
|
||||
{t("copyDiagnostics")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
@@ -1,12 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Alert, Box, Button, List, Paper, SegmentedControl, Stack, Text, TextInput, Textarea } from "@mantine/core";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
CopyButton,
|
||||
Paper,
|
||||
SegmentedControl,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Textarea,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconAlertCircle,
|
||||
IconApi,
|
||||
IconBraces,
|
||||
IconCheck,
|
||||
IconCode,
|
||||
IconCopy,
|
||||
IconDatabase,
|
||||
IconEye,
|
||||
IconSettings,
|
||||
@@ -119,21 +132,17 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
|
||||
[candidate, requestDiagnostics, templateDiagnostics],
|
||||
);
|
||||
|
||||
const { save, runPreview, pasteAiResponse, copyDiagnostics, saveIssues, savePending, previewPending } =
|
||||
useCustomWidgetFormActions({
|
||||
mode,
|
||||
definitionId,
|
||||
form,
|
||||
candidate,
|
||||
templateDiagnostics,
|
||||
requestDiagnostics,
|
||||
preview,
|
||||
setPreview,
|
||||
setMobilePane,
|
||||
setOptionsSnapshot,
|
||||
request,
|
||||
documentationUrl,
|
||||
});
|
||||
const { save, runPreview, pasteAiResponse, saveIssues, savePending, previewPending } = useCustomWidgetFormActions({
|
||||
mode,
|
||||
definitionId,
|
||||
form,
|
||||
candidate,
|
||||
preview,
|
||||
setPreview,
|
||||
setMobilePane,
|
||||
setOptionsSnapshot,
|
||||
});
|
||||
const saveIssueText = saveIssues.map((issue) => `${issue.path ? `${issue.path}: ` : ""}${issue.message}`).join("\n");
|
||||
|
||||
useEffect(() => {
|
||||
const beforeUnload = (event: BeforeUnloadEvent) => {
|
||||
@@ -169,14 +178,29 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
|
||||
<Text size="sm" mb="xs">
|
||||
{w("saveError.description")}
|
||||
</Text>
|
||||
<List size="sm" spacing={4}>
|
||||
{saveIssues.map((issue, index) => (
|
||||
<List.Item key={`${issue.path ?? "widget"}-${index}`}>
|
||||
{issue.path ? `${issue.path}: ` : ""}
|
||||
{issue.message}
|
||||
</List.Item>
|
||||
))}
|
||||
</List>
|
||||
<Textarea
|
||||
value={saveIssueText}
|
||||
readOnly
|
||||
autosize
|
||||
minRows={2}
|
||||
maxRows={10}
|
||||
aria-label={w("saveError.errors")}
|
||||
mb="xs"
|
||||
/>
|
||||
<CopyButton value={saveIssueText} timeout={2_000}>
|
||||
{({ copied, copy }) => (
|
||||
<Button
|
||||
type="button"
|
||||
size="compact-sm"
|
||||
variant="light"
|
||||
color={copied ? "green" : "red"}
|
||||
leftSection={copied ? <IconCheck size={14} /> : <IconCopy size={14} />}
|
||||
onClick={copy}
|
||||
>
|
||||
{copied ? w("saveError.copied") : w("saveError.copy")}
|
||||
</Button>
|
||||
)}
|
||||
</CopyButton>
|
||||
</Alert>
|
||||
)}
|
||||
<nav className={classes.sectionNav} aria-label={w("sectionNavigation")}>
|
||||
@@ -265,7 +289,6 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
|
||||
documentationUrl={documentationUrl}
|
||||
onDocumentationUrlChange={setDocumentationUrl}
|
||||
onPaste={() => void pasteAiResponse()}
|
||||
onCopyDiagnostics={() => void copyDiagnostics()}
|
||||
/>
|
||||
<Paper p="md" className={classes.mobileSaveBar} shadow="sm">
|
||||
<SaveActions
|
||||
|
||||
+2
-35
@@ -5,14 +5,13 @@ import type { Dispatch, SetStateAction } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { UseFormReturnType } from "@mantine/form";
|
||||
|
||||
import { clientApi, fetchApi } from "@homarr/api/client";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import {
|
||||
buildCustomWidgetFixPrompt,
|
||||
customWidgetDefinitionSchema,
|
||||
formatCustomWidgetImportIssues,
|
||||
parseCustomWidgetAiResponse,
|
||||
} from "@homarr/custom-widgets/core";
|
||||
import type { EditorDiagnostic, CustomWidgetFormValues } from "@homarr/custom-widgets/workbench";
|
||||
import type { CustomWidgetFormValues } from "@homarr/custom-widgets/workbench";
|
||||
import { showErrorNotification, showSuccessNotification, showWarningNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
@@ -26,14 +25,10 @@ interface FormActionsInput {
|
||||
definitionId?: string;
|
||||
form: UseFormReturnType<CustomWidgetFormValues>;
|
||||
candidate: ReturnType<typeof buildDefinition>;
|
||||
templateDiagnostics: EditorDiagnostic[];
|
||||
requestDiagnostics: EditorDiagnostic[];
|
||||
preview: PreviewState;
|
||||
setPreview: Dispatch<SetStateAction<PreviewState>>;
|
||||
setMobilePane: Dispatch<SetStateAction<"configure" | "preview">>;
|
||||
setOptionsSnapshot: Dispatch<SetStateAction<Record<string, unknown>>>;
|
||||
request: string;
|
||||
documentationUrl: string;
|
||||
}
|
||||
|
||||
export function useCustomWidgetFormActions(input: FormActionsInput) {
|
||||
@@ -44,7 +39,6 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
|
||||
const createMutation = clientApi.customWidget.create.useMutation();
|
||||
const updateMutation = clientApi.customWidget.update.useMutation();
|
||||
const previewMutation = clientApi.customWidget.previewCreate.useMutation();
|
||||
const [importIssues, setImportIssues] = useState<string[]>([]);
|
||||
const [saveIssues, setSaveIssues] = useState<CustomWidgetSaveIssue[]>([]);
|
||||
const [previewPending, setPreviewPending] = useState(false);
|
||||
|
||||
@@ -169,10 +163,8 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
|
||||
try {
|
||||
const result = parseCustomWidgetAiResponse(await navigator.clipboard.readText());
|
||||
if (!result.success) {
|
||||
setImportIssues(result.issues.map((issue) => `${issue.path?.join(".") ?? "widget"}: ${issue.message}`));
|
||||
throw new Error(formatCustomWidgetImportIssues(result.issues));
|
||||
}
|
||||
setImportIssues([]);
|
||||
applyDefinition(input.form, customWidgetDefinitionSchema.parse(result.widget));
|
||||
input.setPreview({ data: {}, status: {}, session: null, outcome: "idle" });
|
||||
showSuccessNotification({
|
||||
@@ -191,35 +183,10 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
|
||||
}
|
||||
};
|
||||
|
||||
const copyDiagnostics = async () => {
|
||||
try {
|
||||
const diagnostics = [...input.templateDiagnostics, ...input.requestDiagnostics]
|
||||
.map((entry) => `${entry.severity.toUpperCase()} ${entry.code}: ${entry.value ?? ""}`)
|
||||
.concat(importIssues.map((issue) => `IMPORT: ${issue}`))
|
||||
.join("\n");
|
||||
const journal = input.preview.session
|
||||
? await fetchApi.customWidget.previewJournal.query({ sessionId: input.preview.session.id }).catch(() => [])
|
||||
: [];
|
||||
await navigator.clipboard.writeText(
|
||||
buildCustomWidgetFixPrompt({
|
||||
currentConfig: input.candidate.success ? input.candidate.data : input.form.values,
|
||||
request: input.request,
|
||||
documentationUrl: input.documentationUrl,
|
||||
diagnostics,
|
||||
journal,
|
||||
}),
|
||||
);
|
||||
showSuccessNotification({ title: w("ai.diagnostics"), message: w("ai.diagnosticsCopied") });
|
||||
} catch {
|
||||
showErrorNotification({ title: w("ai.diagnostics"), message: t("notification.aiPromptCopyError") });
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
save,
|
||||
runPreview,
|
||||
pasteAiResponse,
|
||||
copyDiagnostics,
|
||||
saveIssues,
|
||||
savePending: createMutation.isPending || updateMutation.isPending,
|
||||
previewPending,
|
||||
|
||||
@@ -64,26 +64,6 @@ export function buildCustomWidgetAiPrompt(
|
||||
return embeddedSkill ? prompt : prompt.slice(0, 8_000);
|
||||
}
|
||||
|
||||
export function buildCustomWidgetFixPrompt(input: {
|
||||
currentConfig: Partial<HomarrCustomWidgetV2> | Record<string, unknown>;
|
||||
request?: string | null;
|
||||
documentationUrl?: string | null;
|
||||
diagnostics: string;
|
||||
journal: unknown;
|
||||
}) {
|
||||
const sections = [AUTHORING_PROMPT];
|
||||
if (input.documentationUrl) sections.push(`API documentation: ${redactUrl(input.documentationUrl)}`);
|
||||
sections.push(`Original request: ${redactText(input.request?.trim() || "Fix the current widget.")}`);
|
||||
sections.push(`Current widget:\n\n\`\`\`json\n${JSON.stringify(redactValue(input.currentConfig), null, 2)}\n\`\`\``);
|
||||
sections.push(
|
||||
`Validation diagnostics:\n\n\`\`\`text\n${redactText(input.diagnostics) || "No static diagnostics."}\n\`\`\``,
|
||||
);
|
||||
sections.push(
|
||||
`Redacted request journal:\n\n\`\`\`json\n${JSON.stringify(redactValue(input.journal), null, 2)}\n\`\`\``,
|
||||
);
|
||||
return sections.join("\n\n");
|
||||
}
|
||||
|
||||
const sensitiveKey =
|
||||
/(^|[-_])(authorization|api[-_]?keys?|passwords?|passwds?|secrets?|tokens?|access[-_]?tokens?|refresh[-_]?tokens?|client[-_]?secrets?)($|[-_])/iu;
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ Create one widget at a time. Repeat the workflow when the user asks for several
|
||||
- Include responsive loading, empty, error, and success states.
|
||||
- Keep actions explicit, permission-scoped, confirmed when destructive, and followed by targeted invalidation.
|
||||
|
||||
Read [references/schema.md](references/schema.md), [references/runtime.md](references/runtime.md), and [references/security.md](references/security.md) before offline authoring. When connected, prefer live resources because installed Mantine components can differ by Homarr release.
|
||||
When connected through MCP, use the live schema, component, example, and skill resources. If live resources are unavailable, follow this document and the authoring prompt; do not assume access to repository-relative files.
|
||||
`;
|
||||
|
||||
export function getCustomWidgetSkill() {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { buildCustomWidgetAiPrompt, buildCustomWidgetFixPrompt, buildCustomWidgetMcpPrompt } from "../core/ai-prompt";
|
||||
import { buildCustomWidgetAiPrompt, buildCustomWidgetMcpPrompt } from "../core/ai-prompt";
|
||||
import { CUSTOM_WIDGET_SKILL_MD } from "../core/authoring-resources";
|
||||
import { CUSTOM_WIDGET_STARTER } from "../core/examples";
|
||||
|
||||
@@ -17,7 +17,8 @@ describe("buildCustomWidgetAiPrompt", () => {
|
||||
|
||||
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).toContain("do not assume access to repository-relative files");
|
||||
expect(prompt).not.toContain("references/schema.md");
|
||||
expect(prompt.endsWith(CUSTOM_WIDGET_SKILL_MD)).toBe(true);
|
||||
});
|
||||
|
||||
@@ -76,25 +77,6 @@ describe("buildCustomWidgetAiPrompt", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildCustomWidgetFixPrompt", () => {
|
||||
test("includes the complete current widget and redacted iteration evidence", () => {
|
||||
const secret = "literal-sensitive-value";
|
||||
const prompt = buildCustomWidgetFixPrompt({
|
||||
currentConfig: {
|
||||
name: "Large widget",
|
||||
template: `<Text>${"x".repeat(9_000)}</Text>`,
|
||||
secrets: [{ value: secret }],
|
||||
},
|
||||
request: `Fix it with password='${secret}'`,
|
||||
diagnostics: `Header apiKey='${secret}' is invalid`,
|
||||
journal: [{ requestId: "load", authorization: secret }],
|
||||
});
|
||||
expect(prompt).toContain("x".repeat(9_000));
|
||||
expect(prompt).toContain("Validation diagnostics");
|
||||
expect(prompt).not.toContain(secret);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildCustomWidgetMcpPrompt", () => {
|
||||
test("describes tool-driven iteration without embedding the copy/paste output contract", () => {
|
||||
const prompt = buildCustomWidgetMcpPrompt("Create two Portainer widgets", "https://example.com/docs");
|
||||
|
||||
@@ -12,6 +12,8 @@ describe("custom widget Agent Skill", () => {
|
||||
const source = await readFile(resolve(repositoryRoot, ".agents/skills/homarr-custom-widget/SKILL.md"), "utf8");
|
||||
expect(CUSTOM_WIDGET_SKILL_MD).toBe(source);
|
||||
expect(getCustomWidgetSkill().skillMd).toBe(source);
|
||||
expect(source).not.toContain("references/schema.md");
|
||||
expect(source).toContain("do not assume access to repository-relative files");
|
||||
});
|
||||
|
||||
test("publishes repository-based skills.sh installation metadata", () => {
|
||||
|
||||
@@ -831,7 +831,10 @@
|
||||
"saveError": {
|
||||
"title": "Widget was not saved",
|
||||
"description": "Fix these validation errors and save again.",
|
||||
"more": "+{count} more errors are shown in the workbench."
|
||||
"more": "+{count} more errors are shown in the workbench.",
|
||||
"errors": "Validation errors",
|
||||
"copy": "Copy errors",
|
||||
"copied": "Errors copied"
|
||||
},
|
||||
"action": {
|
||||
"preview": "Test and preview",
|
||||
@@ -887,13 +890,10 @@
|
||||
"request": "What should this widget do?",
|
||||
"documentationUrl": "API documentation URL (optional)",
|
||||
"paste": "Paste AI response",
|
||||
"copyDiagnostics": "Copy diagnostics",
|
||||
"response": "AI response",
|
||||
"loaded": "Widget loaded into the editor",
|
||||
"invalidResponse": "Clipboard does not contain a valid v2 widget",
|
||||
"invalidResponseDetail": "The AI response is invalid: {message}",
|
||||
"diagnostics": "Diagnostics",
|
||||
"diagnosticsCopied": "Redacted fix prompt copied"
|
||||
"invalidResponseDetail": "The AI response is invalid: {message}"
|
||||
},
|
||||
"preview": {
|
||||
"invalid": "Fix validation errors to preview this widget.",
|
||||
|
||||
Reference in new issue
Block a user