feat: add assistant conversation widget

This commit is contained in:
ajnart committed 2026-08-13 19:56:26 +02:00
1 parent c7b0c1bcf0
commit 00fa1192d7
17 files changed
+779 -93

No files matched your search

+4
View File
@@ -24,6 +24,10 @@ conversation has a minimize action that returns it to the activity bar without i
`Shift + A` opens the compact conversation panel directly. The shortcut is ignored while typing in text
fields, selectors, and editable content.
The **Assistant** board widget embeds the same persisted conversation and full interaction surface directly
on a dashboard. By default it follows the current conversation. Widget settings can instead pin a specific
conversation; the conversation picker stays hidden until **Pinned conversation** is selected.
When the assistant is unavailable, its command-menu result remains visible but disabled and explains that
an administrator must configure and enable it.
@@ -0,0 +1,54 @@
---
title: "Assistant"
description: "Have a complete assistant conversation directly on a board."
hide_title: true
---
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
import { WidgetHeader } from "@site/src/components/widgets/header";
import { assistantWidget } from ".";
<WidgetHeader widget={assistantWidget} categories={["Productivity"]} />
The Assistant widget brings the same conversation available from the user menu and command menu onto a
board. Messages, conversations, tool calls, approvals, files, mentions, model controls, reasoning controls,
usage details, and response actions remain synchronized with the app-wide assistant.
The assistant must be enabled by an administrator in **Management → Assistant**. Signed-out users and users
on an instance without an enabled model see an unavailable state instead of a broken composer.
### Adding the widget
<AddingWidget />
The default widget size is ten columns by four rows and automatically fits boards with fewer columns. Controls
and labels condense as the widget becomes narrower, while the conversation remains scrollable and usable on
touch devices. When a board layout does not provide enough height for conversation controls, the widget shows
a compact **Open assistant** action instead of squeezing an unusable composer into the card. Increase its height
in that layout to keep the complete conversation inline.
### Configuration
<WidgetConfig configuration={assistantWidget.configuration} />
**Current conversation** follows whichever conversation is active in the user-menu panel, `Shift + A`, or
the command menu. This is the default and keeps every assistant entry point synchronized.
**Pinned conversation** reveals a searchable conversation selector. A pinned widget never changes the
active conversation silently: select **Open conversation** inside the widget before continuing that thread.
### Conversation features
- Create, switch, rename, and delete conversations.
- Send text and supported file or image attachments.
- Mention permitted apps, integrations, boards, widgets, and tools with `@`.
- Use `/` shortcuts for common dashboard tasks.
- Review tool calls, approve mutations, and answer structured assistant questions inline.
- Edit user messages and copy, regenerate, branch, quote, export, or rate responses.
- Inspect Markdown, sources, generated files, tool results, reasoning, token use, context-window use, timing,
speed, provider routing, and cost.
- Change the model and reasoning effort for the active conversation.
The expanded assistant and board widget use the same persisted thread runtime. A response continues in the
background when the widget leaves the viewport or the expanded panel is minimized.
+25
View File
@@ -0,0 +1,25 @@
import type { WidgetDefinition } from "@site/src/types";
import { IconRobot } from "@tabler/icons-react";
export const assistantWidget: WidgetDefinition = {
icon: IconRobot,
name: "Assistant",
description: "Have a complete assistant conversation directly on a board.",
path: "../../widgets/assistant",
configuration: {
items: [
{
name: "Conversation",
description: "Follow the active conversation or attach the widget to one persisted conversation.",
values: ["Current conversation", "Pinned conversation"],
defaultValue: "Current conversation",
},
{
name: "Pinned conversation",
description: "Search for a conversation. This field is hidden when Current conversation is selected.",
values: { type: "string" },
defaultValue: "Hidden",
},
],
},
};
@@ -114,6 +114,38 @@
animation: assistant-panel-in 180ms cubic-bezier(0.22, 1, 0.36, 1);
}
.widgetPanel {
container-name: assistant-widget;
container-type: size;
display: flex;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
flex-direction: column;
overflow: hidden;
color: var(--mantine-color-text);
}
.widgetState {
container-type: inline-size;
overflow: auto;
}
.compactState {
width: 100%;
height: 100%;
padding: 0.75rem;
}
.compactCopy {
min-width: 0;
}
.widgetPanel[data-editing] .panelActions {
padding-inline-end: 2.75rem;
}
.panelHeader {
min-height: 3.75rem;
flex: 0 0 auto;
@@ -139,6 +171,120 @@
height: 2.625rem;
}
@container assistant-widget (max-width: 30rem) {
.panelHeader {
min-height: 3.25rem;
padding: 0.45rem 0.5rem 0.45rem 0.65rem;
}
.panelIdentityText > :last-child,
.composerHints {
display: none;
}
.historyButton {
width: 2.625rem;
max-width: 2.625rem;
height: 2.625rem;
padding-inline: 0.75rem;
}
.historyButtonSection {
margin-inline-end: 0;
}
.historyButtonLabel {
display: none;
}
.messages {
padding: 0.5rem 0.65rem 1rem;
}
.composerWrap {
padding: 0.45rem;
}
.composer {
gap: 0.25rem;
padding: 0.25rem 0.3rem;
}
.composerInput {
min-height: 2.625rem;
padding: 0.6rem 0.5rem;
}
.runtimeButtonGroup,
.runtimeControls {
width: 100%;
}
.modelButton {
min-width: 0;
max-width: none;
}
.reasoningButton {
min-width: 2.5rem;
max-width: 7.75rem;
}
.suggestions {
grid-template-columns: 1fr;
}
}
@container assistant-widget (max-width: 22rem) {
.panelIdentityText {
display: none;
}
.panelActions {
margin-inline-start: auto;
}
.panelAction,
.historyButton {
width: 2.25rem;
max-width: 2.25rem;
height: 2.25rem;
padding-inline: 0.55rem;
}
.composerFooter {
overflow: hidden;
}
.runtimeButton {
padding-inline: 0.5rem;
}
}
@container assistant-widget (max-width: 18rem) {
.compactDescription {
display: none;
}
}
@container assistant-widget (max-width: 10rem) {
.compactTitle {
display: none;
}
}
@container assistant-widget (max-height: 24rem) {
.empty {
min-height: 16rem;
padding-block: 1rem;
}
.suggestion {
min-height: 2.5rem;
padding-block: 0.45rem;
}
}
.historyMenu {
background: var(--mantine-color-body);
}
@@ -59,6 +59,7 @@ import {
IconAlertTriangle,
IconApps,
IconArrowUp,
IconArrowsMaximize,
IconAt,
IconBrain,
IconCheck,
@@ -105,7 +106,16 @@ import type { AssistantReasoningMode, AssistantRuntimeModelOption } from "./assi
import { getNearestTriggerScrollTop } from "./assistant-trigger-scroll";
import { getToolResultPresentation } from "./assistant-tool-result";
interface AssistantPanelProps {
export interface AssistantConversationControls {
modelId: string | null;
models: AssistantRuntimeModelOption[];
modelOptionsLoading: boolean;
reasoning: AssistantReasoningMode;
onModelChange: (modelId: string) => void;
onReasoningChange: (reasoning: AssistantReasoningMode) => void;
}
interface AssistantPanelProps extends AssistantConversationControls {
opened: boolean;
onOpen: () => void;
onClose: () => void;
@@ -117,12 +127,6 @@ interface AssistantPanelProps {
latestUserText: string;
latestStatus: MessageStatus | undefined;
pendingAction: AssistantPendingAction | undefined;
modelId: string | null;
models: AssistantRuntimeModelOption[];
modelOptionsLoading: boolean;
reasoning: AssistantReasoningMode;
onModelChange: (modelId: string) => void;
onReasoningChange: (reasoning: AssistantReasoningMode) => void;
}
const markdownRemarkPlugins = [remarkGfm, remarkBreaks];
@@ -1255,10 +1259,7 @@ const ComposerTriggers = () => {
);
};
type ComposerProps = Pick<
AssistantPanelProps,
"modelId" | "models" | "modelOptionsLoading" | "reasoning" | "onModelChange" | "onReasoningChange"
> & { pendingAction: AssistantPendingAction | undefined };
type ComposerProps = AssistantConversationControls & { pendingAction: AssistantPendingAction | undefined };
const RuntimeControls = ({
modelId,
@@ -1622,6 +1623,124 @@ const AssistantActivityBar = ({
);
};
interface AssistantConversationSurfaceProps extends AssistantConversationControls {
isRunning: boolean;
pendingAction: AssistantPendingAction | undefined;
onExpand?: () => void;
onMinimize?: () => void;
}
export const AssistantConversationSurface = ({
isRunning,
pendingAction,
modelId,
models,
modelOptionsLoading,
reasoning,
onModelChange,
onReasoningChange,
onExpand,
onMinimize,
}: AssistantConversationSurfaceProps) => {
const t = useScopedI18n("common.assistant");
return (
<>
<Group className={classes.panelHeader} justify="space-between" wrap="nowrap" gap="xs">
<Group className={classes.panelIdentity} gap="xs" wrap="nowrap">
<ThemeIcon variant="light" color="red" radius="xl">
<IconRobot size={18} />
</ThemeIcon>
<div className={classes.panelIdentityText}>
<Text fw={700} lh={1.1} lineClamp={1}>
{t("title")}
</Text>
<Text size="xs" c="dimmed" lineClamp={1}>
{isRunning ? t("activity.thinking") : t("subtitle")}
</Text>
</div>
</Group>
<Group className={classes.panelActions} gap={2} wrap="nowrap">
<ConversationHistory />
<Tooltip label={t("newConversation")}>
<ThreadListPrimitive.New asChild>
<ActionIcon
className={classes.panelAction}
variant="subtle"
color="gray"
aria-label={t("newConversation")}
>
<IconPlus size={17} />
</ActionIcon>
</ThreadListPrimitive.New>
</Tooltip>
{onExpand && (
<Tooltip label={t("activity.expand")}>
<ActionIcon
className={classes.panelAction}
variant="subtle"
color="gray"
onClick={onExpand}
aria-label={t("activity.expand")}
>
<IconArrowsMaximize size={17} />
</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>
<ThreadPrimitive.Root className={classes.thread}>
<ThreadPrimitive.Viewport className={classes.viewport}>
<Box className={classes.messages}>
<EmptyThread />
<ThreadPrimitive.Messages components={{ UserMessage, AssistantMessage }} />
</Box>
<SelectionToolbarPrimitive.Root className={classes.selectionToolbar}>
<SelectionToolbarPrimitive.Quote asChild>
<Button variant="filled" color="dark" size="compact-sm" leftSection={<IconQuote size={14} />}>
{t("quoteSelection")}
</Button>
</SelectionToolbarPrimitive.Quote>
</SelectionToolbarPrimitive.Root>
<ThreadPrimitive.ScrollToBottom asChild>
<ActionIcon
className={classes.scrollToBottom}
variant="default"
radius="xl"
aria-label={t("scrollToLatest")}
>
<IconArrowUp size={16} style={{ transform: "rotate(180deg)" }} />
</ActionIcon>
</ThreadPrimitive.ScrollToBottom>
</ThreadPrimitive.Viewport>
<PendingActionBanner pendingAction={pendingAction} />
<Composer
modelId={modelId}
models={models}
modelOptionsLoading={modelOptionsLoading}
reasoning={reasoning}
onModelChange={onModelChange}
onReasoningChange={onReasoningChange}
pendingAction={pendingAction}
/>
</ThreadPrimitive.Root>
</>
);
};
export const AssistantPanel = ({
opened,
onOpen,
@@ -1683,82 +1802,17 @@ export const AssistantPanel = ({
{opened && (
<FocusTrap active>
<dialog className={classes.floatingPanel} aria-label={t("title")} open>
<Group className={classes.panelHeader} justify="space-between" wrap="nowrap" gap="xs">
<Group className={classes.panelIdentity} gap="xs" wrap="nowrap">
<ThemeIcon variant="light" color="red" radius="xl">
<IconRobot size={18} />
</ThemeIcon>
<div className={classes.panelIdentityText}>
<Text fw={700} lh={1.1} lineClamp={1}>
{t("title")}
</Text>
<Text size="xs" c="dimmed" lineClamp={1}>
{isRunning ? t("activity.thinking") : t("subtitle")}
</Text>
</div>
</Group>
<Group className={classes.panelActions} gap={2} wrap="nowrap">
<ConversationHistory />
<Tooltip label={t("newConversation")}>
<ThreadListPrimitive.New asChild>
<ActionIcon
className={classes.panelAction}
variant="subtle"
color="gray"
aria-label={t("newConversation")}
>
<IconPlus size={17} />
</ActionIcon>
</ThreadListPrimitive.New>
</Tooltip>
<Tooltip label={t("minimize")}>
<ActionIcon
className={classes.panelAction}
variant="subtle"
color="gray"
onClick={onClose}
aria-label={t("minimize")}
>
<IconMinus size={18} />
</ActionIcon>
</Tooltip>
</Group>
</Group>
<ThreadPrimitive.Root className={classes.thread}>
<ThreadPrimitive.Viewport className={classes.viewport}>
<Box className={classes.messages}>
<EmptyThread />
<ThreadPrimitive.Messages components={{ UserMessage, AssistantMessage }} />
</Box>
<SelectionToolbarPrimitive.Root className={classes.selectionToolbar}>
<SelectionToolbarPrimitive.Quote asChild>
<Button variant="filled" color="dark" size="compact-sm" leftSection={<IconQuote size={14} />}>
{t("quoteSelection")}
</Button>
</SelectionToolbarPrimitive.Quote>
</SelectionToolbarPrimitive.Root>
<ThreadPrimitive.ScrollToBottom asChild>
<ActionIcon
className={classes.scrollToBottom}
variant="default"
radius="xl"
aria-label={t("scrollToLatest")}
>
<IconArrowUp size={16} style={{ transform: "rotate(180deg)" }} />
</ActionIcon>
</ThreadPrimitive.ScrollToBottom>
</ThreadPrimitive.Viewport>
<PendingActionBanner pendingAction={pendingAction} />
<Composer
modelId={modelId}
models={models}
modelOptionsLoading={modelOptionsLoading}
reasoning={reasoning}
onModelChange={onModelChange}
onReasoningChange={onReasoningChange}
pendingAction={pendingAction}
/>
</ThreadPrimitive.Root>
<AssistantConversationSurface
isRunning={isRunning}
pendingAction={pendingAction}
modelId={modelId}
models={models}
modelOptionsLoading={modelOptionsLoading}
reasoning={reasoning}
onModelChange={onModelChange}
onReasoningChange={onReasoningChange}
onMinimize={onClose}
/>
</dialog>
</FocusTrap>
)}
@@ -23,6 +23,7 @@ import { hotkeys } from "@homarr/definitions";
import { showErrorNotification, showWarningNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { openMediaRequestSearch, openSpotlight, useRegisterSpotlightContextResults } from "@homarr/spotlight";
import { AssistantWidgetRendererProvider } from "@homarr/widgets";
import { shouldAutomaticallyContinueAssistant } from "./assistant-auto-submit";
import { AssistantAskUserTool, AssistantConfigureAppTool } from "./assistant-human-tools";
@@ -35,9 +36,11 @@ import { createAssistantPromptInteraction } from "./assistant-spotlight";
import { browserToolContracts } from "./assistant-tool-contracts";
import { AssistantConfigureBoardSettingsTool } from "./assistant-board-settings-tool";
import type { AssistantUIMessage } from "./assistant-message-metadata";
import { AssistantBoardWidget } from "./assistant-widget";
interface AssistantContextValue {
enabled: boolean;
unavailableDescription: string | null;
opened: boolean;
isRunning: boolean;
unreadCount: number;
@@ -148,7 +151,7 @@ export const useHomarrAssistant = () => {
export const useOptionalHomarrAssistant = () => useContext(AssistantContext);
const useAssistantPreferences = () => {
export const useAssistantPreferences = () => {
const value = useContext(AssistantPreferencesContext);
if (!value) throw new Error("useAssistantPreferences must be used within AssistantPreferencesProvider");
return value;
@@ -636,13 +639,23 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => {
useRegisterSpotlightContextResults("homarr-assistant", [spotlightItem], [spotlightItem]);
const value = useMemo(
() => ({ enabled: true, opened, isRunning: assistantIsRunning, unreadCount, open, close, toggle, sendPrompt }),
() => ({
enabled: true,
unavailableDescription: null,
opened,
isRunning: assistantIsRunning,
unreadCount,
open,
close,
toggle,
sendPrompt,
}),
[assistantIsRunning, close, open, opened, sendPrompt, toggle, unreadCount],
);
return (
<AssistantContext.Provider value={value}>
{children}
<AssistantWidgetRendererProvider renderer={AssistantBoardWidget}>{children}</AssistantWidgetRendererProvider>
<AssistantPanel
opened={opened}
onOpen={open}
@@ -692,6 +705,7 @@ const DisabledAssistantProvider = ({ children, description }: DisabledAssistantP
const value = useMemo(
() => ({
enabled: false,
unavailableDescription: description,
opened: false,
isRunning: false,
unreadCount: 0,
@@ -700,10 +714,14 @@ const DisabledAssistantProvider = ({ children, description }: DisabledAssistantP
toggle: () => undefined,
sendPrompt: () => undefined,
}),
[],
[description],
);
return <AssistantContext.Provider value={value}>{children}</AssistantContext.Provider>;
return (
<AssistantContext.Provider value={value}>
<AssistantWidgetRendererProvider renderer={AssistantBoardWidget}>{children}</AssistantWidgetRendererProvider>
</AssistantContext.Provider>
);
};
export const AssistantProvider = ({ children }: PropsWithChildren) => {
@@ -0,0 +1,203 @@
"use client";
import type { ReactNode } from "react";
import { useCallback, useState } from "react";
import { useAui, useAuiState } from "@assistant-ui/react";
import { ActionIcon, Box, Button, Center, Group, Loader, Stack, Text, ThemeIcon, Tooltip } from "@mantine/core";
import { IconAlertTriangle, IconArrowsMaximize, IconMessage, IconRobot } from "@tabler/icons-react";
import { useI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "@homarr/widgets";
import classes from "./assistant-panel.module.css";
import { AssistantConversationSurface } from "./assistant-panel";
import { getPendingAssistantAction } from "./assistant-pending-action";
import { useAssistantPreferences, useHomarrAssistant } from "./assistant-provider";
export const AssistantBoardWidget = (props: WidgetComponentProps<"assistant">) => {
const assistant = useHomarrAssistant();
if (!assistant.enabled) {
return <UnavailableAssistantWidget description={assistant.unavailableDescription} />;
}
return <EnabledAssistantBoardWidget {...props} />;
};
const EnabledAssistantBoardWidget = ({ options, width, height, isEditMode }: WidgetComponentProps<"assistant">) => {
const t = useI18n();
const assistant = useHomarrAssistant();
const preferences = useAssistantPreferences();
const aui = useAui();
const currentThreadId = useAuiState((state) => state.threadListItem.remoteId);
const messages = useAuiState((state) => state.thread.messages);
const isLoading = useAuiState((state) => state.thread.isLoading);
const latestAssistantMessage = messages.findLast((message) => message.role === "assistant");
const pendingAction = getPendingAssistantAction(latestAssistantMessage);
const pinnedConversation = options.conversationMode === "pinned" ? options.conversation : null;
const pinnedConversationIsActive = pinnedConversation === null || pinnedConversation.value === currentThreadId;
const [switching, setSwitching] = useState(false);
const [switchErrorConversationId, setSwitchErrorConversationId] = useState<string | null>(null);
const hasSwitchError = switchErrorConversationId === pinnedConversation?.value;
const selectModel = useCallback(
(modelId: string) => {
preferences.setModelId(modelId);
const threadListItem = aui.threadListItem();
if (!threadListItem.getState().remoteId) return;
threadListItem.updateCustom({ ...threadListItem.getState().custom, modelId });
},
[aui, preferences],
);
const openPinnedConversation = async () => {
if (!pinnedConversation) return;
setSwitchErrorConversationId(null);
setSwitching(true);
try {
await aui.threads().switchToThread(pinnedConversation.value);
} catch {
setSwitchErrorConversationId(pinnedConversation.value);
} finally {
setSwitching(false);
}
};
if (options.conversationMode === "pinned" && pinnedConversation === null) {
return (
<AssistantWidgetState
icon={IconMessage}
title={t("widget.assistant.pinned.selectTitle")}
description={t("widget.assistant.pinned.selectDescription")}
/>
);
}
if (!pinnedConversationIsActive && pinnedConversation) {
return (
<AssistantWidgetState
icon={hasSwitchError ? IconAlertTriangle : IconMessage}
color={hasSwitchError ? "red" : "gray"}
title={t("widget.assistant.pinned.title")}
description={
hasSwitchError
? t("widget.assistant.pinned.error")
: t("widget.assistant.pinned.description", { conversation: pinnedConversation.label })
}
>
<Button
variant="light"
color={hasSwitchError ? "red" : "gray"}
onClick={() => void openPinnedConversation()}
loading={switching}
leftSection={switching ? <Loader type="bars" size="xs" /> : <IconMessage size={16} />}
>
{switching ? t("widget.assistant.pinned.opening") : t("widget.assistant.pinned.open")}
</Button>
</AssistantWidgetState>
);
}
if (width < 300 || height < 280) {
return <CompactAssistantWidget onOpen={assistant.open} />;
}
return (
<Box
className={classes.widgetPanel}
data-compact={width < 420 || height < 420 || undefined}
data-editing={isEditMode || undefined}
aria-busy={isLoading || assistant.isRunning}
>
<AssistantConversationSurface
isRunning={assistant.isRunning}
pendingAction={pendingAction}
modelId={preferences.modelId}
models={preferences.models}
modelOptionsLoading={preferences.isLoading}
reasoning={preferences.reasoning}
onModelChange={selectModel}
onReasoningChange={preferences.setReasoning}
onExpand={assistant.open}
/>
</Box>
);
};
const CompactAssistantWidget = ({ onOpen }: { onOpen: () => void }) => {
const t = useI18n();
return (
<Box className={classes.widgetPanel}>
<Group className={classes.compactState} justify="space-between" wrap="nowrap" gap="sm">
<Group wrap="nowrap" gap="xs" miw={0}>
<ThemeIcon variant="light" color="red" radius="xl" size="lg" flex="0 0 auto">
<IconRobot size={19} />
</ThemeIcon>
<Stack className={classes.compactCopy} gap={1}>
<Text className={classes.compactTitle} size="sm" fw={700} lineClamp={1}>
{t("widget.assistant.compact.title")}
</Text>
<Text className={classes.compactDescription} size="xs" c="dimmed" lineClamp={2}>
{t("widget.assistant.compact.description")}
</Text>
</Stack>
</Group>
<Tooltip label={t("widget.assistant.compact.open")}>
<ActionIcon
variant="light"
color="red"
size="lg"
radius="md"
onClick={onOpen}
aria-label={t("widget.assistant.compact.open")}
>
<IconArrowsMaximize size={18} />
</ActionIcon>
</Tooltip>
</Group>
</Box>
);
};
interface AssistantWidgetStateProps {
icon: typeof IconRobot;
title: string;
description: string;
color?: string;
children?: ReactNode;
}
const AssistantWidgetState = ({
icon: Icon,
title,
description,
color = "red",
children,
}: AssistantWidgetStateProps) => (
<Center className={classes.widgetState} h="100%" p="md">
<Stack align="center" gap="sm" ta="center" maw={360}>
<ThemeIcon variant="light" color={color} radius="xl" size="xl">
<Icon size={24} />
</ThemeIcon>
<Stack gap={3} align="center">
<Text fw={700}>{title}</Text>
<Text size="sm" c="dimmed">
{description}
</Text>
</Stack>
{children}
</Stack>
</Center>
);
const UnavailableAssistantWidget = ({ description }: { description: string | null }) => {
const t = useI18n();
return (
<AssistantWidgetState
icon={IconRobot}
title={t("widget.assistant.unavailable.title")}
description={description ?? t("widget.assistant.unavailable.description")}
/>
);
};
@@ -129,6 +129,26 @@ describe("item actions create-item", () => {
);
});
test("should fit a wide widget to every board layout", () => {
const layoutId = "compact-layout";
const layout = new LayoutMockBuilder({ id: layoutId, columnCount: 4 }).build();
const board = new BoardMockBuilder().addLayout(layout).addEmptySection({ id: "section" }).build();
const layoutsSpy = vi.spyOn(boardContext, "getBoardLayouts");
layoutsSpy.mockReturnValue([layoutId]);
const emptyPositionSpy = vi.spyOn(emptyPositionModule, "getFirstEmptyPosition");
emptyPositionSpy.mockReturnValue({ xOffset: 0, yOffset: 0 });
const result = createItemCallback({ kind: "assistant" })(board);
expect(result.items.at(0)?.layouts.at(0)).toEqual(
expect.objectContaining({ width: layout.columnCount, height: 4 }),
);
expect(emptyPositionSpy).toHaveBeenCalledWith([], layout.columnCount, undefined, {
width: layout.columnCount,
height: 4,
});
});
test("clamps wide defaults and places below a full automatic canvas", () => {
const board = new BoardMockBuilder().addEmptySection({ id: "canvas", yOffset: 0 }).build();
const layout = board.layouts[0];
@@ -47,7 +47,7 @@ interface BoardItemContentProps {
const getOverflowFromKind = (kind: SectionItem["kind"], hasCustomCssClasses: boolean) => {
if (kind === "systemResources") return { overflowX: "visible", overflowY: "visible" } as const;
if (kind === "iframe") return { overflowX: "hidden", overflowY: "hidden" } as const;
if (kind === "iframe" || kind === "assistant") return { overflowX: "hidden", overflowY: "hidden" } as const;
if (hasCustomCssClasses) return {};
return { overflowX: "hidden", overflowY: "auto" } as const;
};