Merge branch 'feat/homarr-assistant' into release/v2

This commit is contained in:
Thomas Camlong committed 2026-08-10 20:13:28 +02:00
commit 58bb0f5500
16 files changed
+343 -89

No files matched your search

@@ -93,6 +93,8 @@ Custom Widget imports detect required bearer, basic, header, or query credential
Workshop is powered by [PocketBase](https://pocketbase.io). The production image compiles the current Docusaurus site and serves it together with the API from one origin. The deployment lives in `apps/workshop/` with a Dockerfile, Compose configuration, version-controlled migrations, hooks, a smoke test, and ACT-local workflow instructions.
Submission links include Open Graph and X/Twitter metadata for the submission title, description, and type. The first uploaded screenshot is used as the social preview image; submissions without screenshots use the Homarr logo.
Run `pnpm docker:workshop` for PocketBase on port 8090, then run
`WORKSHOP_API_URL=http://127.0.0.1:8090 pnpm dev:docs` for Docusaurus on port 3003. `pnpm docker:docs` builds the
combined production layout. Normal start and stop commands retain data; `down --volumes` is an intentional,
@@ -1,5 +1,6 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import BrowserOnly from "@docusaurus/BrowserOnly";
import Head from "@docusaurus/Head";
import Link from "@docusaurus/Link";
import { useLocation } from "@docusaurus/router";
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
@@ -69,6 +70,7 @@ import { WorkshopErrorBoundary } from "./WorkshopErrorBoundary";
import { downloadSubmissionJson, voteAndReconcile } from "./workshop-utils";
const typeLabels: Record<SubmissionType, string> = { customCss: "CSS", customWidget: "Widget" };
const typeSocialLabels: Record<SubmissionType, string> = { customCss: "Custom CSS", customWidget: "Custom widget" };
const typeDotColors: Record<SubmissionType, string> = { customCss: "bg-blue-500", customWidget: "bg-yellow-500" };
const contentLanguages: Record<SubmissionType, string> = { customCss: "css", customWidget: "json" };
const copyState = [
@@ -536,9 +538,31 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
const options = widgetDefinition ? Object.entries(widgetDefinition.options) : [];
const protectedSources = sources.filter(([, source]) => sourceNeedsSecret(source.auth));
const displayedContent = formatWorkshopContent(submission.type, submission.content);
const socialTitle = `${submission.title} · Homarr Workshop`;
const socialDescription = `${typeSocialLabels[submission.type]} for Homarr. ${submission.description}`.trim();
const socialUrl = `${window.location.origin}${window.location.pathname}`;
const socialImage = screenshotUrls[0] ?? `${window.location.origin}/img/logo.png`;
return (
<div className="mx-auto max-w-[90rem] px-4 pb-20 pt-8 sm:px-6 lg:px-8">
<Head>
<title>{socialTitle}</title>
<meta name="description" content={socialDescription} />
<link rel="canonical" href={socialUrl} />
<meta property="og:type" content="article" />
<meta property="og:site_name" content="Homarr Workshop" />
<meta property="og:title" content={socialTitle} />
<meta property="og:description" content={socialDescription} />
<meta property="og:url" content={socialUrl} />
<meta property="og:image" content={socialImage} />
<meta property="og:image:alt" content={`${submission.title} preview`} />
<meta property="article:section" content={typeSocialLabels[submission.type]} />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={socialTitle} />
<meta name="twitter:description" content={socialDescription} />
<meta name="twitter:image" content={socialImage} />
<meta name="twitter:image:alt" content={`${submission.title} preview`} />
</Head>
<Toaster position="bottom-right" richColors />
<Link
to="/workshop"
@@ -94,7 +94,8 @@
}
.securityNote {
max-width: 66ch;
min-width: 0;
flex: 1;
}
.flowActions {
@@ -13,7 +13,6 @@ import {
Group,
Image,
LoadingOverlay,
Modal,
Paper,
PasswordInput,
Select,
@@ -31,7 +30,6 @@ import {
IconCircleCheck,
IconDatabaseSearch,
IconKey,
IconLock,
IconPlus,
IconSearch,
IconShieldCheck,
@@ -154,7 +152,6 @@ export const AssistantConfiguration = () => {
const [modelId, setModelId] = useState("");
const [enabled, setEnabled] = useState(false);
const [webSearchEnabled, setWebSearchEnabled] = useState(false);
const [clearCredentialsOpened, setClearCredentialsOpened] = useState(false);
useEffect(() => {
setProvider(configuration?.provider ?? "openrouter");
@@ -285,27 +282,7 @@ export const AssistantConfiguration = () => {
onError: (error) => showErrorNotification({ title: t("notification.error.title"), message: error.message }),
});
const clearCredentials = clientApi.assistant.clearCredentials.useMutation({
onSuccess: async () => {
setClearCredentialsOpened(false);
setApiKey("");
setHeaders([]);
setModelId("");
setEnabled(false);
await Promise.all([
utils.assistant.getAdminConfiguration.invalidate(),
utils.assistant.discoverModels.invalidate(),
utils.assistant.getAvailability.invalidate(),
]);
showSuccessNotification({
title: t("notification.credentialsCleared.title"),
message: t("notification.credentialsCleared.message"),
});
},
onError: (error) => showErrorNotification({ title: t("notification.error.title"), message: error.message }),
});
const pending = updateConnection.isPending || saveConfiguration.isPending || clearCredentials.isPending;
const pending = updateConnection.isPending || saveConfiguration.isPending;
const resetCredentialFlow = () => {
setApiKey("");
@@ -419,11 +396,6 @@ export const AssistantConfiguration = () => {
{t("serverTools.webSearch.label")}
</Badge>
)}
{hasStoredApiKey && (
<Badge variant="light" color="teal" leftSection={<IconLock size={12} />}>
{t("overview.keyProtected")}
</Badge>
)}
{configuration?.enabled === false && (
<Badge variant="filled" color="gray">
{t("overview.disabled")}
@@ -508,15 +480,7 @@ export const AssistantConfiguration = () => {
)
}
actions={
<Group justify="space-between">
<Button
variant="subtle"
color="red"
onClick={() => setClearCredentialsOpened(true)}
disabled={!configuration?.apiKeyConfigured && !configuration?.customHeadersConfigured}
>
{t("connection.clearCredentials")}
</Button>
<Group justify="flex-end">
{credentialFlow === "idle" && (
<Button
leftSection={<IconCheck size={16} />}
@@ -820,28 +784,6 @@ export const AssistantConfiguration = () => {
</Group>
</ConfigurationSection>
</Stack>
<Modal
opened={clearCredentialsOpened}
onClose={() => setClearCredentialsOpened(false)}
title={t("clearCredentials.title")}
centered
>
<Stack>
<Alert color="red" icon={<IconAlertTriangle size={18} />} title={t("clearCredentials.warningTitle")}>
{t("clearCredentials.description")}
</Alert>
<Divider />
<Group justify="flex-end">
<Button variant="default" onClick={() => setClearCredentialsOpened(false)}>
{t("clearCredentials.cancel")}
</Button>
<Button color="red" loading={clearCredentials.isPending} onClick={() => clearCredentials.mutate()}>
{t("clearCredentials.confirm")}
</Button>
</Group>
</Stack>
</Modal>
</>
);
};
@@ -113,7 +113,7 @@
border-radius: var(--mantine-radius-lg);
color: var(--mantine-color-text);
background: var(--mantine-color-body);
box-shadow: var(--mantine-shadow-xl);
box-shadow: none;
animation: assistant-panel-in 180ms cubic-bezier(0.22, 1, 0.36, 1);
}
@@ -1310,7 +1310,17 @@
}
.composer:has(.composerInput:focus-within) {
box-shadow: inset 0 0 0 2px var(--mantine-color-red-filled);
box-shadow: inset 0 0 0 2px var(--mantine-primary-color-filled);
}
.floatingPanel .composer {
border-end-start-radius: var(--mantine-radius-lg);
border-end-end-radius: var(--mantine-radius-lg);
}
.widgetPanel[data-board-widget] .composer {
border-end-start-radius: var(--assistant-widget-radius);
border-end-end-radius: var(--assistant-widget-radius);
}
.composerDropzone[data-dragging] .composer {
@@ -16,6 +16,7 @@ import {
MessagePrimitive,
ThreadPrimitive,
useAui,
useAuiEvent,
useLocalRuntime,
} from "@assistant-ui/react";
import { LexicalComposerInput } from "@assistant-ui/react-lexical";
@@ -118,14 +119,25 @@ const SurfaceProbe = ({ id, onReady }: SurfaceProbeProps) => {
} as ComponentProps<typeof LexicalComposerInput>);
};
const LexicalRunFocusProbe = () => {
useAuiEvent("thread.runStart", () => {
queueMicrotask(() => {
document.querySelector<HTMLElement>('[data-testid="input-b"] [contenteditable="true"]')?.focus();
});
});
return null;
};
const SurfaceTestAssistant = ({
onReady,
onRuntimeReady,
allowImages = true,
simulateRunFocus = false,
}: {
onReady: SurfaceProbeProps["onReady"];
onRuntimeReady: (runtime: AssistantRuntime) => void;
allowImages?: boolean;
simulateRunFocus?: boolean;
}) => {
const runtime = useLocalRuntime(noOpAdapter, {
adapters: { attachments: createAttachmentAdapter(allowImages ? "image/png,text/plain" : "text/plain") },
@@ -145,10 +157,14 @@ const SurfaceTestAssistant = ({
{ surfaceId: "surface-b" },
createElement(SurfaceProbe, { id: "b", onReady }),
),
simulateRunFocus ? createElement(LexicalRunFocusProbe) : null,
);
};
const renderSurfaceTestAssistant = async ({ allowImages = true }: { allowImages?: boolean } = {}) => {
const renderSurfaceTestAssistant = async ({
allowImages = true,
simulateRunFocus = false,
}: { allowImages?: boolean; simulateRunFocus?: boolean } = {}) => {
const container = document.createElement("div");
containers.push(container);
document.body.append(container);
@@ -160,6 +176,7 @@ const renderSurfaceTestAssistant = async ({ allowImages = true }: { allowImages?
root.render(
createElement(SurfaceTestAssistant, {
allowImages,
simulateRunFocus,
onReady: (id, aui) => clients.set(id, aui),
onRuntimeReady: (runtime) => {
sharedRuntime = runtime;
@@ -334,6 +351,35 @@ describe("AssistantRuntimeProviderWithTools", () => {
await act(async () => root.unmount());
});
test("keeps focus and dashboard scroll on the surface that started the run", async () => {
const { container, root, a } = await renderSurfaceTestAssistant({ simulateRunFocus: true });
const panelInput = getRequiredComposerInput(container, "input-a");
const widgetInput = getRequiredComposerInput(container, "input-b");
const scrollContainer = container;
scrollContainer.scrollTop = 120;
const focusWidget = widgetInput.focus.bind(widgetInput);
const widgetFocus = vi.spyOn(widgetInput, "focus").mockImplementation((options) => {
// jsdom does not implement native focus scrolling, so reproduce the dashboard jump that a
// browser performs when the off-screen widget composer receives programmatic focus.
scrollContainer.scrollTop = 640;
focusWidget(options);
});
panelInput.focus();
act(() => {
a.composer().setText("Send from the floating panel");
a.composer().send();
});
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(widgetFocus).toHaveBeenCalled();
expect(document.activeElement).toBe(panelInput);
expect(scrollContainer.scrollTop).toBe(120);
await act(async () => root.unmount());
});
test("adds pasted images only to the active surface composer", async () => {
const { container, root, a, b } = await renderSurfaceTestAssistant();
const target = getRequiredComposerInput(container, "input-a");
@@ -378,6 +378,42 @@ export const useAssistantComposerSurface = () => {
};
const surfaceElements = new Map<string, HTMLElement>();
let lastFocusedSurfaceElement: HTMLElement | null = null;
let lastFocusedSurfaceAt = 0;
interface AssistantSurfaceScrollPosition {
element: HTMLElement;
left: number;
top: number;
}
const captureAssistantSurfaceScrollPositions = () => {
const elements = new Set<HTMLElement>();
for (const surfaceElement of surfaceElements.values()) {
let element: HTMLElement | null = surfaceElement;
while (element) {
elements.add(element);
element = element.parentElement;
}
}
return Array.from(
elements,
(element): AssistantSurfaceScrollPosition => ({
element,
left: element.scrollLeft,
top: element.scrollTop,
}),
);
};
const restoreAssistantSurfaceScrollPositions = (positions: AssistantSurfaceScrollPosition[]) => {
for (const { element, left, top } of positions) {
if (!element.isConnected) continue;
element.scrollLeft = left;
element.scrollTop = top;
}
};
const getSurfaceIdForElement = (element: Element | null) => {
if (!element) return null;
@@ -461,7 +497,16 @@ const AssistantComposerSurfaceBoundary = ({ children, surfaceId }: PropsWithChil
return (
<AssistantComposerSurfaceContext.Provider value={contextValue}>
<div ref={elementRef} style={{ display: "contents" }} onPasteCapture={handlePasteCapture}>
<div
ref={elementRef}
style={{ display: "contents" }}
onFocusCapture={(event) => {
if (!(event.target instanceof HTMLElement)) return;
lastFocusedSurfaceElement = event.target;
lastFocusedSurfaceAt = performance.now();
}}
onPasteCapture={handlePasteCapture}
>
<AssistantComposerSurfaceEvents />
{children}
</div>
@@ -476,19 +521,42 @@ const AssistantComposerSurfaceBoundary = ({ children, surfaceId }: PropsWithChil
*/
export const AssistantRunFocusPreserver = () => {
useAuiEvent("thread.runStart", () => {
const focusedBeforeRun = document.activeElement instanceof HTMLElement ? document.activeElement : null;
const activeElement = document.activeElement instanceof HTMLElement ? document.activeElement : null;
// Sending can briefly move focus to <body> while Lexical reconciles the editor. Retain the
// last connected assistant input so the run still belongs to the surface the user submitted.
const recentlyFocusedSurface = performance.now() - lastFocusedSurfaceAt < 1000;
const focusedBeforeRun =
activeElement && activeElement !== document.body
? activeElement
: recentlyFocusedSurface && lastFocusedSurfaceElement?.isConnected
? lastFocusedSurfaceElement
: activeElement;
const surfaceIdBeforeRun = getSurfaceIdForElement(focusedBeforeRun);
const scrollPositionsBeforeRun = captureAssistantSurfaceScrollPositions();
let isRestoring = false;
const restoreFocus = () => {
if (isRestoring) return;
const surfaceIdAfterRun = getSurfaceIdForElement(document.activeElement);
if (!surfaceIdAfterRun || surfaceIdAfterRun === surfaceIdBeforeRun) return;
const movedToAnotherSurface = surfaceIdAfterRun !== null && surfaceIdAfterRun !== surfaceIdBeforeRun;
const leftInitiatingSurface = surfaceIdBeforeRun !== null && surfaceIdAfterRun !== surfaceIdBeforeRun;
if (!movedToAnotherSurface && !leftInitiatingSurface) return;
if (focusedBeforeRun?.isConnected) focusedBeforeRun.focus({ preventScroll: true });
if (
getSurfaceIdForElement(document.activeElement) !== surfaceIdBeforeRun &&
document.activeElement instanceof HTMLElement
) {
document.activeElement.blur();
isRestoring = true;
try {
if (focusedBeforeRun?.isConnected) focusedBeforeRun.focus({ preventScroll: true });
if (
getSurfaceIdForElement(document.activeElement) !== surfaceIdBeforeRun &&
document.activeElement instanceof HTMLElement
) {
document.activeElement.blur();
}
// Focusing the other mounted Lexical composer may already have scrolled the dashboard or
// one of its nested board containers. Put every assistant surface ancestor back before the
// browser paints so a send from the floating panel never jumps to the board widget.
restoreAssistantSurfaceScrollPositions(scrollPositionsBeforeRun);
} finally {
isRestoring = false;
}
};
@@ -1,6 +1,6 @@
"use client";
import type { ReactNode } from "react";
import type { CSSProperties, ReactNode } from "react";
import { useCallback, useState } from "react";
import { useAui, useAuiState } from "@assistant-ui/react";
import { ActionIcon, Box, Button, Center, Group, Loader, Stack, Text, ThemeIcon, Tooltip } from "@mantine/core";
@@ -8,6 +8,7 @@ import { IconAlertTriangle, IconArrowsMaximize, IconMessage, IconRobot } from "@
import { useI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "@homarr/widgets";
import { useRequiredBoard } from "@homarr/boards/context";
import classes from "./assistant-panel.module.css";
import { AssistantConversationSurface } from "./assistant-panel";
@@ -31,6 +32,7 @@ export const AssistantBoardWidget = (props: WidgetComponentProps<"assistant">) =
const EnabledAssistantBoardWidget = ({ options, width, height, isEditMode }: WidgetComponentProps<"assistant">) => {
const t = useI18n();
const board = useRequiredBoard();
const assistant = useHomarrAssistant();
const preferences = useAssistantPreferences();
const aui = useAui();
@@ -110,9 +112,11 @@ const EnabledAssistantBoardWidget = ({ options, width, height, isEditMode }: Wid
return (
<Box
className={classes.widgetPanel}
data-board-widget
data-compact={width < 420 || height < 420 || undefined}
data-editing={isEditMode || undefined}
aria-busy={isLoading || assistant.isRunning}
style={{ "--assistant-widget-radius": `var(--mantine-radius-${board.itemRadius})` } as CSSProperties}
>
<AssistantConversationSurface
isRunning={assistant.isRunning}
@@ -205,7 +205,7 @@ export const WidgetContextMenu = ({
[setItemOptions],
);
if (!session || !settings.enableRightClickOnWidgets || isEditMode) return <>{children}</>;
if (!session || !settings.enableRightClickOnWidgets || isEditMode || item.kind === "app") return <>{children}</>;
const visibleWidgetActions = widgetContextActions.filter((action) => !action.hidden);
+32
View File
@@ -10,6 +10,37 @@ const escapeHtml = (value) =>
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
const renderWorkshopSocialHtml = (html, metadata) => {
const title = escapeHtml(metadata.title);
const description = escapeHtml(metadata.description);
const url = escapeHtml(metadata.url);
const image = escapeHtml(metadata.image);
const imageAlt = escapeHtml(`${metadata.submissionTitle} preview`);
const section = escapeHtml(metadata.section);
const tags = [
`<title>${title}</title>`,
`<meta name="description" content="${description}">`,
`<link rel="canonical" href="${url}">`,
`<meta property="og:type" content="article">`,
`<meta property="og:site_name" content="Homarr Workshop">`,
`<meta property="og:title" content="${title}">`,
`<meta property="og:description" content="${description}">`,
`<meta property="og:url" content="${url}">`,
`<meta property="article:section" content="${section}">`,
`<meta name="twitter:card" content="summary_large_image">`,
`<meta name="twitter:title" content="${title}">`,
`<meta name="twitter:description" content="${description}">`,
`<meta property="og:image" content="${image}">`,
`<meta property="og:image:alt" content="${imageAlt}">`,
`<meta name="twitter:image" content="${image}">`,
`<meta name="twitter:image:alt" content="${imageAlt}">`,
].join("\n");
const replaceableTags =
/<title\b[^>]*>[\s\S]*?<\/title>|<meta\b[^>]*(?:name|property)=["'](?:description|og:(?:title|description|url|image|image:alt|type|site_name)|twitter:(?:card|title|description|image|image:alt)|article:section)["'][^>]*>|<link\b[^>]*rel=["']canonical["'][^>]*>/gi;
return html.replace(replaceableTags, "").replace("<head>", `<head>\n${tags}`);
};
const emailTemplate = (filename) => {
const hooksPath = $os.getenv("WORKSHOP_PB_HOOKS_DIR") || __hooks;
return $os.readFile($filepath.join(hooksPath, filename));
@@ -32,5 +63,6 @@ module.exports = {
emailTemplate,
escapeHtml,
rejectRequest,
renderWorkshopSocialHtml,
sendEmail,
};
+40
View File
@@ -17,6 +17,46 @@ onBootstrap((event) => {
console.log(JSON.stringify({ event: "workshop_oauth_synchronized", enabled: configured }));
});
const workshopDetailPage = (event) => {
const id = event.request.pathValue("id");
if (id === "admin") return event.fileFS($os.dirFS("/pb_public"), "workshop/admin/index.html");
const indexHtml = toString($os.readFile("/pb_public/index.html"));
try {
const { renderWorkshopSocialHtml } = require(`${__hooks}/workshop-utils.js`);
const submission = event.app.findRecordById("submissions", id);
const type = submission.getString("type");
const section = type === "customCss" ? "Custom CSS" : "Custom widget";
const submissionTitle = submission.getString("title");
const title = `${submissionTitle} · Homarr Workshop`;
const description = `${section} for Homarr. ${submission.getString("description")}`.trim();
const workshopUrl = $os.getenv("WORKSHOP_WEB_URL").replace(/\/$/, "");
const apiUrl = $os.getenv("WORKSHOP_API_URL").replace(/\/$/, "");
const websiteUrl = $os.getenv("HOMARR_WEBSITE_URL").replace(/\/$/, "");
const screenshot = submission.getStringSlice("screenshots")[0];
const image = screenshot
? `${apiUrl}/api/files/submissions/${submission.id}/${encodeURIComponent(screenshot)}`
: `${websiteUrl}/img/logo.png`;
return event.html(
200,
renderWorkshopSocialHtml(indexHtml, {
title,
description,
url: `${workshopUrl}/${submission.id}`,
image,
section,
submissionTitle,
}),
);
} catch {
return event.html(200, indexHtml);
}
};
routerAdd("GET", "/workshop/{id}", workshopDetailPage);
routerAdd("GET", "/workshop/{id}/{$}", workshopDetailPage);
onRecordCreateRequest((event) => {
event.record.set("revision", 1);
event.record.set("expectedRevision", 0);
+7
View File
@@ -38,6 +38,13 @@ WORKSHOP_TEST_URL="http://127.0.0.1:$WORKSHOP_IMAGE_TEST_PORT" \
EXPECTED_WORKSHOP_WEB_URL="$PREVIEW_ORIGIN/workshop" \
node apps/workshop/tests/runtime-config.integration.mjs
docker exec "$WORKSHOP_IMAGE_TEST_NAME" pocketbase superuser create \
workshop-image@example.invalid 'WorkshopImageTest123!' --dir=/pb_data
WORKSHOP_TEST_URL="http://127.0.0.1:$WORKSHOP_IMAGE_TEST_PORT" \
EXPECTED_WORKSHOP_API_URL="$PREVIEW_ORIGIN" \
EXPECTED_WORKSHOP_WEB_URL="$PREVIEW_ORIGIN/workshop" \
node apps/workshop/tests/workshop-social-metadata.integration.mjs
curl --fail --location --silent "http://127.0.0.1:$WORKSHOP_IMAGE_TEST_PORT/workshop" >/dev/null
curl --fail --silent \
"http://127.0.0.1:$WORKSHOP_IMAGE_TEST_PORT/api/collections/workshop_listings/records?perPage=1" >/dev/null
@@ -1,5 +1,6 @@
import { access, readFile } from "node:fs/promises";
import { resolve } from "node:path";
import vm from "node:vm";
const read = (path) => readFile(resolve(path), "utf8");
@@ -39,6 +40,34 @@ for (const artifact of [
if (hook.includes('findAllRecords("users")') || !hook.includes('findRecordsByFilter("users"')) {
throw new Error("Workshop report notifications must use a filtered, bounded administrator query");
}
if (!hook.includes('routerAdd("GET", "/workshop/{id}"') || !hook.includes('getStringSlice("screenshots")')) {
throw new Error("Workshop detail routes must render server-side social metadata with submission screenshots");
}
const hookUtilsModule = { exports: {} };
vm.runInNewContext(await read("apps/workshop/pb_hooks/workshop-utils.js"), { module: hookUtilsModule });
const socialHtml = hookUtilsModule.exports.renderWorkshopSocialHtml(
'<html><head><title data-rh="true">Homarr documentation</title><meta data-rh="true" property="og:title" content="Homarr documentation"><meta data-rh="true" name="description" content="Generic"><link data-rh="true" rel="canonical" href="https://homarr.dev/"></head></html>',
{
title: "Ocean <Glow> · Homarr Workshop",
description: "Custom CSS for Homarr. Calm & readable.",
url: "https://preview.example/workshop/abc",
image: "https://preview.example/api/files/submissions/abc/preview.png",
section: "Custom CSS",
submissionTitle: "Ocean <Glow>",
},
);
for (const expected of [
"<title>Ocean &lt;Glow&gt; · Homarr Workshop</title>",
'property="og:description" content="Custom CSS for Homarr. Calm &amp; readable."',
'rel="canonical" href="https://preview.example/workshop/abc"',
'property="og:image" content="https://preview.example/api/files/submissions/abc/preview.png"',
'name="twitter:card" content="summary_large_image"',
'property="article:section" content="Custom CSS"',
]) {
if (!socialHtml.includes(expected)) throw new Error(`Workshop social metadata is missing: ${expected}`);
}
if (socialHtml.includes("Homarr documentation")) throw new Error("Workshop social metadata must replace generic tags");
const migration = await read("apps/workshop/pb_migrations/1784240000_workshop_widgets.js");
for (const removedField of ["displayName", "avatarUrl", "githubProfileUrl", "githubUsername"]) {
@@ -0,0 +1,55 @@
const baseUrl = process.env.WORKSHOP_TEST_URL ?? "http://127.0.0.1:18091";
const publicWorkshopUrl = process.env.EXPECTED_WORKSHOP_WEB_URL ?? "https://v2.preview.homarr.dev/workshop";
const publicApiUrl = process.env.EXPECTED_WORKSHOP_API_URL ?? "https://v2.preview.homarr.dev";
const request = async (path, init = {}) => {
const response = await fetch(`${baseUrl}${path}`, init);
const body = await response.json().catch(() => null);
if (!response.ok) throw new Error(`${response.status} ${path}: ${JSON.stringify(body)}`);
return body;
};
const root = await request("/api/collections/_superusers/auth-with-password", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ identity: "workshop-image@example.invalid", password: "WorkshopImageTest123!" }),
});
const headers = { authorization: `Bearer ${root.token}` };
const author = await request("/api/collections/users/records", {
method: "POST",
headers: { ...headers, "content-type": "application/json" },
body: JSON.stringify({
email: "social-author@example.invalid",
password: "WorkshopSocialTest123!",
passwordConfirm: "WorkshopSocialTest123!",
name: "social-author",
}),
});
const png = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
"base64",
);
const data = new FormData();
data.set("type", "customCss");
data.set("title", "Ocean <Glow>");
data.set("description", "Calm & readable.");
data.set("widgetSchema", "homarr-custom-css-v1");
data.set("content", "body { color: white; }");
data.set("author", author.id);
data.set("screenshots", new File([png], "preview.png", { type: "image/png" }));
const submission = await request("/api/collections/submissions/records", { method: "POST", headers, body: data });
const screenshot = encodeURIComponent(submission.screenshots[0]);
const html = await fetch(`${baseUrl}/workshop/${submission.id}`).then((response) => response.text());
for (const expected of [
"<title>Ocean &lt;Glow&gt; · Homarr Workshop</title>",
'property="og:description" content="Custom CSS for Homarr. Calm &amp; readable."',
`rel="canonical" href="${publicWorkshopUrl}/${submission.id}"`,
`property="og:image" content="${publicApiUrl}/api/files/submissions/${submission.id}/${screenshot}"`,
'name="twitter:card" content="summary_large_image"',
'property="article:section" content="Custom CSS"',
]) {
if (!html.includes(expected)) throw new Error(`Rendered Workshop page is missing social metadata: ${expected}`);
}
console.log("Workshop social metadata integration passed");
+7
View File
@@ -151,6 +151,13 @@ describe("Board grid", () => {
await nestedAppLink.click();
await expect(page).toHaveURL(/#board-app-opened$/);
await page.evaluate(() => history.replaceState(null, "", `${location.pathname}${location.search}`));
await firstItem.click({ button: "right" });
await expect(page.getByRole("menu")).toBeVisible();
await page.keyboard.press("Escape");
await nestedItem.click({ button: "right" });
await expect(page.getByRole("menu")).toHaveCount(0);
const normalZoomTileBox = await expectBoundingBoxAsync(logicalTile);
const secondItemBox = await expectBoundingBoxAsync(secondItem);
expect(secondItemBox.x - (normalZoomTileBox.x + normalZoomTileBox.width)).toBeCloseTo(
+1 -14
View File
@@ -6150,7 +6150,6 @@
"connectionReady": "Connection ready",
"unsavedChanges": "Unsaved changes",
"setupRequired": "Setup required",
"keyProtected": "Key protected",
"available": "Available",
"disabled": "Disabled",
"noModel": "No model selected"
@@ -6161,8 +6160,7 @@
"description": "Choose a preset or connect any service that implements the OpenAI Chat Completions API.",
"saved": "Connection saved",
"notSaved": "Not configured",
"save": "Save provider connection",
"clearCredentials": "Remove all credentials"
"save": "Save provider connection"
},
"credentials": {
"title": "Credentials",
@@ -6303,13 +6301,6 @@
"label": "Enable Assistant",
"description": "Shows the assistant to all signed-in users. Existing Homarr permissions still apply to every internal tool call."
},
"clearCredentials": {
"title": "Remove all assistant credentials",
"warningTitle": "The assistant will be disabled",
"description": "This permanently removes the stored API key and additional headers, clears the selected model, and disables the assistant. The encrypted values cannot be recovered.",
"cancel": "Keep credentials",
"confirm": "Remove credentials"
},
"save": "Save assistant",
"notification": {
"connectionSaved": {
@@ -6317,10 +6308,6 @@
"message": "The endpoint and encrypted credentials are ready for model selection.",
"credentialsCleared": "The endpoint changed, so previous credentials were cleared. Add credentials for the new destination if required."
},
"credentialsCleared": {
"title": "Provider credentials removed",
"message": "The stored API key and headers were removed, and the assistant was disabled."
},
"saved": {
"title": "Assistant settings saved",
"message": "The new model and availability settings are active."