"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; 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 ( {safeIconUrl ? ( ) : ( )} {label} ); }; 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) => 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 {children}; return ( { 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} ); }; const MarkdownTable = ({ children, ...props }: ComponentPropsWithoutRef<"table">) => (
{children}
); const MarkdownImage = ({ src, alt = "", ...props }: ComponentPropsWithoutRef<"img">) => { const t = useScopedI18n("common.assistant.image"); const safeSource = getSafeAssistantMarkdownImageSource(src); if (!safeSource) return null; return ( ); }; 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 (
        {code}
      
); } return ( {code} } > {code} ); }; 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 ( {t("markdown.renderingDiagram")} ); } if (rendered.failed || !rendered.svg) { const Pre = components.Pre; const Code = components.Code; return ( {t("markdown.diagramUnavailable")}
          {code}
        
); } return ( ); }; type MarkdownSpanProps = ComponentPropsWithoutRef<"span"> & { "data-assistant-directive"?: string; "data-directive-id"?: string; "data-directive-label"?: string; "data-directive-type"?: string; }; const AssistantDirectiveEntitiesContext = createContext([]); 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 ( ); } return {children}; }; 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 ( ); }; const UserTextPart = () => { const { text } = useMessagePartText(); const { data: entities = [] } = clientApi.assistant.getContextEntities.useQuery(undefined, { staleTime: 60_000 }); const segments = parseAssistantDirectives(text); return ( {segments.map((segment, index) => { if (segment.kind === "text") return {segment.text}; const entity = resolveAssistantDirectiveEntity(entities, segment); return ( ); })} ); }; const AttachmentPreview = () => { const attachment = useAuiState((state) => state.attachment); const [filePreview, setFilePreview] = useState(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 ; } return ( {attachment.type === "image" ? : } ); }; const Attachment = ({ removable = false }: { removable?: boolean }) => { const t = useScopedI18n("common.assistant"); const attachment = useAuiState((state) => state.attachment); return ( {attachment.contentType && ( {attachment.contentType} )} {attachment.status.type === "running" && } {removable && ( )} ); }; const SentAttachment = () => ; const HiddenMessagePart = () => null; const ReasoningPart = (_props: ReasoningMessagePartProps) => ( ); const AssistantMessagePending = ({ status }: EmptyMessagePartProps) => { const t = useScopedI18n("common.assistant"); if (status.type !== "running") return null; return ( {t("activity.thinking")} ); }; const ReasoningVisibilityContext = createContext<{ collapsed: boolean; setCollapsed: (collapsed: boolean) => void; }>({ collapsed: false, setCollapsed: () => undefined }); const SourcePart = (source: SourceMessagePartProps) => { const safeUrl = getSafeAssistantHttpUrl(source.url); const content = ( <> {source.title} ); return safeUrl ? ( {content} ) : ( {content} ); }; const FilePart = ({ data, filename, mimeType }: FileMessagePartProps) => { const t = useScopedI18n("common.assistant"); const downloadable = data.startsWith("data:"); const displayName = filename ?? mimeType; return ( {displayName} {downloadable && ( )} ); }; const ImagePart = ({ image, filename }: ImageMessagePartProps) => { const t = useScopedI18n("common.assistant"); const source = getSafeAssistantAttachmentImageSource( image, typeof window === "undefined" ? undefined : window.location.origin, ); if (!source) { return ( {t("externalImageBlocked")} ); } return ( ); }; 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 ( {presentation.text} ); } if (presentation.type === "properties") { return ( {presentation.fields.map((field) => ( {field.label} {formatToolResultValue(field.value)} ))} ); } if (presentation.type === "icons") { if (presentation.items.length === 0) return null; const hiddenCount = Math.max(0, presentation.totalCount - presentation.items.length); return ( {presentation.items.map((item) => ( {item.name} {item.name} {item.variant} {item.repository && ( {item.repository} )} ))} {hiddenCount > 0 && ( +{hiddenCount} )} ); } const hiddenCount = Math.max(0, presentation.totalCount - presentation.items.length); return ( {presentation.items.map((item, index) => ( {item.title} {item.description && ( {item.description} )} {item.badges.length > 0 && ( {item.badges.map((badge) => ( {badge} ))} )} {item.fields.length > 0 && ( {item.fields.map((field) => ( {field.label}:{" "} {formatToolResultValue(field.value)} ))} )} ))} {hiddenCount > 0 && ( +{hiddenCount} )} ); }; const getToolResultNavigation = (result: unknown) => { if (typeof result !== "object" || result === null || Array.isArray(result)) return []; const record = result as Record; 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 ( {links.map((link) => ( ))} ); }; const ToolResultPreview = ({ result, toolName }: { result: unknown; toolName: string }) => { const navigation = getToolResultNavigation(result); if (navigation.length === 0) return ; return ( ); }; 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 ( setOpened((current) => !current)} aria-expanded={opened} > {denied || failed ? : successful ? : } {displayName} {compactPresentation && traceTarget && ( {traceTarget} )} {duration !== undefined && ( {duration < 1000 ? `${duration} ms` : `${(duration / 1000).toFixed(1)} s`} )} {awaitingApproval ? t("approvalRequired") : denied ? t("denied") : failed ? t("failed") : successful ? t("complete") : t("working")} {awaitingApproval && ( {t("approvalDescription")} {autoApprovalInProgress ? ( {t("autoApproval.approving")} ) : ( )} )} {!compactPresentation && successful && result !== undefined && ( )} {compactPresentation && successful && result !== undefined && ( )} {t("toolInput")} {JSON.stringify(args, null, 2)} {!denied && result !== undefined && ( {t("toolOutput")} {typeof result === "string" ? result : JSON.stringify(result, null, 2)} )} ); }; const AgentTraceToolGroup = ({ children }: { children?: ReactNode }) => ( {children} ); const ChainOfThoughtLayout = ({ children }: { children?: ReactNode }) => ( {children} ); 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 ( setPreferredCollapsed(!collapsed)} > {(running || chainStatus.type === "requires-action") && ( )} {t("reasoning")} ); }; const BranchPicker = () => { const t = useScopedI18n("common.assistant"); return ( / ); }; 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 ( { const markdown = buildAssistantMessageMarkdown({ id: message.id, parentId: message.parentId, format: "assistant-ui/runtime", content: message, createdAt: message.createdAt, }); downloadAssistantMarkdown(markdown, `assistant-message-${message.id.slice(0, 8)}.md`); }} > ); }; const AssistantMessageActions = () => { const t = useScopedI18n("common.assistant"); return ( ); }; const UserMessageActions = () => { const t = useScopedI18n("common.assistant"); return ( ); }; const EditComposer = () => { const t = useScopedI18n("common.assistant"); return ( ); }; const UserMessage = () => { const isEditing = useAuiState((state) => state.composer.isEditing); if (isEditing) return ; return ( {(quote) => ( {quote.text} )} ); }; 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 ? ( ) : ( ); }; 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 ( {telemetry.provider} {details.join(" · ")} } multiline maw="min(24rem, calc(100vw - 2rem))" > ); }; 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 ( setOpened((value) => !value)} > {t("usage.step", { number: index + 1 })} · {turn.modelId} {[ 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")} {generationQuery.isFetching && generationQuery.data?.complete !== true && ( {t("usage.loadingProviderDetails")} )} {!generationQuery.isFetching && generationQuery.data?.complete === false && ( {t("usage.providerDetailsPending")} )} {[ [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]) => (
{label} {value ?? t("usage.notReported")}
))}
{(telemetry.contextUsed !== undefined || telemetry.contextLength !== undefined) && ( {t("usage.contextWindow")}: {telemetry.contextUsed?.toLocaleString() ?? t("usage.notReported")} /{" "} {telemetry.contextLength?.toLocaleString() ?? t("usage.notReported")} )} {steps.length > 0 && ( {t("usage.agentSteps")} {steps.map((step) => ( {t("usage.step", { number: step.index })} {step.routedProvider ? ` · ${step.routedProvider}` : ""} {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)}` : ""} {[ 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")} {(step.normalizedInputTokens !== undefined || step.normalizedOutputTokens !== undefined) && ( {t("usage.normalizedTokens")}: {step.normalizedInputTokens?.toLocaleString() ?? "–"} /{" "} {step.normalizedOutputTokens?.toLocaleString() ?? "–"} )} {(step.routerStrategy || step.routerRegion || step.serviceTier || step.dataRegion || step.isByok) && ( {[ step.routerStrategy, step.routerRegion, step.serviceTier, step.dataRegion, step.isByok ? t("usage.byok") : undefined, ] .filter(Boolean) .join(" · ")} )} {step.generationId && ( {t("usage.generation")}: {step.generationId} )} ))} )}
); }; 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 ( {hasContext ? `${breakdown.percentage}%` : "–"} } /> {[ { label: t("usage.used"), value: breakdown.contextUsed }, { label: t("usage.remaining"), value: breakdown.remaining }, { label: t("usage.capacity"), value: breakdown.contextLength }, ].map((item) => ( {item.label} {item.value?.toLocaleString() ?? t("usage.notReported")} ))} {categories.map((category) => ( {category.label} {category.value?.toLocaleString() ?? t("usage.notReported")} ))} ); }; 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 ( setOpened((value) => !value)} > {t("usage.contextWindow")} {t("usage.requestDetails")} {hasContext && ( {contextPercentage}% )}
{t("usage.tokens")} {hasTokenUsage ? usage.totalTokens.toLocaleString() : t("usage.notReported")}
{t("usage.cost")} {hasCost ? formatCost(usage.cost) : t("usage.notReported")}
{t("usage.agentSteps")} {usage.turns.reduce((total, turn) => total + turn.telemetry.steps.length, 0).toLocaleString()}
{usage.turns.length === 0 ? ( {t("usage.notReported")} ) : ( usage.turns.map((turn, index) => ( )) )}
); }; const RuntimeError = () => { const t = useScopedI18n("common.assistant"); return ( {t("responseError.title")} ); }; 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 (
{t("webSearch.title")} {t("webSearch.completed")}
{telemetry.webSearchRequests !== undefined && ( {t("webSearch.searches", { count: telemetry.webSearchRequests })} )} {sources.length > 0 && ( {t("webSearch.sources", { count: sources.length })} )}
{sources.length > 0 && ( {sources.map((source) => ( {source.title ?? new URL(source.url).hostname} ))} )}
); }; const AssistantMessage = () => ( {({ part, children }) => { switch (part.type) { case "group-agent-trace": { const startIndex = part.indices[0]; const endIndex = part.indices.at(-1); if (startIndex === undefined || endIndex === undefined) return null; return ( {children} ); } case "group-reasoning": return <>{children}; case "group-tool": return {children}; case "indicator": return ; case "text": return ; case "reasoning": return ; case "tool-call": return part.toolUI ?? ; case "source": return ; case "file": return ; case "image": return ; case "data": return part.dataRendererUI; default: return null; } }} ); const 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 ( void exportConversation()} > ); }; const ThreadHistory = ({ onSelect }: { onSelect: () => void }) => { const t = useScopedI18n("common.assistant"); return ( {t("conversations")} ); }; const ConversationHistory = () => { const t = useScopedI18n("common.assistant"); const [opened, setOpened] = useState(false); return ( setOpened(false)} position="bottom-end" width="min(22rem, calc(100vw - 1rem))" shadow="md" withinPortal > setOpened(false)} /> ); }; const AutoApprovalControl = () => { const t = useScopedI18n("common.assistant.autoApproval"); const [opened, setOpened] = useState(false); const { enabled, setEnabled } = useAssistantAutoApproval(); return ( setOpened(false)} position="bottom-end" width="min(19rem, calc(100vw - 1rem))" shadow="md" withinPortal > setOpened((current) => !current)} aria-label={t("label")} aria-pressed={enabled} title={enabled ? t("enabled") : t("label")} > {t("title")} {t("description")} ); }; const ViewRefreshAction = ({ isRefreshing, onRefresh, }: Pick) => { 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 ( void refresh()} aria-label={isRefreshing ? t("working") : t("action")} > ); }; const EmptyThread = () => { const t = useScopedI18n("common.assistant"); return ( {t("emptyTitle")} {t("emptyDescription")} ); }; const TriggerPopoverAutoScroll = ({ viewportRef }: { viewportRef: RefObject }) => { const { activeCategoryId, categories, highlightedIndex, isSearchMode, items, open, query } = unstable_useTriggerPopoverScopeContext(); useLayoutEffect(() => { if (!open) return; const viewport = viewportRef.current; const highlightedItem = viewport?.querySelector("[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 (
{item.label} {item.description && ( {item.description} )}
); }; const ComposerTriggers = () => { const t = useScopedI18n("common.assistant"); const aui = useAui(); const mentionViewportRef = useRef(null); const slashViewportRef = useRef(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 ( <> {(items) => items.map((category) => { const Icon = contextIcons[category.id as keyof typeof contextIcons] ?? IconAt; return ( {category.label} ); }) } {(items) => items.map((item, index) => ) } {(items) => items.map((item, index) => )} ); }; 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()); 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 ( modelCombobox.toggleDropdown()} aria-label={`${t("runtime.model")}: ${selectedModel?.name ?? t("runtime.noModels")}. ${t("runtime.thinking")}: ${t(`runtime.reasoning.${reasoning}`)}`} aria-expanded={modelCombobox.dropdownOpened} > {modelOptionsLoading && } {selectedModel?.name ?? t("runtime.model")} {t(`runtime.reasoning.${reasoning}`)} { setModelSearch(event.currentTarget.value); modelCombobox.updateSelectedOptionIndex(); }} placeholder={t("runtime.searchModels")} aria-label={t("runtime.searchModels")} size="xs" /> {[...groupedModels.entries()].map(([provider, providerModels]) => ( {providerModels.map((model) => ( {model.name} {model.description?.trim() || model.id} {model.inputModalities.includes("image") && ( )} {model.contextLength && ( {formatCompactModelNumber(model.contextLength)} )} {model.id === modelId && } ))} ))} {visibleModels.length === 0 && {t("runtime.noModels")}} {t("runtime.thinking")} { if (assistantReasoningModes.includes(value as AssistantReasoningMode)) { onReasoningChange(value as AssistantReasoningMode); } }} size="xs" fullWidth data={assistantReasoningModes.map((mode) => ({ value: mode, label: t(`runtime.reasoning.${mode}`), }))} /> ); }; 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 ? ( ) : null; return ( {preferences.quotaLoading && !preferences.quota ? : } {preferences.quota?.remaining ?? "–"} {t("providerQuota.title")} {preferences.providerUser?.name || t("providerQuota.communityWorkshop")} {preferences.quota && ( {t(`providerQuota.level.${level}`)} )} {!preferences.providerUser ? ( <> {t("providerQuota.signInDescription")} ) : preferences.quota ? ( <> {t("providerQuota.dailyAllowance")} {preferences.quota.remaining} / {preferences.quota.limit} {t.rich("providerQuota.reset", { relative: resetRelative, time: () => resetTime, })} {t("providerQuota.toolCalls")} ) : ( )} {preferences.quotaError && ( {preferences.quotaError} )} ); }; 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(null); const composerLabel = t("composerPlaceholder"); useLayoutEffect(() => { const editor = composerInputRef.current?.querySelector(".aui-lexical-input"); if (!editor) return; editor.setAttribute("aria-label", composerLabel); return () => { if (editor.getAttribute("aria-label") === composerLabel) editor.removeAttribute("aria-label"); }; }, [composerLabel]); return ( { if (sendBlocked) event.preventDefault(); }} > {() => } {running ? ( ) : ( )} {t("mentions.hint")} {t("commands.hint")} ); }; 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 ( {copy.title} {copy.detail} ); }; const PendingQuestionDock = ({ pendingAction, setTarget, }: { pendingAction: AssistantPendingAction | undefined; setTarget: (target: HTMLDivElement | null) => void; }) => { const t = useScopedI18n("common.assistant"); if (pendingAction?.kind !== "question") return null; return (
); }; 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 ( {title} {unreadCount > 0 && ( {unreadCount > 99 ? "99+" : unreadCount} )} {detail} {needsApproval ? ( {t("pendingAction.review")} ) : ( )} {!isRunning && !needsApproval && ( )} ); }; 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(null); const [reasoningCollapsed, setReasoningCollapsed] = useState(false); const reasoningVisibility = useMemo( () => ({ collapsed: reasoningCollapsed, setCollapsed: setReasoningCollapsed }), [reasoningCollapsed], ); const scrollToLatestBehavior = isRunning || reducedMotion ? "instant" : "smooth"; return ( <> {onWindowClose && onMinimize && ( )}
{t("title")}
{onExpand && ( )}
); }; 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(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 && ( )} {opened && ( { onDismissActivity(); onClose(); }} /> )} ); };