mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-18 13:23:33 +08:00
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:
parent
2f8c2a9a70
commit
867defc09d
@ -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: [
|
||||
{
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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: '' },
|
||||
|
||||
@ -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: [
|
||||
|
||||
@ -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: [
|
||||
{
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 />)
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
|
||||
@ -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 }),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user