From 1dbc25d569de574b1460eac101255b996fc68e1a Mon Sep 17 00:00:00 2001 From: Chris Johnstone Date: Mon, 15 Jun 2026 12:02:46 +1200 Subject: [PATCH 1/4] Add OC-130 protocol first paint stability --- .../AGENT_PROGRESS.md | 8 + src/panels/requestPanel.ts | 23 +- src/webview/requestPanel.css | 70 ++++ src/webview/requestPanel.ts | 54 +++- test/protocolLayoutStability.test.ts | 300 ++++++++++++++++++ test/requestTypeUx.test.ts | 3 +- 6 files changed, 447 insertions(+), 11 deletions(-) create mode 100644 test/protocolLayoutStability.test.ts diff --git a/docs/open-collection-gap-analysis/AGENT_PROGRESS.md b/docs/open-collection-gap-analysis/AGENT_PROGRESS.md index c07cc79..39364c0 100644 --- a/docs/open-collection-gap-analysis/AGENT_PROGRESS.md +++ b/docs/open-collection-gap-analysis/AGENT_PROGRESS.md @@ -71,6 +71,7 @@ Use full branch names for stacking existing branches with `but move +
+
+ +
+
Loading request
+
Preparing editor...
+
+
+
@@ -837,8 +853,8 @@ window.missioPdfJsReady = import('${pdfJsUri}')
- -
+ +
@@ -1083,6 +1099,7 @@ window.missioPdfJsReady = import('${pdfJsUri}')
+ `; } } diff --git a/src/webview/requestPanel.css b/src/webview/requestPanel.css index a2bd49d..1acc07a 100644 --- a/src/webview/requestPanel.css +++ b/src/webview/requestPanel.css @@ -62,6 +62,75 @@ body { overflow: hidden; } +.request-editor-shell { + position: relative; + height: 100vh; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.request-editor-shell.is-hydrating .url-bar, +.request-editor-shell.is-hydrating .main-content, +.request-editor-shell.is-invalid-yaml .url-bar, +.request-editor-shell.is-invalid-yaml .main-content { + visibility: hidden; + pointer-events: none; +} + +.request-startup-shell { + position: absolute; + inset: 0; + z-index: 20; + display: flex; + align-items: flex-start; + justify-content: center; + padding: 12px 16px; + background: var(--bg); +} + +.request-editor-shell.is-ready .request-startup-shell { + display: none; +} + +.request-startup-card { + width: min(100%, 520px); + min-height: 44px; + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--input-bg); + color: var(--fg); +} + +.request-startup-icon { + color: var(--m-fg-muted); + font-size: 16px; + flex: 0 0 auto; +} + +.request-startup-title { + font-size: 12px; + font-weight: 700; + line-height: 1.3; +} + +.request-startup-detail { + margin-top: 2px; + font-size: 12px; + line-height: 1.35; + color: var(--m-fg-muted); + overflow-wrap: anywhere; +} + +.request-editor-shell.is-invalid-yaml .request-startup-icon { + color: var(--badge-warn); +} + /* ── URL Bar ─────────────────────────────── */ .url-bar { display: flex; @@ -222,6 +291,7 @@ body { .protocol-icon-graphql { color: var(--m-protocol-graphql); } .protocol-icon-websocket { color: var(--m-protocol-websocket); } .protocol-icon-grpc { color: var(--m-protocol-grpc); } +.protocol-icon-pending { color: var(--m-fg-muted); } .url-input { width: 100%; height: 100%; diff --git a/src/webview/requestPanel.ts b/src/webview/requestPanel.ts index 82f137a..97a9f74 100644 --- a/src/webview/requestPanel.ts +++ b/src/webview/requestPanel.ts @@ -93,6 +93,40 @@ let _selectedFileVariantIndex: number | undefined; type PanelProtocol = 'http' | 'graphql' | 'websocket' | 'grpc'; let _currentProtocol: PanelProtocol = 'http'; +function detectPanelProtocol(req: any): PanelProtocol { + const detectedProtocol = detectRequestProtocol(req); + return detectedProtocol === 'graphql' || detectedProtocol === 'websocket' || detectedProtocol === 'grpc' + ? detectedProtocol + : 'http'; +} + +function setEditorHydrationState( + state: 'pending' | 'ready' | 'invalid', + protocol: PanelProtocol | 'pending' = 'pending', + message?: string, +): void { + const shell = $('requestEditorShell'); + const startup = $('requestStartupShell'); + const title = $('requestStartupTitle'); + const detail = $('requestStartupDetail'); + + shell.classList.toggle('is-hydrating', state === 'pending'); + shell.classList.toggle('is-ready', state === 'ready'); + shell.classList.toggle('is-invalid-yaml', state === 'invalid'); + shell.dataset.hydrationState = state; + shell.dataset.protocol = protocol; + shell.setAttribute('aria-busy', state === 'pending' ? 'true' : 'false'); + + startup.style.display = state === 'ready' ? 'none' : 'flex'; + if (state === 'invalid') { + title.textContent = 'Request YAML could not be loaded'; + detail.textContent = message || 'Fix the YAML source and the editor will reload.'; + } else { + title.textContent = 'Loading request'; + detail.textContent = protocol === 'pending' ? 'Preparing editor...' : 'Preparing ' + protocol + ' editor...'; + } +} + function scheduleDocumentUpdate(): void { if (updateDocumentTimer) clearTimeout(updateDocumentTimer); setUpdateDocumentTimer(setTimeout(() => { @@ -1435,14 +1469,10 @@ function saveRequest(): void { } // ── Load request into UI ──────────────────────── -function loadRequest(req: any): void { +function loadRequest(req: any): PanelProtocol { setCurrentRequest(req); $('exampleIndicator').style.display = 'none'; - const detectedProtocol = detectRequestProtocol(req); - const protocol: PanelProtocol = - detectedProtocol === 'graphql' || detectedProtocol === 'websocket' || detectedProtocol === 'grpc' - ? detectedProtocol - : 'http'; + const protocol = detectPanelProtocol(req); setProtocolUi(protocol); const details = protocol === 'websocket' ? (req.websocket || {}) @@ -1574,6 +1604,7 @@ function loadRequest(req: any): void { $input('settingMaxRedirects').value = settings.maxRedirects !== undefined && settings.maxRedirects !== 'inherit' ? settings.maxRedirects : '5'; updateBadges(); + return protocol; } // ── CLI Approval Modal ─────────────────────────── @@ -1767,7 +1798,16 @@ window.addEventListener('message', (event: MessageEvent) => { setIgnoreNextLoad(false); break; } - loadRequest(msg.request); + try { + const protocol = loadRequest(msg.request); + setEditorHydrationState('ready', protocol); + } catch (error) { + console.error('Failed to render request editor state', error); + setEditorHydrationState('invalid', 'pending', 'Unable to render this request.'); + } + break; + case 'requestLoadError': + setEditorHydrationState('invalid', 'pending', msg.message); break; case 'response': $('exampleIndicator').style.display = 'none'; diff --git a/test/protocolLayoutStability.test.ts b/test/protocolLayoutStability.test.ts new file mode 100644 index 0000000..b7ee999 --- /dev/null +++ b/test/protocolLayoutStability.test.ts @@ -0,0 +1,300 @@ +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'; +import { JSDOM } from 'jsdom'; +import { + applyRequestEditorModel, + createRequestEditorModelFromRequest, +} from '../src/models/schemaRoundTrip'; +import type { RequestProtocol } from '../src/models/types'; +import { RequestEditorProvider } from '../src/panels/requestPanel'; + +const schema = require('../schema/opencollectionschema.json'); +const protocolRoots: RequestProtocol[] = ['http', 'graphql', 'websocket', 'grpc']; +const schemaByProtocol: Record = { + http: 'HttpRequest', + graphql: 'GraphQLRequest', + websocket: 'WebSocketRequest', + grpc: 'GrpcRequest', +}; + +function makeProvider(): RequestEditorProvider { + return new RequestEditorProvider( + { extensionUri: { fsPath: process.cwd() } } as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + ); +} + +function requestForProtocol(protocol: RequestProtocol): any { + if (protocol === 'graphql') { + return { + info: { name: 'GraphQL health', type: 'graphql' }, + graphql: { + method: 'POST', + url: '{{baseUrl}}/graphql', + headers: [{ name: 'X-Trace', value: '{{traceId}}' }], + body: { query: 'query Health { health { status } }', variables: '{"trace":"{{traceId}}"}' }, + }, + runtime: { scripts: [{ type: 'tests', code: 'test("ok", () => assert(true));' }] }, + settings: { timeout: 5000 }, + }; + } + + if (protocol === 'websocket') { + return { + info: { name: 'Socket echo', type: 'websocket' }, + websocket: { + url: '{{wsBaseUrl}}/ws/echo', + headers: [{ name: 'X-Trace', value: '{{traceId}}' }], + message: { type: 'json', data: '{"ping":true}' }, + }, + runtime: { scripts: [{ type: 'after-response', code: 'console.log("socket");' }] }, + }; + } + + if (protocol === 'grpc') { + return { + info: { name: 'gRPC echo', type: 'grpc' }, + grpc: { + url: '{{grpcBaseUrl}}', + method: 'missio.demo.DemoService/EchoUnary', + methodType: 'unary', + protoFilePath: 'proto/services/missio_demo.proto', + metadata: [{ name: 'x-trace-id', value: '{{traceId}}' }], + message: '{"name":"Ada","trace":{"requestId":"{{traceId}}"}}', + }, + runtime: { scripts: [{ type: 'before-request', code: 'console.log("grpc");' }] }, + }; + } + + return { + info: { name: 'HTTP health', type: 'http' }, + http: { + method: 'GET', + url: '{{baseUrl}}/health', + headers: [{ name: 'Accept', value: 'application/json' }], + }, + runtime: { scripts: [{ type: 'before-request', code: 'console.log("http");' }] }, + settings: { timeout: 5000 }, + }; +} + +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-oc130-${defName}`, + $ref: `${schema.$id}#/$defs/${defName}`, + $defs: schema.$defs, + }); + expect(validate(data), JSON.stringify(validate.errors, null, 2)).toBe(true); +} + +function mountRequestPanelDom(): { dom: JSDOM; messages: unknown[] } { + const body = (makeProvider() as any)._getBodyHtml({} as any) as string; + const dom = new JSDOM(`${body}`, { url: 'https://missio.test' }); + const messages: unknown[] = []; + const win = dom.window as any; + + (globalThis as any).window = win; + (globalThis as any).document = win.document; + (globalThis as any).Node = win.Node; + (globalThis as any).NodeFilter = win.NodeFilter; + (globalThis as any).HTMLElement = win.HTMLElement; + (globalThis as any).HTMLInputElement = win.HTMLInputElement; + (globalThis as any).HTMLTextAreaElement = win.HTMLTextAreaElement; + (globalThis as any).HTMLSelectElement = win.HTMLSelectElement; + (globalThis as any).HTMLButtonElement = win.HTMLButtonElement; + (globalThis as any).HTMLPreElement = win.HTMLPreElement; + (globalThis as any).HTMLImageElement = win.HTMLImageElement; + (globalThis as any).HTMLCanvasElement = win.HTMLCanvasElement; + (globalThis as any).Event = win.Event; + (globalThis as any).KeyboardEvent = win.KeyboardEvent; + (globalThis as any).MouseEvent = win.MouseEvent; + (globalThis as any).WheelEvent = win.WheelEvent; + (globalThis as any).ClipboardEvent = win.ClipboardEvent; + (globalThis as any).DOMParser = win.DOMParser; + (globalThis as any).Image = win.Image; + (globalThis as any).requestAnimationFrame = (callback: FrameRequestCallback) => setTimeout(() => callback(0), 0); + (globalThis as any).cancelAnimationFrame = (handle: ReturnType) => clearTimeout(handle); + (globalThis as any).acquireVsCodeApi = () => ({ + postMessage: (message: unknown) => messages.push(message), + getState: vi.fn(), + setState: vi.fn(), + }); + Object.defineProperty(win.HTMLCanvasElement.prototype, 'getContext', { value: vi.fn(() => ({})), configurable: true }); + return { dom, messages }; +} + +async function loadRequestPanel(): Promise<{ dom: JSDOM; messages: unknown[] }> { + vi.resetModules(); + const mounted = mountRequestPanelDom(); + await import('../src/webview/requestPanel'); + return mounted; +} + +function dispatchPanelMessage(dom: JSDOM, data: unknown): void { + dom.window.dispatchEvent(new dom.window.MessageEvent('message', { data })); +} + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + vi.resetModules(); + for (const key of [ + 'window', + 'document', + 'Node', + 'NodeFilter', + 'HTMLElement', + 'HTMLInputElement', + 'HTMLTextAreaElement', + 'HTMLSelectElement', + 'HTMLButtonElement', + 'HTMLPreElement', + 'HTMLImageElement', + 'HTMLCanvasElement', + 'Event', + 'KeyboardEvent', + 'MouseEvent', + 'WheelEvent', + 'ClipboardEvent', + 'DOMParser', + 'Image', + 'requestAnimationFrame', + 'cancelAnimationFrame', + 'acquireVsCodeApi', + ]) { + delete (globalThis as any)[key]; + } +}); + +describe('OC-130 request editor first paint', () => { + it('renders a neutral startup shell instead of a visible HTTP default', () => { + const html = (makeProvider() as any)._getBodyHtml({} as any) as string; + + expect(html).toContain('id="requestEditorShell"'); + expect(html).toContain('class="request-editor-shell is-hydrating"'); + expect(html).toContain('data-hydration-state="pending"'); + expect(html).toContain('data-protocol="pending"'); + expect(html).toContain('id="requestStartupShell"'); + expect(html).toContain('protocol-icon-pending'); + expect(html).toContain('aria-label="Request type loading"'); + expect(html).not.toContain('aria-label="HTTP request type"'); + }); + + it('hides request controls during pending and invalid states while preserving layout dimensions', () => { + const css = fs.readFileSync(path.join(process.cwd(), 'src', 'webview', 'requestPanel.css'), 'utf8'); + + expect(css).toMatch(/\.request-editor-shell\s*\{[\s\S]*height:\s*100vh;[\s\S]*display:\s*flex;[\s\S]*flex-direction:\s*column;/); + expect(css).toMatch(/\.request-editor-shell\.is-hydrating \.url-bar,[\s\S]*\.request-editor-shell\.is-invalid-yaml \.main-content\s*\{[\s\S]*visibility:\s*hidden;[\s\S]*pointer-events:\s*none;/); + expect(css).toMatch(/\.request-startup-shell\s*\{[\s\S]*position:\s*absolute;[\s\S]*inset:\s*0;[\s\S]*display:\s*flex;/); + expect(css).toMatch(/\.request-startup-card\s*\{[\s\S]*min-height:\s*44px;/); + expect(css).toContain('.protocol-icon-pending'); + }); + + it.each(protocolRoots)('hydrates directly to the %s request layout', async (protocol) => { + const { dom, messages } = await loadRequestPanel(); + const request = requestForProtocol(protocol); + + expect(document.getElementById('requestEditorShell')?.dataset.hydrationState).toBe('pending'); + expect(document.getElementById('protocolIcon')?.className).toContain('protocol-icon-pending'); + expect(messages).toContainEqual({ type: 'ready' }); + + dispatchPanelMessage(dom, { type: 'requestLoaded', request, filePath: `${protocol}.yml` }); + + const shell = document.getElementById('requestEditorShell') as HTMLElement; + const methodPicker = document.getElementById('methodPicker') as HTMLElement; + const protocolIcon = document.getElementById('protocolIcon') as HTMLElement; + const bodyTab = document.querySelector('#reqTabs [data-tab="body"]'); + + expect(shell.dataset.hydrationState).toBe('ready'); + expect(shell.dataset.protocol).toBe(protocol); + expect(shell.classList.contains('is-hydrating')).toBe(false); + expect((document.getElementById('requestStartupShell') as HTMLElement).style.display).toBe('none'); + expect(protocolIcon.dataset.protocol).toBe(protocol); + expect(protocolIcon.className).toContain(`protocol-icon-${protocol}`); + expect(protocolIcon.className).not.toContain('protocol-icon-pending'); + + if (protocol === 'websocket' || protocol === 'grpc') { + expect(methodPicker.style.display).toBe('none'); + expect(bodyTab?.textContent).toBe('Message'); + expect((document.querySelector('#reqTabs [data-tab="params"]') as HTMLElement).style.display).toBe('none'); + expect((document.querySelector('#reqTabs [data-tab="settings"]') as HTMLElement).style.display).toBe('none'); + expect((document.querySelector('#reqTabs [data-tab="export"]') as HTMLElement).style.display).toBe('none'); + expect(document.getElementById('sendBtn')?.textContent).toBe(protocol === 'grpc' ? 'Invoke' : 'Connect + Send'); + } else { + expect(methodPicker.style.display).toBe(''); + expect(bodyTab?.textContent).toBe('Body'); + expect((document.querySelector('#reqTabs [data-tab="params"]') as HTMLElement).style.display).toBe(''); + } + + if (protocol === 'graphql') { + expect((document.getElementById('bodyTypePills') as HTMLElement).style.display).toBe('none'); + expect((document.getElementById('graphqlVariablesEditor') as HTMLElement).style.display).toBe('flex'); + expect((document.getElementById('method') as HTMLSelectElement).value).toBe('POST'); + } + }); + + it('keeps invalid YAML in a neutral fallback instead of revealing HTTP controls', async () => { + const { dom } = await loadRequestPanel(); + + dispatchPanelMessage(dom, { + type: 'requestLoadError', + filePath: 'bad.yml', + message: 'Nested mappings are not allowed in compact mappings', + }); + + const shell = document.getElementById('requestEditorShell') as HTMLElement; + expect(shell.dataset.hydrationState).toBe('invalid'); + expect(shell.dataset.protocol).toBe('pending'); + expect(shell.classList.contains('is-invalid-yaml')).toBe(true); + expect((document.getElementById('requestStartupShell') as HTMLElement).style.display).toBe('flex'); + expect(document.getElementById('requestStartupTitle')?.textContent).toBe('Request YAML could not be loaded'); + expect(document.getElementById('requestStartupDetail')?.textContent).toContain('Nested mappings'); + expect(document.getElementById('protocolIcon')?.className).toContain('protocol-icon-pending'); + }); + + it('sends an explicit invalid-YAML fallback message from the extension host', () => { + const messages: any[] = []; + + (makeProvider() as any)._sendDocumentToWebview( + { postMessage: (message: unknown) => messages.push(message) }, + { + uri: { fsPath: path.join(process.cwd(), 'bad.yml') }, + getText: () => 'info: { name: Bad, type: websocket\nwebsocket: { url: "wss://example.com" }', + }, + ); + + expect(messages).toHaveLength(1); + expect(messages[0]).toMatchObject({ + type: 'requestLoadError', + filePath: path.join(process.cwd(), 'bad.yml'), + }); + expect(messages[0].message).toEqual(expect.any(String)); + }); + + it('preserves protocol round trips and validation for request editor no-op saves', () => { + for (const protocol of protocolRoots) { + const request = requestForProtocol(protocol); + const model = createRequestEditorModelFromRequest(request); + const updated = applyRequestEditorModel(request, model); + const roots = protocolRoots.filter(root => Object.prototype.hasOwnProperty.call(updated as object, root)); + + expect(updated).toEqual(request); + expect(parseYaml(stringifyYaml(updated, { lineWidth: 120 }))).toEqual(updated); + expect(roots).toEqual([protocol]); + if (protocol !== 'http') { + expect((updated as any).http).toBeUndefined(); + } + validateSubschema(protocol, updated); + } + }); +}); diff --git a/test/requestTypeUx.test.ts b/test/requestTypeUx.test.ts index 45728a3..2bdfb70 100644 --- a/test/requestTypeUx.test.ts +++ b/test/requestTypeUx.test.ts @@ -177,7 +177,8 @@ describe('request editor protocol identity guard', () => { 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).toContain('aria-label="Request type loading"'); + expect(html).toContain('protocol-icon-pending'); expect(html).not.toContain('protocol-chip'); expect(html).not.toContain('id="protocolChip"'); expect(html).not.toContain('id="requestTypeSwitcher"'); From d871e2a48e1065cbd6b873799e67392594a81136 Mon Sep 17 00:00:00 2001 From: Chris Johnstone Date: Mon, 15 Jun 2026 12:09:49 +1200 Subject: [PATCH 2/4] Record OC-130 completion ledger --- docs/open-collection-gap-analysis/AGENT_PROGRESS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/open-collection-gap-analysis/AGENT_PROGRESS.md b/docs/open-collection-gap-analysis/AGENT_PROGRESS.md index 39364c0..5210006 100644 --- a/docs/open-collection-gap-analysis/AGENT_PROGRESS.md +++ b/docs/open-collection-gap-analysis/AGENT_PROGRESS.md @@ -70,7 +70,7 @@ Use full branch names for stacking existing branches with `but move Date: Mon, 15 Jun 2026 12:10:11 +1200 Subject: [PATCH 3/4] Record OC-130 completion report --- docs/open-collection-gap-analysis/AGENT_PROGRESS.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/open-collection-gap-analysis/AGENT_PROGRESS.md b/docs/open-collection-gap-analysis/AGENT_PROGRESS.md index 5210006..8641365 100644 --- a/docs/open-collection-gap-analysis/AGENT_PROGRESS.md +++ b/docs/open-collection-gap-analysis/AGENT_PROGRESS.md @@ -133,6 +133,15 @@ Record cross-cutting decisions here so parallel agents do not rediscover them. Verified: task references and goal prompt added; implementation tests not required for docs-only task creation. Next: launch OC-140 after OC-130 settles request editor startup changes, or in parallel only with explicit coordination around shared request editor, WebSocket client, CodeLens, and Copilot surfaces. +### OC-130 Protocol-Native Request Editor First Paint + +- 2026-06-15 12:03 NZT - Codex: Completed OC-130 with a neutral first-paint shell and protocol-native hydration for HTTP, GraphQL, WebSocket, and gRPC request editors. + GitButler: `feature/oc-130-protocol-layout-stability` (g0), stacked on `feature/oc-120-preview-media-controls`; implementation/test commit `a3d3251` (`Add OC-130 protocol first paint stability`). Earlier `but commit --changes ...` failed with `Unexpected hunk with neither newlines or oldlines being 0`, so the owned files were staged by path to g0 and committed with `--only`. + Coverage: implemented `test/protocolLayoutStability.test.ts` for neutral startup markup, hidden pending/invalid controls with stable dimensions, direct protocol hydration for HTTP/GraphQL/WebSocket/gRPC, invalid-YAML fallback, host parse-failure messaging, and no-op round-trip/validation; updated request type UX startup assertions so static HTML is protocol-pending instead of HTTP. + Changed: `src/panels/requestPanel.ts`, `src/webview/requestPanel.ts`, `src/webview/requestPanel.css`, `test/protocolLayoutStability.test.ts`, `test/requestTypeUx.test.ts`, and this ledger. + Verified: `npx vitest run test/protocolLayoutStability.test.ts` passed 9 tests; `npm run compile` passed; focused `npx vitest run test/protocolLayoutStability.test.ts test/requestTypeUx.test.ts test/runtimeAuthoringUx.test.ts test/schemaRoundTrip.test.ts test/validationService.test.ts` passed 5 files/34 tests; targeted `npx vitest run test/protocolLayoutStability.test.ts test/requestTypeUx.test.ts test/runtimeAuthoringUx.test.ts test/graphqlSupport.test.ts test/webSocketSupport.test.ts test/grpcSupport.test.ts test/openCollectionFoundation.test.ts test/schemaRoundTrip.test.ts test/validationService.test.ts` passed 9 files/89 tests; `node scripts\validate-collection.js examples\demo-api` passed 44/44 files; `npm test` passed 25 files/454 tests; `npm run build` passed. + Next: OC-130 is complete. Remaining unassigned changes are parallel/unrelated OC-140 planning IDs `wp`/`tks`/`tkt`/`uxu` and PDF.js media churn IDs `qp`/`ko`. + - 2026-06-15 00:57 NZT - Codex: Added OC-110 as a separate runtime authoring UX task after confirming snippet export belongs to OC-070 and non-HTTP runtime execution belongs to OC-080. GitButler: planning update is on `supervisor/add-runtime-authoring-ux-task`, stacked on `supervisor/oc-050-090-100-audit`; active OC-070 implementation changes in `zz` were not edited or committed. Coverage: documentation-only planning change; no runtime behavior changed. From 9ff4bec90db9482a3f3b1ea59a47acb335dc85b4 Mon Sep 17 00:00:00 2001 From: Chris Johnstone Date: Tue, 21 Jul 2026 17:26:42 +1200 Subject: [PATCH 4/4] Harden OC-130 hydration state --- src/panels/basePanel.ts | 1 + src/webview/requestPanel.ts | 3 +- test/protocolLayoutStability.test.ts | 49 ++++++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 1 deletion(-) diff --git a/src/panels/basePanel.ts b/src/panels/basePanel.ts index bc29ee3..848d8f9 100644 --- a/src/panels/basePanel.ts +++ b/src/panels/basePanel.ts @@ -423,6 +423,7 @@ export abstract class BaseEditorProvider implements vscode.CustomTextEditorProvi .codicon-plug::before { content: '\\eb2d'; } .codicon-radio-tower::before { content: '\\eb34'; } .codicon-type-hierarchy::before { content: '\\ebb9'; } +.codicon-symbol-interface::before { content: '\\eb61'; } .codicon-add::before { content: '\\ea60'; } .codicon-desktop-download::before { content: '\\ec74'; } .codicon-trash::before { content: '\\ea81'; } diff --git a/src/webview/requestPanel.ts b/src/webview/requestPanel.ts index 97a9f74..2872058 100644 --- a/src/webview/requestPanel.ts +++ b/src/webview/requestPanel.ts @@ -1459,6 +1459,7 @@ function setSendingState(sending: boolean): void { // ── Save ──────────────────────────────────────── function saveRequest(): void { + if ($('requestEditorShell').dataset.hydrationState !== 'ready') return; if (updateDocumentTimer) { clearTimeout(updateDocumentTimer); setUpdateDocumentTimer(null); @@ -1796,7 +1797,7 @@ window.addEventListener('message', (event: MessageEvent) => { case 'requestLoaded': if (ignoreNextLoad) { setIgnoreNextLoad(false); - break; + if ($('requestEditorShell').dataset.hydrationState === 'ready') break; } try { const protocol = loadRequest(msg.request); diff --git a/test/protocolLayoutStability.test.ts b/test/protocolLayoutStability.test.ts index b7ee999..e4f8573 100644 --- a/test/protocolLayoutStability.test.ts +++ b/test/protocolLayoutStability.test.ts @@ -192,12 +192,14 @@ describe('OC-130 request editor first paint', () => { it('hides request controls during pending and invalid states while preserving layout dimensions', () => { const css = fs.readFileSync(path.join(process.cwd(), 'src', 'webview', 'requestPanel.css'), 'utf8'); + const panelSource = fs.readFileSync(path.join(process.cwd(), 'src', 'panels', 'basePanel.ts'), 'utf8'); expect(css).toMatch(/\.request-editor-shell\s*\{[\s\S]*height:\s*100vh;[\s\S]*display:\s*flex;[\s\S]*flex-direction:\s*column;/); expect(css).toMatch(/\.request-editor-shell\.is-hydrating \.url-bar,[\s\S]*\.request-editor-shell\.is-invalid-yaml \.main-content\s*\{[\s\S]*visibility:\s*hidden;[\s\S]*pointer-events:\s*none;/); expect(css).toMatch(/\.request-startup-shell\s*\{[\s\S]*position:\s*absolute;[\s\S]*inset:\s*0;[\s\S]*display:\s*flex;/); expect(css).toMatch(/\.request-startup-card\s*\{[\s\S]*min-height:\s*44px;/); expect(css).toContain('.protocol-icon-pending'); + expect(panelSource).toContain(".codicon-symbol-interface::before { content: '\\\\eb61'; }"); }); it.each(protocolRoots)('hydrates directly to the %s request layout', async (protocol) => { @@ -262,6 +264,53 @@ describe('OC-130 request editor first paint', () => { expect(document.getElementById('protocolIcon')?.className).toContain('protocol-icon-pending'); }); + it('blocks Ctrl+S until request hydration is ready', async () => { + const { dom, messages } = await loadRequestPanel(); + const save = () => document.dispatchEvent(new dom.window.KeyboardEvent('keydown', { + key: 's', + ctrlKey: true, + bubbles: true, + cancelable: true, + })); + const savedMessages = () => messages.filter((message: any) => message.type === 'saveDocument'); + + save(); + expect(savedMessages()).toHaveLength(0); + + dispatchPanelMessage(dom, { + type: 'requestLoadError', + filePath: 'bad.yml', + message: 'Nested mappings are not allowed in compact mappings', + }); + save(); + expect(savedMessages()).toHaveLength(0); + + dispatchPanelMessage(dom, { type: 'requestLoaded', request: requestForProtocol('graphql'), filePath: 'graphql.yml' }); + save(); + + expect(savedMessages()).toHaveLength(1); + expect((savedMessages()[0] as any).request.info.type).toBe('graphql'); + }); + + it('recovers from invalid YAML when a previous document update is awaiting its load echo', async () => { + const { dom } = await loadRequestPanel(); + const { setIgnoreNextLoad } = await import('../src/webview/state'); + + setIgnoreNextLoad(true); + dispatchPanelMessage(dom, { + type: 'requestLoadError', + filePath: 'bad.yml', + message: 'Nested mappings are not allowed in compact mappings', + }); + dispatchPanelMessage(dom, { type: 'requestLoaded', request: requestForProtocol('grpc'), filePath: 'grpc.yml' }); + + const shell = document.getElementById('requestEditorShell') as HTMLElement; + expect(shell.dataset.hydrationState).toBe('ready'); + expect(shell.dataset.protocol).toBe('grpc'); + expect(shell.classList.contains('is-invalid-yaml')).toBe(false); + expect((document.getElementById('requestStartupShell') as HTMLElement).style.display).toBe('none'); + }); + it('sends an explicit invalid-YAML fallback message from the extension host', () => { const messages: any[] = [];