feat: improve assistant conversation experience (#6636)

This commit is contained in:
Thomas authored and GitHub committed 2026-08-20 16:26:23 +02:00
1 parent 58a24569ce
commit d241bac55d
13 files changed
+897 -389

No files matched your search

@@ -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);
@@ -52,6 +52,8 @@ export const AssistantAutoApprovalProvider = ({
const trackerRef = useRef(createAssistantAutoApprovalTracker(AUTO_APPROVAL_MAX_ATTEMPTS));
const retryTimersRef = useRef(new Map<string, number>());
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 <AssistantAutoApprovalContext.Provider value={value}>{children}</AssistantAutoApprovalContext.Provider>;
};
@@ -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<void>;
setWidgetVisible: (widgetId: string, visible: boolean) => void;
activateWidget: (widgetId: string) => void;
}
export const AssistantContext = createContext<AssistantContextValue | null>(null);
@@ -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],
);
@@ -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<AskUserArgs, AskUserResult>) => {
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<HTMLInputElement>(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 = ({
</Group>
<Stack gap="xs" mt="md">
{autoConfirming ? (
<AssistantAutomaticActionProgress label={t("automaticAffirmative")} />
) : (
options.map((option, index) => {
const kind = getAssistantAskUserOptionKind(option);
const icon =
kind === "affirmative" ? (
<IconCheck size={17} />
) : kind === "negative" ? (
<IconX size={17} />
) : (
<IconArrowRight size={17} />
);
const color = kind === "affirmative" ? "green" : kind === "negative" ? "red" : "gray";
return (
<UnstyledButton
key={`${option.id}:${index}`}
className={classes.humanToolOption}
data-kind={kind}
disabled={submitting}
onClick={() =>
submitResult({ answer: option.label, optionId: option.id, optionKind: kind, source: "option" })
}
>
<ThemeIcon className={classes.humanToolOptionIcon} variant="light" color={color} size="sm">
{icon}
</ThemeIcon>
<Box className={classes.humanToolOptionText} ta="start">
<Text component="span" size="sm" fw={650} className={classes.humanToolOptionLabel}>
{option.label}
</Text>
{option.description && (
<Text
component="span"
display="block"
size="xs"
c="dimmed"
fw={400}
className={classes.humanToolOptionDescription}
>
{option.description}
</Text>
)}
</Box>
<Badge
className={classes.humanToolOptionBadge}
classNames={{ label: classes.humanToolOptionBadgeLabel }}
variant="light"
color={color}
size="xs"
>
{t(`optionKind.${kind}`)}
</Badge>
</UnstyledButton>
{options.map((option, index) => {
const kind = getAssistantAskUserOptionKind(option);
const icon =
kind === "affirmative" ? (
<IconCheck size={17} />
) : kind === "negative" ? (
<IconX size={17} />
) : (
<IconArrowRight size={17} />
);
})
)}
const color = kind === "affirmative" ? "green" : kind === "negative" ? "red" : "gray";
{!autoConfirming &&
args.allowOther !== false &&
return (
<UnstyledButton
key={`${option.id}:${index}`}
className={classes.humanToolOption}
data-kind={kind}
disabled={submitting}
onClick={() =>
submitResult({ answer: option.label, optionId: option.id, optionKind: kind, source: "option" })
}
>
<ThemeIcon className={classes.humanToolOptionIcon} variant="light" color={color} size="sm">
{icon}
</ThemeIcon>
<Box className={classes.humanToolOptionText} ta="start">
<Text component="span" size="sm" fw={650} className={classes.humanToolOptionLabel}>
{option.label}
</Text>
{option.description && (
<Text
component="span"
display="block"
size="xs"
c="dimmed"
fw={400}
className={classes.humanToolOptionDescription}
>
{option.description}
</Text>
)}
</Box>
<Badge
className={classes.humanToolOptionBadge}
classNames={{ label: classes.humanToolOptionBadgeLabel }}
variant="light"
color={color}
size="xs"
>
{t(`optionKind.${kind}`)}
</Badge>
</UnstyledButton>
);
})}
{args.allowOther !== false &&
(showOther ? (
<Group align="flex-end" gap="xs" wrap="nowrap">
<TextInput
@@ -98,18 +98,18 @@
container-type: size;
position: fixed;
z-index: 290;
inset-inline-start: 50%;
bottom: 1rem;
inset-block: 1rem;
inset-inline-end: 1rem;
inset-inline-start: auto;
display: flex;
width: min(46rem, calc(100vw - 2rem));
height: min(36rem, calc(100dvh - 5rem));
min-height: min(22rem, calc(100dvh - 5rem));
width: min(34rem, calc(100vw - 2rem));
height: auto;
min-height: min(30rem, calc(100dvh - 2rem));
margin: 0;
transform: translateX(-50%);
flex-direction: column;
overflow: hidden;
padding: 0;
border: 0;
border: 1px solid var(--mantine-color-default-border);
border-radius: var(--mantine-radius-lg);
color: var(--mantine-color-text);
background: var(--mantine-color-body);
@@ -157,27 +157,28 @@
border-bottom: 1px solid var(--mantine-color-default-border);
}
.panelIdentity {
min-width: 0;
flex: 1 1 auto;
}
.panelIdentityIcon {
flex: 0 0 auto;
}
.panelIdentityTitle,
.panelThreadTitle {
max-width: 14rem;
}
.panelActions {
min-width: 0;
flex: 0 0 auto;
margin-inline-start: auto;
}
.windowControls {
.newConversationButton {
flex: 0 0 auto;
margin: 0;
padding: 0;
border: 0;
}
.windowControl {
width: 2.25rem;
height: 2.25rem;
}
.windowControl:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: 2px;
}
.panelAction {
@@ -191,10 +192,6 @@
padding: 0.45rem 0.5rem 0.45rem 0.65rem;
}
.composerHints {
display: none;
}
.historyButton {
width: 2.625rem;
max-width: 2.625rem;
@@ -210,6 +207,21 @@
display: none;
}
.newConversationButton {
width: 2.625rem;
max-width: 2.625rem;
height: 2.625rem;
padding-inline: 0.75rem;
}
.newConversationButton :global(.mantine-Button-section) {
margin-inline: 0;
}
.newConversationButton :global(.mantine-Button-label) {
display: none;
}
.messages {
padding: 0.5rem 0.65rem 1rem;
}
@@ -246,9 +258,27 @@
}
}
@container assistant-widget (max-width: 36rem) {
.historyButton {
width: 2.625rem;
max-width: 2.625rem;
height: 2.625rem;
padding-inline: 0.75rem;
}
.historyButtonSection {
margin-inline-end: 0;
}
.historyButtonLabel {
display: none;
}
}
@container assistant-widget (max-width: 22rem) {
.panelAction,
.historyButton {
.historyButton,
.newConversationButton {
width: 2.25rem;
max-width: 2.25rem;
height: 2.25rem;
@@ -268,7 +298,11 @@
padding-inline: 0.15rem;
}
.composerContext > :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;
@@ -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 (
<Tooltip label={t("exportMarkdown")}>
<ActionIcon
variant="subtle"
color="gray"
size="sm"
aria-label={t("exportMarkdown")}
onClick={() => {
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`);
}}
>
<IconFileExport size={14} />
</ActionIcon>
</Tooltip>
);
};
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 (
<Box className={classes.messageActions}>
<ActionBarPrimitive.Root hideWhenRunning className={classes.messageActionBar}>
<Group gap={2} wrap="wrap">
<ActionBarPrimitive.Root
hideWhenRunning
autohide="not-last"
autohideFloat="single-branch"
className={classes.messageActionBar}
>
<Group gap={2} wrap="nowrap">
<ProviderMessageInfo />
<Tooltip label={t("copy")}>
<ActionBarPrimitive.Copy asChild>
<ActionIcon variant="subtle" color="gray" size="sm" aria-label={t("copy")}>
<IconCopy size={14} />
<AuiIf condition={(state) => !state.message.isCopied}>
<IconCopy size={14} />
</AuiIf>
<AuiIf condition={(state) => state.message.isCopied}>
<IconCheck size={14} />
</AuiIf>
</ActionIcon>
</ActionBarPrimitive.Copy>
</Tooltip>
<Tooltip label={t("helpful")}>
<ActionBarPrimitive.FeedbackPositive asChild>
<ActionIcon variant="subtle" color="gray" size="sm" aria-label={t("helpful")}>
<IconThumbUp size={14} />
</ActionIcon>
</ActionBarPrimitive.FeedbackPositive>
</Tooltip>
<Tooltip label={t("notHelpful")}>
<ActionBarPrimitive.FeedbackNegative asChild>
<ActionIcon variant="subtle" color="gray" size="sm" aria-label={t("notHelpful")}>
<IconThumbDown size={14} />
</ActionIcon>
</ActionBarPrimitive.FeedbackNegative>
</Tooltip>
<AssistantMessageExportAction />
<Tooltip label={t("regenerate")}>
<ActionBarPrimitive.Reload asChild>
<ActionIcon variant="subtle" color="gray" size="sm" aria-label={t("regenerate")}>
@@ -1171,6 +1159,50 @@ const AssistantMessageActions = () => {
</ActionIcon>
</ActionBarPrimitive.Reload>
</Tooltip>
<ActionBarMorePrimitive.Root>
<ActionBarMorePrimitive.Trigger asChild>
<ActionIcon variant="subtle" color="gray" size="sm" aria-label={t("moreActions")}>
<IconDotsVertical size={14} />
</ActionIcon>
</ActionBarMorePrimitive.Trigger>
<ActionBarMorePrimitive.Content className={classes.messageMoreMenu} sideOffset={4} align="end">
<ActionBarPrimitive.FeedbackPositive asChild>
<ActionBarMorePrimitive.Item className={classes.messageMoreItem}>
<IconThumbUp size={15} />
{t("helpful")}
</ActionBarMorePrimitive.Item>
</ActionBarPrimitive.FeedbackPositive>
<ActionBarPrimitive.FeedbackNegative asChild>
<ActionBarMorePrimitive.Item className={classes.messageMoreItem}>
<IconThumbDown size={15} />
{t("notHelpful")}
</ActionBarMorePrimitive.Item>
</ActionBarPrimitive.FeedbackNegative>
<AuiIf condition={(state) => state.thread.capabilities.speech && state.message.speech === undefined}>
<ActionBarPrimitive.Speak asChild>
<ActionBarMorePrimitive.Item className={classes.messageMoreItem}>
<IconVolume size={15} />
{t("readAloud")}
</ActionBarMorePrimitive.Item>
</ActionBarPrimitive.Speak>
</AuiIf>
<AuiIf condition={(state) => state.message.speech !== undefined}>
<ActionBarPrimitive.StopSpeaking asChild>
<ActionBarMorePrimitive.Item className={classes.messageMoreItem}>
<IconVolumeOff size={15} />
{t("stopReading")}
</ActionBarMorePrimitive.Item>
</ActionBarPrimitive.StopSpeaking>
</AuiIf>
<ActionBarMorePrimitive.Separator className={classes.messageMoreSeparator} />
<ActionBarPrimitive.ExportMarkdown onExport={() => exportMessage()} asChild>
<ActionBarMorePrimitive.Item className={classes.messageMoreItem}>
<IconFileExport size={15} />
{t("exportMarkdown")}
</ActionBarMorePrimitive.Item>
</ActionBarPrimitive.ExportMarkdown>
</ActionBarMorePrimitive.Content>
</ActionBarMorePrimitive.Root>
</Group>
</ActionBarPrimitive.Root>
<BranchPicker />
@@ -1181,7 +1213,7 @@ const AssistantMessageActions = () => {
const UserMessageActions = () => {
const t = useScopedI18n("common.assistant");
return (
<ActionBarPrimitive.Root hideWhenRunning className={classes.userActions}>
<ActionBarPrimitive.Root hideWhenRunning autohide="not-last" className={classes.userActions}>
<Group gap={2}>
<Tooltip label={t("copyMessage")}>
<ActionBarPrimitive.Copy asChild>
@@ -1225,7 +1257,7 @@ const UserMessage = () => {
const isEditing = useAuiState((state) => state.composer.isEditing);
if (isEditing) return <EditComposer />;
return (
<MessagePrimitive.Root className={`${classes.message} ${classes.userMessageWrap}`}>
<MessagePrimitive.Root className={`${classes.message} ${classes.userMessageWrap}`} data-static>
<Box className={classes.userMessage}>
<MessagePrimitive.Quote>
{(quote) => (
@@ -1732,12 +1764,17 @@ const ConversationContext = () => {
aria-haspopup="dialog"
onClick={() => setOpened((value) => !value)}
>
<RingProgress
size={24}
thickness={3}
roundCaps
sections={hasContext ? [{ value: contextPercentage, color: getContextColor(contextPercentage) }] : []}
/>
<Group gap={5} wrap="nowrap">
<RingProgress
size={22}
thickness={3}
roundCaps
sections={hasContext ? [{ value: contextPercentage, color: getContextColor(contextPercentage) }] : []}
/>
<Text className={classes.composerContextLabel} component="span" size="xs" fw={650}>
{hasContext ? `${contextPercentage}%` : t("mentions.context")}
</Text>
</Group>
</UnstyledButton>
</Popover.Target>
<Popover.Dropdown className={classes.conversationContextPopover}>
@@ -1902,60 +1939,80 @@ const WebSearchActivity = () => {
);
};
const AssistantMessage = () => (
<MessagePrimitive.Root className={`${classes.message} ${classes.assistantMessage}`}>
<MessagePrimitive.GroupedParts groupBy={assistantMessageGroupBy} indicator="empty">
{({ 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 (
<ChainOfThoughtByIndicesProvider startIndex={startIndex} endIndex={endIndex}>
<AssistantChainOfThought>{children}</AssistantChainOfThought>
</ChainOfThoughtByIndicesProvider>
);
const AssistantMessage = () => {
const isComplete = useAuiState((state) => state.message.status?.type === "complete");
return (
<MessagePrimitive.Root
className={`${classes.message} ${classes.assistantMessage}`}
data-static={isComplete || undefined}
>
<MessagePrimitive.GroupedParts groupBy={assistantMessageGroupBy} indicator="empty">
{({ 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 (
<ChainOfThoughtByIndicesProvider startIndex={startIndex} endIndex={endIndex}>
<AssistantChainOfThought>{children}</AssistantChainOfThought>
</ChainOfThoughtByIndicesProvider>
);
}
case "group-reasoning":
return <>{children}</>;
case "group-tool":
return <AgentTraceToolGroup>{children}</AgentTraceToolGroup>;
case "indicator":
return <AssistantMessagePending status={{ type: "running" }} />;
case "text":
return <AssistantTextPart />;
case "reasoning":
return <ReasoningPart {...part} />;
case "tool-call":
return part.toolUI ?? <ToolPart {...part} />;
case "source":
return <SourcePart {...part} />;
case "file":
return <FilePart {...part} />;
case "image":
return <ImagePart {...part} />;
case "data":
return part.dataRendererUI;
default:
return null;
}
case "group-reasoning":
return <>{children}</>;
case "group-tool":
return <AgentTraceToolGroup>{children}</AgentTraceToolGroup>;
case "indicator":
return <AssistantMessagePending status={{ type: "running" }} />;
case "text":
return <AssistantTextPart />;
case "reasoning":
return <ReasoningPart {...part} />;
case "tool-call":
return part.toolUI ?? <ToolPart {...part} />;
case "source":
return <SourcePart {...part} />;
case "file":
return <FilePart {...part} />;
case "image":
return <ImagePart {...part} />;
case "data":
return part.dataRendererUI;
default:
return null;
}
}}
</MessagePrimitive.GroupedParts>
<RuntimeError />
<WebSearchActivity />
<AssistantMessageActions />
</MessagePrimitive.Root>
);
}}
</MessagePrimitive.GroupedParts>
<RuntimeError />
<WebSearchActivity />
<AssistantMessageActions />
</MessagePrimitive.Root>
);
};
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<HTMLInputElement>(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 (
<ThreadListItemPrimitive.Root className={classes.historyItem}>
<TextInput
ref={renameInputRef}
className={classes.historyRenameInput}
value={nextTitle}
onChange={(event) => 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);
}}
/>
<ActionIcon
variant="subtle"
color="green"
size="sm"
aria-label={t("renameConversation.save")}
disabled={!nextTitle.trim()}
loading={renaming}
loaderProps={{ type: "bars" }}
onClick={() => void renameConversation()}
>
<IconCheck size={15} />
</ActionIcon>
<ActionIcon
variant="subtle"
color="gray"
size="sm"
aria-label={t("cancel")}
disabled={renaming}
onClick={() => setRenameOpened(false)}
>
<IconX size={15} />
</ActionIcon>
</ThreadListItemPrimitive.Root>
);
}
return (
<ThreadListItemPrimitive.Root className={classes.historyItem}>
<Group gap={4} wrap="nowrap">
<ThreadListItemPrimitive.Trigger asChild>
<Button
<ThreadListItemPrimitive.Trigger asChild>
<UnstyledButton className={classes.historyItemTrigger} title={title} onClick={onSelect}>
<Text component="span" size="sm" fw={500} truncate>
<ThreadListItemPrimitive.Title fallback={t("newConversation")} />
</Text>
</UnstyledButton>
</ThreadListItemPrimitive.Trigger>
{actionsOpened && (
<Group className={classes.historyItemActions} gap={1} wrap="nowrap">
<ActionIcon
variant="subtle"
color="gray"
size="compact-sm"
justify="flex-start"
flex={1}
style={{ minWidth: 0 }}
onClick={onSelect}
size="sm"
title={t("renameConversation.action")}
aria-label={t("renameConversation.action")}
onClick={() => {
setNextTitle(title ?? "");
setRenameOpened(true);
}}
>
<ThreadListItemPrimitive.Title fallback={t("newConversation")} />
</Button>
</ThreadListItemPrimitive.Trigger>
<Tooltip label={t("exportConversation.action")}>
<IconPencil size={14} />
</ActionIcon>
<ActionIcon
variant="subtle"
color="gray"
@@ -2001,24 +2144,49 @@ const ThreadListItem = () => {
loading={exporting}
loaderProps={{ type: "bars" }}
disabled={!remoteId}
title={t("exportConversation.action")}
aria-label={t("exportConversation.action")}
onClick={() => void exportConversation()}
>
<IconFileExport size={14} />
</ActionIcon>
</Tooltip>
<ThreadListItemPrimitive.Delete asChild>
<ActionIcon variant="subtle" color="red" size="sm" aria-label={t("delete")}>
<ActionIcon
variant="subtle"
color="red"
size="sm"
title={t("delete")}
aria-label={t("delete")}
onClick={deleteConversation}
>
<IconTrash size={14} />
</ActionIcon>
</ThreadListItemPrimitive.Delete>
</Group>
</Group>
)}
<ActionIcon
className={classes.historyItemMenu}
variant="subtle"
color="gray"
size="sm"
title={t("conversationActions")}
aria-label={t("conversationActions")}
aria-expanded={actionsOpened}
onClick={() => setActionsOpened((current) => !current)}
>
{actionsOpened ? <IconX size={15} /> : <IconDotsVertical size={15} />}
</ActionIcon>
</ThreadListItemPrimitive.Root>
);
};
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 (
<HistorySelectContext.Provider value={onSelect}>
<Stack className={classes.historyMenu} gap="xs" p="xs">
@@ -2035,9 +2203,28 @@ const ThreadHistory = ({ onSelect }: { onSelect: () => void }) => {
{t("conversations")}
</Text>
</Group>
<ScrollArea.Autosize mah="min(24rem, 55dvh)" type="auto" offsetScrollbars>
<TextInput
value={query}
onChange={(event) => setQuery(event.currentTarget.value)}
leftSection={<IconSearch size={15} />}
placeholder={t("searchConversations")}
aria-label={t("searchConversations")}
size="xs"
/>
<ScrollArea.Autosize mah="min(24rem, 55dvh)" type="auto" scrollbars="y" offsetScrollbars>
<Stack gap={3}>
<ThreadListPrimitive.Items components={{ ThreadListItem }} />
<ThreadListPrimitive.Items>
{({ threadListItem }) => {
const itemTitle = threadListItem.title ?? t("newConversation");
if (normalizedQuery && !itemTitle.toLocaleLowerCase().includes(normalizedQuery)) return null;
return <ThreadListItem />;
}}
</ThreadListPrimitive.Items>
{normalizedQuery && !hasMatchingConversation && (
<Text size="xs" c="dimmed" ta="center" py="md">
{t("noMatchingConversations")}
</Text>
)}
</Stack>
</ScrollArea.Autosize>
</Stack>
@@ -2052,7 +2239,6 @@ const ConversationHistory = () => {
<Popover
opened={opened}
onChange={setOpened}
onDismiss={() => setOpened(false)}
position="bottom-end"
width="min(22rem, calc(100vw - 1rem))"
shadow="md"
@@ -2191,7 +2377,7 @@ const EmptyThread = () => {
</Text>
</Stack>
<Box className={classes.suggestions}>
<ThreadPrimitive.Suggestion prompt={t("suggestions.health.prompt")} send asChild>
<ThreadPrimitive.Suggestion prompt={t("suggestions.health.prompt")} send={false} clearComposer asChild>
<Button
variant="default"
className={classes.suggestion}
@@ -2200,17 +2386,17 @@ const EmptyThread = () => {
{t("suggestions.health.label")}
</Button>
</ThreadPrimitive.Suggestion>
<ThreadPrimitive.Suggestion prompt={t("suggestions.explore.prompt")} send asChild>
<ThreadPrimitive.Suggestion prompt={t("suggestions.explore.prompt")} send={false} clearComposer asChild>
<Button variant="default" className={classes.suggestion} leftSection={<IconApps size={18} />}>
{t("suggestions.explore.label")}
</Button>
</ThreadPrimitive.Suggestion>
<ThreadPrimitive.Suggestion prompt={t("suggestions.media.prompt")} send asChild>
<ThreadPrimitive.Suggestion prompt={t("suggestions.media.prompt")} send={false} clearComposer asChild>
<Button variant="default" className={classes.suggestion} leftSection={<IconSearch size={18} />}>
{t("suggestions.media.label")}
</Button>
</ThreadPrimitive.Suggestion>
<ThreadPrimitive.Suggestion prompt={t("suggestions.style.prompt")} send asChild>
<ThreadPrimitive.Suggestion prompt={t("suggestions.style.prompt")} send={false} clearComposer asChild>
<Button variant="default" className={classes.suggestion} leftSection={<IconPalette size={18} />}>
{t("suggestions.style.label")}
</Button>
@@ -2792,14 +2978,6 @@ const Composer = (props: ComposerProps) => {
<HomarrProviderQuota />
<ConversationContext />
</Group>
<Group className={classes.composerHints} gap="xs" wrap="nowrap">
<Text size="xs" c="dimmed">
{t("mentions.hint")}
</Text>
<Text size="xs" c="dimmed">
{t("commands.hint")}
</Text>
</Group>
</Group>
</ComposerPrimitive.Root>
</ComposerPrimitive.AttachmentDropzone>
@@ -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 (
<Group className={classes.panelIdentity} gap="xs" wrap="nowrap">
<ThemeIcon className={classes.panelIdentityIcon} variant="light" color="red" radius="xl" size="md">
<IconRobot size={16} />
</ThemeIcon>
<Box miw={0}>
<Text className={classes.panelIdentityTitle} size="sm" fw={700} truncate>
{t("title")}
</Text>
<Text className={classes.panelThreadTitle} size="xs" c="dimmed" truncate>
{threadTitle ?? t("newConversation")}
</Text>
</Box>
</Group>
);
};
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 (
<AssistantDirectiveEntitiesProvider>
<Group className={classes.panelHeader} justify="space-between" wrap="nowrap" gap="xs">
{onWindowClose && onMinimize && (
<Group
component="fieldset"
className={classes.windowControls}
gap={4}
wrap="nowrap"
aria-label={t("windowControls")}
>
<Tooltip label={t("close")}>
<ActionIcon
className={classes.windowControl}
variant="subtle"
color="gray"
onClick={onWindowClose}
aria-label={t("close")}
>
<IconX size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label={t("minimize")}>
<ActionIcon
className={classes.windowControl}
variant="subtle"
color="gray"
onClick={onMinimize}
aria-label={t("minimize")}
>
<IconMinus size={17} />
</ActionIcon>
</Tooltip>
</Group>
)}
<AssistantPanelIdentity />
<Group className={classes.panelActions} gap={2} wrap="nowrap">
<ConversationHistory />
<ViewRefreshAction isRefreshing={isRefreshing} onRefresh={onRefresh} />
<AutoApprovalControl />
<Tooltip label={t("newConversation")}>
<ThreadListPrimitive.New asChild>
<ActionIcon
className={classes.panelAction}
<Button
className={classes.newConversationButton}
variant="subtle"
color="gray"
size="compact-sm"
leftSection={<IconPlus size={17} />}
aria-label={t("newConversation")}
>
<IconPlus size={17} />
</ActionIcon>
{t("newConversation")}
</Button>
</ThreadListPrimitive.New>
</Tooltip>
{onExpand && (
@@ -3072,6 +3242,32 @@ export const AssistantConversationSurface = ({
</ActionIcon>
</Tooltip>
)}
{onMinimize && (
<Tooltip label={t("minimize")}>
<ActionIcon
className={classes.panelAction}
variant="subtle"
color="gray"
onClick={onMinimize}
aria-label={t("minimize")}
>
<IconMinus size={17} />
</ActionIcon>
</Tooltip>
)}
{onDismiss && (
<Tooltip label={t("close")}>
<ActionIcon
className={classes.panelAction}
variant="subtle"
color="gray"
onClick={onDismiss}
aria-label={t("close")}
>
<IconX size={16} />
</ActionIcon>
</Tooltip>
)}
</Group>
</Group>
<AssistantQuestionPortalProvider target={questionPortalTarget}>
@@ -3083,7 +3279,7 @@ export const AssistantConversationSurface = ({
<ThreadPrimitive.Viewport className={classes.viewport} autoScroll>
<Box className={classes.messages}>
<EmptyThread />
<ThreadPrimitive.Messages components={{ UserMessage, AssistantMessage }} />
<ThreadPrimitive.Messages components={assistantThreadMessageComponents} />
</Box>
<SelectionToolbarPrimitive.Root className={classes.selectionToolbar}>
<SelectionToolbarPrimitive.Quote asChild>
@@ -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<HTMLElement | null>(null);
const panelRef = useRef<HTMLDialogElement>(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 && (
<AssistantActivityBar
onOpen={onOpen}
onDismissActivity={onDismissActivity}
@@ -3210,8 +3417,9 @@ export const AssistantPanel = ({
onModelChange={onModelChange}
onReasoningChange={onReasoningChange}
autoFocusComposer
onMinimize={onClose}
onWindowClose={() => {
onMinimize={minimize}
onDismiss={() => {
stopSpeechWithoutSurface();
onDismissActivity();
onClose();
}}
@@ -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<SpeechSynthesisAdapter>();
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<Set<string>>(() => new Set());
const [queuedPrompt, setQueuedPrompt] = useState<string | null>(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 (
<AssistantAutoApprovalProvider conversationId={conversationId}>
<AssistantContext.Provider value={value}>
<AssistantWidgetRendererProvider renderer={AssistantBoardWidget}>{children}</AssistantWidgetRendererProvider>
<AssistantComposerSurfaceProvider surfaceId="floating-panel">
<AssistantPanel
opened={opened}
onOpen={open}
onClose={close}
onDismissActivity={() => {
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}
/>
</AssistantComposerSurfaceProvider>
<AssistantComposerRuntimeProvider>
<AssistantWidgetRendererProvider renderer={AssistantBoardWidget}>{children}</AssistantWidgetRendererProvider>
<AssistantComposerSurfaceBoundary surfaceId="assistant-panel">
<AssistantPanel
opened={opened}
onOpen={open}
onClose={close}
onDismissActivity={() => {
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}
/>
</AssistantComposerSurfaceBoundary>
</AssistantComposerRuntimeProvider>
</AssistantContext.Provider>
</AssistantAutoApprovalProvider>
);
@@ -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,
);
@@ -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<AssistantComposerSurfaceVa
export const useAssistantComposerSurface = () => {
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<HTMLDivElement>(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<HTMLDivElement>) => {
const target = event.target;
@@ -507,7 +516,6 @@ const AssistantComposerSurfaceBoundary = ({ children, surfaceId }: PropsWithChil
}}
onPasteCapture={handlePasteCapture}
>
<AssistantComposerSurfaceEvents />
{children}
</div>
</AssistantComposerSurfaceContext.Provider>
@@ -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 (
<AssistantRuntimeProvider aui={parentAui} runtime={surfaceRuntime}>
<AssistantComposerSurfaceBoundary surfaceId={resolvedSurfaceId}>{children}</AssistantComposerSurfaceBoundary>
<AssistantComposerSurfaceEvents />
{children}
</AssistantRuntimeProvider>
);
};
@@ -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<HTMLDivElement>(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 <UnavailableAssistantWidget description={assistant.unavailableDescription} />;
}
return (
<AssistantComposerSurfaceProvider surfaceId={`board-widget-${props.itemId ?? "preview"}`}>
<EnabledAssistantBoardWidget {...props} />
</AssistantComposerSurfaceProvider>
<AssistantComposerSurfaceBoundary surfaceId={`assistant-widget-${widgetId}`}>
<Box ref={widgetRef} w="100%" h="100%">
<EnabledAssistantBoardWidget {...props} assistantWidgetId={widgetId} />
</Box>
</AssistantComposerSurfaceBoundary>
);
};
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 <CompactAssistantWidget onOpen={assistant.close} location="panel" />;
}
if (assistant.activeWidgetId !== null && assistant.activeWidgetId !== assistantWidgetId) {
return <CompactAssistantWidget onOpen={() => assistant.activateWidget(assistantWidgetId)} location="widget" />;
}
if (width < 300 || height < 280) {
return <CompactAssistantWidget onOpen={assistant.open} />;
}
@@ -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 = <IconArrowsMaximize size={18} />;
if (location === "panel") {
title = t("widget.assistant.compact.openTitle");
description = t("widget.assistant.compact.openDescription");
actionLabel = t("widget.assistant.compact.returnToWidget");
actionIcon = <IconArrowsMinimize size={18} />;
}
if (location === "widget") {
title = t("widget.assistant.compact.otherWidgetTitle");
description = t("widget.assistant.compact.otherWidgetDescription");
actionLabel = t("widget.assistant.compact.useHere");
}
return (
<Box className={classes.widgetPanel}>
@@ -87,23 +142,16 @@ const CompactAssistantWidget = ({ onOpen }: { onOpen: () => void }) => {
</ThemeIcon>
<Stack className={classes.compactCopy} gap={1}>
<Text className={classes.compactTitle} size="sm" fw={700} lineClamp={1}>
{t("widget.assistant.compact.title")}
{title}
</Text>
<Text className={classes.compactDescription} size="xs" c="dimmed" lineClamp={2}>
{t("widget.assistant.compact.description")}
{description}
</Text>
</Stack>
</Group>
<Tooltip label={t("widget.assistant.compact.open")}>
<ActionIcon
variant="light"
color="red"
size={44}
radius="md"
onClick={onOpen}
aria-label={t("widget.assistant.compact.open")}
>
<IconArrowsMaximize size={18} />
<Tooltip label={actionLabel}>
<ActionIcon variant="light" color="red" size={44} radius="md" onClick={onOpen} aria-label={actionLabel}>
{actionIcon}
</ActionIcon>
</Tooltip>
</Group>