feat: improve assistant conversation experience

This commit is contained in:
ajnart committed 2026-08-20 14:39:59 +02:00
1 parent 53b518960c
commit 80b90802e5
11 files changed
+845 -313

No files matched your search

@@ -36,39 +36,67 @@ export const createAssistantAutoApprovalTracker = (maximumAttempts = Number.POSI
interface AssistantAutoApprovalContextValue {
enabled: boolean;
setEnabled: (enabled: boolean) => void;
requestAction: (toolCallId: string, confirm: () => void) => boolean;
preparationEnabled: boolean;
approvalEnabled: boolean;
setPreparationEnabled: (enabled: boolean) => void;
setApprovalEnabled: (enabled: boolean) => void;
requestAction: (toolCallId: string, confirm: () => void, mode?: AssistantAutomaticActionMode) => boolean;
completeAction: (toolCallId: string) => void;
retryRevision: number;
}
export type AssistantAutomaticActionMode = "preparation" | "approval";
const AssistantAutoApprovalContext = createContext<AssistantAutoApprovalContextValue | null>(null);
export const AssistantAutoApprovalProvider = ({
children,
conversationId,
}: PropsWithChildren<{ conversationId: string }>) => {
const [enabled, setEnabledState] = useState(false);
const [preparationEnabled, setPreparationEnabledState] = useState(false);
const [approvalEnabled, setApprovalEnabledState] = useState(false);
const [retryRevision, setRetryRevision] = useState(0);
const trackerRef = useRef(createAssistantAutoApprovalTracker(AUTO_APPROVAL_MAX_ATTEMPTS));
const retryTimersRef = useRef(new Map<string, number>());
const retryTimersRef = useRef(new Map<string, { timer: number; mode: AssistantAutomaticActionMode }>());
const previousConversationIdRef = useRef(conversationId);
const clearRetryTimer = useCallback((toolCallId: string) => {
const timer = retryTimersRef.current.get(toolCallId);
if (timer !== undefined) window.clearTimeout(timer);
const retry = retryTimersRef.current.get(toolCallId);
if (retry !== undefined) window.clearTimeout(retry.timer);
retryTimersRef.current.delete(toolCallId);
}, []);
const clearAutomaticActions = useCallback(() => {
for (const timer of retryTimersRef.current.values()) window.clearTimeout(timer);
retryTimersRef.current.clear();
trackerRef.current.clear();
const clearAutomaticActions = useCallback((mode?: AssistantAutomaticActionMode) => {
for (const [toolCallId, retry] of retryTimersRef.current) {
if (mode !== undefined && retry.mode !== mode) continue;
window.clearTimeout(retry.timer);
retryTimersRef.current.delete(toolCallId);
trackerRef.current.complete(toolCallId);
}
if (mode === undefined) trackerRef.current.clear();
}, []);
const setEnabled = useCallback(
(nextEnabled: boolean) => {
if (!nextEnabled) clearAutomaticActions();
setEnabledState(nextEnabled);
setPreparationEnabledState(nextEnabled);
setApprovalEnabledState(nextEnabled);
},
[clearAutomaticActions],
);
const setPreparationEnabled = useCallback(
(nextEnabled: boolean) => {
if (!nextEnabled) clearAutomaticActions("preparation");
setPreparationEnabledState(nextEnabled);
},
[clearAutomaticActions],
);
const setApprovalEnabled = useCallback(
(nextEnabled: boolean) => {
if (!nextEnabled) clearAutomaticActions("approval");
setApprovalEnabledState(nextEnabled);
},
[clearAutomaticActions],
);
@@ -79,33 +107,35 @@ export const AssistantAutoApprovalProvider = ({
if (previousConversationId === conversationId) return;
clearAutomaticActions();
setEnabledState(false);
setPreparationEnabledState(false);
setApprovalEnabledState(false);
}, [clearAutomaticActions, conversationId]);
useEffect(() => clearAutomaticActions, [clearAutomaticActions]);
const requestAction = useCallback(
(toolCallId: string, confirm: () => void) => {
if (!enabled || !trackerRef.current.claim(toolCallId)) return false;
(toolCallId: string, confirm: () => void, mode: AssistantAutomaticActionMode = "preparation") => {
const modeEnabled = mode === "approval" ? approvalEnabled : preparationEnabled;
if (!modeEnabled || !trackerRef.current.claim(toolCallId)) return false;
try {
confirm();
clearRetryTimer(toolCallId);
retryTimersRef.current.set(
toolCallId,
window.setTimeout(() => {
retryTimersRef.current.set(toolCallId, {
mode,
timer: window.setTimeout(() => {
retryTimersRef.current.delete(toolCallId);
trackerRef.current.release(toolCallId);
setRetryRevision((revision) => revision + 1);
}, AUTO_APPROVAL_RETRY_TIMEOUT_MS),
);
});
return true;
} catch {
trackerRef.current.release(toolCallId);
return false;
}
},
[clearRetryTimer, enabled],
[approvalEnabled, clearRetryTimer, preparationEnabled],
);
const completeAction = useCallback(
@@ -116,10 +146,29 @@ export const AssistantAutoApprovalProvider = ({
[clearRetryTimer],
);
const value = useMemo(
() => ({ enabled, setEnabled, requestAction, completeAction, retryRevision }),
[completeAction, enabled, requestAction, retryRevision, setEnabled],
);
const value = useMemo(() => {
const enabled = preparationEnabled || approvalEnabled;
return {
enabled,
setEnabled,
preparationEnabled,
approvalEnabled,
setPreparationEnabled,
setApprovalEnabled,
requestAction,
completeAction,
retryRevision,
};
}, [
approvalEnabled,
completeAction,
preparationEnabled,
requestAction,
retryRevision,
setApprovalEnabled,
setEnabled,
setPreparationEnabled,
]);
return <AssistantAutoApprovalContext.Provider value={value}>{children}</AssistantAutoApprovalContext.Provider>;
};
@@ -135,16 +184,20 @@ export const useAssistantAutomaticAction = ({
ready,
completed,
confirm,
mode = "preparation",
}: {
toolCallId: string;
ready: boolean;
completed: boolean;
confirm: () => void;
mode?: AssistantAutomaticActionMode;
}) => {
const { completeAction, enabled, requestAction, retryRevision } = useAssistantAutoApproval();
const { approvalEnabled, completeAction, preparationEnabled, requestAction, retryRevision } =
useAssistantAutoApproval();
const [inProgress, setInProgress] = useState(false);
const confirmRef = useRef(confirm);
confirmRef.current = confirm;
const enabled = mode === "approval" ? approvalEnabled : preparationEnabled;
useEffect(() => {
if (!ready || completed || !enabled) {
@@ -153,9 +206,9 @@ export const useAssistantAutomaticAction = ({
return;
}
const requested = requestAction(toolCallId, () => confirmRef.current());
const requested = requestAction(toolCallId, () => confirmRef.current(), mode);
setInProgress(requested);
}, [completeAction, completed, enabled, ready, requestAction, retryRevision, toolCallId]);
}, [completeAction, completed, enabled, mode, ready, requestAction, retryRevision, toolCallId]);
return inProgress;
};
@@ -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,12 +157,30 @@
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;
}
.newConversationButton {
flex: 0 0 auto;
}
.windowControls {
flex: 0 0 auto;
margin: 0;
@@ -210,6 +228,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 +279,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 +319,11 @@
padding-inline: 0.15rem;
}
.composerContext > :last-child {
.composerContextLabel {
display: none;
}
.panelThreadTitle {
display: none;
}
}
@@ -298,17 +353,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;
@@ -339,6 +447,8 @@
.message {
margin-block: 0.9rem;
contain-intrinsic-size: auto 12rem;
content-visibility: auto;
}
.userMessageWrap {
@@ -706,24 +816,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 +1198,38 @@
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);
}
.messageMoreSeparator {
height: 1px;
margin-block: 0.2rem;
background: var(--mantine-color-default-border);
}
.userActions {
min-height: 1.75rem;
}
@@ -1173,13 +1312,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);
}
@@ -1641,12 +1780,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 +1795,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 {
@@ -1705,6 +1845,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,10 +94,12 @@ import {
IconChevronUp,
IconCopy,
IconDownload,
IconDotsVertical,
IconFile,
IconFileExport,
IconLink,
IconMessage,
IconMicrophone,
IconMinus,
IconPaperclip,
IconPalette,
@@ -112,6 +117,8 @@ import {
IconThumbUp,
IconTool,
IconTrash,
IconVolume,
IconVolumeOff,
IconX,
} from "@tabler/icons-react";
import remarkBreaks from "remark-breaks";
@@ -124,6 +131,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 +192,7 @@ interface AssistantPanelProps extends AssistantConversationControls {
onClose: () => void;
onDismissActivity: () => void;
activityDismissed: boolean;
hasVisibleWidget: boolean;
isRunning: boolean;
unreadCount: number;
latestAssistantText: string;
@@ -889,6 +898,7 @@ const ToolPart = ({
toolCallId,
ready: awaitingApproval,
completed: !awaitingApproval,
mode: "approval",
confirm: () => {
respondToApproval({ approved: true, reason: "Approved automatically by the user." });
},
@@ -1107,63 +1117,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 +1161,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 +1215,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>
@@ -1732,12 +1766,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}>
@@ -1948,14 +1987,21 @@ const AssistantMessage = () => (
</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 [nextTitle, setNextTitle] = useState(title ?? "");
const exportConversation = async () => {
if (!remoteId || exporting) return;
@@ -1977,23 +2023,87 @@ const ThreadListItem = () => {
}
};
const renameConversation = () => {
const trimmedTitle = nextTitle.trim();
if (!trimmedTitle) return;
aui.threadListItem().rename(trimmedTitle);
setRenameOpened(false);
setActionsOpened(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: () => aui.threadListItem().delete(),
});
};
if (renameOpened) {
return (
<ThreadListItemPrimitive.Root className={classes.historyItem}>
<TextInput
className={classes.historyRenameInput}
value={nextTitle}
onChange={(event) => setNextTitle(event.currentTarget.value)}
aria-label={t("renameConversation.label")}
maxLength={72}
size="xs"
data-autofocus
onKeyDown={(event) => {
if (event.key === "Enter") renameConversation();
if (event.key === "Escape") setRenameOpened(false);
}}
/>
<ActionIcon
variant="subtle"
color="green"
size="sm"
aria-label={t("renameConversation.save")}
disabled={!nextTitle.trim()}
onClick={renameConversation}
>
<IconCheck size={15} />
</ActionIcon>
<ActionIcon
variant="subtle"
color="gray"
size="sm"
aria-label={t("cancel")}
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 +2111,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 +2170,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 +2206,6 @@ const ConversationHistory = () => {
<Popover
opened={opened}
onChange={setOpened}
onDismiss={() => setOpened(false)}
position="bottom-end"
width="min(22rem, calc(100vw - 1rem))"
shadow="md"
@@ -2191,7 +2344,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 +2353,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>
@@ -2749,6 +2902,26 @@ const Composer = (props: ComposerProps) => {
</ActionIcon>
</ComposerPrimitive.AddAttachment>
</Tooltip>
<AuiIf
condition={(state) => state.thread.capabilities.dictation && state.composer.dictation === undefined}
>
<Tooltip label={t("startVoiceInput")}>
<ComposerPrimitive.Dictate asChild>
<ActionIcon variant="subtle" color="gray" size="lg" aria-label={t("startVoiceInput")}>
<IconMicrophone size={17} />
</ActionIcon>
</ComposerPrimitive.Dictate>
</Tooltip>
</AuiIf>
<AuiIf condition={(state) => state.composer.dictation !== undefined}>
<Tooltip label={t("stopVoiceInput")}>
<ComposerPrimitive.StopDictation asChild>
<ActionIcon variant="light" color="red" size="lg" aria-label={t("stopVoiceInput")}>
<IconPlayerStop size={16} />
</ActionIcon>
</ComposerPrimitive.StopDictation>
</Tooltip>
</AuiIf>
</Group>
<LexicalComposerInput
ref={composerInputRef}
@@ -2792,14 +2965,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 +3144,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 +3182,7 @@ export const AssistantConversationSurface = ({
onReasoningChange,
onExpand,
onMinimize,
onWindowClose,
onDismiss,
}: AssistantConversationSurfaceProps) => {
const t = useScopedI18n("common.assistant");
const reducedMotion = useReducedMotion();
@@ -3011,52 +3197,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 +3229,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 +3266,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 +3313,7 @@ export const AssistantPanel = ({
onClose,
onDismissActivity,
activityDismissed,
hasVisibleWidget,
isRunning,
unreadCount,
latestAssistantText,
@@ -3182,7 +3366,7 @@ export const AssistantPanel = ({
return (
<>
{!opened && (
{!opened && !hasVisibleWidget && (
<AssistantActivityBar
onOpen={onOpen}
onDismissActivity={onDismissActivity}
@@ -3211,7 +3395,7 @@ export const AssistantPanel = ({
onReasoningChange={onReasoningChange}
autoFocusComposer
onMinimize={onClose}
onWindowClose={() => {
onDismiss={() => {
onDismissActivity();
onClose();
}}
@@ -5,13 +5,23 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import type {
AttachmentAdapter,
DictationAdapter,
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,
WebSpeechDictationAdapter,
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 +51,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 +486,14 @@ const AssistantThreadRuntime = () => {
),
[selectedModel],
);
const [dictation, setDictation] = useState<DictationAdapter>();
const [speech, setSpeech] = useState<SpeechSynthesisAdapter>();
useEffect(() => {
if (WebSpeechDictationAdapter.isSupported()) setDictation(new WebSpeechDictationAdapter());
if ("speechSynthesis" in window && typeof SpeechSynthesisUtterance !== "undefined") {
setSpeech(new WebSpeechSynthesisAdapter());
}
}, []);
const feedback = useMemo(
() => ({
submit: ({ message, type }: { message: ThreadMessage; type: "positive" | "negative" }) => {
@@ -511,7 +530,9 @@ const AssistantThreadRuntime = () => {
adapters: {
history,
attachments,
dictation,
feedback,
speech,
},
});
transport.setRuntime(runtime);
@@ -667,6 +688,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 +736,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 +872,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 +881,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>
);
@@ -437,21 +437,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;
@@ -571,13 +573,11 @@ interface AssistantComposerSurfaceProviderProps extends PropsWithChildren {
}
/**
* 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 +588,17 @@ export const AssistantComposerSurfaceProvider = ({ children, surfaceId }: Assist
return (
<AssistantRuntimeProvider aui={parentAui} runtime={surfaceRuntime}>
<AssistantComposerSurfaceBoundary surfaceId={resolvedSurfaceId}>{children}</AssistantComposerSurfaceBoundary>
{children}
</AssistantRuntimeProvider>
);
};
/**
* Convenience boundary for a standalone surface. Use AssistantComposerRuntimeProvider with
* localized boundaries when multiple surfaces need to share one draft and attachment queue.
*/
export const AssistantComposerSurfaceProvider = ({ children, surfaceId }: AssistantComposerSurfaceProviderProps) => (
<AssistantComposerRuntimeProvider>
<AssistantComposerSurfaceBoundary surfaceId={surfaceId}>{children}</AssistantComposerSurfaceBoundary>
</AssistantComposerRuntimeProvider>
);
@@ -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>