diff --git a/src/renderer/components/composer/tools/components/AttachmentButton.tsx b/src/renderer/components/composer/tools/components/AttachmentButton.tsx index 432b707b05d..d5b7089173d 100644 --- a/src/renderer/components/composer/tools/components/AttachmentButton.tsx +++ b/src/renderer/components/composer/tools/components/AttachmentButton.tsx @@ -19,44 +19,48 @@ const useAttachmentToolController = ({ launcher, couldAddImageFile, extensions, const { t } = useTranslation() const [selecting, setSelecting] = useState(false) - const openFileSelectDialog = useCallback(async () => { - if (selecting) { - return - } - // when the number of extensions is greater than 20, use *.* to avoid selecting window lag - const useAllFiles = extensions.length > 20 - - setSelecting(true) - const _files = await window.api.file.select({ - properties: ['openFile', 'multiSelections'], - filters: [ - { - name: 'Files', - extensions: useAllFiles ? ['*'] : extensions.map((i) => i.replace('.', '')) - } - ] - }) - setSelecting(false) - - if (_files) { - if (!useAllFiles) { - setFiles((currentFiles) => [...currentFiles, ...toComposerAttachments(_files)]) + const openFileSelectDialog = useCallback( + async (restoreFocus?: () => void) => { + if (selecting) { return } - const supportedFiles = await filterSupportedFiles(_files, extensions) - if (supportedFiles.length > 0) { - setFiles((currentFiles) => [...currentFiles, ...toComposerAttachments(supportedFiles)]) - } + // when the number of extensions is greater than 20, use *.* to avoid selecting window lag + const useAllFiles = extensions.length > 20 + + setSelecting(true) + const _files = await window.api.file.select({ + properties: ['openFile', 'multiSelections'], + filters: [ + { + name: 'Files', + extensions: useAllFiles ? ['*'] : extensions.map((i) => i.replace('.', '')) + } + ] + }) + setSelecting(false) + window.requestAnimationFrame(() => restoreFocus?.()) - if (supportedFiles.length !== _files.length) { - window.toast.info( - t('chat.input.file_not_supported_count', { - count: _files.length - supportedFiles.length - }) - ) + if (_files) { + if (!useAllFiles) { + setFiles((currentFiles) => [...currentFiles, ...toComposerAttachments(_files)]) + return + } + const supportedFiles = await filterSupportedFiles(_files, extensions) + if (supportedFiles.length > 0) { + setFiles((currentFiles) => [...currentFiles, ...toComposerAttachments(supportedFiles)]) + } + + if (supportedFiles.length !== _files.length) { + window.toast.info( + t('chat.input.file_not_supported_count', { + count: _files.length - supportedFiles.length + }) + ) + } } - } - }, [extensions, selecting, setFiles, t]) + }, + [extensions, selecting, setFiles, t] + ) useEffect(() => { const isDocumentOnly = !couldAddImageFile @@ -72,8 +76,8 @@ const useAttachmentToolController = ({ launcher, couldAddImageFile, extensions, icon: , suffix: isDocumentOnly ? t('chat.input.upload.document_only') : undefined, disabled, - action: () => { - void openFileSelectDialog() + action: ({ inputAdapter }) => { + void openFileSelectDialog(inputAdapter?.focus) } } ]) diff --git a/src/renderer/components/composer/tools/components/QuickPhrasesButton.tsx b/src/renderer/components/composer/tools/components/QuickPhrasesButton.tsx index 8b4761f98d6..0ab05c97656 100644 --- a/src/renderer/components/composer/tools/components/QuickPhrasesButton.tsx +++ b/src/renderer/components/composer/tools/components/QuickPhrasesButton.tsx @@ -28,6 +28,7 @@ const logger = loggerService.withContext('QuickPhrasesButton') const useQuickPhrasesToolController = ({ launcher, setInputValue }: Props) => { const [isAddModalOpen, setIsAddModalOpen] = useState(false) const [isManageModalOpen, setIsManageModalOpen] = useState(false) + const restoreInputFocusRef = useRef<(() => void) | null>(null) const { t } = useTranslation() const { isVisible: isQuickPanelVisible, @@ -76,6 +77,13 @@ const useQuickPhrasesToolController = ({ launcher, setInputValue }: Props) => { [insertText] ) + const restoreInputFocus = useCallback(() => { + window.requestAnimationFrame(() => { + restoreInputFocusRef.current?.() + restoreInputFocusRef.current = null + }) + }, []) + const handleAddModalSave = useCallback( async (data: { title: string; content: string }) => { try { @@ -86,11 +94,37 @@ const useQuickPhrasesToolController = ({ launcher, setInputValue }: Props) => { } }) setIsAddModalOpen(false) + restoreInputFocus() } catch { // handled by useMutation onError } }, - [createPrompt] + [createPrompt, restoreInputFocus] + ) + + const openAddModal = useCallback((options?: QuickPanelCallBackOptions) => { + restoreInputFocusRef.current = options?.inputAdapter?.focus ?? null + setIsAddModalOpen(true) + }, []) + + const closeAddModal = useCallback(() => { + setIsAddModalOpen(false) + restoreInputFocus() + }, [restoreInputFocus]) + + const openManageModal = useCallback((options?: QuickPanelCallBackOptions) => { + restoreInputFocusRef.current = options?.inputAdapter?.focus ?? null + setIsManageModalOpen(true) + }, []) + + const handleManageModalOpenChange = useCallback( + (open: boolean) => { + setIsManageModalOpen(open) + if (!open) { + restoreInputFocus() + } + }, + [restoreInputFocus] ) const phraseItems = useMemo(() => { @@ -122,17 +156,17 @@ const useQuickPhrasesToolController = ({ launcher, setInputValue }: Props) => { newList.push({ label: t('settings.prompts.manage'), icon: , - action: () => setIsManageModalOpen(true) + action: openManageModal }) newList.push({ label: t('settings.prompts.add') + '...', icon: , - action: () => setIsAddModalOpen(true) + action: openAddModal }) return newList - }, [handleItemSelect, isPromptsLoading, promptItems, promptsError, t]) + }, [handleItemSelect, isPromptsLoading, openAddModal, openManageModal, promptItems, promptsError, t]) const quickPanelOpenOptions = useMemo( () => ({ @@ -193,8 +227,8 @@ const useQuickPhrasesToolController = ({ launcher, setInputValue }: Props) => { isAddModalOpen, isCreatingPrompt, isManageModalOpen, - setIsManageModalOpen, - setIsAddModalOpen + closeAddModal, + handleManageModalOpenChange } } @@ -203,25 +237,25 @@ const QuickPhrasesModal = ({ isAddModalOpen, isCreatingPrompt, isManageModalOpen, - setIsManageModalOpen, - setIsAddModalOpen + closeAddModal, + handleManageModalOpenChange }: Pick< ReturnType, | 'handleAddModalSave' | 'isAddModalOpen' | 'isCreatingPrompt' | 'isManageModalOpen' - | 'setIsAddModalOpen' - | 'setIsManageModalOpen' + | 'closeAddModal' + | 'handleManageModalOpenChange' >) => ( <> setIsAddModalOpen(false)} + onCancel={closeAddModal} /> - + ) diff --git a/src/renderer/components/composer/tools/components/WebSearchButton.tsx b/src/renderer/components/composer/tools/components/WebSearchButton.tsx index fb488949810..8bfb7a71c34 100644 --- a/src/renderer/components/composer/tools/components/WebSearchButton.tsx +++ b/src/renderer/components/composer/tools/components/WebSearchButton.tsx @@ -12,7 +12,7 @@ import { isGemini3Model, isGeminiModel, isGPT5SeriesReasoningModel, isOpenAIWebS import { isGeminiWebSearchProvider } from '@shared/utils/provider' import { useNavigate } from '@tanstack/react-router' import { Globe } from 'lucide-react' -import type { FC } from 'react' +import type { FC, MouseEventHandler } from 'react' import { memo, useCallback, useEffect, useMemo } from 'react' import { useTranslation } from 'react-i18next' @@ -76,44 +76,59 @@ const useWebSearchToolController = ({ assistantId, launcher }: Props) => { : undefined const isDisabled = Boolean(disabledReason) - const onClick = useCallback(() => { - if (!assistant || !model) { - window.toast.error(t('error.model.not_exists')) - return - } - if (enableWebSearch) { - void updateAssistant({ settings: { enableWebSearch: false } }) - return - } - - // Built-in web search bypasses the external-provider requirement; the - // toggle simply flips the assistant flag and the model handles search. - if (!hasBuiltinWebSearch && !activeProviderId) { - window.modal.confirm({ - centered: true, - title: t('settings.tool.websearch.search_provider'), - content: t('settings.tool.websearch.search_provider_placeholder'), - onOk: () => navigate({ to: '/settings/websearch' }) - }) - return - } - - if (disabledReason) { - return - } - - void updateAssistant({ settings: { enableWebSearch: true } }) - }, [ - activeProviderId, - assistant, - disabledReason, - enableWebSearch, - hasBuiltinWebSearch, - navigate, - t, - updateAssistant, - model - ]) + const onClick = useCallback( + (restoreFocus?: () => void) => { + if (!assistant || !model) { + window.toast.error(t('error.model.not_exists')) + return + } + if (enableWebSearch) { + void updateAssistant({ settings: { enableWebSearch: false } }) + return + } + + // Built-in web search bypasses the external-provider requirement; the + // toggle simply flips the assistant flag and the model handles search. + if (!hasBuiltinWebSearch && !activeProviderId) { + let skipFocusRestore = false + + window.modal.confirm({ + centered: true, + title: t('settings.tool.websearch.search_provider'), + content: t('settings.tool.websearch.search_provider_placeholder'), + afterClose: restoreFocus + ? () => { + if (!skipFocusRestore) { + window.requestAnimationFrame(restoreFocus) + } + } + : undefined, + onOk: () => { + skipFocusRestore = true + return navigate({ to: '/settings/websearch' }) + } + }) + return + } + + if (disabledReason) { + return + } + + void updateAssistant({ settings: { enableWebSearch: true } }) + }, + [ + activeProviderId, + assistant, + disabledReason, + enableWebSearch, + hasBuiltinWebSearch, + navigate, + t, + updateAssistant, + model + ] + ) const ariaLabel = enableWebSearch ? t('common.close') : t('chat.input.web_search.label') const tooltipTitle = disabledReason ?? ariaLabel @@ -134,7 +149,7 @@ const useWebSearchToolController = ({ assistantId, launcher }: Props) => { active: enableWebSearch, disabled: isDisabled, disabledReason, - action: onClick + action: ({ inputAdapter }) => onClick(inputAdapter?.focus) } ]) }, [disabledReason, enableWebSearch, icon, isDisabled, launcher, onClick, t]) @@ -149,11 +164,18 @@ export const WebSearchToolRuntime: FC = (props) => { const WebSearchButton: FC = (props) => { const { ariaLabel, enableWebSearch, icon, isDisabled, onClick, tooltipTitle } = useWebSearchToolController(props) + const handleClick = useCallback>( + (event) => { + const trigger = event.currentTarget + onClick(() => trigger.focus()) + }, + [onClick] + ) return ( ({ const createLauncherApi = (): ToolLauncherApi => ({ registerLaunchers: vi.fn(() => vi.fn()) }) - +import { installSyncRafMock } from '../../../../../../../tests/__mocks__/requestAnimationFrame' +let restoreRequestAnimationFrame: (() => void) | undefined const selectedFile: FileMetadata = { id: 'selected', path: '/tmp/report.txt', @@ -54,6 +55,12 @@ describe('AttachmentToolRuntime', () => { select: vi.fn(async () => [selectedFile]) } } as typeof window.api + restoreRequestAnimationFrame = installSyncRafMock() + }) + + afterEach(() => { + restoreRequestAnimationFrame?.() + restoreRequestAnimationFrame = undefined }) it('keeps document-only support as a suffix and tooltip instead of a long label', async () => { @@ -164,4 +171,33 @@ describe('AttachmentToolRuntime', () => { expect.objectContaining({ path: selectedFile.path, name: selectedFile.name }) ]) }) + + it('restores composer focus after the file picker closes', async () => { + const launcher = createLauncherApi() + const inputAdapter = { + getText: vi.fn(() => ''), + insertText: vi.fn(), + deleteTriggerRange: vi.fn(), + focus: vi.fn() + } + + render( + + ) + + await waitFor(() => expect(launcher.registerLaunchers).toHaveBeenCalled()) + + const [attachmentLauncher] = vi.mocked(launcher.registerLaunchers).mock.calls[0][0] + await act(async () => { + attachmentLauncher.action?.({ inputAdapter, source: 'popover', quickPanel: {} as never }) + }) + + expect(inputAdapter.focus).toHaveBeenCalledTimes(1) + }) }) diff --git a/src/renderer/components/composer/tools/components/__tests__/QuickPhrasesButton.test.tsx b/src/renderer/components/composer/tools/components/__tests__/QuickPhrasesButton.test.tsx index 0fb2d03902a..4fc087a7c2c 100644 --- a/src/renderer/components/composer/tools/components/__tests__/QuickPhrasesButton.test.tsx +++ b/src/renderer/components/composer/tools/components/__tests__/QuickPhrasesButton.test.tsx @@ -1,6 +1,6 @@ import type { ToolLauncherApi } from '@renderer/components/composer/tools/types' import { act, render, screen, waitFor } from '@testing-library/react' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { QuickPhrasesToolRuntime } from '../QuickPhrasesButton' @@ -27,11 +27,24 @@ vi.mock('@logger', () => ({ })) vi.mock('@renderer/components/resourceCatalog/dialogs/edit', () => ({ - PromptEditDialog: ({ open }: { open: boolean }) => (open ?
: null) + PromptEditDialog: ({ open, onCancel }: { open: boolean; onCancel: () => void }) => + open ? ( +
+ +
+ ) : null })) vi.mock('@renderer/components/resourceCatalog/dialogs/manage', () => ({ - PromptManagementDialog: ({ open }: { open: boolean }) => - open ?
: null + PromptManagementDialog: ({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) => + open ? ( +
+ +
+ ) : null })) vi.mock('@renderer/components/QuickPanel', () => ({ @@ -69,7 +82,8 @@ vi.mock('react-i18next', () => ({ const createLauncherApi = (): ToolLauncherApi => ({ registerLaunchers: vi.fn(() => vi.fn()) }) - +import { installSyncRafMock } from '../../../../../../../tests/__mocks__/requestAnimationFrame' +let restoreRequestAnimationFrame: (() => void) | undefined describe('QuickPhrasesToolRuntime', () => { beforeEach(() => { vi.clearAllMocks() @@ -84,6 +98,12 @@ describe('QuickPhrasesToolRuntime', () => { isLoading: false }) mocks.setTimeoutTimer.mockImplementation((_key: string, callback: () => void) => callback()) + restoreRequestAnimationFrame = installSyncRafMock() + }) + + afterEach(() => { + restoreRequestAnimationFrame?.() + restoreRequestAnimationFrame = undefined }) it('opens the quick phrases panel directly from the slash root without closing first', async () => { @@ -158,4 +178,64 @@ describe('QuickPhrasesToolRuntime', () => { expect(await screen.findByTestId('prompt-management-dialog')).toBeInTheDocument() expect(screen.queryByTestId('prompt-edit-dialog')).not.toBeInTheDocument() }) + + it('restores composer focus after closing the add prompt dialog opened from quick panel', async () => { + const launcher = createLauncherApi() + const inputAdapter = { focus: vi.fn() } + + render() + + await waitFor(() => expect(launcher.registerLaunchers).toHaveBeenCalled()) + + const [quickPhrasesLauncher] = vi.mocked(launcher.registerLaunchers).mock.calls[0][0] + quickPhrasesLauncher.action?.({ + parentPanel: { list: [], symbol: '/' }, + queryAnchor: 0, + quickPanel: {} as never, + source: 'root-panel', + triggerInfo: { type: 'button' } + }) + + const panelOptions = mocks.quickPanelOpen.mock.calls[0][0] + const addItem = panelOptions.list.find((item: { label: string }) => item.label === 'settings.prompts.add...') + + act(() => { + addItem.action({ inputAdapter } as never) + }) + act(() => { + screen.getByText('close prompt edit').click() + }) + + expect(inputAdapter.focus).toHaveBeenCalledTimes(1) + }) + + it('restores composer focus after closing the prompt management dialog opened from quick panel', async () => { + const launcher = createLauncherApi() + const inputAdapter = { focus: vi.fn() } + + render() + + await waitFor(() => expect(launcher.registerLaunchers).toHaveBeenCalled()) + + const [quickPhrasesLauncher] = vi.mocked(launcher.registerLaunchers).mock.calls[0][0] + quickPhrasesLauncher.action?.({ + parentPanel: { list: [], symbol: '/' }, + queryAnchor: 0, + quickPanel: {} as never, + source: 'root-panel', + triggerInfo: { type: 'button' } + }) + + const panelOptions = mocks.quickPanelOpen.mock.calls[0][0] + const manageItem = panelOptions.list.find((item: { label: string }) => item.label === 'settings.prompts.manage') + + act(() => { + manageItem.action({ inputAdapter } as never) + }) + act(() => { + screen.getByText('close prompt management').click() + }) + + expect(inputAdapter.focus).toHaveBeenCalledTimes(1) + }) }) diff --git a/src/renderer/components/composer/tools/components/__tests__/WebSearchButton.test.tsx b/src/renderer/components/composer/tools/components/__tests__/WebSearchButton.test.tsx index 3ab2f24f392..d1c210d58c8 100644 --- a/src/renderer/components/composer/tools/components/__tests__/WebSearchButton.test.tsx +++ b/src/renderer/components/composer/tools/components/__tests__/WebSearchButton.test.tsx @@ -3,9 +3,10 @@ import '@testing-library/jest-dom/vitest' import type { ToolLauncherApi } from '@renderer/components/composer/tools/types' import { type Model, MODEL_CAPABILITY } from '@shared/data/types/model' import { MockUsePreferenceUtils } from '@test-mocks/renderer/usePreference' +import { installSyncRafMock } from '@test-mocks/requestAnimationFrame' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import type * as ReactI18next from 'react-i18next' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import WebSearchButton from '../WebSearchButton' @@ -21,7 +22,7 @@ const mocks = vi.hoisted(() => ({ const launcherApi: ToolLauncherApi = { registerLaunchers: vi.fn(() => vi.fn()) } - +let restoreRequestAnimationFrame: (() => void) | undefined vi.mock('react-i18next', async (importOriginal) => { const actual = await importOriginal() @@ -154,12 +155,19 @@ describe('WebSearchButton', () => { warning: mocks.toastWarning } }) + restoreRequestAnimationFrame = installSyncRafMock() + }) + + afterEach(() => { + restoreRequestAnimationFrame?.() + restoreRequestAnimationFrame = undefined }) - it('opens web search settings and does not update the assistant when external providers are missing', () => { + it('opens web search settings and restores trigger focus when external providers are missing', () => { render() - fireEvent.click(screen.getByRole('button', { name: 'chat.input.web_search.label' })) + const button = screen.getByRole('button', { name: 'chat.input.web_search.label' }) + fireEvent.click(button) expect(mocks.confirm).toHaveBeenCalledWith( expect.objectContaining({ @@ -167,9 +175,27 @@ describe('WebSearchButton', () => { content: 'settings.tool.websearch.search_provider_placeholder' }) ) + const confirmOptions = mocks.confirm.mock.calls[0][0] + confirmOptions.afterClose() + + expect(button).toHaveFocus() expect(mocks.updateAssistant).not.toHaveBeenCalled() }) + it('does not restore trigger focus after confirming the missing-provider navigation', async () => { + render() + + const button = screen.getByRole('button', { name: 'chat.input.web_search.label' }) + fireEvent.click(button) + + const confirmOptions = mocks.confirm.mock.calls[0][0] + await confirmOptions.onOk() + confirmOptions.afterClose() + + expect(mocks.navigate).toHaveBeenCalledWith({ to: '/settings/websearch' }) + expect(button).not.toHaveFocus() + }) + it('disables web search when the configured provider cannot be consumed by the current model', async () => { MockUsePreferenceUtils.setPreferenceValue('chat.web_search.default_search_keywords_provider', 'exa-mcp') @@ -216,4 +242,29 @@ describe('WebSearchButton', () => { sources: ['popover'] }) }) + + it('restores composer focus after the missing-provider confirmation closes from the tool menu', async () => { + const inputAdapter = { + getText: vi.fn(() => ''), + insertText: vi.fn(), + deleteTriggerRange: vi.fn(), + focus: vi.fn() + } + + render() + + await waitFor(() => expect(launcherApi.registerLaunchers).toHaveBeenCalled()) + const [webSearchLauncher] = vi.mocked(launcherApi.registerLaunchers).mock.calls[0][0] + + webSearchLauncher.action?.({ + inputAdapter, + quickPanel: {} as never, + source: 'popover' + }) + + const confirmOptions = mocks.confirm.mock.calls[0][0] + confirmOptions.afterClose() + + expect(inputAdapter.focus).toHaveBeenCalledTimes(1) + }) }) diff --git a/src/renderer/components/composer/variants/AgentComposer.tsx b/src/renderer/components/composer/variants/AgentComposer.tsx index 1edbe0a8338..fae5e2b3eeb 100644 --- a/src/renderer/components/composer/variants/AgentComposer.tsx +++ b/src/renderer/components/composer/variants/AgentComposer.tsx @@ -285,6 +285,7 @@ interface AgentComposerContextControlsProps { iconOnly?: boolean showAgentTrigger?: boolean agentTriggerMode?: 'selector' | 'edit' + onDialogCloseAutoFocus?: () => void onAgentChange: (agentId: string | null) => void | Promise } @@ -319,6 +320,7 @@ const AgentComposerContextControls = ({ iconOnly = false, showAgentTrigger = true, agentTriggerMode = 'selector', + onDialogCloseAutoFocus, onAgentChange }: AgentComposerContextControlsProps) => { const baseTriggerClassName = side === 'bottom' ? COMPOSER_BELOW_SELECTOR_BUTTON_CLASS : COMPOSER_SELECTOR_BUTTON_CLASS @@ -369,7 +371,10 @@ const AgentComposerContextControls = ({ { - if (!open) setAgentEditDialogTarget(null) + if (!open) { + setAgentEditDialogTarget(null) + onDialogCloseAutoFocus?.() + } }} /> @@ -383,6 +388,7 @@ const AgentComposerContextControls = ({ side={side} align="start" mountStrategy="lazy-keep" + onDialogCloseAutoFocus={onDialogCloseAutoFocus} trigger={agentTrigger} /> )} @@ -558,6 +564,21 @@ type AgentComposerControlSlots = Pick AgentComposerControlSlots +type AgentComposerInputAdapter = Parameters>[0] + +const restoreAgentComposerInputFocus = (inputAdapter: AgentComposerInputAdapter) => { + window.requestAnimationFrame(() => inputAdapter?.focus()) +} + +const AgentComposerContextControlsWithAutoFocus = ({ + inputAdapter, + ...props +}: AgentComposerContextControlsProps & { inputAdapter: AgentComposerInputAdapter }) => { + const onDialogCloseAutoFocus = useCallback(() => restoreAgentComposerInputFocus(inputAdapter), [inputAdapter]) + + return +} + // Active agent sessions are bound to their agent, so the agent trigger opens edit instead of switching. const renderAgentToolbarControls: AgentComposerControlsRenderer = (props) => { return { @@ -568,7 +589,13 @@ const renderAgentToolbarControls: AgentComposerControlsRenderer = (props) => { toolMenuPlacement="beforeContext" renderContextControls={({ side, iconOnly }) => ( <> - + )} @@ -584,11 +611,16 @@ const renderAgentHomeControls: AgentComposerControlsRenderer = (props) => {
), - renderBelowControls: () => ( + renderBelowControls: (inputAdapter) => ( ( <> - + )} diff --git a/src/renderer/components/composer/variants/ChatComposer.tsx b/src/renderer/components/composer/variants/ChatComposer.tsx index 76ace1153be..9b10aefb25e 100644 --- a/src/renderer/components/composer/variants/ChatComposer.tsx +++ b/src/renderer/components/composer/variants/ChatComposer.tsx @@ -129,6 +129,7 @@ interface ChatComposerContextControlsProps { side: 'top' | 'bottom' iconOnly?: boolean showAssistantTrigger?: boolean + onDialogCloseAutoFocus?: () => void onAssistantChange: (assistantId: string | null) => void | Promise onModelSelect: (model: Model | undefined) => void onMentionedModelsSelect: (models: Model[]) => void @@ -154,6 +155,7 @@ const ChatComposerContextControls = ({ side, iconOnly = false, showAssistantTrigger = true, + onDialogCloseAutoFocus, onAssistantChange, onModelSelect, onMentionedModelsSelect, @@ -213,6 +215,7 @@ const ChatComposerContextControls = ({ side={side} align="start" mountStrategy="lazy-keep" + onDialogCloseAutoFocus={onDialogCloseAutoFocus} trigger={assistantTrigger} /> ) : null} @@ -283,9 +286,23 @@ const ChatComposerContextControls = ({ type ChatComposerControlProps = Omit type ComposerSurfaceProps = React.ComponentProps +type ComposerInputAdapter = Parameters>[0] type ChatComposerControlSlots = Pick type ChatComposerControlsRenderer = (props: ChatComposerControlProps) => ChatComposerControlSlots +const restoreComposerInputFocus = (inputAdapter: ComposerInputAdapter) => { + window.requestAnimationFrame(() => inputAdapter?.focus()) +} + +const ChatComposerContextControlsWithAutoFocus = ({ + inputAdapter, + ...props +}: ChatComposerControlProps & { side: 'top' | 'bottom'; iconOnly?: boolean; inputAdapter: ComposerInputAdapter }) => { + const onDialogCloseAutoFocus = useCallback(() => restoreComposerInputFocus(inputAdapter), [inputAdapter]) + + return +} + const renderChatToolbarControls: ChatComposerControlsRenderer = (props) => ({ renderLeftControls: (inputAdapter, unifiedPanelControl) => ( ({ unifiedPanelControl={unifiedPanelControl} toolMenuPlacement="beforeContext" renderContextControls={({ side, iconOnly }) => ( - + )} /> ) @@ -305,11 +327,17 @@ const renderChatHomeControls: ChatComposerControlsRenderer = (props) => ({
), - renderBelowControls: () => ( + renderBelowControls: (inputAdapter) => ( ( // Draft/home always picks the assistant via the switcher, regardless of view mode. - + )} /> ) diff --git a/src/renderer/components/composer/variants/__tests__/AgentComposer.test.tsx b/src/renderer/components/composer/variants/__tests__/AgentComposer.test.tsx index 3bc20c49de5..a9d94b7c879 100644 --- a/src/renderer/components/composer/variants/__tests__/AgentComposer.test.tsx +++ b/src/renderer/components/composer/variants/__tests__/AgentComposer.test.tsx @@ -8,6 +8,7 @@ import { type ReactNode, useEffect } from 'react' import type * as ReactI18nextModule from 'react-i18next' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { installSyncRafMock } from '../../../../../../tests/__mocks__/requestAnimationFrame' import type { ComposerSurfaceProps } from '../../ComposerSurface' import type { ComposerSerializedToken } from '../../tokens' import AgentComposer, { AgentHomeComposer, MissingAgentHomeComposer } from '../AgentComposer' @@ -31,6 +32,7 @@ const mocks = vi.hoisted(() => ({ updateModel: vi.fn(), updateSession: vi.fn(), setFiles: vi.fn(), + inputAdapterFocus: vi.fn(), reconcileTokens: vi.fn(), insertToken: vi.fn(), availableSkills: [] as LocalSkill[], @@ -52,7 +54,7 @@ const mocks = vi.hoisted(() => ({ })) const originalResizeObserver = globalThis.ResizeObserver - +let restoreRequestAnimationFrame: (() => void) | undefined interface ResizeObserverMockInstance { callback: ResizeObserverCallback target?: Element @@ -129,10 +131,17 @@ vi.mock('@renderer/components/composer/ComposerSurface', () => { }, [props]) mocks.surfaceProps = props + const inputAdapter = { + focus: mocks.inputAdapterFocus, + getText: () => props.text, + insertText: vi.fn(), + insertToken: mocks.insertToken, + deleteTriggerRange: vi.fn() + } return (
-
{props.renderLeftControls?.(undefined)}
-
{props.renderBelowControls?.(undefined)}
+
{props.renderLeftControls?.(inputAdapter)}
+
{props.renderBelowControls?.(inputAdapter)}
{props.sendAccessory}
} + value={null} + onChange={vi.fn()} + onDialogCloseAutoFocus={onDialogCloseAutoFocus} + /> + ) + await openCreateDialog() + + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + + expect(onDialogCloseAutoFocus).toHaveBeenCalledTimes(1) + }) + it('creates an agent, refreshes, reopens the selector, and does not auto-select by default', async () => { const { onChange } = renderSelector() await openCreateDialog() @@ -547,6 +564,46 @@ describe('AgentSelector', () => { expect(screen.queryByPlaceholderText('Search agents')).not.toBeInTheDocument() }) + it('calls the dialog-close autofocus callback when the edit dialog closes', async () => { + const onDialogCloseAutoFocus = vi.fn() + render( + Open} + value={null} + onChange={vi.fn()} + onDialogCloseAutoFocus={onDialogCloseAutoFocus} + /> + ) + openPopover() + + fireEvent.click(screen.getAllByRole('button', { name: 'Edit agent' })[0]) + expect(await screen.findByRole('heading', { name: 'Edit Agent' }, { timeout: 5000 })).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + + expect(onDialogCloseAutoFocus).toHaveBeenCalledTimes(1) + }) + it('calls the dialog-close autofocus callback once when saving the edit dialog', async () => { + const onDialogCloseAutoFocus = vi.fn() + render( + Open} + value={null} + onChange={vi.fn()} + onDialogCloseAutoFocus={onDialogCloseAutoFocus} + /> + ) + openPopover() + + fireEvent.click(screen.getAllByRole('button', { name: 'Edit agent' })[0]) + expect(await screen.findByRole('heading', { name: 'Edit Agent' }, { timeout: 5000 })).toBeInTheDocument() + fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Saved Agent' } }) + fireEvent.click(screen.getByRole('button', { name: 'Save' })) + + await waitFor(() => expect(updateAgentMock).toHaveBeenCalled()) + await waitFor(() => expect(refetchAgentsMock).toHaveBeenCalledTimes(1)) + expect(onDialogCloseAutoFocus).toHaveBeenCalledTimes(1) + }) + it('does not show the empty state while the agents query is loading', () => { useQueryMock.mockReturnValue({ data: undefined, diff --git a/src/renderer/components/resourceCatalog/selectors/__tests__/AssistantSelector.test.tsx b/src/renderer/components/resourceCatalog/selectors/__tests__/AssistantSelector.test.tsx index ff6205c11d9..06204ac473f 100644 --- a/src/renderer/components/resourceCatalog/selectors/__tests__/AssistantSelector.test.tsx +++ b/src/renderer/components/resourceCatalog/selectors/__tests__/AssistantSelector.test.tsx @@ -367,6 +367,24 @@ describe('AssistantSelector', () => { expect(screen.getByPlaceholderText('Describe this resource')).toBeInTheDocument() }) + it('calls the dialog-close autofocus callback when the create dialog closes', async () => { + const onDialogCloseAutoFocus = vi.fn() + render( + Open} + multi={false} + value={null} + onChange={vi.fn()} + onDialogCloseAutoFocus={onDialogCloseAutoFocus} + /> + ) + await openCreateDialog() + + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + + expect(onDialogCloseAutoFocus).toHaveBeenCalledTimes(1) + }) + it('creates an assistant, refreshes, reopens the selector, and does not auto-select by default', async () => { const onChange = vi.fn() render( @@ -439,6 +457,48 @@ describe('AssistantSelector', () => { expect(screen.queryByPlaceholderText('Search assistants')).not.toBeInTheDocument() }) + it('calls the dialog-close autofocus callback when the edit dialog closes', async () => { + const onDialogCloseAutoFocus = vi.fn() + render( + Open} + multi={false} + value={null} + onChange={vi.fn()} + onDialogCloseAutoFocus={onDialogCloseAutoFocus} + /> + ) + openPopover() + + fireEvent.click(screen.getAllByRole('button', { name: 'Edit assistant' })[0]) + expect(await screen.findByRole('heading', { name: 'Edit Assistant' }, { timeout: 5000 })).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + + expect(onDialogCloseAutoFocus).toHaveBeenCalledTimes(1) + }) + it('calls the dialog-close autofocus callback once when saving the edit dialog', async () => { + const onDialogCloseAutoFocus = vi.fn() + render( + Open} + multi={false} + value={null} + onChange={vi.fn()} + onDialogCloseAutoFocus={onDialogCloseAutoFocus} + /> + ) + openPopover() + + fireEvent.click(screen.getAllByRole('button', { name: 'Edit assistant' })[0]) + expect(await screen.findByRole('heading', { name: 'Edit Assistant' }, { timeout: 5000 })).toBeInTheDocument() + fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Saved Assistant' } }) + fireEvent.click(screen.getByRole('button', { name: 'Save' })) + + await waitFor(() => expect(updateAssistantMock).toHaveBeenCalled()) + await waitFor(() => expect(refetchAssistantsMock).toHaveBeenCalledTimes(1)) + expect(onDialogCloseAutoFocus).toHaveBeenCalledTimes(1) + }) + it('notifies when created assistant cannot be refreshed into the selector', async () => { refetchAssistantsMock.mockRejectedValueOnce(new Error('Refresh failed')) renderSelector() diff --git a/tests/__mocks__/requestAnimationFrame.ts b/tests/__mocks__/requestAnimationFrame.ts new file mode 100644 index 00000000000..7d363be090e --- /dev/null +++ b/tests/__mocks__/requestAnimationFrame.ts @@ -0,0 +1,12 @@ +export function installSyncRafMock() { + const originalRequestAnimationFrame = window.requestAnimationFrame + + window.requestAnimationFrame = ((callback: FrameRequestCallback) => { + callback(0) + return 0 + }) as typeof window.requestAnimationFrame + + return () => { + window.requestAnimationFrame = originalRequestAnimationFrame + } +}