diff --git a/apps/docs/docs/widgets/assistant/index.mdx b/apps/docs/docs/widgets/assistant/index.mdx index 2a4e22645..9454c6e1f 100644 --- a/apps/docs/docs/widgets/assistant/index.mdx +++ b/apps/docs/docs/widgets/assistant/index.mdx @@ -5,7 +5,6 @@ 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 "."; @@ -28,15 +27,8 @@ touch devices. When a board layout does not provide enough height for conversati 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 - - - -**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. +The widget follows whichever conversation is active in the user-menu panel, `Shift + A`, or the command menu. This +keeps every assistant entry point synchronized without storing a user's private conversation details in board settings. ### Conversation features diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx index ddb379fae..66c3819c1 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx @@ -44,9 +44,11 @@ export const createBoardContentPage = >( page: async ({ params }: { params: Promise }) => { const resolvedParams = await params; const queryClient = getQueryClient(); - const session = await auth(); + const sessionPromise = auth(); + const boardPromise = getInitialBoard(resolvedParams); + const session = await sessionPromise; - const board = await getInitialBoard(resolvedParams).catch((error) => { + const board = await boardPromise.catch((error) => { if (error instanceof TRPCError && error.code === "NOT_FOUND") { if (!session) { logger.debug("No home board found for anonymous user, redirecting to login"); diff --git a/apps/nextjs/src/app/[locale]/manage/assistant/_components/assistant-configuration.tsx b/apps/nextjs/src/app/[locale]/manage/assistant/_components/assistant-configuration.tsx index 5f75c9227..e1a2fbe75 100644 --- a/apps/nextjs/src/app/[locale]/manage/assistant/_components/assistant-configuration.tsx +++ b/apps/nextjs/src/app/[locale]/manage/assistant/_components/assistant-configuration.tsx @@ -2,6 +2,7 @@ import type { ReactNode } from "react"; import { useEffect, useMemo, useState } from "react"; +import { useRouter } from "next/navigation"; import { Accordion, ActionIcon, @@ -140,6 +141,7 @@ const ConfigurationSkeleton = ({ label }: { label: string }) => ( export const AssistantConfiguration = () => { const t = useScopedI18n("management.page.settings.section.assistant"); + const router = useRouter(); const utils = clientApi.useUtils(); const { data: configuration, isLoading } = clientApi.assistant.getAdminConfiguration.useQuery(); const [provider, setProvider] = useState("openrouter"); @@ -268,6 +270,7 @@ export const AssistantConfiguration = () => { utils.assistant.getAvailability.invalidate(), utils.assistant.getRuntimeOptions.invalidate(), ]); + router.refresh(); showSuccessNotification({ title: t("notification.saved.title"), message: t("notification.saved.message"), diff --git a/apps/nextjs/src/components/assistant/assistant-panel.tsx b/apps/nextjs/src/components/assistant/assistant-panel.tsx index 5a34c70ab..9ee5cfafa 100644 --- a/apps/nextjs/src/components/assistant/assistant-panel.tsx +++ b/apps/nextjs/src/components/assistant/assistant-panel.tsx @@ -159,6 +159,7 @@ import { getAssistantIconSearchQuery } from "./assistant-tool-label"; import { getAssistantProviderQuotaLevel, isAssistantProviderUnavailable } from "./assistant-provider-quota"; import { getAssistantToolTraceTarget } from "./assistant-tool-trace"; import { getSafeAssistantHttpUrl } from "./assistant-url"; +import { isEscapeOwnedByNestedOverlay } from "../board/advanced-focus/escape"; // Shiki's web grammar bundle is intentionally loaded only when a settled response contains a code // block. Most assistant conversations never need it, so it should not inflate the global dashboard @@ -407,7 +408,27 @@ type MarkdownSpanProps = ComponentPropsWithoutRef<"span"> & { "data-directive-type"?: string; }; -const AssistantDirectiveEntitiesContext = createContext([]); +interface AssistantDirectiveEntitiesState { + entities: AssistantDirectiveEntity[]; + isLoading: boolean; +} + +const AssistantDirectiveEntitiesContext = createContext({ + entities: [], + isLoading: false, +}); + +const AssistantDirectiveEntitiesProvider = ({ children }: { children: ReactNode }) => { + const { data: entities = [], isLoading } = clientApi.assistant.getContextEntities.useQuery(undefined, { + staleTime: 60_000, + }); + + return ( + + {children} + + ); +}; const AssistantMarkdownSpan = ({ children, @@ -417,7 +438,7 @@ const AssistantMarkdownSpan = ({ "data-directive-type": directiveType, ...props }: MarkdownSpanProps) => { - const entities = useContext(AssistantDirectiveEntitiesContext); + const { entities } = useContext(AssistantDirectiveEntitiesContext); if (assistantDirective === "true" && directiveId && directiveLabel && directiveType) { const entity = resolveAssistantDirectiveEntity(entities, { id: directiveId, @@ -444,26 +465,20 @@ const assistantMarkdownComponents = unstable_memoizeMarkdownComponents({ SyntaxHighlighter: AssistantSyntaxHighlighter, }); -const AssistantTextPart = () => { - const { data: entities = [] } = clientApi.assistant.getContextEntities.useQuery(undefined, { staleTime: 60_000 }); - - return ( - - - - ); -}; +const AssistantTextPart = () => ( + +); const UserTextPart = () => { const { text } = useMessagePartText(); - const { data: entities = [] } = clientApi.assistant.getContextEntities.useQuery(undefined, { staleTime: 60_000 }); + const { entities } = useContext(AssistantDirectiveEntitiesContext); const segments = parseAssistantDirectives(text); return ( @@ -548,7 +563,7 @@ const SentAttachment = () => ; const HiddenMessagePart = () => null; const ReasoningPart = (_props: ReasoningMessagePartProps) => ( - + { const aui = useAui(); const mentionViewportRef = useRef(null); const slashViewportRef = useRef(null); - const { data: entities = [], isLoading } = clientApi.assistant.getContextEntities.useQuery(undefined, { - staleTime: 60_000, - }); + const { entities, isLoading } = useContext(AssistantDirectiveEntitiesContext); const categories = useMemo( () => (["app", "integration", "board", "widget"] as const).map((type) => ({ @@ -2914,7 +2927,7 @@ const AssistantActivityBar = ({ return ( - + @@ -2996,7 +3009,7 @@ export const AssistantConversationSurface = ({ const scrollToLatestBehavior = isRunning || reducedMotion ? "instant" : "smooth"; return ( - <> + {onWindowClose && onMinimize && ( - + ); }; @@ -3135,9 +3148,19 @@ export const AssistantPanel = ({ }: AssistantPanelProps) => { const t = useScopedI18n("common.assistant"); const previousFocusRef = useRef(null); + const panelRef = useRef(null); useWindowEvent("keydown", (event) => { - if (opened && event.key === "Escape") onClose(); + if ( + !opened || + event.key !== "Escape" || + event.defaultPrevented || + event.isComposing || + isEscapeOwnedByNestedOverlay(event.target, panelRef.current) + ) + return; + event.preventDefault(); + onClose(); }); useEffect(() => { if (!opened) { @@ -3174,7 +3197,7 @@ export const AssistantPanel = ({ /> )} {opened && ( - + ) = ); }; -const EnabledAssistantBoardWidget = ({ options, width, height, isEditMode }: WidgetComponentProps<"assistant">) => { - const t = useI18n(); +const EnabledAssistantBoardWidget = ({ width, height, isEditMode }: WidgetComponentProps<"assistant">) => { const board = useRequiredBoard(); 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(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); - } + const selectModel = (modelId: string) => { + preferences.setModelId(modelId); + const threadListItem = aui.threadListItem(); + if (!threadListItem.getState().remoteId) return; + threadListItem.updateCustom({ ...threadListItem.getState().custom, modelId }); }; - if (options.conversationMode === "pinned" && pinnedConversation === null) { - return ( - - ); - } - - if (!pinnedConversationIsActive && pinnedConversation) { - return ( - - - - ); - } - if (width < 300 || height < 280) { return ; } diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 6797df4a7..60e60c971 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -5418,30 +5418,6 @@ "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": "Choose the saved conversation this widget should keep available.", - "placeholder": "Select a saved 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." diff --git a/packages/widgets/src/assistant/definition.spec.ts b/packages/widgets/src/assistant/definition.spec.ts index d97b51346..30b16ebee 100644 --- a/packages/widgets/src/assistant/definition.spec.ts +++ b/packages/widgets/src/assistant/definition.spec.ts @@ -8,16 +8,9 @@ describe("assistant widget definition", () => { expect(supportsAdvancedFocus(definition)).toBe(false); }); - it("follows the current conversation by default", () => { + it("does not persist private conversation data in board options", () => { 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); + expect(options).toEqual({}); }); }); diff --git a/packages/widgets/src/assistant/index.ts b/packages/widgets/src/assistant/index.ts index fad37db51..e93951e35 100644 --- a/packages/widgets/src/assistant/index.ts +++ b/packages/widgets/src/assistant/index.ts @@ -1,50 +1,12 @@ 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, isError } = clientApi.assistant.listThreads.useQuery(); - const normalizedQuery = query.trim().toLocaleLowerCase(); - return { - isPending, - isError, - 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", - }, - }, - ); + return optionsBuilder.from(() => ({})); }, }).withDynamicImport(() => import("./component"));