feat: add assistant board styling tools

This commit is contained in:
ajnart committed 2026-08-01 10:39:41 +02:00
1 parent a5fa6d3afe
commit 5458e7d647
21 files changed
+811 -18

No files matched your search

@@ -0,0 +1,401 @@
"use client";
import { useMemo, useState } from "react";
import type { ToolCallMessagePartProps } from "@assistant-ui/react";
import {
Alert,
Badge,
Box,
Button,
ColorInput,
CopyButton,
Group,
InputWrapper,
Select,
SimpleGrid,
Skeleton,
Slider,
Stack,
Switch,
Tabs,
Text,
Textarea,
TextInput,
ThemeIcon,
Tooltip,
} from "@mantine/core";
import {
IconAlertTriangle,
IconCheck,
IconCopy,
IconFileTypeCss,
IconPalette,
IconPhoto,
IconRefresh,
IconSettings,
} from "@tabler/icons-react";
import type { z } from "zod/v4";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { backgroundImageAttachments, backgroundImageRepeats, backgroundImageSizes } from "@homarr/definitions";
import { useZodForm } from "@homarr/form";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { boardSavePartialSettingsSchema } from "@homarr/validation/board";
import { getChangedBoardSettings, getCustomCssWarnings } from "./assistant-board-settings";
import classes from "./assistant-panel.module.css";
import type {
AssistantBoardSettingsChanges,
ConfigureBoardSettingsArgs,
ConfigureBoardSettingsResult,
} from "./assistant-tool-contracts";
type BoardSettings = RouterOutputs["board"]["getBoardSettings"];
const completeSettingsSchema = boardSavePartialSettingsSchema.required();
type BoardSettingsFormValues = z.infer<typeof completeSettingsSchema>;
const normalizeFormValues = (
current: BoardSettings,
proposed: AssistantBoardSettingsChanges,
): BoardSettingsFormValues => {
const values = { ...current, ...proposed };
return {
pageTitle: values.pageTitle ?? "",
metaTitle: values.metaTitle ?? "",
logoImageUrl: values.logoImageUrl ?? "",
faviconImageUrl: values.faviconImageUrl ?? "",
backgroundImageUrl: values.backgroundImageUrl ?? "",
backgroundImageAttachment: values.backgroundImageAttachment,
backgroundImageRepeat: values.backgroundImageRepeat,
backgroundImageSize: values.backgroundImageSize,
primaryColor: values.primaryColor,
secondaryColor: values.secondaryColor,
opacity: values.opacity,
customCss: values.customCss,
iconColor: values.iconColor ?? "",
itemRadius: values.itemRadius,
disableStatus: values.disableStatus,
};
};
const toCurrentValues = (current: BoardSettings): BoardSettingsFormValues => normalizeFormValues(current, {});
export const AssistantConfigureBoardSettingsTool = ({
args,
result,
addResult,
}: ToolCallMessagePartProps<ConfigureBoardSettingsArgs, ConfigureBoardSettingsResult>) => {
const t = useScopedI18n("common.assistant.configureBoardSettings");
const boardId = args?.boardId ?? "";
const settings = clientApi.board.getBoardSettings.useQuery(
{ id: boardId },
{ enabled: result === undefined && boardId.length > 0, retry: false },
);
if (result) {
const cancelled = "cancelled" in result && result.cancelled;
const changedCount = cancelled ? 0 : Math.max(0, Object.keys(result).length - 1);
return (
<Box className={classes.humanToolCompleted}>
<ThemeIcon size="sm" radius="xl" variant="light" color="green">
<IconCheck size={13} />
</ThemeIcon>
<Box miw={0}>
<Text size="xs" c="dimmed">
{cancelled ? t("cancelled") : t("ready")}
</Text>
<Text size="sm" fw={600} truncate>
{cancelled ? args?.boardName : t("changeCount", { count: changedCount })}
</Text>
</Box>
</Box>
);
}
if (!args?.boardId || !args.boardName) {
return <BoardSettingsSkeleton label={t("loading")} />;
}
if (settings.isError) {
return (
<Alert color="red" variant="light" title={t("loadErrorTitle")} icon={<IconAlertTriangle size={18} />}>
<Stack gap="sm">
<Text size="sm">{t("loadErrorDescription")}</Text>
<Button variant="light" color="red" size="compact-sm" w="fit-content" onClick={() => settings.refetch()}>
{t("retry")}
</Button>
</Stack>
</Alert>
);
}
if (!settings.data) return <BoardSettingsSkeleton label={t("loading")} />;
return (
<BoardSettingsForm
current={settings.data}
args={args}
onSubmit={(changes) => addResult({ id: settings.data.id, ...changes })}
onCancel={() => addResult({ id: settings.data.id, cancelled: true })}
/>
);
};
const BoardSettingsSkeleton = ({ label }: { label: string }) => (
<Box className={classes.boardSettingsTool} aria-label={label}>
<Stack gap="sm">
<Skeleton height={20} width="45%" />
<Skeleton height={14} width="78%" />
<Skeleton height={38} />
<Skeleton height={180} />
</Stack>
</Box>
);
const BoardSettingsForm = ({
current,
args,
onSubmit,
onCancel,
}: {
current: BoardSettings;
args: ConfigureBoardSettingsArgs;
onSubmit: (changes: AssistantBoardSettingsChanges) => void;
onCancel: () => void;
}) => {
const t = useScopedI18n("common.assistant.configureBoardSettings");
const boardT = useI18n();
const [submitting, setSubmitting] = useState(false);
const currentValues = useMemo(() => toCurrentValues(current), [current]);
const form = useZodForm(completeSettingsSchema, {
initialValues: normalizeFormValues(current, args.changes),
});
const changes = getChangedBoardSettings(currentValues, form.values);
const changedCount = Object.keys(changes).length;
const cssWarnings = getCustomCssWarnings(form.values.customCss);
const proposedKeys = Object.keys(args.changes);
const defaultTab =
args.changes.customCss !== undefined ? "css" : proposedKeys.some(isAppearanceKey) ? "appearance" : "general";
const optionData = <TValue extends string>(
values: readonly TValue[],
key: "backgroundImageAttachment" | "backgroundImageRepeat" | "backgroundImageSize",
) =>
values.map((value) => ({
value,
label: boardT(`board.field.${key}.option.${value}.label` as never),
}));
return (
<Box className={classes.boardSettingsTool}>
<Group className={classes.boardSettingsHeader} align="flex-start" wrap="nowrap" gap="sm">
<ThemeIcon size="lg" radius="xl" variant="light" color="red">
<IconSettings size={18} />
</ThemeIcon>
<Box flex={1} miw={0}>
<Group gap="xs" wrap="wrap">
<Text fw={700}>{t("title")}</Text>
<Badge variant="light" color="gray" size="sm">
{args.boardName}
</Badge>
</Group>
<Text size="sm" c="dimmed" mt={3}>
{args.summary}
</Text>
{proposedKeys.length > 0 && (
<Text size="xs" c="dimmed" mt="xs">
{t("proposedCount", { count: proposedKeys.length })}
</Text>
)}
</Box>
</Group>
<form
onSubmit={form.onSubmit((values) => {
if (submitting) return;
const nextChanges = getChangedBoardSettings(currentValues, values);
setSubmitting(true);
if (Object.keys(nextChanges).length === 0) {
onCancel();
return;
}
onSubmit(nextChanges);
})}
>
<Tabs defaultValue={defaultTab} mt="md" className={classes.boardSettingsTabs}>
<Tabs.List grow>
<Tabs.Tab value="css" leftSection={<IconFileTypeCss size={15} />}>
{t("tabs.css")}
</Tabs.Tab>
<Tabs.Tab value="appearance" leftSection={<IconPalette size={15} />}>
{t("tabs.appearance")}
</Tabs.Tab>
<Tabs.Tab value="background" leftSection={<IconPhoto size={15} />}>
{t("tabs.background")}
</Tabs.Tab>
<Tabs.Tab value="general" leftSection={<IconSettings size={15} />}>
{t("tabs.general")}
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="css" pt="md">
<Stack gap="sm">
<Textarea
label={boardT("board.field.customCss.label")}
description={t("cssDescription")}
autosize
minRows={10}
maxRows={20}
classNames={{ input: classes.boardCssEditor }}
{...form.getInputProps("customCss")}
/>
<Group justify="space-between" gap="xs" wrap="wrap">
<Text size="xs" c={form.values.customCss.length > 15_500 ? "orange" : "dimmed"}>
{t("cssCharacters", { count: form.values.customCss.length })}
</Text>
<Group gap="xs">
<CopyButton value={form.values.customCss}>
{({ copied, copy }) => (
<Tooltip label={copied ? t("copied") : t("copyCss")}>
<Button
variant="subtle"
size="compact-sm"
leftSection={copied ? <IconCheck size={14} /> : <IconCopy size={14} />}
onClick={copy}
>
{copied ? t("copied") : t("copy")}
</Button>
</Tooltip>
)}
</CopyButton>
<Button
variant="subtle"
color="gray"
size="compact-sm"
leftSection={<IconRefresh size={14} />}
disabled={form.values.customCss === currentValues.customCss}
onClick={() => form.setFieldValue("customCss", currentValues.customCss)}
>
{t("restoreCss")}
</Button>
</Group>
</Group>
{(cssWarnings.importsStylesheet || cssWarnings.loadsRemoteResource) && (
<Alert
variant="light"
color="yellow"
icon={<IconAlertTriangle size={18} />}
title={t("remoteCssTitle")}
>
{t("remoteCssDescription")}
</Alert>
)}
</Stack>
</Tabs.Panel>
<Tabs.Panel value="appearance" pt="md">
<Stack gap="md">
<SimpleGrid cols={{ base: 1, sm: 2 }}>
<ColorInput
label={boardT("board.field.primaryColor.label")}
format="hex"
{...form.getInputProps("primaryColor")}
/>
<ColorInput
label={boardT("board.field.secondaryColor.label")}
format="hex"
{...form.getInputProps("secondaryColor")}
/>
<ColorInput
label={boardT("board.field.iconColor.label")}
format="hex"
{...form.getInputProps("iconColor")}
/>
<Select
label={boardT("board.field.itemRadius.label")}
data={(["xs", "sm", "md", "lg", "xl"] as const).map((value) => ({
value,
label: boardT(`board.field.itemRadius.option.${value}`),
}))}
{...form.getInputProps("itemRadius")}
/>
</SimpleGrid>
<InputWrapper label={boardT("board.field.opacity.label")}>
<Slider min={0} max={100} step={5} label={(value) => `${value}%`} {...form.getInputProps("opacity")} />
</InputWrapper>
</Stack>
</Tabs.Panel>
<Tabs.Panel value="background" pt="md">
<Stack gap="md">
<TextInput
label={boardT("board.field.backgroundImageUrl.label")}
placeholder={boardT("board.field.backgroundImageUrl.placeholder")}
{...form.getInputProps("backgroundImageUrl")}
/>
<SimpleGrid cols={{ base: 1, sm: 2 }}>
<Select
label={boardT("board.field.backgroundImageSize.label")}
data={optionData(backgroundImageSizes.values, "backgroundImageSize")}
{...form.getInputProps("backgroundImageSize")}
/>
<Select
label={boardT("board.field.backgroundImageRepeat.label")}
data={optionData(backgroundImageRepeats.values, "backgroundImageRepeat")}
{...form.getInputProps("backgroundImageRepeat")}
/>
<Select
label={boardT("board.field.backgroundImageAttachment.label")}
data={optionData(backgroundImageAttachments.values, "backgroundImageAttachment")}
{...form.getInputProps("backgroundImageAttachment")}
/>
</SimpleGrid>
</Stack>
</Tabs.Panel>
<Tabs.Panel value="general" pt="md">
<Stack gap="md">
<SimpleGrid cols={{ base: 1, sm: 2 }}>
<TextInput label={boardT("board.field.pageTitle.label")} {...form.getInputProps("pageTitle")} />
<TextInput label={boardT("board.field.metaTitle.label")} {...form.getInputProps("metaTitle")} />
<TextInput label={boardT("board.field.logoImageUrl.label")} {...form.getInputProps("logoImageUrl")} />
<TextInput
label={boardT("board.field.faviconImageUrl.label")}
{...form.getInputProps("faviconImageUrl")}
/>
</SimpleGrid>
<Switch
label={boardT("board.field.disableStatus.label")}
description={boardT("board.field.disableStatus.description")}
{...form.getInputProps("disableStatus", { type: "checkbox" })}
/>
</Stack>
</Tabs.Panel>
</Tabs>
<Group className={classes.boardSettingsActions} justify="space-between" gap="sm" mt="md" wrap="wrap">
<Button
variant="subtle"
color="gray"
leftSection={<IconRefresh size={15} />}
disabled={changedCount === 0 || submitting}
onClick={() => form.setValues(currentValues)}
>
{t("resetAll")}
</Button>
<Group gap="sm" wrap="nowrap" className={classes.boardSettingsSubmitGroup}>
<Text size="xs" c="dimmed">
{changedCount === 0 ? t("noChanges") : t("changeCount", { count: changedCount })}
</Text>
<Button type="submit" loading={submitting}>
{changedCount === 0 ? t("keepCurrent") : t("continue")}
</Button>
</Group>
</Group>
</form>
</Box>
);
};
const isAppearanceKey = (key: string) =>
["primaryColor", "secondaryColor", "opacity", "iconColor", "itemRadius"].includes(key);
@@ -0,0 +1,29 @@
import { describe, expect, test } from "vitest";
import { getChangedBoardSettings, getCustomCssWarnings } from "./assistant-board-settings";
describe("getChangedBoardSettings", () => {
test("returns only changed settings and treats empty nullable values equally", () => {
expect(
getChangedBoardSettings(
{ pageTitle: null, primaryColor: "#112233", opacity: 85 },
{ pageTitle: "", primaryColor: "#445566", opacity: 85 },
),
).toEqual({ primaryColor: "#445566" });
});
test("keeps an intentional custom CSS removal", () => {
expect(getChangedBoardSettings({ customCss: ".item { color: red; }" }, { customCss: "" })).toEqual({
customCss: "",
});
});
});
describe("getCustomCssWarnings", () => {
test("detects imported and remote CSS resources", () => {
expect(getCustomCssWarnings('@import "theme.css"; .item { background: url(https://example.com/a.png); }')).toEqual({
importsStylesheet: true,
loadsRemoteResource: true,
});
});
});
@@ -0,0 +1,33 @@
import type { AssistantBoardSettingsChanges } from "./assistant-tool-contracts";
export const assistantBoardSettingKeys = [
"pageTitle",
"metaTitle",
"logoImageUrl",
"faviconImageUrl",
"backgroundImageUrl",
"backgroundImageAttachment",
"backgroundImageRepeat",
"backgroundImageSize",
"primaryColor",
"secondaryColor",
"opacity",
"customCss",
"iconColor",
"itemRadius",
"disableStatus",
] as const satisfies readonly (keyof AssistantBoardSettingsChanges)[];
const comparableValue = (value: unknown) => (value === null ? "" : value);
export const getChangedBoardSettings = (current: AssistantBoardSettingsChanges, next: AssistantBoardSettingsChanges) =>
Object.fromEntries(
assistantBoardSettingKeys.flatMap((key) =>
comparableValue(current[key]) === comparableValue(next[key]) ? [] : [[key, next[key]]],
),
) as AssistantBoardSettingsChanges;
export const getCustomCssWarnings = (css: string) => ({
importsStylesheet: /@import\b/iu.test(css),
loadsRemoteResource: /url\(\s*["']?https?:\/\//iu.test(css),
});
@@ -55,6 +55,43 @@
height: 2.5rem;
}
.boardSettingsTool {
padding: 0.9rem;
border: 1px solid var(--mantine-color-default-border);
border-radius: var(--mantine-radius-md);
background: var(--mantine-color-body);
}
.boardSettingsHeader {
min-width: 0;
}
.boardSettingsTabs :global(.mantine-Tabs-list) {
flex-wrap: nowrap;
overflow-x: auto;
scrollbar-width: thin;
}
.boardSettingsTabs :global(.mantine-Tabs-tab) {
min-width: max-content;
}
.boardCssEditor {
line-height: 1.55;
tab-size: 2;
font-family: var(--mantine-font-family-monospace);
font-size: var(--mantine-font-size-xs);
}
.boardSettingsActions {
padding-top: 0.8rem;
border-top: 1px solid var(--mantine-color-default-border);
}
.boardSettingsSubmitGroup {
margin-inline-start: auto;
}
.floatingPanel {
position: fixed;
z-index: 290;
@@ -1049,9 +1086,20 @@
}
.humanTool,
.appTool {
.appTool,
.boardSettingsTool {
padding: 0.8rem;
}
.boardSettingsActions,
.boardSettingsSubmitGroup {
width: 100%;
}
.boardSettingsSubmitGroup {
justify-content: space-between;
margin-inline-start: 0;
}
}
@media (prefers-reduced-motion: reduce) {
@@ -68,13 +68,13 @@ import {
IconChevronUp,
IconCopy,
IconDownload,
IconCommand,
IconFile,
IconFileExport,
IconLink,
IconMessage,
IconMinus,
IconPaperclip,
IconPalette,
IconPencil,
IconHistory,
IconPlayerStop,
@@ -1061,9 +1061,9 @@ const EmptyThread = () => {
{t("suggestions.media.label")}
</Button>
</ThreadPrimitive.Suggestion>
<ThreadPrimitive.Suggestion prompt={t("suggestions.command.prompt")} send asChild>
<Button variant="default" className={classes.suggestion} leftSection={<IconCommand size={18} />}>
{t("suggestions.command.label")}
<ThreadPrimitive.Suggestion prompt={t("suggestions.style.prompt")} send asChild>
<Button variant="default" className={classes.suggestion} leftSection={<IconPalette size={18} />}>
{t("suggestions.style.label")}
</Button>
</ThreadPrimitive.Suggestion>
</Box>
@@ -1189,6 +1189,12 @@ const ComposerTriggers = () => {
description: t("commands.media"),
execute: () => aui.composer().setText(t("suggestions.media.prompt")),
},
{
id: "style",
label: "/style",
description: t("commands.style"),
execute: () => aui.composer().setText(t("suggestions.style.prompt")),
},
],
});
@@ -1471,7 +1477,11 @@ const usePendingActionCopy = (action: AssistantPendingAction | undefined) => {
return { title: t("pendingAction.answerTitle"), detail: action.detail ?? t("pendingAction.answerFallback") };
}
if (action.kind === "form") {
return { title: t("pendingAction.formTitle"), detail: action.detail ?? t("configureApp.title") };
const isBoardSettings = action.toolName === "configure_board_settings";
return {
title: isBoardSettings ? t("pendingAction.boardFormTitle") : t("pendingAction.formTitle"),
detail: action.detail ?? (isBoardSettings ? t("configureBoardSettings.title") : t("configureApp.title")),
};
}
const tool = action.toolName.replaceAll("_", " ");
return { title: t("activity.approval"), detail: action.detail ? `${tool} · ${action.detail}` : tool };
@@ -61,6 +61,20 @@ describe("getPendingAssistantAction", () => {
).toEqual({ kind: "form", toolName: "configure_app", detail: "Wikipedia" });
});
test("exposes the board settings form while changes still need review", () => {
expect(
getPendingAssistantAction(
assistantMessage({
type: "tool-call",
toolCallId: "settings-1",
toolName: "configure_board_settings",
args: { boardId: "board-1", boardName: "Home", changes: { customCss: ".item {}" } },
argsText: "{}",
}),
),
).toEqual({ kind: "form", toolName: "configure_board_settings", detail: "Home" });
});
test("exposes an unresolved mutation approval", () => {
expect(
getPendingAssistantAction(
@@ -9,6 +9,7 @@ export type AssistantPendingAction = {
const humanToolKinds = {
ask_user: "question",
configure_app: "form",
configure_board_settings: "form",
} as const;
const getStringArg = (args: unknown, key: string) => {
@@ -37,7 +38,10 @@ export const getPendingAssistantAction = (message: ThreadMessage | undefined): A
return {
kind,
toolName: part.toolName,
detail: getStringArg(part.args, kind === "question" ? "question" : "name"),
detail: getStringArg(
part.args,
kind === "question" ? "question" : part.toolName === "configure_board_settings" ? "boardName" : "name",
),
};
}
@@ -33,6 +33,7 @@ import { AssistantRuntimeProviderWithTools } from "./assistant-runtime-provider"
import { sendAssistantPrompt as sendPromptThroughComposer } from "./assistant-send";
import { createAssistantPromptInteraction } from "./assistant-spotlight";
import { browserToolContracts } from "./assistant-tool-contracts";
import { AssistantConfigureBoardSettingsTool } from "./assistant-board-settings-tool";
import type { AssistantUIMessage } from "./assistant-message-metadata";
interface AssistantContextValue {
@@ -412,6 +413,12 @@ const AssistantRuntime = ({ children }: PropsWithChildren) => {
...browserToolContracts.configure_app,
render: AssistantConfigureAppTool,
},
configure_board_settings: {
type: "human",
display: "standalone",
...browserToolContracts.configure_board_settings,
render: AssistantConfigureBoardSettingsTool,
},
navigate_to_route: {
type: "frontend",
...browserToolContracts.navigate_to_route,
@@ -1,4 +1,5 @@
import { describe, expect, test } from "vitest";
import { z } from "zod/v4";
import { browserToolContracts, normalizeAssistantAppIconUrl } from "./assistant-tool-contracts";
@@ -34,6 +35,35 @@ describe("assistant human tool contracts", () => {
).toBe(true);
});
test("accepts a scoped board settings proposal", () => {
expect(
browserToolContracts.configure_board_settings.parameters.safeParse({
boardId: "board-1",
boardName: "Home",
summary: "Improve dashboard contrast and tighten card spacing.",
changes: {
primaryColor: "#7C3AED",
customCss: ".mantine-Card-root { padding: 0.75rem; }",
},
}).success,
).toBe(true);
});
test("rejects invalid board colors and oversized CSS", () => {
expect(
browserToolContracts.configure_board_settings.parameters.safeParse({
boardId: "board-1",
boardName: "Home",
summary: "Change styling",
changes: { primaryColor: "purple", customCss: "a".repeat(16_385) },
}).success,
).toBe(false);
});
test("converts the board review parameters to a provider JSON schema", () => {
expect(() => z.toJSONSchema(browserToolContracts.configure_board_settings.parameters)).not.toThrow();
});
test("only preserves usable app icon URLs", () => {
expect(normalizeAssistantAppIconUrl("youtube")).toBe("");
expect(normalizeAssistantAppIconUrl("/api/media/youtube.svg")).toBe("/api/media/youtube.svg");
@@ -1,11 +1,43 @@
import { z } from "zod/v4";
import { backgroundImageAttachments, backgroundImageRepeats, backgroundImageSizes } from "@homarr/definitions";
const askUserOptionSchema = z.object({
id: z.string().trim().min(1).max(48),
label: z.string().trim().min(1).max(80),
description: z.string().trim().max(180).optional(),
});
const boardImageValueSchema = z.string().trim().max(2_048).nullable();
const boardTitleValueSchema = z.string().trim().max(255).nullable();
const boardColorSchema = z.string().regex(/^#[0-9A-Fa-f]{6}$/);
export const assistantBoardSettingsChangesSchema = z
.object({
pageTitle: boardTitleValueSchema.optional(),
metaTitle: boardTitleValueSchema.optional(),
logoImageUrl: boardImageValueSchema.optional(),
faviconImageUrl: boardImageValueSchema.optional(),
backgroundImageUrl: boardImageValueSchema.optional(),
backgroundImageAttachment: z.enum(backgroundImageAttachments.values).optional(),
backgroundImageRepeat: z.enum(backgroundImageRepeats.values).optional(),
backgroundImageSize: z.enum(backgroundImageSizes.values).optional(),
primaryColor: boardColorSchema.optional(),
secondaryColor: boardColorSchema.optional(),
opacity: z.number().min(0).max(100).optional(),
customCss: z
.string()
.max(16_384)
.optional()
.describe(
"The complete resulting board stylesheet. Preserve existing rules from board_getBoardSettings unless the user explicitly asks to replace them.",
),
iconColor: boardColorSchema.nullable().optional(),
itemRadius: z.enum(["xs", "sm", "md", "lg", "xl"]).optional(),
disableStatus: z.boolean().optional(),
})
.describe("Only the board fields the user asked to change.");
export const browserToolContracts = {
ask_user: {
description:
@@ -35,6 +67,16 @@ export const browserToolContracts = {
pingUrl: z.string().trim().max(2_048).nullable().optional(),
}),
},
configure_board_settings: {
description:
"Open Homarr's native board settings and custom CSS review form. Always call board_getBoardSettings first, then pass only the requested proposed changes here. The user can edit the complete stylesheet and all supported settings. Use the returned flat object directly with board_savePartialBoardSettings; do not ask for confirmation in prose.",
parameters: z.object({
boardId: z.string().trim().min(1).max(64),
boardName: z.string().trim().min(1).max(255),
summary: z.string().trim().min(1).max(400),
changes: assistantBoardSettingsChangesSchema,
}),
},
navigate_to_route: {
description:
"Navigate the current Homarr tab to a safe internal route. Only same-origin paths beginning with a single slash are accepted.",
@@ -59,6 +101,13 @@ export type AskUserResult = {
source: "option" | "other";
};
export type ConfigureAppArgs = z.infer<(typeof browserToolContracts)["configure_app"]["parameters"]>;
export type ConfigureBoardSettingsArgs = z.infer<
(typeof browserToolContracts)["configure_board_settings"]["parameters"]
>;
export type AssistantBoardSettingsChanges = z.infer<typeof assistantBoardSettingsChangesSchema>;
export type ConfigureBoardSettingsResult =
| (AssistantBoardSettingsChanges & { id: string })
| { id: string; cancelled: true };
export const normalizeAssistantAppIconUrl = (value: string | undefined) => {
if (!value) return "";