diff --git a/apps/docs/docs/management/assistant.mdx b/apps/docs/docs/management/assistant.mdx index db5115118..2a9f038ba 100644 --- a/apps/docs/docs/management/assistant.mdx +++ b/apps/docs/docs/management/assistant.mdx @@ -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 diff --git a/apps/docs/docs/management/boards/index.mdx b/apps/docs/docs/management/boards/index.mdx index cbd4bf483..f28bd2c2b 100644 --- a/apps/docs/docs/management/boards/index.mdx +++ b/apps/docs/docs/management/boards/index.mdx @@ -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 diff --git a/apps/nextjs/package.json b/apps/nextjs/package.json index d58933e42..b1dc44c4d 100644 --- a/apps/nextjs/package.json +++ b/apps/nextjs/package.json @@ -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:", diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_client.module.css b/apps/nextjs/src/app/[locale]/boards/(content)/_client.module.css index 8514e86d9..00ca097b5 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_client.module.css +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_client.module.css @@ -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"] { diff --git a/apps/nextjs/src/app/api/assistant/chat/assistant-tool-loop.spec.ts b/apps/nextjs/src/app/api/assistant/chat/assistant-tool-loop.spec.ts new file mode 100644 index 000000000..9516170b9 --- /dev/null +++ b/apps/nextjs/src/app/api/assistant/chat/assistant-tool-loop.spec.ts @@ -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"'); + }); +}); diff --git a/apps/nextjs/src/components/assistant/assistant-auto-submit.spec.ts b/apps/nextjs/src/components/assistant/assistant-auto-submit.spec.ts index fadf9f11a..8ab6827ba 100644 --- a/apps/nextjs/src/components/assistant/assistant-auto-submit.spec.ts +++ b/apps/nextjs/src/components/assistant/assistant-auto-submit.spec.ts @@ -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); + }); }); diff --git a/apps/nextjs/src/components/assistant/assistant-image.module.css b/apps/nextjs/src/components/assistant/assistant-image.module.css new file mode 100644 index 000000000..1d284f3ad --- /dev/null +++ b/apps/nextjs/src/components/assistant/assistant-image.module.css @@ -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); +} diff --git a/apps/nextjs/src/components/assistant/assistant-image.spec.tsx b/apps/nextjs/src/components/assistant/assistant-image.spec.tsx new file mode 100644 index 000000000..46f5655eb --- /dev/null +++ b/apps/nextjs/src/components/assistant/assistant-image.spec.tsx @@ -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()); + }); +}); diff --git a/apps/nextjs/src/components/assistant/assistant-image.tsx b/apps/nextjs/src/components/assistant/assistant-image.tsx new file mode 100644 index 000000000..c0da2b5d8 --- /dev/null +++ b/apps/nextjs/src/components/assistant/assistant-image.tsx @@ -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, "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 ( + + + {messagePart ? ( + + ) : ( + {alt} + )} + {state === "loading" && ( +
+ +
+ )} + {state === "failed" && ( +
+ + + + {failedLabel} + + + +
+ )} +
+ {caption && state === "loaded" && ( + + {caption} + + )} +
+ ); +}; diff --git a/apps/nextjs/src/components/assistant/assistant-panel.module.css b/apps/nextjs/src/components/assistant/assistant-panel.module.css index a19b4da2b..2de291799 100644 --- a/apps/nextjs/src/components/assistant/assistant-panel.module.css +++ b/apps/nextjs/src/components/assistant/assistant-panel.module.css @@ -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; diff --git a/apps/nextjs/src/components/assistant/assistant-panel.tsx b/apps/nextjs/src/components/assistant/assistant-panel.tsx index 4aec558cf..a482eb7c9 100644 --- a/apps/nextjs/src/components/assistant/assistant-panel.tsx +++ b/apps/nextjs/src/components/assistant/assistant-panel.tsx @@ -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 ( - {alt} ); }; @@ -623,7 +627,18 @@ const ImagePart = ({ image, filename }: ImageMessagePartProps) => { ); } - return ; + return ( + + ); }; 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 = ({ - {toolName.replaceAll("_", " ")} + {displayName} {compactPresentation && traceTarget && ( @@ -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 ( <> + {onWindowClose && onMinimize && onToggleMaximize && ( + + + + + + + + + + + + + + {maximized ? ( + + ) : ( + + )} + + + + )} @@ -2779,19 +2849,6 @@ export const AssistantConversationSurface = ({ )} - {onMinimize && ( - - - - - - )} @@ -2868,6 +2925,7 @@ export const AssistantPanel = ({ }: AssistantPanelProps) => { const t = useScopedI18n("common.assistant"); const previousFocusRef = useRef(null); + const [maximized, setMaximized] = useState(false); useWindowEvent("keydown", (event) => { if (opened && event.key === "Escape") onClose(); @@ -2907,7 +2965,7 @@ export const AssistantPanel = ({ /> )} {opened && ( - + { + onDismissActivity(); + onClose(); + }} + onToggleMaximize={() => setMaximized((current) => !current)} + maximized={maximized} /> )} diff --git a/apps/nextjs/src/components/assistant/assistant-provider.tsx b/apps/nextjs/src/components/assistant/assistant-provider.tsx index 8d4b29079..bfb033a41 100644 --- a/apps/nextjs/src/components/assistant/assistant-provider.tsx +++ b/apps/nextjs/src/components/assistant/assistant-provider.tsx @@ -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({ + const chat = useChat({ + 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) => { diff --git a/apps/nextjs/src/components/assistant/assistant-runtime-options.spec.ts b/apps/nextjs/src/components/assistant/assistant-runtime-options.spec.ts new file mode 100644 index 000000000..bc5bdaebb --- /dev/null +++ b/apps/nextjs/src/components/assistant/assistant-runtime-options.spec.ts @@ -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); + }); +}); diff --git a/apps/nextjs/src/components/assistant/assistant-runtime-options.ts b/apps/nextjs/src/components/assistant/assistant-runtime-options.ts new file mode 100644 index 000000000..77e176905 --- /dev/null +++ b/apps/nextjs/src/components/assistant/assistant-runtime-options.ts @@ -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; diff --git a/apps/nextjs/src/components/assistant/assistant-tool-label.spec.ts b/apps/nextjs/src/components/assistant/assistant-tool-label.spec.ts new file mode 100644 index 000000000..d67cc54f3 --- /dev/null +++ b/apps/nextjs/src/components/assistant/assistant-tool-label.spec.ts @@ -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(); + }); +}); diff --git a/apps/nextjs/src/components/assistant/assistant-tool-label.ts b/apps/nextjs/src/components/assistant/assistant-tool-label.ts new file mode 100644 index 000000000..089e43765 --- /dev/null +++ b/apps/nextjs/src/components/assistant/assistant-tool-label.ts @@ -0,0 +1,8 @@ +const asRecord = (value: unknown): Record | null => + typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record) : 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() : ""; +}; diff --git a/apps/nextjs/src/components/board/sections/grid/grid-editor.css b/apps/nextjs/src/components/board/sections/grid/grid-editor.css index 8ede12b64..457a04d29 100644 --- a/apps/nextjs/src/components/board/sections/grid/grid-editor.css +++ b/apps/nextjs/src/components/board/sections/grid/grid-editor.css @@ -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; diff --git a/apps/nextjs/src/components/board/sections/grid/grid-editor.tsx b/apps/nextjs/src/components/board/sections/grid/grid-editor.tsx index ea80787b3..ff186c583 100644 --- a/apps/nextjs/src/components/board/sections/grid/grid-editor.tsx +++ b/apps/nextjs/src/components/board/sections/grid/grid-editor.tsx @@ -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) => { @@ -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, }); diff --git a/apps/nextjs/src/components/board/sections/grid/section-grid.tsx b/apps/nextjs/src/components/board/sections/grid/section-grid.tsx index ab1b1fb4b..664ea2d15 100644 --- a/apps/nextjs/src/components/board/sections/grid/section-grid.tsx +++ b/apps/nextjs/src/components/board/sections/grid/section-grid.tsx @@ -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)} /> diff --git a/e2e/board-grid.spec.ts b/e2e/board-grid.spec.ts index 8a5926205..c6336e7e9 100644 --- a/e2e/board-grid.spec.ts +++ b/e2e/board-grid.spec.ts @@ -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); diff --git a/packages/api/src/router/board.ts b/packages/api/src/router/board.ts index 8b5ec550f..9e3bc515d 100644 --- a/packages/api/src/router/board.ts +++ b/packages/api/src/router/board.ts @@ -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>(); + +const serializeBoardItemPlacementAsync = async (boardId: string, operation: () => Promise) => { + const previous = boardItemPlacementTails.get(boardId) ?? Promise.resolve(); + const { promise: current, resolve: release } = Promise.withResolvers(); + 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({ 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({ 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 }; + }); }), }); diff --git a/packages/api/src/router/test/board-add-item.spec.ts b/packages/api/src/router/test/board-add-item.spec.ts index 4184c2b5d..089bc0c78 100644 --- a/packages/api/src/router/test/board-add-item.spec.ts +++ b/packages/api/src/router/test/board-add-item.spec.ts @@ -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(); diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index c2b915fab..419922fec 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -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" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5ac64710a..7c4a1d3e3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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)) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 237643ffa..4ee9a6a02 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -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