feat: UI cleanup for the API Designer's spec editor [INS-2667] (#10141)

* Modify the view of workspace

* Add tests

* Fix height

* Modify style
This commit is contained in:
Pavlos Koutoglou
2026-06-25 18:03:15 +02:00
committed by GitHub
parent 52a7a51e93
commit 9bea01ea8d
7 changed files with 236 additions and 118 deletions

View File

@@ -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();
});
});

View File

@@ -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,

View File

@@ -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<LLMConfig>) =>
updateBackendConfig(backend, config),
);
ipcMainHandle('llm.updateBackendConfig', async (_, backend: LLMBackend, config: Partial<LLMConfig>) => {
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();
});
};

View File

@@ -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: <Icon className="w-3" icon="file-code" />,
name: 'Collection',
icon: (
<span className="flex h-5 w-5 items-center justify-center rounded-sm bg-(--color-surprise) text-(--color-font-surprise)">
<Icon className="w-3" icon="bars" />
</span>
),
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: <Icon className="w-3" icon={isSpecPaneOpen ? 'eye' : 'eye-slash'} />,
id: 'generate-mock-server',
name: 'Mock Server',
icon: (
<span className="flex h-5 w-5 items-center justify-center rounded-sm bg-(--color-warning) text-(--color-font-warning)">
<Icon className="w-3" icon="server" />
</span>
),
badge: <Badge icon="sparkles" color="surprise" label="AI" style={{ marginLeft: 'auto', marginRight: '0' }} />,
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: <Icon className="w-3" icon="sync-alt" />,
action: () => switchFormat('yaml'),
},
]
: specFormat === 'yaml'
? [
{
id: 'convert-to-json',
name: 'Convert to JSON',
icon: <Icon className="w-3" icon="sync-alt" />,
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) => {
</div>
);
const specPaneToolbar = apiSpec.contents ? (
<div className="flex h-[40px] shrink-0 items-center gap-2 overflow-hidden border-b border-solid border-(--hl-md) px-(--padding-sm)">
<span className="truncate text-sm text-(--hl) italic">
{parsedSpec?.openapi ? `OpenAPI ${parsedSpec.openapi}` : ''}
</span>
<span className="flex-1" />
<MenuTrigger>
<Button
aria-label="Generate"
className="flex shrink-0 items-center justify-center gap-2 rounded-md border border-solid border-(--hl-md) px-2.5 py-1 text-sm text-(--color-font) ring-1 ring-transparent transition-all hover:bg-(--hl-xs) focus:ring-(--hl-md) focus:ring-inset aria-pressed:bg-(--hl-sm)"
>
<span>Generate</span>
<Icon icon="chevron-down" className="w-2.5 text-(--hl)" />
</Button>
<Popover className="flex min-w-max flex-col overflow-y-hidden">
<Menu
aria-label="Generate menu"
selectionMode="single"
disabledKeys={generateDisabledKeys}
onAction={key => {
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 => (
<MenuItem
className="flex h-(--line-height-xs) w-full items-center gap-2 bg-transparent px-(--padding-sm) whitespace-nowrap text-(--color-font) transition-colors hover:bg-(--hl-sm) focus:bg-(--hl-xs) focus:outline-hidden disabled:cursor-not-allowed aria-disabled:cursor-not-allowed aria-disabled:text-(--hl-md) aria-selected:font-bold"
aria-label={item.name}
>
{item.icon}
<span>{item.name}</span>
{item.badge && (
<span className="flex origin-left scale-90 items-center pl-2 text-xs">{item.badge}</span>
)}
</MenuItem>
)}
</Menu>
</Popover>
</MenuTrigger>
<MenuTrigger>
<Button
aria-label="Spec format"
className="flex shrink-0 items-center justify-center gap-2 rounded-md border border-solid border-(--hl-md) px-2.5 py-1 text-sm text-(--color-font) ring-1 ring-transparent transition-all hover:bg-(--hl-xs) focus:ring-(--hl-md) focus:ring-inset aria-pressed:bg-(--hl-sm)"
>
<span>{specFormat ? specFormat.toUpperCase() : 'JSON'}</span>
<Icon icon="chevron-down" className="w-2.5 text-(--hl)" />
</Button>
<Popover className="flex min-w-max flex-col overflow-y-hidden">
<div 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">
<span className="block px-(--padding-md) py-1 text-xs text-(--hl)">Spec format</span>
<Menu
aria-label="Spec format menu"
selectionMode="single"
selectedKeys={specFormat ? [specFormat] : []}
onAction={key => {
const item = specFormatActionList.find(item => item.id === key);
if (item) {
item.action();
}
}}
items={specFormatActionList}
className="min-w-max focus:outline-hidden"
>
{item => (
<MenuItem
className="flex h-(--line-height-xs) w-full items-center gap-2 bg-transparent px-(--padding-sm) whitespace-nowrap text-(--color-font) transition-colors hover:bg-(--hl-sm) focus:bg-(--hl-xs) focus:outline-hidden aria-selected:font-bold"
aria-label={item.name}
>
<span>{item.name}</span>
</MenuItem>
)}
</Menu>
</div>
</Popover>
</MenuTrigger>
<TooltipTrigger delay={0}>
<ToggleButton
aria-label="Toggle preview"
data-testid="preview-toggle"
isSelected={isSpecPaneOpen}
className="flex shrink-0 items-center justify-center rounded-md border border-solid border-(--hl-md) px-2 py-1 text-sm text-(--color-font) ring-1 ring-transparent transition-all hover:bg-(--hl-xs) focus:ring-(--hl-md) focus:ring-inset"
onChange={value => {
setIsSpecPaneOpen(value);
window.main.trackAnalyticsEvent({
event: AnalyticsEvent.designerPreviewToggled,
properties: {
status: !value ? 'open' : 'collapsed',
},
});
}}
>
{({ isSelected }) => (
<span className="flex h-5 w-5 items-center justify-center">
<Icon icon={isSelected ? 'eye-slash' : 'eye'} fixedWidth />
</span>
)}
</ToggleButton>
<Tooltip
offset={8}
className="rounded-md border border-solid border-(--hl-sm) bg-(--color-bg) px-2 py-1 text-sm text-(--color-font) shadow-lg"
>
{isSpecPaneOpen ? 'Hide docs preview' : 'Show docs preview'}
</Tooltip>
</TooltipTrigger>
</div>
) : null;
const lintToolbar = (
<div
className={`flex h-(--line-height-sm) items-center gap-2 overflow-hidden border-solid border-(--hl-md) px-(--padding-sm) ${isLintPaneOpen ? 'border-b' : ''}`}
@@ -862,80 +976,6 @@ const Component = ({ params }: Route.ComponentProps) => {
workspaceId={workspaceId}
className="border-b border-solid border-(--hl-sm)"
/>
<div className="flex shrink-0 items-center gap-2 p-(--padding-sm)">
<Heading className="text-(--hl) uppercase">Spec</Heading>
<span className="flex-1" />
{isGenerateMockServersWithAIEnabled && (
<Button
onPress={() => {
window.main.trackAnalyticsEvent({
event: AnalyticsEvent.designerGenerateMockClicked,
});
setNewMockServerModalOpen(true);
}}
isDisabled={!apiSpec.contents}
className="flex max-w-full flex-1 items-center justify-center gap-2 truncate rounded-xs px-4 py-1 text-sm text-(--color-font) ring-1 ring-transparent transition-all hover:bg-(--hl-xs) focus:ring-(--hl-md) focus:ring-inset disabled:cursor-not-allowed disabled:opacity-50 aria-pressed:bg-(--hl-sm)"
>
<Icon icon="server" className="w-5 shrink-0" />
<span className="truncate">Generate Mock</span>
</Button>
)}
<ToggleButton
aria-label="Toggle preview"
data-testid="preview-toggle"
isSelected={isSpecPaneOpen}
className="flex h-full items-center justify-center gap-2 rounded-xs px-2 text-sm text-(--color-font) ring-1 ring-transparent transition-all hover:bg-(--hl-xs) focus:ring-(--hl-md) focus:ring-inset aria-pressed:bg-(--hl-sm)"
onChange={value => {
setIsSpecPaneOpen(value);
window.main.trackAnalyticsEvent({
event: AnalyticsEvent.designerPreviewToggled,
properties: {
status: !value ? 'open' : 'collapsed',
},
});
}}
>
{({ isSelected }) => (
<>
<Icon icon={isSelected ? 'eye' : 'eye-slash'} />
<span>Preview</span>
</>
)}
</ToggleButton>
<MenuTrigger>
<Button
aria-label="Spec actions"
className="flex aspect-square h-full items-center justify-center rounded-xs text-sm text-(--color-font) ring-1 ring-transparent transition-all hover:bg-(--hl-xs) focus:ring-(--hl-md) focus:ring-inset aria-pressed:bg-(--hl-sm)"
>
<Icon icon="gear" />
</Button>
<Popover className="flex min-w-max flex-col overflow-y-hidden">
<Menu
aria-label="Spec actions menu"
selectionMode="single"
disabledKeys={disabledKeys}
onAction={key => {
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 => (
<MenuItem
className="flex h-(--line-height-xs) w-full items-center gap-2 bg-transparent px-(--padding-md) whitespace-nowrap text-(--color-font) transition-colors hover:bg-(--hl-sm) focus:bg-(--hl-xs) focus:outline-hidden disabled:cursor-not-allowed aria-disabled:cursor-not-allowed aria-disabled:text-(--hl-md) aria-selected:font-bold"
aria-label={item.name}
>
{item.icon}
<span>{item.name}</span>
</MenuItem>
)}
</Menu>
</Popover>
</MenuTrigger>
</div>
<div className="flex flex-1 flex-col divide-y divide-solid divide-(--hl-md) overflow-y-auto">
{/* Info */}
{info && (
@@ -1344,7 +1384,8 @@ const Component = ({ params }: Route.ComponentProps) => {
</Panel>
<PanelResizeHandle className="h-full w-px bg-(--hl-md)" />
<Panel id="workspace-content" className="flex flex-col">
<PanelGroup autoSaveId="insomnia-panels" direction={direction}>
{specPaneToolbar}
<PanelGroup autoSaveId="insomnia-panels" direction={direction} className="min-h-0 flex-1">
<Panel id="pane-one" minSize={10} className="pane-one theme--pane">
<div className="flex h-full w-full flex-col">
<PanelGroup autoSaveId="insomnia-spec-vertical" direction="vertical" className="min-h-0 flex-1">

View File

@@ -6,9 +6,10 @@ export interface BadgeProps {
color: keyof typeof ThemeEnum;
icon?: IconId;
label: string;
style?: React.CSSProperties;
}
export const Badge: FC<BadgeProps> = ({ color, icon, label }) => {
export const Badge: FC<BadgeProps> = ({ color, icon, label, style }) => {
return (
<span
style={{
@@ -23,6 +24,7 @@ export const Badge: FC<BadgeProps> = ({ color, icon, label }) => {
top: '-1px',
color: `rgb(var(--color-${color}-rgb))`,
borderColor: `rgb(var(--color-${color}-rgb))`,
...style,
}}
>
{icon && <SvgIcon icon={icon} />}

View File

@@ -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);

View File

@@ -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;