fix assistant focus and settings
This commit is contained in:
1 parent
b7ddc028a5
commit
8dd6cbbfdb
7 files changed
+146
-88
No files matched your search
+2
-1
@@ -94,7 +94,8 @@
|
||||
}
|
||||
|
||||
.securityNote {
|
||||
max-width: 66ch;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.flowActions {
|
||||
|
||||
+2
-60
@@ -13,7 +13,6 @@ import {
|
||||
Group,
|
||||
Image,
|
||||
LoadingOverlay,
|
||||
Modal,
|
||||
Paper,
|
||||
PasswordInput,
|
||||
Select,
|
||||
@@ -31,7 +30,6 @@ import {
|
||||
IconCircleCheck,
|
||||
IconDatabaseSearch,
|
||||
IconKey,
|
||||
IconLock,
|
||||
IconPlus,
|
||||
IconSearch,
|
||||
IconShieldCheck,
|
||||
@@ -154,7 +152,6 @@ export const AssistantConfiguration = () => {
|
||||
const [modelId, setModelId] = useState("");
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [webSearchEnabled, setWebSearchEnabled] = useState(false);
|
||||
const [clearCredentialsOpened, setClearCredentialsOpened] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setProvider(configuration?.provider ?? "openrouter");
|
||||
@@ -285,27 +282,7 @@ export const AssistantConfiguration = () => {
|
||||
onError: (error) => showErrorNotification({ title: t("notification.error.title"), message: error.message }),
|
||||
});
|
||||
|
||||
const clearCredentials = clientApi.assistant.clearCredentials.useMutation({
|
||||
onSuccess: async () => {
|
||||
setClearCredentialsOpened(false);
|
||||
setApiKey("");
|
||||
setHeaders([]);
|
||||
setModelId("");
|
||||
setEnabled(false);
|
||||
await Promise.all([
|
||||
utils.assistant.getAdminConfiguration.invalidate(),
|
||||
utils.assistant.discoverModels.invalidate(),
|
||||
utils.assistant.getAvailability.invalidate(),
|
||||
]);
|
||||
showSuccessNotification({
|
||||
title: t("notification.credentialsCleared.title"),
|
||||
message: t("notification.credentialsCleared.message"),
|
||||
});
|
||||
},
|
||||
onError: (error) => showErrorNotification({ title: t("notification.error.title"), message: error.message }),
|
||||
});
|
||||
|
||||
const pending = updateConnection.isPending || saveConfiguration.isPending || clearCredentials.isPending;
|
||||
const pending = updateConnection.isPending || saveConfiguration.isPending;
|
||||
|
||||
const resetCredentialFlow = () => {
|
||||
setApiKey("");
|
||||
@@ -419,11 +396,6 @@ export const AssistantConfiguration = () => {
|
||||
{t("serverTools.webSearch.label")}
|
||||
</Badge>
|
||||
)}
|
||||
{hasStoredApiKey && (
|
||||
<Badge variant="light" color="teal" leftSection={<IconLock size={12} />}>
|
||||
{t("overview.keyProtected")}
|
||||
</Badge>
|
||||
)}
|
||||
{configuration?.enabled === false && (
|
||||
<Badge variant="filled" color="gray">
|
||||
{t("overview.disabled")}
|
||||
@@ -508,15 +480,7 @@ export const AssistantConfiguration = () => {
|
||||
)
|
||||
}
|
||||
actions={
|
||||
<Group justify="space-between">
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={() => setClearCredentialsOpened(true)}
|
||||
disabled={!configuration?.apiKeyConfigured && !configuration?.customHeadersConfigured}
|
||||
>
|
||||
{t("connection.clearCredentials")}
|
||||
</Button>
|
||||
<Group justify="flex-end">
|
||||
{credentialFlow === "idle" && (
|
||||
<Button
|
||||
leftSection={<IconCheck size={16} />}
|
||||
@@ -820,28 +784,6 @@ export const AssistantConfiguration = () => {
|
||||
</Group>
|
||||
</ConfigurationSection>
|
||||
</Stack>
|
||||
|
||||
<Modal
|
||||
opened={clearCredentialsOpened}
|
||||
onClose={() => setClearCredentialsOpened(false)}
|
||||
title={t("clearCredentials.title")}
|
||||
centered
|
||||
>
|
||||
<Stack>
|
||||
<Alert color="red" icon={<IconAlertTriangle size={18} />} title={t("clearCredentials.warningTitle")}>
|
||||
{t("clearCredentials.description")}
|
||||
</Alert>
|
||||
<Divider />
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={() => setClearCredentialsOpened(false)}>
|
||||
{t("clearCredentials.cancel")}
|
||||
</Button>
|
||||
<Button color="red" loading={clearCredentials.isPending} onClick={() => clearCredentials.mutate()}>
|
||||
{t("clearCredentials.confirm")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -113,7 +113,7 @@
|
||||
border-radius: var(--mantine-radius-lg);
|
||||
color: var(--mantine-color-text);
|
||||
background: var(--mantine-color-body);
|
||||
box-shadow: var(--mantine-shadow-xl);
|
||||
box-shadow: none;
|
||||
animation: assistant-panel-in 180ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
@@ -1310,7 +1310,17 @@
|
||||
}
|
||||
|
||||
.composer:has(.composerInput:focus-within) {
|
||||
box-shadow: inset 0 0 0 2px var(--mantine-color-red-filled);
|
||||
box-shadow: inset 0 0 0 2px var(--mantine-primary-color-filled);
|
||||
}
|
||||
|
||||
.floatingPanel .composer {
|
||||
border-end-start-radius: var(--mantine-radius-lg);
|
||||
border-end-end-radius: var(--mantine-radius-lg);
|
||||
}
|
||||
|
||||
.widgetPanel[data-board-widget] .composer {
|
||||
border-end-start-radius: var(--assistant-widget-radius);
|
||||
border-end-end-radius: var(--assistant-widget-radius);
|
||||
}
|
||||
|
||||
.composerDropzone[data-dragging] .composer {
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
MessagePrimitive,
|
||||
ThreadPrimitive,
|
||||
useAui,
|
||||
useAuiEvent,
|
||||
useLocalRuntime,
|
||||
} from "@assistant-ui/react";
|
||||
import { LexicalComposerInput } from "@assistant-ui/react-lexical";
|
||||
@@ -118,14 +119,25 @@ const SurfaceProbe = ({ id, onReady }: SurfaceProbeProps) => {
|
||||
} as ComponentProps<typeof LexicalComposerInput>);
|
||||
};
|
||||
|
||||
const LexicalRunFocusProbe = () => {
|
||||
useAuiEvent("thread.runStart", () => {
|
||||
queueMicrotask(() => {
|
||||
document.querySelector<HTMLElement>('[data-testid="input-b"] [contenteditable="true"]')?.focus();
|
||||
});
|
||||
});
|
||||
return null;
|
||||
};
|
||||
|
||||
const SurfaceTestAssistant = ({
|
||||
onReady,
|
||||
onRuntimeReady,
|
||||
allowImages = true,
|
||||
simulateRunFocus = false,
|
||||
}: {
|
||||
onReady: SurfaceProbeProps["onReady"];
|
||||
onRuntimeReady: (runtime: AssistantRuntime) => void;
|
||||
allowImages?: boolean;
|
||||
simulateRunFocus?: boolean;
|
||||
}) => {
|
||||
const runtime = useLocalRuntime(noOpAdapter, {
|
||||
adapters: { attachments: createAttachmentAdapter(allowImages ? "image/png,text/plain" : "text/plain") },
|
||||
@@ -145,10 +157,14 @@ const SurfaceTestAssistant = ({
|
||||
{ surfaceId: "surface-b" },
|
||||
createElement(SurfaceProbe, { id: "b", onReady }),
|
||||
),
|
||||
simulateRunFocus ? createElement(LexicalRunFocusProbe) : null,
|
||||
);
|
||||
};
|
||||
|
||||
const renderSurfaceTestAssistant = async ({ allowImages = true }: { allowImages?: boolean } = {}) => {
|
||||
const renderSurfaceTestAssistant = async ({
|
||||
allowImages = true,
|
||||
simulateRunFocus = false,
|
||||
}: { allowImages?: boolean; simulateRunFocus?: boolean } = {}) => {
|
||||
const container = document.createElement("div");
|
||||
containers.push(container);
|
||||
document.body.append(container);
|
||||
@@ -160,6 +176,7 @@ const renderSurfaceTestAssistant = async ({ allowImages = true }: { allowImages?
|
||||
root.render(
|
||||
createElement(SurfaceTestAssistant, {
|
||||
allowImages,
|
||||
simulateRunFocus,
|
||||
onReady: (id, aui) => clients.set(id, aui),
|
||||
onRuntimeReady: (runtime) => {
|
||||
sharedRuntime = runtime;
|
||||
@@ -334,6 +351,35 @@ describe("AssistantRuntimeProviderWithTools", () => {
|
||||
await act(async () => root.unmount());
|
||||
});
|
||||
|
||||
test("keeps focus and dashboard scroll on the surface that started the run", async () => {
|
||||
const { container, root, a } = await renderSurfaceTestAssistant({ simulateRunFocus: true });
|
||||
const panelInput = getRequiredComposerInput(container, "input-a");
|
||||
const widgetInput = getRequiredComposerInput(container, "input-b");
|
||||
const scrollContainer = container;
|
||||
scrollContainer.scrollTop = 120;
|
||||
const focusWidget = widgetInput.focus.bind(widgetInput);
|
||||
const widgetFocus = vi.spyOn(widgetInput, "focus").mockImplementation((options) => {
|
||||
// jsdom does not implement native focus scrolling, so reproduce the dashboard jump that a
|
||||
// browser performs when the off-screen widget composer receives programmatic focus.
|
||||
scrollContainer.scrollTop = 640;
|
||||
focusWidget(options);
|
||||
});
|
||||
panelInput.focus();
|
||||
|
||||
act(() => {
|
||||
a.composer().setText("Send from the floating panel");
|
||||
a.composer().send();
|
||||
});
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
expect(widgetFocus).toHaveBeenCalled();
|
||||
expect(document.activeElement).toBe(panelInput);
|
||||
expect(scrollContainer.scrollTop).toBe(120);
|
||||
await act(async () => root.unmount());
|
||||
});
|
||||
|
||||
test("adds pasted images only to the active surface composer", async () => {
|
||||
const { container, root, a, b } = await renderSurfaceTestAssistant();
|
||||
const target = getRequiredComposerInput(container, "input-a");
|
||||
|
||||
@@ -378,6 +378,42 @@ export const useAssistantComposerSurface = () => {
|
||||
};
|
||||
|
||||
const surfaceElements = new Map<string, HTMLElement>();
|
||||
let lastFocusedSurfaceElement: HTMLElement | null = null;
|
||||
let lastFocusedSurfaceAt = 0;
|
||||
|
||||
interface AssistantSurfaceScrollPosition {
|
||||
element: HTMLElement;
|
||||
left: number;
|
||||
top: number;
|
||||
}
|
||||
|
||||
const captureAssistantSurfaceScrollPositions = () => {
|
||||
const elements = new Set<HTMLElement>();
|
||||
for (const surfaceElement of surfaceElements.values()) {
|
||||
let element: HTMLElement | null = surfaceElement;
|
||||
while (element) {
|
||||
elements.add(element);
|
||||
element = element.parentElement;
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(
|
||||
elements,
|
||||
(element): AssistantSurfaceScrollPosition => ({
|
||||
element,
|
||||
left: element.scrollLeft,
|
||||
top: element.scrollTop,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const restoreAssistantSurfaceScrollPositions = (positions: AssistantSurfaceScrollPosition[]) => {
|
||||
for (const { element, left, top } of positions) {
|
||||
if (!element.isConnected) continue;
|
||||
element.scrollLeft = left;
|
||||
element.scrollTop = top;
|
||||
}
|
||||
};
|
||||
|
||||
const getSurfaceIdForElement = (element: Element | null) => {
|
||||
if (!element) return null;
|
||||
@@ -461,7 +497,16 @@ const AssistantComposerSurfaceBoundary = ({ children, surfaceId }: PropsWithChil
|
||||
|
||||
return (
|
||||
<AssistantComposerSurfaceContext.Provider value={contextValue}>
|
||||
<div ref={elementRef} style={{ display: "contents" }} onPasteCapture={handlePasteCapture}>
|
||||
<div
|
||||
ref={elementRef}
|
||||
style={{ display: "contents" }}
|
||||
onFocusCapture={(event) => {
|
||||
if (!(event.target instanceof HTMLElement)) return;
|
||||
lastFocusedSurfaceElement = event.target;
|
||||
lastFocusedSurfaceAt = performance.now();
|
||||
}}
|
||||
onPasteCapture={handlePasteCapture}
|
||||
>
|
||||
<AssistantComposerSurfaceEvents />
|
||||
{children}
|
||||
</div>
|
||||
@@ -476,19 +521,42 @@ const AssistantComposerSurfaceBoundary = ({ children, surfaceId }: PropsWithChil
|
||||
*/
|
||||
export const AssistantRunFocusPreserver = () => {
|
||||
useAuiEvent("thread.runStart", () => {
|
||||
const focusedBeforeRun = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
const activeElement = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
// Sending can briefly move focus to <body> while Lexical reconciles the editor. Retain the
|
||||
// last connected assistant input so the run still belongs to the surface the user submitted.
|
||||
const recentlyFocusedSurface = performance.now() - lastFocusedSurfaceAt < 1000;
|
||||
const focusedBeforeRun =
|
||||
activeElement && activeElement !== document.body
|
||||
? activeElement
|
||||
: recentlyFocusedSurface && lastFocusedSurfaceElement?.isConnected
|
||||
? lastFocusedSurfaceElement
|
||||
: activeElement;
|
||||
const surfaceIdBeforeRun = getSurfaceIdForElement(focusedBeforeRun);
|
||||
const scrollPositionsBeforeRun = captureAssistantSurfaceScrollPositions();
|
||||
let isRestoring = false;
|
||||
|
||||
const restoreFocus = () => {
|
||||
if (isRestoring) return;
|
||||
const surfaceIdAfterRun = getSurfaceIdForElement(document.activeElement);
|
||||
if (!surfaceIdAfterRun || surfaceIdAfterRun === surfaceIdBeforeRun) return;
|
||||
const movedToAnotherSurface = surfaceIdAfterRun !== null && surfaceIdAfterRun !== surfaceIdBeforeRun;
|
||||
const leftInitiatingSurface = surfaceIdBeforeRun !== null && surfaceIdAfterRun !== surfaceIdBeforeRun;
|
||||
if (!movedToAnotherSurface && !leftInitiatingSurface) return;
|
||||
|
||||
if (focusedBeforeRun?.isConnected) focusedBeforeRun.focus({ preventScroll: true });
|
||||
if (
|
||||
getSurfaceIdForElement(document.activeElement) !== surfaceIdBeforeRun &&
|
||||
document.activeElement instanceof HTMLElement
|
||||
) {
|
||||
document.activeElement.blur();
|
||||
isRestoring = true;
|
||||
try {
|
||||
if (focusedBeforeRun?.isConnected) focusedBeforeRun.focus({ preventScroll: true });
|
||||
if (
|
||||
getSurfaceIdForElement(document.activeElement) !== surfaceIdBeforeRun &&
|
||||
document.activeElement instanceof HTMLElement
|
||||
) {
|
||||
document.activeElement.blur();
|
||||
}
|
||||
// Focusing the other mounted Lexical composer may already have scrolled the dashboard or
|
||||
// one of its nested board containers. Put every assistant surface ancestor back before the
|
||||
// browser paints so a send from the floating panel never jumps to the board widget.
|
||||
restoreAssistantSurfaceScrollPositions(scrollPositionsBeforeRun);
|
||||
} finally {
|
||||
isRestoring = false;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import type { CSSProperties, 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";
|
||||
@@ -8,6 +8,7 @@ import { IconAlertTriangle, IconArrowsMaximize, IconMessage, IconRobot } from "@
|
||||
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import type { WidgetComponentProps } from "@homarr/widgets";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
|
||||
import classes from "./assistant-panel.module.css";
|
||||
import { AssistantConversationSurface } from "./assistant-panel";
|
||||
@@ -31,6 +32,7 @@ export const AssistantBoardWidget = (props: WidgetComponentProps<"assistant">) =
|
||||
|
||||
const EnabledAssistantBoardWidget = ({ options, width, height, isEditMode }: WidgetComponentProps<"assistant">) => {
|
||||
const t = useI18n();
|
||||
const board = useRequiredBoard();
|
||||
const assistant = useHomarrAssistant();
|
||||
const preferences = useAssistantPreferences();
|
||||
const aui = useAui();
|
||||
@@ -110,9 +112,11 @@ const EnabledAssistantBoardWidget = ({ options, width, height, isEditMode }: Wid
|
||||
return (
|
||||
<Box
|
||||
className={classes.widgetPanel}
|
||||
data-board-widget
|
||||
data-compact={width < 420 || height < 420 || undefined}
|
||||
data-editing={isEditMode || undefined}
|
||||
aria-busy={isLoading || assistant.isRunning}
|
||||
style={{ "--assistant-widget-radius": `var(--mantine-radius-${board.itemRadius})` } as CSSProperties}
|
||||
>
|
||||
<AssistantConversationSurface
|
||||
isRunning={assistant.isRunning}
|
||||
|
||||
@@ -6150,7 +6150,6 @@
|
||||
"connectionReady": "Connection ready",
|
||||
"unsavedChanges": "Unsaved changes",
|
||||
"setupRequired": "Setup required",
|
||||
"keyProtected": "Key protected",
|
||||
"available": "Available",
|
||||
"disabled": "Disabled",
|
||||
"noModel": "No model selected"
|
||||
@@ -6161,8 +6160,7 @@
|
||||
"description": "Choose a preset or connect any service that implements the OpenAI Chat Completions API.",
|
||||
"saved": "Connection saved",
|
||||
"notSaved": "Not configured",
|
||||
"save": "Save provider connection",
|
||||
"clearCredentials": "Remove all credentials"
|
||||
"save": "Save provider connection"
|
||||
},
|
||||
"credentials": {
|
||||
"title": "Credentials",
|
||||
@@ -6303,13 +6301,6 @@
|
||||
"label": "Enable Assistant",
|
||||
"description": "Shows the assistant to all signed-in users. Existing Homarr permissions still apply to every internal tool call."
|
||||
},
|
||||
"clearCredentials": {
|
||||
"title": "Remove all assistant credentials",
|
||||
"warningTitle": "The assistant will be disabled",
|
||||
"description": "This permanently removes the stored API key and additional headers, clears the selected model, and disables the assistant. The encrypted values cannot be recovered.",
|
||||
"cancel": "Keep credentials",
|
||||
"confirm": "Remove credentials"
|
||||
},
|
||||
"save": "Save assistant",
|
||||
"notification": {
|
||||
"connectionSaved": {
|
||||
@@ -6317,10 +6308,6 @@
|
||||
"message": "The endpoint and encrypted credentials are ready for model selection.",
|
||||
"credentialsCleared": "The endpoint changed, so previous credentials were cleared. Add credentials for the new destination if required."
|
||||
},
|
||||
"credentialsCleared": {
|
||||
"title": "Provider credentials removed",
|
||||
"message": "The stored API key and headers were removed, and the assistant was disabled."
|
||||
},
|
||||
"saved": {
|
||||
"title": "Assistant settings saved",
|
||||
"message": "The new model and availability settings are active."
|
||||
|
||||
Reference in new issue
Block a user