From d241bac55d2bdd4060aef1ce6af6b376df744251 Mon Sep 17 00:00:00 2001 From: Thomas Date: Thu, 20 Aug 2026 16:26:23 +0200 Subject: [PATCH] feat: improve assistant conversation experience (#6636) --- apps/docs/docs/management/assistant.mdx | 5 +- .../assistant/assistant-auto-approval.spec.ts | 40 ++ .../assistant/assistant-auto-approval.tsx | 19 +- .../assistant/assistant-context.tsx | 4 + .../components/assistant/assistant-gate.tsx | 4 + .../assistant/assistant-human-tools.tsx | 127 ++-- .../assistant/assistant-panel.module.css | 227 +++++-- .../components/assistant/assistant-panel.tsx | 554 ++++++++++++------ .../assistant/assistant-provider.tsx | 116 +++- .../assistant-runtime-provider.spec.ts | 23 +- .../assistant/assistant-runtime-provider.tsx | 47 +- .../components/assistant/assistant-widget.tsx | 86 ++- packages/translation/src/lang/en.json | 34 +- 13 files changed, 897 insertions(+), 389 deletions(-) diff --git a/apps/docs/docs/management/assistant.mdx b/apps/docs/docs/management/assistant.mdx index 2ac43a19c..aec1bde4e 100644 --- a/apps/docs/docs/management/assistant.mdx +++ b/apps/docs/docs/management/assistant.mdx @@ -44,12 +44,15 @@ host gateway address. The assistant can work with boards, apps, integrations, widgets, Custom Widgets, and connected services. Use `@` to add Homarr items as context, `/` for shortcuts, and the paperclip for supported files. +Use **Conversations** to search, switch, rename, export, or delete threads. + Each request includes the user's name, current page and board, home board, server time, browser time zone, and a small summary of accessible Homarr resources. Live status is fetched only when needed. ## Permissions and privacy -- Read-only actions can run automatically. Changes require approval. +- Read-only actions can run automatically. Changes require approval by default. Automatic approvals apply only to the + current conversation, turn off when you switch conversations, and never answer questions for you. - Homarr permissions apply to every tool call and mentioned item. - API keys and custom headers are encrypted and never returned to the browser. - Conversations are stored per user and can be exported or deleted. diff --git a/apps/nextjs/src/components/assistant/assistant-auto-approval.spec.ts b/apps/nextjs/src/components/assistant/assistant-auto-approval.spec.ts index 485e708e6..0aae58d36 100644 --- a/apps/nextjs/src/components/assistant/assistant-auto-approval.spec.ts +++ b/apps/nextjs/src/components/assistant/assistant-auto-approval.spec.ts @@ -104,6 +104,46 @@ describe("assistant automatic approval tracker", () => { await act(async () => root.unmount()); }); + test("does not approve a ready action while switching conversations", async () => { + const container = document.createElement("div"); + containers.push(container); + document.body.append(container); + const root = createRoot(container); + let confirmationCount = 0; + const renderConversation = async (conversationId: string, ready: boolean) => { + await act(async () => { + root.render( + createElement( + AssistantAutoApprovalProvider, + { conversationId }, + createElement( + Fragment, + null, + createElement(AutoApprovalProbe), + createElement(AutomaticActionProbe, { + ready, + completed: false, + confirm: () => { + confirmationCount += 1; + }, + }), + ), + ), + ); + }); + }; + + await renderConversation("local-thread-1", false); + act(() => currentAutoApproval?.setEnabled(true)); + await renderConversation("local-thread-2", true); + + expect(confirmationCount).toBe(0); + expect(currentAutoApproval?.enabled).toBe(false); + expect(automaticActionInProgress).toBe(false); + + await act(async () => root.unmount()); + }); + test("clears claimed calls when automatic approvals are turned off", async () => { const container = document.createElement("div"); containers.push(container); diff --git a/apps/nextjs/src/components/assistant/assistant-auto-approval.tsx b/apps/nextjs/src/components/assistant/assistant-auto-approval.tsx index 0b49efe90..64b4a3ec6 100644 --- a/apps/nextjs/src/components/assistant/assistant-auto-approval.tsx +++ b/apps/nextjs/src/components/assistant/assistant-auto-approval.tsx @@ -52,6 +52,8 @@ export const AssistantAutoApprovalProvider = ({ const trackerRef = useRef(createAssistantAutoApprovalTracker(AUTO_APPROVAL_MAX_ATTEMPTS)); const retryTimersRef = useRef(new Map()); const previousConversationIdRef = useRef(conversationId); + const conversationMatches = previousConversationIdRef.current === conversationId; + const effectiveEnabled = conversationMatches && enabled; const clearRetryTimer = useCallback((toolCallId: string) => { const timer = retryTimersRef.current.get(toolCallId); @@ -86,7 +88,7 @@ export const AssistantAutoApprovalProvider = ({ const requestAction = useCallback( (toolCallId: string, confirm: () => void) => { - if (!enabled || !trackerRef.current.claim(toolCallId)) return false; + if (!effectiveEnabled || !trackerRef.current.claim(toolCallId)) return false; try { confirm(); @@ -105,7 +107,7 @@ export const AssistantAutoApprovalProvider = ({ return false; } }, - [clearRetryTimer, enabled], + [clearRetryTimer, effectiveEnabled], ); const completeAction = useCallback( @@ -116,10 +118,15 @@ export const AssistantAutoApprovalProvider = ({ [clearRetryTimer], ); - const value = useMemo( - () => ({ enabled, setEnabled, requestAction, completeAction, retryRevision }), - [completeAction, enabled, requestAction, retryRevision, setEnabled], - ); + const value = useMemo(() => { + return { + enabled: effectiveEnabled, + setEnabled, + requestAction, + completeAction, + retryRevision, + }; + }, [completeAction, effectiveEnabled, requestAction, retryRevision, setEnabled]); return {children}; }; diff --git a/apps/nextjs/src/components/assistant/assistant-context.tsx b/apps/nextjs/src/components/assistant/assistant-context.tsx index b5d2f0a51..bc87c87ad 100644 --- a/apps/nextjs/src/components/assistant/assistant-context.tsx +++ b/apps/nextjs/src/components/assistant/assistant-context.tsx @@ -20,11 +20,15 @@ export interface AssistantContextValue { isRunning: boolean; isRefreshing: boolean; unreadCount: number; + hasVisibleWidget: boolean; + activeWidgetId: string | null; open: () => void; close: () => void; toggle: () => void; sendPrompt: (prompt: string) => boolean; refreshCurrentView: () => Promise; + setWidgetVisible: (widgetId: string, visible: boolean) => void; + activateWidget: (widgetId: string) => void; } export const AssistantContext = createContext(null); diff --git a/apps/nextjs/src/components/assistant/assistant-gate.tsx b/apps/nextjs/src/components/assistant/assistant-gate.tsx index 2a3ddbbb3..162e5c99f 100644 --- a/apps/nextjs/src/components/assistant/assistant-gate.tsx +++ b/apps/nextjs/src/components/assistant/assistant-gate.tsx @@ -34,11 +34,15 @@ const DisabledAssistant = ({ children, description }: PropsWithChildren<{ descri isRunning: false, isRefreshing: false, unreadCount: 0, + hasVisibleWidget: false, + activeWidgetId: null, open: () => undefined, close: () => undefined, toggle: () => undefined, sendPrompt: () => false, refreshCurrentView: () => Promise.resolve(), + setWidgetVisible: () => undefined, + activateWidget: () => undefined, }), [description], ); diff --git a/apps/nextjs/src/components/assistant/assistant-human-tools.tsx b/apps/nextjs/src/components/assistant/assistant-human-tools.tsx index 4f0075398..81f33373f 100644 --- a/apps/nextjs/src/components/assistant/assistant-human-tools.tsx +++ b/apps/nextjs/src/components/assistant/assistant-human-tools.tsx @@ -29,7 +29,7 @@ import { AppForm } from "@homarr/forms-collection"; import { useScopedI18n } from "@homarr/translation/client"; import type { appManageSchema } from "@homarr/validation/app"; -import { getAssistantAffirmativeResult, getAssistantAskUserOptionKind } from "./assistant-ask-user"; +import { getAssistantAskUserOptionKind } from "./assistant-ask-user"; import { useAssistantAutomaticAction } from "./assistant-auto-approval"; import { AssistantAutomaticActionProgress } from "./assistant-automatic-action-progress"; import classes from "./assistant-panel.module.css"; @@ -67,7 +67,6 @@ export const AssistantAskUserTool = ({ result, addResult, status, - toolCallId, }: ToolCallMessagePartProps) => { const t = useScopedI18n("common.assistant.askUser"); const [showOther, setShowOther] = useState(false); @@ -75,17 +74,6 @@ export const AssistantAskUserTool = ({ const [submitting, setSubmitting] = useState(false); const otherInputRef = useRef(null); const options = Array.isArray(args?.options) ? args.options : []; - const affirmativeResult = getAssistantAffirmativeResult(options); - const hasValidQuestion = hasCompleteAssistantToolArguments(status) && Boolean(args?.question) && options.length >= 2; - const autoConfirming = useAssistantAutomaticAction({ - toolCallId, - ready: result === undefined && hasValidQuestion && affirmativeResult !== undefined, - completed: result !== undefined, - confirm: () => { - if (!affirmativeResult) return; - addResult(affirmativeResult); - }, - }); useEffect(() => { if (showOther) otherInputRef.current?.focus(); @@ -165,67 +153,62 @@ export const AssistantAskUserTool = ({ - {autoConfirming ? ( - - ) : ( - options.map((option, index) => { - const kind = getAssistantAskUserOptionKind(option); - const icon = - kind === "affirmative" ? ( - - ) : kind === "negative" ? ( - - ) : ( - - ); - const color = kind === "affirmative" ? "green" : kind === "negative" ? "red" : "gray"; - - return ( - - submitResult({ answer: option.label, optionId: option.id, optionKind: kind, source: "option" }) - } - > - - {icon} - - - - {option.label} - - {option.description && ( - - {option.description} - - )} - - - {t(`optionKind.${kind}`)} - - + {options.map((option, index) => { + const kind = getAssistantAskUserOptionKind(option); + const icon = + kind === "affirmative" ? ( + + ) : kind === "negative" ? ( + + ) : ( + ); - }) - )} + const color = kind === "affirmative" ? "green" : kind === "negative" ? "red" : "gray"; - {!autoConfirming && - args.allowOther !== false && + return ( + + submitResult({ answer: option.label, optionId: option.id, optionKind: kind, source: "option" }) + } + > + + {icon} + + + + {option.label} + + {option.description && ( + + {option.description} + + )} + + + {t(`optionKind.${kind}`)} + + + ); + })} + + {args.allowOther !== false && (showOther ? ( :last-child { + .composerContextLabel { + display: none; + } + + .panelThreadTitle { display: none; } } @@ -298,17 +332,70 @@ } .historyMenu { + min-width: 0; + overflow: hidden; background: var(--mantine-color-body); } +.historyMenu :global(.mantine-ScrollArea-content) { + display: block; + width: 100%; + min-width: 0; +} + .historyItem { + display: flex; + width: 100%; + min-width: 0; + max-width: 100%; + box-sizing: border-box; + align-items: center; + gap: 0.2rem; + overflow: hidden; + padding: 0.15rem 0.2rem 0.15rem 0.35rem; border-radius: var(--mantine-radius-md); } -.historyItem:hover { +.historyItem:hover, +.historyItem[data-active] { background: var(--mantine-color-default-hover); } +.historyItemTrigger { + min-width: 0; + flex: 1; + overflow: hidden; + padding: 0.35rem 0.25rem; + text-align: start; +} + +.historyItemTrigger > span { + display: block; + width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.historyRenameInput { + min-width: 0; + flex: 1; +} + +.historyItemTrigger:focus-visible { + outline: 2px solid var(--mantine-primary-color-filled); + outline-offset: -1px; +} + +.historyItemMenu { + flex: 0 0 auto; + opacity: 1; +} + +.historyItemActions { + flex: 0 0 auto; +} + .thread { position: relative; display: flex; @@ -341,6 +428,11 @@ margin-block: 0.9rem; } +.message[data-static] { + contain-intrinsic-size: auto 12rem; + content-visibility: auto; +} + .userMessageWrap { width: fit-content; max-width: 86%; @@ -706,24 +798,21 @@ .pendingQuestionDock { min-height: 0; - flex: 1 1 auto; + max-height: min(22rem, 48dvh); + flex: 0 1 auto; overflow-y: auto; overscroll-behavior: contain; padding: 0.75rem; + border-top: 1px solid var(--mantine-color-default-border); background: var(--mantine-color-body); } -.thread[data-pending-question] .viewport { - display: none; -} - .pendingQuestionTarget { display: flex; width: 100%; - min-height: 100%; max-width: 44rem; margin-inline: auto; - align-items: safe center; + align-items: flex-start; } .pendingQuestionTarget .humanTool { @@ -1091,6 +1180,43 @@ flex: 0 1 auto; } +.messageMoreMenu { + z-index: 410; + min-width: 11rem; + padding: 0.25rem; + border: 1px solid var(--mantine-color-default-border); + border-radius: var(--mantine-radius-md); + color: var(--mantine-color-text); + background: var(--mantine-color-body); + box-shadow: var(--mantine-shadow-md); +} + +.messageMoreItem { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.45rem 0.55rem; + border-radius: var(--mantine-radius-sm); + font-size: var(--mantine-font-size-sm); + cursor: pointer; + outline: 0; +} + +.messageMoreItem[data-highlighted] { + background: var(--mantine-color-default-hover); +} + +.messageMoreItem:focus-visible { + outline: 2px solid var(--mantine-primary-color-filled); + outline-offset: -2px; +} + +.messageMoreSeparator { + height: 1px; + margin-block: 0.2rem; + background: var(--mantine-color-default-border); +} + .userActions { min-height: 1.75rem; } @@ -1173,13 +1299,13 @@ .composerContext { display: flex; - width: 1.75rem; + width: auto; height: 1.75rem; align-items: center; justify-content: center; - padding: 0; + padding-inline: 0.3rem 0.42rem; border: 1px solid var(--mantine-color-default-border); - border-radius: 50%; + border-radius: var(--mantine-radius-sm); color: var(--mantine-color-text); background: var(--mantine-color-body); } @@ -1415,10 +1541,6 @@ pointer-events: none; } -.composerHints { - padding-inline: 0.45rem; -} - .composerFooter { min-width: 0; max-width: 100%; @@ -1641,12 +1763,12 @@ @keyframes assistant-panel-in { from { opacity: 0; - transform: translate(-50%, 0.75rem) scale(0.985); + transform: translateY(0.75rem) scale(0.985); } to { opacity: 1; - transform: translate(-50%, 0) scale(1); + transform: translateY(0) scale(1); } } @@ -1656,10 +1778,11 @@ } .floatingPanel { - bottom: max(0.5rem, env(safe-area-inset-bottom, 0px)); - width: calc(100vw - 1rem); - height: min(44rem, calc(100dvh - 1rem - env(safe-area-inset-bottom, 0px))); - min-height: min(20rem, calc(100dvh - 1rem - env(safe-area-inset-bottom, 0px))); + inset: 0; + width: 100vw; + height: 100dvh; + min-height: 100dvh; + border-radius: 0; } .panelHeader { @@ -1686,10 +1809,6 @@ padding-inline: 0.1rem; } - .composerHints { - display: none; - } - .runtimeSelectorTrigger { max-width: 100%; } @@ -1705,6 +1824,10 @@ } @media (max-width: 30em) { + .historyItemMenu { + opacity: 1; + } + .historyButton { width: 2.625rem; max-width: 2.625rem; diff --git a/apps/nextjs/src/components/assistant/assistant-panel.tsx b/apps/nextjs/src/components/assistant/assistant-panel.tsx index 9ee5cfafa..80f448266 100644 --- a/apps/nextjs/src/components/assistant/assistant-panel.tsx +++ b/apps/nextjs/src/components/assistant/assistant-panel.tsx @@ -25,6 +25,8 @@ import type { import type { MessageStatus } from "@assistant-ui/react"; import { ActionBarPrimitive, + ActionBarMorePrimitive, + AuiIf, AttachmentPrimitive, BranchPickerPrimitive, ChainOfThoughtByIndicesProvider, @@ -66,6 +68,7 @@ import { SegmentedControl, Stack, Text, + TextInput, ThemeIcon, Tooltip, UnstyledButton, @@ -91,6 +94,7 @@ import { IconChevronUp, IconCopy, IconDownload, + IconDotsVertical, IconFile, IconFileExport, IconLink, @@ -112,6 +116,8 @@ import { IconThumbUp, IconTool, IconTrash, + IconVolume, + IconVolumeOff, IconX, } from "@tabler/icons-react"; import remarkBreaks from "remark-breaks"; @@ -124,6 +130,7 @@ import { useTimeAgo } from "@homarr/common"; import { assistantProviderIds, assistantProviderPresets, assistantReasoningModes } from "@homarr/definitions"; import type { AssistantProvider } from "@homarr/definitions"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; +import { useConfirmModal } from "@homarr/modals"; import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client"; import classes from "./assistant-panel.module.css"; @@ -184,6 +191,7 @@ interface AssistantPanelProps extends AssistantConversationControls { onClose: () => void; onDismissActivity: () => void; activityDismissed: boolean; + hasVisibleWidget: boolean; isRunning: boolean; unreadCount: number; latestAssistantText: string; @@ -1107,63 +1115,43 @@ const downloadAssistantMarkdown = (markdown: string, filename: string) => { setTimeout(() => URL.revokeObjectURL(blobUrl), 0); }; -const AssistantMessageExportAction = () => { - const t = useScopedI18n("common.assistant"); - const message = useAuiState((state) => state.message); - - return ( - - { - const markdown = buildAssistantMessageMarkdown({ - id: message.id, - parentId: message.parentId, - format: "assistant-ui/runtime", - content: message, - createdAt: message.createdAt, - }); - downloadAssistantMarkdown(markdown, `assistant-message-${message.id.slice(0, 8)}.md`); - }} - > - - - - ); -}; - const AssistantMessageActions = () => { const t = useScopedI18n("common.assistant"); + const aui = useAui(); + const exportMessage = () => { + const message = aui.message().getState(); + const markdown = buildAssistantMessageMarkdown({ + id: message.id, + parentId: message.parentId, + format: "assistant-ui/runtime", + content: message, + createdAt: message.createdAt, + }); + downloadAssistantMarkdown(markdown, `assistant-message-${message.id.slice(0, 8)}.md`); + }; + return ( - - + + - + !state.message.isCopied}> + + + state.message.isCopied}> + + - - - - - - - - - - - - - - - @@ -1171,6 +1159,50 @@ const AssistantMessageActions = () => { + + + + + + + + + + + {t("helpful")} + + + + + + {t("notHelpful")} + + + state.thread.capabilities.speech && state.message.speech === undefined}> + + + + {t("readAloud")} + + + + state.message.speech !== undefined}> + + + + {t("stopReading")} + + + + + exportMessage()} asChild> + + + {t("exportMarkdown")} + + + + @@ -1181,7 +1213,7 @@ const AssistantMessageActions = () => { const UserMessageActions = () => { const t = useScopedI18n("common.assistant"); return ( - + @@ -1225,7 +1257,7 @@ const UserMessage = () => { const isEditing = useAuiState((state) => state.composer.isEditing); if (isEditing) return ; return ( - + {(quote) => ( @@ -1732,12 +1764,17 @@ const ConversationContext = () => { aria-haspopup="dialog" onClick={() => setOpened((value) => !value)} > - + + + + {hasContext ? `${contextPercentage}%` : t("mentions.context")} + + @@ -1902,60 +1939,80 @@ const WebSearchActivity = () => { ); }; -const AssistantMessage = () => ( - - - {({ part, children }) => { - switch (part.type) { - case "group-agent-trace": { - const startIndex = part.indices[0]; - const endIndex = part.indices.at(-1); - if (startIndex === undefined || endIndex === undefined) return null; - return ( - - {children} - - ); +const AssistantMessage = () => { + const isComplete = useAuiState((state) => state.message.status?.type === "complete"); + + return ( + + + {({ part, children }) => { + switch (part.type) { + case "group-agent-trace": { + const startIndex = part.indices[0]; + const endIndex = part.indices.at(-1); + if (startIndex === undefined || endIndex === undefined) return null; + return ( + + {children} + + ); + } + case "group-reasoning": + return <>{children}; + case "group-tool": + return {children}; + case "indicator": + return ; + case "text": + return ; + case "reasoning": + return ; + case "tool-call": + return part.toolUI ?? ; + case "source": + return ; + case "file": + return ; + case "image": + return ; + case "data": + return part.dataRendererUI; + default: + return null; } - case "group-reasoning": - return <>{children}; - case "group-tool": - return {children}; - case "indicator": - return ; - case "text": - return ; - case "reasoning": - return ; - case "tool-call": - return part.toolUI ?? ; - case "source": - return ; - case "file": - return ; - case "image": - return ; - case "data": - return part.dataRendererUI; - default: - return null; - } - }} - - - - - -); + }} + + + + + + ); +}; + +const assistantThreadMessageComponents = { UserMessage, AssistantMessage }; const HistorySelectContext = createContext<() => void>(() => undefined); const ThreadListItem = () => { const t = useScopedI18n("common.assistant"); + const aui = useAui(); + const { openConfirmModal } = useConfirmModal(); const onSelect = useContext(HistorySelectContext); const remoteId = useAuiState((state) => state.threadListItem.remoteId); const title = useAuiState((state) => state.threadListItem.title); const [exporting, setExporting] = useState(false); + const [actionsOpened, setActionsOpened] = useState(false); + const [renameOpened, setRenameOpened] = useState(false); + const [renaming, setRenaming] = useState(false); + const [nextTitle, setNextTitle] = useState(title ?? ""); + const renameInputRef = useRef(null); + + useEffect(() => { + if (renameOpened) renameInputRef.current?.focus(); + }, [renameOpened]); const exportConversation = async () => { if (!remoteId || exporting) return; @@ -1977,23 +2034,109 @@ const ThreadListItem = () => { } }; + const renameConversation = async () => { + const trimmedTitle = nextTitle.trim(); + if (!trimmedTitle || renaming) return; + setRenaming(true); + try { + await aui.threadListItem().rename(trimmedTitle); + setRenameOpened(false); + setActionsOpened(false); + } catch { + showErrorNotification({ + title: t("renameConversation.failedTitle"), + message: t("renameConversation.failedDescription"), + }); + } finally { + setRenaming(false); + } + }; + + const deleteConversation = () => { + openConfirmModal({ + title: t("deleteConversation.title"), + children: t("deleteConversation.description", { title: title ?? t("newConversation") }), + confirmProps: { color: "red" }, + labels: { confirm: t("delete"), cancel: t("cancel") }, + onConfirm: async () => { + try { + await aui.threadListItem().delete(); + } catch { + showErrorNotification({ + title: t("deleteConversation.failedTitle"), + message: t("deleteConversation.failedDescription"), + }); + } + }, + }); + }; + + if (renameOpened) { + return ( + + setNextTitle(event.currentTarget.value)} + aria-label={t("renameConversation.label")} + maxLength={72} + size="xs" + onKeyDown={(event) => { + if (event.key === "Enter") void renameConversation(); + if (event.key === "Escape" && !renaming) setRenameOpened(false); + }} + /> + void renameConversation()} + > + + + setRenameOpened(false)} + > + + + + ); + } + return ( - - - - - + + { loading={exporting} loaderProps={{ type: "bars" }} disabled={!remoteId} + title={t("exportConversation.action")} aria-label={t("exportConversation.action")} onClick={() => void exportConversation()} > - - - + - - + + )} + setActionsOpened((current) => !current)} + > + {actionsOpened ? : } + ); }; const ThreadHistory = ({ onSelect }: { onSelect: () => void }) => { const t = useScopedI18n("common.assistant"); + const [query, setQuery] = useState(""); + const normalizedQuery = query.trim().toLocaleLowerCase(); + const threadItems = useAuiState((state) => state.threads.threadItems); + const hasMatchingConversation = threadItems.some((item) => { + const itemTitle = item.title ?? t("newConversation"); + return itemTitle.toLocaleLowerCase().includes(normalizedQuery); + }); return ( @@ -2035,9 +2203,28 @@ const ThreadHistory = ({ onSelect }: { onSelect: () => void }) => { {t("conversations")} - + setQuery(event.currentTarget.value)} + leftSection={} + placeholder={t("searchConversations")} + aria-label={t("searchConversations")} + size="xs" + /> + - + + {({ threadListItem }) => { + const itemTitle = threadListItem.title ?? t("newConversation"); + if (normalizedQuery && !itemTitle.toLocaleLowerCase().includes(normalizedQuery)) return null; + return ; + }} + + {normalizedQuery && !hasMatchingConversation && ( + + {t("noMatchingConversations")} + + )} @@ -2052,7 +2239,6 @@ const ConversationHistory = () => { setOpened(false)} position="bottom-end" width="min(22rem, calc(100vw - 1rem))" shadow="md" @@ -2191,7 +2377,7 @@ const EmptyThread = () => { - + - + - + - + @@ -2792,14 +2978,6 @@ const Composer = (props: ComposerProps) => { - - - {t("mentions.hint")} - - - {t("commands.hint")} - - @@ -2979,9 +3157,30 @@ interface AssistantConversationSurfaceProps extends AssistantConversationControl pendingAction: AssistantPendingAction | undefined; onExpand?: () => void; onMinimize?: () => void; - onWindowClose?: () => void; + onDismiss?: () => void; } +const AssistantPanelIdentity = () => { + const t = useScopedI18n("common.assistant"); + const threadTitle = useAuiState((state) => state.threadListItem.title); + + return ( + + + + + + + {t("title")} + + + {threadTitle ?? t("newConversation")} + + + + ); +}; + export const AssistantConversationSurface = ({ isRunning, pendingAction, @@ -2996,7 +3195,7 @@ export const AssistantConversationSurface = ({ onReasoningChange, onExpand, onMinimize, - onWindowClose, + onDismiss, }: AssistantConversationSurfaceProps) => { const t = useScopedI18n("common.assistant"); const reducedMotion = useReducedMotion(); @@ -3011,52 +3210,23 @@ export const AssistantConversationSurface = ({ return ( - {onWindowClose && onMinimize && ( - - - - - - - - - - - - - )} + - } aria-label={t("newConversation")} > - - + {t("newConversation")} + {onExpand && ( @@ -3072,6 +3242,32 @@ export const AssistantConversationSurface = ({ )} + {onMinimize && ( + + + + + + )} + {onDismiss && ( + + + + + + )} @@ -3083,7 +3279,7 @@ export const AssistantConversationSurface = ({ - + @@ -3130,6 +3326,7 @@ export const AssistantPanel = ({ onClose, onDismissActivity, activityDismissed, + hasVisibleWidget, isRunning, unreadCount, latestAssistantText, @@ -3147,8 +3344,18 @@ export const AssistantPanel = ({ onReasoningChange, }: AssistantPanelProps) => { const t = useScopedI18n("common.assistant"); + const aui = useAui(); const previousFocusRef = useRef(null); const panelRef = useRef(null); + const stopSpeechWithoutSurface = () => { + if (hasVisibleWidget) return; + const thread = aui.thread(); + if (thread.getState().speech !== undefined) thread.stopSpeaking(); + }; + const minimize = () => { + stopSpeechWithoutSurface(); + onClose(); + }; useWindowEvent("keydown", (event) => { if ( @@ -3160,7 +3367,7 @@ export const AssistantPanel = ({ ) return; event.preventDefault(); - onClose(); + minimize(); }); useEffect(() => { if (!opened) { @@ -3182,7 +3389,7 @@ export const AssistantPanel = ({ return ( <> - {!opened && ( + {!opened && !hasVisibleWidget && ( { + onMinimize={minimize} + onDismiss={() => { + stopSpeechWithoutSurface(); onDismissActivity(); onClose(); }} diff --git a/apps/nextjs/src/components/assistant/assistant-provider.tsx b/apps/nextjs/src/components/assistant/assistant-provider.tsx index 43144bb31..0f46d7a0b 100644 --- a/apps/nextjs/src/components/assistant/assistant-provider.tsx +++ b/apps/nextjs/src/components/assistant/assistant-provider.tsx @@ -7,11 +7,19 @@ import type { AttachmentAdapter, MessageFormatAdapter, RemoteThreadListAdapter, + SpeechSynthesisAdapter, ThreadHistoryAdapter, ThreadMessage, Toolkit, } from "@assistant-ui/react"; -import { defineToolkit, useAui, useAuiEvent, useAuiState, useRemoteThreadListRuntime } from "@assistant-ui/react"; +import { + defineToolkit, + useAui, + useAuiEvent, + useAuiState, + useRemoteThreadListRuntime, + WebSpeechSynthesisAdapter, +} from "@assistant-ui/react"; import { useChat } from "@ai-sdk/react"; import { AssistantChatTransport, useAISDKRuntime } from "@assistant-ui/react-ai-sdk"; import { useHotkeys } from "@mantine/hooks"; @@ -41,7 +49,8 @@ import type { AssistantReasoningMode, AssistantRuntimeModelOption } from "./assi import { resolveAssistantPreferenceModelId, resolveAssistantThreadPreferenceModelId } from "./assistant-preferences"; import { assistantAiSdkRuntimeOptions } from "./assistant-runtime-options"; import { - AssistantComposerSurfaceProvider, + AssistantComposerRuntimeProvider, + AssistantComposerSurfaceBoundary, AssistantRunFocusPreserver, AssistantRuntimeProviderWithTools, } from "./assistant-runtime-provider"; @@ -475,6 +484,12 @@ const AssistantThreadRuntime = () => { ), [selectedModel], ); + const [speech, setSpeech] = useState(); + useEffect(() => { + if ("speechSynthesis" in window && typeof SpeechSynthesisUtterance !== "undefined") { + setSpeech(new WebSpeechSynthesisAdapter()); + } + }, []); const feedback = useMemo( () => ({ submit: ({ message, type }: { message: ThreadMessage; type: "positive" | "negative" }) => { @@ -512,6 +527,7 @@ const AssistantThreadRuntime = () => { history, attachments, feedback, + speech, }, }); transport.setRuntime(runtime); @@ -667,6 +683,7 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => { const [opened, setOpened] = useState(false); const [activityDismissed, setActivityDismissed] = useState(true); const [unreadCount, setUnreadCount] = useState(0); + const [visibleWidgetIds, setVisibleWidgetIds] = useState>(() => new Set()); const [queuedPrompt, setQueuedPrompt] = useState(null); const aui = useAui(); const preferences = useAssistantPreferences(); @@ -714,6 +731,21 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => { setOpened(true); }, [markRead]); const close = useCallback(() => setOpened(false), []); + const setWidgetVisible = useCallback((widgetId: string, visible: boolean) => { + setVisibleWidgetIds((current) => { + if (current.has(widgetId) === visible) return current; + const next = new Set(current); + if (visible) next.add(widgetId); + else next.delete(widgetId); + return next; + }); + }, []); + const activateWidget = useCallback((widgetId: string) => { + setVisibleWidgetIds((current) => { + if (!current.has(widgetId) || current.values().next().value === widgetId) return current; + return new Set([widgetId, ...Array.from(current).filter((id) => id !== widgetId)]); + }); + }, []); const toggle = useCallback(() => { if (opened) close(); else open(); @@ -835,6 +867,7 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => { }, [assistantIsRunning, latestStatus?.type, providerUnavailable, sendPrompt, t]); useRegisterSpotlightContextResults("homarr-assistant", [spotlightItem], [spotlightItem]); + const activeWidgetId = visibleWidgetIds.values().next().value ?? null; const value = useMemo( () => ({ enabled: true, @@ -843,46 +876,67 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => { isRunning: assistantIsRunning, isRefreshing, unreadCount, + hasVisibleWidget: visibleWidgetIds.size > 0, + activeWidgetId, open, close, toggle, sendPrompt, refreshCurrentView, + setWidgetVisible, + activateWidget, }), - [assistantIsRunning, close, isRefreshing, open, opened, refreshCurrentView, sendPrompt, toggle, unreadCount], + [ + assistantIsRunning, + activateWidget, + activeWidgetId, + close, + isRefreshing, + open, + opened, + refreshCurrentView, + sendPrompt, + setWidgetVisible, + toggle, + unreadCount, + visibleWidgetIds.size, + ], ); return ( - {children} - - { - markRead(); - setActivityDismissed(true); - }} - activityDismissed={activityDismissed} - isRunning={assistantIsRunning} - unreadCount={unreadCount} - latestAssistantText={latestAssistantText} - latestAssistantPartType={latestAssistantPartType} - latestUserText={queuedPrompt ?? latestUserText} - latestStatus={latestStatus} - pendingAction={pendingAction} - modelId={preferences.modelId} - models={preferences.models} - modelOptionsLoading={preferences.isLoading} - reasoning={preferences.reasoning} - isRefreshing={isRefreshing} - onRefresh={refreshCurrentView} - onModelChange={selectModel} - onReasoningChange={preferences.setReasoning} - /> - + + {children} + + { + markRead(); + setActivityDismissed(true); + }} + activityDismissed={activityDismissed} + hasVisibleWidget={visibleWidgetIds.size > 0} + isRunning={assistantIsRunning} + unreadCount={unreadCount} + latestAssistantText={latestAssistantText} + latestAssistantPartType={latestAssistantPartType} + latestUserText={queuedPrompt ?? latestUserText} + latestStatus={latestStatus} + pendingAction={pendingAction} + modelId={preferences.modelId} + models={preferences.models} + modelOptionsLoading={preferences.isLoading} + reasoning={preferences.reasoning} + isRefreshing={isRefreshing} + onRefresh={refreshCurrentView} + onModelChange={selectModel} + onReasoningChange={preferences.setReasoning} + /> + + ); diff --git a/apps/nextjs/src/components/assistant/assistant-runtime-provider.spec.ts b/apps/nextjs/src/components/assistant/assistant-runtime-provider.spec.ts index 44cd03775..24f1aa940 100644 --- a/apps/nextjs/src/components/assistant/assistant-runtime-provider.spec.ts +++ b/apps/nextjs/src/components/assistant/assistant-runtime-provider.spec.ts @@ -26,7 +26,8 @@ import { z } from "zod/v4"; import { showErrorNotification } from "@homarr/notifications"; import { - AssistantComposerSurfaceProvider, + AssistantComposerRuntimeProvider, + AssistantComposerSurfaceBoundary, AssistantRunFocusPreserver, AssistantRuntimeProviderWithTools, assistantSurfaceComposerCacheLimit, @@ -148,14 +149,22 @@ const SurfaceTestAssistant = ({ { runtime, toolkit }, createElement(AssistantRunFocusPreserver), createElement( - AssistantComposerSurfaceProvider, - { surfaceId: "surface-a" }, - createElement(SurfaceProbe, { id: "a", onReady }), + AssistantComposerRuntimeProvider, + null, + createElement( + AssistantComposerSurfaceBoundary, + { surfaceId: "surface-a" }, + createElement(SurfaceProbe, { id: "a", onReady }), + ), ), createElement( - AssistantComposerSurfaceProvider, - { surfaceId: "surface-b" }, - createElement(SurfaceProbe, { id: "b", onReady }), + AssistantComposerRuntimeProvider, + null, + createElement( + AssistantComposerSurfaceBoundary, + { surfaceId: "surface-b" }, + createElement(SurfaceProbe, { id: "b", onReady }), + ), ), simulateRunFocus ? createElement(LexicalRunFocusProbe) : null, ); diff --git a/apps/nextjs/src/components/assistant/assistant-runtime-provider.tsx b/apps/nextjs/src/components/assistant/assistant-runtime-provider.tsx index e849c2393..85edd94ed 100644 --- a/apps/nextjs/src/components/assistant/assistant-runtime-provider.tsx +++ b/apps/nextjs/src/components/assistant/assistant-runtime-provider.tsx @@ -157,9 +157,6 @@ const createSurfaceComposerHost = ( get attachments() { return getThreadAdapters(threadCore)?.attachments ? attachments : undefined; }, - get dictation() { - return getThreadAdapters(threadCore)?.dictation; - }, }; return new Proxy(threadCore as ThreadCoreWithAdapters, { @@ -294,18 +291,21 @@ export const createAssistantSurfaceRuntime = (runtime: AssistantRuntime): Assist subscribe(callback) { let activeCore = sharedBinding.getState(); let activeConversationId = getConversationId(); + let activeComposer = getComposer(activeConversationId, activeCore); retainConversation(activeConversationId); - let unsubscribeComposer = getComposer(activeConversationId, activeCore).subscribe(callback); + let unsubscribeComposer = activeComposer.subscribe(callback); const handleSharedUpdate = () => { const nextCore = sharedBinding.getState(); const nextConversationId = getConversationId(); if (nextCore !== activeCore || nextConversationId !== activeConversationId) { + if (activeCore.speech !== undefined) activeCore.stopSpeaking(); unsubscribeComposer(); releaseConversation(activeConversationId); activeCore = nextCore; activeConversationId = nextConversationId; retainConversation(activeConversationId); - unsubscribeComposer = getComposer(activeConversationId, activeCore).subscribe(callback); + activeComposer = getComposer(activeConversationId, activeCore); + unsubscribeComposer = activeComposer.subscribe(callback); } callback(); }; @@ -360,6 +360,13 @@ export const createAssistantSurfaceRuntime = (runtime: AssistantRuntime): Assist registerModelContextProvider: () => () => {}, dispose() { activeConversationCounts.clear(); + const threadCores = new Set([ + ...Array.from(surfaceCoreByConversation.values(), (entry) => entry.source), + ...Array.from(composerByConversation.values(), (entry) => entry.source), + ]); + for (const threadCore of threadCores) { + if (threadCore.speech !== undefined) threadCore.stopSpeaking(); + } for (const [conversationId, entry] of composerByConversation) disposeComposer(conversationId, entry); }, }; @@ -373,7 +380,7 @@ const AssistantComposerSurfaceContext = createContext { const value = useContext(AssistantComposerSurfaceContext); - if (!value) throw new Error("useAssistantComposerSurface must be used within AssistantComposerSurfaceProvider"); + if (!value) throw new Error("useAssistantComposerSurface must be used within AssistantComposerSurfaceBoundary"); return value; }; @@ -437,21 +444,23 @@ const AssistantComposerSurfaceEvents = () => { return null; }; -const AssistantComposerSurfaceBoundary = ({ children, surfaceId }: PropsWithChildren<{ surfaceId: string }>) => { +export const AssistantComposerSurfaceBoundary = ({ children, surfaceId }: PropsWithChildren<{ surfaceId: string }>) => { const t = useScopedI18n("common.assistant"); const aui = useAui(); + const instanceId = useId(); + const resolvedSurfaceId = `${surfaceId}:${instanceId}`; const elementRef = useRef(null); - const contextValue = useMemo(() => ({ id: surfaceId }), [surfaceId]); + const contextValue = useMemo(() => ({ id: resolvedSurfaceId }), [resolvedSurfaceId]); useLayoutEffect(() => { const element = elementRef.current; if (!element) return undefined; - surfaceElements.set(surfaceId, element); + surfaceElements.set(resolvedSurfaceId, element); return () => { - if (surfaceElements.get(surfaceId) === element) surfaceElements.delete(surfaceId); + if (surfaceElements.get(resolvedSurfaceId) === element) surfaceElements.delete(resolvedSurfaceId); }; - }, [surfaceId]); + }, [resolvedSurfaceId]); const handlePasteCapture = (event: ClipboardEvent) => { const target = event.target; @@ -507,7 +516,6 @@ const AssistantComposerSurfaceBoundary = ({ children, surfaceId }: PropsWithChil }} onPasteCapture={handlePasteCapture} > - {children} @@ -566,18 +574,12 @@ export const AssistantRunFocusPreserver = () => { return null; }; -interface AssistantComposerSurfaceProviderProps extends PropsWithChildren { - surfaceId: string; -} - /** - * Gives one rendered assistant surface a local composer while preserving the shared conversation. - * Every component below this boundary can keep using the normal assistant-ui primitives and hooks. + * Gives every rendered assistant surface one shared local composer while preserving the shared + * conversation. Individual surfaces should add AssistantComposerSurfaceBoundary around their UI. */ -export const AssistantComposerSurfaceProvider = ({ children, surfaceId }: AssistantComposerSurfaceProviderProps) => { +export const AssistantComposerRuntimeProvider = ({ children }: PropsWithChildren) => { const parentAui = useAui(); - const instanceId = useId(); - const resolvedSurfaceId = `${surfaceId}:${instanceId}`; // oxlint-disable-next-line no-underscore-dangle -- required to derive a scoped runtime without cloning the shared thread. const sharedRuntime = parentAui.threads().__internal_getAssistantRuntime?.(); const surfaceRuntime = useMemo(() => { @@ -588,7 +590,8 @@ export const AssistantComposerSurfaceProvider = ({ children, surfaceId }: Assist return ( - {children} + + {children} ); }; diff --git a/apps/nextjs/src/components/assistant/assistant-widget.tsx b/apps/nextjs/src/components/assistant/assistant-widget.tsx index abe9e1310..1b7acee3e 100644 --- a/apps/nextjs/src/components/assistant/assistant-widget.tsx +++ b/apps/nextjs/src/components/assistant/assistant-widget.tsx @@ -1,8 +1,9 @@ "use client"; import type { CSSProperties, ReactNode } from "react"; +import { useEffect, useId, useRef } from "react"; import { ActionIcon, Box, Center, Group, Stack, Text, ThemeIcon, Tooltip } from "@mantine/core"; -import { IconArrowsMaximize, IconRobot } from "@tabler/icons-react"; +import { IconArrowsMaximize, IconArrowsMinimize, IconRobot } from "@tabler/icons-react"; import { useAui, useAuiState } from "@assistant-ui/react"; import { useI18n } from "@homarr/translation/client"; @@ -13,23 +14,47 @@ import classes from "./assistant-panel.module.css"; import { AssistantConversationSurface } from "./assistant-panel"; import { getPendingAssistantAction } from "./assistant-pending-action"; import { useAssistantPreferences, useHomarrAssistant } from "./assistant-context"; -import { AssistantComposerSurfaceProvider } from "./assistant-runtime-provider"; +import { AssistantComposerSurfaceBoundary } from "./assistant-runtime-provider"; export const AssistantBoardWidget = (props: WidgetComponentProps<"assistant">) => { const assistant = useHomarrAssistant(); + const { enabled, setWidgetVisible } = assistant; + const generatedWidgetId = useId(); + const widgetId = props.itemId ?? generatedWidgetId; + const widgetRef = useRef(null); + + useEffect(() => { + const widget = widgetRef.current; + if (!enabled || !widget) return; + const observer = new IntersectionObserver(([entry]) => setWidgetVisible(widgetId, entry?.isIntersecting === true), { + threshold: 0.15, + }); + observer.observe(widget); + return () => { + observer.disconnect(); + setWidgetVisible(widgetId, false); + }; + }, [enabled, setWidgetVisible, widgetId]); if (!assistant.enabled) { return ; } return ( - - - + + + + + ); }; -const EnabledAssistantBoardWidget = ({ width, height, isEditMode }: WidgetComponentProps<"assistant">) => { +const EnabledAssistantBoardWidget = ({ + width, + height, + isEditMode, + assistantWidgetId, +}: WidgetComponentProps<"assistant"> & { assistantWidgetId: string }) => { const board = useRequiredBoard(); const assistant = useHomarrAssistant(); const preferences = useAssistantPreferences(); @@ -45,6 +70,14 @@ const EnabledAssistantBoardWidget = ({ width, height, isEditMode }: WidgetCompon threadListItem.updateCustom({ ...threadListItem.getState().custom, modelId }); }; + if (assistant.opened) { + return ; + } + + if (assistant.activeWidgetId !== null && assistant.activeWidgetId !== assistantWidgetId) { + return assistant.activateWidget(assistantWidgetId)} location="widget" />; + } + if (width < 300 || height < 280) { return ; } @@ -75,8 +108,30 @@ const EnabledAssistantBoardWidget = ({ width, height, isEditMode }: WidgetCompon ); }; -const CompactAssistantWidget = ({ onOpen }: { onOpen: () => void }) => { +const CompactAssistantWidget = ({ + onOpen, + location = "compact", +}: { + onOpen: () => void; + location?: "compact" | "panel" | "widget"; +}) => { const t = useI18n(); + let title = t("widget.assistant.compact.title"); + let description = t("widget.assistant.compact.description"); + let actionLabel = t("widget.assistant.compact.open"); + let actionIcon = ; + + if (location === "panel") { + title = t("widget.assistant.compact.openTitle"); + description = t("widget.assistant.compact.openDescription"); + actionLabel = t("widget.assistant.compact.returnToWidget"); + actionIcon = ; + } + if (location === "widget") { + title = t("widget.assistant.compact.otherWidgetTitle"); + description = t("widget.assistant.compact.otherWidgetDescription"); + actionLabel = t("widget.assistant.compact.useHere"); + } return ( @@ -87,23 +142,16 @@ const CompactAssistantWidget = ({ onOpen }: { onOpen: () => void }) => { - {t("widget.assistant.compact.title")} + {title} - {t("widget.assistant.compact.description")} + {description} - - - + + + {actionIcon} diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 2d6188928..19e7c1ddd 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -2309,7 +2309,6 @@ "open": "Open Homarr Assistant", "close": "Close assistant", "minimize": "Minimize assistant", - "windowControls": "Conversation window controls", "spotlight": "Ask Homarr Assistant", "spotlightDescription": "Type and send a prompt without leaving the command menu.", "spotlightPrompt": { @@ -2327,6 +2326,24 @@ "newConversation": "New conversation", "conversations": "Conversations", "delete": "Delete", + "cancel": "Cancel", + "conversationActions": "Conversation actions", + "searchConversations": "Search conversations", + "noMatchingConversations": "No matching conversations", + "moreActions": "More response actions", + "renameConversation": { + "action": "Rename", + "label": "Conversation name", + "save": "Save name", + "failedTitle": "Conversation could not be renamed", + "failedDescription": "Homarr could not rename this conversation. Try again." + }, + "deleteConversation": { + "title": "Delete conversation?", + "description": "“{title}” and its messages will be permanently deleted.", + "failedTitle": "Conversation could not be deleted", + "failedDescription": "Homarr could not delete this conversation. Try again." + }, "closeHistory": "Close conversation history", "openHistory": "Open conversation history", "emptyTitle": "What can I help with?", @@ -2399,6 +2416,8 @@ "regenerate": "Regenerate response", "helpful": "Helpful", "notHelpful": "Not helpful", + "readAloud": "Read response aloud", + "stopReading": "Stop reading response", "feedbackError": "Your feedback could not be saved. Try again.", "exportMarkdown": "Export as Markdown", "exportConversation": { @@ -2427,7 +2446,6 @@ }, "mentions": { "menu": "Add Homarr context", - "hint": "Type @ to add context", "context": "Context", "app": "Apps", "integration": "Integrations", @@ -2442,7 +2460,6 @@ }, "commands": { "menu": "Assistant commands", - "hint": "Type / for shortcuts", "health": "Prepare a service-health check", "explore": "Prepare an overview of this Homarr instance", "media": "Prepare a media search", @@ -2555,7 +2572,6 @@ "otherPlaceholder": "Type another answer…", "submit": "Send answer", "cancelOther": "Cancel other answer", - "automaticAffirmative": "Choosing the affirmative answer automatically", "optionKind": { "affirmative": "Confirm", "negative": "Decline", @@ -2630,7 +2646,7 @@ "autoApproval": { "label": "Automatic approvals", "title": "Automatic approvals", - "description": "Automatically approve protected changes, use presented form details, and choose answers categorized as affirmative. Neutral selections still wait for you. Your Homarr permissions still apply. This turns off when you switch conversations.", + "description": "Automatically prepare forms and approve protected changes for this conversation. Questions still wait for your answer, and your Homarr permissions still apply. This turns off when you switch conversations.", "enable": "Enable for this conversation", "disable": "Turn off automatic approvals", "enabled": "Automatic approvals are on", @@ -6083,7 +6099,13 @@ "compact": { "title": "Assistant", "description": "Resize this widget to chat here, or open the full conversation.", - "open": "Open assistant" + "open": "Open assistant", + "openTitle": "Assistant open in side panel", + "openDescription": "The conversation stays in one active surface.", + "returnToWidget": "Return the conversation to this widget", + "otherWidgetTitle": "Assistant active in another widget", + "otherWidgetDescription": "Only one conversation surface stays active at a time.", + "useHere": "Use the assistant in this widget" } } },