From 867defc09d819d47ac3ba410ecfb6026d03fc3f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Mon, 22 Jun 2026 23:00:44 +0800 Subject: [PATCH] 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 --- desktop/src/__tests__/agentsSettings.test.tsx | 2 +- .../__tests__/diagnosticsSettings.test.tsx | 2 +- .../src/__tests__/generalSettings.test.tsx | 87 ++++++++++++++++++- desktop/src/__tests__/memorySettings.test.tsx | 2 +- .../src/__tests__/pluginsSettings.test.tsx | 2 +- desktop/src/__tests__/skillsSettings.test.tsx | 2 +- .../src/components/chat/ChatInput.test.tsx | 67 ++++++++++++++ desktop/src/components/chat/ChatInput.tsx | 8 +- desktop/src/pages/Settings.tsx | 20 +++-- desktop/src/stores/uiStore.ts | 4 + 10 files changed, 181 insertions(+), 15 deletions(-) diff --git a/desktop/src/__tests__/agentsSettings.test.tsx b/desktop/src/__tests__/agentsSettings.test.tsx index e1c3b417..4831d974 100644 --- a/desktop/src/__tests__/agentsSettings.test.tsx +++ b/desktop/src/__tests__/agentsSettings.test.tsx @@ -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: [ { diff --git a/desktop/src/__tests__/diagnosticsSettings.test.tsx b/desktop/src/__tests__/diagnosticsSettings.test.tsx index f9fa0811..592f33e1 100644 --- a/desktop/src/__tests__/diagnosticsSettings.test.tsx +++ b/desktop/src/__tests__/diagnosticsSettings.test.tsx @@ -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 () => { diff --git a/desktop/src/__tests__/generalSettings.test.tsx b/desktop/src/__tests__/generalSettings.test.tsx index a956bdec..0a6fb50e 100644 --- a/desktop/src/__tests__/generalSettings.test.tsx +++ b/desktop/src/__tests__/generalSettings.test.tsx @@ -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() + + fireEvent.click(screen.getByText('General')) + expect(screen.getByLabelText('Skip WebFetch domain preflight')).toBeInTheDocument() + + unmount() + render() + + expect(screen.getByLabelText('Skip WebFetch domain preflight')).toBeInTheDocument() + }) + it('offers the pure white appearance theme', () => { render() @@ -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((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() + + 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: '' }, diff --git a/desktop/src/__tests__/memorySettings.test.tsx b/desktop/src/__tests__/memorySettings.test.tsx index efbeaa3f..920b2553 100644 --- a/desktop/src/__tests__/memorySettings.test.tsx +++ b/desktop/src/__tests__/memorySettings.test.tsx @@ -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: [ diff --git a/desktop/src/__tests__/pluginsSettings.test.tsx b/desktop/src/__tests__/pluginsSettings.test.tsx index b71902dc..010bda8d 100644 --- a/desktop/src/__tests__/pluginsSettings.test.tsx +++ b/desktop/src/__tests__/pluginsSettings.test.tsx @@ -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: [ { diff --git a/desktop/src/__tests__/skillsSettings.test.tsx b/desktop/src/__tests__/skillsSettings.test.tsx index e7f034c7..0398840b 100644 --- a/desktop/src/__tests__/skillsSettings.test.tsx +++ b/desktop/src/__tests__/skillsSettings.test.tsx @@ -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, diff --git a/desktop/src/components/chat/ChatInput.test.tsx b/desktop/src/components/chat/ChatInput.test.tsx index 67c989b5..2f2e9666 100644 --- a/desktop/src/components/chat/ChatInput.test.tsx +++ b/desktop/src/components/chat/ChatInput.test.tsx @@ -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() + + 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() diff --git a/desktop/src/components/chat/ChatInput.tsx b/desktop/src/components/chat/ChatInput.tsx index 54d0ce13..5ee8e762 100644 --- a/desktop/src/components/chat/ChatInput.tsx +++ b/desktop/src/components/chat/ChatInput.tsx @@ -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) diff --git a/desktop/src/pages/Settings.tsx b/desktop/src/pages/Settings.tsx index a259249b..241b8f85 100644 --- a/desktop/src/pages/Settings.tsx +++ b/desktop/src/pages/Settings.tsx @@ -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('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 (
@@ -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 ( - - + diff --git a/desktop/src/stores/uiStore.ts b/desktop/src/stores/uiStore.ts index 4c3206cf..fbdf1d97 100644 --- a/desktop/src/stores/uiStore.ts +++ b/desktop/src/stores/uiStore.ts @@ -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((set) => ({ theme: getStoredTheme(), sidebarOpen: true, activeView: 'code', + activeSettingsTab: 'providers', pendingSettingsTab: null, pendingMemoryPath: null, activeModal: null, @@ -99,6 +102,7 @@ export const useUIStore = create((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 }),