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;
};
@@ -55,5 +55,6 @@ export const widgetDocSlugs: Record<WidgetKind, string | null> = {
beszelSystemStats: "beszel-system-stats",
traefik: "traefik",
customApi: "custom-api",
assistant: "assistant",
wud: "whats-up-docker",
};
+2
View File
@@ -53,6 +53,7 @@ export const widgetKinds = [
"beszelSystemStats",
"traefik",
"customApi",
"assistant",
"wud",
] as const;
export type WidgetKind = (typeof widgetKinds)[number];
@@ -64,4 +65,5 @@ export const widgetDefaultSizes: Partial<Record<WidgetKind, { width: number; hei
patchmon: { width: 2, height: 2 },
mediaMissing: { width: 4, height: 3 },
bazarr: { width: 2, height: 2 },
assistant: { width: 10, height: 4 },
};
+36
View File
@@ -4873,6 +4873,42 @@
"unhealthy": "Unhealthy"
}
},
"assistant": {
"name": "Assistant",
"description": "Have a complete assistant conversation directly on your board.",
"option": {
"conversationMode": {
"label": "Conversation",
"description": "Follow the conversation used elsewhere in Homarr, or keep this widget attached to a specific conversation.",
"option": {
"current": "Current conversation",
"pinned": "Pinned conversation"
}
},
"conversation": {
"label": "Pinned conversation",
"description": "This selector stays hidden while the widget follows the current conversation."
}
},
"pinned": {
"title": "Pinned conversation",
"description": "This widget is attached to {conversation}. Open it to continue here without silently changing your active conversation.",
"selectTitle": "Choose a conversation",
"selectDescription": "Open the widget settings and select the conversation this widget should keep available.",
"open": "Open conversation",
"opening": "Opening conversation...",
"error": "This conversation could not be opened. It may have been deleted."
},
"unavailable": {
"title": "Assistant unavailable",
"description": "Sign in and ask an administrator to configure the Assistant from the management panel."
},
"compact": {
"title": "Assistant",
"description": "Resize this widget to chat here, or open the full conversation.",
"open": "Open assistant"
}
},
"customApi": {
"name": "Custom widget",
"description": "Render a configured Custom JSX definition.",
@@ -0,0 +1,32 @@
"use client";
import { Center, Stack, Text, ThemeIcon } from "@mantine/core";
import { IconRobot } from "@tabler/icons-react";
import { useI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { useAssistantWidgetRenderer } from "./context";
export default function AssistantWidget(props: WidgetComponentProps<"assistant">) {
const Renderer = useAssistantWidgetRenderer();
const t = useI18n();
if (Renderer) return <Renderer {...props} />;
return (
<Center h="100%" p="md">
<Stack align="center" gap="xs" ta="center">
<ThemeIcon variant="light" color="red" radius="xl" size="lg">
<IconRobot size={20} />
</ThemeIcon>
<Text size="sm" fw={600}>
{t("widget.assistant.unavailable.title")}
</Text>
<Text size="xs" c="dimmed">
{t("widget.assistant.unavailable.description")}
</Text>
</Stack>
</Center>
);
}
@@ -0,0 +1,20 @@
"use client";
import type { ComponentType, PropsWithChildren } from "react";
import { createContext, useContext } from "react";
import type { WidgetComponentProps } from "../definition";
type AssistantWidgetRenderer = ComponentType<WidgetComponentProps<"assistant">>;
const AssistantWidgetRendererContext = createContext<AssistantWidgetRenderer | null>(null);
interface AssistantWidgetRendererProviderProps extends PropsWithChildren {
renderer: AssistantWidgetRenderer;
}
export const AssistantWidgetRendererProvider = ({ children, renderer }: AssistantWidgetRendererProviderProps) => (
<AssistantWidgetRendererContext.Provider value={renderer}>{children}</AssistantWidgetRendererContext.Provider>
);
export const useAssistantWidgetRenderer = () => useContext(AssistantWidgetRendererContext);
@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { definition } from ".";
describe("assistant widget definition", () => {
it("follows the current conversation by default", () => {
const options = definition.createOptions();
expect(options.conversationMode.defaultValue).toBe("current");
expect(options.conversation.shouldHide?.({ conversationMode: "current", conversation: null }, [])).toBe(true);
});
it("reveals the conversation selector only when a conversation is pinned", () => {
const options = definition.createOptions();
expect(options.conversation.shouldHide?.({ conversationMode: "pinned", conversation: null }, [])).toBe(false);
});
});
+50
View File
@@ -0,0 +1,50 @@
import { IconRobot } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("assistant", {
icon: IconRobot,
createOptions() {
return optionsBuilder.from(
(factory) => ({
conversationMode: factory.select({
options: (["current", "pinned"] as const).map((value) => ({
value,
label: (t) => t(`widget.assistant.option.conversationMode.option.${value}`),
})),
defaultValue: "current",
withDescription: true,
}),
conversation: factory.dynamicSelect({
withDescription: true,
useOptions(query) {
const { data: threads = [], isPending } = clientApi.assistant.listThreads.useQuery();
const normalizedQuery = query.trim().toLocaleLowerCase();
return {
isPending,
options: threads
.filter((thread) => {
if (normalizedQuery.length === 0) return true;
return (thread.title ?? "").toLocaleLowerCase().includes(normalizedQuery);
})
.map((thread) => ({
value: thread.id,
label: thread.title?.trim() || thread.id,
})),
};
},
}),
}),
{
conversation: {
shouldHide: (options) => options.conversationMode !== "pinned",
},
},
);
},
}).withDynamicImport(() => import("./component"));
export { AssistantWidgetRendererProvider } from "./context";
+3
View File
@@ -64,6 +64,7 @@ import * as beszelSystemStats from "./beszel-system-stats";
import * as video from "./video";
import * as weather from "./weather";
import * as customApi from "./custom-api";
import * as assistant from "./assistant";
import * as wud from "./wud";
export type {
@@ -88,6 +89,7 @@ export {
} from "./definition";
export type { WidgetComponentProps };
export type { WidgetOptionDefinition, WidgetOptionType } from "./options";
export { AssistantWidgetRendererProvider } from "./assistant/context";
export const widgetImports = {
clock,
@@ -144,6 +146,7 @@ export const widgetImports = {
beszelSystemStats,
traefik,
customApi,
assistant,
wud,
} satisfies WidgetImportRecord;