fix(desktop): preserve settings and composer state

Fixes local batch coverage for #810, #811, and #871.

- Lock provider form cancellation and duplicate submission while save is in flight.

- Persist the active Settings tab while navigating away and back.

- Carry an empty-session composer draft onto the replacement session when switching project.

Tested: cd desktop && bun run test -- --run src/__tests__/generalSettings.test.tsx src/components/chat/ChatInput.test.tsx

Tested: cd desktop && bun run test -- --run src/__tests__/diagnosticsSettings.test.tsx

Tested: bun run check:desktop

Not-tested: full bun run verify; local handoff only, not PR/push-ready.

Confidence: high

Scope-risk: narrow
This commit is contained in:
程序员阿江(Relakkes) 2026-06-22 23:00:44 +08:00
parent 2f8c2a9a70
commit 867defc09d
10 changed files with 181 additions and 15 deletions

View File

@ -148,7 +148,7 @@ describe('Settings > Agents tab', () => {
activeTabId: 'session-1',
tabs: [{ sessionId: 'session-1', title: 'Test', type: 'session', status: 'idle' }],
})
useUIStore.setState({ pendingSettingsTab: null })
useUIStore.setState({ activeSettingsTab: 'providers', pendingSettingsTab: null })
useSessionStore.setState({
sessions: [
{

View File

@ -140,7 +140,7 @@ describe('Settings > Diagnostics tab', () => {
})
useSettingsStore.setState({ locale: 'en' })
useUIStore.setState({ pendingSettingsTab: null, toasts: [] })
useUIStore.setState({ activeSettingsTab: 'providers', pendingSettingsTab: null, toasts: [] })
})
it('shows diagnostics status, actions, and recent events', async () => {

View File

@ -342,7 +342,7 @@ describe('Settings > General tab', () => {
updateH5AccessSettings: vi.fn(),
})
useUIStore.setState({ pendingSettingsTab: null, toasts: [] })
useUIStore.setState({ activeSettingsTab: 'providers', pendingSettingsTab: null, toasts: [] })
useUpdateStore.setState({
status: 'idle',
availableVersion: null,
@ -369,6 +369,18 @@ describe('Settings > General tab', () => {
expect(toggle).toBeChecked()
})
it('keeps the selected settings tab when returning to Settings', () => {
const { unmount } = render(<Settings />)
fireEvent.click(screen.getByText('General'))
expect(screen.getByLabelText('Skip WebFetch domain preflight')).toBeInTheDocument()
unmount()
render(<Settings />)
expect(screen.getByLabelText('Skip WebFetch domain preflight')).toBeInTheDocument()
})
it('offers the pure white appearance theme', () => {
render(<Settings />)
@ -1463,6 +1475,7 @@ describe('Settings > Providers tab', () => {
MOCK_DELETE_PROVIDER.mockReset()
MOCK_GET_SETTINGS.mockResolvedValue({})
MOCK_UPDATE_SETTINGS.mockResolvedValue({})
useUIStore.setState({ activeSettingsTab: 'providers', pendingSettingsTab: null, toasts: [] })
useSettingsStore.setState({
locale: 'en',
fetchAll: vi.fn().mockResolvedValue(undefined),
@ -1670,6 +1683,76 @@ describe('Settings > Providers tab', () => {
})
})
it('keeps the provider form locked while save is in flight', async () => {
let resolveCreate!: (provider: SavedProvider) => void
providerStoreState.createProvider = vi.fn().mockImplementation(() => new Promise<SavedProvider>((resolve) => {
resolveCreate = resolve
}))
providerStoreState.presets = [
{
id: 'custom',
name: 'Custom',
baseUrl: 'https://api.example.com/anthropic',
apiFormat: 'anthropic',
defaultModels: {
main: 'custom-main',
haiku: '',
sonnet: '',
opus: '',
},
needsApiKey: true,
websiteUrl: '',
},
]
render(<Settings />)
fireEvent.click(screen.getByRole('button', { name: /Add Provider|添加服务商/i }))
const dialog = screen.getByRole('dialog')
await waitFor(() => {
const settingsTextarea = dialog.querySelector('textarea')
expect(settingsTextarea?.value).toContain('"ANTHROPIC_MODEL"')
})
fireEvent.change(within(dialog).getByPlaceholderText('sk-...'), { target: { value: 'sk-test' } })
fireEvent.click(within(dialog).getByRole('button', { name: /Save|Add|保存|添加/i }))
await waitFor(() => {
expect(providerStoreState.createProvider).toHaveBeenCalledTimes(1)
})
const cancelButton = within(dialog).getByRole('button', { name: /Cancel|取消/i })
expect(cancelButton).toBeDisabled()
fireEvent.click(cancelButton)
fireEvent.click(within(dialog).getByRole('button', { name: /Save|Add|保存|添加/i }))
expect(screen.getByRole('dialog')).toBeInTheDocument()
expect(providerStoreState.createProvider).toHaveBeenCalledTimes(1)
await act(async () => {
resolveCreate({
id: 'provider-new',
presetId: 'custom',
name: 'Custom',
apiKey: 'sk-test',
baseUrl: 'https://api.example.com/anthropic',
apiFormat: 'anthropic',
models: {
main: 'custom-main',
haiku: 'custom-main',
sonnet: 'custom-main',
opus: 'custom-main',
},
})
await Promise.resolve()
})
await waitFor(() => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
})
it('defaults Tool Search on and persists an explicit disable from the provider form', async () => {
MOCK_GET_SETTINGS.mockResolvedValue({ env: { EXISTING_ENV: '1' } })
providerStoreState.createProvider = vi.fn().mockResolvedValue({
@ -1859,7 +1942,7 @@ describe('Settings > Providers tab', () => {
describe('Settings > About tab', () => {
beforeEach(() => {
useUIStore.setState({ pendingSettingsTab: 'about' })
useUIStore.setState({ activeSettingsTab: 'providers', pendingSettingsTab: 'about' })
useSettingsStore.setState({
locale: 'en',
updateProxy: { mode: 'system', url: '' },

View File

@ -78,7 +78,7 @@ describe('MemorySettings', () => {
error: null,
lastSavedAt: null,
})
useUIStore.setState({ pendingMemoryPath: null, pendingSettingsTab: null })
useUIStore.setState({ activeSettingsTab: 'providers', pendingMemoryPath: null, pendingSettingsTab: null })
memoryApiMock.listProjects.mockResolvedValue({
projects: [

View File

@ -137,7 +137,7 @@ describe('Settings > Plugins tab', () => {
beforeEach(() => {
vi.clearAllMocks()
useSettingsStore.setState({ locale: 'en' })
useUIStore.setState({ pendingSettingsTab: null })
useUIStore.setState({ activeSettingsTab: 'providers', pendingSettingsTab: null })
useSessionStore.setState({
sessions: [
{

View File

@ -84,7 +84,7 @@ describe('Settings > Skills tab', () => {
error: null,
})
useTabStore.setState({ tabs: [], activeTabId: null })
useUIStore.setState({ pendingSettingsTab: null })
useUIStore.setState({ activeSettingsTab: 'providers', pendingSettingsTab: null })
useSkillStore.setState({
skills: [],
selectedSkill: null,

View File

@ -322,6 +322,73 @@ describe('ChatInput file mentions', () => {
})
})
it('keeps the unsent draft when switching project on an empty active session', async () => {
installElectronFileHost()
mocks.dialogOpen.mockResolvedValueOnce('/other')
mocks.create.mockResolvedValueOnce({ sessionId: 'session-project-switch', workDir: '/other' })
mocks.getRepositoryContext.mockImplementation(async (workDir: string) => ({
...okRepositoryContext(),
workDir,
repoRoot: workDir,
repoName: workDir.split('/').filter(Boolean).pop() ?? 'repo',
}))
useSessionStore.setState({
sessions: [{
id: sessionId,
title: 'Project',
createdAt: '2026-05-01T00:00:00.000Z',
modifiedAt: '2026-05-01T00:00:00.000Z',
messageCount: 0,
projectPath: '/repo',
workDir: '/repo',
workDirExists: true,
}],
activeSessionId: sessionId,
})
useChatStore.setState({
sessions: {
[sessionId]: {
messages: [],
chatState: 'idle',
connectionState: 'connected',
streamingText: '',
streamingToolInput: '',
activeToolUseId: null,
activeToolName: null,
activeThinkingId: null,
pendingPermission: null,
pendingComputerUsePermission: null,
tokenUsage: { input_tokens: 0, output_tokens: 0 },
streamingResponseChars: 0,
elapsedSeconds: 0,
statusVerb: '',
slashCommands: [],
agentTaskNotifications: {},
elapsedTimer: null,
},
},
})
render(<ChatInput variant="hero" />)
const input = screen.getByRole('textbox') as HTMLTextAreaElement
fireEvent.change(input, {
target: { value: 'draft before switching project', selectionStart: 30 },
})
fireEvent.click(screen.getAllByTitle('/repo')[0]!)
await screen.findByTestId('directory-picker-menu')
fireEvent.click(screen.getByRole('button', { name: /Choose a different folder/ }))
await waitFor(() => {
expect(mocks.create).toHaveBeenCalledWith({ workDir: '/other' })
})
await waitFor(() => {
expect(useTabStore.getState().activeTabId).toBe('session-project-switch')
})
expect(input.value).toBe('draft before switching project')
})
it('restores an unsent composer draft after the composer unmounts', async () => {
const { unmount } = render(<ChatInput compact />)

View File

@ -561,11 +561,17 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
const oldId = activeTabId
const { createSession, deleteSession } = useSessionStore.getState()
const { replaceTabSession } = useTabStore.getState()
const { disconnectSession, connectToSession } = useChatStore.getState()
const { disconnectSession, connectToSession, setComposerDraft } = useChatStore.getState()
const newId = await createSession(
workDir || undefined,
repository ? { repository } : undefined,
)
if (inputRef.current.length > 0 || attachmentsRef.current.length > 0) {
setComposerDraft(newId, {
input: inputRef.current,
attachments: attachmentsRef.current,
})
}
useSessionRuntimeStore.getState().moveSelection(oldId, newId)
disconnectSession(oldId)
replaceTabSession(oldId, newId)

View File

@ -48,7 +48,7 @@ import { DiagnosticsSettings } from './DiagnosticsSettings'
import { TraceList } from './TraceList'
import { ActivitySettings } from './ActivitySettings'
import { MemorySettings } from './MemorySettings'
import { useUIStore, type SettingsTab } from '../stores/uiStore'
import { useUIStore } from '../stores/uiStore'
import { ClaudeOfficialLogin } from '../components/settings/ClaudeOfficialLogin'
import { ChatGPTOfficialLogin } from '../components/settings/ChatGPTOfficialLogin'
import {
@ -181,7 +181,8 @@ function buildH5PublicBaseUrlFromHostDraft(draft: string, currentBaseUrl: string
}
export function Settings() {
const [activeTab, setActiveTab] = useState<SettingsTab>('providers')
const activeTab = useUIStore((s) => s.activeSettingsTab)
const setActiveTab = useUIStore((s) => s.setActiveSettingsTab)
const pendingSettingsTab = useUIStore((s) => s.pendingSettingsTab)
const t = useTranslation()
@ -189,7 +190,7 @@ export function Settings() {
if (!pendingSettingsTab) return
setActiveTab(pendingSettingsTab)
useUIStore.getState().setPendingSettingsTab(null)
}, [pendingSettingsTab])
}, [pendingSettingsTab, setActiveTab])
return (
<div className="flex-1 flex flex-col overflow-hidden bg-[var(--color-surface)]">
@ -1372,7 +1373,7 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF
)
const handleSubmit = async () => {
if (!canSubmit) return
if (!canSubmit || isSubmitting) return
const normalizedModels = normalizeModelMapping(models)
const parsedAutoCompactWindow = parseAutoCompactWindowInput(autoCompactWindow)
const parsedModelContextWindows = buildModelContextWindows(models, modelContextInputs)
@ -1435,6 +1436,11 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF
}
}
const handleClose = () => {
if (isSubmitting) return
onClose()
}
const handleTest = async () => {
if (!baseUrl.trim() || !models.main.trim()) return
setIsTesting(true)
@ -1469,13 +1475,13 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF
return (
<Modal
open={open}
onClose={onClose}
onClose={handleClose}
title={mode === 'create' ? t('settings.providers.addTitle') : t('settings.providers.editTitle')}
width={720}
footer={
<>
<Button variant="secondary" onClick={onClose}>{t('common.cancel')}</Button>
<Button onClick={handleSubmit} disabled={!canSubmit} loading={isSubmitting}>
<Button variant="secondary" onClick={handleClose} disabled={isSubmitting}>{t('common.cancel')}</Button>
<Button onClick={handleSubmit} disabled={!canSubmit || isSubmitting} loading={isSubmitting}>
{mode === 'create' ? t('common.add') : t('common.save')}
</Button>
</>

View File

@ -51,6 +51,7 @@ type UIStore = {
theme: ThemeMode
sidebarOpen: boolean
activeView: ActiveView
activeSettingsTab: SettingsTab
pendingSettingsTab: SettingsTab | null
pendingMemoryPath: string | null
activeModal: string | null
@ -61,6 +62,7 @@ type UIStore = {
toggleSidebar: () => void
setSidebarOpen: (open: boolean) => void
setActiveView: (view: ActiveView) => void
setActiveSettingsTab: (tab: SettingsTab) => void
setPendingSettingsTab: (tab: SettingsTab | null) => void
setPendingMemoryPath: (path: string | null) => void
openModal: (id: string) => void
@ -75,6 +77,7 @@ export const useUIStore = create<UIStore>((set) => ({
theme: getStoredTheme(),
sidebarOpen: true,
activeView: 'code',
activeSettingsTab: 'providers',
pendingSettingsTab: null,
pendingMemoryPath: null,
activeModal: null,
@ -99,6 +102,7 @@ export const useUIStore = create<UIStore>((set) => ({
toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),
setSidebarOpen: (open) => set({ sidebarOpen: open }),
setActiveView: (view) => set({ activeView: view }),
setActiveSettingsTab: (tab) => set({ activeSettingsTab: tab }),
setPendingSettingsTab: (tab) => set({ pendingSettingsTab: tab }),
setPendingMemoryPath: (path) => set({ pendingMemoryPath: path }),
openModal: (id) => set({ activeModal: id }),