From b77505c1cd38b48373d299ea2a64f23698b1289a Mon Sep 17 00:00:00 2001 From: Chris Johnstone Date: Mon, 15 Jun 2026 00:15:44 +1200 Subject: [PATCH 1/4] Add OC-100 request type creation UX --- .../AGENT_GOAL_PROMPTS.md | 2 +- .../tasks/10-request-type-ux.md | 43 ++--- src/commands/requestCommands.ts | 42 ++-- src/panels/requestPanel.ts | 13 +- src/services/requestTemplates.ts | 88 +++++++++ src/webview/requestPanel.css | 4 +- src/webview/requestPanel.ts | 70 ++++--- test/mocks/vscode.ts | 1 + test/requestTypeUx.test.ts | 180 ++++++++++++++++++ 9 files changed, 365 insertions(+), 78 deletions(-) create mode 100644 src/services/requestTemplates.ts create mode 100644 test/requestTypeUx.test.ts diff --git a/docs/open-collection-gap-analysis/AGENT_GOAL_PROMPTS.md b/docs/open-collection-gap-analysis/AGENT_GOAL_PROMPTS.md index a811854..a1c45ca 100644 --- a/docs/open-collection-gap-analysis/AGENT_GOAL_PROMPTS.md +++ b/docs/open-collection-gap-analysis/AGENT_GOAL_PROMPTS.md @@ -55,7 +55,7 @@ If an agent host does not inject the project-local skills, tell the agent to rea ## Request Type UX ```text -/goal Complete OC-100 request type UX using $missio-agent-coordination and $missio-editor-schema-implementer without stopping until Bruno/Postman UX benchmarking is recorded, UI request creation supports HTTP/GraphQL/WebSocket/gRPC type selection, the visual editor clearly shows and safely switches request type with preservation/loss confirmation, schema-valid conversion helpers are implemented, AGENT_PROGRESS updates are complete, and complete automated creation, conversion, validation, round-trip, and regression tests are passing. +/goal Complete OC-100 request type UX using $missio-agent-coordination and $missio-editor-schema-implementer without stopping until Bruno/Postman UX benchmarking is recorded, UI request creation supports HTTP/GraphQL/WebSocket/gRPC type selection, the visual editor clearly shows read-only request type identity without offering a saved-request switcher, AGENT_PROGRESS updates are complete, and complete automated creation, validation, round-trip, and regression tests are passing. ``` ## Protocol Runtime Lifecycle diff --git a/docs/open-collection-gap-analysis/tasks/10-request-type-ux.md b/docs/open-collection-gap-analysis/tasks/10-request-type-ux.md index 0732fb3..19d63c9 100644 --- a/docs/open-collection-gap-analysis/tasks/10-request-type-ux.md +++ b/docs/open-collection-gap-analysis/tasks/10-request-type-ux.md @@ -2,7 +2,7 @@ ## Goal -Make request type management obvious and deliberate in Missio's UI. Users should be able to choose the request type when creating a request, see the current type while editing, and convert between compatible request types without corrupting schema data. +Make request type management obvious and deliberate in Missio's UI. Users should be able to choose the request type when creating a request and see the current type while editing without needing to inspect YAML. ## Product Benchmark @@ -14,7 +14,7 @@ Review current request-type workflows in industry-standard REST/API clients befo | Postman | New request protocol picker, type labels in tabs/sidebar, request conversion behavior, and guardrails when switching protocols with incompatible fields. | | Missio current state | Command palette/tree context creation, request editor header, YAML fallback, and how `type: http`, `type: graphql`, `type: websocket`, and `type: grpc` are represented. | -Record the benchmark findings in `AGENT_PROGRESS.md` before implementation. Prefer patterns that are familiar to users of Bruno/Postman, but keep Missio aligned with the OpenCollection schema rather than copying product-specific data models. +Record the benchmark findings in `AGENT_PROGRESS.md` before implementation. Prefer patterns that are familiar to users of Bruno/Postman, but keep Missio aligned with the OpenCollection schema rather than copying product-specific data models. Do not add a saved-request type switcher unless future benchmark evidence or product direction calls for one; current Bruno/Postman behavior emphasizes protocol selection at creation, and Postman documents protocol lock-in after saving. ## Current Gap @@ -22,10 +22,8 @@ Record the benchmark findings in `AGENT_PROGRESS.md` before implementation. Pref | --- | --- | | New request | Users cannot choose `http`, `graphql`, `websocket`, or `grpc` from the UI when creating a request. | | Editor header | The current request type is not prominent or editable in the visual editor. | -| Type switching | There is no guided way to change request type, even when common fields such as name, URL, headers, auth, variables, runtime, and settings can be preserved. | -| Data safety | Switching between incompatible protocols needs explicit confirmation and a preview of fields that will be preserved, transformed, or dropped. | | Tree/context actions | Context menus do not expose protocol-specific creation commands at collection/folder scope. | -| Tests | Existing protocol tests prove execution/editor behavior, but not request-type creation or conversion UX. | +| Tests | Existing protocol tests prove execution/editor behavior, but not request-type creation UX. | ## Implementation Plan @@ -44,38 +42,27 @@ Record the benchmark findings in `AGENT_PROGRESS.md` before implementation. Pref | --- | --- | | Creation | User selects HTTP, GraphQL, WebSocket, or gRPC before the starter YAML is written. | | Current type | Editor shows a clear protocol/type control near the request title or primary request controls. | -| Switching | Type control offers compatible conversions and marks destructive conversions with confirmation. | -| Preservation preview | Conversion explains preserved, transformed, and removed fields before applying. | +| Current type | The visual editor shows the saved request type as read-only protocol identity; users create a new request when they need a different protocol. | | Defaults | New templates use realistic schema-valid defaults and line up with demo requests. | -3. Implement schema-aware conversion: - -| Source/Target | Minimum Preservation | -| --- | --- | -| HTTP to GraphQL | Name, URL, method where relevant, headers, auth, params, variables, runtime, settings. | -| GraphQL to HTTP | Name, URL, method, headers, auth, params, variables, runtime, settings; GraphQL query/variables become a safe body representation only with user confirmation. | -| HTTP/GraphQL to WebSocket | Name, URL where compatible, headers, auth, variables, runtime, settings. | -| HTTP/GraphQL to gRPC | Name, auth, variables, runtime, settings; URL/metadata preserved only when semantically valid. | -| WebSocket/gRPC to other protocols | Preserve shared schema fields and require confirmation for protocol-specific message/method/proto data loss. | - -4. Add guardrails: +3. Add guardrails: | Guardrail | Behavior | | --- | --- | -| Non-destructive default | Do not discard protocol-specific fields without explicit confirmation. | -| YAML round-trip | Unknown but schema-valid fields must survive no-op edits and safe conversions. | -| Validation | Converted requests validate against the selected type's schema before saving. | -| Undo | Conversion should be a normal editor/workspace edit that users can undo. | +| Non-destructive default | Do not change `info.type` or protocol roots from the visual editor. | +| YAML round-trip | Unknown but schema-valid fields must survive no-op edits. | +| Validation | Created starter requests validate against the selected type's schema before saving. | +| Manual YAML edits | Manual `type:` changes remain possible through YAML and must keep protocol-aware validation diagnostics. | -5. Add tests and docs: +4. Add tests and docs: | Area | Coverage | | --- | --- | -| Command/service unit tests | Creation templates and conversion helpers for all supported request types. | -| Editor/webview tests | Type selector rendering, conversion confirmation messaging, and save payloads. | -| Round-trip tests | Converted requests preserve shared fields and do not leak old protocol roots. | +| Command/service unit tests | Creation templates for all supported request types. | +| Editor/webview tests | Type identity rendering and save payloads. | +| Round-trip tests | Created and existing protocol requests preserve shared/schema-valid fields and do not leak stale protocol roots. | | Regression tests | Existing HTTP, GraphQL, WebSocket, and gRPC fixtures still load, edit, validate, and execute. | -| User docs | Briefly document how to create and switch request types, including destructive conversion warnings. | +| User docs | Briefly document how to create request types and that existing request type changes are done by editing YAML or creating a new request. | ## Acceptance Criteria @@ -83,7 +70,7 @@ Record the benchmark findings in `AGENT_PROGRESS.md` before implementation. Pref | --- | --- | | Protocol creation | UI can create schema-valid HTTP, GraphQL, WebSocket, and gRPC requests. | | Type visibility | Visual editor makes the current request type obvious without requiring YAML inspection. | -| Safe switching | Users can switch request type with clear preservation/loss preview and confirmation for destructive conversions. | +| No unsafe switching | Visual editor does not present a saved-request type switcher; request type remains schema-owned and visible. | | Schema alignment | Saved YAML uses the OpenCollection `type:` field and protocol-specific roots correctly. | | Industry alignment | Progress log records Bruno/Postman UX observations and the resulting Missio decisions. | | Tests | Complete automated tests cover creation, conversion, validation, round-trip preservation, and regressions for existing protocol fixtures. | diff --git a/src/commands/requestCommands.ts b/src/commands/requestCommands.ts index d0d1523..546415a 100644 --- a/src/commands/requestCommands.ts +++ b/src/commands/requestCommands.ts @@ -1,11 +1,12 @@ import * as vscode from 'vscode'; import * as path from 'path'; import type { CommandContext } from './types'; -import type { HttpRequest, MissioCollection, OpenCollectionRequest, RequestDefaults } from '../models/types'; +import type { MissioCollection, OpenCollectionRequest, RequestDefaults } from '../models/types'; import { getItemKind, isGraphQLRequest, isGrpcRequest, isHttpRequest, isProtocolRequest, isWebSocketRequest } from '../models/types'; import { RequestEditorProvider } from '../panels/requestPanel'; import { readRequestFile, readFolderFile, stringifyYaml } from '../services/yamlParser'; import { promptForUnresolvedVars } from '../services/unresolvedVars'; +import { createRequestTemplate, REQUEST_PROTOCOL_CHOICES, requestProtocolLabel, slugifyRequestName } from '../services/requestTemplates'; export function registerRequestCommands(ctx: CommandContext): vscode.Disposable[] { const { collectionService, httpClient, requestExecutionService, responseProvider } = ctx; @@ -160,32 +161,35 @@ export function registerRequestCommands(ctx: CommandContext): vscode.Disposable[ targetDir = collection.rootDir; } + const protocolPick = await vscode.window.showQuickPick( + REQUEST_PROTOCOL_CHOICES.map(choice => ({ + label: choice.label, + description: choice.description, + protocol: choice.protocol, + })), + { placeHolder: 'Select request type' }, + ); + if (!protocolPick) { return; } + + const protocolLabel = requestProtocolLabel(protocolPick.protocol); const name = await vscode.window.showInputBox({ - prompt: 'Request name', - placeHolder: 'get-users', + prompt: `${protocolLabel} request name`, + placeHolder: protocolPick.protocol === 'graphql' + ? 'graphql-health' + : protocolPick.protocol === 'websocket' + ? 'socket-echo' + : protocolPick.protocol === 'grpc' + ? 'echo-unary' + : 'get-users', }); if (!name) { return; } if (!targetDir) { return; } - const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); + const slug = slugifyRequestName(name); const fileName = `${slug}.yml`; const filePath = path.join(targetDir, fileName); - const template: HttpRequest = { - info: { name, type: 'http', seq: 1 }, - http: { - method: 'GET', - url: '{{baseUrl}}/', - headers: [], - params: [], - }, - settings: { - encodeUrl: true, - timeout: 30000, - followRedirects: true, - maxRedirects: 5, - }, - }; + const template = createRequestTemplate(protocolPick.protocol, name); const content = stringifyYaml(template, { lineWidth: 120 }); await vscode.workspace.fs.writeFile(vscode.Uri.file(filePath), Buffer.from(content, 'utf-8')); diff --git a/src/panels/requestPanel.ts b/src/panels/requestPanel.ts index 2f6195f..fa8d55f 100644 --- a/src/panels/requestPanel.ts +++ b/src/panels/requestPanel.ts @@ -3,7 +3,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { parse as parseYaml } from 'yaml'; import type { HttpRequest, OpenCollectionRequest, RequestDefaults, MissioCollection } from '../models/types'; -import { getItemKind, isGraphQLRequest, isHttpRequest, isProtocolRequest, isWebSocketRequest } from '../models/types'; +import { getItemKind, isGraphQLRequest, isGrpcRequest, isHttpRequest, isProtocolRequest, isWebSocketRequest } from '../models/types'; import { requestLog, type ResolvedRequest } from '../services/httpClient'; import type { RequestExecutionService } from '../services/requestExecutionService'; import { exportRequest, findTarget, EXPORT_TARGETS } from '../services/snippetExporter'; @@ -135,13 +135,16 @@ export class RequestEditorProvider extends BaseEditorProvider { ? 'graphql' : isWebSocketRequest(current) ? 'websocket' - : isHttpRequest(current) - ? 'http' - : undefined; + : isGrpcRequest(current) + ? 'grpc' + : isHttpRequest(current) + ? 'http' + : undefined; if (!currentProtocol) return false; if (!isProtocolRequest(next)) return true; if (currentProtocol === 'graphql') return isGraphQLRequest(next); if (currentProtocol === 'websocket') return isWebSocketRequest(next); + if (currentProtocol === 'grpc') return isGrpcRequest(next); return isHttpRequest(next); } @@ -820,7 +823,7 @@ export class RequestEditorProvider extends BaseEditorProvider { - +
HTTP
diff --git a/src/services/requestTemplates.ts b/src/services/requestTemplates.ts new file mode 100644 index 0000000..9605a70 --- /dev/null +++ b/src/services/requestTemplates.ts @@ -0,0 +1,88 @@ +import type { OpenCollectionRequest, RequestProtocol } from '../models/types'; + +export interface RequestProtocolChoice { + protocol: RequestProtocol; + label: string; + description: string; +} + +export const REQUEST_PROTOCOL_CHOICES: RequestProtocolChoice[] = [ + { protocol: 'http', label: 'HTTP', description: 'REST or HTTP API request' }, + { protocol: 'graphql', label: 'GraphQL', description: 'GraphQL query or mutation request' }, + { protocol: 'websocket', label: 'WebSocket', description: 'WebSocket connect and message request' }, + { protocol: 'grpc', label: 'gRPC', description: 'Unary gRPC request with protobuf configuration' }, +]; + +export function requestProtocolLabel(protocol: RequestProtocol): string { + return REQUEST_PROTOCOL_CHOICES.find(choice => choice.protocol === protocol)?.label ?? protocol.toUpperCase(); +} + +export function slugifyRequestName(name: string): string { + return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'request'; +} + +export function createRequestTemplate(protocol: RequestProtocol, name: string, seq = 1): OpenCollectionRequest { + switch (protocol) { + case 'graphql': + return { + info: { name, type: 'graphql', seq }, + graphql: { + method: 'POST', + url: '{{baseUrl}}/graphql', + body: { + query: [ + 'query Example {', + ' health {', + ' status', + ' }', + '}', + ].join('\n'), + variables: '{}', + }, + }, + settings: { + encodeUrl: true, + timeout: 30000, + followRedirects: true, + maxRedirects: 5, + }, + }; + case 'websocket': + return { + info: { name, type: 'websocket', seq }, + websocket: { + url: '{{wsBaseUrl}}/ws/echo', + message: { + type: 'text', + data: 'hello', + }, + }, + }; + case 'grpc': + return { + info: { name, type: 'grpc', seq }, + grpc: { + url: '{{grpcBaseUrl}}', + method: 'package.Service/Method', + methodType: 'unary', + protoFilePath: 'proto/service.proto', + message: '{}', + }, + }; + case 'http': + default: + return { + info: { name, type: 'http', seq }, + http: { + method: 'GET', + url: '{{baseUrl}}/', + }, + settings: { + encodeUrl: true, + timeout: 30000, + followRedirects: true, + maxRedirects: 5, + }, + }; + } +} diff --git a/src/webview/requestPanel.css b/src/webview/requestPanel.css index 0258c98..59ff08c 100644 --- a/src/webview/requestPanel.css +++ b/src/webview/requestPanel.css @@ -76,7 +76,7 @@ body { min-width: 100px; } .protocol-chip { - min-width: 72px; + min-width: 92px; background: var(--input-bg); color: var(--vscode-textLink-foreground, #4fc1ff); border: 1px solid var(--input-border); @@ -89,6 +89,8 @@ body { justify-content: center; line-height: 1.4; box-sizing: border-box; + flex-shrink: 0; + white-space: nowrap; } .method-picker .method-select { width: 100%; diff --git a/src/webview/requestPanel.ts b/src/webview/requestPanel.ts index f94fc47..495e15b 100644 --- a/src/webview/requestPanel.ts +++ b/src/webview/requestPanel.ts @@ -78,6 +78,7 @@ import { canFormatRawBody, formatRawBody } from './requestBodyFormatter'; import { applyRequestEditorModel, cloneJson, + detectRequestProtocol, isVisualEditableRequest, type FormFieldEditorRow, type KeyValueEditorRow, @@ -88,7 +89,7 @@ import { // ── Document update scheduling ─────────────────── let _selectedBodyVariantIndex: number | undefined; let _selectedFileVariantIndex: number | undefined; -type PanelProtocol = 'http' | 'graphql' | 'websocket'; +type PanelProtocol = 'http' | 'graphql' | 'websocket' | 'grpc'; let _currentProtocol: PanelProtocol = 'http'; function scheduleDocumentUpdate(): void { @@ -628,45 +629,53 @@ function setProtocolUi(protocol: PanelProtocol): void { _currentProtocol = protocol; const isGraphQL = protocol === 'graphql'; const isWebSocket = protocol === 'websocket'; + const isGrpc = protocol === 'grpc'; const methodPicker = $('methodPicker') as HTMLElement; const protocolChip = $('protocolChip') as HTMLElement; - methodPicker.style.display = isWebSocket ? 'none' : ''; - protocolChip.style.display = isWebSocket ? 'flex' : 'none'; - protocolChip.textContent = 'WS'; - - setRequestTabVisible('params', !isWebSocket); - setRequestTabVisible('settings', !isWebSocket); - setRequestTabVisible('export', !isWebSocket); + const protocolLabels: Record = { + http: 'HTTP', + graphql: 'GraphQL', + websocket: 'WebSocket', + grpc: 'gRPC', + }; + methodPicker.style.display = (isWebSocket || isGrpc) ? 'none' : ''; + protocolChip.style.display = 'flex'; + protocolChip.textContent = protocolLabels[protocol]; + protocolChip.dataset.protocol = protocol; + + setRequestTabVisible('params', !isWebSocket && !isGrpc); + setRequestTabVisible('settings', !isWebSocket && !isGrpc); + setRequestTabVisible('export', !isWebSocket && !isGrpc); const bodyTab = document.querySelector('#reqTabs [data-tab="body"]'); - if (bodyTab) bodyTab.textContent = isWebSocket ? 'Message' : 'Body'; - if (isWebSocket && ['params', 'settings', 'export'].some(tabId => document.getElementById('panel-' + tabId)?.classList.contains('active'))) { + if (bodyTab) bodyTab.textContent = (isWebSocket || isGrpc) ? 'Message' : 'Body'; + if ((isWebSocket || isGrpc) && ['params', 'settings', 'export'].some(tabId => document.getElementById('panel-' + tabId)?.classList.contains('active'))) { switchTab($('reqTabs'), 'body', reqPanelIds); } - $('bodyTypePills').style.display = (isGraphQL || isWebSocket) ? 'none' : 'flex'; + $('bodyTypePills').style.display = (isGraphQL || isWebSocket || isGrpc) ? 'none' : 'flex'; const bodyData = $('bodyData') as HTMLTextAreaElement; bodyData.placeholder = isGraphQL ? 'query Example { viewer { id name } }' - : isWebSocket + : isWebSocket || isGrpc ? 'Message payload' : ''; (document.getElementById('url') as HTMLElement).setAttribute( 'data-placeholder', - isWebSocket ? '{{wsBaseUrl}}/echo' : '{{baseUrl}}/api/endpoint', + isWebSocket ? '{{wsBaseUrl}}/echo' : isGrpc ? '{{grpcBaseUrl}}' : '{{baseUrl}}/api/endpoint', ); if (!isSending) { - $('sendBtn').textContent = isWebSocket ? 'Connect + Send' : 'Send'; + $('sendBtn').textContent = isWebSocket ? 'Connect + Send' : isGrpc ? 'Invoke' : 'Send'; } - $('saveExampleBtn').style.display = isWebSocket ? 'none' : ''; + $('saveExampleBtn').style.display = (isWebSocket || isGrpc) ? 'none' : ''; $('refreshOAuthRetryBtn').style.display = 'none'; if (isGraphQL) { setCurrentLang('text'); ($('bodyLangMode') as HTMLSelectElement).value = currentLang; setBodyType('raw'); - } else if (isWebSocket) { - setCurrentLang('text'); + } else if (isWebSocket || isGrpc) { + setCurrentLang(isGrpc ? 'json' : 'text'); ($('bodyLangMode') as HTMLSelectElement).value = currentLang; setBodyType('raw'); } else { @@ -1166,7 +1175,7 @@ function setSendingState(sending: boolean): void { } else { btn.classList.remove('sending'); btn.classList.remove('btn-cancel'); - btn.textContent = _currentProtocol === 'websocket' ? 'Connect + Send' : 'Send'; + btn.textContent = _currentProtocol === 'websocket' ? 'Connect + Send' : _currentProtocol === 'grpc' ? 'Invoke' : 'Send'; btn.disabled = false; } } @@ -1186,14 +1195,20 @@ function saveRequest(): void { function loadRequest(req: any): void { setCurrentRequest(req); $('exampleIndicator').style.display = 'none'; - const protocol: PanelProtocol = req.websocket ? 'websocket' : req.graphql ? 'graphql' : 'http'; + const detectedProtocol = detectRequestProtocol(req); + const protocol: PanelProtocol = + detectedProtocol === 'graphql' || detectedProtocol === 'websocket' || detectedProtocol === 'grpc' + ? detectedProtocol + : 'http'; setProtocolUi(protocol); const details = protocol === 'websocket' ? (req.websocket || {}) : protocol === 'graphql' ? (req.graphql || {}) - : (req.http || {}); - if (protocol !== 'websocket') { + : protocol === 'grpc' + ? (req.grpc || {}) + : (req.http || {}); + if (protocol === 'http' || protocol === 'graphql') { (methodSelect as HTMLSelectElement).value = (details.method || (protocol === 'graphql' ? 'POST' : 'GET')).toUpperCase(); updateMethodColor(); } @@ -1218,13 +1233,13 @@ function loadRequest(req: any): void { _selectedBodyVariantIndex = undefined; _selectedFileVariantIndex = undefined; ($('graphqlVariablesData') as HTMLTextAreaElement).value = ''; - const requestBody = protocol === 'websocket' ? details.message : details.body; + const requestBody = (protocol === 'websocket' || protocol === 'grpc') ? details.message : details.body; if (requestBody) { _selectedBodyVariantIndex = Array.isArray(requestBody) ? Math.max(0, requestBody.findIndex((v: any) => v.selected)) : undefined; const body = Array.isArray(requestBody) - ? (protocol === 'websocket' + ? (protocol === 'websocket' || protocol === 'grpc' ? requestBody[_selectedBodyVariantIndex ?? 0]?.message : requestBody[_selectedBodyVariantIndex ?? 0]?.body) : requestBody; @@ -1245,6 +1260,13 @@ function loadRequest(req: any): void { updateBodyFormatterState(); ($('bodyData') as HTMLTextAreaElement).value = body.data ?? ''; syncHighlight(); + } else if (protocol === 'grpc') { + setBodyType('raw'); + setCurrentLang('json'); + ($('bodyLangMode') as HTMLSelectElement).value = currentLang; + updateBodyFormatterState(); + ($('bodyData') as HTMLTextAreaElement).value = typeof body === 'string' ? body : (body.message ?? ''); + syncHighlight(); } else if (body.type === 'form-urlencoded' || body.type === 'multipart-form') { setBodyType(body.type); $('bodyFormBody').innerHTML = ''; @@ -1271,7 +1293,7 @@ function loadRequest(req: any): void { setBodyType('raw'); ($('bodyData') as HTMLTextAreaElement).value = ''; syncHighlight(); - } else if (protocol === 'websocket') { + } else if (protocol === 'websocket' || protocol === 'grpc') { setBodyType('raw'); ($('bodyData') as HTMLTextAreaElement).value = ''; syncHighlight(); diff --git a/test/mocks/vscode.ts b/test/mocks/vscode.ts index fdd9b59..8d2af10 100644 --- a/test/mocks/vscode.ts +++ b/test/mocks/vscode.ts @@ -42,6 +42,7 @@ export const window = { showInformationMessage: async () => {}, showWarningMessage: async () => {}, showErrorMessage: async () => {}, + showQuickPick: async () => undefined, showInputBox: async () => undefined, createOutputChannel: () => ({ appendLine: () => {}, dispose: () => {} }), createStatusBarItem: () => ({ show: () => {}, hide: () => {}, dispose: () => {} }), diff --git a/test/requestTypeUx.test.ts b/test/requestTypeUx.test.ts new file mode 100644 index 0000000..b3e50bd --- /dev/null +++ b/test/requestTypeUx.test.ts @@ -0,0 +1,180 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import * as path from 'path'; +import Ajv from 'ajv'; +import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; +import * as vscode from 'vscode'; +import { + applyRequestEditorModel, + createRequestEditorModelFromRequest, +} from '../src/models/schemaRoundTrip'; +import type { MissioCollection, RequestProtocol } from '../src/models/types'; +import { registerRequestCommands } from '../src/commands/requestCommands'; +import { RequestEditorProvider } from '../src/panels/requestPanel'; +import { + createRequestTemplate, + REQUEST_PROTOCOL_CHOICES, + requestProtocolLabel, + slugifyRequestName, +} from '../src/services/requestTemplates'; + +const schema = require('../schema/opencollectionschema.json'); + +const schemaByProtocol: Record = { + http: 'HttpRequest', + graphql: 'GraphQLRequest', + websocket: 'WebSocketRequest', + grpc: 'GrpcRequest', +}; + +const protocolRoots: RequestProtocol[] = ['http', 'graphql', 'websocket', 'grpc']; + +function validateSubschema(protocol: RequestProtocol, data: unknown): void { + const ajv = new Ajv({ allErrors: true, strict: false }); + const defName = schemaByProtocol[protocol]; + const validate = ajv.compile({ + $schema: schema.$schema, + $id: `${schema.$id}#test-${defName}`, + $ref: `${schema.$id}#/$defs/${defName}`, + $defs: schema.$defs, + }); + expect(validate(data), JSON.stringify(validate.errors, null, 2)).toBe(true); +} + +function makeCollection(rootDir: string): MissioCollection { + return { + id: path.join(rootDir, 'opencollection.yml'), + filePath: path.join(rootDir, 'opencollection.yml'), + rootDir, + data: { + opencollection: '1.0.0', + info: { name: 'Request Type UX Test' }, + }, + }; +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('request type UX templates', () => { + it('builds schema-valid starter requests for every supported protocol', () => { + for (const choice of REQUEST_PROTOCOL_CHOICES) { + const request = createRequestTemplate(choice.protocol, `${choice.label} Example`); + const yamlRoundTrip = parseYaml(stringifyYaml(request, { lineWidth: 120 })); + + expect((request as any).info).toMatchObject({ + name: `${choice.label} Example`, + type: choice.protocol, + }); + expect(protocolRoots.filter(root => Object.prototype.hasOwnProperty.call(request, root))).toEqual([choice.protocol]); + expect(yamlRoundTrip).toEqual(request); + validateSubschema(choice.protocol, request); + } + }); + + it('round-trips created starters through the visual editor model without stale protocol roots', () => { + for (const choice of REQUEST_PROTOCOL_CHOICES) { + const request = createRequestTemplate(choice.protocol, `${choice.label} Round Trip`); + const model = createRequestEditorModelFromRequest(request); + const updated = applyRequestEditorModel(request, model); + + expect(updated).toEqual(request); + expect(protocolRoots.filter(root => Object.prototype.hasOwnProperty.call(updated as object, root))).toEqual([choice.protocol]); + validateSubschema(choice.protocol, updated); + } + }); + + it('uses stable labels and filesystem-safe slugs for request creation', () => { + expect(requestProtocolLabel('grpc')).toBe('gRPC'); + expect(slugifyRequestName('Echo unary / metadata')).toBe('echo-unary-metadata'); + expect(slugifyRequestName('***')).toBe('request'); + }); +}); + +describe('new request command protocol selection', () => { + it('writes a schema-valid gRPC starter selected from the creation picker', async () => { + const rootDir = path.join(process.cwd(), 'tmp-request-type-ux'); + const handlers = new Map unknown>(); + vi.spyOn(vscode.commands, 'registerCommand').mockImplementation((name: string, callback: (...args: any[]) => unknown) => { + handlers.set(name, callback); + return { dispose: () => {} } as any; + }); + vi.spyOn(vscode.window, 'showQuickPick').mockResolvedValue({ + label: 'gRPC', + description: 'Unary gRPC request with protobuf configuration', + protocol: 'grpc', + } as any); + vi.spyOn(vscode.window, 'showInputBox').mockResolvedValue('Echo unary / metadata'); + const writeFile = vi.spyOn(vscode.workspace.fs, 'writeFile').mockResolvedValue(undefined as any); + const open = vi.spyOn(RequestEditorProvider, 'open').mockResolvedValue(undefined); + + registerRequestCommands({ + collectionService: { + getCollections: () => [makeCollection(rootDir)], + loadRequestFile: vi.fn(), + }, + environmentService: {}, + httpClient: {}, + requestExecutionService: { cancelAll: vi.fn() }, + responseProvider: {}, + collectionTreeProvider: {}, + extensionContext: {}, + } as any); + + const newRequest = handlers.get('missio.newRequest'); + expect(newRequest).toBeDefined(); + await newRequest?.({ collection: { rootDir } }); + + expect(vscode.window.showQuickPick).toHaveBeenCalledWith( + expect.arrayContaining([expect.objectContaining({ label: 'HTTP', protocol: 'http' })]), + { placeHolder: 'Select request type' }, + ); + expect(writeFile).toHaveBeenCalledOnce(); + const [uri, content] = writeFile.mock.calls[0]; + expect(uri.fsPath).toBe(path.join(rootDir, 'echo-unary-metadata.yml')); + const request = parseYaml(Buffer.from(content as Uint8Array).toString('utf-8')); + expect(request.info.type).toBe('grpc'); + expect(request.grpc).toMatchObject({ + url: '{{grpcBaseUrl}}', + methodType: 'unary', + protoFilePath: 'proto/service.proto', + }); + expect(request.http).toBeUndefined(); + validateSubschema('grpc', request); + expect(open).toHaveBeenCalledWith(path.join(rootDir, 'echo-unary-metadata.yml')); + }); +}); + +describe('request editor protocol identity guard', () => { + it('allows same-protocol gRPC payloads but rejects protocol switches', () => { + const provider = new RequestEditorProvider( + { extensionUri: { fsPath: process.cwd() } } as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + ); + const current = createRequestTemplate('grpc', 'Echo unary'); + + expect((provider as any)._canApplyRequestEdit(current, createRequestTemplate('grpc', 'Echo unary edited'))).toBe(true); + expect((provider as any)._canApplyRequestEdit(current, createRequestTemplate('http', 'Echo unary'))).toBe(false); + }); + + it('renders a read-only protocol chip in the request editor shell', () => { + const provider = new RequestEditorProvider( + { extensionUri: { fsPath: process.cwd() } } as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + ); + + const html = (provider as any)._getBodyHtml({} as any) as string; + + expect(html).toContain('id="protocolChip"'); + expect(html).toContain('aria-label="Request type"'); + expect(html).not.toContain('id="requestTypeSwitcher"'); + }); +}); From 5b54570ee64b3a4d0ec0599d0daefc5c01031831 Mon Sep 17 00:00:00 2001 From: Chris Johnstone Date: Mon, 15 Jun 2026 00:18:15 +1200 Subject: [PATCH 2/4] Record OC-100 completion --- .../AGENT_PROGRESS.md | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/docs/open-collection-gap-analysis/AGENT_PROGRESS.md b/docs/open-collection-gap-analysis/AGENT_PROGRESS.md index 40c91db..96a8e49 100644 --- a/docs/open-collection-gap-analysis/AGENT_PROGRESS.md +++ b/docs/open-collection-gap-analysis/AGENT_PROGRESS.md @@ -67,7 +67,7 @@ Use full branch names for stacking existing branches with `but move Date: Mon, 15 Jun 2026 00:47:00 +1200 Subject: [PATCH 3/4] Polish request protocol indicator --- .../AGENT_PROGRESS.md | 22 ++++++++++- package.json | 32 +++++++++++++++ src/panels/basePanel.ts | 3 ++ src/panels/requestPanel.ts | 6 ++- src/webview/requestPanel.css | 39 ++++++++++--------- src/webview/requestPanel.ts | 15 +++++-- src/webview/theme.css | 12 ++++++ test/requestTypeUx.test.ts | 23 +++++++++-- 8 files changed, 123 insertions(+), 29 deletions(-) diff --git a/docs/open-collection-gap-analysis/AGENT_PROGRESS.md b/docs/open-collection-gap-analysis/AGENT_PROGRESS.md index 96a8e49..17195fb 100644 --- a/docs/open-collection-gap-analysis/AGENT_PROGRESS.md +++ b/docs/open-collection-gap-analysis/AGENT_PROGRESS.md @@ -64,7 +64,7 @@ Use full branch names for stacking existing branches with `but move OPTIONS -
HTTP
-
+
+ +
+
diff --git a/src/webview/requestPanel.css b/src/webview/requestPanel.css index 59ff08c..9b6c4f7 100644 --- a/src/webview/requestPanel.css +++ b/src/webview/requestPanel.css @@ -75,23 +75,6 @@ body { position: relative; min-width: 100px; } -.protocol-chip { - min-width: 92px; - background: var(--input-bg); - color: var(--vscode-textLink-foreground, #4fc1ff); - border: 1px solid var(--input-border); - border-radius: 4px; - padding: 6px 10px; - font-size: 14px; - font-weight: 600; - font-family: var(--vscode-editor-font-family, monospace); - align-items: center; - justify-content: center; - line-height: 1.4; - box-sizing: border-box; - flex-shrink: 0; - white-space: nowrap; -} .method-picker .method-select { width: 100%; } @@ -219,13 +202,33 @@ body { position: relative; min-width: 0; } +.protocol-icon { + position: absolute; + left: 10px; + top: 50%; + width: 16px; + height: 16px; + transform: translateY(-50%); + z-index: 101; + pointer-events: none; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 14px; + line-height: 1; + opacity: 0.95; +} +.protocol-icon-http { color: var(--m-protocol-http); } +.protocol-icon-graphql { color: var(--m-protocol-graphql); } +.protocol-icon-websocket { color: var(--m-protocol-websocket); } +.protocol-icon-grpc { color: var(--m-protocol-grpc); } .url-input { width: 100%; height: 100%; background: var(--input-bg); color: var(--input-fg); border: 1px solid var(--input-border); - padding: 6px 10px; + padding: 6px 10px 6px 34px; border-radius: 4px; font-size: 12px; font-family: var(--vscode-editor-font-family, monospace); diff --git a/src/webview/requestPanel.ts b/src/webview/requestPanel.ts index 495e15b..902bfb0 100644 --- a/src/webview/requestPanel.ts +++ b/src/webview/requestPanel.ts @@ -631,17 +631,24 @@ function setProtocolUi(protocol: PanelProtocol): void { const isWebSocket = protocol === 'websocket'; const isGrpc = protocol === 'grpc'; const methodPicker = $('methodPicker') as HTMLElement; - const protocolChip = $('protocolChip') as HTMLElement; + const protocolIcon = $('protocolIcon') as HTMLElement; const protocolLabels: Record = { http: 'HTTP', graphql: 'GraphQL', websocket: 'WebSocket', grpc: 'gRPC', }; + const protocolIcons: Record = { + http: 'globe', + graphql: 'type-hierarchy', + websocket: 'plug', + grpc: 'radio-tower', + }; methodPicker.style.display = (isWebSocket || isGrpc) ? 'none' : ''; - protocolChip.style.display = 'flex'; - protocolChip.textContent = protocolLabels[protocol]; - protocolChip.dataset.protocol = protocol; + protocolIcon.className = 'codicon codicon-' + protocolIcons[protocol] + ' protocol-icon protocol-icon-' + protocol; + protocolIcon.dataset.protocol = protocol; + protocolIcon.setAttribute('aria-label', protocolLabels[protocol] + ' request type'); + protocolIcon.setAttribute('title', protocolLabels[protocol] + ' request type'); setRequestTabVisible('params', !isWebSocket && !isGrpc); setRequestTabVisible('settings', !isWebSocket && !isGrpc); diff --git a/src/webview/theme.css b/src/webview/theme.css index cdb8d46..36d5742 100644 --- a/src/webview/theme.css +++ b/src/webview/theme.css @@ -55,6 +55,12 @@ --m-method-head: var(--vscode-missio-methodHead, #9cd8a4); --m-method-options: var(--vscode-missio-methodOptions, #d16faa); + /* Request protocols */ + --m-protocol-http: var(--vscode-missio-protocolHttp, #8aabeb); + --m-protocol-graphql: var(--vscode-missio-protocolGraphql, #d16faa); + --m-protocol-websocket: var(--vscode-missio-protocolWebsocket, #4ec9b0); + --m-protocol-grpc: var(--vscode-missio-protocolGrpc, #b9aad9); + /* Syntax tokens */ --m-tk-key: #9cdcfe; --m-tk-str: #ce9178; @@ -164,6 +170,12 @@ body[data-vscode-theme-kind="vscode-high-contrast-light"] { --m-method-head: var(--vscode-missio-methodHead, #2e7d32); --m-method-options: var(--vscode-missio-methodOptions, #ad1457); + /* Request protocols */ + --m-protocol-http: var(--vscode-missio-protocolHttp, #1565c0); + --m-protocol-graphql: var(--vscode-missio-protocolGraphql, #ad1457); + --m-protocol-websocket: var(--vscode-missio-protocolWebsocket, #00796b); + --m-protocol-grpc: var(--vscode-missio-protocolGrpc, #6a1b9a); + /* Variable highlighting */ --m-var-color: #795e26; --m-var-bg: rgba(121,94,38,0.08); diff --git a/test/requestTypeUx.test.ts b/test/requestTypeUx.test.ts index b3e50bd..c94f98d 100644 --- a/test/requestTypeUx.test.ts +++ b/test/requestTypeUx.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; +import * as fs from 'fs'; import * as path from 'path'; import Ajv from 'ajv'; import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; @@ -161,7 +162,7 @@ describe('request editor protocol identity guard', () => { expect((provider as any)._canApplyRequestEdit(current, createRequestTemplate('http', 'Echo unary'))).toBe(false); }); - it('renders a read-only protocol chip in the request editor shell', () => { + it('renders a read-only protocol icon inside the request URL field', () => { const provider = new RequestEditorProvider( { extensionUri: { fsPath: process.cwd() } } as any, {} as any, @@ -173,8 +174,24 @@ describe('request editor protocol identity guard', () => { const html = (provider as any)._getBodyHtml({} as any) as string; - expect(html).toContain('id="protocolChip"'); - expect(html).toContain('aria-label="Request type"'); + expect(html).toMatch(/
[\s\S]*id="protocolIcon"[\s\S]*id="url"/); + expect(html).toContain('id="protocolIcon"'); + expect(html).toContain('role="img"'); + expect(html).toContain('aria-label="HTTP request type"'); + expect(html).not.toContain('protocol-chip'); + expect(html).not.toContain('id="protocolChip"'); expect(html).not.toContain('id="requestTypeSwitcher"'); }); + + it('defines request protocol colors in centralized theme surfaces', () => { + const themeCss = fs.readFileSync(path.join(process.cwd(), 'src', 'webview', 'theme.css'), 'utf8'); + const pkg = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'package.json'), 'utf8')); + const contributedColorIds = new Set((pkg.contributes.colors as Array<{ id: string }>).map(color => color.id)); + + for (const id of ['Http', 'Graphql', 'Websocket', 'Grpc']) { + expect(themeCss).toContain(`--m-protocol-${id.toLowerCase()}`); + expect(themeCss).toContain(`--vscode-missio-protocol${id}`); + expect(contributedColorIds.has(`missio.protocol${id}`)).toBe(true); + } + }); }); From 2a70d052c91cbf866f3e1ff20cf57f207dbb7e19 Mon Sep 17 00:00:00 2001 From: Chris Johnstone Date: Mon, 15 Jun 2026 01:11:11 +1200 Subject: [PATCH 4/4] Align request body toolbar actions --- src/webview/requestPanel.css | 4 ++-- test/requestTypeUx.test.ts | 7 +++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/webview/requestPanel.css b/src/webview/requestPanel.css index 9b6c4f7..9b0f815 100644 --- a/src/webview/requestPanel.css +++ b/src/webview/requestPanel.css @@ -778,9 +778,9 @@ td.var-cell .var-overlay { .add-row-btn:hover { border-color: var(--btn-bg); background: rgba(0,120,212,0.05); } /* ── Body Type Pills ────────────────────── */ -.body-toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; justify-content: space-between; } +.body-toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; justify-content: flex-start; } .body-type-pills { display: flex; border: 1px solid var(--input-border); border-radius: 4px; overflow: hidden; } -.body-toolbar-actions { display: flex; align-items: center; gap: 8px; } +.body-toolbar-actions { display: flex; align-items: center; gap: 8px; margin-left: auto; } .body-type-pills .pill { padding: 5px 12px; background: transparent; diff --git a/test/requestTypeUx.test.ts b/test/requestTypeUx.test.ts index c94f98d..c5cc4b8 100644 --- a/test/requestTypeUx.test.ts +++ b/test/requestTypeUx.test.ts @@ -194,4 +194,11 @@ describe('request editor protocol identity guard', () => { expect(contributedColorIds.has(`missio.protocol${id}`)).toBe(true); } }); + + it('keeps request body formatting actions right aligned when body type pills are hidden', () => { + const requestPanelCss = fs.readFileSync(path.join(process.cwd(), 'src', 'webview', 'requestPanel.css'), 'utf8'); + + expect(requestPanelCss).toMatch(/\.body-toolbar\s*\{[^}]*justify-content:\s*flex-start;/); + expect(requestPanelCss).toMatch(/\.body-toolbar-actions\s*\{[^}]*margin-left:\s*auto;/); + }); });