feat: streamline assistant interactions

This commit is contained in:
ajnart committed 2026-08-14 11:32:10 +02:00
1 parent 27448868dd
commit 63699b1610
7 files changed
+721 -222

No files matched your search

@@ -1,28 +1,75 @@
.content {
height: 100dvh;
.activityBar {
position: fixed;
z-index: 290;
inset-inline-start: 50%;
bottom: 1rem;
display: flex;
width: min(31rem, calc(100vw - 1rem));
min-height: 3.5rem;
align-items: center;
gap: 0.25rem;
padding: 0.35rem 0.4rem 0.35rem 0.5rem;
transform: translateX(-50%);
border: 1px solid var(--mantine-color-default-border);
border-radius: var(--mantine-radius-lg);
background: var(--mantine-color-body);
animation: assistant-activity-in 180ms cubic-bezier(0.22, 1, 0.36, 1);
}
.body {
display: flex;
height: calc(100dvh - 4.25rem);
padding: 0;
overflow: hidden;
}
.shell {
position: relative;
display: flex;
width: 100%;
.activityTrigger {
min-width: 0;
flex: 1;
padding: 0.15rem;
border-radius: var(--mantine-radius-md);
text-align: start;
}
.history {
width: 15rem;
flex: 0 0 15rem;
border-inline-end: 1px solid var(--mantine-color-default-border);
background: color-mix(in srgb, var(--mantine-color-body) 92%, var(--mantine-color-gray-6));
transition: transform 180ms ease;
.activityTrigger:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: 2px;
}
.activityCopy {
min-width: 0;
flex: 1;
}
.activityExpandIcon {
flex: 0 0 auto;
color: var(--mantine-color-dimmed);
}
.floatingPanel {
position: fixed;
z-index: 290;
inset-inline-start: 50%;
bottom: 1rem;
display: flex;
width: min(46rem, calc(100vw - 2rem));
height: min(36rem, calc(100dvh - 5rem));
min-height: 22rem;
margin: 0;
transform: translateX(-50%);
flex-direction: column;
overflow: hidden;
padding: 0;
border: 0;
border-radius: var(--mantine-radius-lg);
color: var(--mantine-color-text);
background: var(--mantine-color-body);
box-shadow: var(--mantine-shadow-xl);
animation: assistant-panel-in 180ms cubic-bezier(0.22, 1, 0.36, 1);
}
.panelHeader {
min-height: 3.75rem;
flex: 0 0 auto;
padding: 0.65rem 0.75rem 0.65rem 0.9rem;
border-bottom: 1px solid var(--mantine-color-default-border);
}
.historyMenu {
background: var(--mantine-color-body);
}
.historyItem {
@@ -37,6 +84,7 @@
position: relative;
display: flex;
min-width: 0;
min-height: 0;
flex: 1;
flex-direction: column;
}
@@ -52,21 +100,21 @@
.messages {
width: 100%;
max-width: 52rem;
max-width: 44rem;
margin-inline: auto;
padding: 1.25rem 1rem 8.75rem;
padding: 0.75rem 1rem 1.5rem;
}
.message {
margin-block: 1rem;
margin-block: 0.9rem;
}
.userMessage {
width: fit-content;
max-width: 90%;
max-width: 86%;
margin-inline-start: auto;
padding: 0.7rem 0.9rem;
border-radius: 1rem 1rem 0.3rem 1rem;
padding: 0.65rem 0.85rem;
border-radius: 0.9rem 0.9rem 0.25rem 0.9rem;
background: var(--mantine-color-red-light);
}
@@ -75,7 +123,7 @@
}
.messageText {
line-height: 1.65;
line-height: 1.6;
overflow-wrap: anywhere;
}
@@ -102,32 +150,32 @@
}
.composerWrap {
position: absolute;
inset-inline: 0;
bottom: 0;
padding: 2.5rem 1rem 1rem;
background: var(--mantine-color-body);
flex: 0 0 auto;
padding: 0.65rem 0.75rem 0.75rem;
border-top: 1px solid var(--mantine-color-default-border);
pointer-events: none;
background: var(--mantine-color-body);
}
.composer {
display: flex;
width: 100%;
max-width: 52rem;
max-width: 44rem;
margin-inline: auto;
align-items: flex-end;
gap: 0.5rem;
padding: 0.45rem;
border-radius: 1rem;
padding: 0.4rem;
border: 1px solid var(--mantine-color-default-border);
border-radius: var(--mantine-radius-lg);
background: var(--mantine-color-default);
box-shadow: var(--mantine-shadow-sm);
pointer-events: auto;
}
.composer:focus-within {
border-color: var(--mantine-primary-color-filled);
}
.composerInput {
min-height: 2.5rem;
max-height: 12rem;
max-height: 9rem;
flex: 1;
resize: none;
border: 0;
@@ -140,10 +188,10 @@
.empty {
display: flex;
min-height: 60vh;
min-height: 23rem;
align-items: center;
justify-content: center;
padding: 3rem 1rem 8rem;
padding: 2rem 1rem;
text-align: center;
}
@@ -151,67 +199,98 @@
display: grid;
width: 100%;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.65rem;
gap: 0.55rem;
}
.suggestion {
height: auto;
min-height: 3.25rem;
min-height: 3rem;
justify-content: flex-start;
padding: 0.7rem 0.9rem;
padding: 0.65rem 0.8rem;
text-align: start;
white-space: normal;
}
.mobileHistoryButton,
.historyClose {
display: none;
.scrollToBottom {
position: absolute;
inset-inline-start: 50%;
bottom: 0.75rem;
transform: translateX(-50%);
}
@keyframes assistant-activity-in {
from {
opacity: 0;
transform: translate(-50%, 0.5rem);
}
to {
opacity: 1;
transform: translate(-50%, 0);
}
}
@keyframes assistant-panel-in {
from {
opacity: 0;
transform: translate(-50%, 0.75rem) scale(0.985);
}
to {
opacity: 1;
transform: translate(-50%, 0) scale(1);
}
}
@media (max-width: 48em) {
.history {
position: absolute;
z-index: 4;
inset-block: 0;
inset-inline-start: 0;
width: min(20rem, 88vw);
transform: translateX(-105%);
visibility: hidden;
transition:
transform 180ms ease,
visibility 180ms ease;
box-shadow: var(--mantine-shadow-xl);
.activityBar {
bottom: 0.5rem;
}
[dir="rtl"] .history {
transform: translateX(105%);
.floatingPanel {
bottom: 0.5rem;
width: calc(100vw - 1rem);
height: min(44rem, calc(100dvh - 1rem));
min-height: 20rem;
}
.history[data-opened="true"] {
transform: translateX(0);
visibility: visible;
}
.mobileHistoryButton {
position: absolute;
z-index: 3;
top: 0.65rem;
inset-inline-start: 0.65rem;
display: inline-flex;
}
.historyClose {
display: inline-flex;
.panelHeader {
padding-inline-start: 0.7rem;
}
.suggestions {
grid-template-columns: 1fr;
}
.empty {
min-height: 32rem;
padding-block: 1.5rem;
}
}
@media (max-width: 30em) {
.historyButton {
max-width: 2.25rem;
padding-inline: 0.5rem;
}
.historyButton :global(.mantine-Button-section) {
margin-inline-end: 0;
}
.historyButton :global(.mantine-Button-label) {
display: none;
}
.messages {
padding-inline: 0.75rem;
}
}
@media (prefers-reduced-motion: reduce) {
.history {
transition: none;
.activityBar,
.floatingPanel {
animation: none;
}
.viewport {
@@ -1,7 +1,8 @@
"use client";
import { useState } from "react";
import { createContext, useContext, useState } from "react";
import type { ToolCallMessagePartProps } from "@assistant-ui/react";
import type { MessageStatus } from "@assistant-ui/react";
import {
ActionBarPrimitive,
ComposerPrimitive,
@@ -13,8 +14,22 @@ import {
ThreadPrimitive,
useAuiState,
} from "@assistant-ui/react";
import { ActionIcon, Box, Button, Drawer, Group, ScrollArea, Stack, Text, ThemeIcon, Tooltip } from "@mantine/core";
import { useMediaQuery } from "@mantine/hooks";
import {
ActionIcon,
Badge,
Box,
Button,
Group,
Loader,
Popover,
ScrollArea,
Stack,
Text,
ThemeIcon,
Tooltip,
UnstyledButton,
} from "@mantine/core";
import { useWindowEvent } from "@mantine/hooks";
import {
IconActivityHeartbeat,
IconAlertTriangle,
@@ -22,9 +37,9 @@ import {
IconArchive,
IconArrowUp,
IconCheck,
IconChevronUp,
IconCopy,
IconCommand,
IconMenu2,
IconHistory,
IconPlayerStop,
IconPlus,
@@ -42,7 +57,14 @@ import classes from "./assistant-panel.module.css";
interface AssistantPanelProps {
opened: boolean;
onOpen: () => void;
onClose: () => void;
onMarkRead: () => void;
isRunning: boolean;
unreadCount: number;
latestAssistantText: string;
latestUserText: string;
latestStatus: MessageStatus | undefined;
}
const TextPart = () => <MessagePartPrimitive.Text className={classes.messageText} />;
@@ -194,13 +216,24 @@ const AssistantMessage = () => (
</MessagePrimitive.Root>
);
const HistorySelectContext = createContext<() => void>(() => undefined);
const ThreadListItem = () => {
const t = useScopedI18n("common.assistant");
const onSelect = useContext(HistorySelectContext);
return (
<ThreadListItemPrimitive.Root className={classes.historyItem}>
<Group gap={4} wrap="nowrap">
<ThreadListItemPrimitive.Trigger asChild>
<Button variant="subtle" color="gray" size="compact-sm" justify="flex-start" flex={1} style={{ minWidth: 0 }}>
<Button
variant="subtle"
color="gray"
size="compact-sm"
justify="flex-start"
flex={1}
style={{ minWidth: 0 }}
onClick={onSelect}
>
<ThreadListItemPrimitive.Title fallback={t("newConversation")} />
</Button>
</ThreadListItemPrimitive.Trigger>
@@ -221,11 +254,20 @@ const ThreadListItem = () => {
const ArchivedThreadListItem = () => {
const t = useScopedI18n("common.assistant");
const onSelect = useContext(HistorySelectContext);
return (
<ThreadListItemPrimitive.Root className={classes.historyItem}>
<Group gap={4} wrap="nowrap">
<ThreadListItemPrimitive.Trigger asChild>
<Button variant="subtle" color="gray" size="compact-sm" justify="flex-start" flex={1} style={{ minWidth: 0 }}>
<Button
variant="subtle"
color="gray"
size="compact-sm"
justify="flex-start"
flex={1}
style={{ minWidth: 0 }}
onClick={onSelect}
>
<ThreadListItemPrimitive.Title fallback={t("archivedConversation")} />
</Button>
</ThreadListItemPrimitive.Trigger>
@@ -244,51 +286,66 @@ const ArchivedThreadListItem = () => {
);
};
const ThreadHistory = ({ opened, onClose }: { opened: boolean; onClose: () => void }) => {
const ThreadHistory = ({ onSelect }: { onSelect: () => void }) => {
const t = useScopedI18n("common.assistant");
const isMobile = useMediaQuery("(max-width: 48em)");
const hidden = isMobile && !opened;
return (
<Stack
className={classes.history}
gap="xs"
p="sm"
data-opened={opened}
inert={hidden ? true : undefined}
aria-hidden={hidden || undefined}
<HistorySelectContext.Provider value={onSelect}>
<Stack className={classes.historyMenu} gap="xs" p="xs">
<Group gap="xs" wrap="nowrap" px={4} pt={4}>
<ThreadListPrimitive.New asChild>
<Button variant="light" leftSection={<IconPlus size={16} />} fullWidth onClick={onSelect}>
{t("newConversation")}
</Button>
</ThreadListPrimitive.New>
</Group>
<Group gap="xs" px="xs" mt="xs">
<IconHistory size={14} />
<Text size="sm" fw={600} c="dimmed">
{t("conversations")}
</Text>
</Group>
<ScrollArea h="min(24rem, 55dvh)" type="auto" offsetScrollbars>
<Stack gap={3}>
<ThreadListPrimitive.Items components={{ ThreadListItem }} />
<Text size="sm" fw={600} c="dimmed" px="xs" mt="sm">
{t("archived")}
</Text>
<ThreadListPrimitive.Items archived components={{ ThreadListItem: ArchivedThreadListItem }} />
</Stack>
</ScrollArea>
</Stack>
</HistorySelectContext.Provider>
);
};
const ConversationHistory = () => {
const t = useScopedI18n("common.assistant");
const [opened, setOpened] = useState(false);
return (
<Popover
opened={opened}
onChange={setOpened}
position="bottom-end"
width="min(22rem, calc(100vw - 1rem))"
shadow="md"
withinPortal
>
<Group gap="xs" wrap="nowrap">
<ThreadListPrimitive.New asChild>
<Button variant="light" leftSection={<IconPlus size={16} />} fullWidth onClick={onClose}>
{t("newConversation")}
</Button>
</ThreadListPrimitive.New>
<ActionIcon
className={classes.historyClose}
<Popover.Target>
<Button
className={classes.historyButton}
variant="subtle"
color="gray"
onClick={onClose}
aria-label={t("closeHistory")}
size="compact-sm"
leftSection={<IconHistory size={16} />}
onClick={() => setOpened((current) => !current)}
>
<IconX size={17} />
</ActionIcon>
</Group>
<Group gap="xs" px="xs" mt="xs">
<IconHistory size={14} />
<Text size="sm" fw={600} c="dimmed">
{t("conversations")}
</Text>
</Group>
<ScrollArea flex={1} type="auto">
<Stack gap={3}>
<ThreadListPrimitive.Items components={{ ThreadListItem }} />
<Text size="sm" fw={600} c="dimmed" px="xs" mt="sm">
{t("archived")}
</Text>
<ThreadListPrimitive.Items archived components={{ ThreadListItem: ArchivedThreadListItem }} />
</Stack>
</ScrollArea>
</Stack>
</Button>
</Popover.Target>
<Popover.Dropdown p={0}>
<ThreadHistory onSelect={() => setOpened(false)} />
</Popover.Dropdown>
</Popover>
);
};
@@ -366,67 +423,160 @@ const Composer = () => {
);
};
export const AssistantPanel = ({ opened, onClose }: AssistantPanelProps) => {
const AssistantActivityBar = ({
isRunning,
unreadCount,
latestAssistantText,
latestUserText,
latestStatus,
onOpen,
onMarkRead,
}: Omit<AssistantPanelProps, "opened" | "onClose">) => {
const t = useScopedI18n("common.assistant");
const [historyOpened, setHistoryOpened] = useState(false);
const needsApproval = latestStatus?.type === "requires-action";
const failed = latestStatus?.type === "incomplete" && latestStatus.reason !== "cancelled";
const visible = isRunning || unreadCount > 0 || needsApproval;
if (!visible) return null;
const title = isRunning
? t("activity.thinking")
: needsApproval
? t("activity.approval")
: failed
? t("activity.failed")
: t("activity.ready");
const detail = isRunning
? latestUserText || t("activity.working")
: latestAssistantText || (failed ? t("responseError.description") : t("activity.completed"));
return (
<Drawer
opened={opened}
onClose={onClose}
position="right"
size="min(52rem, 100vw)"
zIndex={300}
className={classes.drawer}
classNames={{ content: classes.content, body: classes.body }}
title={
<Group gap="xs">
<ThemeIcon variant="light" color="red" radius="xl">
<IconRobot size={18} />
</ThemeIcon>
<div>
<Text fw={700} lh={1.1}>
{t("title")}
<Box component="output" className={classes.activityBar} aria-live="polite">
<UnstyledButton className={classes.activityTrigger} onClick={onOpen} aria-label={t("activity.expand")}>
<Group gap="sm" wrap="nowrap">
{isRunning ? (
<ThemeIcon variant="light" color="red" radius="xl">
<Loader type="bars" size="sm" color="red" />
</ThemeIcon>
) : (
<ThemeIcon variant="light" color={failed ? "red" : needsApproval ? "yellow" : "green"} radius="xl">
{failed ? (
<IconAlertTriangle size={17} />
) : needsApproval ? (
<IconRobot size={17} />
) : (
<IconCheck size={17} />
)}
</ThemeIcon>
)}
<Stack gap={0} className={classes.activityCopy}>
<Group gap="xs" wrap="nowrap">
<Text size="sm" fw={700}>
{title}
</Text>
{unreadCount > 0 && (
<Badge size="xs" variant="filled" color="red" circle>
{unreadCount > 99 ? "99+" : unreadCount}
</Badge>
)}
</Group>
<Text size="xs" c="dimmed" lineClamp={1}>
{detail}
</Text>
<Text size="xs" c="dimmed">
{t("subtitle")}
</Text>
</div>
</Stack>
<IconChevronUp size={17} className={classes.activityExpandIcon} />
</Group>
}
closeButtonProps={{ icon: <IconX size={18} />, "aria-label": t("close") }}
>
<Box className={classes.shell}>
<ThreadHistory opened={historyOpened} onClose={() => setHistoryOpened(false)} />
<ThreadPrimitive.Root className={classes.thread}>
<ActionIcon
className={classes.mobileHistoryButton}
variant="default"
onClick={() => setHistoryOpened(true)}
aria-label={t("openHistory")}
>
<IconMenu2 size={17} />
</ActionIcon>
<ThreadPrimitive.Viewport className={classes.viewport}>
<Box className={classes.messages}>
<EmptyThread />
<ThreadPrimitive.Messages components={{ UserMessage, AssistantMessage }} />
</Box>
<ThreadPrimitive.ScrollToBottom asChild>
<ActionIcon
pos="absolute"
bottom={110}
left="50%"
variant="default"
radius="xl"
aria-label={t("scrollToLatest")}
>
<IconArrowUp size={16} style={{ transform: "rotate(180deg)" }} />
</ActionIcon>
</ThreadPrimitive.ScrollToBottom>
</ThreadPrimitive.Viewport>
<Composer />
</ThreadPrimitive.Root>
</Box>
</Drawer>
</UnstyledButton>
{!isRunning && !needsApproval && (
<ActionIcon variant="subtle" color="gray" size="sm" onClick={onMarkRead} aria-label={t("activity.dismiss")}>
<IconX size={15} />
</ActionIcon>
)}
</Box>
);
};
export const AssistantPanel = ({
opened,
onOpen,
onClose,
onMarkRead,
isRunning,
unreadCount,
latestAssistantText,
latestUserText,
latestStatus,
}: AssistantPanelProps) => {
const t = useScopedI18n("common.assistant");
useWindowEvent("keydown", (event) => {
if (opened && event.key === "Escape") onClose();
});
return (
<>
{!opened && (
<AssistantActivityBar
onOpen={onOpen}
onMarkRead={onMarkRead}
isRunning={isRunning}
unreadCount={unreadCount}
latestAssistantText={latestAssistantText}
latestUserText={latestUserText}
latestStatus={latestStatus}
/>
)}
{opened && (
<dialog className={classes.floatingPanel} aria-label={t("title")} open>
<Group className={classes.panelHeader} justify="space-between" wrap="nowrap">
<Group gap="xs" wrap="nowrap">
<ThemeIcon variant="light" color="red" radius="xl">
<IconRobot size={18} />
</ThemeIcon>
<div>
<Text fw={700} lh={1.1}>
{t("title")}
</Text>
<Text size="xs" c="dimmed" lineClamp={1}>
{isRunning ? t("activity.thinking") : t("subtitle")}
</Text>
</div>
</Group>
<Group gap={2} wrap="nowrap">
<ConversationHistory />
<Tooltip label={t("newConversation")}>
<ThreadListPrimitive.New asChild>
<ActionIcon variant="subtle" color="gray" aria-label={t("newConversation")}>
<IconPlus size={17} />
</ActionIcon>
</ThreadListPrimitive.New>
</Tooltip>
<ActionIcon variant="subtle" color="gray" onClick={onClose} aria-label={t("close")}>
<IconX size={18} />
</ActionIcon>
</Group>
</Group>
<ThreadPrimitive.Root className={classes.thread}>
<ThreadPrimitive.Viewport className={classes.viewport}>
<Box className={classes.messages}>
<EmptyThread />
<ThreadPrimitive.Messages components={{ UserMessage, AssistantMessage }} />
</Box>
<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>
<Composer />
</ThreadPrimitive.Root>
</dialog>
)}
</>
);
};
@@ -1,9 +1,15 @@
"use client";
import type { PropsWithChildren } from "react";
import { createContext, useCallback, useContext, useMemo, useState } from "react";
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import type { MessageFormatAdapter, RemoteThreadListAdapter, ThreadHistoryAdapter, Toolkit } from "@assistant-ui/react";
import type {
MessageFormatAdapter,
RemoteThreadListAdapter,
ThreadHistoryAdapter,
ThreadMessage,
Toolkit,
} from "@assistant-ui/react";
import {
AssistantRuntimeProvider,
defineToolkit,
@@ -21,7 +27,7 @@ import { lastAssistantMessageIsCompleteWithApprovalResponses } from "ai";
import { clientApi, fetchApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { hotkeys } from "@homarr/definitions";
import { showErrorNotification } from "@homarr/notifications";
import { showErrorNotification, showWarningNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import {
openMediaRequestSearch,
@@ -31,14 +37,18 @@ import {
} from "@homarr/spotlight";
import { AssistantPanel } from "./assistant-panel";
import { createAssistantPromptInteraction } from "./assistant-spotlight";
import { browserToolContracts } from "./assistant-tool-contracts";
interface AssistantContextValue {
enabled: boolean;
opened: boolean;
isRunning: boolean;
unreadCount: number;
open: () => void;
close: () => void;
toggle: () => void;
sendPrompt: (prompt: string) => void;
}
const AssistantContext = createContext<AssistantContextValue | null>(null);
@@ -267,58 +277,189 @@ const AssistantTools = ({ toolkit }: { toolkit: Toolkit }) => {
return null;
};
export const AssistantProvider = ({ children }: PropsWithChildren) => {
const t = useScopedI18n("common.assistant");
const session = useSession();
const [opened, setOpened] = useState(false);
const { data, isLoading: isAvailabilityLoading } = clientApi.assistant.getAvailability.useQuery(undefined, {
enabled: session.status === "authenticated",
staleTime: 60_000,
});
const enabled = Boolean(data?.enabled && session.status === "authenticated");
const getMessageText = (message: ThreadMessage | undefined) =>
message?.content
.filter((part) => part.type === "text")
.map((part) => part.text)
.join("\n")
.trim() ?? "";
const open = useCallback(() => setOpened(true), []);
const getLatestMessage = (messages: readonly ThreadMessage[], role: ThreadMessage["role"]) =>
messages.findLast((message) => message.role === role);
const getNotificationKey = (message: ThreadMessage | undefined) => {
if (message?.role !== "assistant") return null;
if (message.status.type === "running") return null;
return `${message.id}:${message.status.type}:${"reason" in message.status ? message.status.reason : ""}`;
};
const EnabledAssistantProvider = ({ children }: PropsWithChildren) => {
const t = useScopedI18n("common.assistant");
const [opened, setOpened] = useState(false);
const [unreadCount, setUnreadCount] = useState(0);
const aui = useAui();
const messages = useAuiState((state) => state.thread.messages);
const isRunning = useAuiState((state) => state.thread.isRunning);
const isLoading = useAuiState((state) => state.thread.isLoading);
const latestAssistantMessage = getLatestMessage(messages, "assistant");
const latestUserMessage = getLatestMessage(messages, "user");
const latestAssistantText = getMessageText(latestAssistantMessage);
const latestUserText = getMessageText(latestUserMessage);
const latestStatus = latestAssistantMessage?.role === "assistant" ? latestAssistantMessage.status : undefined;
const notificationKey = getNotificationKey(latestAssistantMessage);
const initializedRef = useRef(false);
const lastNotifiedKeyRef = useRef<string | null>(null);
const markRead = useCallback(() => {
lastNotifiedKeyRef.current = notificationKey;
setUnreadCount(0);
}, [notificationKey]);
const open = useCallback(() => {
markRead();
setOpened(true);
}, [markRead]);
const close = useCallback(() => setOpened(false), []);
const toggle = useCallback(() => setOpened((current) => !current), []);
const openIfEnabled = useCallback(() => {
if (enabled) {
open();
const toggle = useCallback(() => {
setOpened((current) => {
if (!current) markRead();
return !current;
});
}, [markRead]);
const sendPrompt = useCallback(
(prompt: string) => {
const text = prompt.trim();
if (text.length === 0) return;
if (isRunning || latestStatus?.type === "requires-action") {
showWarningNotification({
title: t("busy.title"),
message: t("busy.description"),
});
return;
}
aui.thread().append({ role: "user", content: [{ type: "text", text }] });
},
[aui, isRunning, latestStatus?.type, t],
);
useEffect(() => {
if (isLoading) return;
if (!initializedRef.current) {
initializedRef.current = true;
lastNotifiedKeyRef.current = notificationKey;
return;
}
}, [enabled, open]);
useHotkeys([[hotkeys.openAssistant, openIfEnabled]]);
if (notificationKey === null || notificationKey === lastNotifiedKeyRef.current) return;
if (opened) {
lastNotifiedKeyRef.current = notificationKey;
setUnreadCount(0);
return;
}
lastNotifiedKeyRef.current = notificationKey;
setUnreadCount((current) => current + 1);
}, [isLoading, notificationKey, opened]);
useHotkeys([[hotkeys.openAssistant, open]]);
const spotlightItem = useMemo(
() => ({
id: "homarr-assistant",
name: t("spotlight"),
icon: "/logo/logo.png",
description: enabled
? t("spotlightDescription")
: session.status !== "authenticated"
? t("unavailable.signIn")
: isAvailabilityLoading
? t("unavailable.checking")
: t("unavailable.notConfigured"),
unavailable: !enabled,
interaction: () => (enabled ? { type: "javaScript" as const, onSelect: open } : { type: "none" as const }),
description: t("spotlightDescription"),
interaction: () => createAssistantPromptInteraction({ sendPrompt }),
}),
[enabled, isAvailabilityLoading, open, session.status, t],
[sendPrompt, t],
);
useRegisterSpotlightContextResults("homarr-assistant", [spotlightItem], [spotlightItem]);
useRegisterSpotlightContextActions("homarr-assistant", [spotlightItem], [spotlightItem]);
const value = useMemo(() => ({ enabled, opened, open, close, toggle }), [close, enabled, open, opened, toggle]);
const value = useMemo(
() => ({ enabled: true, opened, isRunning, unreadCount, open, close, toggle, sendPrompt }),
[close, isRunning, open, opened, sendPrompt, toggle, unreadCount],
);
return (
<AssistantContext.Provider value={value}>
{enabled ? (
<AssistantRuntime>
{children}
<AssistantPanel opened={opened} onClose={close} />
</AssistantRuntime>
) : (
children
)}
{children}
<AssistantPanel
opened={opened}
onOpen={open}
onClose={close}
onMarkRead={markRead}
isRunning={isRunning}
unreadCount={unreadCount}
latestAssistantText={latestAssistantText}
latestUserText={latestUserText}
latestStatus={latestStatus}
/>
</AssistantContext.Provider>
);
};
interface DisabledAssistantProviderProps extends PropsWithChildren {
description: string;
}
const DisabledAssistantProvider = ({ children, description }: DisabledAssistantProviderProps) => {
const t = useScopedI18n("common.assistant");
const spotlightItem = useMemo(
() => ({
id: "homarr-assistant",
name: t("spotlight"),
icon: "/logo/logo.png",
description,
unavailable: true,
interaction: () => ({ type: "none" as const }),
}),
[description, t],
);
useRegisterSpotlightContextResults("homarr-assistant", [spotlightItem], [spotlightItem]);
useRegisterSpotlightContextActions("homarr-assistant", [spotlightItem], [spotlightItem]);
const value = useMemo(
() => ({
enabled: false,
opened: false,
isRunning: false,
unreadCount: 0,
open: () => undefined,
close: () => undefined,
toggle: () => undefined,
sendPrompt: () => undefined,
}),
[],
);
return <AssistantContext.Provider value={value}>{children}</AssistantContext.Provider>;
};
export const AssistantProvider = ({ children }: PropsWithChildren) => {
const t = useScopedI18n("common.assistant");
const session = useSession();
const { data, isLoading } = clientApi.assistant.getAvailability.useQuery(undefined, {
enabled: session.status === "authenticated",
staleTime: 60_000,
});
const enabled = Boolean(data?.enabled && session.status === "authenticated");
if (enabled) {
return (
<AssistantRuntime>
<EnabledAssistantProvider>{children}</EnabledAssistantProvider>
</AssistantRuntime>
);
}
const unavailableDescription =
session.status !== "authenticated"
? t("unavailable.signIn")
: isLoading
? t("unavailable.checking")
: t("unavailable.notConfigured");
return <DisabledAssistantProvider description={unavailableDescription}>{children}</DisabledAssistantProvider>;
};
@@ -0,0 +1,37 @@
import { describe, expect, it, vi } from "vitest";
import { createAssistantPromptInteraction } from "./assistant-spotlight";
describe("createAssistantPromptInteraction", () => {
it("keeps Spotlight open when the prompt is empty", () => {
const sendPrompt = vi.fn();
const interaction = createAssistantPromptInteraction({ sendPrompt });
const [action] = interaction.useActions({}, " ");
expect(action).toBeDefined();
if (!action) return;
const sendInteraction = action.useInteraction();
expect(sendInteraction.type).toBe("javaScript");
if (sendInteraction.type !== "javaScript") return;
sendInteraction.onSelect();
expect(sendInteraction.closeSpotlightOnTrigger).toBe(false);
expect(sendPrompt).not.toHaveBeenCalled();
});
it("trims and sends a prompt in the background", () => {
const sendPrompt = vi.fn();
const interaction = createAssistantPromptInteraction({ sendPrompt });
const [action] = interaction.useActions({}, " Check my services ");
expect(action).toBeDefined();
if (!action) return;
const sendInteraction = action.useInteraction();
expect(sendInteraction.type).toBe("javaScript");
if (sendInteraction.type !== "javaScript") return;
sendInteraction.onSelect();
expect(sendInteraction.closeSpotlightOnTrigger).toBe(true);
expect(sendPrompt).toHaveBeenCalledWith("Check my services");
});
});
@@ -0,0 +1,63 @@
"use client";
import { Group, Kbd, Stack, Text, ThemeIcon } from "@mantine/core";
import { IconArrowUp, IconRobot } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client";
interface AssistantPromptInteractionOptions {
sendPrompt: (prompt: string) => void;
}
const AssistantPromptDetail = () => {
const t = useScopedI18n("common.assistant");
return (
<Group px="md" py="sm" wrap="nowrap">
<ThemeIcon color="red" variant="light" radius="xl">
<IconRobot size={17} />
</ThemeIcon>
<Stack gap={1}>
<Text fw={600}>{t("spotlightPrompt.title")}</Text>
<Text size="xs" c="dimmed">
{t("spotlightPrompt.description")}
</Text>
</Stack>
</Group>
);
};
const AssistantPromptAction = ({ hasPrompt }: { hasPrompt: boolean }) => {
const t = useScopedI18n("common.assistant");
return (
<Group w="100%" px="md" py="xs" justify="space-between" wrap="nowrap" opacity={hasPrompt ? 1 : 0.6}>
<Group gap="sm" wrap="nowrap">
<IconArrowUp size={18} />
<Text>{hasPrompt ? t("spotlightPrompt.send") : t("spotlightPrompt.empty")}</Text>
</Group>
<Kbd size="sm">Enter</Kbd>
</Group>
);
};
export const createAssistantPromptInteraction = ({ sendPrompt }: AssistantPromptInteractionOptions) =>
({
type: "children",
option: {},
DetailComponent: AssistantPromptDetail,
useActions: (_options: Record<string, unknown>, query: string) => {
const prompt = query.trim();
return [
{
key: "send-assistant-prompt",
Component: () => <AssistantPromptAction hasPrompt={prompt.length > 0} />,
useInteraction: () => ({
type: "javaScript" as const,
onSelect: () => {
if (prompt.length > 0) sendPrompt(prompt);
},
closeSpotlightOnTrigger: prompt.length > 0,
}),
},
];
},
}) as const;