Merge feat/homarr-assistant into release/v2
This commit is contained in:
25 files changed
+967
-146
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
|
||||
Generated
+6
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user