feat: add assistant conversation widget
This commit is contained in:
1 parent
c7b0c1bcf0
commit
00fa1192d7
17 files changed
+779
-93
No files matched your search
@@ -114,6 +114,38 @@
|
||||
animation: assistant-panel-in 180ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.widgetPanel {
|
||||
container-name: assistant-widget;
|
||||
container-type: size;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
color: var(--mantine-color-text);
|
||||
}
|
||||
|
||||
.widgetState {
|
||||
container-type: inline-size;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.compactState {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.compactCopy {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.widgetPanel[data-editing] .panelActions {
|
||||
padding-inline-end: 2.75rem;
|
||||
}
|
||||
|
||||
.panelHeader {
|
||||
min-height: 3.75rem;
|
||||
flex: 0 0 auto;
|
||||
@@ -139,6 +171,120 @@
|
||||
height: 2.625rem;
|
||||
}
|
||||
|
||||
@container assistant-widget (max-width: 30rem) {
|
||||
.panelHeader {
|
||||
min-height: 3.25rem;
|
||||
padding: 0.45rem 0.5rem 0.45rem 0.65rem;
|
||||
}
|
||||
|
||||
.panelIdentityText > :last-child,
|
||||
.composerHints {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.historyButton {
|
||||
width: 2.625rem;
|
||||
max-width: 2.625rem;
|
||||
height: 2.625rem;
|
||||
padding-inline: 0.75rem;
|
||||
}
|
||||
|
||||
.historyButtonSection {
|
||||
margin-inline-end: 0;
|
||||
}
|
||||
|
||||
.historyButtonLabel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.messages {
|
||||
padding: 0.5rem 0.65rem 1rem;
|
||||
}
|
||||
|
||||
.composerWrap {
|
||||
padding: 0.45rem;
|
||||
}
|
||||
|
||||
.composer {
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem 0.3rem;
|
||||
}
|
||||
|
||||
.composerInput {
|
||||
min-height: 2.625rem;
|
||||
padding: 0.6rem 0.5rem;
|
||||
}
|
||||
|
||||
.runtimeButtonGroup,
|
||||
.runtimeControls {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.modelButton {
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.reasoningButton {
|
||||
min-width: 2.5rem;
|
||||
max-width: 7.75rem;
|
||||
}
|
||||
|
||||
.suggestions {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@container assistant-widget (max-width: 22rem) {
|
||||
.panelIdentityText {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.panelActions {
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
.panelAction,
|
||||
.historyButton {
|
||||
width: 2.25rem;
|
||||
max-width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
padding-inline: 0.55rem;
|
||||
}
|
||||
|
||||
.composerFooter {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.runtimeButton {
|
||||
padding-inline: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@container assistant-widget (max-width: 18rem) {
|
||||
.compactDescription {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@container assistant-widget (max-width: 10rem) {
|
||||
.compactTitle {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@container assistant-widget (max-height: 24rem) {
|
||||
.empty {
|
||||
min-height: 16rem;
|
||||
padding-block: 1rem;
|
||||
}
|
||||
|
||||
.suggestion {
|
||||
min-height: 2.5rem;
|
||||
padding-block: 0.45rem;
|
||||
}
|
||||
}
|
||||
|
||||
.historyMenu {
|
||||
background: var(--mantine-color-body);
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ import {
|
||||
IconAlertTriangle,
|
||||
IconApps,
|
||||
IconArrowUp,
|
||||
IconArrowsMaximize,
|
||||
IconAt,
|
||||
IconBrain,
|
||||
IconCheck,
|
||||
@@ -105,7 +106,16 @@ import type { AssistantReasoningMode, AssistantRuntimeModelOption } from "./assi
|
||||
import { getNearestTriggerScrollTop } from "./assistant-trigger-scroll";
|
||||
import { getToolResultPresentation } from "./assistant-tool-result";
|
||||
|
||||
interface AssistantPanelProps {
|
||||
export interface AssistantConversationControls {
|
||||
modelId: string | null;
|
||||
models: AssistantRuntimeModelOption[];
|
||||
modelOptionsLoading: boolean;
|
||||
reasoning: AssistantReasoningMode;
|
||||
onModelChange: (modelId: string) => void;
|
||||
onReasoningChange: (reasoning: AssistantReasoningMode) => void;
|
||||
}
|
||||
|
||||
interface AssistantPanelProps extends AssistantConversationControls {
|
||||
opened: boolean;
|
||||
onOpen: () => void;
|
||||
onClose: () => void;
|
||||
@@ -117,12 +127,6 @@ interface AssistantPanelProps {
|
||||
latestUserText: string;
|
||||
latestStatus: MessageStatus | undefined;
|
||||
pendingAction: AssistantPendingAction | undefined;
|
||||
modelId: string | null;
|
||||
models: AssistantRuntimeModelOption[];
|
||||
modelOptionsLoading: boolean;
|
||||
reasoning: AssistantReasoningMode;
|
||||
onModelChange: (modelId: string) => void;
|
||||
onReasoningChange: (reasoning: AssistantReasoningMode) => void;
|
||||
}
|
||||
|
||||
const markdownRemarkPlugins = [remarkGfm, remarkBreaks];
|
||||
@@ -1255,10 +1259,7 @@ const ComposerTriggers = () => {
|
||||
);
|
||||
};
|
||||
|
||||
type ComposerProps = Pick<
|
||||
AssistantPanelProps,
|
||||
"modelId" | "models" | "modelOptionsLoading" | "reasoning" | "onModelChange" | "onReasoningChange"
|
||||
> & { pendingAction: AssistantPendingAction | undefined };
|
||||
type ComposerProps = AssistantConversationControls & { pendingAction: AssistantPendingAction | undefined };
|
||||
|
||||
const RuntimeControls = ({
|
||||
modelId,
|
||||
@@ -1622,6 +1623,124 @@ const AssistantActivityBar = ({
|
||||
);
|
||||
};
|
||||
|
||||
interface AssistantConversationSurfaceProps extends AssistantConversationControls {
|
||||
isRunning: boolean;
|
||||
pendingAction: AssistantPendingAction | undefined;
|
||||
onExpand?: () => void;
|
||||
onMinimize?: () => void;
|
||||
}
|
||||
|
||||
export const AssistantConversationSurface = ({
|
||||
isRunning,
|
||||
pendingAction,
|
||||
modelId,
|
||||
models,
|
||||
modelOptionsLoading,
|
||||
reasoning,
|
||||
onModelChange,
|
||||
onReasoningChange,
|
||||
onExpand,
|
||||
onMinimize,
|
||||
}: AssistantConversationSurfaceProps) => {
|
||||
const t = useScopedI18n("common.assistant");
|
||||
|
||||
return (
|
||||
<>
|
||||
<Group className={classes.panelHeader} justify="space-between" wrap="nowrap" gap="xs">
|
||||
<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>
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
{isRunning ? t("activity.thinking") : t("subtitle")}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Group className={classes.panelActions} gap={2} wrap="nowrap">
|
||||
<ConversationHistory />
|
||||
<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>
|
||||
)}
|
||||
{onMinimize && (
|
||||
<Tooltip label={t("minimize")}>
|
||||
<ActionIcon
|
||||
className={classes.panelAction}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={onMinimize}
|
||||
aria-label={t("minimize")}
|
||||
>
|
||||
<IconMinus size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
<ThreadPrimitive.Root className={classes.thread}>
|
||||
<ThreadPrimitive.Viewport className={classes.viewport}>
|
||||
<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 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>
|
||||
<PendingActionBanner pendingAction={pendingAction} />
|
||||
<Composer
|
||||
modelId={modelId}
|
||||
models={models}
|
||||
modelOptionsLoading={modelOptionsLoading}
|
||||
reasoning={reasoning}
|
||||
onModelChange={onModelChange}
|
||||
onReasoningChange={onReasoningChange}
|
||||
pendingAction={pendingAction}
|
||||
/>
|
||||
</ThreadPrimitive.Root>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const AssistantPanel = ({
|
||||
opened,
|
||||
onOpen,
|
||||
@@ -1683,82 +1802,17 @@ export const AssistantPanel = ({
|
||||
{opened && (
|
||||
<FocusTrap active>
|
||||
<dialog className={classes.floatingPanel} aria-label={t("title")} open>
|
||||
<Group className={classes.panelHeader} justify="space-between" wrap="nowrap" gap="xs">
|
||||
<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>
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
{isRunning ? t("activity.thinking") : t("subtitle")}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Group className={classes.panelActions} gap={2} wrap="nowrap">
|
||||
<ConversationHistory />
|
||||
<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>
|
||||
<Tooltip label={t("minimize")}>
|
||||
<ActionIcon
|
||||
className={classes.panelAction}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={onClose}
|
||||
aria-label={t("minimize")}
|
||||
>
|
||||
<IconMinus size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Group>
|
||||
<ThreadPrimitive.Root className={classes.thread}>
|
||||
<ThreadPrimitive.Viewport className={classes.viewport}>
|
||||
<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 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>
|
||||
<PendingActionBanner pendingAction={pendingAction} />
|
||||
<Composer
|
||||
modelId={modelId}
|
||||
models={models}
|
||||
modelOptionsLoading={modelOptionsLoading}
|
||||
reasoning={reasoning}
|
||||
onModelChange={onModelChange}
|
||||
onReasoningChange={onReasoningChange}
|
||||
pendingAction={pendingAction}
|
||||
/>
|
||||
</ThreadPrimitive.Root>
|
||||
<AssistantConversationSurface
|
||||
isRunning={isRunning}
|
||||
pendingAction={pendingAction}
|
||||
modelId={modelId}
|
||||
models={models}
|
||||
modelOptionsLoading={modelOptionsLoading}
|
||||
reasoning={reasoning}
|
||||
onModelChange={onModelChange}
|
||||
onReasoningChange={onReasoningChange}
|
||||
onMinimize={onClose}
|
||||
/>
|
||||
</dialog>
|
||||
</FocusTrap>
|
||||
)}
|
||||
|
||||
@@ -23,6 +23,7 @@ import { hotkeys } from "@homarr/definitions";
|
||||
import { showErrorNotification, showWarningNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { openMediaRequestSearch, openSpotlight, useRegisterSpotlightContextResults } from "@homarr/spotlight";
|
||||
import { AssistantWidgetRendererProvider } from "@homarr/widgets";
|
||||
|
||||
import { shouldAutomaticallyContinueAssistant } from "./assistant-auto-submit";
|
||||
import { AssistantAskUserTool, AssistantConfigureAppTool } from "./assistant-human-tools";
|
||||
@@ -35,9 +36,11 @@ import { createAssistantPromptInteraction } from "./assistant-spotlight";
|
||||
import { browserToolContracts } from "./assistant-tool-contracts";
|
||||
import { AssistantConfigureBoardSettingsTool } from "./assistant-board-settings-tool";
|
||||
import type { AssistantUIMessage } from "./assistant-message-metadata";
|
||||
import { AssistantBoardWidget } from "./assistant-widget";
|
||||
|
||||
interface AssistantContextValue {
|
||||
enabled: boolean;
|
||||
unavailableDescription: string | null;
|
||||
opened: boolean;
|
||||
isRunning: boolean;
|
||||
unreadCount: number;
|
||||
@@ -148,7 +151,7 @@ export const useHomarrAssistant = () => {
|
||||
|
||||
export const useOptionalHomarrAssistant = () => useContext(AssistantContext);
|
||||
|
||||
const useAssistantPreferences = () => {
|
||||
export const useAssistantPreferences = () => {
|
||||
const value = useContext(AssistantPreferencesContext);
|
||||
if (!value) throw new Error("useAssistantPreferences must be used within AssistantPreferencesProvider");
|
||||
return value;
|
||||
@@ -636,13 +639,23 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => {
|
||||
useRegisterSpotlightContextResults("homarr-assistant", [spotlightItem], [spotlightItem]);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ enabled: true, opened, isRunning: assistantIsRunning, unreadCount, open, close, toggle, sendPrompt }),
|
||||
() => ({
|
||||
enabled: true,
|
||||
unavailableDescription: null,
|
||||
opened,
|
||||
isRunning: assistantIsRunning,
|
||||
unreadCount,
|
||||
open,
|
||||
close,
|
||||
toggle,
|
||||
sendPrompt,
|
||||
}),
|
||||
[assistantIsRunning, close, open, opened, sendPrompt, toggle, unreadCount],
|
||||
);
|
||||
|
||||
return (
|
||||
<AssistantContext.Provider value={value}>
|
||||
{children}
|
||||
<AssistantWidgetRendererProvider renderer={AssistantBoardWidget}>{children}</AssistantWidgetRendererProvider>
|
||||
<AssistantPanel
|
||||
opened={opened}
|
||||
onOpen={open}
|
||||
@@ -692,6 +705,7 @@ const DisabledAssistantProvider = ({ children, description }: DisabledAssistantP
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
enabled: false,
|
||||
unavailableDescription: description,
|
||||
opened: false,
|
||||
isRunning: false,
|
||||
unreadCount: 0,
|
||||
@@ -700,10 +714,14 @@ const DisabledAssistantProvider = ({ children, description }: DisabledAssistantP
|
||||
toggle: () => undefined,
|
||||
sendPrompt: () => undefined,
|
||||
}),
|
||||
[],
|
||||
[description],
|
||||
);
|
||||
|
||||
return <AssistantContext.Provider value={value}>{children}</AssistantContext.Provider>;
|
||||
return (
|
||||
<AssistantContext.Provider value={value}>
|
||||
<AssistantWidgetRendererProvider renderer={AssistantBoardWidget}>{children}</AssistantWidgetRendererProvider>
|
||||
</AssistantContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const AssistantProvider = ({ children }: PropsWithChildren) => {
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useAui, useAuiState } from "@assistant-ui/react";
|
||||
import { ActionIcon, Box, Button, Center, Group, Loader, Stack, Text, ThemeIcon, Tooltip } from "@mantine/core";
|
||||
import { IconAlertTriangle, IconArrowsMaximize, IconMessage, IconRobot } from "@tabler/icons-react";
|
||||
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import type { WidgetComponentProps } from "@homarr/widgets";
|
||||
|
||||
import classes from "./assistant-panel.module.css";
|
||||
import { AssistantConversationSurface } from "./assistant-panel";
|
||||
import { getPendingAssistantAction } from "./assistant-pending-action";
|
||||
import { useAssistantPreferences, useHomarrAssistant } from "./assistant-provider";
|
||||
|
||||
export const AssistantBoardWidget = (props: WidgetComponentProps<"assistant">) => {
|
||||
const assistant = useHomarrAssistant();
|
||||
|
||||
if (!assistant.enabled) {
|
||||
return <UnavailableAssistantWidget description={assistant.unavailableDescription} />;
|
||||
}
|
||||
|
||||
return <EnabledAssistantBoardWidget {...props} />;
|
||||
};
|
||||
|
||||
const EnabledAssistantBoardWidget = ({ options, width, height, isEditMode }: WidgetComponentProps<"assistant">) => {
|
||||
const t = useI18n();
|
||||
const assistant = useHomarrAssistant();
|
||||
const preferences = useAssistantPreferences();
|
||||
const aui = useAui();
|
||||
const currentThreadId = useAuiState((state) => state.threadListItem.remoteId);
|
||||
const messages = useAuiState((state) => state.thread.messages);
|
||||
const isLoading = useAuiState((state) => state.thread.isLoading);
|
||||
const latestAssistantMessage = messages.findLast((message) => message.role === "assistant");
|
||||
const pendingAction = getPendingAssistantAction(latestAssistantMessage);
|
||||
const pinnedConversation = options.conversationMode === "pinned" ? options.conversation : null;
|
||||
const pinnedConversationIsActive = pinnedConversation === null || pinnedConversation.value === currentThreadId;
|
||||
const [switching, setSwitching] = useState(false);
|
||||
const [switchErrorConversationId, setSwitchErrorConversationId] = useState<string | null>(null);
|
||||
const hasSwitchError = switchErrorConversationId === pinnedConversation?.value;
|
||||
|
||||
const selectModel = useCallback(
|
||||
(modelId: string) => {
|
||||
preferences.setModelId(modelId);
|
||||
const threadListItem = aui.threadListItem();
|
||||
if (!threadListItem.getState().remoteId) return;
|
||||
threadListItem.updateCustom({ ...threadListItem.getState().custom, modelId });
|
||||
},
|
||||
[aui, preferences],
|
||||
);
|
||||
|
||||
const openPinnedConversation = async () => {
|
||||
if (!pinnedConversation) return;
|
||||
setSwitchErrorConversationId(null);
|
||||
setSwitching(true);
|
||||
try {
|
||||
await aui.threads().switchToThread(pinnedConversation.value);
|
||||
} catch {
|
||||
setSwitchErrorConversationId(pinnedConversation.value);
|
||||
} finally {
|
||||
setSwitching(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (options.conversationMode === "pinned" && pinnedConversation === null) {
|
||||
return (
|
||||
<AssistantWidgetState
|
||||
icon={IconMessage}
|
||||
title={t("widget.assistant.pinned.selectTitle")}
|
||||
description={t("widget.assistant.pinned.selectDescription")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!pinnedConversationIsActive && pinnedConversation) {
|
||||
return (
|
||||
<AssistantWidgetState
|
||||
icon={hasSwitchError ? IconAlertTriangle : IconMessage}
|
||||
color={hasSwitchError ? "red" : "gray"}
|
||||
title={t("widget.assistant.pinned.title")}
|
||||
description={
|
||||
hasSwitchError
|
||||
? t("widget.assistant.pinned.error")
|
||||
: t("widget.assistant.pinned.description", { conversation: pinnedConversation.label })
|
||||
}
|
||||
>
|
||||
<Button
|
||||
variant="light"
|
||||
color={hasSwitchError ? "red" : "gray"}
|
||||
onClick={() => void openPinnedConversation()}
|
||||
loading={switching}
|
||||
leftSection={switching ? <Loader type="bars" size="xs" /> : <IconMessage size={16} />}
|
||||
>
|
||||
{switching ? t("widget.assistant.pinned.opening") : t("widget.assistant.pinned.open")}
|
||||
</Button>
|
||||
</AssistantWidgetState>
|
||||
);
|
||||
}
|
||||
|
||||
if (width < 300 || height < 280) {
|
||||
return <CompactAssistantWidget onOpen={assistant.open} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
className={classes.widgetPanel}
|
||||
data-compact={width < 420 || height < 420 || undefined}
|
||||
data-editing={isEditMode || undefined}
|
||||
aria-busy={isLoading || assistant.isRunning}
|
||||
>
|
||||
<AssistantConversationSurface
|
||||
isRunning={assistant.isRunning}
|
||||
pendingAction={pendingAction}
|
||||
modelId={preferences.modelId}
|
||||
models={preferences.models}
|
||||
modelOptionsLoading={preferences.isLoading}
|
||||
reasoning={preferences.reasoning}
|
||||
onModelChange={selectModel}
|
||||
onReasoningChange={preferences.setReasoning}
|
||||
onExpand={assistant.open}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const CompactAssistantWidget = ({ onOpen }: { onOpen: () => void }) => {
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<Box className={classes.widgetPanel}>
|
||||
<Group className={classes.compactState} justify="space-between" wrap="nowrap" gap="sm">
|
||||
<Group wrap="nowrap" gap="xs" miw={0}>
|
||||
<ThemeIcon variant="light" color="red" radius="xl" size="lg" flex="0 0 auto">
|
||||
<IconRobot size={19} />
|
||||
</ThemeIcon>
|
||||
<Stack className={classes.compactCopy} gap={1}>
|
||||
<Text className={classes.compactTitle} size="sm" fw={700} lineClamp={1}>
|
||||
{t("widget.assistant.compact.title")}
|
||||
</Text>
|
||||
<Text className={classes.compactDescription} size="xs" c="dimmed" lineClamp={2}>
|
||||
{t("widget.assistant.compact.description")}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Tooltip label={t("widget.assistant.compact.open")}>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="red"
|
||||
size="lg"
|
||||
radius="md"
|
||||
onClick={onOpen}
|
||||
aria-label={t("widget.assistant.compact.open")}
|
||||
>
|
||||
<IconArrowsMaximize size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
interface AssistantWidgetStateProps {
|
||||
icon: typeof IconRobot;
|
||||
title: string;
|
||||
description: string;
|
||||
color?: string;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
const AssistantWidgetState = ({
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
color = "red",
|
||||
children,
|
||||
}: AssistantWidgetStateProps) => (
|
||||
<Center className={classes.widgetState} h="100%" p="md">
|
||||
<Stack align="center" gap="sm" ta="center" maw={360}>
|
||||
<ThemeIcon variant="light" color={color} radius="xl" size="xl">
|
||||
<Icon size={24} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={3} align="center">
|
||||
<Text fw={700}>{title}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{description}
|
||||
</Text>
|
||||
</Stack>
|
||||
{children}
|
||||
</Stack>
|
||||
</Center>
|
||||
);
|
||||
|
||||
const UnavailableAssistantWidget = ({ description }: { description: string | null }) => {
|
||||
const t = useI18n();
|
||||
return (
|
||||
<AssistantWidgetState
|
||||
icon={IconRobot}
|
||||
title={t("widget.assistant.unavailable.title")}
|
||||
description={description ?? t("widget.assistant.unavailable.description")}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -129,6 +129,26 @@ describe("item actions create-item", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("should fit a wide widget to every board layout", () => {
|
||||
const layoutId = "compact-layout";
|
||||
const layout = new LayoutMockBuilder({ id: layoutId, columnCount: 4 }).build();
|
||||
const board = new BoardMockBuilder().addLayout(layout).addEmptySection({ id: "section" }).build();
|
||||
const layoutsSpy = vi.spyOn(boardContext, "getBoardLayouts");
|
||||
layoutsSpy.mockReturnValue([layoutId]);
|
||||
const emptyPositionSpy = vi.spyOn(emptyPositionModule, "getFirstEmptyPosition");
|
||||
emptyPositionSpy.mockReturnValue({ xOffset: 0, yOffset: 0 });
|
||||
|
||||
const result = createItemCallback({ kind: "assistant" })(board);
|
||||
|
||||
expect(result.items.at(0)?.layouts.at(0)).toEqual(
|
||||
expect.objectContaining({ width: layout.columnCount, height: 4 }),
|
||||
);
|
||||
expect(emptyPositionSpy).toHaveBeenCalledWith([], layout.columnCount, undefined, {
|
||||
width: layout.columnCount,
|
||||
height: 4,
|
||||
});
|
||||
});
|
||||
|
||||
test("clamps wide defaults and places below a full automatic canvas", () => {
|
||||
const board = new BoardMockBuilder().addEmptySection({ id: "canvas", yOffset: 0 }).build();
|
||||
const layout = board.layouts[0];
|
||||
|
||||
@@ -47,7 +47,7 @@ interface BoardItemContentProps {
|
||||
|
||||
const getOverflowFromKind = (kind: SectionItem["kind"], hasCustomCssClasses: boolean) => {
|
||||
if (kind === "systemResources") return { overflowX: "visible", overflowY: "visible" } as const;
|
||||
if (kind === "iframe") return { overflowX: "hidden", overflowY: "hidden" } as const;
|
||||
if (kind === "iframe" || kind === "assistant") return { overflowX: "hidden", overflowY: "hidden" } as const;
|
||||
if (hasCustomCssClasses) return {};
|
||||
return { overflowX: "hidden", overflowY: "auto" } as const;
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user