fix: address assistant review findings

This commit is contained in:
ajnart committed 2026-08-14 11:32:11 +02:00
1 parent 87f264a286
commit b988e431e8
8 files changed
+71 -180

No files matched your search

+2 -10
View File
@@ -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
<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.
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
@@ -44,9 +44,11 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
page: async ({ params }: { params: Promise<TParams> }) => {
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");
@@ -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<AssistantProvider>("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"),
@@ -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<AssistantDirectiveEntity[]>([]);
interface AssistantDirectiveEntitiesState {
entities: AssistantDirectiveEntity[];
isLoading: boolean;
}
const AssistantDirectiveEntitiesContext = createContext<AssistantDirectiveEntitiesState>({
entities: [],
isLoading: false,
});
const AssistantDirectiveEntitiesProvider = ({ children }: { children: ReactNode }) => {
const { data: entities = [], isLoading } = clientApi.assistant.getContextEntities.useQuery(undefined, {
staleTime: 60_000,
});
return (
<AssistantDirectiveEntitiesContext.Provider value={{ entities, isLoading }}>
{children}
</AssistantDirectiveEntitiesContext.Provider>
);
};
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 (
<AssistantDirectiveEntitiesContext.Provider value={entities}>
<MarkdownTextPrimitive
className={classes.messageMarkdown}
preprocess={normalizeAssistantMarkdown}
remarkPlugins={markdownRemarkPlugins}
components={assistantMarkdownComponents}
componentsByLanguage={{ mermaid: { SyntaxHighlighter: MermaidDiagram } }}
defer
/>
</AssistantDirectiveEntitiesContext.Provider>
);
};
const AssistantTextPart = () => (
<MarkdownTextPrimitive
className={classes.messageMarkdown}
preprocess={normalizeAssistantMarkdown}
remarkPlugins={markdownRemarkPlugins}
components={assistantMarkdownComponents}
componentsByLanguage={{ mermaid: { SyntaxHighlighter: MermaidDiagram } }}
defer
/>
);
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 (
<span className={classes.messageText}>
@@ -548,7 +563,7 @@ const SentAttachment = () => <Attachment />;
const HiddenMessagePart = () => null;
const ReasoningPart = (_props: ReasoningMessagePartProps) => (
<AssistantDirectiveEntitiesContext.Provider value={[]}>
<AssistantDirectiveEntitiesContext.Provider value={{ entities: [], isLoading: false }}>
<MarkdownTextPrimitive
className={`${classes.messageMarkdown} ${classes.reasoningText}`}
preprocess={normalizeAssistantMarkdown}
@@ -2264,9 +2279,7 @@ const ComposerTriggers = () => {
const aui = useAui();
const mentionViewportRef = useRef<HTMLDivElement>(null);
const slashViewportRef = useRef<HTMLDivElement>(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 (
<Box component="output" className={classes.activityBar} aria-live="polite">
<UnstyledButton className={classes.activityTrigger} onClick={onOpen} aria-label={t("activity.expand")}>
<UnstyledButton className={classes.activityTrigger} onClick={onOpen}>
<Group gap="sm" wrap="nowrap">
<ThemeIcon variant="light" color={activityColor} radius="xl">
<AssistantDotMatrix state={activityState} label={title} role="presentation" aria-hidden />
@@ -2996,7 +3009,7 @@ export const AssistantConversationSurface = ({
const scrollToLatestBehavior = isRunning || reducedMotion ? "instant" : "smooth";
return (
<>
<AssistantDirectiveEntitiesProvider>
<Group className={classes.panelHeader} justify="space-between" wrap="nowrap" gap="xs">
{onWindowClose && onMinimize && (
<Group
@@ -3107,7 +3120,7 @@ export const AssistantConversationSurface = ({
</ThreadPrimitive.Root>
</ReasoningVisibilityContext.Provider>
</AssistantQuestionPortalProvider>
</>
</AssistantDirectiveEntitiesProvider>
);
};
@@ -3135,9 +3148,19 @@ export const AssistantPanel = ({
}: AssistantPanelProps) => {
const t = useScopedI18n("common.assistant");
const previousFocusRef = useRef<HTMLElement | null>(null);
const panelRef = useRef<HTMLDialogElement>(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 && (
<dialog className={classes.floatingPanel} aria-label={t("title")} open>
<dialog ref={panelRef} className={classes.floatingPanel} aria-label={t("title")} open>
<AssistantConversationSurface
isRunning={isRunning}
pendingAction={pendingAction}
@@ -1,10 +1,9 @@
"use client";
import type { CSSProperties, ReactNode } from "react";
import { useCallback, useState } from "react";
import { ActionIcon, Box, Center, Group, Stack, Text, ThemeIcon, Tooltip } from "@mantine/core";
import { IconArrowsMaximize, IconRobot } from "@tabler/icons-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";
@@ -30,81 +29,22 @@ export const AssistantBoardWidget = (props: WidgetComponentProps<"assistant">) =
);
};
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<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);
}
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 (
<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} />;
}
-24
View File
@@ -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."
@@ -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({});
});
});
+1 -39
View File
@@ -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"));