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

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