fix assistant focus and settings

This commit is contained in:
ajnart committed 2026-08-14 11:32:11 +02:00
1 parent b7fdff9f7f
commit fc5ba7d888
6 files changed
+145 -74

No files matched your search

@@ -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}