Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request 'feat(ai-chat): validate endpoint URL in settings (v0.59.0)' (#406) from feat/v0.59.0-ai-chat-endpoint-validation into main

scott 685b583d b912ee78

+243 -4
+1
CHANGELOG.md
··· 8 8 ## [Unreleased] 9 9 10 10 ### Added 11 + - AI chat endpoint URL validation (v0.59.0, #675) — the AI chat settings panel now validates the Endpoint field before persisting. Invalid inputs (empty strings, `ftp://…`, `javascript:`, `file://`, bare hostnames without a protocol, protocol-relative `//…`, malformed URLs) no longer overwrite a working endpoint in localStorage: the previous value is retained. Invalid inputs also set `aria-invalid="true"` on the input and surface an inline error message (`role="alert"`) explaining the accepted formats (`/api/ai` same-origin path or an absolute https URL). A permanent helper hint sits below the input so users understand the format up-front; both elements are linked via `aria-describedby`. Built on the existing `validateEndpoint()` helper, which was previously only consulted to toggle the onboarding banner. 17 new tests (7 unit tests for `validateEndpoint`, 10 jsdom tests for the persist flow + a11y attributes). 11 12 - Sheets fit-and-finish (v0.58.0) — four Excel/Sheets-parity wins for daily spreadsheet work: 12 13 - **Auto-format on entry**: typing `$1,234.56`, `75%`, `2025-03-15`, or `1,234` in a cell now stores the parsed numeric value and stamps an appropriate format (`currency`, `percent`, `date`, `number`) instead of keeping the raw string. Preserves existing cell formats so explicit user choices aren't clobbered. New `src/sheets/auto-format.ts` module with 30 unit tests. 13 14 - **Preset color palette**: both the text-color and background-color pickers now have a ▾ dropdown beside the native `<input type="color">` showing 14 curated swatches (black/grays/white + 9 accent colors in a 7×2 grid). The native picker still works; the palette short-circuits it for the common case. New `src/sheets/color-palette.ts` module with 15 jsdom tests covering render, open/close, click-outside, and Escape.
+1 -1
package.json
··· 1 1 { 2 2 "name": "tools", 3 - "version": "0.58.0", 3 + "version": "0.59.0", 4 4 "private": true, 5 5 "type": "module", 6 6 "main": "electron/main.js",
+24
src/css/app.css
··· 8640 8640 margin-top: 4px; 8641 8641 } 8642 8642 8643 + /* #675 — endpoint validation helper + error text. */ 8644 + .ai-chat-endpoint-hint { 8645 + margin: 2px 0 0; 8646 + font-size: 0.7rem; 8647 + color: var(--color-text-faint); 8648 + line-height: 1.3; 8649 + } 8650 + .ai-chat-endpoint-hint code { 8651 + font-family: ui-monospace, 'SF Mono', monospace; 8652 + font-size: 0.95em; 8653 + padding: 0 3px; 8654 + background: var(--color-surface-alt, var(--color-surface)); 8655 + border-radius: 2px; 8656 + } 8657 + .ai-chat-endpoint-error { 8658 + margin: 2px 0 0; 8659 + font-size: 0.7rem; 8660 + color: var(--color-danger, #d32f2f); 8661 + line-height: 1.3; 8662 + } 8663 + .ai-chat-settings-field input[aria-invalid="true"] { 8664 + border-color: var(--color-danger, #d32f2f); 8665 + } 8666 + 8643 8667 .ai-chat-context-row { 8644 8668 flex-direction: row; 8645 8669 align-items: center;
+5 -1
src/lib/ai-chat/sidebar-dom.ts
··· 25 25 positionBtn: HTMLButtonElement; 26 26 settingsPanel: HTMLElement; 27 27 endpointInput: HTMLInputElement; 28 + endpointError: HTMLElement; 28 29 modelSelect: HTMLSelectElement; 29 30 contextToggle: HTMLInputElement; 30 31 actionsToggle: HTMLInputElement; ··· 92 93 <summary>Advanced</summary> 93 94 <div class="ai-chat-settings-field"> 94 95 <label for="ai-endpoint">Endpoint</label> 95 - <input type="text" id="ai-endpoint" placeholder="/api/ai" spellcheck="false" autocomplete="off"> 96 + <input type="text" id="ai-endpoint" placeholder="/api/ai" spellcheck="false" autocomplete="off" aria-describedby="ai-endpoint-hint ai-endpoint-error"> 97 + <p id="ai-endpoint-hint" class="ai-chat-endpoint-hint">Use a same-origin path (e.g. <code>/api/ai</code>) or an absolute https URL.</p> 98 + <p id="ai-endpoint-error" class="ai-chat-endpoint-error" role="alert" hidden></p> 96 99 </div> 97 100 </details> 98 101 </div> ··· 139 142 positionBtn: container.querySelector('#ai-chat-position-btn') as HTMLButtonElement, 140 143 settingsPanel: container.querySelector('#ai-chat-settings') as HTMLElement, 141 144 endpointInput: container.querySelector('#ai-endpoint') as HTMLInputElement, 145 + endpointError: container.querySelector('#ai-endpoint-error') as HTMLElement, 142 146 modelSelect: container.querySelector('#ai-model') as HTMLSelectElement, 143 147 contextToggle: container.querySelector('#ai-context-toggle') as HTMLInputElement, 144 148 actionsToggle: container.querySelector('#ai-actions-toggle') as HTMLInputElement,
+28 -2
src/lib/ai-chat/wiring.ts
··· 2 2 * AI Chat — shared event wiring for the chat panel across all editor types. 3 3 */ 4 4 5 - import { isConfigured, saveConfig, MODEL_OPTIONS } from './types.js'; 5 + import { isConfigured, saveConfig, validateEndpoint, MODEL_OPTIONS } from './types.js'; 6 6 import type { ChatConfig, ChatPosition, ChatState } from './types.js'; 7 7 import type { EditorType } from './system-prompt.js'; 8 8 import type { createChatSidebar } from './sidebar-dom.js'; ··· 173 173 ? (chatUI.container.querySelector('#ai-model-custom') as HTMLInputElement).value.trim() 174 174 : chatUI.modelSelect.value; 175 175 176 + // #675 — validate endpoint before persisting. An invalid URL shown in 177 + // the input blocks the save, flags the input with aria-invalid=true, 178 + // and renders an error message in the adjacent role=alert element. 179 + // Other fields (model, etc.) still persist so the user doesn't lose 180 + // unrelated edits because of a typo in the endpoint. 181 + const rawEndpoint = chatUI.endpointInput.value.trim(); 182 + const endpointValid = validateEndpoint(rawEndpoint); 183 + if (!endpointValid) { 184 + chatUI.endpointInput.setAttribute('aria-invalid', 'true'); 185 + if (chatUI.endpointError) { 186 + chatUI.endpointError.textContent = 187 + rawEndpoint.length === 0 188 + ? 'Endpoint is required.' 189 + : 'Invalid endpoint. Use a same-origin path (e.g. /api/ai) or an absolute https URL.'; 190 + chatUI.endpointError.hidden = false; 191 + } 192 + } else { 193 + chatUI.endpointInput.removeAttribute('aria-invalid'); 194 + if (chatUI.endpointError) { 195 + chatUI.endpointError.textContent = ''; 196 + chatUI.endpointError.hidden = true; 197 + } 198 + } 199 + 176 200 chatConfig = { 177 201 ...chatConfig, 178 - endpoint: chatUI.endpointInput.value.trim(), 202 + // Keep the previous endpoint if the new one is invalid — don't 203 + // overwrite a working URL with garbage. 204 + endpoint: endpointValid ? rawEndpoint : chatConfig.endpoint, 179 205 model: model || 'anthropic/claude-sonnet-4.6', 180 206 }; 181 207 opts.chatConfig = chatConfig;
+184
tests/ai-chat-endpoint-validation.test.ts
··· 1 + // @vitest-environment jsdom 2 + /** 3 + * #675 — AI chat endpoint URL validation. 4 + * 5 + * `validateEndpoint()` already existed in types.ts but was only consulted 6 + * post-save to toggle the onboarding banner — `persistSettings()` would 7 + * happily write garbage (`ftp://…`, bare hostnames, malformed URLs) to 8 + * localStorage. These tests pin the new contract: invalid endpoints must 9 + * not be saved, the input gets `aria-invalid="true"` + an error message 10 + * in an adjacent `role="alert"` element, and valid endpoints clear both. 11 + */ 12 + import { describe, it, expect, beforeEach } from 'vitest'; 13 + import { 14 + createChatSidebar, 15 + createChatState, 16 + initChatWiring, 17 + loadConfig, 18 + validateEndpoint, 19 + } from '../src/lib/ai-chat.js'; 20 + 21 + const store: Record<string, string> = {}; 22 + const mockLS = { 23 + getItem: (key: string) => store[key] ?? null, 24 + setItem: (key: string, val: string) => { store[key] = val; }, 25 + removeItem: (key: string) => { delete store[key]; }, 26 + clear: () => { for (const k of Object.keys(store)) delete store[k]; }, 27 + get length() { return Object.keys(store).length; }, 28 + key: (i: number) => Object.keys(store)[i] ?? null, 29 + }; 30 + Object.defineProperty(globalThis, 'localStorage', { value: mockLS, writable: true }); 31 + 32 + function setup() { 33 + document.body.innerHTML = ''; 34 + const chatUI = createChatSidebar(); 35 + document.body.appendChild(chatUI.container); 36 + const chatState = createChatState(); 37 + const toggleBtn = document.createElement('button'); 38 + document.body.appendChild(toggleBtn); 39 + const chatConfig = loadConfig(); 40 + const wiring = initChatWiring({ 41 + chatUI, 42 + chatState, 43 + chatConfig, 44 + toggleBtn, 45 + editorType: 'doc', 46 + onSend: () => {}, 47 + }); 48 + return { chatUI, wiring }; 49 + } 50 + 51 + describe('validateEndpoint', () => { 52 + it('accepts same-origin paths', () => { 53 + expect(validateEndpoint('/api/ai')).toBe(true); 54 + expect(validateEndpoint('/foo/bar')).toBe(true); 55 + }); 56 + 57 + it('accepts absolute http(s) URLs with a host', () => { 58 + expect(validateEndpoint('http://ai')).toBe(true); 59 + expect(validateEndpoint('https://ai.example.com')).toBe(true); 60 + expect(validateEndpoint('https://ai.example.com/v1/chat')).toBe(true); 61 + }); 62 + 63 + it('rejects empty/whitespace', () => { 64 + expect(validateEndpoint('')).toBe(false); 65 + expect(validateEndpoint(' ')).toBe(false); 66 + }); 67 + 68 + it('rejects non-http protocols', () => { 69 + expect(validateEndpoint('ftp://files.example.com')).toBe(false); 70 + expect(validateEndpoint('javascript:alert(1)')).toBe(false); 71 + expect(validateEndpoint('file:///etc/passwd')).toBe(false); 72 + }); 73 + 74 + it('rejects bare hostnames (no protocol)', () => { 75 + expect(validateEndpoint('ai.example.com')).toBe(false); 76 + expect(validateEndpoint('localhost')).toBe(false); 77 + }); 78 + 79 + it('rejects protocol-relative URLs', () => { 80 + expect(validateEndpoint('//ai.example.com')).toBe(false); 81 + }); 82 + 83 + it('rejects malformed URLs', () => { 84 + expect(validateEndpoint('http://')).toBe(false); 85 + expect(validateEndpoint('https://')).toBe(false); 86 + expect(validateEndpoint('not a url')).toBe(false); 87 + }); 88 + }); 89 + 90 + describe('AI chat endpoint validation — persistSettings', () => { 91 + beforeEach(() => { 92 + mockLS.clear(); 93 + document.body.innerHTML = ''; 94 + }); 95 + 96 + it('persists valid absolute URL', () => { 97 + const { chatUI, wiring } = setup(); 98 + chatUI.endpointInput.value = 'https://ai.example.com/v1'; 99 + wiring.persistSettings(); 100 + expect(mockLS.getItem('tools-ai-endpoint')).toBe('https://ai.example.com/v1'); 101 + expect(chatUI.endpointInput.getAttribute('aria-invalid')).not.toBe('true'); 102 + }); 103 + 104 + it('persists valid same-origin path', () => { 105 + const { chatUI, wiring } = setup(); 106 + chatUI.endpointInput.value = '/api/ai'; 107 + wiring.persistSettings(); 108 + expect(mockLS.getItem('tools-ai-endpoint')).toBe('/api/ai'); 109 + }); 110 + 111 + it('does NOT persist ftp:// endpoint', () => { 112 + const { chatUI, wiring } = setup(); 113 + // Seed an initial valid value first so we can assert it's not overwritten. 114 + chatUI.endpointInput.value = '/api/ai'; 115 + wiring.persistSettings(); 116 + // Now try to overwrite with garbage. 117 + chatUI.endpointInput.value = 'ftp://bad.example.com'; 118 + wiring.persistSettings(); 119 + expect(mockLS.getItem('tools-ai-endpoint')).toBe('/api/ai'); 120 + }); 121 + 122 + it('does NOT persist bare hostname', () => { 123 + const { chatUI, wiring } = setup(); 124 + chatUI.endpointInput.value = '/api/ai'; 125 + wiring.persistSettings(); 126 + chatUI.endpointInput.value = 'ai.example.com'; 127 + wiring.persistSettings(); 128 + expect(mockLS.getItem('tools-ai-endpoint')).toBe('/api/ai'); 129 + }); 130 + 131 + it('does NOT persist malformed URL', () => { 132 + const { chatUI, wiring } = setup(); 133 + chatUI.endpointInput.value = '/api/ai'; 134 + wiring.persistSettings(); 135 + chatUI.endpointInput.value = 'not a url'; 136 + wiring.persistSettings(); 137 + expect(mockLS.getItem('tools-ai-endpoint')).toBe('/api/ai'); 138 + }); 139 + 140 + it('does NOT persist empty endpoint', () => { 141 + const { chatUI, wiring } = setup(); 142 + chatUI.endpointInput.value = '/api/ai'; 143 + wiring.persistSettings(); 144 + chatUI.endpointInput.value = ''; 145 + wiring.persistSettings(); 146 + expect(mockLS.getItem('tools-ai-endpoint')).toBe('/api/ai'); 147 + }); 148 + 149 + it('sets aria-invalid=true on invalid endpoint', () => { 150 + const { chatUI, wiring } = setup(); 151 + chatUI.endpointInput.value = 'ftp://bad'; 152 + wiring.persistSettings(); 153 + expect(chatUI.endpointInput.getAttribute('aria-invalid')).toBe('true'); 154 + }); 155 + 156 + it('shows an error message in an adjacent alert element', () => { 157 + const { chatUI, wiring } = setup(); 158 + chatUI.endpointInput.value = 'ftp://bad'; 159 + wiring.persistSettings(); 160 + const err = chatUI.container.querySelector('#ai-endpoint-error') as HTMLElement; 161 + expect(err).not.toBeNull(); 162 + expect(err.getAttribute('role')).toBe('alert'); 163 + expect(err.hidden).toBe(false); 164 + expect(err.textContent?.length).toBeGreaterThan(0); 165 + }); 166 + 167 + it('clears aria-invalid and error message once a valid endpoint is entered', () => { 168 + const { chatUI, wiring } = setup(); 169 + chatUI.endpointInput.value = 'ftp://bad'; 170 + wiring.persistSettings(); 171 + const err = chatUI.container.querySelector('#ai-endpoint-error') as HTMLElement; 172 + expect(err.hidden).toBe(false); 173 + 174 + chatUI.endpointInput.value = 'https://ai.example.com'; 175 + wiring.persistSettings(); 176 + expect(chatUI.endpointInput.getAttribute('aria-invalid')).not.toBe('true'); 177 + expect(err.hidden).toBe(true); 178 + }); 179 + 180 + it('error element is linked to the input via aria-describedby', () => { 181 + const { chatUI } = setup(); 182 + expect(chatUI.endpointInput.getAttribute('aria-describedby')).toContain('ai-endpoint-error'); 183 + }); 184 + });