Merge feat/homarr-assistant into release/v2

This commit is contained in:
Thomas Camlong committed 2026-08-10 12:03:15 +02:00
commit b040fbc2d0
25 files changed
+967 -146

No files matched your search

+11 -2
View File
@@ -22,6 +22,8 @@ While a response is running, a compact activity bar appears at the bottom of the
avatar shows a thinking indicator. The activity bar can be opened to follow the response. Completed
responses add an unread count to the user menu until they are opened or dismissed. The expanded
conversation has a minimize action that returns it to the activity bar without interrupting a response.
Its desktop-style window controls can also maximize the conversation to the available viewport or close it
without leaving an activity bar behind. Each control includes an icon, tooltip, and accessible label.
The transcript follows streamed output while it is already at the bottom, including fast responses. Scrolling
up releases that follow state until **Scroll to latest** is selected or the user returns to the bottom.
@@ -129,7 +131,9 @@ Fenced `mermaid` blocks render as diagrams in the current color scheme using Mer
invalid diagrams fall back to their source instead of breaking the response.
When Homarr's icon search returns an image URL, visual result tables render that actual icon in a compact
image column instead of substituting an emoji. Assistant-provided Markdown images accept safe relative,
HTTP, and HTTPS sources and load without forwarding the Homarr page as the referrer.
HTTP, and HTTPS sources and load without forwarding the Homarr page as the referrer. Image message parts
render through assistant-ui's image primitive in a bounded responsive preview with an accessible loading state,
a filename caption when available, and an in-place retry when the source fails.
Images up to 1 MB and text, Markdown, CSV, HTML, XML, or JSON documents up to 350 KB can be attached by
using the paperclip, dropping files onto the composer, or pasting an image with `Cmd/Ctrl + V`. Attachment
previews stay local to the composer where they were added and show upload state, file type, and a removable image
@@ -192,7 +196,8 @@ the assistant does not require a second typed confirmation first. Tool cards exp
validated input, result, and error state without flooding the conversation with raw JSON by default.
Common collection and widget results are summarized inline as compact responsive cards, while the full
structured result remains available in the tool card details. Icon searches show the returned image variants,
file formats, and repositories instead of the size of the complete icon catalog.
file formats, and repositories instead of the size of the complete icon catalog. Their trace uses a readable
activity such as **Looking for “homarr” icons** instead of exposing the internal MCP procedure name.
Large tool-driven requests can use up to 32 agent steps and a larger response budget within one request, so
multi-item dashboard changes normally finish without requiring a follow-up “Continue” message. Independent
@@ -204,6 +209,10 @@ the native widget configuration form and standard mutation approval, then places
available dashboard position. The generated content is retained even though the Notebook content field is
normally hidden from widget settings.
Automatic placement checks every responsive layout and treats both widgets and complete container footprints
as occupied. New assistant-created items use the first free position in the main canvas instead of overlapping
existing content or being placed in a gutter.
Every assistant-created dashboard widget now passes through Homarr's native widget editor before it is added.
The form fills widget defaults, validates widget-specific options, and only offers compatible integrations that
the current user can use. Required integrations cannot be left empty, single-integration widgets cannot receive
+1 -1
View File
@@ -138,7 +138,7 @@ For example, a 12-column layout with a two-column left rail leaves ten columns f
Enabling or widening a rail reflows canvas items into the remaining columns.
Disabling a rail moves its contents back into the next available canvas positions.
Rails fill the available window height and remain fixed while the page scrolls.
Rails fill the available window height and remain fixed while the page scrolls. They do not grow or scroll independently.
Apps, widgets, and Containers inside them still use the same fixed `200 × 200` cells and can be rearranged in edit mode, where each rail shows its name and boundary.
#### Breakpoint
+1
View File
@@ -15,6 +15,7 @@
},
"dependencies": {
"@ai-sdk/openai-compatible": "catalog:",
"@ai-sdk/react": "catalog:",
"@assistant-ui/react": "catalog:",
"@assistant-ui/react-ai-sdk": "catalog:",
"@assistant-ui/react-lexical": "catalog:",
@@ -21,10 +21,7 @@
height: var(--board-gutter-viewport-height);
padding-top: 24px;
margin-top: -24px;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-gutter: stable;
overflow: clip;
}
.gutter[data-board-editing="true"] {
@@ -0,0 +1,103 @@
import { describe, expect, test, vi } from "vitest";
import { jsonSchema, simulateReadableStream, stepCountIs, streamText, tool } from "ai";
import { MockLanguageModelV4 } from "ai/test";
const usage = {
inputTokens: { total: 10, noCache: 10, cacheRead: 0, cacheWrite: 0 },
outputTokens: { total: 5, text: 5, reasoning: 0 },
};
const finish = (unified: "stop" | "tool-calls") => ({
type: "finish" as const,
finishReason: { unified, raw: unified },
usage,
});
describe("Assistant tool loop", () => {
test("keeps a valid icon search alongside another agent tool result", async () => {
const findIcons = vi.fn(() => ({
countIcons: 1,
icons: [
{
slug: "homarr-labs/dashboard-icons",
icons: [{ name: "homarr.svg", url: "https://cdn.example.com/homarr.svg" }],
},
],
}));
const getBoardSettings = vi.fn(() => ({ id: "board-1", name: "Home" }));
const model = new MockLanguageModelV4({
doStream: [
{
stream: simulateReadableStream({
chunks: [
{ type: "stream-start" as const, warnings: [] },
{
type: "tool-call" as const,
toolCallId: "icon-call",
toolName: "icon_findIcons",
input: '{"searchText":"homarr"}',
},
{
type: "tool-call" as const,
toolCallId: "board-call",
toolName: "board_getBoardSettings",
input: '{"id":"board-1"}',
},
finish("tool-calls"),
],
chunkDelayInMs: null,
}),
},
{
stream: simulateReadableStream({
chunks: [
{ type: "stream-start" as const, warnings: [] },
{ type: "text-start" as const, id: "answer" },
{ type: "text-delta" as const, id: "answer", delta: "Both tools completed." },
{ type: "text-end" as const, id: "answer" },
finish("stop"),
],
chunkDelayInMs: null,
}),
},
],
});
const result = streamText({
model,
prompt: "Find the Homarr icon and inspect my board.",
tools: {
icon_findIcons: tool({
inputSchema: jsonSchema({
type: "object",
properties: {
searchText: { type: "string" },
limitPerGroup: { type: "number", minimum: 1, maximum: 500, default: 12 },
},
additionalProperties: false,
}),
execute: findIcons,
}),
board_getBoardSettings: tool({
inputSchema: jsonSchema({
type: "object",
properties: { id: { type: "string" } },
required: ["id"],
additionalProperties: false,
}),
execute: getBoardSettings,
}),
},
stopWhen: stepCountIs(2),
});
await expect(result.text).resolves.toBe("Both tools completed.");
expect(findIcons).toHaveBeenCalledWith(
{ searchText: "homarr" },
expect.objectContaining({ toolCallId: "icon-call" }),
);
expect(getBoardSettings).toHaveBeenCalledTimes(1);
const nextPrompt = JSON.stringify(model.doStreamCalls[1]?.prompt);
expect(nextPrompt).toContain("homarr.svg");
expect(nextPrompt).toContain('"id":"board-1"');
});
});
@@ -55,4 +55,36 @@ describe("shouldAutomaticallyContinueAssistant", () => {
}),
).toBe(false);
});
test("continues a completed browser-tool step without treating an earlier agent step as user input", () => {
expect(
shouldAutomaticallyContinueAssistant({
messages: [
{
id: "assistant-message",
role: "assistant",
parts: [
{ type: "step-start" },
{
type: "dynamic-tool",
toolName: "icon_findIcons",
toolCallId: "icon-1",
input: { searchText: "homarr" },
state: "input-available",
},
{ type: "step-start" },
{
type: "dynamic-tool",
toolName: "refresh_current_view",
toolCallId: "refresh-1",
input: {},
state: "output-available",
output: { success: true },
},
],
},
],
}),
).toBe(true);
});
});
@@ -0,0 +1,69 @@
.assistantImage {
width: fit-content;
max-width: min(100%, 28rem);
margin: 0.65rem 0;
}
.assistantImageViewport {
position: relative;
display: grid;
min-width: min(12rem, 100%);
min-height: 6rem;
overflow: hidden;
place-items: center;
border: 1px solid var(--mantine-color-default-border);
border-radius: var(--mantine-radius-md);
background:
linear-gradient(45deg, var(--mantine-color-default) 25%, transparent 25%),
linear-gradient(-45deg, var(--mantine-color-default) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, var(--mantine-color-default) 75%),
linear-gradient(-45deg, transparent 75%, var(--mantine-color-default) 75%);
background-position:
0 0,
0 0.45rem,
0.45rem -0.45rem,
-0.45rem 0;
background-size: 0.9rem 0.9rem;
}
.assistantImageViewport > img {
display: block;
width: auto;
max-width: 100%;
height: auto;
max-height: 24rem;
margin: 0;
border-radius: 0;
object-fit: contain;
}
.assistantImageStatus {
position: absolute;
inset: 0;
min-height: 6rem;
color: var(--mantine-color-dimmed);
background: color-mix(in srgb, var(--mantine-color-body) 92%, transparent);
}
.assistantImageCaption {
max-width: 100%;
padding: 0.3rem 0.15rem 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
:global(td) .assistantImage {
width: 2rem;
margin: 0 auto;
}
:global(td) .assistantImageViewport,
:global(td) .assistantImageStatus {
min-width: 2rem;
min-height: 2rem;
}
:global(td) .assistantImageViewport {
border-radius: var(--mantine-radius-sm);
}
@@ -0,0 +1,83 @@
// @vitest-environment jsdom
import { act, createElement } from "react";
import { createRoot } from "react-dom/client";
import { MantineProvider } from "@mantine/core";
import { afterEach, beforeAll, describe, expect, test, vi } from "vitest";
import { AssistantImage } from "./assistant-image";
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const containers: HTMLDivElement[] = [];
beforeAll(() => {
Object.defineProperty(window, "matchMedia", {
configurable: true,
value: vi.fn().mockReturnValue({
matches: false,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
}),
});
});
afterEach(() => {
for (const container of containers.splice(0)) container.remove();
});
const renderImage = async () => {
const container = document.createElement("div");
containers.push(container);
document.body.append(container);
const root = createRoot(container);
await act(async () => {
root.render(
createElement(
MantineProvider,
undefined,
createElement(AssistantImage, {
source: "https://cdn.example.com/homarr.png",
alt: "Homarr icon",
loadingLabel: "Loading image",
failedLabel: "Image could not be loaded",
retryLabel: "Retry image",
}),
),
);
});
return { container, root };
};
describe("AssistantImage", () => {
test("shows an accessible loading state and reveals the responsive image after loading", async () => {
const { container, root } = await renderImage();
expect(container.querySelector("output")?.getAttribute("aria-label")).toBe("Loading image");
const image = container.querySelector("img");
expect(image?.getAttribute("alt")).toBe("Homarr icon");
expect(image?.getAttribute("referrerpolicy")).toBe("no-referrer");
await act(async () => image?.dispatchEvent(new Event("load")));
expect(container.querySelector("output")).toBeNull();
expect(container.querySelector("figure")?.getAttribute("data-image-state")).toBe("loaded");
await act(async () => root.unmount());
});
test("replaces a broken image with a recoverable error and retries it", async () => {
const { container, root } = await renderImage();
const firstImage = container.querySelector("img") as HTMLImageElement;
await act(async () => firstImage.dispatchEvent(new Event("error")));
expect(container.querySelector('[role="alert"]')?.textContent).toContain("Image could not be loaded");
const retry = Array.from(container.querySelectorAll("button")).find((button) =>
button.textContent?.includes("Retry image"),
);
await act(async () => retry?.click());
expect(container.querySelector('[role="alert"]')).toBeNull();
expect(container.querySelector("output")).not.toBeNull();
expect(container.querySelector("img")).not.toBe(firstImage);
await act(async () => root.unmount());
});
});
@@ -0,0 +1,97 @@
"use client";
import type { ComponentPropsWithoutRef } from "react";
import { useEffect, useState } from "react";
import { MessagePartPrimitive } from "@assistant-ui/react";
import { Box, Button, Center, Loader, Stack, Text } from "@mantine/core";
import { IconPhotoOff, IconRefresh } from "@tabler/icons-react";
import classes from "./assistant-image.module.css";
interface AssistantImageProps extends Omit<ComponentPropsWithoutRef<"img">, "src"> {
source: string;
messagePart?: boolean;
caption?: string;
loadingLabel: string;
failedLabel: string;
retryLabel: string;
}
/**
* A bounded image preview shared by Markdown and assistant-ui image message parts. Message parts
* deliberately render through MessagePartPrimitive.Image so their source remains connected to the
* assistant-ui part context; the sanitized source prop overrides the raw part value.
*/
export const AssistantImage = ({
source,
messagePart = false,
caption,
loadingLabel,
failedLabel,
retryLabel,
alt,
className,
...imageProps
}: AssistantImageProps) => {
const [attempt, setAttempt] = useState(0);
const [state, setState] = useState<"loading" | "loaded" | "failed">("loading");
useEffect(() => {
setState("loading");
setAttempt(0);
}, [source]);
const sharedProps = {
...imageProps,
src: source,
className,
loading: "lazy" as const,
decoding: "async" as const,
referrerPolicy: "no-referrer" as const,
onLoad: () => setState("loaded"),
onError: () => setState("failed"),
};
return (
<Box component="figure" className={classes.assistantImage} data-image-state={state}>
<Box className={classes.assistantImageViewport}>
{messagePart ? (
<MessagePartPrimitive.Image key={attempt} {...sharedProps} alt={alt} />
) : (
<img key={attempt} {...sharedProps} alt={alt} />
)}
{state === "loading" && (
<Center component="output" className={classes.assistantImageStatus} aria-label={loadingLabel}>
<Loader type="bars" size="sm" />
</Center>
)}
{state === "failed" && (
<Center className={classes.assistantImageStatus} role="alert">
<Stack align="center" gap={6} p="sm">
<IconPhotoOff size={22} aria-hidden />
<Text size="xs" ta="center">
{failedLabel}
</Text>
<Button
size="compact-xs"
variant="default"
leftSection={<IconRefresh size={13} aria-hidden />}
onClick={() => {
setState("loading");
setAttempt((value) => value + 1);
}}
>
{retryLabel}
</Button>
</Stack>
</Center>
)}
</Box>
{caption && state === "loaded" && (
<Text component="figcaption" className={classes.assistantImageCaption} size="xs" c="dimmed">
{caption}
</Text>
)}
</Box>
);
};
@@ -117,6 +117,15 @@
animation: assistant-panel-in 180ms cubic-bezier(0.22, 1, 0.36, 1);
}
.floatingPanel[data-maximized] {
inset: 1rem;
width: auto;
height: auto;
min-height: 0;
transform: none;
animation: none;
}
.widgetPanel {
container-name: assistant-widget;
container-type: size;
@@ -150,6 +159,7 @@
}
.panelHeader {
min-width: 0;
min-height: 3.75rem;
flex: 0 0 auto;
padding: 0.65rem 0.75rem 0.65rem 0.9rem;
@@ -166,9 +176,52 @@
}
.panelActions {
min-width: 0;
flex: 0 0 auto;
}
.windowControls {
flex: 0 0 auto;
margin: 0;
padding: 0;
border: 0;
}
.windowControl {
width: 1.75rem;
height: 1.75rem;
border: 0;
color: rgba(0, 0, 0, 0.72);
transition:
filter 160ms cubic-bezier(0.22, 1, 0.36, 1),
transform 160ms cubic-bezier(0.22, 1, 0.36, 1);
}
.windowControl:hover {
filter: brightness(0.94) saturate(1.08);
}
.windowControl:active {
transform: scale(0.92);
}
.windowControl:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: 2px;
}
.windowControlClose {
background: var(--mantine-color-red-6);
}
.windowControlMinimize {
background: var(--mantine-color-yellow-5);
}
.windowControlMaximize {
background: var(--mantine-color-green-6);
}
.panelAction {
width: 2.625rem;
height: 2.625rem;
@@ -322,12 +375,15 @@
min-height: 0;
flex: 1;
flex-direction: column;
overflow-x: hidden;
overflow-y: auto;
}
.messages {
width: 100%;
min-width: 0;
max-width: 44rem;
box-sizing: border-box;
margin-inline: auto;
padding: 0.75rem 1rem 1.5rem;
}
@@ -1276,6 +1332,7 @@
position: relative;
display: flex;
width: 100%;
min-width: 0;
max-width: none;
flex-direction: column;
align-items: stretch;
@@ -1368,11 +1425,15 @@
.composerFooter {
min-width: 0;
max-width: 100%;
overflow: hidden;
padding-inline: 0.2rem;
}
.composerControls {
min-width: 0;
max-width: 100%;
flex: 1 1 auto;
overflow: hidden;
}
@@ -1482,7 +1543,8 @@
inset-inline: 0;
bottom: calc(100% + 0.45rem);
max-height: min(22rem, 45dvh);
overflow: auto;
overflow-x: hidden;
overflow-y: auto;
padding: 0.35rem;
border: 1px solid var(--mantine-color-default-border);
border-radius: var(--mantine-radius-md);
@@ -1492,12 +1554,17 @@
.triggerList {
display: grid;
min-width: 0;
max-width: 100%;
gap: 0.15rem;
}
.triggerItem {
display: flex;
width: 100%;
min-width: 0;
max-width: 100%;
box-sizing: border-box;
min-height: 2.75rem;
align-items: center;
gap: 0.55rem;
@@ -1599,6 +1666,14 @@
min-height: min(20rem, calc(100dvh - 1rem - env(safe-area-inset-bottom, 0px)));
}
.floatingPanel[data-maximized] {
inset: 0;
width: 100vw;
height: 100dvh;
min-height: 0;
border-radius: 0;
}
.panelHeader {
min-height: 3.5rem;
padding: 0.5rem;
@@ -1642,6 +1717,14 @@
}
@media (max-width: 30em) {
.panelIdentity > :first-child {
display: none;
}
.panelIdentityText {
max-width: 7rem;
}
.historyButton {
width: 2.625rem;
max-width: 2.625rem;
@@ -1726,6 +1809,7 @@
@media (prefers-reduced-motion: reduce) {
.activityBar,
.floatingPanel,
.windowControl,
.disclosureIcon,
.reasoningChevron {
animation: none;
@@ -78,6 +78,7 @@ import {
IconApps,
IconArrowUp,
IconArrowsMaximize,
IconArrowsMinimize,
IconAt,
IconCheck,
IconChevronDown,
@@ -124,6 +125,7 @@ import classes from "./assistant-panel.module.css";
import { getAssistantActivityState } from "./assistant-activity-state";
import { useAssistantAutoApproval, useAssistantAutomaticAction } from "./assistant-auto-approval";
import { AssistantDotMatrix } from "./assistant-dot-matrix";
import { AssistantImage } from "./assistant-image";
import { getAssistantDirectiveTranslationKey, parseAssistantDirectives } from "./assistant-directives";
import { remarkAssistantDirectives, resolveAssistantDirectiveEntity } from "./assistant-markdown-directives";
import type { AssistantDirectiveEntity } from "./assistant-markdown-directives";
@@ -142,6 +144,7 @@ import type { AssistantReasoningMode, AssistantRuntimeModelOption } from "./assi
import { useAssistantReasoningState } from "./assistant-reasoning-state";
import { getNearestTriggerScrollTop } from "./assistant-trigger-scroll";
import { getToolResultPresentation } from "./assistant-tool-result";
import { getAssistantIconSearchQuery } from "./assistant-tool-label";
import { getAssistantToolTraceTarget } from "./assistant-tool-trace";
import { getSafeAssistantHttpUrl } from "./assistant-url";
@@ -267,18 +270,19 @@ const MarkdownTable = ({ children, ...props }: ComponentPropsWithoutRef<"table">
);
const MarkdownImage = ({ src, alt = "", ...props }: ComponentPropsWithoutRef<"img">) => {
const t = useScopedI18n("common.assistant.image");
const safeSource = getSafeAssistantMarkdownImageSource(src);
if (!safeSource) return null;
return (
<img
<AssistantImage
{...props}
src={safeSource}
source={safeSource}
alt={alt}
className={classes.markdownImage}
loading="lazy"
decoding="async"
referrerPolicy="no-referrer"
loadingLabel={t("loading")}
failedLabel={t("failed")}
retryLabel={t("retry")}
/>
);
};
@@ -623,7 +627,18 @@ const ImagePart = ({ image, filename }: ImageMessagePartProps) => {
</Group>
);
}
return <Box component="img" src={source} alt={filename ?? t("attachedImage")} className={classes.messageImage} />;
return (
<AssistantImage
messagePart
source={source}
alt={filename ?? t("attachedImage")}
caption={filename}
className={classes.messageImage}
loadingLabel={t("image.loading")}
failedLabel={t("image.failed")}
retryLabel={t("image.retry")}
/>
);
};
const formatToolResultValue = (value: string | number | boolean) =>
@@ -835,6 +850,13 @@ const ToolPart = ({
const successful = completed && !denied && !failed;
const compactPresentation = compact && !awaitingApproval;
const traceTarget = getAssistantToolTraceTarget(args);
const iconSearchQuery = getAssistantIconSearchQuery(toolName, args);
const displayName =
iconSearchQuery === null
? toolName.replaceAll("_", " ")
: iconSearchQuery.length > 0
? t("toolActivity.iconSearch", { query: iconSearchQuery })
: t("toolActivity.iconBrowse");
const duration = timing?.completedAt !== undefined ? Math.max(0, timing.completedAt - timing.startedAt) : undefined;
const autoApprovalInProgress = useAssistantAutomaticAction({
toolCallId,
@@ -865,7 +887,7 @@ const ToolPart = ({
</ThemeIcon>
<Box miw={0}>
<Text size={compactPresentation ? "xs" : "sm"} fw={650} lineClamp={1}>
{toolName.replaceAll("_", " ")}
{displayName}
</Text>
{compactPresentation && traceTarget && (
<Text size="xs" c="dimmed" lineClamp={1}>
@@ -2710,6 +2732,9 @@ interface AssistantConversationSurfaceProps extends AssistantConversationControl
pendingAction: AssistantPendingAction | undefined;
onExpand?: () => void;
onMinimize?: () => void;
onWindowClose?: () => void;
onToggleMaximize?: () => void;
maximized?: boolean;
}
export const AssistantConversationSurface = ({
@@ -2726,6 +2751,9 @@ export const AssistantConversationSurface = ({
onReasoningChange,
onExpand,
onMinimize,
onWindowClose,
onToggleMaximize,
maximized = false,
}: AssistantConversationSurfaceProps) => {
const t = useScopedI18n("common.assistant");
const reducedMotion = useReducedMotion();
@@ -2740,6 +2768,48 @@ export const AssistantConversationSurface = ({
return (
<>
<Group className={classes.panelHeader} justify="space-between" wrap="nowrap" gap="xs">
{onWindowClose && onMinimize && onToggleMaximize && (
<Group
component="fieldset"
className={classes.windowControls}
gap={4}
wrap="nowrap"
aria-label={t("windowControls")}
>
<Tooltip label={t("close")}>
<ActionIcon
className={`${classes.windowControl} ${classes.windowControlClose}`}
onClick={onWindowClose}
aria-label={t("close")}
>
<IconX size={12} stroke={2.4} />
</ActionIcon>
</Tooltip>
<Tooltip label={t("minimize")}>
<ActionIcon
className={`${classes.windowControl} ${classes.windowControlMinimize}`}
onClick={onMinimize}
aria-label={t("minimize")}
>
<IconMinus size={12} stroke={2.4} />
</ActionIcon>
</Tooltip>
<Tooltip label={maximized ? t("restore") : t("maximize")}>
<ActionIcon
className={`${classes.windowControl} ${classes.windowControlMaximize}`}
onClick={onToggleMaximize}
aria-label={maximized ? t("restore") : t("maximize")}
aria-pressed={maximized}
>
{maximized ? (
<IconArrowsMinimize size={11} stroke={2.4} />
) : (
<IconArrowsMaximize size={11} stroke={2.4} />
)}
</ActionIcon>
</Tooltip>
</Group>
)}
<Group className={classes.panelIdentity} gap="xs" wrap="nowrap">
<ThemeIcon variant="light" color="red" radius="xl">
<IconRobot size={18} />
@@ -2779,19 +2849,6 @@ export const AssistantConversationSurface = ({
</ActionIcon>
</Tooltip>
)}
{onMinimize && (
<Tooltip label={t("minimize")}>
<ActionIcon
className={classes.panelAction}
variant="subtle"
color="gray"
onClick={onMinimize}
aria-label={t("minimize")}
>
<IconMinus size={18} />
</ActionIcon>
</Tooltip>
)}
</Group>
</Group>
<AssistantQuestionPortalProvider target={questionPortalTarget}>
@@ -2868,6 +2925,7 @@ export const AssistantPanel = ({
}: AssistantPanelProps) => {
const t = useScopedI18n("common.assistant");
const previousFocusRef = useRef<HTMLElement | null>(null);
const [maximized, setMaximized] = useState(false);
useWindowEvent("keydown", (event) => {
if (opened && event.key === "Escape") onClose();
@@ -2907,7 +2965,7 @@ export const AssistantPanel = ({
/>
)}
{opened && (
<dialog className={classes.floatingPanel} aria-label={t("title")} open>
<dialog className={classes.floatingPanel} data-maximized={maximized || undefined} aria-label={t("title")} open>
<AssistantConversationSurface
isRunning={isRunning}
pendingAction={pendingAction}
@@ -2921,6 +2979,12 @@ export const AssistantPanel = ({
onReasoningChange={onReasoningChange}
autoFocusComposer
onMinimize={onClose}
onWindowClose={() => {
onDismissActivity();
onClose();
}}
onToggleMaximize={() => setMaximized((current) => !current)}
maximized={maximized}
/>
</dialog>
)}
@@ -12,7 +12,8 @@ import type {
Toolkit,
} from "@assistant-ui/react";
import { defineToolkit, useAui, useAuiEvent, useAuiState, useRemoteThreadListRuntime } from "@assistant-ui/react";
import { AssistantChatTransport, useChatRuntime } from "@assistant-ui/react-ai-sdk";
import { useChat } from "@ai-sdk/react";
import { AssistantChatTransport, useAISDKRuntime } from "@assistant-ui/react-ai-sdk";
import { useHotkeys } from "@mantine/hooks";
import { createAssistantStream } from "assistant-stream";
@@ -36,6 +37,7 @@ import { AssistantPanel } from "./assistant-panel";
import { getPendingAssistantAction } from "./assistant-pending-action";
import type { AssistantReasoningMode, AssistantRuntimeModelOption } from "./assistant-preferences";
import { resolveAssistantPreferenceModelId, resolveAssistantThreadPreferenceModelId } from "./assistant-preferences";
import { assistantAiSdkRuntimeOptions } from "./assistant-runtime-options";
import {
AssistantComposerSurfaceProvider,
AssistantRunFocusPreserver,
@@ -321,6 +323,8 @@ const createHistoryAdapter = (threadId: string | undefined): ThreadHistoryAdapte
const AssistantThreadRuntime = () => {
const t = useScopedI18n("common.assistant");
const aui = useAui();
const localThreadId = useAuiState((state) => state.threadListItem.id);
const threadId = useAuiState((state) => state.threadListItem.remoteId);
const preferences = useAssistantPreferences();
const transport = useMemo(
@@ -364,16 +368,25 @@ const AssistantThreadRuntime = () => {
[t],
);
return useChatRuntime<AssistantUIMessage>({
const chat = useChat<AssistantUIMessage>({
id: localThreadId,
transport,
onError,
sendAutomaticallyWhen: shouldAutomaticallyContinueAssistant,
});
const runtime = useAISDKRuntime(chat, {
...assistantAiSdkRuntimeOptions,
adapters: {
history,
attachments,
feedback,
},
});
transport.setRuntime(runtime);
// oxlint-disable-next-line no-underscore-dangle -- assistant-ui requires this hook to resolve the persisted remote thread before sending.
transport.__internal_setGetThreadListItem(() => (aui.threadListItem.source ? aui.threadListItem() : undefined));
return runtime;
};
const AssistantRuntime = ({ children }: PropsWithChildren) => {
@@ -0,0 +1,9 @@
import { describe, expect, test } from "vitest";
import { assistantAiSdkRuntimeOptions } from "./assistant-runtime-options";
describe("assistantAiSdkRuntimeOptions", () => {
test("preserves pending server tools across automatic agent continuations", () => {
expect(assistantAiSdkRuntimeOptions.cancelPendingToolCallsOnSend).toBe(false);
});
});
@@ -0,0 +1,5 @@
export const assistantAiSdkRuntimeOptions = {
// Automatic frontend-tool continuations are sends too. Letting assistant-ui cancel every
// pending tool on send incorrectly turns earlier server tools into user cancellations.
cancelPendingToolCallsOnSend: false,
} as const;
@@ -0,0 +1,14 @@
import { describe, expect, test } from "vitest";
import { getAssistantIconSearchQuery } from "./assistant-tool-label";
describe("getAssistantIconSearchQuery", () => {
test("extracts and trims the streamed icon search target", () => {
expect(getAssistantIconSearchQuery("icon_findIcons", { searchText: " homarr " })).toBe("homarr");
});
test("distinguishes browsing all icons from unrelated tools", () => {
expect(getAssistantIconSearchQuery("icon_findIcons", {})).toBe("");
expect(getAssistantIconSearchQuery("app_create", { searchText: "homarr" })).toBeNull();
});
});
@@ -0,0 +1,8 @@
const asRecord = (value: unknown): Record<string, unknown> | null =>
typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : null;
export const getAssistantIconSearchQuery = (toolName: string, args: unknown) => {
if (toolName !== "icon_findIcons") return null;
const searchText = asRecord(args)?.searchText;
return typeof searchText === "string" && searchText.trim().length > 0 ? searchText.trim() : "";
};
@@ -116,11 +116,6 @@ body[data-board-grid-interacting="resize"] .board-grid-entry[data-dnd-active="tr
pointer-events: none;
}
.board-grid-resize-outline[data-grid-resize-valid="false"] {
background: color-mix(in srgb, var(--mantine-color-red-filled) 8%, transparent);
border-color: var(--mantine-color-red-filled);
}
.board-grid-editor[data-dnd-drop-target="true"] {
outline: 2px solid color-mix(in srgb, var(--mantine-primary-color-filled) 60%, transparent);
outline-offset: 4px;
@@ -59,6 +59,7 @@ interface GridEditorProps {
sectionId: string;
columnCount: number;
rowCount: number;
maxRowCount: number | null;
placements: readonly SectionGridPlacement[];
className: string;
}
@@ -707,7 +708,14 @@ const GridCollisionSynchronizer = ({ interaction }: { interaction: GridInteracti
};
/** A section-local dnd-kit canvas registered with the board transaction. */
export default function GridEditor({ sectionId, columnCount, rowCount, placements, className }: GridEditorProps) {
export default function GridEditor({
sectionId,
columnCount,
rowCount,
maxRowCount,
placements,
className,
}: GridEditorProps) {
const t = useI18n();
const { section, items, innerSections } = useSectionContext();
const { interaction, registerGrid, getDepth, isDropTargetEligible } = useBoardGridEditor();
@@ -744,7 +752,7 @@ export default function GridEditor({ sectionId, columnCount, rowCount, placement
interaction && (interaction.sourceGridId === sectionId || interaction.targetGridId === sectionId);
const previewRowCount = getLayoutRowCount(previewGrid?.placements ?? renderPlacements);
const renderedRowCount =
section.kind !== "container" && participatesInInteraction
maxRowCount === null && participatesInInteraction
? Math.max(rowCount, previewRowCount) + (growsDuringDrag ? 1 : 0)
: rowCount;
@@ -775,14 +783,14 @@ export default function GridEditor({ sectionId, columnCount, rowCount, placement
return registerGrid({
id: sectionId,
columnCount,
maxRowCount: section.kind === "container" ? rowCount : null,
maxRowCount,
parentGridId,
ownerPlacementId,
placements,
depth,
element,
});
}, [columnCount, depth, ownerPlacementId, parentGridId, placements, registerGrid, rowCount, section.kind, sectionId]);
}, [columnCount, depth, maxRowCount, ownerPlacementId, parentGridId, placements, registerGrid, sectionId]);
useLayoutEffect(() => {
const viewport = gridRef.current?.parentElement;
@@ -824,7 +832,7 @@ export default function GridEditor({ sectionId, columnCount, rowCount, placement
placement={placement}
label={label}
columnCount={columnCount}
maxRowCount={section.kind === "container" ? rowCount : null}
maxRowCount={maxRowCount}
/>
);
})}
@@ -1044,6 +1052,7 @@ const GridResizeHandles = ({
y: number;
direction: GridResizeDirection;
visualScale: { x: number; y: number };
placement: SectionGridPlacement;
} | null>(null);
const start = (direction: GridResizeDirection) =>
@@ -1077,7 +1086,14 @@ const GridResizeHandles = ({
cancelResize();
return;
}
pointerStartRef.current = { id: event.pointerId, x: event.clientX, y: event.clientY, direction, visualScale };
pointerStartRef.current = {
id: event.pointerId,
x: event.clientX,
y: event.clientY,
direction,
visualScale,
placement: { ...placement },
};
};
const handlePointerMove = (event: ReactPointerEvent<HTMLSpanElement>) => {
@@ -1095,14 +1111,14 @@ const GridResizeHandles = ({
const valid = update(started.direction, snappedDelta.x, snappedDelta.y);
setContinuousResize({
placement: getContinuousResizePlacement({
placement,
placement: started.placement,
direction: started.direction,
deltaColumns: continuousDelta.x,
deltaRows: continuousDelta.y,
columnCount,
maxRowCount,
minWidth: placement.minW ?? 1,
minHeight: placement.minH ?? 1,
minWidth: started.placement.minW ?? 1,
minHeight: started.placement.minH ?? 1,
}),
valid,
});
@@ -150,6 +150,7 @@ export const SectionGrid = ({
const minimumViewportRowCount = useMinimumViewportRowCount(section.kind === "empty", canvasScale);
const contentRowCount = Math.max(1, getLayoutRowCount(displayPlacements));
const rowCount = Math.max(contentRowCount, requestedRowCount, minimumViewportRowCount);
const maxRowCount = section.kind === "container" || railPlacement !== "main" ? rowCount : null;
const logicalWidth = getLogicalTrackSize(columnCount);
const logicalHeight = getLogicalTrackSize(rowCount);
const canvasAttributes = isEditMode
@@ -164,7 +165,7 @@ export const SectionGrid = ({
innerSections: displayedInnerSections,
integrations,
columnCount,
maxRowCount: section.kind === "container" ? rowCount : null,
maxRowCount,
announce,
}}
>
@@ -182,6 +183,7 @@ export const SectionGrid = ({
sectionId={section.id}
columnCount={columnCount}
rowCount={rowCount}
maxRowCount={maxRowCount}
placements={displayPlacements}
className={combineClasses("board-grid-editor", classes.editorGrid)}
/>
+72 -1
View File
@@ -95,7 +95,8 @@ describe("Board grid", () => {
await expect(rail).toBeVisible();
await expect(rail).toHaveAttribute("data-board-gutter", "left");
await expect(railRootSection).toHaveAttribute("data-rail-placement", "left");
await expect(rail).toHaveCSS("overflow-x", "hidden");
await expect(rail).toHaveCSS("overflow-x", "clip");
await expect(rail).toHaveCSS("overflow-y", "clip");
const initialRailBox = await expectBoundingBoxAsync(rail);
const initialRailRootBox = await expectBoundingBoxAsync(railRootSection);
expect(initialRailRootBox.width).toBeCloseTo(initialRailBox.width, 1);
@@ -240,6 +241,7 @@ describe("Board grid", () => {
const viewMainSectionBox = await expectBoundingBoxAsync(mainSection);
const viewFirstItemBox = await expectBoundingBoxAsync(logicalTile);
const viewScrollY = await page.evaluate(() => window.scrollY);
runtimeResources.enterEditMode();
await editToggle.click();
await expect(page.locator(`[data-grid-runtime="${gridRuntimeMarker}"]`)).toHaveCount(3);
@@ -285,6 +287,39 @@ describe("Board grid", () => {
await page.mouse.up();
await expect.poll(async () => await readEditorGridSnapshotAsync(railGrid)).toEqual(railSnapshot);
await rail.locator('[data-e2e-rail-overflow-fixture="true"]').evaluate((element) => element.remove());
await rail.evaluate((element) => {
const overflowFixture = document.createElement("div");
overflowFixture.dataset.e2eRailOverflowFixture = "true";
overflowFixture.style.position = "absolute";
overflowFixture.style.insetBlockStart = "100%";
overflowFixture.style.width = "1px";
overflowFixture.style.height = "500px";
overflowFixture.style.pointerEvents = "none";
element.appendChild(overflowFixture);
});
await expect
.poll(async () => await rail.evaluate((element) => element.scrollHeight - element.clientHeight))
.toBeGreaterThan(0);
const fixedRailGridHeight = (await expectBoundingBoxAsync(railGrid)).height;
const fixedRailHeight = (await expectBoundingBoxAsync(rail)).height;
const verticalRailItemBox = await expectBoundingBoxAsync(railItem);
await page.mouse.move(
verticalRailItemBox.x + verticalRailItemBox.width / 2,
verticalRailItemBox.y + verticalRailItemBox.height / 2,
);
await page.mouse.down();
await page.mouse.move(verticalRailItemBox.x + verticalRailItemBox.width / 2 + 8, verticalRailItemBox.y);
await expect(railItem).toHaveAttribute("data-dnd-drag-source", "true");
await page.mouse.move(railBox.x + railBox.width / 2, railBox.y + railBox.height - 2, { steps: 12 });
await page.waitForTimeout(250);
expect(await rail.evaluate((element) => element.scrollTop)).toBe(0);
expect((await expectBoundingBoxAsync(rail)).height).toBeCloseTo(fixedRailHeight, 1);
expect((await expectBoundingBoxAsync(railGrid)).height).toBeCloseTo(fixedRailGridHeight, 1);
await page.keyboard.press("Escape");
await page.mouse.up();
await expect.poll(async () => await readEditorGridSnapshotAsync(railGrid)).toEqual(railSnapshot);
await rail.locator('[data-e2e-rail-overflow-fixture="true"]').evaluate((element) => element.remove());
await page.evaluate((scrollY) => window.scrollTo({ top: scrollY, behavior: "auto" }), viewScrollY);
await expect(logicalTile).toHaveCSS("overflow-x", "hidden");
await expect(logicalTile).toHaveCSS("overflow-y", "auto");
await logicalTile.evaluate((element) => {
@@ -767,6 +802,42 @@ describe("Board grid", () => {
await expect(eastResizeHandle).not.toHaveAttribute("data-grid-resize-disabled");
await resizeLocatorByImmediateReleaseAsync(page, eastResizeHandle, logicalCellPitch * canvasScale, 0);
await expect(containerSection).toHaveAttribute("data-grid-w", "2");
await expectGridEntryGeometrySettledAsync(containerSection);
await expect(eastResizeHandle).not.toHaveAttribute("data-grid-resize-disabled");
const expandingEastResizeBox = await expectBoundingBoxAsync(eastResizeHandle);
const expandingEastResizeStart = {
x: expandingEastResizeBox.x + expandingEastResizeBox.width / 2,
y: expandingEastResizeBox.y + expandingEastResizeBox.height / 2,
};
const expandingContainerBox = await expectBoundingBoxAsync(containerSection);
const eastHandleGrabOffset = expandingContainerBox.x + expandingContainerBox.width - expandingEastResizeStart.x;
const continuousExpansion = logicalCellPitch * canvasScale * 1.2;
await page.mouse.move(expandingEastResizeStart.x, expandingEastResizeStart.y);
await page.mouse.down();
await expect(page.locator("body")).toHaveAttribute("data-board-grid-interacting", "resize");
await page.mouse.move(expandingEastResizeStart.x + continuousExpansion, expandingEastResizeStart.y, {
steps: 12,
});
await expect(resizeOutline).toBeVisible();
const expandedOutlineBox = await expectBoundingBoxAsync(resizeOutline);
expect(expandedOutlineBox.x + expandedOutlineBox.width).toBeCloseTo(
expandingEastResizeStart.x + continuousExpansion + eastHandleGrabOffset,
1,
);
expect(
await resizeOutline.evaluate((element) => {
const probe = document.createElement("span");
probe.style.color = "var(--mantine-primary-color-filled)";
element.appendChild(probe);
const usesPrimaryColor = getComputedStyle(element).borderTopColor === getComputedStyle(probe).color;
probe.remove();
return usesPrimaryColor;
}),
).toBe(true);
await page.keyboard.press("Escape");
await page.mouse.up();
await expect(containerSection).toHaveAttribute("data-grid-w", "2");
const northResizeHandle = containerSection.locator(':scope > [data-grid-resize-handle="n"]');
await expectTargetSizeAsync(northResizeHandle);
+115 -95
View File
@@ -80,6 +80,50 @@ import {
} from "./board/custom-widget-placement-access";
import { validateTimetableOptionsChangeAsync } from "./widgets/timetable";
interface BoardItemPlacementRectangle {
xOffset: number;
yOffset: number;
width: number;
height: number;
}
const boardItemPlacementTails = new Map<string, Promise<void>>();
const serializeBoardItemPlacementAsync = async <T>(boardId: string, operation: () => Promise<T>) => {
const previous = boardItemPlacementTails.get(boardId) ?? Promise.resolve();
const { promise: current, resolve: release } = Promise.withResolvers<void>();
const tail = previous.then(() => current);
boardItemPlacementTails.set(boardId, tail);
await previous;
try {
return await operation();
} finally {
release();
if (boardItemPlacementTails.get(boardId) === tail) boardItemPlacementTails.delete(boardId);
}
};
const findFirstAvailableBoardItemPosition = (
placements: readonly BoardItemPlacementRectangle[],
columnCount: number,
size: { width: number; height: number },
) => {
for (let yOffset = 0; yOffset < 9999; yOffset++) {
for (let xOffset = 0; xOffset + size.width <= columnCount; xOffset++) {
const overlaps = placements.some(
(placement) =>
placement.yOffset < yOffset + size.height &&
placement.yOffset + placement.height > yOffset &&
placement.xOffset < xOffset + size.width &&
placement.xOffset + placement.width > xOffset,
);
if (!overlaps) return { xOffset, yOffset };
}
}
return null;
};
export const boardRouter = createTRPCRouter({
exists: permissionRequiredProcedure
.requiresPermission("board-create")
@@ -1816,7 +1860,7 @@ export const boardRouter = createTRPCRouter({
mcp: {
enabled: true,
description:
"Add a widget/app item to a board after configure_widget has reviewed it. Automatically places it in the first empty section at the next free grid position. Use the configure_widget result's boardId, kind, options, and integrationIds exactly. Integration IDs must be accessible to the current user. To create a formatted dashboard note, configure kind 'notebook' with options { content: Tiptap-compatible HTML, showToolbar: boolean, allowReadOnlyCheck: boolean }. Returns { itemId }",
"Add a widget/app item to a board after configure_widget has reviewed it. Automatically places it in the main canvas at the first free grid position without overlapping items or containers. Use the configure_widget result's boardId, kind, options, and integrationIds exactly. Integration IDs must be accessible to the current user. To create a formatted dashboard note, configure kind 'notebook' with options { content: Tiptap-compatible HTML, showToolbar: boolean, allowReadOnlyCheck: boolean }. Returns { itemId }",
},
})
.input(addItemToBoardSchema)
@@ -1878,109 +1922,85 @@ export const boardRouter = createTRPCRouter({
}
}
const board = await ctx.db.query.boards.findFirst({
where: eq(boards.id, input.boardId),
with: {
sections: true,
layouts: true,
items: { with: { layouts: true } },
},
});
return await serializeBoardItemPlacementAsync(input.boardId, async () => {
const board = await ctx.db.query.boards.findFirst({
where: eq(boards.id, input.boardId),
with: {
sections: { with: { layouts: true } },
layouts: true,
items: { with: { layouts: true } },
},
});
if (!board) {
throw new TRPCError({ code: "NOT_FOUND", message: "Board not found" });
}
const emptySection = board.sections
.filter((s) => s.kind === "empty")
.toSorted((a, b) => (a.yOffset ?? 0) - (b.yOffset ?? 0))[0];
if (!emptySection) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Board has no empty section to place items in" });
}
const itemId = createId();
await ctx.db.insert(items).values({
id: itemId,
boardId: input.boardId,
kind: input.kind,
options: superjson.stringify(input.options),
advancedOptions: emptySuperJSON,
});
const layoutRows: (typeof itemLayouts.$inferInsert)[] = [];
for (const layout of board.layouts) {
const existingInSection = board.items
.flatMap((item) => item.layouts)
.filter((il) => il.sectionId === emptySection.id && il.layoutId === layout.id);
const occupied: boolean[][] = [];
for (const il of existingInSection) {
for (let y = il.yOffset; y < il.yOffset + il.height; y++) {
while (occupied.length <= y) occupied.push(Array.from<boolean>({ length: layout.columnCount }).fill(false));
const occupiedRow = occupied[y];
if (!occupiedRow) continue;
for (let x = il.xOffset; x < il.xOffset + il.width; x++) {
occupiedRow[x] = true;
}
}
if (!board) {
throw new TRPCError({ code: "NOT_FOUND", message: "Board not found" });
}
const emptySection = board.sections
.filter((section) => section.kind === "empty" && getRootSectionLane(section.xOffset) === "main")
.toSorted(
(sectionA, sectionB) =>
(sectionA.yOffset ?? 0) - (sectionB.yOffset ?? 0) || sectionA.id.localeCompare(sectionB.id),
)[0];
if (!emptySection) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Board has no main section to place items in" });
}
const itemId = createId();
const defaultSize = widgetDefaultSizes[input.kind as WidgetKind] ?? { width: 1, height: 1 };
const sizeForLayout = { ...defaultSize, width: Math.min(layout.columnCount, defaultSize.width) };
const layoutRows: (typeof itemLayouts.$inferInsert)[] = board.layouts.map((layout) => {
const columnCount = getBoardLaneColumnCount(layout, getRootSectionLane(emptySection.xOffset));
const size = { ...defaultSize, width: Math.min(columnCount, defaultSize.width) };
const itemPlacements = board.items
.flatMap((item) => item.layouts)
.filter((itemLayout) => itemLayout.sectionId === emptySection.id && itemLayout.layoutId === layout.id);
const containerPlacements = board.sections
.filter((section) => section.kind === "container")
.flatMap((section) => section.layouts)
.filter(
(sectionLayout) =>
sectionLayout.parentSectionId === emptySection.id && sectionLayout.layoutId === layout.id,
);
const position = findFirstAvailableBoardItemPosition(
[...itemPlacements, ...containerPlacements],
columnCount,
size,
);
const fitsAt = (x: number, y: number) => {
if (x + sizeForLayout.width > layout.columnCount) return false;
for (let dy = 0; dy < sizeForLayout.height; dy++) {
const row = occupied[y + dy];
if (!row) continue;
for (let dx = 0; dx < sizeForLayout.width; dx++) {
if (row[x + dx]) return false;
}
if (!position) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Board section is full, no free grid position available",
});
}
return true;
};
let placed = false;
for (let y = 0; y < 9999 && !placed; y++) {
if (!occupied[y]) occupied.push(Array.from<boolean>({ length: layout.columnCount }).fill(false));
for (let x = 0; x < layout.columnCount && !placed; x++) {
if (fitsAt(x, y)) {
layoutRows.push({
itemId,
sectionId: emptySection.id,
layoutId: layout.id,
xOffset: x,
yOffset: y,
width: sizeForLayout.width,
height: sizeForLayout.height,
});
placed = true;
}
}
return {
itemId,
sectionId: emptySection.id,
layoutId: layout.id,
...position,
width: size.width,
height: size.height,
};
});
await ctx.db.insert(items).values({
id: itemId,
boardId: input.boardId,
kind: input.kind,
options: superjson.stringify(input.options),
advancedOptions: emptySuperJSON,
});
if (layoutRows.length > 0) await ctx.db.insert(itemLayouts).values(layoutRows);
if (input.integrationIds.length > 0) {
await ctx.db
.insert(integrationItems)
.values(input.integrationIds.map((integrationId) => ({ itemId, integrationId })));
}
if (!placed) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Board section is full, no free grid position available",
});
}
}
if (layoutRows.length > 0) {
await ctx.db.insert(itemLayouts).values(layoutRows);
}
if (input.integrationIds.length > 0) {
await ctx.db
.insert(integrationItems)
.values(input.integrationIds.map((integrationId) => ({ itemId, integrationId })));
}
return { itemId };
return { itemId };
});
}),
});
@@ -5,7 +5,17 @@ import { describe, expect, test } from "vitest";
import type { Session } from "@homarr/auth";
import { createId } from "@homarr/common";
import { boards, integrations, integrationUserPermissions, items, layouts, sections, users } from "@homarr/db/schema";
import { eq as dbEq } from "@homarr/db";
import {
boards,
integrations,
integrationUserPermissions,
items,
layouts,
sectionLayouts,
sections,
users,
} from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
import { boardRouter } from "../board";
@@ -16,19 +26,28 @@ const createBoardCallerAsync = async () => {
const boardId = createId();
await db.insert(users).values({ id: userId });
await db.insert(boards).values({ id: boardId, name: "Home", creatorId: userId });
const layoutId = createId();
await db.insert(layouts).values({
id: createId(),
id: layoutId,
name: "Base",
boardId,
columnCount: 12,
breakpoint: 0,
});
await db.insert(sections).values({ id: createId(), boardId, kind: "empty", xOffset: 0, yOffset: 0 });
const sectionId = createId();
await db.insert(sections).values({ id: sectionId, boardId, kind: "empty", xOffset: 0, yOffset: 0 });
const session = {
user: { id: userId, permissions: [], colorScheme: "light" },
expires: new Date().toISOString(),
} satisfies Session;
return { db, userId, boardId, caller: boardRouter.createCaller({ db, deviceType: undefined, session }) };
return {
db,
userId,
boardId,
layoutId,
sectionId,
caller: boardRouter.createCaller({ db, deviceType: undefined, session }),
};
};
describe("board.addItem", () => {
@@ -52,6 +71,97 @@ describe("board.addItem", () => {
});
});
test("places a new item after the complete footprint of an existing container", async () => {
const { db, boardId, caller, layoutId, sectionId } = await createBoardCallerAsync();
const mobileLayoutId = createId();
await db.insert(layouts).values({
id: mobileLayoutId,
name: "Mobile",
boardId,
columnCount: 4,
breakpoint: 640,
});
const containerId = createId();
await db.insert(sections).values({ id: containerId, boardId, kind: "container" });
await db.insert(sectionLayouts).values([
{
sectionId: containerId,
parentSectionId: sectionId,
layoutId,
xOffset: 0,
yOffset: 0,
width: 4,
height: 3,
},
{
sectionId: containerId,
parentSectionId: sectionId,
layoutId: mobileLayoutId,
xOffset: 0,
yOffset: 0,
width: 2,
height: 1,
},
]);
const result = await caller.addItem({ boardId, kind: "app", options: {}, integrationIds: [] });
const placements = await db.query.itemLayouts.findMany({
where: (table, { eq }) => eq(table.itemId, result.itemId),
});
expect(placements).toEqual(
expect.arrayContaining([
expect.objectContaining({ layoutId, xOffset: 4, yOffset: 0, width: 1, height: 1 }),
expect.objectContaining({ layoutId: mobileLayoutId, xOffset: 2, yOffset: 0, width: 1, height: 1 }),
]),
);
});
test("uses the main section width instead of placing into gutter columns", async () => {
const { db, boardId, caller, layoutId, sectionId } = await createBoardCallerAsync();
await db
.update(layouts)
.set({ leftGutterColumnCount: 2, rightGutterColumnCount: 2 })
.where(dbEq(layouts.id, layoutId));
const containerId = createId();
await db.insert(sections).values({ id: containerId, boardId, kind: "container" });
await db.insert(sectionLayouts).values({
sectionId: containerId,
parentSectionId: sectionId,
layoutId,
xOffset: 0,
yOffset: 0,
width: 8,
height: 1,
});
const result = await caller.addItem({ boardId, kind: "app", options: {}, integrationIds: [] });
const placement = await db.query.itemLayouts.findFirst({
where: (table, { eq }) => eq(table.itemId, result.itemId),
});
expect(placement).toMatchObject({ xOffset: 0, yOffset: 1 });
});
test("serializes concurrent automatic placements on the same board", async () => {
const { db, boardId, caller } = await createBoardCallerAsync();
const results = await Promise.all([
caller.addItem({ boardId, kind: "app", options: {}, integrationIds: [] }),
caller.addItem({ boardId, kind: "app", options: {}, integrationIds: [] }),
]);
const placements = await db.query.itemLayouts.findMany({
where: (table, { inArray }) =>
inArray(
table.itemId,
results.map((result) => result.itemId),
),
});
expect(placements).toHaveLength(2);
expect(new Set(placements.map((placement) => `${placement.xOffset}:${placement.yOffset}`)).size).toBe(2);
});
test("accepts an integration the current user can use", async () => {
const { db, userId, boardId, caller } = await createBoardCallerAsync();
const integrationId = createId();
+12
View File
@@ -2018,6 +2018,9 @@
"open": "Open Homarr Assistant",
"close": "Close assistant",
"minimize": "Minimize assistant",
"maximize": "Maximize assistant",
"restore": "Restore assistant size",
"windowControls": "Conversation window controls",
"spotlight": "Ask Homarr Assistant",
"spotlightDescription": "Type and send a prompt without leaving the command menu.",
"spotlightPrompt": {
@@ -2093,6 +2096,11 @@
"downloadFile": "Download {name}",
"attachedImage": "Attached image",
"externalImageBlocked": "External image blocked for privacy",
"image": {
"loading": "Loading image",
"failed": "This image could not be loaded.",
"retry": "Try image again"
},
"quoteSelection": "Quote",
"dismissQuote": "Remove quote",
"attachments": {
@@ -2168,6 +2176,10 @@
},
"toolInput": "Input",
"toolOutput": "Output",
"toolActivity": {
"iconSearch": "Looking for “{query}” icons",
"iconBrowse": "Browsing available icons"
},
"toolNavigation": {
"openPreview": "Open preview",
"openWidget": "Open widget"
+6
View File
@@ -9,6 +9,9 @@ catalogs:
'@ai-sdk/openai-compatible':
specifier: 3.0.14
version: 3.0.14
'@ai-sdk/react':
specifier: ^4.0.55
version: 4.0.55
'@assistant-ui/react':
specifier: 0.14.27
version: 0.14.27
@@ -945,6 +948,9 @@ importers:
'@ai-sdk/openai-compatible':
specifier: 'catalog:'
version: 3.0.14(zod@4.4.3)
'@ai-sdk/react':
specifier: 'catalog:'
version: 4.0.55(react@19.2.7)(zod@4.4.3)
'@assistant-ui/react':
specifier: 'catalog:'
version: 0.14.27(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(immer@11.1.9)(ioredis@5.11.1(supports-color@10.2.2))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7))
+1
View File
@@ -3,6 +3,7 @@ packages:
- packages/*
- tooling/*
catalog:
"@ai-sdk/react": ^4.0.55
"@babel/parser": ^7.29.7
"@codemirror/autocomplete": ^6.20.3
"@codemirror/commands": ^6.10.3