diff --git a/src/webview/requestPanel.ts b/src/webview/requestPanel.ts index 9356491..31e89c6 100644 --- a/src/webview/requestPanel.ts +++ b/src/webview/requestPanel.ts @@ -151,6 +151,7 @@ function setEditorHydrationState( title.textContent = 'Loading request'; detail.textContent = protocol === 'pending' ? 'Preparing editor...' : 'Preparing ' + protocol + ' editor...'; } + updateWebSocketControls(); } function scheduleDocumentUpdate(): void { @@ -166,14 +167,40 @@ function webSocketCanSend(): boolean { return _currentProtocol === 'websocket' && _webSocketSession.state === 'connected'; } +function requestActionUnavailableMessage(): string | undefined { + const shell = $('requestEditorShell'); + const state = shell.dataset.hydrationState; + if (state === 'ready') return undefined; + if (state === 'invalid') return 'Request YAML could not be loaded'; + return 'Request editor is loading'; +} + +function requestActionsReady(): boolean { + return requestActionUnavailableMessage() === undefined; +} + +function setActionDisabled(button: HTMLButtonElement, disabled: boolean, title: string): void { + button.disabled = disabled; + if (disabled) { + button.setAttribute('aria-disabled', 'true'); + } else { + button.removeAttribute('aria-disabled'); + } + button.title = title; +} + function updateWebSocketControls(): void { const isWebSocket = _currentProtocol === 'websocket'; const connectBtn = $('sendBtn') as HTMLButtonElement; const sendMessageBtn = $('wsSendBtn') as HTMLButtonElement; + const unavailableMessage = requestActionUnavailableMessage(); + const actionsReady = unavailableMessage === undefined; sendMessageBtn.style.display = isWebSocket ? '' : 'none'; connectBtn.classList.toggle('ws-lifecycle-action', isWebSocket); if (!isWebSocket) { connectBtn.classList.remove('ws-disconnect-state'); + setActionDisabled(connectBtn, !actionsReady, unavailableMessage ?? (_currentProtocol === 'grpc' ? 'Invoke gRPC request' : 'Send request')); + setActionDisabled(sendMessageBtn, true, unavailableMessage ?? 'Connect WebSocket before sending a message'); return; } @@ -184,15 +211,19 @@ function updateWebSocketControls(): void { const canDisconnect = connecting || connected || disconnecting; connectBtn.textContent = canDisconnect ? 'Disconnect' : 'Connect'; connectBtn.classList.toggle('ws-disconnect-state', canDisconnect); - connectBtn.disabled = disconnecting; - connectBtn.title = connecting + const connectTitle = unavailableMessage ?? (connecting ? 'Disconnect WebSocket while connecting' : connected ? 'Disconnect WebSocket' : disconnecting ? 'Disconnecting WebSocket' - : 'Connect WebSocket'; - sendMessageBtn.disabled = !connected; + : 'Connect WebSocket'); + setActionDisabled(connectBtn, !actionsReady || disconnecting, connectTitle); + setActionDisabled( + sendMessageBtn, + !actionsReady || !connected, + unavailableMessage ?? (connected ? 'Send WebSocket message' : 'Connect WebSocket before sending a message'), + ); } function syncRuntimeTabForProtocol(): void { @@ -1684,9 +1715,75 @@ function setSendingState(sending: boolean): void { btn.disabled = false; } updateWebSocketControls(); + if (sending && _currentProtocol !== 'websocket') { + btn.title = 'Cancel request'; + } } // ── Save ──────────────────────────────────────── +function activatePrimaryRequestAction(): void { + if (!requestActionsReady()) return; + if (_currentProtocol === 'websocket') { + if (_webSocketSession.state === 'connecting' || _webSocketSession.state === 'connected') { + disconnectWebSocket(); + } else if (_webSocketSession.state !== 'disconnecting') { + connectWebSocket(); + } + return; + } + if (isSending) { + cancelRequest(); + } else { + sendRequest(); + } +} + +function activateWebSocketSendMessageAction(): void { + if (!requestActionsReady() || !webSocketCanSend()) return; + sendWebSocketMessage(); +} + +function bindFirstActivationButton(button: HTMLButtonElement, action: () => void): void { + let suppressNextClick = false; + + button.addEventListener('mousedown', (event: MouseEvent) => { + if (event.button !== 0 || button.disabled) return; + suppressNextClick = true; + event.preventDefault(); + event.stopPropagation(); + action(); + }, true); + + button.addEventListener('click', (event: MouseEvent) => { + if (suppressNextClick) { + suppressNextClick = false; + event.preventDefault(); + event.stopPropagation(); + return; + } + if (button.disabled) return; + event.preventDefault(); + event.stopPropagation(); + action(); + }, true); + + button.addEventListener('keydown', (event: KeyboardEvent) => { + if (event.repeat || (event.key !== 'Enter' && event.key !== ' ')) return; + if (button.disabled) return; + event.preventDefault(); + event.stopPropagation(); + action(); + }, true); +} + +function bindRequestActionControls(): void { + bindFirstActivationButton($('sendBtn') as HTMLButtonElement, activatePrimaryRequestAction); + bindFirstActivationButton($('wsSendBtn') as HTMLButtonElement, activateWebSocketSendMessageAction); + updateWebSocketControls(); +} + +bindRequestActionControls(); + function saveRequest(): void { if (updateDocumentTimer) { clearTimeout(updateDocumentTimer); @@ -2229,20 +2326,6 @@ $('varToggleBtn').addEventListener('click', () => { syncUrlHighlight(); syncAllVarOverlays(); }); -$('sendBtn').addEventListener('click', () => { - if (_currentProtocol === 'websocket') { - if (_webSocketSession.state === 'connecting' || _webSocketSession.state === 'connected') { - disconnectWebSocket(); - } else if (_webSocketSession.state !== 'disconnecting') { - connectWebSocket(); - } - return; - } - if (isSending) { cancelRequest(); } else { sendRequest(); } -}); -$('wsSendBtn').addEventListener('click', () => { - sendWebSocketMessage(); -}); $('wsClearHistoryBtn').addEventListener('click', () => { _webSocketVisibleEvents = []; renderWebSocketSession(); diff --git a/test/protocolLayoutStability.test.ts b/test/protocolLayoutStability.test.ts index eaf4dc4..670b69a 100644 --- a/test/protocolLayoutStability.test.ts +++ b/test/protocolLayoutStability.test.ts @@ -10,6 +10,7 @@ import { } from '../src/models/schemaRoundTrip'; import type { RequestProtocol } from '../src/models/types'; import { RequestEditorProvider } from '../src/panels/requestPanel'; +import { MissioCodeLensProvider } from '../src/providers/codeLensProvider'; const schema = require('../schema/opencollectionschema.json'); const protocolRoots: RequestProtocol[] = ['http', 'graphql', 'websocket', 'grpc']; @@ -144,6 +145,27 @@ function dispatchPanelMessage(dom: JSDOM, data: unknown): void { dom.window.dispatchEvent(new dom.window.MessageEvent('message', { data })); } +function messagesOfType(messages: unknown[], type: string, startIndex = 0): any[] { + return messages.slice(startIndex).filter((message: any) => message?.type === type); +} + +function dispatchMouseDown(element: HTMLElement): void { + const view = element.ownerDocument.defaultView!; + element.dispatchEvent(new view.MouseEvent('mousedown', { bubbles: true, cancelable: true, button: 0 })); +} + +function dispatchMouseActivationSequence(element: HTMLElement): void { + const view = element.ownerDocument.defaultView!; + element.dispatchEvent(new view.MouseEvent('mousedown', { bubbles: true, cancelable: true, button: 0 })); + element.dispatchEvent(new view.MouseEvent('mouseup', { bubbles: true, cancelable: true, button: 0 })); + element.dispatchEvent(new view.MouseEvent('click', { bubbles: true, cancelable: true, button: 0 })); +} + +function dispatchKeyboardActivation(element: HTMLElement, key: 'Enter' | ' '): void { + const view = element.ownerDocument.defaultView!; + element.dispatchEvent(new view.KeyboardEvent('keydown', { bubbles: true, cancelable: true, key })); +} + afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); @@ -483,3 +505,170 @@ describe('OC-130 request editor first paint', () => { } }); }); + +describe('OC-170 request action first-click reliability', () => { + it.each(['http', 'graphql', 'grpc'] as RequestProtocol[])('fires %s Send on the first mouse-down after hydration', async (protocol) => { + const { dom, messages } = await loadRequestPanel(); + const sendBtn = document.getElementById('sendBtn') as HTMLButtonElement; + + expect(sendBtn.disabled).toBe(true); + expect(sendBtn.getAttribute('aria-disabled')).toBe('true'); + expect(sendBtn.title).toBe('Request editor is loading'); + const pendingCount = messages.length; + dispatchMouseDown(sendBtn); + expect(messages).toHaveLength(pendingCount); + + dispatchPanelMessage(dom, { type: 'requestLoaded', request: requestForProtocol(protocol), filePath: `${protocol}.yml` }); + + expect(sendBtn.disabled).toBe(false); + expect(sendBtn.hasAttribute('aria-disabled')).toBe(false); + + if (protocol === 'graphql') { + (document.getElementById('bodyData') as HTMLTextAreaElement).value = 'query EditedFirstClick { health { status } }'; + } + + const beforeAction = messages.length; + dispatchMouseDown(sendBtn); + const sent = messagesOfType(messages, 'sendRequest', beforeAction); + + expect(sent).toHaveLength(1); + expect(sent[0].request.info.type).toBe(protocol); + if (protocol === 'graphql') { + expect(sent[0].request.graphql.body.query).toBe('query EditedFirstClick { health { status } }'); + } + }); + + it('fires WebSocket Connect, Send Message, and Disconnect on first mouse-down actions', async () => { + const { dom, messages } = await loadRequestPanel(); + dispatchPanelMessage(dom, { type: 'requestLoaded', request: requestForProtocol('websocket'), filePath: 'websocket.yml' }); + + const lifecycleBtn = document.getElementById('sendBtn') as HTMLButtonElement; + const sendMessageBtn = document.getElementById('wsSendBtn') as HTMLButtonElement; + + expect(lifecycleBtn.disabled).toBe(false); + expect(lifecycleBtn.textContent).toBe('Connect'); + expect(sendMessageBtn.disabled).toBe(true); + expect(sendMessageBtn.getAttribute('aria-disabled')).toBe('true'); + + let beforeAction = messages.length; + dispatchMouseDown(lifecycleBtn); + let posted = messagesOfType(messages, 'webSocketConnect', beforeAction); + expect(posted).toHaveLength(1); + expect(posted[0].request.info.type).toBe('websocket'); + + dispatchPanelMessage(dom, { + type: 'webSocketSession', + session: { + requestId: 'websocket.yml', + state: 'connected', + events: [], + inboundCount: 0, + outboundCount: 0, + }, + }); + + expect(lifecycleBtn.textContent).toBe('Disconnect'); + expect(lifecycleBtn.disabled).toBe(false); + expect(sendMessageBtn.disabled).toBe(false); + expect(sendMessageBtn.hasAttribute('aria-disabled')).toBe(false); + + beforeAction = messages.length; + dispatchMouseDown(sendMessageBtn); + posted = messagesOfType(messages, 'webSocketSendMessage', beforeAction); + expect(posted).toHaveLength(1); + expect(posted[0].request.info.type).toBe('websocket'); + + beforeAction = messages.length; + dispatchMouseDown(lifecycleBtn); + posted = messagesOfType(messages, 'webSocketDisconnect', beforeAction); + expect(posted).toHaveLength(1); + }); + + it('suppresses the click that follows a handled first mouse-down action', async () => { + const { dom, messages } = await loadRequestPanel(); + dispatchPanelMessage(dom, { type: 'requestLoaded', request: requestForProtocol('graphql'), filePath: 'graphql.yml' }); + + const beforeAction = messages.length; + dispatchMouseActivationSequence(document.getElementById('sendBtn') as HTMLButtonElement); + expect(messagesOfType(messages, 'sendRequest', beforeAction)).toHaveLength(1); + }); + + it.each(['http', 'graphql', 'grpc'] as RequestProtocol[])('supports keyboard activation for %s Send', async (protocol) => { + const { dom, messages } = await loadRequestPanel(); + dispatchPanelMessage(dom, { type: 'requestLoaded', request: requestForProtocol(protocol), filePath: `${protocol}.yml` }); + + const sendBtn = document.getElementById('sendBtn') as HTMLButtonElement; + let beforeAction = messages.length; + dispatchKeyboardActivation(sendBtn, 'Enter'); + expect(messagesOfType(messages, 'sendRequest', beforeAction)).toHaveLength(1); + + beforeAction = messages.length; + dispatchKeyboardActivation(sendBtn, ' '); + expect(messagesOfType(messages, 'sendRequest', beforeAction)).toHaveLength(1); + }); + + it('supports keyboard activation for WebSocket lifecycle controls', async () => { + const { dom, messages } = await loadRequestPanel(); + dispatchPanelMessage(dom, { type: 'requestLoaded', request: requestForProtocol('websocket'), filePath: 'websocket.yml' }); + + const lifecycleBtn = document.getElementById('sendBtn') as HTMLButtonElement; + const sendMessageBtn = document.getElementById('wsSendBtn') as HTMLButtonElement; + + let beforeAction = messages.length; + dispatchKeyboardActivation(lifecycleBtn, 'Enter'); + expect(messagesOfType(messages, 'webSocketConnect', beforeAction)).toHaveLength(1); + + dispatchPanelMessage(dom, { + type: 'webSocketSession', + session: { + requestId: 'websocket.yml', + state: 'connected', + events: [], + inboundCount: 0, + outboundCount: 0, + }, + }); + + beforeAction = messages.length; + dispatchKeyboardActivation(sendMessageBtn, ' '); + expect(messagesOfType(messages, 'webSocketSendMessage', beforeAction)).toHaveLength(1); + + beforeAction = messages.length; + dispatchKeyboardActivation(lifecycleBtn, ' '); + expect(messagesOfType(messages, 'webSocketDisconnect', beforeAction)).toHaveLength(1); + }); + + it('keeps command, editor toolbar, and CodeLens routes armed for every protocol', () => { + const packageJson = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'package.json'), 'utf8')); + const editorTitleSend = packageJson.contributes.menus['editor/title'].find((entry: any) => entry.command === 'missio.sendRequest'); + const provider = new MissioCodeLensProvider(); + + expect(editorTitleSend).toMatchObject({ + command: 'missio.sendRequest', + group: 'navigation', + }); + + for (const protocol of protocolRoots) { + const request = requestForProtocol(protocol); + const yaml = stringifyYaml(request, { lineWidth: 120 }); + const lenses = provider.provideCodeLenses({ + getText: () => yaml, + uri: { fsPath: path.join(process.cwd(), `${protocol}.yml`) }, + } as any); + const commandLens = lenses.find(lens => lens.command?.command); + + expect(commandLens?.command?.arguments).toEqual([path.join(process.cwd(), `${protocol}.yml`)]); + if (protocol === 'websocket') { + expect(lenses.map(lens => lens.command?.command).filter(Boolean)).toEqual([ + 'missio.connectWebSocket', + 'missio.sendWebSocketMessage', + 'missio.disconnectWebSocket', + ]); + } else { + expect(commandLens?.command?.command).toBe('missio.sendRequest'); + } + } + + provider.dispose(); + }); +}); diff --git a/test/webSocketSupport.test.ts b/test/webSocketSupport.test.ts index d9ba2be..7182ad9 100644 --- a/test/webSocketSupport.test.ts +++ b/test/webSocketSupport.test.ts @@ -600,7 +600,9 @@ describe('WebSocket editor, variables, and tools', () => { const css = fs.readFileSync(path.join(process.cwd(), 'src', 'webview', 'requestPanel.css'), 'utf8'); const script = fs.readFileSync(path.join(process.cwd(), 'src', 'webview', 'requestPanel.ts'), 'utf8'); - expect(html).toContain('id="sendBtn">Send'); + expect(html).toContain('id="sendBtn" type="button" disabled aria-disabled="true" title="Request editor is loading">Send'); + expect(html).toContain('id="wsSendBtn" type="button"'); + expect(html).toContain('disabled aria-disabled="true" title="Request editor is loading">Send'); expect(html).toContain('class="btn btn-primary ws-lifecycle-btn ws-send-btn" id="wsSendBtn"'); expect(html).not.toContain('id="wsDisconnectBtn"'); expect(html).toContain('id="webSocketSessionPanel"'); From 66edc188ac0aaf785f9328176837f7c1675bcdf8 Mon Sep 17 00:00:00 2001 From: Chris Johnstone Date: Mon, 15 Jun 2026 17:27:02 +1200 Subject: [PATCH 2/5] Record OC-170 first-click reliability completion --- .../AGENT_PROGRESS.md | 28 ++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/docs/open-collection-gap-analysis/AGENT_PROGRESS.md b/docs/open-collection-gap-analysis/AGENT_PROGRESS.md index 17d031a..639fb7b 100644 --- a/docs/open-collection-gap-analysis/AGENT_PROGRESS.md +++ b/docs/open-collection-gap-analysis/AGENT_PROGRESS.md @@ -20,7 +20,7 @@ Status values: `Unclaimed`, `Claimed`, `In Progress`, `Review Ready`, `Blocked`, | Role | Owner | Scope | Since | Current Focus | | --- | --- | --- | --- | --- | -| Supervisor | Codex | Sanity-check completed tracks, run build/test/package/install verification, preserve GitButler branch hygiene, and append progress reports. | 2026-06-14 22:31 NZT | OC-160 complete after gRPC demo server startup, diagnostics, live smoke, package, and install verification. | +| Supervisor | Codex | Sanity-check completed tracks, run build/test/package/install verification, preserve GitButler branch hygiene, and append progress reports. | 2026-06-14 22:31 NZT | OC-170 complete after request action first-click reliability, full regression, package, and install verification. | ## Test Coverage Rules @@ -74,6 +74,7 @@ Use full branch names for stacking existing branches with `but move Date: Mon, 15 Jun 2026 17:29:26 +1200 Subject: [PATCH 3/5] Refresh OC-170 completion ledger --- docs/open-collection-gap-analysis/AGENT_PROGRESS.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/open-collection-gap-analysis/AGENT_PROGRESS.md b/docs/open-collection-gap-analysis/AGENT_PROGRESS.md index 639fb7b..780fd34 100644 --- a/docs/open-collection-gap-analysis/AGENT_PROGRESS.md +++ b/docs/open-collection-gap-analysis/AGENT_PROGRESS.md @@ -74,7 +74,7 @@ Use full branch names for stacking existing branches with `but move Date: Mon, 15 Jun 2026 19:45:32 +1200 Subject: [PATCH 4/5] Refresh OC-170 ledger hash --- docs/open-collection-gap-analysis/AGENT_PROGRESS.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/open-collection-gap-analysis/AGENT_PROGRESS.md b/docs/open-collection-gap-analysis/AGENT_PROGRESS.md index 780fd34..9686488 100644 --- a/docs/open-collection-gap-analysis/AGENT_PROGRESS.md +++ b/docs/open-collection-gap-analysis/AGENT_PROGRESS.md @@ -74,7 +74,7 @@ Use full branch names for stacking existing branches with `but move Date: Mon, 15 Jun 2026 19:48:49 +1200 Subject: [PATCH 5/5] Refresh OC-170 final ledger hash --- docs/open-collection-gap-analysis/AGENT_PROGRESS.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/open-collection-gap-analysis/AGENT_PROGRESS.md b/docs/open-collection-gap-analysis/AGENT_PROGRESS.md index 9686488..eff8b8f 100644 --- a/docs/open-collection-gap-analysis/AGENT_PROGRESS.md +++ b/docs/open-collection-gap-analysis/AGENT_PROGRESS.md @@ -74,7 +74,7 @@ Use full branch names for stacking existing branches with `but move