diff --git a/packages/insomnia-smoke-test/tests/smoke/spec-toolbar.test.ts b/packages/insomnia-smoke-test/tests/smoke/spec-toolbar.test.ts new file mode 100644 index 0000000000..0ea3f59cea --- /dev/null +++ b/packages/insomnia-smoke-test/tests/smoke/spec-toolbar.test.ts @@ -0,0 +1,43 @@ +import { expect } from '@playwright/test'; + +import { test } from '../../playwright/test'; + +test.describe('Spec editor toolbar', () => { + test.slow(process.platform === 'darwin' || process.platform === 'win32', 'Slow app start on these platforms'); + + test('generate dropdown, format switch, and preview toggle', async ({ page }) => { + // Setup: create a design document from the Pet Store example + await page.getByRole('button', { name: 'Create document' }).click(); + await page.getByRole('dialog').getByRole('button', { name: 'Create' }).click(); + await page.click('text=Use example'); + await page.click('text=Pet Store'); + + const codeEditor = page.locator('.pane-one').getByTestId('CodeEditor'); + await expect.soft(codeEditor).toContainText('openapi: 3.0.4'); + + // The toolbar shows the OpenAPI version label + await expect.soft(page.locator('.pane-one').getByText('OpenAPI 3.0.4')).toBeVisible(); + + // The Generate dropdown exposes the Collection option + await page.getByRole('button', { name: 'Generate' }).click(); + await expect.soft(page.getByRole('menuitemradio', { name: 'Collection' })).toBeVisible(); + await page.keyboard.press('Escape'); + + // The format dropdown converts the spec between YAML and JSON + const formatButton = page.getByRole('button', { name: 'Spec format' }); + await expect.soft(formatButton).toContainText('YAML'); + await formatButton.click(); + await page.getByRole('menuitemradio', { name: 'JSON' }).click(); + await expect.soft(codeEditor).toContainText('"openapi": "3.0.4"'); + await expect.soft(formatButton).toContainText('JSON'); + + // The single preview toggle shows/hides the docs preview pane. + // A freshly created document starts with the preview collapsed. + const previewToggle = page.getByTestId('preview-toggle'); + await expect.soft(page.locator('.pane-two')).toBeHidden(); + await previewToggle.click(); + await expect.soft(page.locator('.pane-two')).toBeVisible(); + await previewToggle.click(); + await expect.soft(page.locator('.pane-two')).toBeHidden(); + }); +}); diff --git a/packages/insomnia/src/main/ipc/electron.ts b/packages/insomnia/src/main/ipc/electron.ts index e98b894121..cf97e603da 100644 --- a/packages/insomnia/src/main/ipc/electron.ts +++ b/packages/insomnia/src/main/ipc/electron.ts @@ -289,7 +289,8 @@ export type RendererOnChannels = | 'hide-oauth-authorization-modal' | 'mcp-auth-confirmation' | 'git.db-synced' - | 'git.file-problems-changed'; + | 'git.file-problems-changed' + | 'llm.changed'; export const ipcMainOn = ( channel: MainOnChannels, diff --git a/packages/insomnia/src/main/llm-config-service.ts b/packages/insomnia/src/main/llm-config-service.ts index 7938883d04..7efc30a026 100644 --- a/packages/insomnia/src/main/llm-config-service.ts +++ b/packages/insomnia/src/main/llm-config-service.ts @@ -1,6 +1,6 @@ import path from 'node:path'; -import { app } from 'electron'; +import { app, BrowserWindow } from 'electron'; import { services } from 'insomnia-data'; import { LLM_BACKENDS } from '~/common/constants'; @@ -152,18 +152,36 @@ export interface LLMConfigServiceAPI { setAIFeatureEnabled: typeof setAIFeatureEnabled; } +// Notify every renderer that the LLM config or AI feature flags changed so that +// any mounted consumer (e.g. `useAIFeatureStatus`) re-reads the latest values. +// Main owns the data, so it also owns the change signal — this keeps every +// window consistent instead of only the one that performed the mutation. +const broadcastLLMChanged = () => { + for (const w of BrowserWindow.getAllWindows()) { + w.webContents.send('llm.changed'); + } +}; + export const registerLLMConfigServiceAPI = () => { ipcMainHandle('llm.getActiveBackend', async () => getActiveBackend()); - ipcMainHandle('llm.setActiveBackend', async (_, backend: LLMBackend) => setActiveBackend(backend)); - ipcMainHandle('llm.clearActiveBackend', async () => clearActiveBackend()); + ipcMainHandle('llm.setActiveBackend', async (_, backend: LLMBackend) => { + await setActiveBackend(backend); + broadcastLLMChanged(); + }); + ipcMainHandle('llm.clearActiveBackend', async () => { + await clearActiveBackend(); + broadcastLLMChanged(); + }); ipcMainHandle('llm.getBackendConfig', async (_, backend: LLMBackend) => getBackendConfig(backend)); - ipcMainHandle('llm.updateBackendConfig', async (_, backend: LLMBackend, config: Partial) => - updateBackendConfig(backend, config), - ); + ipcMainHandle('llm.updateBackendConfig', async (_, backend: LLMBackend, config: Partial) => { + await updateBackendConfig(backend, config); + broadcastLLMChanged(); + }); ipcMainHandle('llm.getAllConfigurations', async () => getAllConfigurations()); ipcMainHandle('llm.getCurrentConfig', async () => getCurrentConfig()); ipcMainHandle('llm.getAIFeatureEnabled', async (_, feature: AIFeatureNames) => getAIFeatureEnabled(feature)); - ipcMainHandle('llm.setAIFeatureEnabled', async (_, feature: AIFeatureNames, enabled: boolean) => - setAIFeatureEnabled(feature, enabled), - ); + ipcMainHandle('llm.setAIFeatureEnabled', async (_, feature: AIFeatureNames, enabled: boolean) => { + await setAIFeatureEnabled(feature, enabled); + broadcastLLMChanged(); + }); }; diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx index 187cb163af..181e9063c2 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx @@ -50,6 +50,7 @@ import { useSpecUpdateActionFetcher } from '~/routes/organization.$organizationI import { useStorageRulesLoaderFetcher } from '~/routes/organization.$organizationId.storage-rules'; import { AnalyticsEvent } from '~/ui/analytics'; import { CodeEditor, type CodeEditorHandle } from '~/ui/components/.client/codemirror/code-editor'; +import { Badge } from '~/ui/components/base/badge'; import { DesignEmptyState } from '~/ui/components/design-empty-state'; import { DocumentTab } from '~/ui/components/document-tab'; import { Icon } from '~/ui/components/icon'; @@ -165,7 +166,8 @@ interface GroupedLintMessage { interface SpecActionItem { id: string; name: string; - icon: ReactNode; + icon?: ReactNode; + badge?: ReactNode; isDisabled?: boolean; action: () => void; } @@ -554,11 +556,15 @@ const Component = ({ params }: Route.ComponentProps) => { }); }; - const specActionList: SpecActionItem[] = [ + const generateActionList: SpecActionItem[] = [ { id: 'generate-request-collection', - name: 'Generate collection', - icon: , + name: 'Collection', + icon: ( + + + + ), isDisabled: !apiSpec.contents || lintErrors.length > 0 || generateRequestCollectionFetcher.state !== 'idle', action: () => generateRequestCollectionFetcher.submit({ @@ -568,41 +574,38 @@ const Component = ({ params }: Route.ComponentProps) => { }), }, { - id: 'toggle-preview', - name: 'Toggle preview', - icon: , + id: 'generate-mock-server', + name: 'Mock Server', + icon: ( + + + + ), + badge: , + isDisabled: !apiSpec.contents || !isGenerateMockServersWithAIEnabled, action: () => { window.main.trackAnalyticsEvent({ - event: AnalyticsEvent.designerPreviewToggled, - properties: { - status: !isSpecPaneOpen ? 'open' : 'collapsed', - }, + event: AnalyticsEvent.designerGenerateMockClicked, }); - setIsSpecPaneOpen(!isSpecPaneOpen); + setNewMockServerModalOpen(true); }, }, - ...(specFormat === 'json' - ? [ - { - id: 'convert-to-yaml', - name: 'Convert to YAML', - icon: , - action: () => switchFormat('yaml'), - }, - ] - : specFormat === 'yaml' - ? [ - { - id: 'convert-to-json', - name: 'Convert to JSON', - icon: , - action: () => switchFormat('json'), - }, - ] - : []), ]; - const disabledKeys = specActionList.filter(item => item.isDisabled).map(item => item.id); + const specFormatActionList: SpecActionItem[] = [ + { + id: 'json', + name: 'JSON', + action: () => switchFormat('json'), + }, + { + id: 'yaml', + name: 'YAML', + action: () => switchFormat('yaml'), + }, + ]; + + const generateDisabledKeys = generateActionList.filter(item => item.isDisabled).map(item => item.id); const uniquenessKey = `${apiSpec?._id}::${apiSpec?.created}::${gitVersion}::${vcsVersion}`; @@ -658,6 +661,117 @@ const Component = ({ params }: Route.ComponentProps) => { ); + const specPaneToolbar = apiSpec.contents ? ( +
+ + {parsedSpec?.openapi ? `OpenAPI ${parsedSpec.openapi}` : ''} + + + + + + { + const item = generateActionList.find(item => item.id === key); + if (item) { + item.action(); + } + }} + items={generateActionList} + className="min-w-max overflow-y-auto rounded-md border border-solid border-(--hl-sm) bg-(--color-bg) py-2 text-sm shadow-lg select-none focus:outline-hidden" + > + {item => ( + + {item.icon} + {item.name} + {item.badge && ( + {item.badge} + )} + + )} + + + + + + +
+ Spec format + { + const item = specFormatActionList.find(item => item.id === key); + if (item) { + item.action(); + } + }} + items={specFormatActionList} + className="min-w-max focus:outline-hidden" + > + {item => ( + + {item.name} + + )} + +
+
+
+ + { + setIsSpecPaneOpen(value); + window.main.trackAnalyticsEvent({ + event: AnalyticsEvent.designerPreviewToggled, + properties: { + status: !value ? 'open' : 'collapsed', + }, + }); + }} + > + {({ isSelected }) => ( + + + + )} + + + {isSpecPaneOpen ? 'Hide docs preview' : 'Show docs preview'} + + +
+ ) : null; + const lintToolbar = (
{ workspaceId={workspaceId} className="border-b border-solid border-(--hl-sm)" /> -
- Spec - - {isGenerateMockServersWithAIEnabled && ( - - )} - { - setIsSpecPaneOpen(value); - window.main.trackAnalyticsEvent({ - event: AnalyticsEvent.designerPreviewToggled, - properties: { - status: !value ? 'open' : 'collapsed', - }, - }); - }} - > - {({ isSelected }) => ( - <> - - Preview - - )} - - - - - { - const item = specActionList.find(item => item.id === key); - if (item) { - item.action(); - } - }} - items={specActionList} - className="min-w-max overflow-y-auto rounded-md border border-solid border-(--hl-sm) bg-(--color-bg) py-2 text-sm shadow-lg select-none focus:outline-hidden" - > - {item => ( - - {item.icon} - {item.name} - - )} - - - -
{/* Info */} {info && ( @@ -1344,7 +1384,8 @@ const Component = ({ params }: Route.ComponentProps) => { - + {specPaneToolbar} +
diff --git a/packages/insomnia/src/ui/components/base/badge.tsx b/packages/insomnia/src/ui/components/base/badge.tsx index ae5a740700..2253e3e593 100644 --- a/packages/insomnia/src/ui/components/base/badge.tsx +++ b/packages/insomnia/src/ui/components/base/badge.tsx @@ -6,9 +6,10 @@ export interface BadgeProps { color: keyof typeof ThemeEnum; icon?: IconId; label: string; + style?: React.CSSProperties; } -export const Badge: FC = ({ color, icon, label }) => { +export const Badge: FC = ({ color, icon, label, style }) => { return ( = ({ color, icon, label }) => { top: '-1px', color: `rgb(var(--color-${color}-rgb))`, borderColor: `rgb(var(--color-${color}-rgb))`, + ...style, }} > {icon && } diff --git a/packages/insomnia/src/ui/components/settings/ai-settings.tsx b/packages/insomnia/src/ui/components/settings/ai-settings.tsx index e833e42bb7..6d3c911001 100644 --- a/packages/insomnia/src/ui/components/settings/ai-settings.tsx +++ b/packages/insomnia/src/ui/components/settings/ai-settings.tsx @@ -56,6 +56,8 @@ export const AISettings = () => { const toggleAIFeature = useCallback(async (feature: AIFeatureNames, enabled: boolean) => { setAIFeatures(prev => ({ ...prev, [feature]: enabled })); + // Main broadcasts an `llm.changed` event after the write, which consumers + // (e.g. the spec view Generate dropdown) listen for to re-read the status. await window.main.llm.setAIFeatureEnabled(feature, enabled); }, []); @@ -64,6 +66,8 @@ export const AISettings = () => { await window.main.llm.updateBackendConfig(backend, extras); if (setCurrent) { + // Activating an LLM can enable AI features that require an active LLM; + // main broadcasts `llm.changed` so consumers re-read the status. await window.main.llm.setActiveBackend(backend); const newCurrentConfig = await window.main.llm.getCurrentConfig(); setCurrentLLM(newCurrentConfig); diff --git a/packages/insomnia/src/ui/hooks/use-organization-features.tsx b/packages/insomnia/src/ui/hooks/use-organization-features.tsx index 9625b5d078..32b591c261 100644 --- a/packages/insomnia/src/ui/hooks/use-organization-features.tsx +++ b/packages/insomnia/src/ui/hooks/use-organization-features.tsx @@ -75,6 +75,15 @@ export function useAIFeatureStatus(): AIFeatureStatus { loadFeatureStatus(); }, [loadFeatureStatus]); + // Re-read the status when the AI settings change in the main process (the + // source of truth), since this hook would otherwise keep a stale snapshot + // taken at mount time. Main broadcasts to every window, so all consumers stay + // consistent regardless of which window performed the change. + useEffect(() => { + const unsubscribe = window.main.on('llm.changed', loadFeatureStatus); + return unsubscribe; + }, [loadFeatureStatus]); + const generateMockServersWithAIAllowedByOrg = features.aiMockServers ? features.aiMockServers.enabled : true; const generateCommitMessagesWithAIAllowedByOrg = features.aiCommitMessages ? features.aiCommitMessages.enabled : true; const mcpClientWithAIAllowedByOrg = features.aiMcpClient ? features.aiMcpClient.enabled : true;