Files
homarr/apps/nextjs/src/components/assistant/assistant-panel.tsx
T

3211 lines
114 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import type { ComponentPropsWithoutRef, MouseEvent as ReactMouseEvent, ReactNode, RefObject } from "react";
import {
createContext,
lazy,
Suspense,
useContext,
useEffect,
useId,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import type {
EmptyMessagePartProps,
FileMessagePartProps,
ImageMessagePartProps,
ReasoningMessagePartProps,
SourceMessagePartProps,
ToolCallMessagePartProps,
Unstable_TriggerItem,
} from "@assistant-ui/react";
import type { MessageStatus } from "@assistant-ui/react";
import {
ActionBarPrimitive,
AttachmentPrimitive,
BranchPickerPrimitive,
ChainOfThoughtByIndicesProvider,
ChainOfThoughtPrimitive,
ComposerPrimitive,
ErrorPrimitive,
MessagePrimitive,
SelectionToolbarPrimitive,
ThreadListItemPrimitive,
ThreadListPrimitive,
ThreadPrimitive,
useAui,
useAuiState,
useMessagePartText,
unstable_useMentionAdapter,
unstable_useSlashCommandAdapter,
unstable_useTriggerPopoverScopeContext,
} from "@assistant-ui/react";
import { LexicalComposerInput } from "@assistant-ui/react-lexical";
import type { DirectiveChipProps } from "@assistant-ui/react-lexical";
import { MarkdownTextPrimitive, unstable_memoizeMarkdownComponents } from "@assistant-ui/react-markdown";
import type { SyntaxHighlighterProps } from "@assistant-ui/react-markdown";
import {
ActionIcon,
Anchor,
Badge,
Box,
Button,
Collapse,
Combobox,
Divider,
Group,
Image,
Loader,
Popover,
Progress,
RingProgress,
ScrollArea,
SegmentedControl,
Stack,
Text,
ThemeIcon,
Tooltip,
UnstyledButton,
useCombobox,
useComputedColorScheme,
} from "@mantine/core";
import { useReducedMotion, useWindowEvent } from "@mantine/hooks";
import {
IconActivityHeartbeat,
IconAlertTriangle,
IconApps,
IconArrowUp,
IconArrowsMaximize,
IconAt,
IconBattery1,
IconBattery3,
IconBatteryFilled,
IconBatteryOff,
IconCheck,
IconChevronDown,
IconChevronLeft,
IconChevronRight,
IconChevronUp,
IconCopy,
IconDownload,
IconFile,
IconFileExport,
IconLink,
IconMessage,
IconMinus,
IconPaperclip,
IconPalette,
IconPencil,
IconPhoto,
IconHistory,
IconPlayerStop,
IconPlus,
IconQuote,
IconRefresh,
IconRobot,
IconSearch,
IconShieldCheck,
IconThumbDown,
IconThumbUp,
IconTool,
IconTrash,
IconX,
} from "@tabler/icons-react";
import remarkBreaks from "remark-breaks";
import remarkDirective from "remark-directive";
import remarkGfm from "remark-gfm";
import { useRouter } from "next/navigation";
import { clientApi, fetchApi } from "@homarr/api/client";
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 { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client";
import classes from "./assistant-panel.module.css";
import { getAssistantActivityState } from "./assistant-activity-state";
import { useAssistantAutoApproval, useAssistantAutomaticAction } from "./assistant-auto-approval";
import {
buildAssistantConversationMarkdown,
buildAssistantMessageMarkdown,
getAssistantConversationExportFilename,
} from "./assistant-conversation-export";
import { AssistantDotMatrix } from "./assistant-dot-matrix";
import { useAssistantPreferences } from "./assistant-context";
import { AssistantImage } from "./assistant-image";
import { getAssistantDirectiveTranslationKey, parseAssistantDirectives } from "./assistant-directives";
import { remarkAssistantDirectives, resolveAssistantDirectiveEntity } from "./assistant-markdown-directives";
import type { AssistantDirectiveEntity } from "./assistant-markdown-directives";
import { normalizeAssistantMarkdown } from "./assistant-markdown";
import { getSafeAssistantAttachmentImageSource, getSafeAssistantMarkdownImageSource } from "./assistant-markdown-image";
import {
getAssistantContextBreakdown,
getAssistantConversationUsage,
getAssistantTelemetry,
} from "./assistant-message-metadata";
import type { AssistantContextBreakdown, AssistantConversationTurnUsage } from "./assistant-message-metadata";
import { assistantMessageGroupBy } from "./assistant-message-grouping";
import type { AssistantPendingAction } from "./assistant-pending-action";
import { AssistantQuestionPortalProvider } from "./assistant-question-portal";
import type { AssistantReasoningMode, AssistantRuntimeModelOption } from "./assistant-preferences";
import { useAssistantReasoningState } from "./assistant-reasoning-state";
import { getNearestTriggerScrollTop } from "./assistant-trigger-scroll";
import { getToolResultPresentation } from "./assistant-tool-result";
import { getAssistantIconSearchQuery } from "./assistant-tool-label";
import { getAssistantProviderQuotaLevel, isAssistantProviderUnavailable } from "./assistant-provider-quota";
import { getAssistantToolTraceTarget } from "./assistant-tool-trace";
import { getSafeAssistantHttpUrl } from "./assistant-url";
// Shiki's web grammar bundle is intentionally loaded only when a settled response contains a code
// block. Most assistant conversations never need it, so it should not inflate the global dashboard
// shell merely because the assistant provider is mounted.
const ShikiHighlighter = lazy(() => import("react-shiki/web"));
export interface AssistantConversationControls {
modelId: string | null;
models: AssistantRuntimeModelOption[];
modelOptionsLoading: boolean;
reasoning: AssistantReasoningMode;
isRefreshing: boolean;
autoFocusComposer?: boolean;
onRefresh: () => Promise<void>;
onModelChange: (modelId: string) => void;
onReasoningChange: (reasoning: AssistantReasoningMode) => void;
}
interface AssistantPanelProps extends AssistantConversationControls {
opened: boolean;
onOpen: () => void;
onClose: () => void;
onDismissActivity: () => void;
activityDismissed: boolean;
isRunning: boolean;
unreadCount: number;
latestAssistantText: string;
latestAssistantPartType: string | undefined;
latestUserText: string;
latestStatus: MessageStatus | undefined;
pendingAction: AssistantPendingAction | undefined;
}
const markdownRemarkPlugins = [remarkDirective, remarkAssistantDirectives, remarkGfm, remarkBreaks];
const contextIcons = {
app: IconApps,
integration: IconLink,
board: IconMessage,
widget: IconTool,
tool: IconTool,
tools: IconTool,
};
const ContextDirectiveChip = ({
directiveId,
directiveType,
label,
iconUrl,
}: DirectiveChipProps & { iconUrl?: string }) => {
const t = useScopedI18n("common.assistant");
const Icon = contextIcons[directiveType as keyof typeof contextIcons] ?? IconAt;
const safeIconUrl = getSafeAssistantMarkdownImageSource(iconUrl);
const translationKey = getAssistantDirectiveTranslationKey(directiveType);
const typeLabel = t(`mentions.${translationKey}`);
return (
<span
className={classes.directiveChip}
data-directive-type={directiveType}
data-directive-id={directiveId}
title={`${typeLabel}: ${label}`}
>
{safeIconUrl ? (
<img className={classes.directiveChipImage} src={safeIconUrl} alt="" aria-hidden referrerPolicy="no-referrer" />
) : (
<Icon size={12} aria-hidden />
)}
<span className={classes.directiveChipLabel}>{label}</span>
</span>
);
};
const getInternalAssistantHref = (href: string) => {
if (href.startsWith("/") && !href.startsWith("//")) return href;
if (typeof window === "undefined" || !URL.canParse(href)) return null;
const url = new URL(href);
return url.origin === window.location.origin ? `${url.pathname}${url.search}${url.hash}` : null;
};
const hasModifiedLinkClick = (event: ReactMouseEvent<HTMLAnchorElement>) =>
event.button !== 0 || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey;
const MarkdownLink = ({ href, children, onClick, onMouseEnter, ...props }: ComponentPropsWithoutRef<"a">) => {
const router = useRouter();
const httpLink = href !== undefined && /^https?:\/\//iu.test(href);
const relativeLink = href?.startsWith("/") && !href.startsWith("//");
const safeLink = href?.startsWith("#") || href?.startsWith("mailto:") || httpLink || relativeLink;
if (!href || !safeLink) return <span>{children}</span>;
return (
<a
href={href}
target={httpLink ? "_blank" : undefined}
rel={httpLink ? "noreferrer" : undefined}
{...props}
onMouseEnter={(event) => {
onMouseEnter?.(event);
const internalHref = getInternalAssistantHref(href);
if (internalHref) router.prefetch(internalHref);
}}
onClick={(event) => {
onClick?.(event);
if (event.defaultPrevented || hasModifiedLinkClick(event)) return;
const internalHref = getInternalAssistantHref(href);
if (!internalHref) return;
event.preventDefault();
router.push(internalHref);
}}
>
{children}
</a>
);
};
const MarkdownTable = ({ children, ...props }: ComponentPropsWithoutRef<"table">) => (
<div className={classes.markdownTableWrap}>
<table {...props}>{children}</table>
</div>
);
const MarkdownImage = ({ src, alt = "", ...props }: ComponentPropsWithoutRef<"img">) => {
const t = useScopedI18n("common.assistant.image");
const safeSource = getSafeAssistantMarkdownImageSource(src);
if (!safeSource) return null;
return (
<AssistantImage
{...props}
source={safeSource}
alt={alt}
className={classes.markdownImage}
loadingLabel={t("loading")}
failedLabel={t("failed")}
retryLabel={t("retry")}
/>
);
};
const AssistantSyntaxHighlighter = ({ code, components, language }: SyntaxHighlighterProps) => {
const colorScheme = useComputedColorScheme("light");
const streaming = useAuiState((state) => state.part.status?.type === "running");
const Pre = components.Pre;
const Code = components.Code;
if (streaming) {
return (
<Pre>
<Code>{code}</Code>
</Pre>
);
}
return (
<Suspense
fallback={
<Pre>
<Code>{code}</Code>
</Pre>
}
>
<ShikiHighlighter
className={classes.syntaxHighlighter}
language={language === "unknown" ? "text" : language}
theme={colorScheme === "dark" ? "github-dark" : "github-light"}
showLanguage={false}
addDefaultStyles={false}
>
{code}
</ShikiHighlighter>
</Suspense>
);
};
const MermaidDiagram = ({ code, components }: SyntaxHighlighterProps) => {
const t = useScopedI18n("common.assistant");
const colorScheme = useComputedColorScheme("light");
const diagramId = `assistant-mermaid-${useId().replaceAll(/[^A-Za-z0-9_-]/gu, "")}`;
const [rendered, setRendered] = useState<{ code: string; svg?: string; failed?: boolean }>({ code });
useEffect(() => {
let active = true;
setRendered({ code });
void import("mermaid")
.then(async ({ default: mermaid }) => {
mermaid.initialize({
startOnLoad: false,
securityLevel: "strict",
suppressErrorRendering: true,
theme: colorScheme === "dark" ? "dark" : "neutral",
fontFamily: "Inter, system-ui, sans-serif",
});
return await mermaid.render(diagramId, code);
})
.then(({ svg }) => {
if (active) setRendered({ code, svg });
})
.catch(() => {
document.getElementById(`d${diagramId}`)?.remove();
if (active) setRendered({ code, failed: true });
});
return () => {
active = false;
};
}, [code, colorScheme, diagramId]);
if (rendered.code !== code || (!rendered.svg && !rendered.failed)) {
return (
<Group component="output" className={classes.mermaidStatus} gap="xs">
<Loader type="bars" size="xs" />
<Text size="xs" c="dimmed">
{t("markdown.renderingDiagram")}
</Text>
</Group>
);
}
if (rendered.failed || !rendered.svg) {
const Pre = components.Pre;
const Code = components.Code;
return (
<Box className={classes.mermaidFallback}>
<Text size="xs" c="dimmed" mb="xs">
{t("markdown.diagramUnavailable")}
</Text>
<Pre>
<Code>{code}</Code>
</Pre>
</Box>
);
}
return (
<Box
component="figure"
className={classes.mermaidDiagram}
aria-label={t("markdown.diagram")}
dangerouslySetInnerHTML={{ __html: rendered.svg }}
/>
);
};
type MarkdownSpanProps = ComponentPropsWithoutRef<"span"> & {
"data-assistant-directive"?: string;
"data-directive-id"?: string;
"data-directive-label"?: string;
"data-directive-type"?: string;
};
const AssistantDirectiveEntitiesContext = createContext<AssistantDirectiveEntity[]>([]);
const AssistantMarkdownSpan = ({
children,
"data-assistant-directive": assistantDirective,
"data-directive-id": directiveId,
"data-directive-label": directiveLabel,
"data-directive-type": directiveType,
...props
}: MarkdownSpanProps) => {
const entities = useContext(AssistantDirectiveEntitiesContext);
if (assistantDirective === "true" && directiveId && directiveLabel && directiveType) {
const entity = resolveAssistantDirectiveEntity(entities, {
id: directiveId,
label: directiveLabel,
type: directiveType,
});
return (
<ContextDirectiveChip
directiveId={entity?.id ?? directiveId}
directiveType={directiveType}
label={entity?.label ?? directiveLabel}
iconUrl={entity?.iconUrl}
/>
);
}
return <span {...props}>{children}</span>;
};
const assistantMarkdownComponents = unstable_memoizeMarkdownComponents({
a: MarkdownLink,
img: MarkdownImage,
span: AssistantMarkdownSpan,
table: MarkdownTable,
SyntaxHighlighter: AssistantSyntaxHighlighter,
});
const AssistantTextPart = () => {
const { data: entities = [] } = clientApi.assistant.getContextEntities.useQuery(undefined, { staleTime: 60_000 });
return (
<AssistantDirectiveEntitiesContext.Provider value={entities}>
<MarkdownTextPrimitive
className={classes.messageMarkdown}
preprocess={normalizeAssistantMarkdown}
remarkPlugins={markdownRemarkPlugins}
components={assistantMarkdownComponents}
componentsByLanguage={{ mermaid: { SyntaxHighlighter: MermaidDiagram } }}
defer
/>
</AssistantDirectiveEntitiesContext.Provider>
);
};
const UserTextPart = () => {
const { text } = useMessagePartText();
const { data: entities = [] } = clientApi.assistant.getContextEntities.useQuery(undefined, { staleTime: 60_000 });
const segments = parseAssistantDirectives(text);
return (
<span className={classes.messageText}>
{segments.map((segment, index) => {
if (segment.kind === "text") return <span key={`${index}:${segment.text}`}>{segment.text}</span>;
const entity = resolveAssistantDirectiveEntity(entities, segment);
return (
<ContextDirectiveChip
key={`${index}:${segment.type}:${segment.id}`}
directiveId={entity?.id ?? segment.id}
directiveType={segment.type}
label={entity?.label ?? segment.label}
iconUrl={entity?.iconUrl}
/>
);
})}
</span>
);
};
const AttachmentPreview = () => {
const attachment = useAuiState((state) => state.attachment);
const [filePreview, setFilePreview] = useState<string | null>(null);
const contentImage = attachment.content?.find((part) => part.type === "image");
const persistedPreview =
contentImage?.type === "image"
? getSafeAssistantAttachmentImageSource(
contentImage.image,
typeof window === "undefined" ? undefined : window.location.origin,
)
: null;
useEffect(() => {
if (persistedPreview || attachment.type !== "image" || !attachment.file) {
setFilePreview(null);
return;
}
const objectUrl = URL.createObjectURL(attachment.file);
setFilePreview(objectUrl);
return () => URL.revokeObjectURL(objectUrl);
}, [attachment.file, attachment.type, persistedPreview]);
const source = persistedPreview ?? filePreview;
if (source) {
return <Box component="img" className={classes.attachmentImage} src={source} alt="" aria-hidden />;
}
return (
<Box className={classes.attachmentFileIcon}>
{attachment.type === "image" ? <IconPhoto size={18} /> : <IconFile size={18} />}
</Box>
);
};
const Attachment = ({ removable = false }: { removable?: boolean }) => {
const t = useScopedI18n("common.assistant");
const attachment = useAuiState((state) => state.attachment);
return (
<AttachmentPrimitive.Root className={classes.attachment}>
<AttachmentPreview />
<Box className={classes.attachmentCopy}>
<Text size="xs" fw={600} lineClamp={1} className={classes.attachmentName}>
<AttachmentPrimitive.Name />
</Text>
{attachment.contentType && (
<Text size="xs" truncate className={classes.attachmentType}>
{attachment.contentType}
</Text>
)}
</Box>
{attachment.status.type === "running" && <Loader type="bars" size="xs" />}
{removable && (
<AttachmentPrimitive.Remove asChild>
<ActionIcon variant="subtle" color="gray" size="xs" aria-label={t("removeAttachment")}>
<IconX size={12} />
</ActionIcon>
</AttachmentPrimitive.Remove>
)}
</AttachmentPrimitive.Root>
);
};
const SentAttachment = () => <Attachment />;
const HiddenMessagePart = () => null;
const ReasoningPart = (_props: ReasoningMessagePartProps) => (
<AssistantDirectiveEntitiesContext.Provider value={[]}>
<MarkdownTextPrimitive
className={`${classes.messageMarkdown} ${classes.reasoningText}`}
preprocess={normalizeAssistantMarkdown}
remarkPlugins={markdownRemarkPlugins}
components={assistantMarkdownComponents}
componentsByLanguage={{ mermaid: { SyntaxHighlighter: MermaidDiagram } }}
/>
</AssistantDirectiveEntitiesContext.Provider>
);
const AssistantMessagePending = ({ status }: EmptyMessagePartProps) => {
const t = useScopedI18n("common.assistant");
if (status.type !== "running") return null;
return (
<Group component="output" className={classes.messagePending} gap="xs" wrap="nowrap" aria-live="polite">
<AssistantDotMatrix state="thinking" role="presentation" aria-hidden />
<Text size="xs" fw={600} c="dimmed">
{t("activity.thinking")}
</Text>
</Group>
);
};
const ReasoningVisibilityContext = createContext<{
collapsed: boolean;
setCollapsed: (collapsed: boolean) => void;
}>({ collapsed: false, setCollapsed: () => undefined });
const SourcePart = (source: SourceMessagePartProps) => {
const safeUrl = getSafeAssistantHttpUrl(source.url);
const content = (
<>
<IconLink size={13} />
{source.title}
</>
);
return safeUrl ? (
<Anchor className={classes.source} href={safeUrl} target="_blank" rel="noreferrer" size="xs">
{content}
</Anchor>
) : (
<Text component="span" className={classes.source} c="dimmed" size="xs">
{content}
</Text>
);
};
const FilePart = ({ data, filename, mimeType }: FileMessagePartProps) => {
const t = useScopedI18n("common.assistant");
const downloadable = data.startsWith("data:");
const displayName = filename ?? mimeType;
return (
<Group className={classes.messageFile} gap="xs" wrap="nowrap">
<IconFile size={16} />
<Text size="xs" lineClamp={1} flex={1}>
{displayName}
</Text>
{downloadable && (
<ActionIcon
component="a"
href={data}
download={filename ?? "assistant-file"}
variant="subtle"
color="gray"
size="xs"
aria-label={t("downloadFile", { name: displayName })}
>
<IconDownload size={13} />
</ActionIcon>
)}
</Group>
);
};
const ImagePart = ({ image, filename }: ImageMessagePartProps) => {
const t = useScopedI18n("common.assistant");
const source = getSafeAssistantAttachmentImageSource(
image,
typeof window === "undefined" ? undefined : window.location.origin,
);
if (!source) {
return (
<Group className={classes.messageFile} gap="xs" wrap="nowrap">
<IconAlertTriangle size={16} />
<Text size="xs">{t("externalImageBlocked")}</Text>
</Group>
);
}
return (
<AssistantImage
messagePart
source={source}
alt={filename ?? t("attachedImage")}
caption={filename}
className={classes.messageImage}
loadingLabel={t("image.loading")}
failedLabel={t("image.failed")}
retryLabel={t("image.retry")}
/>
);
};
const formatToolResultValue = (value: string | number | boolean) =>
typeof value === "number" ? value.toLocaleString() : String(value);
const ToolResultPresentation = ({ result, toolName }: { result: unknown; toolName: string }) => {
const presentation = getToolResultPresentation(result, { toolName });
if (!presentation) return null;
if (presentation.type === "text") {
return (
<Text size="sm" className={classes.toolResultText}>
{presentation.text}
</Text>
);
}
if (presentation.type === "properties") {
return (
<Box className={classes.toolResultProperties}>
{presentation.fields.map((field) => (
<Box key={field.label} className={classes.toolResultProperty}>
<Text size="xs" c="dimmed" lineClamp={1}>
{field.label}
</Text>
<Text size="sm" fw={600} lineClamp={2}>
{formatToolResultValue(field.value)}
</Text>
</Box>
))}
</Box>
);
}
if (presentation.type === "icons") {
if (presentation.items.length === 0) return null;
const hiddenCount = Math.max(0, presentation.totalCount - presentation.items.length);
return (
<Box className={classes.toolResultIconCollection}>
{presentation.items.map((item) => (
<Box key={`${item.repository ?? "icon"}-${item.url}`} className={classes.toolResultIconItem}>
<Box className={classes.toolResultIconPreview}>
<Image
src={item.url}
alt={item.name}
className={classes.toolResultIconImage}
fit="contain"
loading="lazy"
/>
</Box>
<Group gap={4} mt="xs" wrap="nowrap" justify="space-between">
<Text size="xs" fw={650} lineClamp={1} title={item.name}>
{item.name}
</Text>
<Badge size="xs" variant="light" color="gray" flex="0 0 auto">
{item.variant}
</Badge>
</Group>
{item.repository && (
<Text size="xs" c="dimmed" lineClamp={1} title={item.repository}>
{item.repository}
</Text>
)}
</Box>
))}
{hiddenCount > 0 && (
<Badge className={classes.toolResultMore} size="sm" variant="outline" color="gray">
+{hiddenCount}
</Badge>
)}
</Box>
);
}
const hiddenCount = Math.max(0, presentation.totalCount - presentation.items.length);
return (
<Box className={classes.toolResultCollection}>
{presentation.items.map((item, index) => (
<Box key={`${item.title}-${index}`} className={classes.toolResultItem}>
<Group gap="xs" justify="space-between" wrap="nowrap">
<Box className={classes.toolResultIdentity}>
<Text size="sm" fw={650} lineClamp={1}>
{item.title}
</Text>
{item.description && (
<Text size="xs" c="dimmed" lineClamp={1}>
{item.description}
</Text>
)}
</Box>
{item.badges.length > 0 && (
<Group gap={4} wrap="nowrap">
{item.badges.map((badge) => (
<Badge key={badge} size="xs" variant="light" color="gray">
{badge}
</Badge>
))}
</Group>
)}
</Group>
{item.fields.length > 0 && (
<Group gap="xs" mt={6} wrap="wrap">
{item.fields.map((field) => (
<Text key={field.label} size="xs" c="dimmed">
{field.label}:{" "}
<Text component="span" inherit c="var(--mantine-color-text)" fw={600}>
{formatToolResultValue(field.value)}
</Text>
</Text>
))}
</Group>
)}
</Box>
))}
{hiddenCount > 0 && (
<Badge className={classes.toolResultMore} size="sm" variant="outline" color="gray">
+{hiddenCount}
</Badge>
)}
</Box>
);
};
const getToolResultNavigation = (result: unknown) => {
if (typeof result !== "object" || result === null || Array.isArray(result)) return [];
const record = result as Record<string, unknown>;
const candidates = [
{ href: record.previewPath, translationKey: "toolNavigation.openPreview" as const },
{ href: record.managementPath, translationKey: "toolNavigation.openWidget" as const },
];
return candidates.filter(
(candidate): candidate is { href: string; translationKey: (typeof candidate)["translationKey"] } =>
typeof candidate.href === "string" && candidate.href.startsWith("/") && !candidate.href.startsWith("//"),
);
};
const ToolResultNavigation = ({ result }: { result: unknown }) => {
const t = useScopedI18n("common.assistant");
const router = useRouter();
const links = getToolResultNavigation(result);
if (links.length === 0) return null;
return (
<Group className={classes.toolResultNavigation} gap="xs" wrap="wrap">
{links.map((link) => (
<Button
key={`${link.translationKey}-${link.href}`}
component="a"
href={link.href}
size="compact-xs"
variant="light"
leftSection={<IconLink size={13} aria-hidden />}
onMouseEnter={() => router.prefetch(link.href)}
onFocus={() => router.prefetch(link.href)}
onClick={(event) => {
if (hasModifiedLinkClick(event)) return;
event.preventDefault();
router.push(link.href);
}}
>
{t(link.translationKey)}
</Button>
))}
</Group>
);
};
const ToolResultPreview = ({ result, toolName }: { result: unknown; toolName: string }) => {
const navigation = getToolResultNavigation(result);
if (navigation.length === 0) return <ToolResultPresentation result={result} toolName={toolName} />;
return (
<Stack gap="xs">
<ToolResultPresentation result={result} toolName={toolName} />
<ToolResultNavigation result={result} />
</Stack>
);
};
const AgentTraceToolContext = createContext(false);
const ToolPart = ({
toolCallId,
toolName,
args,
result,
isError,
status,
approval,
respondToApproval,
timing,
}: ToolCallMessagePartProps) => {
const t = useScopedI18n("common.assistant");
const compact = useContext(AgentTraceToolContext);
const [opened, setOpened] = useState(false);
const [approvalResponse, setApprovalResponse] = useState<"approve" | "deny" | null>(null);
const completed = status?.type === "complete";
const awaitingApproval = approval !== undefined && approval.approved === undefined && !approval.resolution;
const denied = approval?.approved === false;
const failed =
!denied &&
(isError === true ||
status?.type === "incomplete" ||
(typeof result === "object" && result !== null && "error" in result));
const successful = completed && !denied && !failed;
const compactPresentation = compact && !awaitingApproval;
const traceTarget = getAssistantToolTraceTarget(args);
const iconSearchQuery = getAssistantIconSearchQuery(toolName, args);
const displayName =
iconSearchQuery === null
? toolName.replaceAll("_", " ")
: iconSearchQuery.length > 0
? t("toolActivity.iconSearch", { query: iconSearchQuery })
: t("toolActivity.iconBrowse");
const duration = timing?.completedAt !== undefined ? Math.max(0, timing.completedAt - timing.startedAt) : undefined;
const autoApprovalInProgress = useAssistantAutomaticAction({
toolCallId,
ready: awaitingApproval,
completed: !awaitingApproval,
confirm: () => {
respondToApproval({ approved: true, reason: "Approved automatically by the user." });
},
});
return (
<Box className={classes.tool} data-compact={compactPresentation || undefined}>
<UnstyledButton
className={classes.toolHeader}
onClick={() => setOpened((current) => !current)}
aria-expanded={opened}
>
<Group justify="space-between" wrap="nowrap" gap="xs">
<Group gap="xs" wrap="nowrap">
<ThemeIcon
size="sm"
radius="xl"
variant="light"
color={denied || failed ? "red" : successful ? "green" : "gray"}
>
{denied || failed ? <IconX size={13} /> : successful ? <IconCheck size={13} /> : <IconRobot size={13} />}
</ThemeIcon>
<Box miw={0}>
<Text size={compactPresentation ? "xs" : "sm"} fw={650} lineClamp={1}>
{displayName}
</Text>
{compactPresentation && traceTarget && (
<Text size="xs" c="dimmed" lineClamp={1}>
{traceTarget}
</Text>
)}
</Box>
</Group>
<Group gap={6} wrap="nowrap">
{duration !== undefined && (
<Text size="xs" c="dimmed">
{duration < 1000 ? `${duration} ms` : `${(duration / 1000).toFixed(1)} s`}
</Text>
)}
<Text size="xs" c="dimmed">
{awaitingApproval
? t("approvalRequired")
: denied
? t("denied")
: failed
? t("failed")
: successful
? t("complete")
: t("working")}
</Text>
<IconChevronDown
size={14}
className={classes.disclosureIcon}
data-opened={opened || awaitingApproval || undefined}
/>
</Group>
</Group>
</UnstyledButton>
{awaitingApproval && (
<Box className={classes.approvalPanel}>
<Text size="sm" fw={600}>
{t("approvalDescription")}
</Text>
<ToolResultPreview result={args} toolName={toolName} />
{autoApprovalInProgress ? (
<Group className={classes.autoApprovalProgress} gap="sm" wrap="nowrap">
<Loader type="bars" size="sm" color="green" />
<Text size="sm" fw={600}>
{t("autoApproval.approving")}
</Text>
</Group>
) : (
<Group className={classes.approvalActions} gap="sm" grow wrap="nowrap">
<Button
size="md"
fullWidth
leftSection={<IconCheck size={18} />}
loading={approvalResponse === "approve"}
disabled={approvalResponse !== null}
onClick={() => {
setApprovalResponse("approve");
respondToApproval({ approved: true });
}}
>
{t("approveAndRun")}
</Button>
<Button
size="md"
fullWidth
variant="default"
leftSection={<IconX size={18} />}
loading={approvalResponse === "deny"}
disabled={approvalResponse !== null}
onClick={() => {
setApprovalResponse("deny");
respondToApproval({ approved: false });
}}
>
{t("deny")}
</Button>
</Group>
)}
</Box>
)}
{!compactPresentation && successful && result !== undefined && (
<ToolResultPreview result={result} toolName={toolName} />
)}
<Collapse expanded={opened}>
<Stack gap="xs" mt="sm">
{compactPresentation && successful && result !== undefined && (
<ToolResultPreview result={result} toolName={toolName} />
)}
<Box>
<Text size="xs" fw={600} c="dimmed">
{t("toolInput")}
</Text>
<Text className={classes.codeBlock} size="xs" component="pre">
{JSON.stringify(args, null, 2)}
</Text>
</Box>
{!denied && result !== undefined && (
<Box>
<Text size="xs" fw={600} c="dimmed">
{t("toolOutput")}
</Text>
<Text className={classes.codeBlock} size="xs" component="pre">
{typeof result === "string" ? result : JSON.stringify(result, null, 2)}
</Text>
</Box>
)}
</Stack>
</Collapse>
</Box>
);
};
const AgentTraceToolGroup = ({ children }: { children?: ReactNode }) => (
<AgentTraceToolContext.Provider value>
<Stack className={classes.agentTraceTools} gap={4}>
{children}
</Stack>
</AgentTraceToolContext.Provider>
);
const ChainOfThoughtLayout = ({ children }: { children?: ReactNode }) => (
<Stack className={classes.reasoningParts} gap={6}>
{children}
</Stack>
);
const AssistantChainOfThought = ({ children }: { children?: ReactNode }) => {
const t = useScopedI18n("common.assistant");
const contentId = useId();
const { collapsed: preferredCollapsed, setCollapsed: setPreferredCollapsed } = useContext(ReasoningVisibilityContext);
const { chainStatus, collapsed } = useAssistantReasoningState(preferredCollapsed);
const running = chainStatus.type === "running";
return (
<ChainOfThoughtPrimitive.Root className={classes.reasoning} data-opened={!collapsed || undefined}>
<ChainOfThoughtPrimitive.AccordionTrigger asChild>
<UnstyledButton
className={classes.reasoningToggle}
type="button"
aria-expanded={!collapsed}
aria-controls={contentId}
onClick={() => setPreferredCollapsed(!collapsed)}
>
<Group gap="xs" wrap="nowrap">
{(running || chainStatus.type === "requires-action") && (
<AssistantDotMatrix state={running ? "thinking" : "waiting"} role="presentation" aria-hidden />
)}
<Text size="xs" fw={650} c="dimmed">
{t("reasoning")}
</Text>
</Group>
<IconChevronDown size={14} className={classes.reasoningChevron} />
</UnstyledButton>
</ChainOfThoughtPrimitive.AccordionTrigger>
<Box id={contentId} className={classes.reasoningContent} aria-busy={running} hidden={collapsed}>
<ChainOfThoughtLayout>{children}</ChainOfThoughtLayout>
</Box>
</ChainOfThoughtPrimitive.Root>
);
};
const BranchPicker = () => {
const t = useScopedI18n("common.assistant");
return (
<BranchPickerPrimitive.Root hideWhenSingleBranch className={classes.branchPicker}>
<BranchPickerPrimitive.Previous asChild>
<ActionIcon variant="subtle" color="gray" size="sm" aria-label={t("previousResponse")}>
<IconChevronLeft size={14} />
</ActionIcon>
</BranchPickerPrimitive.Previous>
<Text size="xs" c="dimmed">
<BranchPickerPrimitive.Number /> / <BranchPickerPrimitive.Count />
</Text>
<BranchPickerPrimitive.Next asChild>
<ActionIcon variant="subtle" color="gray" size="sm" aria-label={t("nextResponse")}>
<IconChevronRight size={14} />
</ActionIcon>
</BranchPickerPrimitive.Next>
</BranchPickerPrimitive.Root>
);
};
const downloadAssistantMarkdown = (markdown: string, filename: string) => {
const blobUrl = URL.createObjectURL(new Blob([markdown], { type: "text/markdown;charset=utf-8" }));
const link = document.createElement("a");
link.href = blobUrl;
link.download = filename;
link.hidden = true;
document.body.append(link);
link.click();
link.remove();
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");
return (
<Box className={classes.messageActions}>
<ActionBarPrimitive.Root hideWhenRunning className={classes.messageActionBar}>
<Group gap={2} wrap="wrap">
<ProviderMessageInfo />
<Tooltip label={t("copy")}>
<ActionBarPrimitive.Copy asChild>
<ActionIcon variant="subtle" color="gray" size="sm" aria-label={t("copy")}>
<IconCopy size={14} />
</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")}>
<IconRefresh size={14} />
</ActionIcon>
</ActionBarPrimitive.Reload>
</Tooltip>
</Group>
</ActionBarPrimitive.Root>
<BranchPicker />
</Box>
);
};
const UserMessageActions = () => {
const t = useScopedI18n("common.assistant");
return (
<ActionBarPrimitive.Root hideWhenRunning className={classes.userActions}>
<Group gap={2}>
<Tooltip label={t("copyMessage")}>
<ActionBarPrimitive.Copy asChild>
<ActionIcon variant="subtle" color="gray" size="sm" aria-label={t("copyMessage")}>
<IconCopy size={14} />
</ActionIcon>
</ActionBarPrimitive.Copy>
</Tooltip>
<Tooltip label={t("editMessage")}>
<ActionBarPrimitive.Edit asChild>
<ActionIcon variant="subtle" color="gray" size="sm" aria-label={t("editMessage")}>
<IconPencil size={14} />
</ActionIcon>
</ActionBarPrimitive.Edit>
</Tooltip>
</Group>
</ActionBarPrimitive.Root>
);
};
const EditComposer = () => {
const t = useScopedI18n("common.assistant");
return (
<ComposerPrimitive.Root className={classes.editComposer}>
<ComposerPrimitive.Input className={classes.editComposerInput} rows={2} aria-label={t("editMessage")} />
<Group gap="xs" justify="flex-end">
<ComposerPrimitive.Cancel asChild>
<Button size="compact-sm" variant="default">
{t("cancelEdit")}
</Button>
</ComposerPrimitive.Cancel>
<ComposerPrimitive.Send asChild>
<Button size="compact-sm">{t("updateMessage")}</Button>
</ComposerPrimitive.Send>
</Group>
</ComposerPrimitive.Root>
);
};
const UserMessage = () => {
const isEditing = useAuiState((state) => state.composer.isEditing);
if (isEditing) return <EditComposer />;
return (
<MessagePrimitive.Root className={`${classes.message} ${classes.userMessageWrap}`}>
<Box className={classes.userMessage}>
<MessagePrimitive.Quote>
{(quote) => (
<Text component="blockquote" className={classes.messageQuote} size="sm">
{quote.text}
</Text>
)}
</MessagePrimitive.Quote>
<MessagePrimitive.Attachments components={{ Attachment: SentAttachment }} />
<MessagePrimitive.Parts
components={{ Text: UserTextPart, File: HiddenMessagePart, Image: HiddenMessagePart }}
/>
</Box>
<Group className={classes.userMessageActions} justify="flex-end" gap="xs" wrap="wrap">
<UserMessageActions />
<BranchPicker />
</Group>
</MessagePrimitive.Root>
);
};
const formatDuration = (milliseconds: number) =>
milliseconds < 1000 ? `${Math.round(milliseconds)} ms` : `${(milliseconds / 1000).toFixed(1)} s`;
const formatCost = (cost: number) => {
if (cost === 0) return "$0";
if (cost < 0.00001) return `<$0.00001`;
return `$${cost.toFixed(cost < 0.001 ? 5 : cost < 0.01 ? 4 : 3)}`;
};
const getContextColor = (percentage: number) => {
if (percentage >= 90) return "red";
if (percentage >= 75) return "orange";
return "blue";
};
const getAssistantProviderPreset = (provider: string) => {
if (!assistantProviderIds.includes(provider as AssistantProvider)) return null;
return assistantProviderPresets[provider as AssistantProvider];
};
const ProviderIcon = ({ provider, size = 16 }: { provider: string; size?: number }) => {
const colorScheme = useComputedColorScheme("light");
const preset = getAssistantProviderPreset(provider);
const source = colorScheme === "dark" && preset && "darkIconUrl" in preset ? preset.darkIconUrl : preset?.iconUrl;
return source ? (
<Box component="img" className={classes.providerIcon} src={source} alt="" aria-hidden w={size} h={size} />
) : (
<IconRobot size={size} aria-hidden />
);
};
const ProviderMessageInfo = () => {
const metadata = useAuiState((state) => state.message.metadata);
const telemetry = getAssistantTelemetry(metadata);
if (!telemetry) return null;
const details = [
telemetry.modelId,
telemetry.durationMs !== undefined ? formatDuration(telemetry.durationMs) : undefined,
telemetry.outputTokensPerSecond !== undefined ? `${telemetry.outputTokensPerSecond.toFixed(1)} tok/s` : undefined,
telemetry.cost !== undefined ? formatCost(telemetry.cost) : undefined,
].filter(Boolean);
return (
<Tooltip
label={
<Stack gap={2}>
<Text size="xs" fw={700} tt="capitalize">
{telemetry.provider}
</Text>
<Text size="xs">{details.join(" · ")}</Text>
</Stack>
}
multiline
maw="min(24rem, calc(100vw - 2rem))"
>
<Box
component="span"
className={classes.providerAction}
// oxlint-disable-next-line jsx-a11y/prefer-tag-over-role -- the logo and its focused tooltip form one labelled image
role="img"
tabIndex={0}
aria-label={`${telemetry.provider}: ${telemetry.modelId}`}
>
<ProviderIcon provider={telemetry.provider} />
</Box>
</Tooltip>
);
};
const ConversationTurn = ({
index,
messageId,
threadId,
turn,
}: {
index: number;
messageId?: string;
threadId?: string;
turn: AssistantConversationTurnUsage;
}) => {
const t = useScopedI18n("common.assistant");
const detailsId = useId();
const [opened, setOpened] = useState(false);
const generationQuery = clientApi.assistant.getGenerationTelemetry.useQuery(
{ threadId: threadId ?? "", messageId: messageId ?? "" },
{
enabled: opened && turn.provider === "openrouter" && Boolean(threadId) && Boolean(messageId),
refetchInterval: (query) =>
query.state.data?.complete === false && query.state.dataUpdateCount < 6 ? 1500 : false,
retry: 2,
staleTime: (query) => (query.state.data?.complete ? 5 * 60_000 : 0),
},
);
const generationById = new Map(
generationQuery.data?.generations.flatMap((generation) =>
generation.generationId ? [[generation.generationId, generation] as const] : [],
) ?? [],
);
const steps = turn.telemetry.steps.map((step) => ({
...step,
...(step.generationId ? generationById.get(step.generationId) : undefined),
}));
const sumCompleteMetric = (getValue: (step: (typeof steps)[number]) => number | undefined) => {
if (steps.length === 0) return undefined;
const values = steps.map(getValue);
return values.some((value) => value === undefined)
? undefined
: (values as number[]).reduce((sum, value) => sum + value, 0);
};
const providerInputTokens = sumCompleteMetric((step) => step.inputTokens);
const providerOutputTokens = sumCompleteMetric((step) => step.outputTokens);
const providerGenerationTimeMs = sumCompleteMetric((step) => step.generationTimeMs);
const providerOutputTokensPerSecond =
providerOutputTokens !== undefined && providerGenerationTimeMs !== undefined && providerGenerationTimeMs > 0
? providerOutputTokens / (providerGenerationTimeMs / 1000)
: undefined;
const latestStep = steps.at(-1);
const providerContextUsed =
latestStep?.inputTokens !== undefined && latestStep.outputTokens !== undefined
? latestStep.inputTokens + latestStep.outputTokens
: undefined;
const usage = {
...turn.usage,
...(generationQuery.data?.complete && providerInputTokens !== undefined
? { inputTokens: providerInputTokens }
: {}),
...(generationQuery.data?.complete && providerOutputTokens !== undefined
? { outputTokens: providerOutputTokens }
: {}),
...(generationQuery.data?.complete && providerInputTokens !== undefined && providerOutputTokens !== undefined
? { totalTokens: providerInputTokens + providerOutputTokens }
: {}),
...(generationQuery.data?.complete && sumCompleteMetric((step) => step.cachedInputTokens) !== undefined
? { cachedInputTokens: sumCompleteMetric((step) => step.cachedInputTokens) }
: {}),
...(generationQuery.data?.complete && sumCompleteMetric((step) => step.reasoningTokens) !== undefined
? { reasoningTokens: sumCompleteMetric((step) => step.reasoningTokens) }
: {}),
};
const telemetry = {
...turn.telemetry,
steps,
...(generationQuery.data?.complete && providerGenerationTimeMs !== undefined
? { generationTimeMs: providerGenerationTimeMs }
: {}),
...(generationQuery.data?.complete && providerOutputTokensPerSecond !== undefined
? { providerOutputTokensPerSecond }
: {}),
...(generationQuery.data?.complete && providerContextUsed !== undefined
? {
contextUsed: providerContextUsed,
...(turn.telemetry.contextLength
? { contextUtilization: Math.min(providerContextUsed / turn.telemetry.contextLength, 1) }
: {}),
}
: {}),
...(generationQuery.data?.complete && sumCompleteMetric((step) => step.cost) !== undefined
? { cost: sumCompleteMetric((step) => step.cost), costType: "reported" as const }
: {}),
...(generationQuery.data?.complete && sumCompleteMetric((step) => step.upstreamCost) !== undefined
? { upstreamCost: sumCompleteMetric((step) => step.upstreamCost) }
: {}),
...(generationQuery.data?.complete && sumCompleteMetric((step) => step.cacheDiscount) !== undefined
? { cacheDiscount: sumCompleteMetric((step) => step.cacheDiscount) }
: {}),
...(generationQuery.data?.complete && sumCompleteMetric((step) => step.fallbackCount) !== undefined
? { fallbackCount: sumCompleteMetric((step) => step.fallbackCount) }
: {}),
...(generationQuery.data?.complete && sumCompleteMetric((step) => step.fallbackLatencyMs) !== undefined
? { fallbackLatencyMs: sumCompleteMetric((step) => step.fallbackLatencyMs) }
: {}),
};
const displayedThroughput = telemetry.providerOutputTokensPerSecond ?? telemetry.outputTokensPerSecond;
return (
<Box className={classes.conversationTurn} data-opened={opened || undefined}>
<UnstyledButton
className={classes.conversationTurnTrigger}
aria-expanded={opened}
aria-controls={detailsId}
onClick={() => setOpened((value) => !value)}
>
<Group justify="space-between" align="flex-start" gap="xs" wrap="nowrap">
<Group gap="xs" wrap="nowrap" miw={0}>
<Box className={classes.conversationTurnProvider}>
<ProviderIcon provider={turn.provider} size={15} />
</Box>
<Box miw={0}>
<Text size="xs" fw={650} truncate>
{t("usage.step", { number: index + 1 })} · {turn.modelId}
</Text>
<Text size="xs" c="dimmed">
{[
telemetry.durationMs !== undefined ? formatDuration(telemetry.durationMs) : undefined,
displayedThroughput !== undefined ? `${displayedThroughput.toFixed(1)} tok/s` : undefined,
usage.totalTokens !== undefined
? `${usage.totalTokens.toLocaleString()} ${t("usage.tokens")}`
: undefined,
telemetry.cost !== undefined ? formatCost(telemetry.cost) : undefined,
]
.filter(Boolean)
.join(" · ") || t("usage.notReported")}
</Text>
</Box>
</Group>
<IconChevronDown size={14} className={classes.disclosureIcon} data-opened={opened || undefined} />
</Group>
</UnstyledButton>
<Collapse id={detailsId} expanded={opened}>
<Stack gap="sm" pt="sm">
{generationQuery.isFetching && generationQuery.data?.complete !== true && (
<Group gap="xs">
<Loader size="xs" type="bars" />
<Text size="xs" c="dimmed">
{t("usage.loadingProviderDetails")}
</Text>
</Group>
)}
{!generationQuery.isFetching && generationQuery.data?.complete === false && (
<Text size="xs" c="dimmed">
{t("usage.providerDetailsPending")}
</Text>
)}
<Box className={classes.usageGrid}>
{[
[t("usage.input"), usage.inputTokens?.toLocaleString()],
[t("usage.output"), usage.outputTokens?.toLocaleString()],
[t("usage.cached"), usage.cachedInputTokens?.toLocaleString()],
[t("usage.reasoning"), usage.reasoningTokens?.toLocaleString()],
[t("usage.cacheWrite"), usage.cacheWriteTokens?.toLocaleString()],
[t("usage.tokens"), usage.totalTokens?.toLocaleString()],
[
t("usage.firstOutput"),
telemetry.timeToFirstOutputMs !== undefined ? formatDuration(telemetry.timeToFirstOutputMs) : undefined,
],
[
t("usage.endToEnd"),
telemetry.durationMs !== undefined ? formatDuration(telemetry.durationMs) : undefined,
],
[
t("usage.generationTime"),
telemetry.generationTimeMs !== undefined ? formatDuration(telemetry.generationTimeMs) : undefined,
],
[
t("usage.providerThroughput"),
displayedThroughput !== undefined ? `${displayedThroughput.toFixed(1)} tok/s` : undefined,
],
[
t("usage.cost"),
telemetry.cost !== undefined
? `${formatCost(telemetry.cost)}${telemetry.costType ? ` · ${t(`usage.${telemetry.costType}`)}` : ""}`
: undefined,
],
[
t("usage.upstreamCost"),
telemetry.upstreamCost !== undefined ? formatCost(telemetry.upstreamCost) : undefined,
],
[t("usage.fallbacks"), telemetry.fallbackCount?.toLocaleString()],
[
t("usage.fallbackLatency"),
telemetry.fallbackLatencyMs !== undefined ? formatDuration(telemetry.fallbackLatencyMs) : undefined,
],
[
t("usage.cacheDiscount"),
telemetry.cacheDiscount !== undefined ? formatCost(telemetry.cacheDiscount) : undefined,
],
[t("usage.webSearches"), telemetry.webSearchRequests?.toLocaleString()],
[t("usage.finishReason"), telemetry.finishReason],
].map(([label, value]) => (
<div key={label}>
<Text size="xs" c="dimmed">
{label}
</Text>
<Text size="sm" fw={600}>
{value ?? t("usage.notReported")}
</Text>
</div>
))}
</Box>
{(telemetry.contextUsed !== undefined || telemetry.contextLength !== undefined) && (
<Text size="xs" c="dimmed">
{t("usage.contextWindow")}: {telemetry.contextUsed?.toLocaleString() ?? t("usage.notReported")} /{" "}
{telemetry.contextLength?.toLocaleString() ?? t("usage.notReported")}
</Text>
)}
{steps.length > 0 && (
<Stack gap={4}>
<Text size="xs" fw={650}>
{t("usage.agentSteps")}
</Text>
{steps.map((step) => (
<Box key={step.index} className={classes.stepRow}>
<Group justify="space-between" gap="xs" wrap="nowrap">
<Text size="xs" fw={600}>
{t("usage.step", { number: step.index })}
{step.routedProvider ? ` · ${step.routedProvider}` : ""}
</Text>
<Text size="xs" c="dimmed">
{formatDuration(step.durationMs)}
{step.providerOutputTokensPerSecond !== undefined
? ` · ${step.providerOutputTokensPerSecond.toFixed(1)} tok/s`
: step.outputTokensPerSecond !== undefined
? ` · ${step.outputTokensPerSecond.toFixed(1)} tok/s`
: ""}
{step.cost !== undefined ? ` · ${formatCost(step.cost)}` : ""}
</Text>
</Group>
<Text size="xs" c="dimmed">
{[
step.providerLatencyMs !== undefined
? `${t("usage.providerLatency")} ${formatDuration(step.providerLatencyMs)}`
: undefined,
step.generationTimeMs !== undefined
? `${t("usage.generationTime")} ${formatDuration(step.generationTimeMs)}`
: undefined,
step.moderationLatencyMs !== undefined
? `${t("usage.moderationLatency")} ${formatDuration(step.moderationLatencyMs)}`
: undefined,
step.inputTokens !== undefined
? `${step.inputTokens.toLocaleString()} ${t("usage.inputShort")}`
: undefined,
step.outputTokens !== undefined
? `${step.outputTokens.toLocaleString()} ${t("usage.outputShort")}`
: undefined,
step.cachedInputTokens !== undefined
? `${step.cachedInputTokens.toLocaleString()} ${t("usage.cachedShort")}`
: undefined,
step.reasoningTokens !== undefined
? `${step.reasoningTokens.toLocaleString()} ${t("usage.reasoningShort")}`
: undefined,
step.fallbackCount !== undefined
? `${t("usage.fallbacks")} ${step.fallbackCount.toLocaleString()}`
: undefined,
step.fallbackLatencyMs !== undefined
? `${t("usage.fallbackLatency")} ${formatDuration(step.fallbackLatencyMs)}`
: undefined,
step.nativeFinishReason,
]
.filter(Boolean)
.join(" · ") || t("usage.notReported")}
</Text>
{(step.normalizedInputTokens !== undefined || step.normalizedOutputTokens !== undefined) && (
<Text size="xs" c="dimmed">
{t("usage.normalizedTokens")}: {step.normalizedInputTokens?.toLocaleString() ?? "–"} /{" "}
{step.normalizedOutputTokens?.toLocaleString() ?? "–"}
</Text>
)}
{(step.routerStrategy || step.routerRegion || step.serviceTier || step.dataRegion || step.isByok) && (
<Text size="xs" c="dimmed">
{[
step.routerStrategy,
step.routerRegion,
step.serviceTier,
step.dataRegion,
step.isByok ? t("usage.byok") : undefined,
]
.filter(Boolean)
.join(" · ")}
</Text>
)}
{step.generationId && (
<Text size="xs" c="dimmed" className={classes.generationId} title={step.generationId}>
{t("usage.generation")}: {step.generationId}
</Text>
)}
</Box>
))}
</Stack>
)}
</Stack>
</Collapse>
</Box>
);
};
const ConversationContextBreakdown = ({ breakdown }: { breakdown: AssistantContextBreakdown }) => {
const t = useScopedI18n("common.assistant");
const hasContext = breakdown.percentage !== undefined;
const categories = [
{ label: t("usage.input"), value: breakdown.inputTokens },
{ label: t("usage.cached"), value: breakdown.cachedInputTokens },
{ label: t("usage.output"), value: breakdown.outputTokens },
{ label: t("usage.reasoning"), value: breakdown.reasoningTokens },
];
return (
<Box className={classes.contextBreakdown}>
<RingProgress
className={classes.contextBreakdownRing}
size={76}
thickness={7}
roundCaps
sections={
hasContext ? [{ value: breakdown.percentage ?? 0, color: getContextColor(breakdown.percentage ?? 0) }] : []
}
label={
<Text ta="center" size="xs" fw={700}>
{hasContext ? `${breakdown.percentage}%` : "–"}
</Text>
}
/>
<Box className={classes.contextBreakdownWindow}>
{[
{ label: t("usage.used"), value: breakdown.contextUsed },
{ label: t("usage.remaining"), value: breakdown.remaining },
{ label: t("usage.capacity"), value: breakdown.contextLength },
].map((item) => (
<Box key={item.label}>
<Text size="xs" c="dimmed">
{item.label}
</Text>
<Text size="sm" fw={650}>
{item.value?.toLocaleString() ?? t("usage.notReported")}
</Text>
</Box>
))}
</Box>
<Box className={classes.contextBreakdownCategories}>
{categories.map((category) => (
<Box key={category.label}>
<Text size="xs" c="dimmed">
{category.label}
</Text>
<Text size="xs" fw={650}>
{category.value?.toLocaleString() ?? t("usage.notReported")}
</Text>
</Box>
))}
</Box>
</Box>
);
};
const ConversationContext = () => {
const t = useScopedI18n("common.assistant");
const [opened, setOpened] = useState(false);
const messages = useAuiState((state) => state.thread.messages);
const threadId = useAuiState((state) => state.threadListItem.remoteId);
const metadata = useMemo(() => messages.map((message) => message.metadata), [messages]);
const messageIdByRequestId = useMemo(
() =>
new Map(
messages.flatMap((message) => {
const telemetry = getAssistantTelemetry(message.metadata);
return telemetry ? [[telemetry.requestId, message.id] as const] : [];
}),
),
[messages],
);
const usage = useMemo(() => getAssistantConversationUsage(metadata), [metadata]);
const breakdown = useMemo(() => getAssistantContextBreakdown(usage), [usage]);
const hasTokenUsage = usage.turns.some((turn) => turn.totalTokens !== undefined);
const hasCost = usage.turns.some((turn) => turn.cost !== undefined);
const hasContext = breakdown.percentage !== undefined;
const contextPercentage = breakdown.percentage ?? 0;
const quickLabel = [
hasTokenUsage ? `${usage.totalTokens.toLocaleString()} ${t("usage.tokens")}` : undefined,
hasCost ? formatCost(usage.cost) : undefined,
]
.filter(Boolean)
.join(" · ");
return (
<Popover
opened={opened}
onChange={setOpened}
width="min(28rem, calc(100vw - 1rem))"
position="top-end"
shadow="md"
withinPortal
trapFocus
returnFocus
>
<Popover.Target>
<UnstyledButton
className={classes.composerContext}
type="button"
aria-label={quickLabel ? `${t("usage.contextWindow")}: ${quickLabel}` : t("usage.contextWindow")}
title={quickLabel || t("usage.contextWindow")}
aria-expanded={opened}
aria-haspopup="dialog"
onClick={() => setOpened((value) => !value)}
>
<RingProgress
size={24}
thickness={3}
roundCaps
sections={hasContext ? [{ value: contextPercentage, color: getContextColor(contextPercentage) }] : []}
/>
</UnstyledButton>
</Popover.Target>
<Popover.Dropdown className={classes.conversationContextPopover}>
<Stack gap="sm">
<Group justify="space-between" gap="xs" wrap="nowrap">
<Box miw={0}>
<Text size="sm" fw={700}>
{t("usage.contextWindow")}
</Text>
<Text size="xs" c="dimmed">
{t("usage.requestDetails")}
</Text>
</Box>
{hasContext && (
<Badge size="sm" variant="light" color={getContextColor(contextPercentage)}>
{contextPercentage}%
</Badge>
)}
</Group>
<Box className={classes.conversationUsageSummary}>
<div>
<Text size="xs" c="dimmed">
{t("usage.tokens")}
</Text>
<Text size="sm" fw={650}>
{hasTokenUsage ? usage.totalTokens.toLocaleString() : t("usage.notReported")}
</Text>
</div>
<div>
<Text size="xs" c="dimmed">
{t("usage.cost")}
</Text>
<Text size="sm" fw={650}>
{hasCost ? formatCost(usage.cost) : t("usage.notReported")}
</Text>
</div>
<div>
<Text size="xs" c="dimmed">
{t("usage.agentSteps")}
</Text>
<Text size="sm" fw={650}>
{usage.turns.reduce((total, turn) => total + turn.telemetry.steps.length, 0).toLocaleString()}
</Text>
</div>
</Box>
<ConversationContextBreakdown breakdown={breakdown} />
<Divider />
<ScrollArea.Autosize mah="min(14rem, 30dvh)" type="auto" offsetScrollbars>
<Stack gap={5}>
{usage.turns.length === 0 ? (
<Text size="sm" c="dimmed" py="xs">
{t("usage.notReported")}
</Text>
) : (
usage.turns.map((turn, index) => (
<ConversationTurn
key={turn.requestId}
index={index}
turn={turn}
threadId={threadId ?? undefined}
messageId={messageIdByRequestId.get(turn.requestId)}
/>
))
)}
</Stack>
</ScrollArea.Autosize>
</Stack>
</Popover.Dropdown>
</Popover>
);
};
const RuntimeError = () => {
const t = useScopedI18n("common.assistant");
return (
<MessagePrimitive.Error>
<ErrorPrimitive.Root className={classes.messageError}>
<Group align="flex-start" wrap="nowrap">
<ThemeIcon color="red" variant="light" radius="xl" size="md">
<IconAlertTriangle size={17} />
</ThemeIcon>
<Stack gap="xs" flex={1}>
<Text size="sm" fw={700}>
{t("responseError.title")}
</Text>
<ErrorPrimitive.Message className={classes.messageErrorText} />
<ActionBarPrimitive.Reload asChild>
<Button
variant="light"
color="red"
size="compact-sm"
w="fit-content"
leftSection={<IconRefresh size={14} />}
>
{t("responseError.retry")}
</Button>
</ActionBarPrimitive.Reload>
</Stack>
</Group>
</ErrorPrimitive.Root>
</MessagePrimitive.Error>
);
};
const WebSearchActivity = () => {
const t = useScopedI18n("common.assistant");
const metadata = useAuiState((state) => state.message.metadata);
const telemetry = getAssistantTelemetry(metadata);
if (!telemetry) return null;
const sources = telemetry.webSearchSources ?? [];
if (telemetry.webSearchRequests === undefined && sources.length === 0) return null;
return (
<Box className={`${classes.tool} ${classes.webSearchActivity}`}>
<Group justify="space-between" align="flex-start" wrap="nowrap" gap="sm">
<Group gap="xs" wrap="nowrap" align="flex-start">
<ThemeIcon size="sm" radius="xl" variant="light" color="blue">
<IconSearch size={13} />
</ThemeIcon>
<div>
<Text size="sm" fw={600}>
{t("webSearch.title")}
</Text>
<Text size="xs" c="dimmed">
{t("webSearch.completed")}
</Text>
</div>
</Group>
<Group gap={4} wrap="wrap" justify="flex-end">
{telemetry.webSearchRequests !== undefined && (
<Badge size="xs" variant="light" color="blue">
{t("webSearch.searches", { count: telemetry.webSearchRequests })}
</Badge>
)}
{sources.length > 0 && (
<Badge size="xs" variant="light" color="gray">
{t("webSearch.sources", { count: sources.length })}
</Badge>
)}
</Group>
</Group>
{sources.length > 0 && (
<Group className={classes.webSearchSources} gap="xs" wrap="wrap">
{sources.map((source) => (
<Anchor
key={source.url}
className={classes.webSearchSource}
href={source.url}
target="_blank"
rel="noreferrer"
size="xs"
title={source.title ?? source.url}
>
<IconLink size={13} />
<Text component="span" inherit lineClamp={1}>
{source.title ?? new URL(source.url).hostname}
</Text>
</Anchor>
))}
</Group>
)}
</Box>
);
};
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>
);
}
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>
);
const HistorySelectContext = createContext<() => void>(() => undefined);
const ThreadListItem = () => {
const t = useScopedI18n("common.assistant");
const onSelect = useContext(HistorySelectContext);
const remoteId = useAuiState((state) => state.threadListItem.remoteId);
const title = useAuiState((state) => state.threadListItem.title);
const [exporting, setExporting] = useState(false);
const exportConversation = async () => {
if (!remoteId || exporting) return;
setExporting(true);
try {
const conversation = await fetchApi.assistant.getThread.query({ threadId: remoteId });
const markdown = buildAssistantConversationMarkdown(conversation);
downloadAssistantMarkdown(
markdown,
getAssistantConversationExportFilename(conversation.thread.title ?? title, remoteId),
);
} catch {
showErrorNotification({
title: t("exportConversation.failedTitle"),
message: t("exportConversation.failedDescription"),
});
} finally {
setExporting(false);
}
};
return (
<ThreadListItemPrimitive.Root className={classes.historyItem}>
<Group gap={4} wrap="nowrap">
<ThreadListItemPrimitive.Trigger asChild>
<Button
variant="subtle"
color="gray"
size="compact-sm"
justify="flex-start"
flex={1}
style={{ minWidth: 0 }}
onClick={onSelect}
>
<ThreadListItemPrimitive.Title fallback={t("newConversation")} />
</Button>
</ThreadListItemPrimitive.Trigger>
<Tooltip label={t("exportConversation.action")}>
<ActionIcon
variant="subtle"
color="gray"
size="sm"
loading={exporting}
loaderProps={{ type: "bars" }}
disabled={!remoteId}
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")}>
<IconTrash size={14} />
</ActionIcon>
</ThreadListItemPrimitive.Delete>
</Group>
</ThreadListItemPrimitive.Root>
);
};
const ThreadHistory = ({ onSelect }: { onSelect: () => void }) => {
const t = useScopedI18n("common.assistant");
return (
<HistorySelectContext.Provider value={onSelect}>
<Stack className={classes.historyMenu} gap="xs" p="xs">
<Group gap="xs" wrap="nowrap" px={4} pt={4}>
<ThreadListPrimitive.New asChild>
<Button variant="light" leftSection={<IconPlus size={16} />} fullWidth onClick={onSelect}>
{t("newConversation")}
</Button>
</ThreadListPrimitive.New>
</Group>
<Group gap="xs" px="xs" mt="xs">
<IconHistory size={14} />
<Text size="sm" fw={600} c="dimmed">
{t("conversations")}
</Text>
</Group>
<ScrollArea.Autosize mah="min(24rem, 55dvh)" type="auto" offsetScrollbars>
<Stack gap={3}>
<ThreadListPrimitive.Items components={{ ThreadListItem }} />
</Stack>
</ScrollArea.Autosize>
</Stack>
</HistorySelectContext.Provider>
);
};
const ConversationHistory = () => {
const t = useScopedI18n("common.assistant");
const [opened, setOpened] = useState(false);
return (
<Popover
opened={opened}
onChange={setOpened}
onDismiss={() => setOpened(false)}
position="bottom-end"
width="min(22rem, calc(100vw - 1rem))"
shadow="md"
withinPortal
>
<Popover.Target>
<Button
className={classes.historyButton}
variant="subtle"
color="gray"
size="compact-sm"
leftSection={<IconHistory size={16} />}
classNames={{ section: classes.historyButtonSection, label: classes.historyButtonLabel }}
onClick={() => setOpened((current) => !current)}
onKeyDown={(event) => {
if (event.key === "Escape" && opened) {
event.stopPropagation();
setOpened(false);
}
}}
aria-label={opened ? t("closeHistory") : t("openHistory")}
>
{t("conversations")}
</Button>
</Popover.Target>
<Popover.Dropdown p={0}>
<ThreadHistory onSelect={() => setOpened(false)} />
</Popover.Dropdown>
</Popover>
);
};
const AutoApprovalControl = () => {
const t = useScopedI18n("common.assistant.autoApproval");
const [opened, setOpened] = useState(false);
const { enabled, setEnabled } = useAssistantAutoApproval();
return (
<Popover
opened={opened}
onChange={setOpened}
onDismiss={() => setOpened(false)}
position="bottom-end"
width="min(19rem, calc(100vw - 1rem))"
shadow="md"
withinPortal
>
<Popover.Target>
<ActionIcon
className={classes.panelAction}
variant={enabled ? "light" : "subtle"}
color={enabled ? "green" : "gray"}
onClick={() => setOpened((current) => !current)}
aria-label={t("label")}
aria-pressed={enabled}
title={enabled ? t("enabled") : t("label")}
>
<IconShieldCheck size={17} />
</ActionIcon>
</Popover.Target>
<Popover.Dropdown>
<Stack gap="sm">
<Box>
<Text size="sm" fw={700}>
{t("title")}
</Text>
<Text size="xs" c="dimmed" mt={3}>
{t("description")}
</Text>
</Box>
<Button
fullWidth
variant={enabled ? "default" : "light"}
color={enabled ? "gray" : "green"}
leftSection={<IconShieldCheck size={16} />}
onClick={() => {
setEnabled(!enabled);
setOpened(false);
}}
>
{enabled ? t("disable") : t("enable")}
</Button>
</Stack>
</Popover.Dropdown>
</Popover>
);
};
const ViewRefreshAction = ({
isRefreshing,
onRefresh,
}: Pick<AssistantConversationControls, "isRefreshing" | "onRefresh">) => {
const t = useScopedI18n("common.assistant.refresh");
const refresh = async () => {
try {
await onRefresh();
showSuccessNotification({ title: t("completeTitle"), message: t("completeDescription") });
} catch {
showErrorNotification({ title: t("failedTitle"), message: t("failedDescription") });
}
};
return (
<Tooltip label={isRefreshing ? t("working") : t("action")}>
<ActionIcon
className={classes.panelAction}
variant="subtle"
color="gray"
loading={isRefreshing}
loaderProps={{ type: "bars" }}
onClick={() => void refresh()}
aria-label={isRefreshing ? t("working") : t("action")}
>
<IconRefresh size={17} />
</ActionIcon>
</Tooltip>
);
};
const EmptyThread = () => {
const t = useScopedI18n("common.assistant");
return (
<ThreadPrimitive.Empty>
<Box className={classes.empty}>
<Stack align="center" gap="lg" maw={560} w="100%">
<Stack align="center" gap="xs" maw={430}>
<ThemeIcon size={52} radius="xl" variant="light" color="red">
<IconRobot size={27} />
</ThemeIcon>
<Text size="xl" fw={700}>
{t("emptyTitle")}
</Text>
<Text size="sm" className={classes.emptyDescription}>
{t("emptyDescription")}
</Text>
</Stack>
<Box className={classes.suggestions}>
<ThreadPrimitive.Suggestion prompt={t("suggestions.health.prompt")} send asChild>
<Button
variant="default"
className={classes.suggestion}
leftSection={<IconActivityHeartbeat size={18} />}
>
{t("suggestions.health.label")}
</Button>
</ThreadPrimitive.Suggestion>
<ThreadPrimitive.Suggestion prompt={t("suggestions.explore.prompt")} send 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>
<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>
<Button variant="default" className={classes.suggestion} leftSection={<IconPalette size={18} />}>
{t("suggestions.style.label")}
</Button>
</ThreadPrimitive.Suggestion>
</Box>
</Stack>
</Box>
</ThreadPrimitive.Empty>
);
};
const TriggerPopoverAutoScroll = ({ viewportRef }: { viewportRef: RefObject<HTMLDivElement | null> }) => {
const { activeCategoryId, categories, highlightedIndex, isSearchMode, items, open, query } =
unstable_useTriggerPopoverScopeContext();
useLayoutEffect(() => {
if (!open) return;
const viewport = viewportRef.current;
const highlightedItem = viewport?.querySelector<HTMLElement>("[data-highlighted]");
if (!viewport || !highlightedItem) return;
const viewportRect = viewport.getBoundingClientRect();
const itemRect = highlightedItem.getBoundingClientRect();
const viewportTop = viewportRect.top + viewport.clientTop;
const viewportBottom = viewportTop + viewport.clientHeight;
viewport.scrollTop = getNearestTriggerScrollTop({
scrollTop: viewport.scrollTop,
viewportTop,
viewportBottom,
itemTop: itemRect.top,
itemBottom: itemRect.bottom,
});
}, [activeCategoryId, categories, highlightedIndex, isSearchMode, items, open, query, viewportRef]);
return null;
};
const TriggerItem = ({ item, index }: { item: Unstable_TriggerItem; index: number }) => {
const Icon =
typeof item.metadata?.icon === "string" && item.metadata.icon in contextIcons
? contextIcons[item.metadata.icon as keyof typeof contextIcons]
: IconAt;
return (
<ComposerPrimitive.Unstable_TriggerPopoverItem className={classes.triggerItem} item={item} index={index}>
<ThemeIcon size="sm" variant="light" color="gray">
<Icon size={13} />
</ThemeIcon>
<div className={classes.triggerItemText}>
<Text size="sm" fw={600} lineClamp={1}>
{item.label}
</Text>
{item.description && (
<Text size="xs" c="dimmed" lineClamp={1}>
{item.description}
</Text>
)}
</div>
</ComposerPrimitive.Unstable_TriggerPopoverItem>
);
};
const ComposerTriggers = () => {
const t = useScopedI18n("common.assistant");
const aui = useAui();
const mentionViewportRef = useRef<HTMLDivElement>(null);
const slashViewportRef = useRef<HTMLDivElement>(null);
const { data: entities = [], isLoading } = clientApi.assistant.getContextEntities.useQuery(undefined, {
staleTime: 60_000,
});
const categories = useMemo(
() =>
(["app", "integration", "board", "widget"] as const).map((type) => ({
id: type,
label: t(`mentions.${type}`),
items: entities
.filter((entity) => entity.type === type)
.map((entity) => ({
id: entity.id,
type: entity.type,
label: entity.label,
description: entity.description,
icon: entity.type,
})),
})),
[entities, t],
);
const mention = unstable_useMentionAdapter({
categories,
includeModelContextTools: {
category: { id: "tools", label: t("mentions.tools") },
formatLabel: (name) => name.replaceAll("_", " "),
icon: "tools",
},
iconMap: contextIcons,
fallbackIcon: IconAt,
});
const slash = unstable_useSlashCommandAdapter({
removeOnExecute: true,
commands: [
{
id: "health",
label: "/health",
description: t("commands.health"),
execute: () => aui.composer().setText(t("suggestions.health.prompt")),
},
{
id: "explore",
label: "/explore",
description: t("commands.explore"),
execute: () => aui.composer().setText(t("suggestions.explore.prompt")),
},
{
id: "media",
label: "/media",
description: t("commands.media"),
execute: () => aui.composer().setText(t("suggestions.media.prompt")),
},
{
id: "style",
label: "/style",
description: t("commands.style"),
execute: () => aui.composer().setText(t("suggestions.style.prompt")),
},
],
});
return (
<>
<ComposerPrimitive.Unstable_TriggerPopover
ref={mentionViewportRef}
className={classes.triggerPopover}
char="@"
adapter={mention.adapter}
isLoading={isLoading}
aria-label={t("mentions.menu")}
>
<TriggerPopoverAutoScroll viewportRef={mentionViewportRef} />
<ComposerPrimitive.Unstable_TriggerPopover.Directive {...mention.directive} />
<ComposerPrimitive.Unstable_TriggerPopoverCategories className={classes.triggerList}>
{(items) =>
items.map((category) => {
const Icon = contextIcons[category.id as keyof typeof contextIcons] ?? IconAt;
return (
<ComposerPrimitive.Unstable_TriggerPopoverCategoryItem
key={category.id}
categoryId={category.id}
className={classes.triggerItem}
>
<ThemeIcon size="sm" variant="light" color="gray">
<Icon size={13} />
</ThemeIcon>
<Text size="sm" fw={600} flex={1}>
{category.label}
</Text>
<IconChevronRight size={14} />
</ComposerPrimitive.Unstable_TriggerPopoverCategoryItem>
);
})
}
</ComposerPrimitive.Unstable_TriggerPopoverCategories>
<ComposerPrimitive.Unstable_TriggerPopoverItems className={classes.triggerList}>
{(items) =>
items.map((item, index) => <TriggerItem key={`${item.type}:${item.id}`} item={item} index={index} />)
}
</ComposerPrimitive.Unstable_TriggerPopoverItems>
</ComposerPrimitive.Unstable_TriggerPopover>
<ComposerPrimitive.Unstable_TriggerPopover
ref={slashViewportRef}
className={classes.triggerPopover}
char="/"
adapter={slash.adapter}
aria-label={t("commands.menu")}
>
<TriggerPopoverAutoScroll viewportRef={slashViewportRef} />
<ComposerPrimitive.Unstable_TriggerPopover.Action {...slash.action} />
<ComposerPrimitive.Unstable_TriggerPopoverItems className={classes.triggerList}>
{(items) => items.map((item, index) => <TriggerItem key={item.id} item={item} index={index} />)}
</ComposerPrimitive.Unstable_TriggerPopoverItems>
</ComposerPrimitive.Unstable_TriggerPopover>
</>
);
};
type ComposerProps = AssistantConversationControls & { pendingAction: AssistantPendingAction | undefined };
const getModelProviderLabel = (modelId: string, fallback: string) => {
const separator = modelId.indexOf("/");
if (separator <= 0) return fallback;
const provider = modelId
.slice(0, separator)
.replace(/^[^\p{L}\p{N}]+/u, "")
.replaceAll(/[-_]/gu, " ");
if (!provider) return fallback;
return `${provider.charAt(0).toLocaleUpperCase()}${provider.slice(1)}`;
};
const formatCompactModelNumber = (value: number) =>
new Intl.NumberFormat(undefined, { notation: "compact", maximumFractionDigits: 1 }).format(value);
const RuntimeControls = ({
modelId,
models,
modelOptionsLoading,
reasoning,
onModelChange,
onReasoningChange,
}: ComposerProps) => {
const t = useScopedI18n("common.assistant");
const [modelSearch, setModelSearch] = useState("");
const selectedModel = models.find((model) => model.id === modelId);
const normalizedModelSearch = modelSearch.trim().toLocaleLowerCase();
const visibleModels =
normalizedModelSearch.length === 0
? models
: models.filter(
(model) =>
model.name.toLocaleLowerCase().includes(normalizedModelSearch) ||
model.id.toLocaleLowerCase().includes(normalizedModelSearch),
);
const groupedModels = visibleModels.reduce((groups, model) => {
const provider = getModelProviderLabel(model.id, t("runtime.otherModels"));
const providerModels = groups.get(provider);
if (providerModels) providerModels.push(model);
else groups.set(provider, [model]);
return groups;
}, new Map<string, AssistantRuntimeModelOption[]>());
const modelCombobox = useCombobox({
onDropdownOpen: () => {
modelCombobox.selectActiveOption();
requestAnimationFrame(() => modelCombobox.focusSearchInput());
},
onDropdownClose: () => {
modelCombobox.resetSelectedOption();
setModelSearch("");
},
});
const selectModel = (value: string) => {
if (!models.some((model) => model.id === value)) return;
onModelChange(value);
modelCombobox.closeDropdown();
};
return (
<Combobox store={modelCombobox} onOptionSubmit={selectModel} withinPortal position="top-start" width={340}>
<Combobox.Target>
<UnstyledButton
className={classes.runtimeSelectorTrigger}
type="button"
disabled={modelOptionsLoading || models.length === 0}
onClick={() => modelCombobox.toggleDropdown()}
aria-label={`${t("runtime.model")}: ${selectedModel?.name ?? t("runtime.noModels")}. ${t("runtime.thinking")}: ${t(`runtime.reasoning.${reasoning}`)}`}
aria-expanded={modelCombobox.dropdownOpened}
>
<Group gap="xs" wrap="nowrap">
{modelOptionsLoading && <Loader size={12} />}
<Text className={classes.runtimeSelectorName} size="xs" fw={650} lineClamp={1}>
{selectedModel?.name ?? t("runtime.model")}
</Text>
<Badge className={classes.runtimeSelectorEffort} size="xs" variant="light" color="gray">
{t(`runtime.reasoning.${reasoning}`)}
</Badge>
<Combobox.Chevron size="xs" />
</Group>
</UnstyledButton>
</Combobox.Target>
<Combobox.Dropdown className={classes.modelDropdown}>
<Combobox.Search
value={modelSearch}
onChange={(event) => {
setModelSearch(event.currentTarget.value);
modelCombobox.updateSelectedOptionIndex();
}}
placeholder={t("runtime.searchModels")}
aria-label={t("runtime.searchModels")}
size="xs"
/>
<Combobox.Options className={classes.modelOptions}>
{[...groupedModels.entries()].map(([provider, providerModels]) => (
<Combobox.Group key={provider} label={provider}>
{providerModels.map((model) => (
<Combobox.Option
className={classes.modelOption}
key={model.id}
value={model.id}
active={model.id === modelId}
>
<Group gap="xs" wrap="nowrap">
<Divider
className={classes.modelOptionDivider}
orientation="vertical"
data-active={model.id === modelId || undefined}
/>
<Stack gap={1} className={classes.modelOptionText}>
<Text size="sm" fw={model.id === modelId ? 650 : 500} lineClamp={1}>
{model.name}
</Text>
<Text size="xs" className={classes.modelOptionDescription} lineClamp={1}>
{model.description?.trim() || model.id}
</Text>
</Stack>
<Group className={classes.modelOptionMeta} gap={4} wrap="nowrap">
{model.inputModalities.includes("image") && (
<Tooltip label={t("runtime.imageInput")}>
<IconPhoto size={14} aria-label={t("runtime.imageInput")} />
</Tooltip>
)}
{model.contextLength && (
<Badge size="xs" variant="light" color="gray">
{formatCompactModelNumber(model.contextLength)}
</Badge>
)}
{model.id === modelId && <IconCheck size={15} className={classes.runtimeOptionCheck} />}
</Group>
</Group>
</Combobox.Option>
))}
</Combobox.Group>
))}
{visibleModels.length === 0 && <Combobox.Empty>{t("runtime.noModels")}</Combobox.Empty>}
</Combobox.Options>
<Divider />
<Box className={classes.reasoningSelector}>
<Text size="xs" fw={650} mb={5}>
{t("runtime.thinking")}
</Text>
<SegmentedControl
className={classes.reasoningSegmentedControl}
value={reasoning}
onChange={(value) => {
if (assistantReasoningModes.includes(value as AssistantReasoningMode)) {
onReasoningChange(value as AssistantReasoningMode);
}
}}
size="xs"
fullWidth
data={assistantReasoningModes.map((mode) => ({
value: mode,
label: t(`runtime.reasoning.${mode}`),
}))}
/>
</Box>
</Combobox.Dropdown>
</Combobox>
);
};
const providerQuotaIcons = {
ok: IconBatteryFilled,
warning: IconBattery3,
bad: IconBattery1,
dead: IconBatteryOff,
};
const providerQuotaColors = {
ok: "green",
warning: "yellow",
bad: "orange",
dead: "red",
} as const;
const HomarrProviderQuota = () => {
const t = useScopedI18n("common.assistant");
const locale = useCurrentIntlLocale();
const preferences = useAssistantPreferences();
const quota = preferences.quota;
const resetAt = useMemo(() => new Date(quota?.resetsAt ?? Date.now()), [quota?.resetsAt]);
const resetRelative = useTimeAgo(resetAt, 30_000);
if (preferences.provider !== "homarr") return null;
const level = preferences.quota ? getAssistantProviderQuotaLevel(preferences.quota) : "dead";
const Icon = providerQuotaIcons[level];
const color = providerQuotaColors[level];
const percentage = preferences.quota
? Math.min(100, Math.max(0, (preferences.quota.remaining / Math.max(preferences.quota.limit, 1)) * 100))
: 0;
const label = preferences.providerUser
? quota
? t("providerQuota.remainingLabel", {
remaining: quota.remaining,
limit: quota.limit,
})
: t("providerQuota.loading")
: t("providerQuota.signInRequired");
const resetTime = quota ? (
<time dateTime={quota.resetsAt} title={new Date(quota.resetsAt).toLocaleString(locale)}>
{new Date(quota.resetsAt).toLocaleTimeString(locale, {
hour: "2-digit",
minute: "2-digit",
timeZoneName: "short",
})}
</time>
) : null;
return (
<Popover position="top" width={290} shadow="md" withinPortal>
<Popover.Target>
<UnstyledButton className={classes.providerQuotaTrigger} data-level={level} type="button" aria-label={label}>
{preferences.quotaLoading && !preferences.quota ? <Loader size={14} /> : <Icon size={16} aria-hidden />}
<Text component="span" size="xs" fw={650}>
{preferences.quota?.remaining ?? "–"}
</Text>
</UnstyledButton>
</Popover.Target>
<Popover.Dropdown>
<Stack gap="sm">
<Group justify="space-between" wrap="nowrap">
<Group gap="xs" wrap="nowrap">
<ThemeIcon color={color} variant="light" size="sm" radius="xl">
<Icon size={15} aria-hidden />
</ThemeIcon>
<Box>
<Text size="sm" fw={700}>
{t("providerQuota.title")}
</Text>
<Text size="xs" c="dimmed">
{preferences.providerUser?.name || t("providerQuota.communityWorkshop")}
</Text>
</Box>
</Group>
{preferences.quota && (
<Badge color={color} variant="light" size="sm">
{t(`providerQuota.level.${level}`)}
</Badge>
)}
</Group>
{!preferences.providerUser ? (
<>
<Text size="sm" c="dimmed">
{t("providerQuota.signInDescription")}
</Text>
<Button
size="compact-sm"
loading={preferences.quotaLoading}
onClick={() => void preferences.signInToProvider().catch(() => undefined)}
>
{t("providerQuota.signIn")}
</Button>
</>
) : preferences.quota ? (
<>
<Box>
<Group justify="space-between" gap="xs" mb={5}>
<Text size="xs" c="dimmed">
{t("providerQuota.dailyAllowance")}
</Text>
<Text size="xs" fw={650}>
{preferences.quota.remaining} / {preferences.quota.limit}
</Text>
</Group>
<Progress value={percentage} color={color} size="sm" aria-label={label} />
</Box>
<Text size="xs" c="dimmed">
{t.rich("providerQuota.reset", {
relative: resetRelative,
time: () => resetTime,
})}
</Text>
<Text size="xs" c="dimmed">
{t("providerQuota.toolCalls")}
</Text>
</>
) : (
<Button
variant="light"
size="compact-sm"
loading={preferences.quotaLoading}
onClick={() => void preferences.refreshQuota()}
>
{t("providerQuota.retry")}
</Button>
)}
{preferences.quotaError && (
<Text size="xs" c="red">
{preferences.quotaError}
</Text>
)}
</Stack>
</Popover.Dropdown>
</Popover>
);
};
const Composer = (props: ComposerProps) => {
const t = useScopedI18n("common.assistant");
const preferences = useAssistantPreferences();
const running = useAuiState((state) => state.thread.isRunning);
const hasPendingAction = props.pendingAction !== undefined;
const providerUnavailable = isAssistantProviderUnavailable({
provider: preferences.provider,
signedIn: preferences.providerUser !== null,
remaining: preferences.quota?.remaining,
});
const sendBlocked = hasPendingAction || providerUnavailable;
const composerInputRef = useRef<HTMLDivElement>(null);
const composerLabel = t("composerPlaceholder");
useLayoutEffect(() => {
const editor = composerInputRef.current?.querySelector<HTMLElement>(".aui-lexical-input");
if (!editor) return;
editor.setAttribute("aria-label", composerLabel);
return () => {
if (editor.getAttribute("aria-label") === composerLabel) editor.removeAttribute("aria-label");
};
}, [composerLabel]);
return (
<Box className={classes.composerWrap}>
<ComposerPrimitive.Unstable_TriggerPopoverRoot>
<ComposerPrimitive.AttachmentDropzone className={classes.composerDropzone}>
<ComposerPrimitive.Root
className={classes.composer}
data-pending-action={hasPendingAction || undefined}
onSubmit={(event) => {
if (sendBlocked) event.preventDefault();
}}
>
<ComposerTriggers />
<ComposerPrimitive.Quote className={classes.composerQuote}>
<IconQuote size={15} />
<ComposerPrimitive.QuoteText className={classes.composerQuoteText} />
<ComposerPrimitive.QuoteDismiss asChild>
<ActionIcon variant="subtle" color="gray" size="xs" aria-label={t("dismissQuote")}>
<IconX size={12} />
</ActionIcon>
</ComposerPrimitive.QuoteDismiss>
</ComposerPrimitive.Quote>
<ComposerPrimitive.Attachments>{() => <Attachment removable />}</ComposerPrimitive.Attachments>
<Group className={classes.composerRow} gap="xs" wrap="nowrap" align="flex-end">
<Group gap={2} wrap="nowrap">
<Tooltip label={t("attachments.add")}>
<ComposerPrimitive.AddAttachment asChild>
<ActionIcon variant="subtle" color="gray" size="lg" aria-label={t("attachments.add")}>
<IconPaperclip size={17} />
</ActionIcon>
</ComposerPrimitive.AddAttachment>
</Tooltip>
</Group>
<LexicalComposerInput
ref={composerInputRef}
className={classes.composerInput}
data-assistant-composer-input
placeholder={composerLabel}
directiveChip={ContextDirectiveChip}
// oxlint-disable-next-line jsx-a11y/no-autofocus -- opening the assistant is an explicit intent to compose
autoFocus={props.autoFocusComposer}
data-autofocus={props.autoFocusComposer ? true : undefined}
/>
{running ? (
<ComposerPrimitive.Cancel asChild>
<ActionIcon color="red" variant="light" size="lg" aria-label={t("stop")}>
<IconPlayerStop size={18} />
</ActionIcon>
</ComposerPrimitive.Cancel>
) : (
<ComposerPrimitive.Send asChild>
<ActionIcon
color="red"
variant="filled"
size="lg"
aria-label={
hasPendingAction
? t("pendingAction.sendBlocked")
: providerUnavailable
? t("providerQuota.unavailableDescription")
: t("send")
}
disabled={sendBlocked}
>
<IconArrowUp size={18} />
</ActionIcon>
</ComposerPrimitive.Send>
)}
</Group>
<Group className={classes.composerFooter} justify="space-between" gap="xs" wrap="nowrap">
<Group className={classes.composerControls} gap={5} wrap="nowrap">
<RuntimeControls {...props} />
<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>
</ComposerPrimitive.Unstable_TriggerPopoverRoot>
</Box>
);
};
const usePendingActionCopy = (action: AssistantPendingAction | undefined) => {
const t = useScopedI18n("common.assistant");
if (!action) return undefined;
if (action.kind === "question") {
return { title: t("pendingAction.answerTitle"), detail: action.detail ?? t("pendingAction.answerFallback") };
}
if (action.kind === "form") {
const isBoardSettings = action.toolName === "configure_board_settings";
return {
title: isBoardSettings ? t("pendingAction.boardFormTitle") : t("pendingAction.formTitle"),
detail: action.detail ?? (isBoardSettings ? t("configureBoardSettings.title") : t("configureApp.title")),
};
}
const tool = action.toolName.replaceAll("_", " ");
return { title: t("activity.approval"), detail: action.detail ? `${tool} · ${action.detail}` : tool };
};
const PendingActionBanner = ({ pendingAction }: { pendingAction: AssistantPendingAction | undefined }) => {
const t = useScopedI18n("common.assistant");
const copy = usePendingActionCopy(pendingAction);
if (!copy || pendingAction?.kind === "question") return null;
return (
<Box component="output" className={classes.pendingActionBanner}>
<Group gap="sm" wrap="nowrap">
<ThemeIcon variant="light" color="yellow" radius="xl">
<IconMessage size={17} />
</ThemeIcon>
<Stack gap={0} flex={1} miw={0}>
<Text size="sm" fw={700}>
{copy.title}
</Text>
<Text size="xs" c="dimmed" lineClamp={1}>
{copy.detail}
</Text>
</Stack>
<ThreadPrimitive.ScrollToBottom asChild>
<Button variant="light" color="yellow" size="compact-sm">
{t("pendingAction.review")}
</Button>
</ThreadPrimitive.ScrollToBottom>
</Group>
</Box>
);
};
const PendingQuestionDock = ({
pendingAction,
setTarget,
}: {
pendingAction: AssistantPendingAction | undefined;
setTarget: (target: HTMLDivElement | null) => void;
}) => {
const t = useScopedI18n("common.assistant");
if (pendingAction?.kind !== "question") return null;
return (
<Box component="section" className={classes.pendingQuestionDock} aria-label={t("pendingAction.answerTitle")}>
<div ref={setTarget} className={classes.pendingQuestionTarget} />
</Box>
);
};
const AssistantActivityBar = ({
isRunning,
unreadCount,
latestAssistantText,
latestAssistantPartType,
latestUserText,
latestStatus,
pendingAction,
onOpen,
onDismissActivity,
activityDismissed,
}: Pick<
AssistantPanelProps,
| "isRunning"
| "unreadCount"
| "latestAssistantText"
| "latestAssistantPartType"
| "latestUserText"
| "latestStatus"
| "pendingAction"
| "onOpen"
| "onDismissActivity"
| "activityDismissed"
>) => {
const t = useScopedI18n("common.assistant");
const pendingCopy = usePendingActionCopy(pendingAction);
const needsApproval = pendingAction !== undefined || latestStatus?.type === "requires-action";
const failed = latestStatus?.type === "incomplete" && latestStatus.reason !== "cancelled";
const visible =
!activityDismissed &&
(isRunning || unreadCount > 0 || needsApproval || latestStatus !== undefined || latestAssistantText.length > 0);
if (!visible) return null;
const title = isRunning
? t("activity.thinking")
: pendingCopy
? pendingCopy.title
: needsApproval
? t("activity.approval")
: failed
? t("activity.failed")
: t("activity.ready");
const detail = isRunning
? latestUserText || t("activity.working")
: pendingCopy?.detail || latestAssistantText || (failed ? t("responseError.description") : t("activity.completed"));
const activityState = getAssistantActivityState({
isRunning,
latestPartType: latestAssistantPartType,
needsApproval,
failed,
});
const activityColor = needsApproval ? "yellow" : failed ? "red" : isRunning ? "red" : "green";
return (
<Box component="output" className={classes.activityBar} aria-live="polite">
<UnstyledButton className={classes.activityTrigger} onClick={onOpen} aria-label={t("activity.expand")}>
<Group gap="sm" wrap="nowrap">
<ThemeIcon variant="light" color={activityColor} radius="xl">
<AssistantDotMatrix state={activityState} label={title} role="presentation" aria-hidden />
</ThemeIcon>
<Stack gap={0} className={classes.activityCopy}>
<Group gap="xs" wrap="nowrap">
<Text size="sm" fw={700}>
{title}
</Text>
{unreadCount > 0 && (
<Badge size="xs" variant="filled" color="red" circle>
{unreadCount > 99 ? "99+" : unreadCount}
</Badge>
)}
</Group>
<Text size="xs" c="dimmed" lineClamp={1}>
{detail}
</Text>
</Stack>
{needsApproval ? (
<Box className={classes.activityReviewAction}>
<Text component="span" size="xs" fw={700}>
{t("pendingAction.review")}
</Text>
<IconChevronUp size={15} />
</Box>
) : (
<IconChevronUp size={17} className={classes.activityExpandIcon} />
)}
</Group>
</UnstyledButton>
{!isRunning && !needsApproval && (
<ActionIcon
className={classes.activityDismiss}
variant="subtle"
color="gray"
onClick={onDismissActivity}
aria-label={t("activity.dismiss")}
>
<IconX size={15} />
</ActionIcon>
)}
</Box>
);
};
interface AssistantConversationSurfaceProps extends AssistantConversationControls {
isRunning: boolean;
pendingAction: AssistantPendingAction | undefined;
onExpand?: () => void;
onMinimize?: () => void;
onWindowClose?: () => void;
}
export const AssistantConversationSurface = ({
isRunning,
pendingAction,
modelId,
models,
modelOptionsLoading,
reasoning,
isRefreshing,
autoFocusComposer,
onRefresh,
onModelChange,
onReasoningChange,
onExpand,
onMinimize,
onWindowClose,
}: AssistantConversationSurfaceProps) => {
const t = useScopedI18n("common.assistant");
const reducedMotion = useReducedMotion();
const [questionPortalTarget, setQuestionPortalTarget] = useState<HTMLDivElement | null>(null);
const [reasoningCollapsed, setReasoningCollapsed] = useState(false);
const reasoningVisibility = useMemo(
() => ({ collapsed: reasoningCollapsed, setCollapsed: setReasoningCollapsed }),
[reasoningCollapsed],
);
const scrollToLatestBehavior = isRunning || reducedMotion ? "instant" : "smooth";
return (
<>
<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>
)}
<Group className={classes.panelIdentity} gap="xs" wrap="nowrap">
<ThemeIcon variant="light" color="red" radius="xl">
<IconRobot size={18} />
</ThemeIcon>
<div className={classes.panelIdentityText}>
<Text fw={700} lh={1.1} lineClamp={1}>
{t("title")}
</Text>
</div>
</Group>
<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}
variant="subtle"
color="gray"
aria-label={t("newConversation")}
>
<IconPlus size={17} />
</ActionIcon>
</ThreadListPrimitive.New>
</Tooltip>
{onExpand && (
<Tooltip label={t("activity.expand")}>
<ActionIcon
className={classes.panelAction}
variant="subtle"
color="gray"
onClick={onExpand}
aria-label={t("activity.expand")}
>
<IconArrowsMaximize size={17} />
</ActionIcon>
</Tooltip>
)}
</Group>
</Group>
<AssistantQuestionPortalProvider target={questionPortalTarget}>
<ReasoningVisibilityContext.Provider value={reasoningVisibility}>
<ThreadPrimitive.Root
className={classes.thread}
data-pending-question={pendingAction?.kind === "question" || undefined}
>
<ThreadPrimitive.Viewport className={classes.viewport} autoScroll>
<Box className={classes.messages}>
<EmptyThread />
<ThreadPrimitive.Messages components={{ UserMessage, AssistantMessage }} />
</Box>
<SelectionToolbarPrimitive.Root className={classes.selectionToolbar}>
<SelectionToolbarPrimitive.Quote asChild>
<Button variant="filled" color="dark" size="compact-sm" leftSection={<IconQuote size={14} />}>
{t("quoteSelection")}
</Button>
</SelectionToolbarPrimitive.Quote>
</SelectionToolbarPrimitive.Root>
<ThreadPrimitive.ScrollToBottom behavior={scrollToLatestBehavior} asChild>
<ActionIcon
className={classes.scrollToBottom}
variant="default"
radius="xl"
aria-label={t("scrollToLatest")}
>
<IconArrowUp size={16} style={{ transform: "rotate(180deg)" }} />
</ActionIcon>
</ThreadPrimitive.ScrollToBottom>
</ThreadPrimitive.Viewport>
<PendingQuestionDock pendingAction={pendingAction} setTarget={setQuestionPortalTarget} />
<PendingActionBanner pendingAction={pendingAction} />
<Composer
modelId={modelId}
models={models}
modelOptionsLoading={modelOptionsLoading}
reasoning={reasoning}
isRefreshing={isRefreshing}
onRefresh={onRefresh}
onModelChange={onModelChange}
onReasoningChange={onReasoningChange}
autoFocusComposer={autoFocusComposer}
pendingAction={pendingAction}
/>
</ThreadPrimitive.Root>
</ReasoningVisibilityContext.Provider>
</AssistantQuestionPortalProvider>
</>
);
};
export const AssistantPanel = ({
opened,
onOpen,
onClose,
onDismissActivity,
activityDismissed,
isRunning,
unreadCount,
latestAssistantText,
latestAssistantPartType,
latestUserText,
latestStatus,
pendingAction,
modelId,
models,
modelOptionsLoading,
reasoning,
isRefreshing,
onRefresh,
onModelChange,
onReasoningChange,
}: AssistantPanelProps) => {
const t = useScopedI18n("common.assistant");
const previousFocusRef = useRef<HTMLElement | null>(null);
useWindowEvent("keydown", (event) => {
if (opened && event.key === "Escape") onClose();
});
useEffect(() => {
if (!opened) {
const rememberOutsideFocus = () => {
if (document.activeElement instanceof HTMLElement) {
previousFocusRef.current = document.activeElement;
}
};
rememberOutsideFocus();
document.addEventListener("focusin", rememberOutsideFocus);
return () => document.removeEventListener("focusin", rememberOutsideFocus);
}
const restoreTarget = previousFocusRef.current;
return () => {
restoreTarget?.focus();
previousFocusRef.current = null;
};
}, [opened]);
return (
<>
{!opened && (
<AssistantActivityBar
onOpen={onOpen}
onDismissActivity={onDismissActivity}
activityDismissed={activityDismissed}
isRunning={isRunning}
unreadCount={unreadCount}
latestAssistantText={latestAssistantText}
latestAssistantPartType={latestAssistantPartType}
latestUserText={latestUserText}
latestStatus={latestStatus}
pendingAction={pendingAction}
/>
)}
{opened && (
<dialog className={classes.floatingPanel} aria-label={t("title")} open>
<AssistantConversationSurface
isRunning={isRunning}
pendingAction={pendingAction}
modelId={modelId}
models={models}
modelOptionsLoading={modelOptionsLoading}
reasoning={reasoning}
isRefreshing={isRefreshing}
onRefresh={onRefresh}
onModelChange={onModelChange}
onReasoningChange={onReasoningChange}
autoFocusComposer
onMinimize={onClose}
onWindowClose={() => {
onDismissActivity();
onClose();
}}
/>
</dialog>
)}
</>
);
};