Skip to content

Commit cbac630

Browse files
committed
fix(composer): restore focus after dialog close
1 parent 70909ed commit cbac630

15 files changed

Lines changed: 615 additions & 131 deletions

src/renderer/components/composer/tools/components/AttachmentButton.tsx

Lines changed: 40 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -19,44 +19,48 @@ const useAttachmentToolController = ({ launcher, couldAddImageFile, extensions,
1919
const { t } = useTranslation()
2020
const [selecting, setSelecting] = useState<boolean>(false)
2121

22-
const openFileSelectDialog = useCallback(async () => {
23-
if (selecting) {
24-
return
25-
}
26-
// when the number of extensions is greater than 20, use *.* to avoid selecting window lag
27-
const useAllFiles = extensions.length > 20
28-
29-
setSelecting(true)
30-
const _files = await window.api.file.select({
31-
properties: ['openFile', 'multiSelections'],
32-
filters: [
33-
{
34-
name: 'Files',
35-
extensions: useAllFiles ? ['*'] : extensions.map((i) => i.replace('.', ''))
36-
}
37-
]
38-
})
39-
setSelecting(false)
40-
41-
if (_files) {
42-
if (!useAllFiles) {
43-
setFiles((currentFiles) => [...currentFiles, ...toComposerAttachments(_files)])
22+
const openFileSelectDialog = useCallback(
23+
async (restoreFocus?: () => void) => {
24+
if (selecting) {
4425
return
4526
}
46-
const supportedFiles = await filterSupportedFiles(_files, extensions)
47-
if (supportedFiles.length > 0) {
48-
setFiles((currentFiles) => [...currentFiles, ...toComposerAttachments(supportedFiles)])
49-
}
27+
// when the number of extensions is greater than 20, use *.* to avoid selecting window lag
28+
const useAllFiles = extensions.length > 20
29+
30+
setSelecting(true)
31+
const _files = await window.api.file.select({
32+
properties: ['openFile', 'multiSelections'],
33+
filters: [
34+
{
35+
name: 'Files',
36+
extensions: useAllFiles ? ['*'] : extensions.map((i) => i.replace('.', ''))
37+
}
38+
]
39+
})
40+
setSelecting(false)
41+
window.requestAnimationFrame(() => restoreFocus?.())
5042

51-
if (supportedFiles.length !== _files.length) {
52-
window.toast.info(
53-
t('chat.input.file_not_supported_count', {
54-
count: _files.length - supportedFiles.length
55-
})
56-
)
43+
if (_files) {
44+
if (!useAllFiles) {
45+
setFiles((currentFiles) => [...currentFiles, ...toComposerAttachments(_files)])
46+
return
47+
}
48+
const supportedFiles = await filterSupportedFiles(_files, extensions)
49+
if (supportedFiles.length > 0) {
50+
setFiles((currentFiles) => [...currentFiles, ...toComposerAttachments(supportedFiles)])
51+
}
52+
53+
if (supportedFiles.length !== _files.length) {
54+
window.toast.info(
55+
t('chat.input.file_not_supported_count', {
56+
count: _files.length - supportedFiles.length
57+
})
58+
)
59+
}
5760
}
58-
}
59-
}, [extensions, selecting, setFiles, t])
61+
},
62+
[extensions, selecting, setFiles, t]
63+
)
6064

6165
useEffect(() => {
6266
const isDocumentOnly = !couldAddImageFile
@@ -72,8 +76,8 @@ const useAttachmentToolController = ({ launcher, couldAddImageFile, extensions,
7276
icon: <Paperclip />,
7377
suffix: isDocumentOnly ? t('chat.input.upload.document_only') : undefined,
7478
disabled,
75-
action: () => {
76-
void openFileSelectDialog()
79+
action: ({ inputAdapter }) => {
80+
void openFileSelectDialog(inputAdapter?.focus)
7781
}
7882
}
7983
])

src/renderer/components/composer/tools/components/QuickPhrasesButton.tsx

Lines changed: 46 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ const logger = loggerService.withContext('QuickPhrasesButton')
2727
const useQuickPhrasesToolController = ({ launcher, setInputValue }: Props) => {
2828
const [isAddModalOpen, setIsAddModalOpen] = useState(false)
2929
const [isManageModalOpen, setIsManageModalOpen] = useState(false)
30+
const restoreInputFocusRef = useRef<(() => void) | null>(null)
3031
const { t } = useTranslation()
3132
const {
3233
isVisible: isQuickPanelVisible,
@@ -75,6 +76,13 @@ const useQuickPhrasesToolController = ({ launcher, setInputValue }: Props) => {
7576
[insertText]
7677
)
7778

79+
const restoreInputFocus = useCallback(() => {
80+
window.requestAnimationFrame(() => {
81+
restoreInputFocusRef.current?.()
82+
restoreInputFocusRef.current = null
83+
})
84+
}, [])
85+
7886
const handleAddModalSave = useCallback(
7987
async (data: { title: string; content: string }) => {
8088
try {
@@ -85,11 +93,37 @@ const useQuickPhrasesToolController = ({ launcher, setInputValue }: Props) => {
8593
}
8694
})
8795
setIsAddModalOpen(false)
96+
restoreInputFocus()
8897
} catch {
8998
// handled by useMutation onError
9099
}
91100
},
92-
[createPrompt]
101+
[createPrompt, restoreInputFocus]
102+
)
103+
104+
const openAddModal = useCallback((options?: QuickPanelCallBackOptions) => {
105+
restoreInputFocusRef.current = options?.inputAdapter?.focus ?? null
106+
setIsAddModalOpen(true)
107+
}, [])
108+
109+
const closeAddModal = useCallback(() => {
110+
setIsAddModalOpen(false)
111+
restoreInputFocus()
112+
}, [restoreInputFocus])
113+
114+
const openManageModal = useCallback((options?: QuickPanelCallBackOptions) => {
115+
restoreInputFocusRef.current = options?.inputAdapter?.focus ?? null
116+
setIsManageModalOpen(true)
117+
}, [])
118+
119+
const handleManageModalOpenChange = useCallback(
120+
(open: boolean) => {
121+
setIsManageModalOpen(open)
122+
if (!open) {
123+
restoreInputFocus()
124+
}
125+
},
126+
[restoreInputFocus]
93127
)
94128

95129
const phraseItems = useMemo(() => {
@@ -121,17 +155,17 @@ const useQuickPhrasesToolController = ({ launcher, setInputValue }: Props) => {
121155
newList.push({
122156
label: t('settings.prompts.manage'),
123157
icon: <Pencil />,
124-
action: () => setIsManageModalOpen(true)
158+
action: openManageModal
125159
})
126160

127161
newList.push({
128162
label: t('settings.prompts.add') + '...',
129163
icon: <Plus />,
130-
action: () => setIsAddModalOpen(true)
164+
action: openAddModal
131165
})
132166

133167
return newList
134-
}, [handleItemSelect, isPromptsLoading, promptItems, promptsError, t])
168+
}, [handleItemSelect, isPromptsLoading, openAddModal, openManageModal, promptItems, promptsError, t])
135169

136170
const quickPanelOpenOptions = useMemo<QuickPanelOpenOptions>(
137171
() => ({
@@ -192,8 +226,8 @@ const useQuickPhrasesToolController = ({ launcher, setInputValue }: Props) => {
192226
isAddModalOpen,
193227
isCreatingPrompt,
194228
isManageModalOpen,
195-
setIsManageModalOpen,
196-
setIsAddModalOpen
229+
closeAddModal,
230+
handleManageModalOpenChange
197231
}
198232
}
199233

@@ -202,25 +236,25 @@ const QuickPhrasesModal = ({
202236
isAddModalOpen,
203237
isCreatingPrompt,
204238
isManageModalOpen,
205-
setIsManageModalOpen,
206-
setIsAddModalOpen
239+
closeAddModal,
240+
handleManageModalOpenChange
207241
}: Pick<
208242
ReturnType<typeof useQuickPhrasesToolController>,
209243
| 'handleAddModalSave'
210244
| 'isAddModalOpen'
211245
| 'isCreatingPrompt'
212246
| 'isManageModalOpen'
213-
| 'setIsAddModalOpen'
214-
| 'setIsManageModalOpen'
247+
| 'closeAddModal'
248+
| 'handleManageModalOpenChange'
215249
>) => (
216250
<>
217251
<PromptEditDialog
218252
open={isAddModalOpen}
219253
saving={isCreatingPrompt}
220254
onSave={handleAddModalSave}
221-
onCancel={() => setIsAddModalOpen(false)}
255+
onCancel={closeAddModal}
222256
/>
223-
<PromptManagementDialog open={isManageModalOpen} onOpenChange={setIsManageModalOpen} />
257+
<PromptManagementDialog open={isManageModalOpen} onOpenChange={handleManageModalOpenChange} />
224258
</>
225259
)
226260

src/renderer/components/composer/tools/components/WebSearchButton.tsx

Lines changed: 52 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { isGemini3Model, isGeminiModel, isGPT5SeriesReasoningModel, isOpenAIWebS
1212
import { isGeminiWebSearchProvider } from '@shared/utils/provider'
1313
import { useNavigate } from '@tanstack/react-router'
1414
import { Globe } from 'lucide-react'
15-
import type { FC } from 'react'
15+
import type { FC, MouseEventHandler } from 'react'
1616
import { memo, useCallback, useEffect, useMemo } from 'react'
1717
import { useTranslation } from 'react-i18next'
1818

@@ -76,44 +76,48 @@ const useWebSearchToolController = ({ assistantId, launcher }: Props) => {
7676
: undefined
7777
const isDisabled = Boolean(disabledReason)
7878

79-
const onClick = useCallback(() => {
80-
if (!assistant || !model) {
81-
window.toast.error(t('error.model.not_exists'))
82-
return
83-
}
84-
if (enableWebSearch) {
85-
void updateAssistant({ settings: { enableWebSearch: false } })
86-
return
87-
}
88-
89-
// Built-in web search bypasses the external-provider requirement; the
90-
// toggle simply flips the assistant flag and the model handles search.
91-
if (!hasBuiltinWebSearch && !activeProviderId) {
92-
window.modal.confirm({
93-
centered: true,
94-
title: t('settings.tool.websearch.search_provider'),
95-
content: t('settings.tool.websearch.search_provider_placeholder'),
96-
onOk: () => navigate({ to: '/settings/websearch' })
97-
})
98-
return
99-
}
100-
101-
if (disabledReason) {
102-
return
103-
}
104-
105-
void updateAssistant({ settings: { enableWebSearch: true } })
106-
}, [
107-
activeProviderId,
108-
assistant,
109-
disabledReason,
110-
enableWebSearch,
111-
hasBuiltinWebSearch,
112-
navigate,
113-
t,
114-
updateAssistant,
115-
model
116-
])
79+
const onClick = useCallback(
80+
(restoreFocus?: () => void) => {
81+
if (!assistant || !model) {
82+
window.toast.error(t('error.model.not_exists'))
83+
return
84+
}
85+
if (enableWebSearch) {
86+
void updateAssistant({ settings: { enableWebSearch: false } })
87+
return
88+
}
89+
90+
// Built-in web search bypasses the external-provider requirement; the
91+
// toggle simply flips the assistant flag and the model handles search.
92+
if (!hasBuiltinWebSearch && !activeProviderId) {
93+
window.modal.confirm({
94+
centered: true,
95+
title: t('settings.tool.websearch.search_provider'),
96+
content: t('settings.tool.websearch.search_provider_placeholder'),
97+
afterClose: restoreFocus ? () => window.requestAnimationFrame(restoreFocus) : undefined,
98+
onOk: () => navigate({ to: '/settings/websearch' })
99+
})
100+
return
101+
}
102+
103+
if (disabledReason) {
104+
return
105+
}
106+
107+
void updateAssistant({ settings: { enableWebSearch: true } })
108+
},
109+
[
110+
activeProviderId,
111+
assistant,
112+
disabledReason,
113+
enableWebSearch,
114+
hasBuiltinWebSearch,
115+
navigate,
116+
t,
117+
updateAssistant,
118+
model
119+
]
120+
)
117121

118122
const ariaLabel = enableWebSearch ? t('common.close') : t('chat.input.web_search.label')
119123
const tooltipTitle = disabledReason ?? ariaLabel
@@ -134,7 +138,7 @@ const useWebSearchToolController = ({ assistantId, launcher }: Props) => {
134138
active: enableWebSearch,
135139
disabled: isDisabled,
136140
disabledReason,
137-
action: onClick
141+
action: ({ inputAdapter }) => onClick(inputAdapter?.focus)
138142
}
139143
])
140144
}, [disabledReason, enableWebSearch, icon, isDisabled, launcher, onClick, t])
@@ -149,11 +153,18 @@ export const WebSearchToolRuntime: FC<Props> = (props) => {
149153

150154
const WebSearchButton: FC<Props> = (props) => {
151155
const { ariaLabel, enableWebSearch, icon, isDisabled, onClick, tooltipTitle } = useWebSearchToolController(props)
156+
const handleClick = useCallback<MouseEventHandler<HTMLButtonElement>>(
157+
(event) => {
158+
const trigger = event.currentTarget
159+
onClick(() => trigger.focus())
160+
},
161+
[onClick]
162+
)
152163

153164
return (
154165
<Tooltip placement="top" content={tooltipTitle}>
155166
<ActionIconButton
156-
onClick={onClick}
167+
onClick={handleClick}
157168
active={enableWebSearch}
158169
aria-label={ariaLabel}
159170
aria-pressed={enableWebSearch}

0 commit comments

Comments
 (0)