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

Configure Feed

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

feat(ai-chat): polish pass — onboarding, parse errors, friendly errors, bulk-apply confirm (#392)

scott 4a84ccd2 a6ccb762

+566 -25
+8
CHANGELOG.md
··· 10 10 ### Added 11 11 - E2EE key-loss warning: one-time modal on first visit to an encrypted document, tailored to whether the user is anonymous, signed in without synced key, or fully backed up. Shield icon in the topbar re-opens the explanation. (#671) 12 12 - Trash: 30-second undo window for permanent document deletion — `DELETE /api/documents/:id` now soft-marks the row via `pending_permanent_delete_at`, a background finalizer cascades real deletion of versions + blobs after the grace period, and `PUT /api/documents/:id/undo-delete` aborts within the window. Trash UI shows a "Permanently deleted. Undo" toast for 30s. (#674) 13 + - AI chat: onboarding banner in every editor — when no endpoint is configured, opening the chat surfaces a titled CTA pointing to the settings panel instead of silently focusing an empty field. (#676) 14 + - AI chat: parse-error notes — malformed `<action>` blocks in AI responses now render a dismissible warning note listing each failed block so users can spot and report broken output instead of seeing silent drops. (#679) 15 + - AI chat: confirmation before bulk auto-apply — flipping the actions toggle to "on" while pending suggestion cards exist now prompts before applying all of them at once, preventing accidental destructive batches. (#677) 16 + 17 + ### Changed 18 + - AI chat: friendlier API error surfacing — 401/403/429 responses now render clear, actionable messages (e.g. "Authentication failed (401). Check your API key or endpoint settings.") instead of the raw status line. (#682) 19 + - AI chat: stricter endpoint validation — `isConfigured` now delegates to a new `validateEndpoint` helper that rejects non-HTTP(S) protocols and malformed URLs, so invalid configs open the settings panel instead of silently failing on send. (#675) 13 20 14 21 ### Fixed 15 22 - Share links: enforce expiry server-side on document, snapshot, and save endpoints. Client surfaces a blocking "link has expired" overlay; owners are never gated. (#673) ··· 30 37 - Diagrams: operation-based undo/redo history — replaces snapshot cloning with invertible operations for collaboration-safe undo (#668) 31 38 32 39 ### Changed 40 + - Add undo window for permanent doc deletion (#674) 33 41 - Sheets: decomposed main.ts monolith into focused modules — core-state, cell-computation, selection-state, session-bootstrap (#656) 34 42 35 43 ## [0.46.0] — 2026-04-15
+3 -2
src/calendar/ai-chat-panel.ts
··· 11 11 import { 12 12 isConfigured, buildSystemMessage, streamChat, 13 13 appendMessage, appendStreamingBubble, renderMarkdown, 14 - appendActionCard, 14 + appendActionCard, appendParseErrorNote, 15 15 type ChatMessage, 16 16 } from '../lib/ai-chat.js'; 17 17 import { splitResponse, isCalendarAction } from '../lib/ai-actions.js'; ··· 154 154 deps.chatState.messages.push({ role: 'assistant', content: doneText, ts: Date.now() }); 155 155 156 156 if (actionsEnabled) { 157 - const { displayText, actions } = splitResponse(doneText); 157 + const { displayText, actions, errors } = splitResponse(doneText); 158 + appendParseErrorNote(deps.chatUI.messageList, errors); 158 159 if (actions.length > 0) { 159 160 bubble.update(renderMarkdown(displayText)); 160 161 for (const action of actions) {
+74
src/css/app.css
··· 8952 8952 opacity: 0.4; 8953 8953 } 8954 8954 8955 + /* AI chat onboarding banner (shown when no endpoint is configured) */ 8956 + .ai-chat-onboarding { 8957 + margin: var(--space-sm) var(--space-md); 8958 + padding: var(--space-sm) var(--space-md); 8959 + border: 1px solid var(--color-accent); 8960 + border-radius: var(--radius-md); 8961 + background: color-mix(in oklch, var(--color-accent) 8%, var(--color-surface)); 8962 + font-size: 0.8125rem; 8963 + color: var(--color-text); 8964 + display: flex; 8965 + flex-direction: column; 8966 + gap: var(--space-xs); 8967 + } 8968 + 8969 + .ai-chat-onboarding-title { 8970 + font-weight: 600; 8971 + font-size: 0.875rem; 8972 + } 8973 + 8974 + .ai-chat-onboarding-body { 8975 + color: var(--color-text-muted); 8976 + line-height: 1.4; 8977 + } 8978 + 8979 + .ai-chat-onboarding-cta { 8980 + align-self: flex-start; 8981 + padding: 4px 12px; 8982 + border: 1px solid var(--color-accent); 8983 + border-radius: var(--radius-sm); 8984 + background: var(--color-accent); 8985 + color: white; 8986 + font-size: 0.75rem; 8987 + cursor: pointer; 8988 + transition: opacity var(--transition-fast); 8989 + } 8990 + 8991 + .ai-chat-onboarding-cta:hover { 8992 + opacity: 0.85; 8993 + } 8994 + 8995 + /* AI chat parse-error note (non-fatal action parse failures) */ 8996 + .ai-chat-parse-errors { 8997 + margin: var(--space-xs) var(--space-md); 8998 + padding: var(--space-xs) var(--space-sm); 8999 + border: 1px solid var(--color-warning, #d97706); 9000 + border-left-width: 3px; 9001 + border-radius: var(--radius-sm); 9002 + background: color-mix(in oklch, var(--color-warning, #d97706) 6%, var(--color-surface)); 9003 + color: var(--color-text); 9004 + font-size: 0.75rem; 9005 + } 9006 + 9007 + .ai-chat-parse-errors-title { 9008 + font-weight: 600; 9009 + margin-bottom: 2px; 9010 + } 9011 + 9012 + .ai-chat-parse-errors ul { 9013 + margin: 0; 9014 + padding-left: 1.1rem; 9015 + color: var(--color-text-muted); 9016 + } 9017 + 9018 + /* AI chat error bubble */ 9019 + .ai-chat-bubble--error { 9020 + border: 1px solid var(--color-error, #f14d4c); 9021 + background: color-mix(in oklch, var(--color-error, #f14d4c) 6%, var(--color-surface)); 9022 + } 9023 + 9024 + .ai-chat-error { 9025 + color: var(--color-error, #f14d4c); 9026 + font-size: 0.8125rem; 9027 + } 9028 + 8955 9029 .zen-mode .ai-chat-sidebar { 8956 9030 display: none !important; 8957 9031 }
+3 -2
src/diagrams/ai-chat-wiring.ts
··· 7 7 import { 8 8 createChatSidebar, createChatState, loadConfig, isConfigured, 9 9 buildSystemMessage, streamChat, appendMessage, appendStreamingBubble, 10 - renderMarkdown, appendActionCard, escapeHtml, initChatWiring, 10 + renderMarkdown, appendActionCard, appendParseErrorNote, escapeHtml, initChatWiring, 11 11 type ChatMessage, 12 12 } from '../lib/ai-chat.js'; 13 13 import { splitResponse, isDiagramAction } from '../lib/ai-actions.js'; ··· 141 141 chatState.messages.push({ role: 'assistant', content: text, ts: Date.now() }); 142 142 143 143 if (actionsEnabled) { 144 - const { displayText, actions } = splitResponse(text); 144 + const { displayText, actions, errors } = splitResponse(text); 145 + appendParseErrorNote(chatUI.messageList, errors); 145 146 if (actions.length > 0) { 146 147 bubble.update(renderMarkdown(displayText)); 147 148 for (const action of actions) {
+3 -2
src/docs/ai-chat-wiring.ts
··· 9 9 import { 10 10 createChatSidebar, createChatState, loadConfig, isConfigured, 11 11 buildSystemMessage, streamChat, appendMessage, appendStreamingBubble, 12 - renderMarkdown, appendActionCard, escapeHtml, initChatWiring, 12 + renderMarkdown, appendActionCard, appendParseErrorNote, escapeHtml, initChatWiring, 13 13 type ChatMessage, 14 14 } from '../lib/ai-chat.js'; 15 15 import { splitResponse, isDocAction } from '../lib/ai-actions.js'; ··· 105 105 106 106 // Parse and render action cards 107 107 if (actionsEnabled) { 108 - const { displayText, actions } = splitResponse(text); 108 + const { displayText, actions, errors } = splitResponse(text); 109 + appendParseErrorNote(chatUI.messageList, errors); 109 110 if (actions.length > 0) { 110 111 bubble.update(renderMarkdown(displayText)); 111 112 for (const action of actions) {
+3 -2
src/forms/ai-chat-panel.ts
··· 10 10 import { 11 11 isConfigured, buildSystemMessage, streamChat, 12 12 appendMessage, appendStreamingBubble, renderMarkdown, 13 - appendActionCard, 13 + appendActionCard, appendParseErrorNote, 14 14 type ChatMessage, 15 15 } from '../lib/ai-chat.js'; 16 16 import { splitResponse, isFormAction } from '../lib/ai-actions.js'; ··· 117 117 deps.chatState.messages.push({ role: 'assistant', content: doneText, ts: Date.now() }); 118 118 119 119 if (actionsEnabled) { 120 - const { displayText, actions } = splitResponse(doneText); 120 + const { displayText, actions, errors } = splitResponse(doneText); 121 + appendParseErrorNote(deps.chatUI.messageList, errors); 121 122 if (actions.length > 0) { 122 123 bubble.update(renderMarkdown(displayText)); 123 124 for (const action of actions) {
+6
src/lib/ai-chat.ts
··· 14 14 loadConfig, 15 15 saveConfig, 16 16 isConfigured, 17 + validateEndpoint, 17 18 MODEL_OPTIONS, 18 19 createChatState, 19 20 } from './ai-chat/types.js'; ··· 29 30 export { 30 31 streamChat, 31 32 sendChat, 33 + friendlyStatusError, 32 34 } from './ai-chat/streaming.js'; 33 35 34 36 // ── Markdown rendering ──────────────────────────────────────────────── ··· 45 47 autoResizeTextarea, 46 48 type ActionCardCallbacks, 47 49 appendActionCard, 50 + appendParseErrorNote, 51 + showOnboardingBanner, 52 + hasOnboardingBanner, 53 + removeOnboardingBanner, 48 54 } from './ai-chat/sidebar-dom.js'; 49 55 50 56 // ── Shared wiring ─────────────────────────────────────────────────────
+54
src/lib/ai-chat/sidebar-dom.ts
··· 198 198 textarea.style.height = Math.min(textarea.scrollHeight, 120) + 'px'; 199 199 } 200 200 201 + // ── Onboarding banner (#676) ─────────────────────────────────────────── 202 + 203 + /** 204 + * #676 — render a one-time "configure your endpoint" banner when the chat 205 + * is opened without a valid endpoint configured. Idempotent. 206 + */ 207 + export function showOnboardingBanner( 208 + list: HTMLElement, 209 + settingsBtn: HTMLButtonElement, 210 + ): void { 211 + if (hasOnboardingBanner(list)) return; 212 + const banner = document.createElement('div'); 213 + banner.className = 'ai-chat-onboarding'; 214 + banner.innerHTML = ` 215 + <div class="ai-chat-onboarding-title">Configure your AI endpoint</div> 216 + <div class="ai-chat-onboarding-body"> 217 + Set the endpoint URL in settings before sending messages. A local gateway or a hosted 218 + OpenAI-compatible endpoint both work — e.g. <code>/api/ai</code> or <code>https://ai.example.com</code>. 219 + </div> 220 + <button type="button" class="ai-action-btn">Open settings</button> 221 + `; 222 + const btn = banner.querySelector('button') as HTMLButtonElement; 223 + btn.addEventListener('click', () => settingsBtn.click()); 224 + list.prepend(banner); 225 + } 226 + 227 + export function hasOnboardingBanner(list: HTMLElement): boolean { 228 + return !!list.querySelector('.ai-chat-onboarding'); 229 + } 230 + 231 + export function removeOnboardingBanner(list: HTMLElement): void { 232 + list.querySelector('.ai-chat-onboarding')?.remove(); 233 + } 234 + 235 + // ── Parse-error surfacing (#679) ─────────────────────────────────────── 236 + 237 + /** 238 + * #679 — when `splitResponse()` returns non-empty `errors`, surface them 239 + * inline so users know the AI emitted a malformed action. No-op on empty. 240 + */ 241 + export function appendParseErrorNote(list: HTMLElement, errors: string[]): void { 242 + if (errors.length === 0) return; 243 + const row = document.createElement('div'); 244 + row.className = 'ai-chat-parse-errors'; 245 + row.setAttribute('role', 'alert'); 246 + const items = errors.map((e) => `<li>${escapeHtml(e)}</li>`).join(''); 247 + row.innerHTML = ` 248 + <div class="ai-chat-parse-errors-title">⚠ Some actions could not be parsed:</div> 249 + <ul>${items}</ul> 250 + `; 251 + list.appendChild(row); 252 + list.scrollTop = list.scrollHeight; 253 + } 254 + 201 255 // ── Action Cards ───────────────────────────────��────────────────────── 202 256 203 257 export interface ActionCardCallbacks {
+24 -2
src/lib/ai-chat/streaming.ts
··· 4 4 5 5 import type { ChatConfig, ChatMessage } from './types.js'; 6 6 7 + // ── Error messaging ──────────────────────────────────────────────────── 8 + 9 + /** 10 + * #682 — map HTTP status codes to friendly, actionable error messages. 11 + * 12 + * The provider/gateway's own `detail` text is surfaced verbatim alongside a 13 + * short explanation of the code, so users can see both "what broke" and 14 + * "what to do about it" without needing to recognize raw status numbers. 15 + */ 16 + export function friendlyStatusError(status: number, detail: string): string { 17 + switch (status) { 18 + case 401: 19 + return `Authentication failed (401). Check your API key or endpoint settings. ${detail}`.trim(); 20 + case 403: 21 + return `Not allowed (403). This endpoint says you don't have permission. ${detail}`.trim(); 22 + case 429: 23 + return `Rate limit hit (429). Too many requests — try again in a moment. ${detail}`.trim(); 24 + default: 25 + return `AI request failed (${status}): ${detail}`; 26 + } 27 + } 28 + 7 29 // ── API call (streaming) ─────────────────────────────────────────────── 8 30 9 31 /** ··· 59 81 const errBody = await response.json(); 60 82 detail = errBody.error?.message || errBody.error || detail; 61 83 } catch { /* ignore */ } 62 - callbacks.onError(`AI request failed (${response.status}): ${detail}`); 84 + callbacks.onError(friendlyStatusError(response.status, detail)); 63 85 return; 64 86 } 65 87 ··· 153 175 const errBody = await res.json(); 154 176 detail = errBody.error?.message || errBody.error || detail; 155 177 } catch { /* ignore */ } 156 - throw new Error(`AI request failed (${res.status}): ${detail}`); 178 + throw new Error(friendlyStatusError(res.status, detail)); 157 179 } 158 180 159 181 const data = await res.json();
+27 -1
src/lib/ai-chat/types.ts
··· 62 62 if (cfg.position !== undefined) localStorage.setItem(LS_POSITION, cfg.position); 63 63 } 64 64 65 + /** 66 + * #675 — validate a chat endpoint URL. 67 + * 68 + * Accepts: 69 + * - absolute http(s) URLs with a host (e.g. `https://ai.example.com/v1`) 70 + * - same-origin paths starting with `/` (e.g. `/api/ai`) 71 + * 72 + * Rejects everything else, including ftp://, malformed URLs, bare hostnames, 73 + * and protocol-relative URLs without a host. 74 + */ 75 + export function validateEndpoint(raw: string): boolean { 76 + const s = raw.trim(); 77 + if (s.length === 0) return false; 78 + // Same-origin path. 79 + if (s.startsWith('/') && !s.startsWith('//')) return true; 80 + // Absolute URL with http(s) protocol and a host. 81 + try { 82 + const u = new URL(s); 83 + if (u.protocol !== 'http:' && u.protocol !== 'https:') return false; 84 + if (!u.host) return false; 85 + return true; 86 + } catch { 87 + return false; 88 + } 89 + } 90 + 65 91 export function isConfigured(cfg: ChatConfig): boolean { 66 - return cfg.endpoint.length > 0; 92 + return validateEndpoint(cfg.endpoint); 67 93 } 68 94 69 95 // ── Popular models for dropdown ────────────────────────────────────────
+47 -9
src/lib/ai-chat/wiring.ts
··· 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'; 9 - import { autoResizeTextarea } from './sidebar-dom.js'; 9 + import { 10 + autoResizeTextarea, 11 + showOnboardingBanner, 12 + removeOnboardingBanner, 13 + } from './sidebar-dom.js'; 10 14 11 15 // ── Shared chat wiring ─────────────────────────────────────────────── 12 16 ··· 17 21 toggleBtn: HTMLElement; 18 22 editorType: EditorType; 19 23 onSend: () => void; 24 + /** 25 + * #677 — called when auto-apply is toggled on and there are pending action 26 + * cards. Implementations should confirm with the user before bulk-applying. 27 + * Return true to proceed, false to skip. Default uses window.confirm. 28 + */ 29 + confirmBulkApply?: (pendingCount: number) => boolean; 20 30 } 21 31 22 32 /** ··· 83 93 chatUI.autoApplyToggle.checked = chatConfig.autoApply; 84 94 applyAutoApplyState(chatConfig.autoApply); 85 95 96 + const defaultConfirmBulkApply = (n: number): boolean => 97 + window.confirm( 98 + `Auto-apply is on. Apply ${n} pending suggestion${n === 1 ? '' : 's'} now? ` + 99 + 'They will be written into the document immediately.', 100 + ); 101 + const confirmBulkApply = opts.confirmBulkApply ?? defaultConfirmBulkApply; 102 + 86 103 chatUI.autoApplyToggle.addEventListener('change', () => { 87 104 const enabled = chatUI.autoApplyToggle.checked; 88 - chatConfig = { ...chatConfig, autoApply: enabled }; 89 - opts.chatConfig = chatConfig; 90 - saveConfig({ autoApply: enabled }); 91 - applyAutoApplyState(enabled); 92 105 93 106 // Retroactively approve pending cards when turning on. 94 107 if (enabled) { 95 108 const pending = chatUI.messageList.querySelectorAll<HTMLButtonElement>( 96 109 '.ai-action-card:not(.ai-action-card--applied):not(.ai-action-card--dismissed):not(.ai-action-card--suggested) .ai-action-btn--apply', 97 110 ); 98 - pending.forEach((btn) => btn.click()); 111 + if (pending.length > 0) { 112 + // #677 — confirm before bulk-applying pending cards. If user declines, 113 + // keep the toggle ON (new cards will still auto-apply) but don't 114 + // retroactively touch existing pending cards. 115 + const ok = confirmBulkApply(pending.length); 116 + if (ok) { 117 + pending.forEach((btn) => btn.click()); 118 + } 119 + } 99 120 } 121 + 122 + chatConfig = { ...chatConfig, autoApply: enabled }; 123 + opts.chatConfig = chatConfig; 124 + saveConfig({ autoApply: enabled }); 125 + applyAutoApplyState(enabled); 100 126 }); 101 127 102 128 // Toggle panel ··· 108 134 } else { 109 135 chatUI.container.style.display = ''; 110 136 toggleBtn.classList.add('active'); 111 - if (!isConfigured(chatConfig) && !settingsShownOnce) { 112 - chatUI.settingsPanel.style.display = ''; 113 - settingsShownOnce = true; 137 + if (!isConfigured(chatConfig)) { 138 + // #676 — persistent onboarding banner in the message list every time 139 + // we open without a valid endpoint. Banner is idempotent; it clears 140 + // itself when the config becomes valid. 141 + showOnboardingBanner(chatUI.messageList, chatUI.settingsBtn); 142 + if (!settingsShownOnce) { 143 + chatUI.settingsPanel.style.display = ''; 144 + settingsShownOnce = true; 145 + } 146 + } else { 147 + removeOnboardingBanner(chatUI.messageList); 114 148 } 115 149 chatUI.input.focus(); 116 150 } ··· 147 181 opts.chatConfig = chatConfig; 148 182 saveConfig(chatConfig); 149 183 updateModelBadge(); 184 + // #676 — if the endpoint now validates, clear the onboarding banner. 185 + if (isConfigured(chatConfig)) { 186 + removeOnboardingBanner(chatUI.messageList); 187 + } 150 188 } 151 189 152 190 chatUI.endpointInput.addEventListener('change', persistSettings);
+3 -2
src/sheets/ai-chat-panel.ts
··· 10 10 import { 11 11 isConfigured, buildSystemMessage, streamChat, 12 12 appendMessage, appendStreamingBubble, renderMarkdown, 13 - appendActionCard, 13 + appendActionCard, appendParseErrorNote, 14 14 } from '../lib/ai-chat.js'; 15 15 import { splitResponse, isSheetAction } from '../lib/ai-actions.js'; 16 16 import { executeSheetAction } from './ai-sheet-actions.js'; ··· 143 143 deps.chatState.messages.push({ role: 'assistant', content: text, ts: Date.now() }); 144 144 145 145 if (actionsEnabled) { 146 - const { displayText, actions } = splitResponse(text); 146 + const { displayText, actions, errors } = splitResponse(text); 147 + appendParseErrorNote(deps.chatUI.messageList, errors); 147 148 if (actions.length > 0) { 148 149 bubble.update(renderMarkdown(displayText)); 149 150 for (const action of actions) {
+3 -2
src/slides/ai-chat-panel.ts
··· 9 9 import { 10 10 createChatSidebar, createChatState, loadConfig, isConfigured, 11 11 buildSystemMessage, streamChat, appendMessage, appendStreamingBubble, 12 - renderMarkdown, appendActionCard, escapeHtml, initChatWiring, 12 + renderMarkdown, appendActionCard, appendParseErrorNote, escapeHtml, initChatWiring, 13 13 type ChatMessage, 14 14 } from '../lib/ai-chat.js'; 15 15 import { splitResponse, isSlideAction } from '../lib/ai-actions.js'; ··· 111 111 chatState.messages.push({ role: 'assistant', content: doneText, ts: Date.now() }); 112 112 113 113 if (actionsEnabled) { 114 - const { displayText, actions: parsedActions } = splitResponse(doneText); 114 + const { displayText, actions: parsedActions, errors } = splitResponse(doneText); 115 + appendParseErrorNote(chatUI.messageList, errors); 115 116 if (parsedActions.length > 0) { 116 117 bubble.update(renderMarkdown(displayText)); 117 118 for (const action of parsedActions) {
+306
tests/ai-chat-polish.test.ts
··· 1 + // @vitest-environment jsdom 2 + /** 3 + * v0.50.0 AI chat polish — #675, #676, #677, #679, #682. 4 + * 5 + * Covers endpoint URL validation, first-run onboarding banner, bulk-apply 6 + * confirmation, parse-error surfacing, and status-code-aware error messages. 7 + */ 8 + 9 + import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; 10 + import { 11 + validateEndpoint, 12 + isConfigured, 13 + type ChatConfig, 14 + } from '../src/lib/ai-chat/types.js'; 15 + import { friendlyStatusError } from '../src/lib/ai-chat/streaming.js'; 16 + import { 17 + createChatSidebar, 18 + appendParseErrorNote, 19 + showOnboardingBanner, 20 + hasOnboardingBanner, 21 + } from '../src/lib/ai-chat/sidebar-dom.js'; 22 + 23 + // localStorage shim 24 + const store: Record<string, string> = {}; 25 + const mockLS = { 26 + getItem: (k: string) => store[k] ?? null, 27 + setItem: (k: string, v: string) => { store[k] = v; }, 28 + removeItem: (k: string) => { delete store[k]; }, 29 + clear: () => { for (const k of Object.keys(store)) delete store[k]; }, 30 + get length() { return Object.keys(store).length; }, 31 + key: (i: number) => Object.keys(store)[i] ?? null, 32 + }; 33 + Object.defineProperty(globalThis, 'localStorage', { value: mockLS, writable: true }); 34 + 35 + describe('#675 validateEndpoint', () => { 36 + it('accepts absolute http URLs', () => { 37 + expect(validateEndpoint('http://ai.example.com')).toBe(true); 38 + expect(validateEndpoint('https://api.openai.com/v1')).toBe(true); 39 + }); 40 + 41 + it('accepts same-origin relative paths starting with /', () => { 42 + expect(validateEndpoint('/api/ai')).toBe(true); 43 + expect(validateEndpoint('/v1/chat')).toBe(true); 44 + }); 45 + 46 + it('rejects empty or whitespace-only strings', () => { 47 + expect(validateEndpoint('')).toBe(false); 48 + expect(validateEndpoint(' ')).toBe(false); 49 + }); 50 + 51 + it('rejects malformed URLs', () => { 52 + expect(validateEndpoint('not a url')).toBe(false); 53 + expect(validateEndpoint('http://')).toBe(false); 54 + expect(validateEndpoint('ftp://example.com')).toBe(false); 55 + }); 56 + 57 + it('rejects protocol-relative URLs without host', () => { 58 + expect(validateEndpoint('//')).toBe(false); 59 + }); 60 + 61 + it('rejects strings that are neither absolute URL nor path', () => { 62 + expect(validateEndpoint('example.com')).toBe(false); 63 + expect(validateEndpoint('api/ai')).toBe(false); 64 + }); 65 + }); 66 + 67 + describe('#675 isConfigured (tightened)', () => { 68 + const base = (overrides: Partial<ChatConfig> = {}): ChatConfig => ({ 69 + endpoint: '/api/ai', 70 + model: 'anthropic/claude-sonnet-4.6', 71 + maxTokens: 4096, 72 + autoApply: false, 73 + position: 'side', 74 + ...overrides, 75 + }); 76 + 77 + it('returns true for a valid endpoint', () => { 78 + expect(isConfigured(base({ endpoint: 'https://ai.example.com' }))).toBe(true); 79 + }); 80 + 81 + it('returns false for an invalid endpoint even when non-empty', () => { 82 + expect(isConfigured(base({ endpoint: 'not a url' }))).toBe(false); 83 + }); 84 + 85 + it('returns false for an empty endpoint', () => { 86 + expect(isConfigured(base({ endpoint: '' }))).toBe(false); 87 + }); 88 + }); 89 + 90 + describe('#682 friendlyStatusError', () => { 91 + it('returns a specific message for 401', () => { 92 + const msg = friendlyStatusError(401, 'Unauthorized'); 93 + expect(msg).toMatch(/check your api key|authentication/i); 94 + }); 95 + 96 + it('returns a specific message for 403', () => { 97 + const msg = friendlyStatusError(403, 'Forbidden'); 98 + expect(msg).toMatch(/not allowed|forbidden|permission/i); 99 + }); 100 + 101 + it('returns a rate-limit message for 429', () => { 102 + const msg = friendlyStatusError(429, 'Too Many Requests'); 103 + expect(msg).toMatch(/rate limit|too many|try again/i); 104 + }); 105 + 106 + it('falls back to generic message for other codes', () => { 107 + const msg = friendlyStatusError(500, 'boom'); 108 + expect(msg).toMatch(/500/); 109 + expect(msg).toMatch(/boom/); 110 + }); 111 + 112 + it('preserves detail when available', () => { 113 + const msg = friendlyStatusError(401, 'Token expired'); 114 + expect(msg).toContain('Token expired'); 115 + }); 116 + }); 117 + 118 + describe('#676 onboarding banner', () => { 119 + let ui: ReturnType<typeof createChatSidebar>; 120 + beforeEach(() => { 121 + document.body.innerHTML = ''; 122 + ui = createChatSidebar(); 123 + document.body.append(ui.container); 124 + }); 125 + 126 + it('showOnboardingBanner inserts an onboarding card into the message list', () => { 127 + showOnboardingBanner(ui.messageList, ui.settingsBtn); 128 + expect(hasOnboardingBanner(ui.messageList)).toBe(true); 129 + const banner = ui.messageList.querySelector('.ai-chat-onboarding'); 130 + expect(banner?.textContent).toMatch(/configure|endpoint/i); 131 + }); 132 + 133 + it('is idempotent — calling twice does not create two banners', () => { 134 + showOnboardingBanner(ui.messageList, ui.settingsBtn); 135 + showOnboardingBanner(ui.messageList, ui.settingsBtn); 136 + expect(ui.messageList.querySelectorAll('.ai-chat-onboarding').length).toBe(1); 137 + }); 138 + 139 + it('banner button clicks the settings button to open settings', () => { 140 + let clicked = 0; 141 + ui.settingsBtn.addEventListener('click', () => { clicked++; }); 142 + showOnboardingBanner(ui.messageList, ui.settingsBtn); 143 + const btn = ui.messageList.querySelector<HTMLButtonElement>('.ai-chat-onboarding button'); 144 + btn?.click(); 145 + expect(clicked).toBe(1); 146 + }); 147 + }); 148 + 149 + describe('#679 appendParseErrorNote', () => { 150 + it('renders a warning row with each error message', () => { 151 + const list = document.createElement('div'); 152 + appendParseErrorNote(list, ['Unknown type: xyz', 'Missing field foo']); 153 + const row = list.querySelector('.ai-chat-parse-errors'); 154 + expect(row).not.toBeNull(); 155 + expect(row!.textContent).toContain('Unknown type: xyz'); 156 + expect(row!.textContent).toContain('Missing field foo'); 157 + }); 158 + 159 + it('does not render when errors array is empty', () => { 160 + const list = document.createElement('div'); 161 + appendParseErrorNote(list, []); 162 + expect(list.querySelector('.ai-chat-parse-errors')).toBeNull(); 163 + }); 164 + }); 165 + 166 + describe('#677 auto-apply bulk confirmation', () => { 167 + // This integration test needs the full wiring. We verify the contract: 168 + // when there are >=2 pending action cards and auto-apply is toggled on, 169 + // a confirmation function is invoked before clicking apply on any of them. 170 + // The wiring module accepts a `confirm` hook for testability. 171 + beforeEach(() => { 172 + document.body.innerHTML = ''; 173 + mockLS.clear(); 174 + }); 175 + afterEach(() => { 176 + vi.restoreAllMocks(); 177 + }); 178 + 179 + it('confirm hook is invoked when pending cards exist', async () => { 180 + const { initChatWiring } = await import('../src/lib/ai-chat/wiring.js'); 181 + const ui = createChatSidebar(); 182 + document.body.append(ui.container); 183 + const state = { 184 + messages: [], 185 + loading: false, 186 + error: null as string | null, 187 + abortController: null as AbortController | null, 188 + }; 189 + const cfg: ChatConfig = { 190 + endpoint: 'https://ai.example.com', 191 + model: 'anthropic/claude-sonnet-4.6', 192 + maxTokens: 4096, 193 + autoApply: false, 194 + position: 'side', 195 + }; 196 + 197 + // Two fake pending action cards in the message list 198 + for (let i = 0; i < 2; i++) { 199 + const card = document.createElement('div'); 200 + card.className = 'ai-action-card'; 201 + card.innerHTML = '<button class="ai-action-btn ai-action-btn--apply">Apply</button>'; 202 + ui.messageList.append(card); 203 + } 204 + 205 + const confirmSpy = vi.fn().mockReturnValue(false); 206 + const toggleBtn = document.createElement('button'); 207 + initChatWiring({ 208 + chatUI: ui, 209 + chatState: state, 210 + chatConfig: cfg, 211 + toggleBtn, 212 + editorType: 'doc', 213 + onSend: () => {}, 214 + confirmBulkApply: confirmSpy, 215 + }); 216 + 217 + // Toggle auto-apply on → should prompt confirmation 218 + ui.autoApplyToggle.checked = true; 219 + ui.autoApplyToggle.dispatchEvent(new Event('change')); 220 + 221 + expect(confirmSpy).toHaveBeenCalledTimes(1); 222 + expect(confirmSpy).toHaveBeenCalledWith(2); // 2 pending cards 223 + }); 224 + 225 + it('does not prompt when there are no pending cards', async () => { 226 + const { initChatWiring } = await import('../src/lib/ai-chat/wiring.js'); 227 + const ui = createChatSidebar(); 228 + document.body.append(ui.container); 229 + const state = { 230 + messages: [], 231 + loading: false, 232 + error: null as string | null, 233 + abortController: null as AbortController | null, 234 + }; 235 + const cfg: ChatConfig = { 236 + endpoint: 'https://ai.example.com', 237 + model: 'anthropic/claude-sonnet-4.6', 238 + maxTokens: 4096, 239 + autoApply: false, 240 + position: 'side', 241 + }; 242 + 243 + const confirmSpy = vi.fn().mockReturnValue(true); 244 + const toggleBtn = document.createElement('button'); 245 + initChatWiring({ 246 + chatUI: ui, 247 + chatState: state, 248 + chatConfig: cfg, 249 + toggleBtn, 250 + editorType: 'doc', 251 + onSend: () => {}, 252 + confirmBulkApply: confirmSpy, 253 + }); 254 + 255 + ui.autoApplyToggle.checked = true; 256 + ui.autoApplyToggle.dispatchEvent(new Event('change')); 257 + 258 + expect(confirmSpy).not.toHaveBeenCalled(); 259 + }); 260 + 261 + it('skips bulk-apply when confirmation is denied', async () => { 262 + const { initChatWiring } = await import('../src/lib/ai-chat/wiring.js'); 263 + const ui = createChatSidebar(); 264 + document.body.append(ui.container); 265 + const state = { 266 + messages: [], 267 + loading: false, 268 + error: null as string | null, 269 + abortController: null as AbortController | null, 270 + }; 271 + const cfg: ChatConfig = { 272 + endpoint: 'https://ai.example.com', 273 + model: 'anthropic/claude-sonnet-4.6', 274 + maxTokens: 4096, 275 + autoApply: false, 276 + position: 'side', 277 + }; 278 + 279 + let clickedApply = 0; 280 + const card = document.createElement('div'); 281 + card.className = 'ai-action-card'; 282 + const applyBtn = document.createElement('button'); 283 + applyBtn.className = 'ai-action-btn ai-action-btn--apply'; 284 + applyBtn.addEventListener('click', () => { clickedApply++; }); 285 + card.append(applyBtn); 286 + ui.messageList.append(card); 287 + 288 + const confirmSpy = vi.fn().mockReturnValue(false); 289 + const toggleBtn = document.createElement('button'); 290 + initChatWiring({ 291 + chatUI: ui, 292 + chatState: state, 293 + chatConfig: cfg, 294 + toggleBtn, 295 + editorType: 'doc', 296 + onSend: () => {}, 297 + confirmBulkApply: confirmSpy, 298 + }); 299 + 300 + ui.autoApplyToggle.checked = true; 301 + ui.autoApplyToggle.dispatchEvent(new Event('change')); 302 + 303 + expect(confirmSpy).toHaveBeenCalledTimes(1); 304 + expect(clickedApply).toBe(0); // no retroactive apply 305 + }); 306 + });
+2 -1
tests/ai-chat.test.ts
··· 1030 1030 json: vi.fn().mockResolvedValue({ error: { message: 'Invalid API key' } }), 1031 1031 } as unknown as Response); 1032 1032 1033 + // #682 — 401 now uses friendly status-code messaging. 1033 1034 await expect(sendChat(baseConfig, baseMessages, systemPrompt)) 1034 - .rejects.toThrow('AI request failed (401): Invalid API key'); 1035 + .rejects.toThrow(/Authentication failed \(401\).*Invalid API key/); 1035 1036 }); 1036 1037 1037 1038 it('throws with statusText when error body is unparseable', async () => {