diff --git a/desktop/src/components/shared/Modal.test.tsx b/desktop/src/components/shared/Modal.test.tsx new file mode 100644 index 00000000..69958905 --- /dev/null +++ b/desktop/src/components/shared/Modal.test.tsx @@ -0,0 +1,36 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { Modal } from './Modal' + +describe('Modal', () => { + it('portals the dialog to body so the scrim covers the full app shell', () => { + const onClose = vi.fn() + const { container } = render( +
+ + Provider form + +
, + ) + + const dialog = screen.getByRole('dialog', { name: 'Provider' }) + + expect(container.contains(dialog)).toBe(false) + expect(document.body.contains(dialog)).toBe(true) + }) + + it('closes when the backdrop is clicked', () => { + const onClose = vi.fn() + render( + + Provider form + , + ) + + const backdrop = screen.getByRole('dialog').previousElementSibling + expect(backdrop).not.toBeNull() + fireEvent.click(backdrop!) + + expect(onClose).toHaveBeenCalledTimes(1) + }) +}) diff --git a/desktop/src/components/shared/Modal.tsx b/desktop/src/components/shared/Modal.tsx index c1f5b278..adaa82c5 100644 --- a/desktop/src/components/shared/Modal.tsx +++ b/desktop/src/components/shared/Modal.tsx @@ -1,4 +1,5 @@ import { useEffect, type ReactNode } from 'react' +import { createPortal } from 'react-dom' type ModalProps = { open: boolean @@ -21,7 +22,7 @@ export function Modal({ open, onClose, title, children, width = 560, footer }: M if (!open) return null - return ( + return createPortal(
{/* Backdrop */}
{title && (
@@ -60,6 +62,7 @@ export function Modal({ open, onClose, title, children, width = 560, footer }: M
)}
-
+ , + document.body, ) } diff --git a/desktop/src/pages/EmptySession.tsx b/desktop/src/pages/EmptySession.tsx index fb764f2c..34c0f065 100644 --- a/desktop/src/pages/EmptySession.tsx +++ b/desktop/src/pages/EmptySession.tsx @@ -58,7 +58,6 @@ export function EmptySession() { const slashItemRefs = useRef<(HTMLButtonElement | null)[]>([]) const createSession = useSessionStore((state) => state.createSession) const sendMessage = useChatStore((state) => state.sendMessage) - const setSessionRuntime = useChatStore((state) => state.setSessionRuntime) const connectToSession = useChatStore((state) => state.connectToSession) const setActiveView = useUIStore((state) => state.setActiveView) const addToast = useUIStore((state) => state.addToast) @@ -233,12 +232,11 @@ export function EmptySession() { modelId: settings.currentModel?.id ?? OFFICIAL_DEFAULT_MODEL_ID, } const sessionId = await createSession(workDir || undefined) + useSessionRuntimeStore.getState().setSelection(sessionId, draftSelection) + useSessionRuntimeStore.getState().clearSelection(DRAFT_RUNTIME_SELECTION_KEY) setActiveView('code') useTabStore.getState().openTab(sessionId, 'New Session') connectToSession(sessionId) - useSessionRuntimeStore.getState().setSelection(sessionId, draftSelection) - useSessionRuntimeStore.getState().clearSelection(DRAFT_RUNTIME_SELECTION_KEY) - setSessionRuntime(sessionId, draftSelection) const attachmentPayload: AttachmentRef[] = attachments.map((attachment) => ({ type: attachment.type, name: attachment.name, diff --git a/desktop/src/pages/Settings.tsx b/desktop/src/pages/Settings.tsx index 3e95bc25..5ff8c76a 100644 --- a/desktop/src/pages/Settings.tsx +++ b/desktop/src/pages/Settings.tsx @@ -358,6 +358,39 @@ function openExternalUrl(url: string) { .catch(() => window.open(url, '_blank', 'noopener,noreferrer')) } +const API_KEY_JSON_PLACEHOLDER = '••••••••' +const API_KEY_JSON_KEYS = ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN'] as const + +function maskSettingsJsonSecrets(raw: string, apiKey: string): string { + if (!apiKey.trim()) return raw + try { + const parsed = JSON.parse(raw) as { env?: Record } + if (!parsed.env || typeof parsed.env !== 'object') return raw + let changed = false + for (const key of API_KEY_JSON_KEYS) { + if (parsed.env[key] === apiKey) { + parsed.env[key] = API_KEY_JSON_PLACEHOLDER + changed = true + } + } + return changed ? JSON.stringify(parsed, null, 2) : raw + } catch { + return raw + } +} + +function restoreSettingsJsonSecrets(settings: T, apiKey: string): T { + if (!apiKey.trim() || !settings || typeof settings !== 'object') return settings + const parsed = settings as { env?: Record } + if (!parsed.env || typeof parsed.env !== 'object') return settings + for (const key of API_KEY_JSON_KEYS) { + if (parsed.env[key] === API_KEY_JSON_PLACEHOLDER) { + parsed.env[key] = apiKey + } + } + return settings +} + function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderFormProps) { const { createProvider, updateProvider, testConfig } = useProviderStore() const fetchSettings = useSettingsStore((s) => s.fetchAll) @@ -446,6 +479,9 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF const canSubmit = name.trim() && baseUrl.trim() && (mode === 'edit' || !requiresApiKey || apiKey.trim()) && models.main.trim() && !settingsJsonError const apiKeyUrl = selectedPreset.apiKeyUrl?.trim() const promoText = selectedPreset.promoText?.trim() + const displayedSettingsJson = showApiKey + ? settingsJson + : maskSettingsJsonSecrets(settingsJson, apiKey) const apiFormatItems = [ { value: 'anthropic' as const, @@ -486,7 +522,7 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF // settings never conflict with the user's global ~/.claude/settings.json. if (settingsJson.trim()) { try { - const parsed = JSON.parse(settingsJson) + const parsed = restoreSettingsJsonSecrets(JSON.parse(settingsJson), apiKey) const { providersApi } = await import('../api/providers') await providersApi.updateSettings(parsed) } catch { @@ -720,12 +756,12 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF