From 63699b16100ced5e8dd69865bfb8634ee59e8dd2 Mon Sep 17 00:00:00 2001 From: ajnart Date: Thu, 30 Jul 2026 11:06:16 +0200 Subject: [PATCH] feat: streamline assistant interactions --- apps/docs/docs/management/assistant.mdx | 16 +- .../assistant/assistant-panel.module.css | 233 ++++++++---- .../components/assistant/assistant-panel.tsx | 354 +++++++++++++----- .../assistant/assistant-provider.tsx | 217 +++++++++-- .../assistant/assistant-spotlight.spec.ts | 37 ++ .../assistant/assistant-spotlight.tsx | 63 ++++ packages/translation/src/lang/en.json | 23 +- 7 files changed, 721 insertions(+), 222 deletions(-) create mode 100644 apps/nextjs/src/components/assistant/assistant-spotlight.spec.ts create mode 100644 apps/nextjs/src/components/assistant/assistant-spotlight.tsx diff --git a/apps/docs/docs/management/assistant.mdx b/apps/docs/docs/management/assistant.mdx index 6c87a4d5c..7bf6df1fe 100644 --- a/apps/docs/docs/management/assistant.mdx +++ b/apps/docs/docs/management/assistant.mdx @@ -9,8 +9,15 @@ tags: # Assistant Administrators can configure the instance-wide AI assistant from **Management → Assistant**. -It is available to signed-in users from the user menu and the `Cmd/Ctrl + K` command menu. -`Cmd/Ctrl + Shift + K` opens it directly. +It is available to signed-in users from the user menu and the `Cmd/Ctrl + K` command menu. Selecting +**Ask Homarr Assistant** in the command menu turns its search field into a prompt composer. Press +`Enter` to send the prompt in the background without opening the conversation. + +While a response is running, a compact activity bar appears at the bottom of the page and the user-menu +avatar shows a thinking indicator. The activity bar can be opened to follow the response. Completed +responses add an unread count to the user menu until they are opened or dismissed. + +`Cmd/Ctrl + Shift + K` opens the compact conversation panel directly. When the assistant is unavailable, its command-menu result remains visible but disabled and explains that an administrator must configure and enable it. @@ -56,8 +63,9 @@ uses. Choose OpenRouter or another provider when you prefer a provider-neutral p ## Conversations and tools -Conversations and messages are stored per user in the Homarr database. Users can start, revisit, archive, -and delete conversations from the assistant panel. +Conversations and messages are stored per user in the Homarr database. The assistant stays out of the +page layout until it is opened. Users can start, revisit, archive, and delete conversations from the +conversation menu in the floating panel. The assistant can use the same internal tool definitions as the [Homarr MCP server](./mcp). Each tool call runs as the signed-in user, so normal Homarr permission checks still apply. Read-only diff --git a/apps/nextjs/src/components/assistant/assistant-panel.module.css b/apps/nextjs/src/components/assistant/assistant-panel.module.css index 4cb0d06a7..439552e5f 100644 --- a/apps/nextjs/src/components/assistant/assistant-panel.module.css +++ b/apps/nextjs/src/components/assistant/assistant-panel.module.css @@ -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 { diff --git a/apps/nextjs/src/components/assistant/assistant-panel.tsx b/apps/nextjs/src/components/assistant/assistant-panel.tsx index 046b67af4..7664a422d 100644 --- a/apps/nextjs/src/components/assistant/assistant-panel.tsx +++ b/apps/nextjs/src/components/assistant/assistant-panel.tsx @@ -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 = () => ; @@ -194,13 +216,24 @@ const AssistantMessage = () => ( ); +const HistorySelectContext = createContext<() => void>(() => undefined); + const ThreadListItem = () => { const t = useScopedI18n("common.assistant"); + const onSelect = useContext(HistorySelectContext); return ( - @@ -221,11 +254,20 @@ const ThreadListItem = () => { const ArchivedThreadListItem = () => { const t = useScopedI18n("common.assistant"); + const onSelect = useContext(HistorySelectContext); return ( - @@ -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 ( - + + + + + + + + + + {t("conversations")} + + + + + + + {t("archived")} + + + + + + + ); +}; + +const ConversationHistory = () => { + const t = useScopedI18n("common.assistant"); + const [opened, setOpened] = useState(false); + return ( + - - - - - + + + + setOpened(false)} /> + + ); }; @@ -366,67 +423,160 @@ const Composer = () => { ); }; -export const AssistantPanel = ({ opened, onClose }: AssistantPanelProps) => { +const AssistantActivityBar = ({ + isRunning, + unreadCount, + latestAssistantText, + latestUserText, + latestStatus, + onOpen, + onMarkRead, +}: Omit) => { 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 ( - - - - -
- - {t("title")} + + + + {isRunning ? ( + + + + ) : ( + + {failed ? ( + + ) : needsApproval ? ( + + ) : ( + + )} + + )} + + + + {title} + + {unreadCount > 0 && ( + + {unreadCount > 99 ? "99+" : unreadCount} + + )} + + + {detail} - - {t("subtitle")} - -
+
+
- } - closeButtonProps={{ icon: , "aria-label": t("close") }} - > - - setHistoryOpened(false)} /> - - setHistoryOpened(true)} - aria-label={t("openHistory")} - > - - - - - - - - - - - - - - - - - + + {!isRunning && !needsApproval && ( + + + + )} + + ); +}; + +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 && ( + + )} + {opened && ( + + + + + + +
+ + {t("title")} + + + {isRunning ? t("activity.thinking") : t("subtitle")} + +
+
+ + + + + + + + + + + + + +
+ + + + + + + + + + + + + + +
+ )} + ); }; diff --git a/apps/nextjs/src/components/assistant/assistant-provider.tsx b/apps/nextjs/src/components/assistant/assistant-provider.tsx index a342c042e..06f8e20cd 100644 --- a/apps/nextjs/src/components/assistant/assistant-provider.tsx +++ b/apps/nextjs/src/components/assistant/assistant-provider.tsx @@ -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(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(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 ( - {enabled ? ( - - {children} - - - ) : ( - children - )} + {children} + ); }; + +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 {children}; +}; + +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 ( + + {children} + + ); + } + + const unavailableDescription = + session.status !== "authenticated" + ? t("unavailable.signIn") + : isLoading + ? t("unavailable.checking") + : t("unavailable.notConfigured"); + + return {children}; +}; diff --git a/apps/nextjs/src/components/assistant/assistant-spotlight.spec.ts b/apps/nextjs/src/components/assistant/assistant-spotlight.spec.ts new file mode 100644 index 000000000..896bbfab5 --- /dev/null +++ b/apps/nextjs/src/components/assistant/assistant-spotlight.spec.ts @@ -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"); + }); +}); diff --git a/apps/nextjs/src/components/assistant/assistant-spotlight.tsx b/apps/nextjs/src/components/assistant/assistant-spotlight.tsx new file mode 100644 index 000000000..2319cf82a --- /dev/null +++ b/apps/nextjs/src/components/assistant/assistant-spotlight.tsx @@ -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 ( + + + + + + {t("spotlightPrompt.title")} + + {t("spotlightPrompt.description")} + + + + ); +}; + +const AssistantPromptAction = ({ hasPrompt }: { hasPrompt: boolean }) => { + const t = useScopedI18n("common.assistant"); + return ( + + + + {hasPrompt ? t("spotlightPrompt.send") : t("spotlightPrompt.empty")} + + Enter + + ); +}; + +export const createAssistantPromptInteraction = ({ sendPrompt }: AssistantPromptInteractionOptions) => + ({ + type: "children", + option: {}, + DetailComponent: AssistantPromptDetail, + useActions: (_options: Record, query: string) => { + const prompt = query.trim(); + return [ + { + key: "send-assistant-prompt", + Component: () => 0} />, + useInteraction: () => ({ + type: "javaScript" as const, + onSelect: () => { + if (prompt.length > 0) sendPrompt(prompt); + }, + closeSpotlightOnTrigger: prompt.length > 0, + }), + }, + ]; + }, + }) as const; diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 79dbb8f0d..014aae861 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -1969,6 +1969,7 @@ "switchToLightMode": "Switch to light mode", "management": "Management", "assistant": "Homarr Assistant", + "assistantThinking": "Thinking", "docker": "Docker", "preferences": "Your preferences", "logout": "Logout", @@ -1984,7 +1985,13 @@ "open": "Open Homarr Assistant", "close": "Close assistant", "spotlight": "Ask Homarr Assistant", - "spotlightDescription": "Start or continue a conversation with your Homarr instance.", + "spotlightDescription": "Type and send a prompt without leaving the command menu.", + "spotlightPrompt": { + "title": "Ask Assistant", + "description": "Type a prompt above. Enter sends it in the background.", + "send": "Send prompt", + "empty": "Type a prompt above" + }, "unavailable": { "checking": "Checking whether the assistant is available…", "notConfigured": "An administrator must configure and enable the assistant first.", @@ -2029,6 +2036,20 @@ "description": "Try again. If the problem continues, ask an administrator to verify the configured model endpoint.", "retry": "Try again" }, + "busy": { + "title": "The assistant is already working", + "description": "Wait for the current response or open it to stop the run." + }, + "activity": { + "thinking": "Assistant is thinking", + "working": "Working on your prompt", + "ready": "Response ready", + "completed": "Open the response", + "approval": "Approval needed", + "failed": "Assistant needs attention", + "expand": "Open the assistant response", + "dismiss": "Dismiss assistant activity" + }, "scrollToLatest": "Scroll to latest", "approvalRequired": "Approval required", "complete": "Complete",