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

@@ -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;
};